diff --git a/dashboard/backend/src/routes/index.ts b/dashboard/backend/src/routes/index.ts index 0127e2f..730ef9f 100644 --- a/dashboard/backend/src/routes/index.ts +++ b/dashboard/backend/src/routes/index.ts @@ -8,6 +8,18 @@ import otaTargetDeviceRouter from './otaTargetDevice.js'; import shareCodeLogsRouter from './shareCodeLogs.js'; import usersRouter from './users.js'; import dashboardRouter from './dashboard.js'; +import { + wwwGlobalRouter, + wwwNavRouter, + wwwSectionsRouter, + wwwPageRouter, + wwwProductsRouter, + wwwNewsRouter, + wwwMediaRouter, + wwwI18nRouter, + wwwContactRouter, + wwwPageSeoRouter, +} from './www/index.js'; const routes: Router[] = [ authRouter, @@ -19,6 +31,17 @@ const routes: Router[] = [ shareCodeLogsRouter, usersRouter, dashboardRouter, + // 官网 CMS + wwwGlobalRouter, + wwwNavRouter, + wwwSectionsRouter, + wwwPageRouter, + wwwProductsRouter, + wwwNewsRouter, + wwwMediaRouter, + wwwI18nRouter, + wwwContactRouter, + wwwPageSeoRouter, ]; export default routes; diff --git a/dashboard/backend/src/routes/www/contact.ts b/dashboard/backend/src/routes/www/contact.ts new file mode 100644 index 0000000..db1ef02 --- /dev/null +++ b/dashboard/backend/src/routes/www/contact.ts @@ -0,0 +1,254 @@ +import { Router, type Request, type Response } from 'express'; +import { eq, and, count, desc, gte, lte } from 'drizzle-orm'; +import { db } from '../../config/database.js'; +import { wwwFormSettings, wwwFormFields, wwwFormSubmissions } from '../../schemas/index.js'; +import logger from '../../config/logger.js'; +import { ApiResponse } from '../../utils/response.js'; +import { authMiddleware } from '../../middleware/auth.js'; + +const router = Router(); +router.use(authMiddleware); + +// ===== 8.1 表单全局配置 ===== + +// GET /api/www/contact/settings +router.get('/api/www/contact/settings', async (_req: Request, res: Response) => { + try { + const [row] = await db.select().from(wwwFormSettings).limit(1); + if (!row) { + res.json(ApiResponse.noData('尚未配置')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/contact/settings] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取表单配置失败')); + } +}); + +// PUT /api/www/contact/settings +router.put('/api/www/contact/settings', async (req: Request, res: Response) => { + try { + const b = req.body; + const [existing] = await db.select().from(wwwFormSettings).limit(1); + + if (existing) { + await db.update(wwwFormSettings).set({ + recipientEmail: b.recipient_email ?? existing.recipientEmail, + successMessageZh: b.success_message_zh ?? existing.successMessageZh, + successMessageEn: b.success_message_en ?? existing.successMessageEn, + errorMessageZh: b.error_message_zh ?? existing.errorMessageZh, + errorMessageEn: b.error_message_en ?? existing.errorMessageEn, + cooldownSeconds: b.cooldown_seconds ?? existing.cooldownSeconds, + }).where(eq(wwwFormSettings.id, existing.id)); + } else { + await db.insert(wwwFormSettings).values({ + recipientEmail: b.recipient_email || '', + successMessageZh: b.success_message_zh || '感谢您的留言,我们会尽快回复!', + successMessageEn: b.success_message_en || 'Thank you for your message. We will get back to you soon!', + errorMessageZh: b.error_message_zh || '提交失败,请稍后重试。', + errorMessageEn: b.error_message_en || 'Submission failed. Please try again later.', + cooldownSeconds: b.cooldown_seconds || 60, + }); + } + + const [updated] = await db.select().from(wwwFormSettings).limit(1); + res.json(ApiResponse.success(updated, '表单配置已更新')); + } catch (e: unknown) { + logger.error(`[www/contact/settings] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新表单配置失败')); + } +}); + +// ===== 8.2 表单字段 ===== + +// GET /api/www/contact/fields +router.get('/api/www/contact/fields', async (_req: Request, res: Response) => { + try { + const rows = await db.select().from(wwwFormFields).orderBy(wwwFormFields.sortOrder); + res.json(ApiResponse.success({ items: rows, total: rows.length })); + } catch (e: unknown) { + logger.error(`[www/contact/fields] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取字段列表失败')); + } +}); + +// POST /api/www/contact/fields +router.post('/api/www/contact/fields', async (req: Request, res: Response) => { + try { + const b = req.body; + if (!b.name_zh || !b.name_en || !b.field_type) { + res.json(ApiResponse.error('name_zh、name_en、field_type 为必填项')); + return; + } + + const result = await db.insert(wwwFormFields).values({ + nameZh: b.name_zh, + nameEn: b.name_en, + fieldType: b.field_type, + isRequired: b.is_required ? 1 : 0, + placeholderZh: b.placeholder_zh || '', + placeholderEn: b.placeholder_en || '', + optionsJson: b.options_json || null, + sortOrder: b.sort_order || 0, + }); + + const newId = result[0].insertId; + const [created] = await db.select().from(wwwFormFields).where(eq(wwwFormFields.id, newId)); + res.json(ApiResponse.success(created, '字段已创建')); + } catch (e: unknown) { + logger.error(`[www/contact/fields] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('创建字段失败')); + } +}); + +// PUT /api/www/contact/fields/:id +router.put('/api/www/contact/fields/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwFormFields).where(eq(wwwFormFields.id, id)); + if (!existing) { + res.json(ApiResponse.noData('字段不存在')); + return; + } + + const b = req.body; + await db.update(wwwFormFields).set({ + nameZh: b.name_zh ?? existing.nameZh, + nameEn: b.name_en ?? existing.nameEn, + fieldType: b.field_type ?? existing.fieldType, + isRequired: b.is_required !== undefined ? (b.is_required ? 1 : 0) : existing.isRequired, + placeholderZh: b.placeholder_zh ?? existing.placeholderZh, + placeholderEn: b.placeholder_en ?? existing.placeholderEn, + optionsJson: b.options_json !== undefined ? b.options_json : existing.optionsJson, + sortOrder: b.sort_order ?? existing.sortOrder, + }).where(eq(wwwFormFields.id, id)); + + const [updated] = await db.select().from(wwwFormFields).where(eq(wwwFormFields.id, id)); + res.json(ApiResponse.success(updated, '字段已更新')); + } catch (e: unknown) { + logger.error(`[www/contact/fields] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新字段失败')); + } +}); + +// DELETE /api/www/contact/fields/:id +router.delete('/api/www/contact/fields/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwFormFields).where(eq(wwwFormFields.id, id)); + if (!existing) { + res.json(ApiResponse.noData('字段不存在')); + return; + } + await db.delete(wwwFormFields).where(eq(wwwFormFields.id, id)); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/contact/fields] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除字段失败')); + } +}); + +// PATCH /api/www/contact/fields/sort +router.patch('/api/www/contact/fields/sort', async (req: Request, res: Response) => { + try { + const items: { id: number; sort_order: number }[] = req.body; + if (!Array.isArray(items)) { + res.json(ApiResponse.error('请求体应为数组')); + return; + } + for (const item of items) { + await db.update(wwwFormFields).set({ sortOrder: item.sort_order }).where(eq(wwwFormFields.id, item.id)); + } + res.json(ApiResponse.success(null, '排序已更新')); + } catch (e: unknown) { + logger.error(`[www/contact/fields/sort] PATCH error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新排序失败')); + } +}); + +// ===== 8.3 提交记录 ===== + +// GET /api/www/contact/submissions?is_read=0&start_date=&end_date= +router.get('/api/www/contact/submissions', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '20', 10), 1000); + const isRead = req.query.is_read !== undefined ? Number(req.query.is_read) : undefined; + const startDate = req.query.start_date as string | undefined; + const endDate = req.query.end_date as string | undefined; + + const conditions = []; + if (isRead !== undefined) conditions.push(eq(wwwFormSubmissions.isRead, isRead)); + if (startDate) conditions.push(gte(wwwFormSubmissions.createdAt, new Date(startDate))); + if (endDate) conditions.push(lte(wwwFormSubmissions.createdAt, new Date(`${endDate} 23:59:59`))); + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db.select({ value: count() }).from(wwwFormSubmissions).where(whereClause); + const total = totalResult?.value ?? 0; + + const rows = await db.select().from(wwwFormSubmissions) + .where(whereClause) + .orderBy(desc(wwwFormSubmissions.createdAt)) + .offset(skip) + .limit(limit); + + res.json(ApiResponse.success({ items: rows, total, skip, limit })); + } catch (e: unknown) { + logger.error(`[www/contact/submissions] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取提交记录失败')); + } +}); + +// GET /api/www/contact/submissions/:id +router.get('/api/www/contact/submissions/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [row] = await db.select().from(wwwFormSubmissions).where(eq(wwwFormSubmissions.id, id)); + if (!row) { + res.json(ApiResponse.noData('记录不存在')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/contact/submissions/:id] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取记录详情失败')); + } +}); + +// PATCH /api/www/contact/submissions/:id/read +router.patch('/api/www/contact/submissions/:id/read', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwFormSubmissions).where(eq(wwwFormSubmissions.id, id)); + if (!existing) { + res.json(ApiResponse.noData('记录不存在')); + return; + } + await db.update(wwwFormSubmissions).set({ isRead: 1 }).where(eq(wwwFormSubmissions.id, id)); + res.json(ApiResponse.success(null, '已标记为已读')); + } catch (e: unknown) { + logger.error(`[www/contact/submissions/read] PATCH error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('标记已读失败')); + } +}); + +// DELETE /api/www/contact/submissions/:id +router.delete('/api/www/contact/submissions/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwFormSubmissions).where(eq(wwwFormSubmissions.id, id)); + if (!existing) { + res.json(ApiResponse.noData('记录不存在')); + return; + } + await db.delete(wwwFormSubmissions).where(eq(wwwFormSubmissions.id, id)); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/contact/submissions] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除记录失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/www/global.ts b/dashboard/backend/src/routes/www/global.ts new file mode 100644 index 0000000..6102b7a --- /dev/null +++ b/dashboard/backend/src/routes/www/global.ts @@ -0,0 +1,200 @@ +import { Router, type Request, type Response } from 'express'; +import { eq } from 'drizzle-orm'; +import { db } from '../../config/database.js'; +import { wwwSiteSettings, wwwFooterSettings, wwwSocialLinks, wwwSeoDefaults } from '../../schemas/index.js'; +import logger from '../../config/logger.js'; +import { ApiResponse } from '../../utils/response.js'; +import { authMiddleware } from '../../middleware/auth.js'; + +const router = Router(); +router.use(authMiddleware); + +// ===== 1.1 站点信息 ===== + +// GET /api/www/global/site +router.get('/api/www/global/site', async (_req: Request, res: Response) => { + try { + const [row] = await db.select().from(wwwSiteSettings).limit(1); + if (!row) { + res.json(ApiResponse.noData('尚未配置站点信息')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/global/site] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取站点信息失败')); + } +}); + +// PUT /api/www/global/site +router.put('/api/www/global/site', async (req: Request, res: Response) => { + try { + const { site_title, favicon_url, logo_dark_url, logo_light_url } = req.body; + const [existing] = await db.select().from(wwwSiteSettings).limit(1); + + if (existing) { + await db.update(wwwSiteSettings).set({ + siteTitle: site_title ?? existing.siteTitle, + faviconUrl: favicon_url ?? existing.faviconUrl, + logoDarkUrl: logo_dark_url ?? existing.logoDarkUrl, + logoLightUrl: logo_light_url ?? existing.logoLightUrl, + }).where(eq(wwwSiteSettings.id, existing.id)); + } else { + await db.insert(wwwSiteSettings).values({ + siteTitle: site_title || '', + faviconUrl: favicon_url || '', + logoDarkUrl: logo_dark_url || '', + logoLightUrl: logo_light_url || '', + }); + } + + const [updated] = await db.select().from(wwwSiteSettings).limit(1); + res.json(ApiResponse.success(updated, '站点信息已更新')); + } catch (e: unknown) { + logger.error(`[www/global/site] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新站点信息失败')); + } +}); + +// ===== 1.2 页脚配置 ===== + +// GET /api/www/global/footer +router.get('/api/www/global/footer', async (_req: Request, res: Response) => { + try { + const [row] = await db.select().from(wwwFooterSettings).limit(1); + if (!row) { + res.json(ApiResponse.noData('尚未配置页脚')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/global/footer] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取页脚配置失败')); + } +}); + +// PUT /api/www/global/footer +router.put('/api/www/global/footer', async (req: Request, res: Response) => { + try { + const { company_name, company_address, contact_phone, contact_email, copyright_text, icp_number, police_number } = req.body; + const [existing] = await db.select().from(wwwFooterSettings).limit(1); + + if (existing) { + await db.update(wwwFooterSettings).set({ + companyName: company_name ?? existing.companyName, + companyAddress: company_address ?? existing.companyAddress, + contactPhone: contact_phone ?? existing.contactPhone, + contactEmail: contact_email ?? existing.contactEmail, + copyrightText: copyright_text ?? existing.copyrightText, + icpNumber: icp_number ?? existing.icpNumber, + policeNumber: police_number ?? existing.policeNumber, + }).where(eq(wwwFooterSettings.id, existing.id)); + } else { + await db.insert(wwwFooterSettings).values({ + companyName: company_name || '', + companyAddress: company_address || '', + contactPhone: contact_phone || '', + contactEmail: contact_email || '', + copyrightText: copyright_text || '', + icpNumber: icp_number || '', + policeNumber: police_number || '', + }); + } + + const [updated] = await db.select().from(wwwFooterSettings).limit(1); + res.json(ApiResponse.success(updated, '页脚配置已更新')); + } catch (e: unknown) { + logger.error(`[www/global/footer] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新页脚配置失败')); + } +}); + +// ===== 1.3 社交链接 ===== + +// GET /api/www/global/social +router.get('/api/www/global/social', async (_req: Request, res: Response) => { + try { + const rows = await db.select().from(wwwSocialLinks).orderBy(wwwSocialLinks.sortOrder); + res.json(ApiResponse.success(rows)); + } catch (e: unknown) { + logger.error(`[www/global/social] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取社交链接失败')); + } +}); + +// PUT /api/www/global/social(全量覆盖) +router.put('/api/www/global/social', async (req: Request, res: Response) => { + try { + const items: { platform: string; url?: string; qrcode_url?: string }[] = req.body; + if (!Array.isArray(items)) { + res.json(ApiResponse.error('请求体应为数组')); + return; + } + + // 清空后重建 + await db.delete(wwwSocialLinks); + if (items.length > 0) { + await db.insert(wwwSocialLinks).values( + items.map((item, idx) => ({ + platform: item.platform, + url: item.url || '', + qrcodeUrl: item.qrcode_url || '', + sortOrder: idx + 1, + })) + ); + } + + const rows = await db.select().from(wwwSocialLinks).orderBy(wwwSocialLinks.sortOrder); + res.json(ApiResponse.success(rows, '社交链接已更新')); + } catch (e: unknown) { + logger.error(`[www/global/social] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新社交链接失败')); + } +}); + +// ===== 1.4 SEO 默认值 ===== + +// GET /api/www/global/seo +router.get('/api/www/global/seo', async (_req: Request, res: Response) => { + try { + const [row] = await db.select().from(wwwSeoDefaults).limit(1); + if (!row) { + res.json(ApiResponse.noData('尚未配置 SEO 默认值')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/global/seo] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取 SEO 默认值失败')); + } +}); + +// PUT /api/www/global/seo +router.put('/api/www/global/seo', async (req: Request, res: Response) => { + try { + const { meta_description, meta_keywords, og_image_url } = req.body; + const [existing] = await db.select().from(wwwSeoDefaults).limit(1); + + if (existing) { + await db.update(wwwSeoDefaults).set({ + metaDescription: meta_description ?? existing.metaDescription, + metaKeywords: meta_keywords ?? existing.metaKeywords, + ogImageUrl: og_image_url ?? existing.ogImageUrl, + }).where(eq(wwwSeoDefaults.id, existing.id)); + } else { + await db.insert(wwwSeoDefaults).values({ + metaDescription: meta_description || '', + metaKeywords: meta_keywords || '', + ogImageUrl: og_image_url || '', + }); + } + + const [updated] = await db.select().from(wwwSeoDefaults).limit(1); + res.json(ApiResponse.success(updated, 'SEO 默认值已更新')); + } catch (e: unknown) { + logger.error(`[www/global/seo] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新 SEO 默认值失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/www/i18n.ts b/dashboard/backend/src/routes/www/i18n.ts new file mode 100644 index 0000000..41e74a6 --- /dev/null +++ b/dashboard/backend/src/routes/www/i18n.ts @@ -0,0 +1,84 @@ +import { Router, type Request, type Response } from 'express'; +import { eq, like, or } from 'drizzle-orm'; +import { db } from '../../config/database.js'; +import { wwwI18nEntries } from '../../schemas/index.js'; +import logger from '../../config/logger.js'; +import { ApiResponse } from '../../utils/response.js'; +import { authMiddleware } from '../../middleware/auth.js'; + +const router = Router(); +router.use(authMiddleware); + +// GET /api/www/i18n?keyword=nav +router.get('/api/www/i18n', async (req: Request, res: Response) => { + try { + const keyword = req.query.keyword as string | undefined; + + let rows; + if (keyword) { + rows = await db.select().from(wwwI18nEntries).where( + or( + like(wwwI18nEntries.dictKey, `%${keyword}%`), + like(wwwI18nEntries.valueZh, `%${keyword}%`), + like(wwwI18nEntries.valueEn, `%${keyword}%`) + ) + ); + } else { + rows = await db.select().from(wwwI18nEntries); + } + + res.json(ApiResponse.success({ items: rows, total: rows.length })); + } catch (e: unknown) { + logger.error(`[www/i18n] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取词条列表失败')); + } +}); + +// PUT /api/www/i18n/:id +router.put('/api/www/i18n/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwI18nEntries).where(eq(wwwI18nEntries.id, id)); + if (!existing) { + res.json(ApiResponse.noData('词条不存在')); + return; + } + + const { value_zh, value_en } = req.body; + await db.update(wwwI18nEntries).set({ + valueZh: value_zh ?? existing.valueZh, + valueEn: value_en ?? existing.valueEn, + }).where(eq(wwwI18nEntries.id, id)); + + const [updated] = await db.select().from(wwwI18nEntries).where(eq(wwwI18nEntries.id, id)); + res.json(ApiResponse.success(updated, '词条已更新')); + } catch (e: unknown) { + logger.error(`[www/i18n/:id] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新词条失败')); + } +}); + +// PUT /api/www/i18n/batch +router.put('/api/www/i18n/batch', async (req: Request, res: Response) => { + try { + const items: { id: number; value_zh?: string; value_en?: string }[] = req.body; + if (!Array.isArray(items)) { + res.json(ApiResponse.error('请求体应为数组')); + return; + } + + for (const item of items) { + await db.update(wwwI18nEntries).set({ + ...(item.value_zh !== undefined && { valueZh: item.value_zh }), + ...(item.value_en !== undefined && { valueEn: item.value_en }), + }).where(eq(wwwI18nEntries.id, item.id)); + } + + res.json(ApiResponse.success(null, `已更新 ${items.length} 条词条`)); + } catch (e: unknown) { + logger.error(`[www/i18n/batch] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('批量更新失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/www/index.ts b/dashboard/backend/src/routes/www/index.ts new file mode 100644 index 0000000..1b74ab4 --- /dev/null +++ b/dashboard/backend/src/routes/www/index.ts @@ -0,0 +1,13 @@ +/** + * 官网 CMS 路由统一导出 + */ +export { default as wwwGlobalRouter } from './global.js'; +export { default as wwwNavRouter } from './nav.js'; +export { default as wwwSectionsRouter } from './sections.js'; +export { default as wwwPageRouter } from './page.js'; +export { default as wwwProductsRouter } from './products.js'; +export { default as wwwNewsRouter } from './news.js'; +export { default as wwwMediaRouter } from './media.js'; +export { default as wwwI18nRouter } from './i18n.js'; +export { default as wwwContactRouter } from './contact.js'; +export { default as wwwPageSeoRouter } from './pageSeo.js'; diff --git a/dashboard/backend/src/routes/www/media.ts b/dashboard/backend/src/routes/www/media.ts new file mode 100644 index 0000000..2b7252c --- /dev/null +++ b/dashboard/backend/src/routes/www/media.ts @@ -0,0 +1,345 @@ +import { Router, type Request, type Response } from 'express'; +import { eq, like, and, count, isNull, inArray } from 'drizzle-orm'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { db } from '../../config/database.js'; +import { wwwMedia, wwwMediaTags, wwwMediaTagMap } from '../../schemas/index.js'; +import logger from '../../config/logger.js'; +import { ApiResponse } from '../../utils/response.js'; +import { authMiddleware } from '../../middleware/auth.js'; + +const router = Router(); +router.use(authMiddleware); + +// 上传目录 +const UPLOAD_DIR = path.resolve(process.cwd(), 'uploads'); +if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + +const storage = multer.diskStorage({ + destination: (_req, _file, cb) => { + const now = new Date(); + const subDir = path.join(UPLOAD_DIR, `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}`); + fs.mkdirSync(subDir, { recursive: true }); + cb(null, subDir); + }, + filename: (_req, file, cb) => { + const ext = path.extname(file.originalname); + const name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`; + cb(null, name); + }, +}); + +const upload = multer({ + storage, + limits: { fileSize: 50 * 1024 * 1024 }, // 50MB +}); + +function getFileType(mimetype: string): string { + if (mimetype.startsWith('image/')) return 'image'; + if (mimetype.startsWith('video/')) return 'video'; + return 'file'; +} + +// ===== 6. 素材库 ===== + +// GET /api/www/media?category=product&tag=xxx&keyword=xxx&file_type=image +router.get('/api/www/media', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '20', 10), 1000); + const category = req.query.category as string | undefined; + const keyword = req.query.keyword as string | undefined; + const fileType = req.query.file_type as string | undefined; + const tag = req.query.tag as string | undefined; + + const conditions = [isNull(wwwMedia.deletedAt)]; + if (category) conditions.push(eq(wwwMedia.category, category)); + if (fileType) conditions.push(eq(wwwMedia.fileType, fileType)); + if (keyword) conditions.push(like(wwwMedia.filename, `%${keyword}%`)); + + // 标签筛选需要子查询 + if (tag) { + const [tagRow] = await db.select().from(wwwMediaTags).where(eq(wwwMediaTags.tagName, tag)); + if (tagRow) { + const tagMaps = await db.select().from(wwwMediaTagMap).where(eq(wwwMediaTagMap.tagId, tagRow.id)); + const mediaIds = tagMaps.map((m) => m.mediaId); + if (mediaIds.length === 0) { + res.json(ApiResponse.success({ items: [], total: 0, skip, limit })); + return; + } + conditions.push(inArray(wwwMedia.id, mediaIds)); + } else { + res.json(ApiResponse.success({ items: [], total: 0, skip, limit })); + return; + } + } + + const whereClause = and(...conditions); + + const [totalResult] = await db.select({ value: count() }).from(wwwMedia).where(whereClause); + const total = totalResult?.value ?? 0; + + const rows = await db.select().from(wwwMedia) + .where(whereClause) + .offset(skip) + .limit(limit); + + // 附带标签 + const items = await Promise.all(rows.map(async (row) => { + const tagMaps = await db.select().from(wwwMediaTagMap).where(eq(wwwMediaTagMap.mediaId, row.id)); + const tagIds = tagMaps.map((t) => t.tagId); + let tags: string[] = []; + if (tagIds.length > 0) { + const tagRows = await db.select().from(wwwMediaTags).where(inArray(wwwMediaTags.id, tagIds)); + tags = tagRows.map((t) => t.tagName); + } + return { ...row, tags }; + })); + + res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e: unknown) { + logger.error(`[www/media] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取素材列表失败')); + } +}); + +// POST /api/www/media/upload(multipart/form-data, field: file) +router.post('/api/www/media/upload', upload.single('file'), async (req: Request, res: Response) => { + try { + const file = req.file; + if (!file) { + res.json(ApiResponse.error('未选择文件')); + return; + } + + const fileType = getFileType(file.mimetype); + const category = (req.body.category as string) || 'other'; + const relativePath = `/uploads/${path.relative(UPLOAD_DIR, file.path)}`; + + const result = await db.insert(wwwMedia).values({ + filename: file.originalname, + filePath: relativePath, + fileType, + mimeType: file.mimetype, + fileSize: file.size, + category, + }); + + const newId = result[0].insertId; + + // 处理标签 + const tagsStr = req.body.tags as string | undefined; + if (tagsStr) { + const tagNames = tagsStr.split(',').map((t: string) => t.trim()).filter(Boolean); + for (const tagName of tagNames) { + let [tagRow] = await db.select().from(wwwMediaTags).where(eq(wwwMediaTags.tagName, tagName)); + if (!tagRow) { + const tagResult = await db.insert(wwwMediaTags).values({ tagName }); + const tagId = tagResult[0].insertId; + tagRow = { id: tagId, tagName }; + } + await db.insert(wwwMediaTagMap).values({ mediaId: newId, tagId: tagRow.id }); + } + } + + const [created] = await db.select().from(wwwMedia).where(eq(wwwMedia.id, newId)); + res.json(ApiResponse.success(created, '上传成功')); + } catch (e: unknown) { + logger.error(`[www/media/upload] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('上传失败')); + } +}); + +// POST /api/www/media/upload-batch(multipart/form-data, field: files) +router.post('/api/www/media/upload-batch', upload.array('files', 20), async (req: Request, res: Response) => { + try { + const files = req.files as Express.Multer.File[]; + if (!files || files.length === 0) { + res.json(ApiResponse.error('未选择文件')); + return; + } + + const category = (req.body.category as string) || 'other'; + const results = []; + + for (const file of files) { + const fileType = getFileType(file.mimetype); + const relativePath = `/uploads/${path.relative(UPLOAD_DIR, file.path)}`; + const result = await db.insert(wwwMedia).values({ + filename: file.originalname, + filePath: relativePath, + fileType, + mimeType: file.mimetype, + fileSize: file.size, + category, + }); + results.push({ id: result[0].insertId, filename: file.originalname }); + } + + res.json(ApiResponse.success(results, `成功上传 ${results.length} 个文件`)); + } catch (e: unknown) { + logger.error(`[www/media/upload-batch] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('批量上传失败')); + } +}); + +// GET /api/www/media/tags(放在 :id 前面避免路由冲突) +router.get('/api/www/media/tags', async (_req: Request, res: Response) => { + try { + const rows = await db.select().from(wwwMediaTags); + res.json(ApiResponse.success(rows)); + } catch (e: unknown) { + logger.error(`[www/media/tags] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取标签失败')); + } +}); + +// POST /api/www/media/tags +router.post('/api/www/media/tags', async (req: Request, res: Response) => { + try { + const { tag_name } = req.body; + if (!tag_name) { + res.json(ApiResponse.error('tag_name 为必填项')); + return; + } + const [existing] = await db.select().from(wwwMediaTags).where(eq(wwwMediaTags.tagName, tag_name)); + if (existing) { + res.json(ApiResponse.error('标签已存在')); + return; + } + const result = await db.insert(wwwMediaTags).values({ tagName: tag_name }); + const newId = result[0].insertId; + res.json(ApiResponse.success({ id: newId, tag_name }, '标签已创建')); + } catch (e: unknown) { + logger.error(`[www/media/tags] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('创建标签失败')); + } +}); + +// PUT /api/www/media/tags/:id +router.put('/api/www/media/tags/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const { tag_name } = req.body; + if (!tag_name) { + res.json(ApiResponse.error('tag_name 为必填项')); + return; + } + const [existing] = await db.select().from(wwwMediaTags).where(eq(wwwMediaTags.id, id)); + if (!existing) { + res.json(ApiResponse.noData('标签不存在')); + return; + } + await db.update(wwwMediaTags).set({ tagName: tag_name }).where(eq(wwwMediaTags.id, id)); + res.json(ApiResponse.success({ id, tag_name }, '标签已更新')); + } catch (e: unknown) { + logger.error(`[www/media/tags] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新标签失败')); + } +}); + +// DELETE /api/www/media/tags/:id +router.delete('/api/www/media/tags/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwMediaTags).where(eq(wwwMediaTags.id, id)); + if (!existing) { + res.json(ApiResponse.noData('标签不存在')); + return; + } + await db.delete(wwwMediaTagMap).where(eq(wwwMediaTagMap.tagId, id)); + await db.delete(wwwMediaTags).where(eq(wwwMediaTags.id, id)); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/media/tags] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除标签失败')); + } +}); + +// GET /api/www/media/:id +router.get('/api/www/media/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [row] = await db.select().from(wwwMedia).where(eq(wwwMedia.id, id)); + if (!row) { + res.json(ApiResponse.noData('素材不存在')); + return; + } + + const tagMaps = await db.select().from(wwwMediaTagMap).where(eq(wwwMediaTagMap.mediaId, id)); + const tagIds = tagMaps.map((t) => t.tagId); + let tags: string[] = []; + if (tagIds.length > 0) { + const tagRows = await db.select().from(wwwMediaTags).where(inArray(wwwMediaTags.id, tagIds)); + tags = tagRows.map((t) => t.tagName); + } + + res.json(ApiResponse.success({ ...row, tags })); + } catch (e: unknown) { + logger.error(`[www/media/:id] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取素材详情失败')); + } +}); + +// PUT /api/www/media/:id +router.put('/api/www/media/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwMedia).where(eq(wwwMedia.id, id)); + if (!existing) { + res.json(ApiResponse.noData('素材不存在')); + return; + } + + const { filename, category, tags } = req.body; + await db.update(wwwMedia).set({ + filename: filename ?? existing.filename, + category: category ?? existing.category, + }).where(eq(wwwMedia.id, id)); + + // 更新标签(全量覆盖) + if (Array.isArray(tags)) { + await db.delete(wwwMediaTagMap).where(eq(wwwMediaTagMap.mediaId, id)); + for (const tagName of tags) { + let [tagRow] = await db.select().from(wwwMediaTags).where(eq(wwwMediaTags.tagName, tagName)); + if (!tagRow) { + const tagResult = await db.insert(wwwMediaTags).values({ tagName }); + tagRow = { id: tagResult[0].insertId, tagName }; + } + await db.insert(wwwMediaTagMap).values({ mediaId: id, tagId: tagRow.id }); + } + } + + const [updated] = await db.select().from(wwwMedia).where(eq(wwwMedia.id, id)); + res.json(ApiResponse.success(updated, '素材已更新')); + } catch (e: unknown) { + logger.error(`[www/media/:id] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新素材失败')); + } +}); + +// DELETE /api/www/media/:id(软删除) +router.delete('/api/www/media/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwMedia).where(eq(wwwMedia.id, id)); + if (!existing) { + res.json(ApiResponse.noData('素材不存在')); + return; + } + + if (existing.refCount > 0) { + res.json(ApiResponse.error(`该素材被引用 ${existing.refCount} 次,无法删除`)); + return; + } + + await db.update(wwwMedia).set({ deletedAt: new Date() }).where(eq(wwwMedia.id, id)); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/media/:id] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除素材失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/www/nav.ts b/dashboard/backend/src/routes/www/nav.ts new file mode 100644 index 0000000..f0aaf0f --- /dev/null +++ b/dashboard/backend/src/routes/www/nav.ts @@ -0,0 +1,162 @@ +import { Router, type Request, type Response } from 'express'; +import { eq } from 'drizzle-orm'; +import { db } from '../../config/database.js'; +import { wwwNavItems, wwwNavAppearance } from '../../schemas/index.js'; +import logger from '../../config/logger.js'; +import { ApiResponse } from '../../utils/response.js'; +import { authMiddleware } from '../../middleware/auth.js'; + +const router = Router(); +router.use(authMiddleware); + +// ===== 2.1 导航入口 ===== + +// GET /api/www/nav/items +router.get('/api/www/nav/items', async (_req: Request, res: Response) => { + try { + const rows = await db.select().from(wwwNavItems).orderBy(wwwNavItems.sortOrder); + res.json(ApiResponse.success({ items: rows, total: rows.length })); + } catch (e: unknown) { + logger.error(`[www/nav/items] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取导航入口失败')); + } +}); + +// POST /api/www/nav/items +router.post('/api/www/nav/items', async (req: Request, res: Response) => { + try { + const { name_zh, name_en, link, is_visible, display_mode, icon_name, image_url, open_new_tab } = req.body; + if (!name_zh || !name_en || !link) { + res.json(ApiResponse.error('name_zh、name_en、link 为必填项')); + return; + } + + const result = await db.insert(wwwNavItems).values({ + nameZh: name_zh, + nameEn: name_en, + link, + isVisible: is_visible !== undefined ? (is_visible ? 1 : 0) : 1, + displayMode: display_mode || 'text', + iconName: icon_name || '', + imageUrl: image_url || '', + openNewTab: open_new_tab ? 1 : 0, + }); + + const newId = result[0].insertId; + const [created] = await db.select().from(wwwNavItems).where(eq(wwwNavItems.id, newId)); + res.json(ApiResponse.success(created, '导航入口已创建')); + } catch (e: unknown) { + logger.error(`[www/nav/items] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('创建导航入口失败')); + } +}); + +// PUT /api/www/nav/items/:id +router.put('/api/www/nav/items/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwNavItems).where(eq(wwwNavItems.id, id)); + if (!existing) { + res.json(ApiResponse.noData('导航入口不存在')); + return; + } + + const { name_zh, name_en, link, is_visible, display_mode, icon_name, image_url, open_new_tab } = req.body; + await db.update(wwwNavItems).set({ + nameZh: name_zh ?? existing.nameZh, + nameEn: name_en ?? existing.nameEn, + link: link ?? existing.link, + isVisible: is_visible !== undefined ? (is_visible ? 1 : 0) : existing.isVisible, + displayMode: display_mode ?? existing.displayMode, + iconName: icon_name ?? existing.iconName, + imageUrl: image_url ?? existing.imageUrl, + openNewTab: open_new_tab !== undefined ? (open_new_tab ? 1 : 0) : existing.openNewTab, + }).where(eq(wwwNavItems.id, id)); + + const [updated] = await db.select().from(wwwNavItems).where(eq(wwwNavItems.id, id)); + res.json(ApiResponse.success(updated, '导航入口已更新')); + } catch (e: unknown) { + logger.error(`[www/nav/items] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新导航入口失败')); + } +}); + +// DELETE /api/www/nav/items/:id +router.delete('/api/www/nav/items/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwNavItems).where(eq(wwwNavItems.id, id)); + if (!existing) { + res.json(ApiResponse.noData('导航入口不存在')); + return; + } + await db.delete(wwwNavItems).where(eq(wwwNavItems.id, id)); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/nav/items] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除导航入口失败')); + } +}); + +// PATCH /api/www/nav/items/sort +router.patch('/api/www/nav/items/sort', async (req: Request, res: Response) => { + try { + const items: { id: number; sort_order: number }[] = req.body; + if (!Array.isArray(items)) { + res.json(ApiResponse.error('请求体应为数组')); + return; + } + for (const item of items) { + await db.update(wwwNavItems).set({ sortOrder: item.sort_order }).where(eq(wwwNavItems.id, item.id)); + } + res.json(ApiResponse.success(null, '排序已更新')); + } catch (e: unknown) { + logger.error(`[www/nav/items/sort] PATCH error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新排序失败')); + } +}); + +// ===== 2.2 导航外观 ===== + +// GET /api/www/nav/appearance +router.get('/api/www/nav/appearance', async (_req: Request, res: Response) => { + try { + const [row] = await db.select().from(wwwNavAppearance).limit(1); + if (!row) { + res.json(ApiResponse.noData('尚未配置导航外观')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/nav/appearance] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取导航外观失败')); + } +}); + +// PUT /api/www/nav/appearance +router.put('/api/www/nav/appearance', async (req: Request, res: Response) => { + try { + const { homepage_style, non_homepage_style } = req.body; + const [existing] = await db.select().from(wwwNavAppearance).limit(1); + + if (existing) { + await db.update(wwwNavAppearance).set({ + homepageStyle: homepage_style ?? existing.homepageStyle, + nonHomepageStyle: non_homepage_style ?? existing.nonHomepageStyle, + }).where(eq(wwwNavAppearance.id, existing.id)); + } else { + await db.insert(wwwNavAppearance).values({ + homepageStyle: homepage_style || 'transparent', + nonHomepageStyle: non_homepage_style || 'transparent', + }); + } + + const [updated] = await db.select().from(wwwNavAppearance).limit(1); + res.json(ApiResponse.success(updated, '导航外观已更新')); + } catch (e: unknown) { + logger.error(`[www/nav/appearance] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新导航外观失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/www/news.ts b/dashboard/backend/src/routes/www/news.ts new file mode 100644 index 0000000..4e9cef3 --- /dev/null +++ b/dashboard/backend/src/routes/www/news.ts @@ -0,0 +1,223 @@ +import { Router, type Request, type Response } from 'express'; +import { eq, like, and, count, desc } from 'drizzle-orm'; +import { db } from '../../config/database.js'; +import { wwwNewsArticles, wwwNewsRecommendations } from '../../schemas/index.js'; +import logger from '../../config/logger.js'; +import { ApiResponse } from '../../utils/response.js'; +import { authMiddleware } from '../../middleware/auth.js'; + +const router = Router(); +router.use(authMiddleware); + +// ===== 5.1 新闻文章 ===== + +// GET /api/www/news?status=published&title=xxx +router.get('/api/www/news', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '20', 10), 1000); + const status = req.query.status as string | undefined; + const title = req.query.title as string | undefined; + + const conditions = []; + if (status) conditions.push(eq(wwwNewsArticles.status, status)); + if (title) conditions.push(like(wwwNewsArticles.titleZh, `%${title}%`)); + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db.select({ value: count() }).from(wwwNewsArticles).where(whereClause); + const total = totalResult?.value ?? 0; + + const rows = await db.select({ + id: wwwNewsArticles.id, + titleZh: wwwNewsArticles.titleZh, + titleEn: wwwNewsArticles.titleEn, + slug: wwwNewsArticles.slug, + summaryZh: wwwNewsArticles.summaryZh, + coverUrl: wwwNewsArticles.coverUrl, + status: wwwNewsArticles.status, + publishedAt: wwwNewsArticles.publishedAt, + createdAt: wwwNewsArticles.createdAt, + updatedAt: wwwNewsArticles.updatedAt, + }).from(wwwNewsArticles) + .where(whereClause) + .orderBy(desc(wwwNewsArticles.createdAt)) + .offset(skip) + .limit(limit); + + res.json(ApiResponse.success({ items: rows, total, skip, limit })); + } catch (e: unknown) { + logger.error(`[www/news] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取新闻列表失败')); + } +}); + +// POST /api/www/news +router.post('/api/www/news', async (req: Request, res: Response) => { + try { + const b = req.body; + if (!b.title_zh || !b.title_en || !b.slug) { + res.json(ApiResponse.error('title_zh、title_en、slug 为必填项')); + return; + } + + // 检查 slug 唯一性 + const [existingSlug] = await db.select().from(wwwNewsArticles).where(eq(wwwNewsArticles.slug, b.slug)); + if (existingSlug) { + res.json(ApiResponse.error(`slug "${b.slug}" 已存在`)); + return; + } + + const result = await db.insert(wwwNewsArticles).values({ + titleZh: b.title_zh, + titleEn: b.title_en, + slug: b.slug, + summaryZh: b.summary_zh || '', + summaryEn: b.summary_en || '', + coverUrl: b.cover_url || '', + contentZh: b.content_zh || null, + contentEn: b.content_en || null, + status: b.status || 'draft', + publishedAt: b.published_at ? new Date(b.published_at) : null, + }); + + const newId = result[0].insertId; + const [created] = await db.select().from(wwwNewsArticles).where(eq(wwwNewsArticles.id, newId)); + res.json(ApiResponse.success(created, '文章已创建')); + } catch (e: unknown) { + logger.error(`[www/news] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('创建文章失败')); + } +}); + +// GET /api/www/news/:id +router.get('/api/www/news/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [row] = await db.select().from(wwwNewsArticles).where(eq(wwwNewsArticles.id, id)); + if (!row) { + res.json(ApiResponse.noData('文章不存在')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/news/:id] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取文章详情失败')); + } +}); + +// PUT /api/www/news/:id +router.put('/api/www/news/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwNewsArticles).where(eq(wwwNewsArticles.id, id)); + if (!existing) { + res.json(ApiResponse.noData('文章不存在')); + return; + } + + const b = req.body; + + // 如果修改了 slug,检查唯一性 + if (b.slug && b.slug !== existing.slug) { + const [slugExists] = await db.select().from(wwwNewsArticles).where(eq(wwwNewsArticles.slug, b.slug)); + if (slugExists) { + res.json(ApiResponse.error(`slug "${b.slug}" 已存在`)); + return; + } + } + + await db.update(wwwNewsArticles).set({ + titleZh: b.title_zh ?? existing.titleZh, + titleEn: b.title_en ?? existing.titleEn, + slug: b.slug ?? existing.slug, + summaryZh: b.summary_zh ?? existing.summaryZh, + summaryEn: b.summary_en ?? existing.summaryEn, + coverUrl: b.cover_url ?? existing.coverUrl, + contentZh: b.content_zh !== undefined ? b.content_zh : existing.contentZh, + contentEn: b.content_en !== undefined ? b.content_en : existing.contentEn, + status: b.status ?? existing.status, + publishedAt: b.published_at !== undefined ? (b.published_at ? new Date(b.published_at) : null) : existing.publishedAt, + }).where(eq(wwwNewsArticles.id, id)); + + const [updated] = await db.select().from(wwwNewsArticles).where(eq(wwwNewsArticles.id, id)); + res.json(ApiResponse.success(updated, '文章已更新')); + } catch (e: unknown) { + logger.error(`[www/news/:id] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新文章失败')); + } +}); + +// DELETE /api/www/news/:id +router.delete('/api/www/news/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwNewsArticles).where(eq(wwwNewsArticles.id, id)); + if (!existing) { + res.json(ApiResponse.noData('文章不存在')); + return; + } + + // 删除推荐关联 + await db.delete(wwwNewsRecommendations).where(eq(wwwNewsRecommendations.articleId, id)); + await db.delete(wwwNewsArticles).where(eq(wwwNewsArticles.id, id)); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/news/:id] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除文章失败')); + } +}); + +// ===== 5.2 文章推荐 ===== + +// GET /api/www/news/:id/recommendations +router.get('/api/www/news/:id/recommendations', async (req: Request, res: Response) => { + try { + const articleId = Number(req.params.id); + const rows = await db.select().from(wwwNewsRecommendations) + .where(eq(wwwNewsRecommendations.articleId, articleId)) + .orderBy(wwwNewsRecommendations.sortOrder); + res.json(ApiResponse.success(rows)); + } catch (e: unknown) { + logger.error(`[www/news/recommendations] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取推荐列表失败')); + } +}); + +// PUT /api/www/news/:id/recommendations(全量覆盖,最多 3 篇) +router.put('/api/www/news/:id/recommendations', async (req: Request, res: Response) => { + try { + const articleId = Number(req.params.id); + const { recommended_ids } = req.body; + + if (!Array.isArray(recommended_ids)) { + res.json(ApiResponse.error('recommended_ids 应为数组')); + return; + } + if (recommended_ids.length > 3) { + res.json(ApiResponse.error('最多推荐 3 篇文章')); + return; + } + + await db.delete(wwwNewsRecommendations).where(eq(wwwNewsRecommendations.articleId, articleId)); + if (recommended_ids.length > 0) { + await db.insert(wwwNewsRecommendations).values( + recommended_ids.map((rid: number, idx: number) => ({ + articleId, + recommendedId: rid, + sortOrder: idx + 1, + })) + ); + } + + const rows = await db.select().from(wwwNewsRecommendations) + .where(eq(wwwNewsRecommendations.articleId, articleId)) + .orderBy(wwwNewsRecommendations.sortOrder); + res.json(ApiResponse.success(rows, '推荐已更新')); + } catch (e: unknown) { + logger.error(`[www/news/recommendations] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新推荐失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/www/page.ts b/dashboard/backend/src/routes/www/page.ts new file mode 100644 index 0000000..53b240f --- /dev/null +++ b/dashboard/backend/src/routes/www/page.ts @@ -0,0 +1,240 @@ +import { Router, type Request, type Response } from 'express'; +import { eq } from 'drizzle-orm'; +import { db } from '../../config/database.js'; +import { wwwProductListSettings, wwwNewsListSettings, wwwAboutSettings, wwwSupportSettings } from '../../schemas/index.js'; +import logger from '../../config/logger.js'; +import { ApiResponse } from '../../utils/response.js'; +import { authMiddleware } from '../../middleware/auth.js'; + +const router = Router(); +router.use(authMiddleware); + +// ===== 3.3 产品列表页配置 ===== + +// GET /api/www/page/product-list +router.get('/api/www/page/product-list', async (_req: Request, res: Response) => { + try { + const [row] = await db.select().from(wwwProductListSettings).limit(1); + if (!row) { + res.json(ApiResponse.noData('尚未配置')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/page/product-list] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取配置失败')); + } +}); + +// PUT /api/www/page/product-list +router.put('/api/www/page/product-list', async (req: Request, res: Response) => { + try { + const b = req.body; + const [existing] = await db.select().from(wwwProductListSettings).limit(1); + + if (existing) { + await db.update(wwwProductListSettings).set({ + heroTitleZh: b.hero_title_zh ?? existing.heroTitleZh, + heroTitleEn: b.hero_title_en ?? existing.heroTitleEn, + heroSubtitleZh: b.hero_subtitle_zh ?? existing.heroSubtitleZh, + heroSubtitleEn: b.hero_subtitle_en ?? existing.heroSubtitleEn, + heroOverlineZh: b.hero_overline_zh ?? existing.heroOverlineZh, + heroOverlineEn: b.hero_overline_en ?? existing.heroOverlineEn, + heroBgUrl: b.hero_bg_url ?? existing.heroBgUrl, + maxColumns: b.max_columns ?? existing.maxColumns, + }).where(eq(wwwProductListSettings.id, existing.id)); + } else { + await db.insert(wwwProductListSettings).values({ + heroTitleZh: b.hero_title_zh || '产品中心', + heroTitleEn: b.hero_title_en || 'Products', + heroSubtitleZh: b.hero_subtitle_zh || '', + heroSubtitleEn: b.hero_subtitle_en || '', + heroOverlineZh: b.hero_overline_zh || 'LUXSIN PRODUCTS', + heroOverlineEn: b.hero_overline_en || 'LUXSIN PRODUCTS', + heroBgUrl: b.hero_bg_url || '', + maxColumns: b.max_columns || 3, + }); + } + + const [updated] = await db.select().from(wwwProductListSettings).limit(1); + res.json(ApiResponse.success(updated, '产品列表页配置已更新')); + } catch (e: unknown) { + logger.error(`[www/page/product-list] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新配置失败')); + } +}); + +// ===== 3.4 新闻列表页配置 ===== + +// GET /api/www/page/news-list +router.get('/api/www/page/news-list', async (_req: Request, res: Response) => { + try { + const [row] = await db.select().from(wwwNewsListSettings).limit(1); + if (!row) { + res.json(ApiResponse.noData('尚未配置')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/page/news-list] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取配置失败')); + } +}); + +// PUT /api/www/page/news-list +router.put('/api/www/page/news-list', async (req: Request, res: Response) => { + try { + const b = req.body; + const [existing] = await db.select().from(wwwNewsListSettings).limit(1); + + if (existing) { + await db.update(wwwNewsListSettings).set({ + heroTitleZh: b.hero_title_zh ?? existing.heroTitleZh, + heroTitleEn: b.hero_title_en ?? existing.heroTitleEn, + heroSubtitleZh: b.hero_subtitle_zh ?? existing.heroSubtitleZh, + heroSubtitleEn: b.hero_subtitle_en ?? existing.heroSubtitleEn, + heroBgUrl: b.hero_bg_url ?? existing.heroBgUrl, + pageSize: b.page_size ?? existing.pageSize, + paginationMode: b.pagination_mode ?? existing.paginationMode, + }).where(eq(wwwNewsListSettings.id, existing.id)); + } else { + await db.insert(wwwNewsListSettings).values({ + heroTitleZh: b.hero_title_zh || '新闻中心', + heroTitleEn: b.hero_title_en || 'News', + heroSubtitleZh: b.hero_subtitle_zh || '', + heroSubtitleEn: b.hero_subtitle_en || '', + heroBgUrl: b.hero_bg_url || '', + pageSize: b.page_size || 9, + paginationMode: b.pagination_mode || 'paginator', + }); + } + + const [updated] = await db.select().from(wwwNewsListSettings).limit(1); + res.json(ApiResponse.success(updated, '新闻列表页配置已更新')); + } catch (e: unknown) { + logger.error(`[www/page/news-list] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新配置失败')); + } +}); + +// ===== 3.5 关于我们配置 ===== + +// GET /api/www/page/about +router.get('/api/www/page/about', async (_req: Request, res: Response) => { + try { + const [row] = await db.select().from(wwwAboutSettings).limit(1); + if (!row) { + res.json(ApiResponse.noData('尚未配置')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/page/about] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取配置失败')); + } +}); + +// PUT /api/www/page/about +router.put('/api/www/page/about', async (req: Request, res: Response) => { + try { + const b = req.body; + const [existing] = await db.select().from(wwwAboutSettings).limit(1); + + if (existing) { + await db.update(wwwAboutSettings).set({ + heroTitleZh: b.hero_title_zh ?? existing.heroTitleZh, + heroTitleEn: b.hero_title_en ?? existing.heroTitleEn, + heroSubtitleZh: b.hero_subtitle_zh ?? existing.heroSubtitleZh, + heroSubtitleEn: b.hero_subtitle_en ?? existing.heroSubtitleEn, + heroBgUrl: b.hero_bg_url ?? existing.heroBgUrl, + s1TitleZh: b.s1_title_zh ?? existing.s1TitleZh, + s1TitleEn: b.s1_title_en ?? existing.s1TitleEn, + s1ContentZh: b.s1_content_zh !== undefined ? b.s1_content_zh : existing.s1ContentZh, + s1ContentEn: b.s1_content_en !== undefined ? b.s1_content_en : existing.s1ContentEn, + s1ImageUrl: b.s1_image_url ?? existing.s1ImageUrl, + s1CtaZh: b.s1_cta_zh ?? existing.s1CtaZh, + s1CtaEn: b.s1_cta_en ?? existing.s1CtaEn, + s1CtaUrl: b.s1_cta_url ?? existing.s1CtaUrl, + s2TitleZh: b.s2_title_zh ?? existing.s2TitleZh, + s2TitleEn: b.s2_title_en ?? existing.s2TitleEn, + s2Features: b.s2_features !== undefined ? b.s2_features : existing.s2Features, + s3TitleZh: b.s3_title_zh ?? existing.s3TitleZh, + s3TitleEn: b.s3_title_en ?? existing.s3TitleEn, + s3CtaZh: b.s3_cta_zh ?? existing.s3CtaZh, + s3CtaEn: b.s3_cta_en ?? existing.s3CtaEn, + s3CtaUrl: b.s3_cta_url ?? existing.s3CtaUrl, + }).where(eq(wwwAboutSettings.id, existing.id)); + } else { + await db.insert(wwwAboutSettings).values({ + heroTitleZh: b.hero_title_zh || '关于我们', + heroTitleEn: b.hero_title_en || 'About Us', + heroSubtitleZh: b.hero_subtitle_zh || '', + heroSubtitleEn: b.hero_subtitle_en || '', + heroBgUrl: b.hero_bg_url || '', + }); + } + + const [updated] = await db.select().from(wwwAboutSettings).limit(1); + res.json(ApiResponse.success(updated, '关于我们配置已更新')); + } catch (e: unknown) { + logger.error(`[www/page/about] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新配置失败')); + } +}); + +// ===== 3.6 技术支持配置 ===== + +// GET /api/www/page/support +router.get('/api/www/page/support', async (_req: Request, res: Response) => { + try { + const [row] = await db.select().from(wwwSupportSettings).limit(1); + if (!row) { + res.json(ApiResponse.noData('尚未配置')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/page/support] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取配置失败')); + } +}); + +// PUT /api/www/page/support +router.put('/api/www/page/support', async (req: Request, res: Response) => { + try { + const b = req.body; + const [existing] = await db.select().from(wwwSupportSettings).limit(1); + + if (existing) { + await db.update(wwwSupportSettings).set({ + heroTitleZh: b.hero_title_zh ?? existing.heroTitleZh, + heroTitleEn: b.hero_title_en ?? existing.heroTitleEn, + heroSubtitleZh: b.hero_subtitle_zh ?? existing.heroSubtitleZh, + heroSubtitleEn: b.hero_subtitle_en ?? existing.heroSubtitleEn, + heroBgUrl: b.hero_bg_url ?? existing.heroBgUrl, + s1FaqCategories: b.s1_faq_categories !== undefined ? b.s1_faq_categories : existing.s1FaqCategories, + s2Downloads: b.s2_downloads !== undefined ? b.s2_downloads : existing.s2Downloads, + s3Contact: b.s3_contact !== undefined ? b.s3_contact : existing.s3Contact, + s4CtaZh: b.s4_cta_zh ?? existing.s4CtaZh, + s4CtaEn: b.s4_cta_en ?? existing.s4CtaEn, + s4CtaUrl: b.s4_cta_url ?? existing.s4CtaUrl, + }).where(eq(wwwSupportSettings.id, existing.id)); + } else { + await db.insert(wwwSupportSettings).values({ + heroTitleZh: b.hero_title_zh || '技术支持', + heroTitleEn: b.hero_title_en || 'Support', + heroSubtitleZh: b.hero_subtitle_zh || '', + heroSubtitleEn: b.hero_subtitle_en || '', + heroBgUrl: b.hero_bg_url || '', + }); + } + + const [updated] = await db.select().from(wwwSupportSettings).limit(1); + res.json(ApiResponse.success(updated, '技术支持配置已更新')); + } catch (e: unknown) { + logger.error(`[www/page/support] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新配置失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/www/pageSeo.ts b/dashboard/backend/src/routes/www/pageSeo.ts new file mode 100644 index 0000000..e194d5e --- /dev/null +++ b/dashboard/backend/src/routes/www/pageSeo.ts @@ -0,0 +1,89 @@ +import { Router, type Request, type Response } from 'express'; +import { eq, and, isNull } from 'drizzle-orm'; +import { db } from '../../config/database.js'; +import { wwwPageSeo } from '../../schemas/index.js'; +import logger from '../../config/logger.js'; +import { ApiResponse } from '../../utils/response.js'; +import { authMiddleware } from '../../middleware/auth.js'; + +const router = Router(); +router.use(authMiddleware); + +// GET /api/www/page-seo?page_type=home&entity_id=1 +router.get('/api/www/page-seo', async (req: Request, res: Response) => { + try { + const pageType = req.query.page_type as string; + const entityId = req.query.entity_id ? Number(req.query.entity_id) : undefined; + + if (!pageType) { + res.json(ApiResponse.error('page_type 参数必填')); + return; + } + + const conditions = [eq(wwwPageSeo.pageType, pageType)]; + if (entityId) { + conditions.push(eq(wwwPageSeo.entityId, entityId)); + } else { + conditions.push(isNull(wwwPageSeo.entityId)); + } + + const [row] = await db.select().from(wwwPageSeo).where(and(...conditions)); + if (!row) { + res.json(ApiResponse.noData('尚未配置该页面 SEO')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/page-seo] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取 SEO 配置失败')); + } +}); + +// PUT /api/www/page-seo(创建/更新) +router.put('/api/www/page-seo', async (req: Request, res: Response) => { + try { + const b = req.body; + if (!b.page_type) { + res.json(ApiResponse.error('page_type 为必填项')); + return; + } + + const entityId = b.entity_id || null; + + // 查找已有记录 + const conditions = [eq(wwwPageSeo.pageType, b.page_type)]; + if (entityId) { + conditions.push(eq(wwwPageSeo.entityId, entityId)); + } else { + conditions.push(isNull(wwwPageSeo.entityId)); + } + + const [existing] = await db.select().from(wwwPageSeo).where(and(...conditions)); + + if (existing) { + await db.update(wwwPageSeo).set({ + metaTitle: b.meta_title ?? existing.metaTitle, + metaDescription: b.meta_description ?? existing.metaDescription, + metaKeywords: b.meta_keywords ?? existing.metaKeywords, + ogImageUrl: b.og_image_url ?? existing.ogImageUrl, + }).where(eq(wwwPageSeo.id, existing.id)); + } else { + await db.insert(wwwPageSeo).values({ + pageType: b.page_type, + entityId, + metaTitle: b.meta_title || '', + metaDescription: b.meta_description || '', + metaKeywords: b.meta_keywords || '', + ogImageUrl: b.og_image_url || '', + }); + } + + const [updated] = await db.select().from(wwwPageSeo).where(and(...conditions)); + res.json(ApiResponse.success(updated, 'SEO 配置已更新')); + } catch (e: unknown) { + logger.error(`[www/page-seo] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新 SEO 配置失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/www/products.ts b/dashboard/backend/src/routes/www/products.ts new file mode 100644 index 0000000..79eb59f --- /dev/null +++ b/dashboard/backend/src/routes/www/products.ts @@ -0,0 +1,300 @@ +import { Router, type Request, type Response } from 'express'; +import { eq, like, and, count } from 'drizzle-orm'; +import { db } from '../../config/database.js'; +import { wwwProductSeries, wwwProducts, wwwSectionBlocks, wwwSpecGroups, wwwSpecItems } from '../../schemas/index.js'; +import logger from '../../config/logger.js'; +import { ApiResponse } from '../../utils/response.js'; +import { authMiddleware } from '../../middleware/auth.js'; + +const router = Router(); +router.use(authMiddleware); + +// ===== 4.1 产品系列 ===== + +// GET /api/www/product/series +router.get('/api/www/product/series', async (_req: Request, res: Response) => { + try { + const rows = await db.select().from(wwwProductSeries).orderBy(wwwProductSeries.sortOrder); + res.json(ApiResponse.success({ items: rows, total: rows.length })); + } catch (e: unknown) { + logger.error(`[www/product/series] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取产品系列失败')); + } +}); + +// POST /api/www/product/series +router.post('/api/www/product/series', async (req: Request, res: Response) => { + try { + const b = req.body; + if (!b.name_zh || !b.name_en) { + res.json(ApiResponse.error('name_zh 和 name_en 为必填项')); + return; + } + + const result = await db.insert(wwwProductSeries).values({ + nameZh: b.name_zh, + nameEn: b.name_en, + overline: b.overline || '', + subtitleZh: b.subtitle_zh || '', + subtitleEn: b.subtitle_en || '', + coverUrl: b.cover_url || '', + isVisible: b.is_visible !== undefined ? (b.is_visible ? 1 : 0) : 1, + sortOrder: b.sort_order || 0, + }); + + const newId = result[0].insertId; + const [created] = await db.select().from(wwwProductSeries).where(eq(wwwProductSeries.id, newId)); + res.json(ApiResponse.success(created, '产品系列已创建')); + } catch (e: unknown) { + logger.error(`[www/product/series] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('创建产品系列失败')); + } +}); + +// GET /api/www/product/series/:id +router.get('/api/www/product/series/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [row] = await db.select().from(wwwProductSeries).where(eq(wwwProductSeries.id, id)); + if (!row) { + res.json(ApiResponse.noData('系列不存在')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/product/series/:id] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取系列详情失败')); + } +}); + +// PUT /api/www/product/series/:id +router.put('/api/www/product/series/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwProductSeries).where(eq(wwwProductSeries.id, id)); + if (!existing) { + res.json(ApiResponse.noData('系列不存在')); + return; + } + + const b = req.body; + await db.update(wwwProductSeries).set({ + nameZh: b.name_zh ?? existing.nameZh, + nameEn: b.name_en ?? existing.nameEn, + overline: b.overline ?? existing.overline, + subtitleZh: b.subtitle_zh ?? existing.subtitleZh, + subtitleEn: b.subtitle_en ?? existing.subtitleEn, + coverUrl: b.cover_url ?? existing.coverUrl, + isVisible: b.is_visible !== undefined ? (b.is_visible ? 1 : 0) : existing.isVisible, + sortOrder: b.sort_order ?? existing.sortOrder, + }).where(eq(wwwProductSeries.id, id)); + + const [updated] = await db.select().from(wwwProductSeries).where(eq(wwwProductSeries.id, id)); + res.json(ApiResponse.success(updated, '系列已更新')); + } catch (e: unknown) { + logger.error(`[www/product/series/:id] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新系列失败')); + } +}); + +// DELETE /api/www/product/series/:id +router.delete('/api/www/product/series/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwProductSeries).where(eq(wwwProductSeries.id, id)); + if (!existing) { + res.json(ApiResponse.noData('系列不存在')); + return; + } + + // 检查是否有关联产品 + const [productCount] = await db.select({ value: count() }).from(wwwProducts).where(eq(wwwProducts.seriesId, id)); + if (productCount && productCount.value > 0) { + res.json(ApiResponse.error(`该系列下还有 ${productCount.value} 个产品,无法删除`)); + return; + } + + await db.delete(wwwProductSeries).where(eq(wwwProductSeries.id, id)); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/product/series/:id] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除系列失败')); + } +}); + +// PATCH /api/www/product/series/sort +router.patch('/api/www/product/series/sort', async (req: Request, res: Response) => { + try { + const items: { id: number; sort_order: number }[] = req.body; + if (!Array.isArray(items)) { + res.json(ApiResponse.error('请求体应为数组')); + return; + } + for (const item of items) { + await db.update(wwwProductSeries).set({ sortOrder: item.sort_order }).where(eq(wwwProductSeries.id, item.id)); + } + res.json(ApiResponse.success(null, '排序已更新')); + } catch (e: unknown) { + logger.error(`[www/product/series/sort] PATCH error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新排序失败')); + } +}); + +// ===== 4.2 产品 ===== + +// GET /api/www/products?series_id=1&is_visible=1&name=xxx +router.get('/api/www/products', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '20', 10), 1000); + const seriesId = req.query.series_id ? Number(req.query.series_id) : undefined; + const isVisible = req.query.is_visible !== undefined ? Number(req.query.is_visible) : undefined; + const name = req.query.name as string | undefined; + + const conditions = []; + if (seriesId) conditions.push(eq(wwwProducts.seriesId, seriesId)); + if (isVisible !== undefined) conditions.push(eq(wwwProducts.isVisible, isVisible)); + if (name) conditions.push(like(wwwProducts.nameZh, `%${name}%`)); + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db.select({ value: count() }).from(wwwProducts).where(whereClause); + const total = totalResult?.value ?? 0; + + const rows = await db.select().from(wwwProducts) + .where(whereClause) + .orderBy(wwwProducts.sortOrder) + .offset(skip) + .limit(limit); + + res.json(ApiResponse.success({ items: rows, total, skip, limit })); + } catch (e: unknown) { + logger.error(`[www/products] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取产品列表失败')); + } +}); + +// POST /api/www/products +router.post('/api/www/products', async (req: Request, res: Response) => { + try { + const b = req.body; + if (!b.series_id || !b.name_zh || !b.name_en || !b.slug) { + res.json(ApiResponse.error('series_id、name_zh、name_en、slug 为必填项')); + return; + } + + // 检查 slug 唯一性 + const [existingSlug] = await db.select().from(wwwProducts).where(eq(wwwProducts.slug, b.slug)); + if (existingSlug) { + res.json(ApiResponse.error(`slug "${b.slug}" 已存在`)); + return; + } + + const result = await db.insert(wwwProducts).values({ + seriesId: b.series_id, + nameZh: b.name_zh, + nameEn: b.name_en, + slug: b.slug, + introZh: b.intro_zh || '', + introEn: b.intro_en || '', + coverUrl: b.cover_url || '', + isVisible: b.is_visible !== undefined ? (b.is_visible ? 1 : 0) : 1, + sortOrder: b.sort_order || 0, + }); + + const newId = result[0].insertId; + const [created] = await db.select().from(wwwProducts).where(eq(wwwProducts.id, newId)); + res.json(ApiResponse.success(created, '产品已创建')); + } catch (e: unknown) { + logger.error(`[www/products] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('创建产品失败')); + } +}); + +// GET /api/www/products/:id +router.get('/api/www/products/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [row] = await db.select().from(wwwProducts).where(eq(wwwProducts.id, id)); + if (!row) { + res.json(ApiResponse.noData('产品不存在')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[www/products/:id] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取产品详情失败')); + } +}); + +// PUT /api/www/products/:id +router.put('/api/www/products/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwProducts).where(eq(wwwProducts.id, id)); + if (!existing) { + res.json(ApiResponse.noData('产品不存在')); + return; + } + + const b = req.body; + + // 如果修改了 slug,检查唯一性 + if (b.slug && b.slug !== existing.slug) { + const [slugExists] = await db.select().from(wwwProducts).where(eq(wwwProducts.slug, b.slug)); + if (slugExists) { + res.json(ApiResponse.error(`slug "${b.slug}" 已存在`)); + return; + } + } + + await db.update(wwwProducts).set({ + seriesId: b.series_id ?? existing.seriesId, + nameZh: b.name_zh ?? existing.nameZh, + nameEn: b.name_en ?? existing.nameEn, + slug: b.slug ?? existing.slug, + introZh: b.intro_zh ?? existing.introZh, + introEn: b.intro_en ?? existing.introEn, + coverUrl: b.cover_url ?? existing.coverUrl, + isVisible: b.is_visible !== undefined ? (b.is_visible ? 1 : 0) : existing.isVisible, + sortOrder: b.sort_order ?? existing.sortOrder, + }).where(eq(wwwProducts.id, id)); + + const [updated] = await db.select().from(wwwProducts).where(eq(wwwProducts.id, id)); + res.json(ApiResponse.success(updated, '产品已更新')); + } catch (e: unknown) { + logger.error(`[www/products/:id] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新产品失败')); + } +}); + +// DELETE /api/www/products/:id(级联删除 Section + 参数规格) +router.delete('/api/www/products/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwProducts).where(eq(wwwProducts.id, id)); + if (!existing) { + res.json(ApiResponse.noData('产品不存在')); + return; + } + + // 级联删除该产品的所有 Section 及参数规格 + const sections = await db.select().from(wwwSectionBlocks).where(eq(wwwSectionBlocks.productId, id)); + for (const section of sections) { + const groups = await db.select().from(wwwSpecGroups).where(eq(wwwSpecGroups.sectionBlockId, section.id)); + for (const g of groups) { + await db.delete(wwwSpecItems).where(eq(wwwSpecItems.specGroupId, g.id)); + } + await db.delete(wwwSpecGroups).where(eq(wwwSpecGroups.sectionBlockId, section.id)); + } + await db.delete(wwwSectionBlocks).where(eq(wwwSectionBlocks.productId, id)); + await db.delete(wwwProducts).where(eq(wwwProducts.id, id)); + + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/products/:id] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除产品失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/www/sections.ts b/dashboard/backend/src/routes/www/sections.ts new file mode 100644 index 0000000..a99c235 --- /dev/null +++ b/dashboard/backend/src/routes/www/sections.ts @@ -0,0 +1,479 @@ +import { Router, type Request, type Response } from 'express'; +import { eq, and } from 'drizzle-orm'; +import { db } from '../../config/database.js'; +import { wwwSectionBlocks, wwwSpecGroups, wwwSpecItems } from '../../schemas/index.js'; +import logger from '../../config/logger.js'; +import { ApiResponse } from '../../utils/response.js'; +import { authMiddleware } from '../../middleware/auth.js'; + +const router = Router(); +router.use(authMiddleware); + +// ===== 3.1 Section 区块 ===== + +// GET /api/www/sections?page_type=home&product_id=1 +router.get('/api/www/sections', async (req: Request, res: Response) => { + try { + const pageType = req.query.page_type as string; + const productId = req.query.product_id ? Number(req.query.product_id) : undefined; + + if (!pageType) { + res.json(ApiResponse.error('page_type 参数必填')); + return; + } + + const conditions = [eq(wwwSectionBlocks.pageType, pageType)]; + if (productId) conditions.push(eq(wwwSectionBlocks.productId, productId)); + + const rows = await db.select().from(wwwSectionBlocks) + .where(and(...conditions)) + .orderBy(wwwSectionBlocks.sortOrder); + + res.json(ApiResponse.success({ items: rows, total: rows.length })); + } catch (e: unknown) { + logger.error(`[www/sections] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取 Section 列表失败')); + } +}); + +// GET /api/www/sections/:id(含子数据) +router.get('/api/www/sections/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [section] = await db.select().from(wwwSectionBlocks).where(eq(wwwSectionBlocks.id, id)); + if (!section) { + res.json(ApiResponse.noData('Section 不存在')); + return; + } + + // 如果是 spec_table 布局,附带参数分组数据 + let specGroups: unknown[] = []; + if (section.layout === 'spec_table') { + const groups = await db.select().from(wwwSpecGroups) + .where(eq(wwwSpecGroups.sectionBlockId, id)) + .orderBy(wwwSpecGroups.sortOrder); + + specGroups = await Promise.all(groups.map(async (g) => { + const items = await db.select().from(wwwSpecItems) + .where(eq(wwwSpecItems.specGroupId, g.id)) + .orderBy(wwwSpecItems.sortOrder); + return { ...g, items }; + })); + } + + res.json(ApiResponse.success({ ...section, specGroups })); + } catch (e: unknown) { + logger.error(`[www/sections/:id] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取 Section 详情失败')); + } +}); + +// POST /api/www/sections +router.post('/api/www/sections', async (req: Request, res: Response) => { + try { + const b = req.body; + if (!b.page_type || !b.layout) { + res.json(ApiResponse.error('page_type 和 layout 为必填项')); + return; + } + + const result = await db.insert(wwwSectionBlocks).values({ + pageType: b.page_type, + productId: b.product_id || null, + layout: b.layout, + theme: b.theme || 'light', + bgType: b.bg_type || 'color', + bgValue: b.bg_value || '', + overlayEnabled: b.overlay_enabled ? 1 : 0, + overlayOpacity: b.overlay_opacity ?? '0.00', + overlineZh: b.overline_zh || '', + overlineEn: b.overline_en || '', + titleZh: b.title_zh || '', + titleEn: b.title_en || '', + subtitleZh: b.subtitle_zh || '', + subtitleEn: b.subtitle_en || '', + contentZh: b.content_zh || null, + contentEn: b.content_en || null, + ctaPrimaryZh: b.cta_primary_zh || '', + ctaPrimaryEn: b.cta_primary_en || '', + ctaPrimaryUrl: b.cta_primary_url || '', + ctaSecondaryZh: b.cta_secondary_zh || '', + ctaSecondaryEn: b.cta_secondary_en || '', + ctaSecondaryUrl: b.cta_secondary_url || '', + mediaUrlZh: b.media_url_zh || '', + mediaUrlEn: b.media_url_en || '', + videoUrl: b.video_url || '', + config: b.config || null, + isVisible: b.is_visible !== undefined ? (b.is_visible ? 1 : 0) : 1, + sortOrder: b.sort_order || 0, + }); + + const newId = result[0].insertId; + const [created] = await db.select().from(wwwSectionBlocks).where(eq(wwwSectionBlocks.id, newId)); + res.json(ApiResponse.success(created, 'Section 已创建')); + } catch (e: unknown) { + logger.error(`[www/sections] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('创建 Section 失败')); + } +}); + +// PUT /api/www/sections/:id +router.put('/api/www/sections/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwSectionBlocks).where(eq(wwwSectionBlocks.id, id)); + if (!existing) { + res.json(ApiResponse.noData('Section 不存在')); + return; + } + + const b = req.body; + await db.update(wwwSectionBlocks).set({ + layout: b.layout ?? existing.layout, + theme: b.theme ?? existing.theme, + bgType: b.bg_type ?? existing.bgType, + bgValue: b.bg_value ?? existing.bgValue, + overlayEnabled: b.overlay_enabled !== undefined ? (b.overlay_enabled ? 1 : 0) : existing.overlayEnabled, + overlayOpacity: b.overlay_opacity ?? existing.overlayOpacity, + overlineZh: b.overline_zh ?? existing.overlineZh, + overlineEn: b.overline_en ?? existing.overlineEn, + titleZh: b.title_zh ?? existing.titleZh, + titleEn: b.title_en ?? existing.titleEn, + subtitleZh: b.subtitle_zh ?? existing.subtitleZh, + subtitleEn: b.subtitle_en ?? existing.subtitleEn, + contentZh: b.content_zh !== undefined ? b.content_zh : existing.contentZh, + contentEn: b.content_en !== undefined ? b.content_en : existing.contentEn, + ctaPrimaryZh: b.cta_primary_zh ?? existing.ctaPrimaryZh, + ctaPrimaryEn: b.cta_primary_en ?? existing.ctaPrimaryEn, + ctaPrimaryUrl: b.cta_primary_url ?? existing.ctaPrimaryUrl, + ctaSecondaryZh: b.cta_secondary_zh ?? existing.ctaSecondaryZh, + ctaSecondaryEn: b.cta_secondary_en ?? existing.ctaSecondaryEn, + ctaSecondaryUrl: b.cta_secondary_url ?? existing.ctaSecondaryUrl, + mediaUrlZh: b.media_url_zh ?? existing.mediaUrlZh, + mediaUrlEn: b.media_url_en ?? existing.mediaUrlEn, + videoUrl: b.video_url ?? existing.videoUrl, + config: b.config !== undefined ? b.config : existing.config, + isVisible: b.is_visible !== undefined ? (b.is_visible ? 1 : 0) : existing.isVisible, + sortOrder: b.sort_order ?? existing.sortOrder, + }).where(eq(wwwSectionBlocks.id, id)); + + const [updated] = await db.select().from(wwwSectionBlocks).where(eq(wwwSectionBlocks.id, id)); + res.json(ApiResponse.success(updated, 'Section 已更新')); + } catch (e: unknown) { + logger.error(`[www/sections/:id] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新 Section 失败')); + } +}); + +// DELETE /api/www/sections/:id +router.delete('/api/www/sections/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwSectionBlocks).where(eq(wwwSectionBlocks.id, id)); + if (!existing) { + res.json(ApiResponse.noData('Section 不存在')); + return; + } + + // 级联删除参数规格 + const groups = await db.select().from(wwwSpecGroups).where(eq(wwwSpecGroups.sectionBlockId, id)); + for (const g of groups) { + await db.delete(wwwSpecItems).where(eq(wwwSpecItems.specGroupId, g.id)); + } + await db.delete(wwwSpecGroups).where(eq(wwwSpecGroups.sectionBlockId, id)); + await db.delete(wwwSectionBlocks).where(eq(wwwSectionBlocks.id, id)); + + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/sections/:id] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除 Section 失败')); + } +}); + +// PATCH /api/www/sections/sort +router.patch('/api/www/sections/sort', async (req: Request, res: Response) => { + try { + const items: { id: number; sort_order: number }[] = req.body; + if (!Array.isArray(items)) { + res.json(ApiResponse.error('请求体应为数组')); + return; + } + for (const item of items) { + await db.update(wwwSectionBlocks).set({ sortOrder: item.sort_order }).where(eq(wwwSectionBlocks.id, item.id)); + } + res.json(ApiResponse.success(null, '排序已更新')); + } catch (e: unknown) { + logger.error(`[www/sections/sort] PATCH error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新排序失败')); + } +}); + +// POST /api/www/sections/:id/duplicate +router.post('/api/www/sections/:id/duplicate', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [source] = await db.select().from(wwwSectionBlocks).where(eq(wwwSectionBlocks.id, id)); + if (!source) { + res.json(ApiResponse.noData('Section 不存在')); + return; + } + + const { id: _, createdAt: __, updatedAt: ___, ...data } = source; + const result = await db.insert(wwwSectionBlocks).values({ + ...data, + titleZh: `${source.titleZh} (副本)`, + titleEn: `${source.titleEn} (copy)`, + sortOrder: source.sortOrder + 1, + }); + + const newId = result[0].insertId; + + // 复制参数规格 + if (source.layout === 'spec_table') { + const groups = await db.select().from(wwwSpecGroups).where(eq(wwwSpecGroups.sectionBlockId, id)); + for (const g of groups) { + const gResult = await db.insert(wwwSpecGroups).values({ + sectionBlockId: newId, + titleZh: g.titleZh, + titleEn: g.titleEn, + sortOrder: g.sortOrder, + defaultVisible: g.defaultVisible, + }); + const newGroupId = gResult[0].insertId; + const items = await db.select().from(wwwSpecItems).where(eq(wwwSpecItems.specGroupId, g.id)); + if (items.length > 0) { + await db.insert(wwwSpecItems).values(items.map((item) => ({ + specGroupId: newGroupId, + nameZh: item.nameZh, + nameEn: item.nameEn, + valueZh: item.valueZh, + valueEn: item.valueEn, + sortOrder: item.sortOrder, + }))); + } + } + } + + const [created] = await db.select().from(wwwSectionBlocks).where(eq(wwwSectionBlocks.id, newId)); + res.json(ApiResponse.success(created, 'Section 已复制')); + } catch (e: unknown) { + logger.error(`[www/sections/duplicate] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('复制 Section 失败')); + } +}); + +// ===== 3.2 Spec Table 参数规格 ===== + +// GET /api/www/sections/:section_id/spec-groups +router.get('/api/www/sections/:section_id/spec-groups', async (req: Request, res: Response) => { + try { + const sectionId = Number(req.params.section_id); + const groups = await db.select().from(wwwSpecGroups) + .where(eq(wwwSpecGroups.sectionBlockId, sectionId)) + .orderBy(wwwSpecGroups.sortOrder); + + // 附带每组的参数项 + const result = await Promise.all(groups.map(async (g) => { + const items = await db.select().from(wwwSpecItems) + .where(eq(wwwSpecItems.specGroupId, g.id)) + .orderBy(wwwSpecItems.sortOrder); + return { ...g, items }; + })); + + res.json(ApiResponse.success({ items: result, total: result.length })); + } catch (e: unknown) { + logger.error(`[www/spec-groups] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取参数分组失败')); + } +}); + +// POST /api/www/sections/:section_id/spec-groups +router.post('/api/www/sections/:section_id/spec-groups', async (req: Request, res: Response) => { + try { + const sectionId = Number(req.params.section_id); + const { title_zh, title_en, default_visible } = req.body; + if (!title_zh || !title_en) { + res.json(ApiResponse.error('title_zh 和 title_en 为必填项')); + return; + } + + const result = await db.insert(wwwSpecGroups).values({ + sectionBlockId: sectionId, + titleZh: title_zh, + titleEn: title_en, + defaultVisible: default_visible || 5, + }); + + const newId = result[0].insertId; + const [created] = await db.select().from(wwwSpecGroups).where(eq(wwwSpecGroups.id, newId)); + res.json(ApiResponse.success(created, '参数分组已创建')); + } catch (e: unknown) { + logger.error(`[www/spec-groups] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('创建参数分组失败')); + } +}); + +// PUT /api/www/sections/:section_id/spec-groups/:group_id +router.put('/api/www/sections/:section_id/spec-groups/:group_id', async (req: Request, res: Response) => { + try { + const groupId = Number(req.params.group_id); + const [existing] = await db.select().from(wwwSpecGroups).where(eq(wwwSpecGroups.id, groupId)); + if (!existing) { + res.json(ApiResponse.noData('参数分组不存在')); + return; + } + + const { title_zh, title_en, default_visible } = req.body; + await db.update(wwwSpecGroups).set({ + titleZh: title_zh ?? existing.titleZh, + titleEn: title_en ?? existing.titleEn, + defaultVisible: default_visible ?? existing.defaultVisible, + }).where(eq(wwwSpecGroups.id, groupId)); + + const [updated] = await db.select().from(wwwSpecGroups).where(eq(wwwSpecGroups.id, groupId)); + res.json(ApiResponse.success(updated, '参数分组已更新')); + } catch (e: unknown) { + logger.error(`[www/spec-groups] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新参数分组失败')); + } +}); + +// DELETE /api/www/sections/:section_id/spec-groups/:group_id +router.delete('/api/www/sections/:section_id/spec-groups/:group_id', async (req: Request, res: Response) => { + try { + const groupId = Number(req.params.group_id); + const [existing] = await db.select().from(wwwSpecGroups).where(eq(wwwSpecGroups.id, groupId)); + if (!existing) { + res.json(ApiResponse.noData('参数分组不存在')); + return; + } + await db.delete(wwwSpecItems).where(eq(wwwSpecItems.specGroupId, groupId)); + await db.delete(wwwSpecGroups).where(eq(wwwSpecGroups.id, groupId)); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/spec-groups] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除参数分组失败')); + } +}); + +// PATCH /api/www/sections/:section_id/spec-groups/sort +router.patch('/api/www/sections/:section_id/spec-groups/sort', async (req: Request, res: Response) => { + try { + const items: { id: number; sort_order: number }[] = req.body; + if (!Array.isArray(items)) { + res.json(ApiResponse.error('请求体应为数组')); + return; + } + for (const item of items) { + await db.update(wwwSpecGroups).set({ sortOrder: item.sort_order }).where(eq(wwwSpecGroups.id, item.id)); + } + res.json(ApiResponse.success(null, '排序已更新')); + } catch (e: unknown) { + logger.error(`[www/spec-groups/sort] PATCH error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新排序失败')); + } +}); + +// GET /api/www/spec-groups/:group_id/items +router.get('/api/www/spec-groups/:group_id/items', async (req: Request, res: Response) => { + try { + const groupId = Number(req.params.group_id); + const items = await db.select().from(wwwSpecItems) + .where(eq(wwwSpecItems.specGroupId, groupId)) + .orderBy(wwwSpecItems.sortOrder); + res.json(ApiResponse.success({ items, total: items.length })); + } catch (e: unknown) { + logger.error(`[www/spec-items] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取参数项失败')); + } +}); + +// POST /api/www/spec-groups/:group_id/items +router.post('/api/www/spec-groups/:group_id/items', async (req: Request, res: Response) => { + try { + const groupId = Number(req.params.group_id); + const { name_zh, name_en, value_zh, value_en } = req.body; + if (!name_zh || !name_en || !value_zh || !value_en) { + res.json(ApiResponse.error('name_zh、name_en、value_zh、value_en 为必填项')); + return; + } + + const result = await db.insert(wwwSpecItems).values({ + specGroupId: groupId, + nameZh: name_zh, + nameEn: name_en, + valueZh: value_zh, + valueEn: value_en, + }); + + const newId = result[0].insertId; + const [created] = await db.select().from(wwwSpecItems).where(eq(wwwSpecItems.id, newId)); + res.json(ApiResponse.success(created, '参数项已创建')); + } catch (e: unknown) { + logger.error(`[www/spec-items] POST error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('创建参数项失败')); + } +}); + +// PUT /api/www/spec-items/:id +router.put('/api/www/spec-items/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwSpecItems).where(eq(wwwSpecItems.id, id)); + if (!existing) { + res.json(ApiResponse.noData('参数项不存在')); + return; + } + + const { name_zh, name_en, value_zh, value_en } = req.body; + await db.update(wwwSpecItems).set({ + nameZh: name_zh ?? existing.nameZh, + nameEn: name_en ?? existing.nameEn, + valueZh: value_zh ?? existing.valueZh, + valueEn: value_en ?? existing.valueEn, + }).where(eq(wwwSpecItems.id, id)); + + const [updated] = await db.select().from(wwwSpecItems).where(eq(wwwSpecItems.id, id)); + res.json(ApiResponse.success(updated, '参数项已更新')); + } catch (e: unknown) { + logger.error(`[www/spec-items] PUT error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新参数项失败')); + } +}); + +// DELETE /api/www/spec-items/:id +router.delete('/api/www/spec-items/:id', async (req: Request, res: Response) => { + try { + const id = Number(req.params.id); + const [existing] = await db.select().from(wwwSpecItems).where(eq(wwwSpecItems.id, id)); + if (!existing) { + res.json(ApiResponse.noData('参数项不存在')); + return; + } + await db.delete(wwwSpecItems).where(eq(wwwSpecItems.id, id)); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + logger.error(`[www/spec-items] DELETE error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('删除参数项失败')); + } +}); + +// PATCH /api/www/spec-groups/:group_id/items/sort +router.patch('/api/www/spec-groups/:group_id/items/sort', async (req: Request, res: Response) => { + try { + const items: { id: number; sort_order: number }[] = req.body; + if (!Array.isArray(items)) { + res.json(ApiResponse.error('请求体应为数组')); + return; + } + for (const item of items) { + await db.update(wwwSpecItems).set({ sortOrder: item.sort_order }).where(eq(wwwSpecItems.id, item.id)); + } + res.json(ApiResponse.success(null, '排序已更新')); + } catch (e: unknown) { + logger.error(`[www/spec-items/sort] PATCH error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('更新排序失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/schemas/index.ts b/dashboard/backend/src/schemas/index.ts index 607330d..647eeae 100644 --- a/dashboard/backend/src/schemas/index.ts +++ b/dashboard/backend/src/schemas/index.ts @@ -10,3 +10,6 @@ export { dashboardUsers } from './dashboardUser.js'; export { shareCodeLogs } from './shareCodeLog.js'; export { userActives } from './userActive.js'; export { userDevices } from './userDevice.js'; + +// 官网 CMS +export * from './www/index.js'; diff --git a/dashboard/backend/src/schemas/www/contact.ts b/dashboard/backend/src/schemas/www/contact.ts new file mode 100644 index 0000000..21b3595 --- /dev/null +++ b/dashboard/backend/src/schemas/www/contact.ts @@ -0,0 +1,34 @@ +import { mysqlTable, bigint, varchar, tinyint, int, json, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const wwwFormSettings = mysqlTable('www_form_settings', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + recipientEmail: varchar('recipient_email', { length: 100 }).notNull().default(''), + successMessageZh: varchar('success_message_zh', { length: 300 }).notNull().default('感谢您的留言,我们会尽快回复!'), + successMessageEn: varchar('success_message_en', { length: 300 }).notNull().default('Thank you for your message. We will get back to you soon!'), + errorMessageZh: varchar('error_message_zh', { length: 300 }).notNull().default('提交失败,请稍后重试。'), + errorMessageEn: varchar('error_message_en', { length: 300 }).notNull().default('Submission failed. Please try again later.'), + cooldownSeconds: int('cooldown_seconds').notNull().default(60), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwFormFields = mysqlTable('www_form_fields', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + nameZh: varchar('name_zh', { length: 50 }).notNull(), + nameEn: varchar('name_en', { length: 50 }).notNull(), + fieldType: varchar('field_type', { length: 20 }).notNull(), + isRequired: tinyint('is_required').notNull().default(0), + placeholderZh: varchar('placeholder_zh', { length: 100 }).notNull().default(''), + placeholderEn: varchar('placeholder_en', { length: 100 }).notNull().default(''), + optionsJson: json('options_json'), + sortOrder: int('sort_order').notNull().default(0), +}); + +export const wwwFormSubmissions = mysqlTable('www_form_submissions', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + formData: json('form_data').notNull(), + visitorIp: varchar('visitor_ip', { length: 45 }).notNull().default(''), + userAgent: varchar('user_agent', { length: 500 }).notNull().default(''), + isRead: tinyint('is_read').notNull().default(0), + createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/dashboard/backend/src/schemas/www/globalSettings.ts b/dashboard/backend/src/schemas/www/globalSettings.ts new file mode 100644 index 0000000..75036d1 --- /dev/null +++ b/dashboard/backend/src/schemas/www/globalSettings.ts @@ -0,0 +1,39 @@ +import { mysqlTable, bigint, varchar, datetime, int } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const wwwSiteSettings = mysqlTable('www_site_settings', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + siteTitle: varchar('site_title', { length: 100 }).notNull().default(''), + faviconUrl: varchar('favicon_url', { length: 500 }).notNull().default(''), + logoDarkUrl: varchar('logo_dark_url', { length: 500 }).notNull().default(''), + logoLightUrl: varchar('logo_light_url', { length: 500 }).notNull().default(''), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwFooterSettings = mysqlTable('www_footer_settings', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + companyName: varchar('company_name', { length: 200 }).notNull().default(''), + companyAddress: varchar('company_address', { length: 500 }).notNull().default(''), + contactPhone: varchar('contact_phone', { length: 50 }).notNull().default(''), + contactEmail: varchar('contact_email', { length: 100 }).notNull().default(''), + copyrightText: varchar('copyright_text', { length: 200 }).notNull().default(''), + icpNumber: varchar('icp_number', { length: 50 }).notNull().default(''), + policeNumber: varchar('police_number', { length: 50 }).notNull().default(''), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwSocialLinks = mysqlTable('www_social_links', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + platform: varchar('platform', { length: 30 }).notNull().unique(), + url: varchar('url', { length: 500 }).notNull().default(''), + qrcodeUrl: varchar('qrcode_url', { length: 500 }).notNull().default(''), + sortOrder: int('sort_order').notNull().default(0), +}); + +export const wwwSeoDefaults = mysqlTable('www_seo_defaults', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + metaDescription: varchar('meta_description', { length: 160 }).notNull().default(''), + metaKeywords: varchar('meta_keywords', { length: 300 }).notNull().default(''), + ogImageUrl: varchar('og_image_url', { length: 500 }).notNull().default(''), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); diff --git a/dashboard/backend/src/schemas/www/i18n.ts b/dashboard/backend/src/schemas/www/i18n.ts new file mode 100644 index 0000000..62f180a --- /dev/null +++ b/dashboard/backend/src/schemas/www/i18n.ts @@ -0,0 +1,9 @@ +import { mysqlTable, bigint, varchar } from 'drizzle-orm/mysql-core'; + +export const wwwI18nEntries = mysqlTable('www_i18n_entries', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + dictKey: varchar('dict_key', { length: 100 }).notNull().unique(), + valueZh: varchar('value_zh', { length: 500 }).notNull().default(''), + valueEn: varchar('value_en', { length: 500 }).notNull().default(''), + description: varchar('description', { length: 200 }).notNull().default(''), +}); diff --git a/dashboard/backend/src/schemas/www/index.ts b/dashboard/backend/src/schemas/www/index.ts new file mode 100644 index 0000000..d03fa95 --- /dev/null +++ b/dashboard/backend/src/schemas/www/index.ts @@ -0,0 +1,12 @@ +/** + * 官网 CMS Schemas 统一导出 + */ +export * from './globalSettings.js'; +export * from './nav.js'; +export * from './sections.js'; +export * from './products.js'; +export * from './news.js'; +export * from './media.js'; +export * from './i18n.js'; +export * from './pages.js'; +export * from './contact.js'; diff --git a/dashboard/backend/src/schemas/www/media.ts b/dashboard/backend/src/schemas/www/media.ts new file mode 100644 index 0000000..949c977 --- /dev/null +++ b/dashboard/backend/src/schemas/www/media.ts @@ -0,0 +1,31 @@ +import { mysqlTable, bigint, varchar, int, datetime, primaryKey } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const wwwMedia = mysqlTable('www_media', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + filename: varchar('filename', { length: 255 }).notNull(), + filePath: varchar('file_path', { length: 500 }).notNull(), + fileType: varchar('file_type', { length: 10 }).notNull(), + mimeType: varchar('mime_type', { length: 50 }).notNull().default(''), + fileSize: bigint('file_size', { mode: 'number', unsigned: true }).notNull().default(0), + width: int('width'), + height: int('height'), + category: varchar('category', { length: 20 }).notNull().default('other'), + thumbnailUrl: varchar('thumbnail_url', { length: 500 }).notNull().default(''), + webpUrl: varchar('webp_url', { length: 500 }).notNull().default(''), + refCount: int('ref_count').notNull().default(0), + createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + deletedAt: datetime('deleted_at'), +}); + +export const wwwMediaTags = mysqlTable('www_media_tags', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + tagName: varchar('tag_name', { length: 50 }).notNull().unique(), +}); + +export const wwwMediaTagMap = mysqlTable('www_media_tag_map', { + mediaId: bigint('media_id', { mode: 'number', unsigned: true }).notNull(), + tagId: bigint('tag_id', { mode: 'number', unsigned: true }).notNull(), +}, (table) => [ + primaryKey({ columns: [table.mediaId, table.tagId] }), +]); diff --git a/dashboard/backend/src/schemas/www/nav.ts b/dashboard/backend/src/schemas/www/nav.ts new file mode 100644 index 0000000..fbef78b --- /dev/null +++ b/dashboard/backend/src/schemas/www/nav.ts @@ -0,0 +1,24 @@ +import { mysqlTable, bigint, varchar, tinyint, int, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const wwwNavItems = mysqlTable('www_nav_items', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + nameZh: varchar('name_zh', { length: 50 }).notNull(), + nameEn: varchar('name_en', { length: 50 }).notNull(), + link: varchar('link', { length: 500 }).notNull(), + isVisible: tinyint('is_visible').notNull().default(1), + sortOrder: int('sort_order').notNull().default(0), + displayMode: varchar('display_mode', { length: 20 }).notNull().default('text'), + iconName: varchar('icon_name', { length: 50 }).notNull().default(''), + imageUrl: varchar('image_url', { length: 500 }).notNull().default(''), + openNewTab: tinyint('open_new_tab').notNull().default(0), + createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwNavAppearance = mysqlTable('www_nav_appearance', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + homepageStyle: varchar('homepage_style', { length: 20 }).notNull().default('transparent'), + nonHomepageStyle: varchar('non_homepage_style', { length: 20 }).notNull().default('transparent'), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); diff --git a/dashboard/backend/src/schemas/www/news.ts b/dashboard/backend/src/schemas/www/news.ts new file mode 100644 index 0000000..43dcdff --- /dev/null +++ b/dashboard/backend/src/schemas/www/news.ts @@ -0,0 +1,25 @@ +import { mysqlTable, bigint, varchar, int, datetime, mediumtext } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const wwwNewsArticles = mysqlTable('www_news_articles', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + titleZh: varchar('title_zh', { length: 200 }).notNull(), + titleEn: varchar('title_en', { length: 200 }).notNull(), + slug: varchar('slug', { length: 200 }).notNull().unique(), + summaryZh: varchar('summary_zh', { length: 300 }).notNull().default(''), + summaryEn: varchar('summary_en', { length: 300 }).notNull().default(''), + coverUrl: varchar('cover_url', { length: 500 }).notNull().default(''), + contentZh: mediumtext('content_zh'), + contentEn: mediumtext('content_en'), + status: varchar('status', { length: 20 }).notNull().default('draft'), + publishedAt: datetime('published_at'), + createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwNewsRecommendations = mysqlTable('www_news_recommendations', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + articleId: bigint('article_id', { mode: 'number', unsigned: true }).notNull(), + recommendedId: bigint('recommended_id', { mode: 'number', unsigned: true }).notNull(), + sortOrder: int('sort_order').notNull().default(0), +}); diff --git a/dashboard/backend/src/schemas/www/pages.ts b/dashboard/backend/src/schemas/www/pages.ts new file mode 100644 index 0000000..1eb8fd6 --- /dev/null +++ b/dashboard/backend/src/schemas/www/pages.ts @@ -0,0 +1,80 @@ +import { mysqlTable, bigint, varchar, tinyint, int, text, json, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const wwwPageSeo = mysqlTable('www_page_seo', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + pageType: varchar('page_type', { length: 20 }).notNull(), + entityId: bigint('entity_id', { mode: 'number', unsigned: true }), + metaTitle: varchar('meta_title', { length: 200 }).notNull().default(''), + metaDescription: varchar('meta_description', { length: 160 }).notNull().default(''), + metaKeywords: varchar('meta_keywords', { length: 300 }).notNull().default(''), + ogImageUrl: varchar('og_image_url', { length: 500 }).notNull().default(''), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwProductListSettings = mysqlTable('www_product_list_settings', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + heroTitleZh: varchar('hero_title_zh', { length: 100 }).notNull().default('产品中心'), + heroTitleEn: varchar('hero_title_en', { length: 100 }).notNull().default('Products'), + heroSubtitleZh: varchar('hero_subtitle_zh', { length: 200 }).notNull().default('探索高保真音频设备,寻找您的理想之声'), + heroSubtitleEn: varchar('hero_subtitle_en', { length: 200 }).notNull().default('Explore Hi-Fi audio devices, find your ideal sound'), + heroOverlineZh: varchar('hero_overline_zh', { length: 50 }).notNull().default('LUXSIN PRODUCTS'), + heroOverlineEn: varchar('hero_overline_en', { length: 50 }).notNull().default('LUXSIN PRODUCTS'), + heroBgUrl: varchar('hero_bg_url', { length: 500 }).notNull().default(''), + maxColumns: tinyint('max_columns').notNull().default(3), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwNewsListSettings = mysqlTable('www_news_list_settings', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + heroTitleZh: varchar('hero_title_zh', { length: 100 }).notNull().default('新闻中心'), + heroTitleEn: varchar('hero_title_en', { length: 100 }).notNull().default('News'), + heroSubtitleZh: varchar('hero_subtitle_zh', { length: 200 }).notNull().default('获取乐笙最新资讯与行业动态'), + heroSubtitleEn: varchar('hero_subtitle_en', { length: 200 }).notNull().default('Get the latest Luxsin news and industry insights'), + heroBgUrl: varchar('hero_bg_url', { length: 500 }).notNull().default(''), + pageSize: int('page_size').notNull().default(9), + paginationMode: varchar('pagination_mode', { length: 20 }).notNull().default('paginator'), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwAboutSettings = mysqlTable('www_about_settings', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + heroTitleZh: varchar('hero_title_zh', { length: 100 }).notNull().default('关于我们'), + heroTitleEn: varchar('hero_title_en', { length: 100 }).notNull().default('About Us'), + heroSubtitleZh: varchar('hero_subtitle_zh', { length: 200 }).notNull().default(''), + heroSubtitleEn: varchar('hero_subtitle_en', { length: 200 }).notNull().default(''), + heroBgUrl: varchar('hero_bg_url', { length: 500 }).notNull().default(''), + s1TitleZh: varchar('s1_title_zh', { length: 200 }).notNull().default(''), + s1TitleEn: varchar('s1_title_en', { length: 200 }).notNull().default(''), + s1ContentZh: text('s1_content_zh'), + s1ContentEn: text('s1_content_en'), + s1ImageUrl: varchar('s1_image_url', { length: 500 }).notNull().default(''), + s1CtaZh: varchar('s1_cta_zh', { length: 100 }).notNull().default(''), + s1CtaEn: varchar('s1_cta_en', { length: 100 }).notNull().default(''), + s1CtaUrl: varchar('s1_cta_url', { length: 500 }).notNull().default(''), + s2TitleZh: varchar('s2_title_zh', { length: 200 }).notNull().default(''), + s2TitleEn: varchar('s2_title_en', { length: 200 }).notNull().default(''), + s2Features: json('s2_features'), + s3TitleZh: varchar('s3_title_zh', { length: 200 }).notNull().default(''), + s3TitleEn: varchar('s3_title_en', { length: 200 }).notNull().default(''), + s3CtaZh: varchar('s3_cta_zh', { length: 100 }).notNull().default(''), + s3CtaEn: varchar('s3_cta_en', { length: 100 }).notNull().default(''), + s3CtaUrl: varchar('s3_cta_url', { length: 500 }).notNull().default(''), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwSupportSettings = mysqlTable('www_support_settings', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + heroTitleZh: varchar('hero_title_zh', { length: 100 }).notNull().default('技术支持'), + heroTitleEn: varchar('hero_title_en', { length: 100 }).notNull().default('Support'), + heroSubtitleZh: varchar('hero_subtitle_zh', { length: 200 }).notNull().default(''), + heroSubtitleEn: varchar('hero_subtitle_en', { length: 200 }).notNull().default(''), + heroBgUrl: varchar('hero_bg_url', { length: 500 }).notNull().default(''), + s1FaqCategories: json('s1_faq_categories'), + s2Downloads: json('s2_downloads'), + s3Contact: json('s3_contact'), + s4CtaZh: varchar('s4_cta_zh', { length: 100 }).notNull().default(''), + s4CtaEn: varchar('s4_cta_en', { length: 100 }).notNull().default(''), + s4CtaUrl: varchar('s4_cta_url', { length: 500 }).notNull().default(''), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); diff --git a/dashboard/backend/src/schemas/www/products.ts b/dashboard/backend/src/schemas/www/products.ts new file mode 100644 index 0000000..3ec69a3 --- /dev/null +++ b/dashboard/backend/src/schemas/www/products.ts @@ -0,0 +1,31 @@ +import { mysqlTable, bigint, varchar, tinyint, int, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const wwwProductSeries = mysqlTable('www_product_series', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + nameZh: varchar('name_zh', { length: 100 }).notNull(), + nameEn: varchar('name_en', { length: 100 }).notNull(), + overline: varchar('overline', { length: 50 }).notNull().default(''), + subtitleZh: varchar('subtitle_zh', { length: 200 }).notNull().default(''), + subtitleEn: varchar('subtitle_en', { length: 200 }).notNull().default(''), + coverUrl: varchar('cover_url', { length: 500 }).notNull().default(''), + sortOrder: int('sort_order').notNull().default(0), + isVisible: tinyint('is_visible').notNull().default(1), + createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwProducts = mysqlTable('www_products', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + seriesId: bigint('series_id', { mode: 'number', unsigned: true }).notNull(), + nameZh: varchar('name_zh', { length: 100 }).notNull(), + nameEn: varchar('name_en', { length: 100 }).notNull(), + slug: varchar('slug', { length: 100 }).notNull().unique(), + introZh: varchar('intro_zh', { length: 200 }).notNull().default(''), + introEn: varchar('intro_en', { length: 200 }).notNull().default(''), + coverUrl: varchar('cover_url', { length: 500 }).notNull().default(''), + sortOrder: int('sort_order').notNull().default(0), + isVisible: tinyint('is_visible').notNull().default(1), + createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); diff --git a/dashboard/backend/src/schemas/www/sections.ts b/dashboard/backend/src/schemas/www/sections.ts new file mode 100644 index 0000000..c2d030a --- /dev/null +++ b/dashboard/backend/src/schemas/www/sections.ts @@ -0,0 +1,55 @@ +import { mysqlTable, bigint, varchar, tinyint, int, text, decimal, json, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const wwwSectionBlocks = mysqlTable('www_section_blocks', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + pageType: varchar('page_type', { length: 20 }).notNull(), + productId: bigint('product_id', { mode: 'number', unsigned: true }), + layout: varchar('layout', { length: 30 }).notNull(), + theme: varchar('theme', { length: 10 }).notNull().default('light'), + bgType: varchar('bg_type', { length: 10 }).notNull().default('color'), + bgValue: varchar('bg_value', { length: 500 }).notNull().default(''), + overlayEnabled: tinyint('overlay_enabled').notNull().default(0), + overlayOpacity: decimal('overlay_opacity', { precision: 3, scale: 2 }).notNull().default('0.00'), + overlineZh: varchar('overline_zh', { length: 100 }).notNull().default(''), + overlineEn: varchar('overline_en', { length: 100 }).notNull().default(''), + titleZh: varchar('title_zh', { length: 200 }).notNull().default(''), + titleEn: varchar('title_en', { length: 200 }).notNull().default(''), + subtitleZh: varchar('subtitle_zh', { length: 300 }).notNull().default(''), + subtitleEn: varchar('subtitle_en', { length: 300 }).notNull().default(''), + contentZh: text('content_zh'), + contentEn: text('content_en'), + ctaPrimaryZh: varchar('cta_primary_zh', { length: 100 }).notNull().default(''), + ctaPrimaryEn: varchar('cta_primary_en', { length: 100 }).notNull().default(''), + ctaPrimaryUrl: varchar('cta_primary_url', { length: 500 }).notNull().default(''), + ctaSecondaryZh: varchar('cta_secondary_zh', { length: 100 }).notNull().default(''), + ctaSecondaryEn: varchar('cta_secondary_en', { length: 100 }).notNull().default(''), + ctaSecondaryUrl: varchar('cta_secondary_url', { length: 500 }).notNull().default(''), + mediaUrlZh: varchar('media_url_zh', { length: 500 }).notNull().default(''), + mediaUrlEn: varchar('media_url_en', { length: 500 }).notNull().default(''), + videoUrl: varchar('video_url', { length: 500 }).notNull().default(''), + config: json('config'), + isVisible: tinyint('is_visible').notNull().default(1), + sortOrder: int('sort_order').notNull().default(0), + createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: datetime('updated_at').notNull().default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`), +}); + +export const wwwSpecGroups = mysqlTable('www_spec_groups', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + sectionBlockId: bigint('section_block_id', { mode: 'number', unsigned: true }).notNull(), + titleZh: varchar('title_zh', { length: 100 }).notNull(), + titleEn: varchar('title_en', { length: 100 }).notNull(), + sortOrder: int('sort_order').notNull().default(0), + defaultVisible: int('default_visible').notNull().default(5), +}); + +export const wwwSpecItems = mysqlTable('www_spec_items', { + id: bigint('id', { mode: 'number', unsigned: true }).primaryKey().autoincrement(), + specGroupId: bigint('spec_group_id', { mode: 'number', unsigned: true }).notNull(), + nameZh: varchar('name_zh', { length: 100 }).notNull(), + nameEn: varchar('name_en', { length: 100 }).notNull(), + valueZh: varchar('value_zh', { length: 500 }).notNull(), + valueEn: varchar('value_en', { length: 500 }).notNull(), + sortOrder: int('sort_order').notNull().default(0), +}); diff --git a/db/01_global_settings.sql b/db/01_global_settings.sql new file mode 100644 index 0000000..7de34d6 --- /dev/null +++ b/db/01_global_settings.sql @@ -0,0 +1,95 @@ +-- ============================================================ +-- 01_global_settings.sql +-- 全局配置:站点基础信息、页脚、社交媒体、SEO 默认值、导航栏 +-- 对应 proposal.md 第 1、2 节 +-- ============================================================ + +-- ----------------------------------------------------------- +-- 1.1 站点基础信息(单行记录) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_site_settings` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `site_title` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '站点标题,全站
...
", + "content_en": "...
", + "status": "published", + "published_at": "2026-07-30T10:00:00Z" +} +``` + +--- + +### 5.2 文章推荐 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/news/:id/recommendations` | 获取文章的推荐列表 | +| PUT | `/api/www/news/:id/recommendations` | 更新推荐文章(全量覆盖,最多 3 篇) | + +**PUT Body:** + +```json +{ "recommended_ids": [5, 12, 8] } +``` + +--- + +## 6. 素材库 `/api/www/media` + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/media?category=product&tag=DMP-A8` | 获取素材列表(分页/筛选/搜索) | +| POST | `/api/www/media/upload` | 上传素材(multipart/form-data) | +| POST | `/api/www/media/upload-batch` | 批量上传 | +| GET | `/api/www/media/:id` | 获取素材详情 | +| PUT | `/api/www/media/:id` | 更新素材信息(文件名/分类/标签) | +| DELETE | `/api/www/media/:id` | 软删除素材(检查引用计数) | + +**GET Query 参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| category | string | 分类筛选: product / banner / news / brand / video / document / other | +| tag | string | 按标签名筛选 | +| keyword | string | 按文件名模糊搜索 | +| file_type | string | 按类型筛选: image / video / file | + +**GET 响应 data 每项:** + +```json +{ + "id": 1, + "filename": "dmp-a8-front.jpg", + "file_path": "/uploads/images/products/2026/07/dmp-a8-front.jpg", + "file_type": "image", + "mime_type": "image/jpeg", + "file_size": 245760, + "width": 1200, + "height": 900, + "category": "product", + "thumbnail_url": "/uploads/thumbnails/dmp-a8-front_thumb.jpg", + "webp_url": "/uploads/thumbnails/dmp-a8-front.webp", + "ref_count": 3, + "tags": ["DMP-A8", "2026新品"], + "created_at": "2026-07-30T10:00:00Z" +} +``` + +### 6.1 素材标签 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/media/tags` | 获取所有标签列表 | +| POST | `/api/www/media/tags` | 创建标签 | +| PUT | `/api/www/media/tags/:id` | 重命名标签 | +| DELETE | `/api/www/media/tags/:id` | 删除标签(自动解除关联) | + +--- + +## 7. 翻译管理 `/api/www/i18n` + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/i18n?keyword=nav` | 获取词条列表(支持搜索) | +| PUT | `/api/www/i18n/:id` | 更新词条(仅改中文/英文值) | +| PUT | `/api/www/i18n/batch` | 批量更新词条 | + +**GET Query 参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| keyword | string | 按 key 或值模糊搜索 | + +**PUT 单条 Body:** + +```json +{ + "value_zh": "产品", + "value_en": "Products" +} +``` + +**PUT 批量 Body:** + +```json +[ + { "id": 1, "value_zh": "产品", "value_en": "Products" }, + { "id": 2, "value_zh": "新闻", "value_en": "News" } +] +``` + +--- + +## 8. 联系表单 `/api/www/contact` + +### 8.1 表单全局配置 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/contact/settings` | 获取表单配置 | +| PUT | `/api/www/contact/settings` | 更新表单配置 | + +### 8.2 表单字段 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/contact/fields` | 获取字段列表 | +| POST | `/api/www/contact/fields` | 新增字段 | +| PUT | `/api/www/contact/fields/:id` | 更新字段 | +| DELETE | `/api/www/contact/fields/:id` | 删除字段 | +| PATCH | `/api/www/contact/fields/sort` | 字段排序 | + +**POST Body:** + +```json +{ + "name_zh": "姓名", + "name_en": "Name", + "field_type": "text", + "is_required": true, + "placeholder_zh": "请输入您的姓名", + "placeholder_en": "Your name", + "options_json": null +} +``` + +### 8.3 提交记录 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/contact/submissions?is_read=0` | 获取提交记录列表 | +| GET | `/api/www/contact/submissions/:id` | 查看提交详情 | +| PATCH | `/api/www/contact/submissions/:id/read` | 标记为已读 | +| DELETE | `/api/www/contact/submissions/:id` | 删除提交记录 | + +**GET Query 参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| is_read | 0/1 | 筛选已读/未读 | +| start_date | date | 开始日期 | +| end_date | date | 结束日期 | + +--- + +## 9. 页面 SEO `/api/www/page-seo` + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/page-seo?page_type=home` | 获取页面 SEO 配置 | +| GET | `/api/www/page-seo?page_type=product_detail&entity_id=1` | 获取产品详情页 SEO | +| PUT | `/api/www/page-seo` | 创建/更新页面 SEO | + +**PUT Body:** + +```json +{ + "page_type": "product_detail", + "entity_id": 1, + "meta_title": "DMP-A8 - 旗舰数字流媒体播放器", + "meta_description": "乐笙 DMP-A8 旗舰数字流媒体播放器,支持 PCM 768kHz...", + "meta_keywords": "DMP-A8,流媒体,播放器,HiFi", + "og_image_url": "/uploads/products/dmp-a8-og.jpg" +} +``` + +--- + +## 附录:接口总览 + +| # | 模块 | 接口数 | URL 前缀 | +|---|------|--------|---------| +| 1 | 全局配置 | 8 | `/api/www/global/` | +| 2 | 导航管理 | 7 | `/api/www/nav/` | +| 3 | Section 区块 | 18 | `/api/www/sections/` + `/api/www/spec-*` | +| 4 | 页面配置 | 8 | `/api/www/page/` | +| 5 | 产品管理 | 12 | `/api/www/products/` + `/api/www/product/series/` | +| 6 | 新闻管理 | 7 | `/api/www/news/` | +| 7 | 素材库 | 11 | `/api/www/media/` | +| 8 | 翻译管理 | 3 | `/api/www/i18n/` | +| 9 | 联系表单 | 9 | `/api/www/contact/` | +| 10 | 页面 SEO | 3 | `/api/www/page-seo/` | +| | **合计** | **86** | | diff --git a/docs/dashboard/www-cms-menu.md b/docs/dashboard/www-cms-menu.md new file mode 100644 index 0000000..e4f656f --- /dev/null +++ b/docs/dashboard/www-cms-menu.md @@ -0,0 +1,234 @@ +# 官网 CMS 侧边栏菜单设计 + +> **版本**: v1.0 +> **关联文档**: [proposal.md](../www/proposal.md) +> **目标**: 定义 Dashboard 侧边栏中「官网管理」模块的菜单结构、路由规划及页面职责 + +--- + +## 1. 现有菜单结构 + +| order | 菜单 | 路由 | 说明 | +|-------|------|------|------| +| 1 | 首页 | `/home` | Dashboard 数据看板 | +| 2 | 耳机 | `/headphone` | 品牌管理 / 型号管理 | +| 3 | OTA 升级 | `/upgrade` | OTA / 目标设备 / 黑名单 | +| 4 | 分享码 | `/share-code` | 分享码日志 | +| 5 | 系统 | `/system` | 用户管理(隐藏,仅超管) | +| 6 | 工具箱 | `/toolbox` | Luxsin Controller | + +--- + +## 2. 新增「官网管理」菜单 + +在现有菜单之后新增一个顶级分组 **「官网管理」**,包含以下子菜单: + +``` +官网管理 (www) +├── 全局配置 (global) +│ ├── 站点信息 (basic) +│ ├── 页脚配置 (footer) +│ ├── 社交链接 (social) +│ └── SEO 默认值 (seo) +├── 导航管理 (nav) +│ ├── 导航入口 (items) +│ └── 导航外观 (appearance) +├── 页面管理 (page) +│ ├── 首页区块 (home) +│ ├── 产品列表页 (product-list) +│ ├── 新闻列表页 (news-list) +│ ├── 关于我们 (about) +│ └── 技术支持 (support) +├── 产品管理 (product) +│ ├── 产品系列 (series) +│ └── 产品列表 (list) +├── 新闻管理 (news) +│ └── 新闻文章 (articles) +├── 素材库 (media) +├── 翻译管理 (i18n) +├── 联系表单 (contact) +│ ├── 表单配置 (settings) +│ └── 提交记录 (submissions) +└── 页面 SEO (page-seo) +``` + +--- + +## 3. 路由与菜单详细定义 + +### 3.1 路由命名规范 + +- 顶级分组: `www` +- 子菜单: `www_{module}` 格式,用 `_` 分隔层级 +- 路由路径: `/www/{module}` 或 `/www/{module}/{sub}` +- i18n Key: `route.www_{name}` + +### 3.2 完整路由表 + +| route name | path | icon | order | keepAlive | 说明 | +|---|---|---|---|---|---| +| **www** | `/www` | `mdi:globe-model` | 7 | — | 顶级分组 | +| www_global | `/www/global` | `mdi:application-cog-outline` | 1 | — | 全局配置(分组) | +| www_global_basic | `/www/global/basic` | `mdi:information-outline` | 1 | ✓ | 站点标题 / Favicon / Logo | +| www_global_footer | `/www/global/footer` | `mdi:page-layout-footer` | 2 | ✓ | 公司名称 / 地址 / 备案信息 | +| www_global_social | `/www/global/social` | `mdi:share-variant-outline` | 3 | ✓ | 微信 / 微博 / B站 / YouTube 等 | +| www_global_seo | `/www/global/seo` | `mdi:magnify` | 4 | ✓ | 全站默认 Meta / OG 图片 | +| www_nav | `/www/nav` | `mdi:menu` | 2 | — | 导航管理(分组) | +| www_nav_items | `/www/nav/items` | `mdi:format-list-bulleted` | 1 | ✓ | 入口增删排序 / 显示形式 | +| www_nav_appearance | `/www/nav/appearance` | `mdi:palette-outline` | 2 | ✓ | 首页/非首页导航风格 | +| www_page | `/www/page` | `mdi:file-document-outline` | 3 | — | 页面管理(分组) | +| www_page_home | `/www/page/home` | `mdi:home-outline` | 1 | ✓ | 首页 Section 区块配置 | +| www_page_product-list | `/www/page/product-list` | `mdi:format-list-bulleted-square` | 2 | ✓ | 产品列表 Page Hero / 列数 | +| www_page_news-list | `/www/page/news-list` | `mdi:newspaper-variant-outline` | 3 | ✓ | 新闻列表 Page Hero / 分页 | +| www_page_about | `/www/page/about` | `mdi:account-group-outline` | 4 | ✓ | 关于我们文本/图片/CTA | +| www_page_support | `/www/page/support` | `mdi:lifebuoy` | 5 | ✓ | 技术支持 FAQ/下载/联系 | +| www_product | `/www/product` | `mdi:package-variant-closed` | 4 | — | 产品管理(分组) | +| www_product_series | `/www/product/series` | `mdi:layers-outline` | 1 | ✓ | 产品系列 CRUD | +| www_product_list | `/www/product/list` | `mdi:view-list-outline` | 2 | ✓ | 产品 CRUD + Section + 参数规格 | +| www_news | `/www/news` | `mdi:newspaper-variant-multiple-outline` | 5 | — | 新闻管理(分组) | +| www_news_articles | `/www/news/articles` | `mdi:article-outline` | 1 | ✓ | 新闻文章 CRUD | +| www_media | `/www/media` | `mdi:image-multiple-outline` | 6 | ✓ | 素材库(独立页面) | +| www_i18n | `/www/i18n` | `mdi:translate` | 7 | ✓ | 翻译词条管理(独立页面) | +| www_contact | `/www/contact` | `mdi:email-outline` | 8 | — | 联系表单(分组) | +| www_contact_settings | `/www/contact/settings` | `mdi:form-textbox` | 1 | ✓ | 表单字段/全局配置 | +| www_contact_submissions | `/www/contact/submissions` | `mdi:inbox-outline` | 2 | ✓ | 提交记录列表 | +| www_page-seo | `/www/page-seo` | `mdi:search-web` | 9 | ✓ | 页面级 SEO 覆盖(独立页面) | + +--- + +## 4. 页面职责说明 + +### 4.1 全局配置 + +| 子页面 | 对应 proposal | 对应表 | 页面类型 | +|--------|-------------|--------|---------| +| 站点信息 | §1.1 | `www_site_settings` | 单表单(直接编辑保存) | +| 页脚配置 | §1.2 | `www_footer_settings` | 单表单 | +| 社交链接 | §1.3 | `www_social_links` | 列表(增删改排序) | +| SEO 默认值 | §1.4 | `www_seo_defaults` | 单表单 | + +### 4.2 导航管理 + +| 子页面 | 对应 proposal | 对应表 | 页面类型 | +|--------|-------------|--------|---------| +| 导航入口 | §2.1 | `www_nav_items` | 列表(拖拽排序/增删改) | +| 导航外观 | §2.3 | `www_nav_appearance` | 单表单 | + +### 4.3 页面管理 + +| 子页面 | 对应 proposal | 对应表 | 页面类型 | +|--------|-------------|--------|---------| +| 首页区块 | §3.1 | `www_section_blocks` (page_type=homepage) | Section 编辑器(拖拽排序/增删/配置面板) | +| 产品列表页 | §3.3 | `www_product_list_settings` | 单表单 | +| 新闻列表页 | §3.4 | `www_news_list_settings` | 单表单 | +| 关于我们 | §3.6 | `www_about_settings` | 单表单(多 Section 展开编辑) | +| 技术支持 | §3.7 | `www_support_settings` | 单表单(多 Section 展开编辑) | + +### 4.4 产品管理 + +| 子页面 | 对应 proposal | 对应表 | 页面类型 | +|--------|-------------|--------|---------| +| 产品系列 | §4.1 | `www_product_series` | 列表 CRUD | +| 产品列表 | §4.2 §4.3 | `www_products` + `www_section_blocks` + `www_spec_groups` + `www_spec_items` | 列表 CRUD → 详情页编辑(Section + 参数规格) | + +### 4.5 新闻管理 + +| 子页面 | 对应 proposal | 对应表 | 页面类型 | +|--------|-------------|--------|---------| +| 新闻文章 | §5.1 §5.2 | `www_news_articles` + `www_news_recommendations` | 列表 CRUD + 富文本编辑器 | + +### 4.6 素材库 + +| 子页面 | 对应 proposal | 对应表 | 页面类型 | +|--------|-------------|--------|---------| +| 素材库 | §6 | `www_media` + `www_media_tags` + `www_media_tag_map` | 文件管理(上传/分类/标签/搜索/预览) | + +### 4.7 翻译管理 + +| 子页面 | 对应 proposal | 对应表 | 页面类型 | +|--------|-------------|--------|---------| +| 翻译词条 | §8.3 | `www_i18n_entries` | 列表(搜索/筛选/编辑) | + +### 4.8 联系表单 + +| 子页面 | 对应 proposal | 对应表 | 页面类型 | +|--------|-------------|--------|---------| +| 表单配置 | §9.1 §9.2 | `www_form_settings` + `www_form_fields` | 单表单 + 字段列表(拖拽排序) | +| 提交记录 | §9 | `www_form_submissions` | 列表(查看/标记已读/删除) | + +### 4.9 页面 SEO + +| 子页面 | 对应 proposal | 对应表 | 页面类型 | +|--------|-------------|--------|---------| +| 页面 SEO | §7.1 | `www_page_seo` | 列表(按页面类型筛选/编辑) | + +--- + +## 5. 优先级与开发顺序 + +### P0 - 核心功能(一期必须) + +| 序号 | 菜单 | 理由 | +|------|------|------| +| 1 | 产品系列 + 产品列表 | 官网核心内容,其他页面依赖产品数据 | +| 2 | 首页区块 | 官网首屏,营销关键页面 | +| 3 | 全局配置 | 站点基础信息,所有页面依赖 | +| 4 | 导航管理 | 站点导航骨架 | +| 5 | 素材库 | 图片和文件管理,所有页面依赖 | +| 6 | 翻译管理 | 双语支持基础 | + +### P1 - 重要功能(一期完整) + +| 序号 | 菜单 | 理由 | +|------|------|------| +| 7 | 新闻文章 | 内容营销 | +| 8 | 产品列表页 / 新闻列表页 | 列表展示配置 | +| 9 | 关于我们 / 技术支持 | 品牌展示页面 | +| 10 | 页面 SEO | 搜索引擎优化 | + +### P2 - 次要功能(可后置) + +| 序号 | 菜单 | 理由 | +|------|------|------| +| 11 | 联系表单 | 用户互动,优先级较低 | + +--- + +## 6. 完整侧边栏菜单(最终视图) + +``` +📊 首页 /home +🎧 耳机 /headphone +📦 OTA 升级 /upgrade +🔗 分享码 /share-code +───────────────────────────── +🌐 官网管理 /www + 📋 全局配置 /www/global + · 站点信息 /www/global/basic + · 页脚配置 /www/global/footer + · 社交链接 /www/global/social + · SEO 默认值 /www/global/seo + 📍 导航管理 /www/nav + · 导航入口 /www/nav/items + · 导航外观 /www/nav/appearance + 📄 页面管理 /www/page + · 首页区块 /www/page/home + · 产品列表页 /www/page/product-list + · 新闻列表页 /www/page/news-list + · 关于我们 /www/page/about + · 技术支持 /www/page/support + 📦 产品管理 /www/product + · 产品系列 /www/product/series + · 产品列表 /www/product/list + 📰 新闻管理 /www/news + · 新闻文章 /www/news/articles + 🖼️ 素材库 /www/media + 🌍 翻译管理 /www/i18n + ✉️ 联系表单 /www/contact + · 表单配置 /www/contact/settings + · 提交记录 /www/contact/submissions + 🔍 页面 SEO /www/page-seo +───────────────────────────── +⚙️ 系统 /system(隐藏) +🧰 工具箱 /toolbox +``` diff --git a/docs/www/proposal.md b/docs/www/proposal.md index 89d1c1f..961c6be 100644 --- a/docs/www/proposal.md +++ b/docs/www/proposal.md @@ -172,6 +172,7 @@ Admin 可对导航栏中的每个入口进行以下配置: | 特性列表 | 子表单 | 布局 5(Feature Grid)专用:图标 + 标题 + 描述 | | 步骤列表 | 子表单 | 布局 8(Scroll Narrative)专用:步骤标题 + 描述 + 配图 | | 视频地址 | 素材选择 | 布局 7(Video Showcase)专用 | +| 参数分组列表 | 子表单 | 布局 10(Spec Table)专用:分组标题 + 参数项列表(详见 4.3 节) | > 不同布局模式下,仅显示该布局所需的配置字段,无关字段自动隐藏。 @@ -290,6 +291,61 @@ Admin 可对导航栏中的每个入口进行以下配置: - 未配置任何 Section 的产品,详情页仅显示 Page Hero(标题为产品名)+ 产品简介 - 建议为每个产品至少配置 3-5 个 Section 区块 +### 4.3 产品参数规格(Spec Table 布局) + +参数规格作为一种 **Section 布局类型**(布局 10),在产品详情页中与其他 Section 一起拖拽排序、显示/隐藏。 + +**前端展示效果:** + +- 每个分组以卡片形式展示,左侧为分组标题(如“基本规格”“输入 / 输出”) +- 卡片内以「标签: 值」列表形式排列参数项,细线分隔 +- 每个分组默认显示前 N 项,超出部分收起,显示“查看全部参数 ∨”按钮展开 +- 展开后按钮变为“收起参数 ∧” + +**后台配置结构:** + +每个产品可独立配置多个参数分组,每个分组包含多个参数项: + +| 层级 | 配置项 | 类型 | 必填 | 说明 | +|------|--------|------|------|------| +| 分组 | 分组标题(中/英) | 文本 | 是 | 如“基本规格”“输入 / 输出”“XLR 输出音频特性” | +| 分组 | 排列顺序 | 数字排序 | 否 | 分组在产品页中的显示顺序 | +| 分组 | 默认显示数量 | 数字 | 否 | 收起状态下默认显示前 N 项,默认 5 | +| 参数项 | 参数名称(中/英) | 文本 | 是 | 如“信噪比”“尺寸”“重量” | +| 参数项 | 参数值(中/英) | 文本 | 是 | 如“≥129.5dB”“236mm × 236.8mm × 64mm” | +| 参数项 | 排列顺序 | 数字排序 | 否 | 参数在分组内的显示顺序 | + +**配置示例:** + +``` +产品:Luxsin X8 +├── 分组 1:基本规格(默认显示 5 项) +│ ├── 型号: Luxsin X8 +│ ├── 尺寸: 236mm (长) × 236.8mm (宽) × 64mm (高) +│ ├── 重量: 2750g +│ ├── 电源输入: AC 100–120V/220–240V–50/60Hz +│ └── 屏幕: 4英寸 LCD (480×960) 触摸屏 +├── 分组 2:输入 / 输出(默认显示 5 项) +│ ├── USB-A 输入: 用于本地固件升级 +│ ├── USB-B 输入: 最高支持 PCM 768kHz/32bit;DSD512(Native) +│ ├── USB-C 输入: 最高支持 PCM 768kHz/32bit;DSD512(Native) +│ ├── IIS 输入: 最高支持 PCM 768kHz/32bit;DSD512(Native) +│ └── 同轴输入: 最高支持 PCM 192kHz/24bit +└── 分组 3:XLR 输出音频特性(默认显示 5 项) + ├── 信噪比: ≥129.5dB + ├── 总谐波失真: <−121.8dB @不加权 + ├── 声道分离度: ≥133dB (1kHz@200kΩ) + ├── 底噪: <1.1uVrms + └── 线路输出电平: 4.2Vrms (1kHz@200kΩ) +``` + +**Admin 端操作:** + +- 在产品详情页 Section 配置中,添加布局类型为「参数规格」的区块 +- 区块内可添加多个分组,每个分组内可添加多个参数项 +- 分组和参数项均支持拖拽排序 +- 若参数项≤默认显示数量,前端不显示“查看全部参数”按钮 + --- ## 5. 新闻管理