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 '站点标题,全站 后缀', + `favicon_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Favicon 素材路径', + `logo_dark_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Logo 深色版(浅色区块上用)', + `logo_light_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Logo 浅色版(深色区块上用)', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='站点基础信息(单行)'; + +-- ----------------------------------------------------------- +-- 1.2 页脚配置(单行记录) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_footer_settings` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `company_name` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '公司名称', + `company_address` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '公司地址', + `contact_phone` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '联系电话', + `contact_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '联系邮箱', + `copyright_text` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '版权信息', + `icp_number` VARCHAR(50) NOT NULL DEFAULT '' COMMENT 'ICP 备案号', + `police_number` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '公安备案号', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='页脚配置(单行)'; + +-- ----------------------------------------------------------- +-- 1.3 社交媒体链接 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_social_links` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `platform` VARCHAR(30) NOT NULL COMMENT '平台标识: wechat / weibo / bilibili / youtube / facebook / instagram', + `url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '主页链接(微信公众号时为空)', + `qrcode_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '二维码图片路径(仅微信用)', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_platform` (`platform`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='社交媒体链接'; + +-- ----------------------------------------------------------- +-- 1.4 全站 SEO 默认值(单行记录) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_seo_defaults` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `meta_description` VARCHAR(160) NOT NULL DEFAULT '' COMMENT '默认 Meta Description', + `meta_keywords` VARCHAR(300) NOT NULL DEFAULT '' COMMENT '默认 Meta Keywords(逗号分隔)', + `og_image_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '默认 OG 图片(1200×630px)', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='全站 SEO 默认值(单行)'; + +-- ----------------------------------------------------------- +-- 2.1 导航入口 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_nav_items` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name_zh` VARCHAR(50) NOT NULL COMMENT '入口名称(中文)', + `name_en` VARCHAR(50) NOT NULL COMMENT '入口名称(英文)', + `link` VARCHAR(500) NOT NULL COMMENT '链接地址(站内路由或外部 URL)', + `is_visible` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否显示', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排列顺序', + `display_mode` VARCHAR(20) NOT NULL DEFAULT 'text' COMMENT '显示形式: text / icon_text / image / image_text', + `icon_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '图标名(display_mode 含 icon 时)', + `image_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '图片路径(display_mode 含 image 时)', + `open_new_tab` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否新窗口打开', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='导航栏入口'; + +-- ----------------------------------------------------------- +-- 2.3 导航栏外观配置(单行记录) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_nav_appearance` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `homepage_style` VARCHAR(20) NOT NULL DEFAULT 'transparent' COMMENT '首页导航风格: transparent / glass', + `non_homepage_style` VARCHAR(20) NOT NULL DEFAULT 'transparent' COMMENT '非首页导航风格: transparent / glass', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='导航栏外观配置(单行)'; diff --git a/db/02_sections.sql b/db/02_sections.sql new file mode 100644 index 0000000..eb46505 --- /dev/null +++ b/db/02_sections.sql @@ -0,0 +1,108 @@ +-- ============================================================ +-- 02_sections.sql +-- 页面区块系统:Section 区块、特性列表、步骤列表、数据指标、参数规格 +-- 对应 proposal.md 第 3.1、4.3 节 +-- ============================================================ + +-- ----------------------------------------------------------- +-- Section 区块(通用,支持多种页面类型) +-- page_type: homepage / product / about / support +-- 首页和产品详情页可自由增删区块;关于我们/技术支持为固定布局 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_section_blocks` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `page_type` VARCHAR(20) NOT NULL COMMENT '所属页面: homepage / product / about / support', + `product_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联产品 ID(page_type=product 时)', + `layout` VARCHAR(30) NOT NULL COMMENT '布局类型: hero_split / hero_center / image_text / two_column / feature_grid / spec_showcase / video_showcase / scroll_narrative / cta_banner / spec_table', + `theme` VARCHAR(10) NOT NULL DEFAULT 'light' COMMENT '主题: light / dark', + `bg_type` VARCHAR(10) NOT NULL DEFAULT 'color' COMMENT '背景类型: color / image / video', + `bg_value` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '背景值(颜色值 或 素材路径)', + `overlay_enabled` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用遮罩层', + `overlay_opacity` DECIMAL(3,2) NOT NULL DEFAULT 0.00 COMMENT '遮罩透明度 0~1', + `overline_zh` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Overline 文本(中文)', + `overline_en` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Overline 文本(英文)', + `title_zh` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标题(中文)', + `title_en` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标题(英文)', + `subtitle_zh` VARCHAR(300) NOT NULL DEFAULT '' COMMENT '副标题(中文)', + `subtitle_en` VARCHAR(300) NOT NULL DEFAULT '' COMMENT '副标题(英文)', + `content_zh` TEXT COMMENT '正文内容(中文,富文本)', + `content_en` TEXT COMMENT '正文内容(英文,富文本)', + `cta_primary_zh` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '主 CTA 文案(中文)', + `cta_primary_en` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '主 CTA 文案(英文)', + `cta_primary_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '主 CTA 链接', + `cta_secondary_zh` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '次 CTA 文案(中文)', + `cta_secondary_en` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '次 CTA 文案(英文)', + `cta_secondary_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '次 CTA 链接', + `media_url_zh` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '图片/媒体路径(中文)', + `media_url_en` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '图片/媒体路径(英文)', + `video_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '视频地址(Video Showcase 专用)', + `config` JSON COMMENT '布局扩展配置(JSON),Feature Grid / Steps / Data Metrics 等', + `is_visible` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否显示', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序(同页面内)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_page_type` (`page_type`, `sort_order`), + KEY `idx_product_id` (`product_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='页面 Section 区块'; + +-- ----------------------------------------------------------- +-- config 字段 JSON 结构说明(不在数据库中约束,应用层校验) +-- +-- Feature Grid (layout=feature_grid): +-- { +-- "features": [ +-- { "icon": "...", "title_zh": "...", "title_en": "...", +-- "desc_zh": "...", "desc_en": "..." } +-- ] +-- } +-- +-- Data Metrics (layout=spec_showcase): +-- { +-- "metrics": [ +-- { "value": "≥129.5", "unit": "dB", "label_zh": "信噪比", "label_en": "SNR" } +-- ] +-- } +-- +-- Steps (layout=scroll_narrative): +-- { +-- "steps": [ +-- { "title_zh": "...", "title_en": "...", +-- "desc_zh": "...", "desc_en": "...", +-- "media_url": "..." } +-- ] +-- } +-- ----------------------------------------------------------- + +-- ----------------------------------------------------------- +-- 4.3 产品参数规格(Spec Table 布局子表) +-- 参数分组,挂载在 section_blocks 上 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_spec_groups` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `section_block_id` BIGINT UNSIGNED NOT NULL COMMENT '所属 Section 区块 ID', + `title_zh` VARCHAR(100) NOT NULL COMMENT '分组标题(中文)', + `title_en` VARCHAR(100) NOT NULL COMMENT '分组标题(英文)', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '分组排序', + `default_visible` INT NOT NULL DEFAULT 5 COMMENT '收起状态下默认显示项数', + PRIMARY KEY (`id`), + KEY `idx_section_block` (`section_block_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='产品参数规格分组'; + +-- ----------------------------------------------------------- +-- 参数项,挂载在 spec_groups 上 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_spec_items` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `spec_group_id` BIGINT UNSIGNED NOT NULL COMMENT '所属参数分组 ID', + `name_zh` VARCHAR(100) NOT NULL COMMENT '参数名称(中文)', + `name_en` VARCHAR(100) NOT NULL COMMENT '参数名称(英文)', + `value_zh` VARCHAR(500) NOT NULL COMMENT '参数值(中文)', + `value_en` VARCHAR(500) NOT NULL COMMENT '参数值(英文)', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '参数排序', + PRIMARY KEY (`id`), + KEY `idx_spec_group` (`spec_group_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='产品参数规格项'; diff --git a/db/03_products.sql b/db/03_products.sql new file mode 100644 index 0000000..db5c2e8 --- /dev/null +++ b/db/03_products.sql @@ -0,0 +1,47 @@ +-- ============================================================ +-- 03_products.sql +-- 产品管理:产品系列、产品 CRUD +-- 对应 proposal.md 第 4.1、4.2 节 +-- ============================================================ + +-- ----------------------------------------------------------- +-- 4.1 产品系列 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_product_series` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name_zh` VARCHAR(100) NOT NULL COMMENT '系列名称(中文)', + `name_en` VARCHAR(100) NOT NULL COMMENT '系列名称(英文)', + `overline` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '系列英文名(Overline),如 DMP SERIES', + `subtitle_zh` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '系列副标题(中文)', + `subtitle_en` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '系列副标题(英文)', + `cover_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '系列封面图路径', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排列顺序', + `is_visible` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否显示', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_sort` (`sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='产品系列'; + +-- ----------------------------------------------------------- +-- 4.2 产品 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_products` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `series_id` BIGINT UNSIGNED NOT NULL COMMENT '所属产品系列 ID', + `name_zh` VARCHAR(100) NOT NULL COMMENT '产品名称(中文)', + `name_en` VARCHAR(100) NOT NULL COMMENT '产品名称(英文)', + `slug` VARCHAR(100) NOT NULL COMMENT 'URL 路径标识,如 dmp-a8', + `intro_zh` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '产品简介(中文,≤50字)', + `intro_en` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '产品简介(英文)', + `cover_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '产品封面图路径(4:3)', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '同系列内排序', + `is_visible` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否显示', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_slug` (`slug`), + KEY `idx_series` (`series_id`, `sort_order`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='产品'; diff --git a/db/04_news.sql b/db/04_news.sql new file mode 100644 index 0000000..11b6237 --- /dev/null +++ b/db/04_news.sql @@ -0,0 +1,42 @@ +-- ============================================================ +-- 04_news.sql +-- 新闻管理:文章 CRUD、相关推荐 +-- 对应 proposal.md 第 5 节 +-- ============================================================ + +-- ----------------------------------------------------------- +-- 5.1 新闻文章 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_news_articles` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `title_zh` VARCHAR(200) NOT NULL COMMENT '文章标题(中文)', + `title_en` VARCHAR(200) NOT NULL COMMENT '文章标题(英文)', + `slug` VARCHAR(200) NOT NULL COMMENT 'URL 路径标识,如 new-product-launch', + `summary_zh` VARCHAR(300) NOT NULL DEFAULT '' COMMENT '文章摘要(中文,≤100字)', + `summary_en` VARCHAR(300) NOT NULL DEFAULT '' COMMENT '文章摘要(英文)', + `cover_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '封面图路径(16:9)', + `content_zh` MEDIUMTEXT COMMENT '正文内容(中文,富文本)', + `content_en` MEDIUMTEXT COMMENT '正文内容(英文,富文本)', + `status` VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT '发布状态: draft / published / offline', + `published_at` DATETIME DEFAULT NULL COMMENT '发布时间(可为未来时间,定时发布)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_slug` (`slug`), + KEY `idx_status` (`status`, `published_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='新闻文章'; + +-- ----------------------------------------------------------- +-- 5.2 文章相关推荐(手动指定,最多 3 篇) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_news_recommendations` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `article_id` BIGINT UNSIGNED NOT NULL COMMENT '文章 ID', + `recommended_id` BIGINT UNSIGNED NOT NULL COMMENT '推荐文章 ID', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '推荐排序', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_article_recommended` (`article_id`, `recommended_id`), + KEY `idx_article` (`article_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='文章手动推荐关联'; diff --git a/db/05_media.sql b/db/05_media.sql new file mode 100644 index 0000000..30ce529 --- /dev/null +++ b/db/05_media.sql @@ -0,0 +1,53 @@ +-- ============================================================ +-- 05_media.sql +-- 素材库:媒体资源、标签 +-- 对应 proposal.md 第 6 节 +-- ============================================================ + +-- ----------------------------------------------------------- +-- 6.2 素材库 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_media` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `filename` VARCHAR(255) NOT NULL COMMENT '原始文件名', + `file_path` VARCHAR(500) NOT NULL COMMENT '存储路径(相对 uploads/)', + `file_type` VARCHAR(10) NOT NULL COMMENT '类型: image / video / file', + `mime_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT 'MIME 类型', + `file_size` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '文件大小(字节)', + `width` INT DEFAULT NULL COMMENT '图片/视频宽度(px)', + `height` INT DEFAULT NULL COMMENT '图片/视频高度(px)', + `category` VARCHAR(20) NOT NULL DEFAULT 'other' COMMENT '分类: product / banner / news / brand / video / document / other', + `thumbnail_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '缩略图路径(300px 宽)', + `webp_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'WebP 版本路径(图片自动生成)', + `ref_count` INT NOT NULL DEFAULT 0 COMMENT '引用计数', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `deleted_at` DATETIME DEFAULT NULL COMMENT '软删除时间', + PRIMARY KEY (`id`), + KEY `idx_category` (`category`), + KEY `idx_filename` (`filename`), + KEY `idx_created` (`created_at`), + KEY `idx_deleted` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='素材库'; + +-- ----------------------------------------------------------- +-- 素材标签 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_media_tags` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `tag_name` VARCHAR(50) NOT NULL COMMENT '标签名', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_tag_name` (`tag_name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='素材标签'; + +-- ----------------------------------------------------------- +-- 素材-标签多对多关联 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_media_tag_map` ( + `media_id` BIGINT UNSIGNED NOT NULL, + `tag_id` BIGINT UNSIGNED NOT NULL, + PRIMARY KEY (`media_id`, `tag_id`), + KEY `idx_tag` (`tag_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='素材-标签关联'; diff --git a/db/06_i18n.sql b/db/06_i18n.sql new file mode 100644 index 0000000..93751b1 --- /dev/null +++ b/db/06_i18n.sql @@ -0,0 +1,39 @@ +-- ============================================================ +-- 06_i18n.sql +-- 多语言管理:翻译词条 +-- 对应 proposal.md 第 8.3 节 +-- ============================================================ + +-- ----------------------------------------------------------- +-- 翻译词条(系统固定 UI 文案的 key-value 翻译) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_i18n_entries` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `dict_key` VARCHAR(100) NOT NULL COMMENT '词条 Key,如 nav.products', + `value_zh` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '中文值', + `value_en` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '英文值', + `description` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '词条说明(仅供 admin 参考)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_key` (`dict_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='翻译词条(i18n key-value)'; + +-- ----------------------------------------------------------- +-- 预置词条数据 +-- ----------------------------------------------------------- +INSERT INTO `www_i18n_entries` (`dict_key`, `value_zh`, `value_en`, `description`) VALUES + ('nav.products', '产品', 'Products', '导航入口'), + ('nav.news', '新闻', 'News', '导航入口'), + ('nav.about', '关于我们', 'About Us', '导航入口'), + ('nav.support', '技术支持', 'Support', '导航入口'), + ('btn.learn_more', '了解更多', 'Learn More', '通用按钮'), + ('btn.explore', '探索产品', 'Explore', '通用按钮'), + ('btn.contact', '联系我们', 'Contact Us', '通用按钮'), + ('btn.load_more', '加载更多', 'Load More', '列表页按钮'), + ('btn.view_all', '查看全部参数', 'View All Specs', '参数规格展开'), + ('btn.collapse', '收起参数', 'Collapse', '参数规格收起'), + ('label.read_more', '阅读更多', 'Read More', '新闻卡片链接'), + ('label.breadcrumb_home', '首页', 'Home', '面包屑'), + ('label.breadcrumb_products', '产品中心', 'Products', '面包屑'), + ('footer.copyright', '© {year} 乐笙. 保留所有权利.', '© {year} Luxsin. All rights reserved.', '页脚版权') +ON DUPLICATE KEY UPDATE `description` = VALUES(`description`); diff --git a/db/07_pages.sql b/db/07_pages.sql new file mode 100644 index 0000000..91acafc --- /dev/null +++ b/db/07_pages.sql @@ -0,0 +1,120 @@ +-- ============================================================ +-- 07_pages.sql +-- 页面级配置:SEO 覆盖、产品列表页、新闻列表页、关于我们、技术支持 +-- 对应 proposal.md 第 3.3~3.7、7 节 +-- ============================================================ + +-- ----------------------------------------------------------- +-- 7.1 页面级 SEO 配置 +-- page_type: home / products / product_detail / news / news_detail / about / support +-- 产品详情页和新闻详情页通过 entity_id 关联具体产品/文章 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_page_seo` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `page_type` VARCHAR(20) NOT NULL COMMENT '页面类型: home / products / product_detail / news / news_detail / about / support', + `entity_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联实体 ID(产品 ID 或文章 ID,通用页面时为 NULL)', + `meta_title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '自定义 <title>(空则自动生成)', + `meta_description` VARCHAR(160) NOT NULL DEFAULT '' COMMENT 'Meta Description(空则继承全局默认)', + `meta_keywords` VARCHAR(300) NOT NULL DEFAULT '' COMMENT 'Meta Keywords(空则继承全局默认)', + `og_image_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'OG 图片(空则继承全局默认)', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_page_entity` (`page_type`, `entity_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='页面级 SEO 配置'; + +-- ----------------------------------------------------------- +-- 3.3 产品列表页配置(单行) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_product_list_settings` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hero_title_zh` VARCHAR(100) NOT NULL DEFAULT '产品中心' COMMENT 'Page Hero 标题(中文)', + `hero_title_en` VARCHAR(100) NOT NULL DEFAULT 'Products' COMMENT 'Page Hero 标题(英文)', + `hero_subtitle_zh` VARCHAR(200) NOT NULL DEFAULT '探索高保真音频设备,寻找您的理想之声' COMMENT '副标题(中文)', + `hero_subtitle_en` VARCHAR(200) NOT NULL DEFAULT 'Explore Hi-Fi audio devices, find your ideal sound' COMMENT '副标题(英文)', + `hero_overline_zh` VARCHAR(50) NOT NULL DEFAULT 'LUXSIN PRODUCTS' COMMENT 'Overline(中文)', + `hero_overline_en` VARCHAR(50) NOT NULL DEFAULT 'LUXSIN PRODUCTS' COMMENT 'Overline(英文)', + `hero_bg_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Page Hero 背景图', + `max_columns` TINYINT NOT NULL DEFAULT 3 COMMENT '每行最大产品数: 2 / 3 / 4', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='产品列表页配置(单行)'; + +-- ----------------------------------------------------------- +-- 3.4 新闻列表页配置(单行) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_news_list_settings` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `hero_title_zh` VARCHAR(100) NOT NULL DEFAULT '新闻中心' COMMENT 'Page Hero 标题(中文)', + `hero_title_en` VARCHAR(100) NOT NULL DEFAULT 'News' COMMENT 'Page Hero 标题(英文)', + `hero_subtitle_zh` VARCHAR(200) NOT NULL DEFAULT '获取乐笙最新资讯与行业动态' COMMENT '副标题(中文)', + `hero_subtitle_en` VARCHAR(200) NOT NULL DEFAULT 'Get the latest Luxsin news and industry insights' COMMENT '副标题(英文)', + `hero_bg_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Page Hero 背景图', + `page_size` INT NOT NULL DEFAULT 9 COMMENT '每页显示数量', + `pagination_mode` VARCHAR(20) NOT NULL DEFAULT 'paginator' COMMENT '分页方式: paginator / load_more', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='新闻列表页配置(单行)'; + +-- ----------------------------------------------------------- +-- 3.6 关于我们页面配置(单行) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_about_settings` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + -- Page Hero + `hero_title_zh` VARCHAR(100) NOT NULL DEFAULT '关于我们' COMMENT 'Hero 标题(中文)', + `hero_title_en` VARCHAR(100) NOT NULL DEFAULT 'About Us' COMMENT 'Hero 标题(英文)', + `hero_subtitle_zh` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Hero 副标题(中文)', + `hero_subtitle_en` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Hero 副标题(英文)', + `hero_bg_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Hero 背景图', + -- Section 1: 品牌故事 + `s1_title_zh` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Section 1 标题(中文)', + `s1_title_en` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Section 1 标题(英文)', + `s1_content_zh` TEXT COMMENT 'Section 1 正文(中文,富文本)', + `s1_content_en` TEXT COMMENT 'Section 1 正文(英文,富文本)', + `s1_image_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Section 1 配图', + `s1_cta_zh` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Section 1 CTA(中文)', + `s1_cta_en` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Section 1 CTA(英文)', + `s1_cta_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Section 1 CTA 链接', + -- Section 2: 价值观/团队 + `s2_title_zh` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Section 2 标题(中文)', + `s2_title_en` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Section 2 标题(英文)', + `s2_features` JSON COMMENT '特性列表 JSON: [{icon, title_zh, title_en, desc_zh, desc_en}]', + -- Section 3: CTA + `s3_title_zh` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Section 3 标题(中文)', + `s3_title_en` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Section 3 标题(英文)', + `s3_cta_zh` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Section 3 CTA(中文)', + `s3_cta_en` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'Section 3 CTA(英文)', + `s3_cta_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Section 3 CTA 链接', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='关于我们页面配置(单行)'; + +-- ----------------------------------------------------------- +-- 3.7 技术支持页面配置(单行) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_support_settings` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + -- Page Hero + `hero_title_zh` VARCHAR(100) NOT NULL DEFAULT '技术支持' COMMENT 'Hero 标题(中文)', + `hero_title_en` VARCHAR(100) NOT NULL DEFAULT 'Support' COMMENT 'Hero 标题(英文)', + `hero_subtitle_zh` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Hero 副标题(中文)', + `hero_subtitle_en` VARCHAR(200) NOT NULL DEFAULT '' COMMENT 'Hero 副标题(英文)', + `hero_bg_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'Hero 背景图', + -- Section 1: 常见问题分类 + `s1_faq_categories` JSON COMMENT 'FAQ 分类 JSON: [{icon, name_zh, name_en, desc_zh, desc_en}]', + -- Section 2: 下载中心 + `s2_downloads` JSON COMMENT '下载资源 JSON: [{name_zh, name_en, file_url, version}]', + -- Section 3: 联系方式 + `s3_contact` JSON COMMENT '联系信息 JSON: [{title_zh, title_en, info_zh, info_en, image_url}]', + -- Section 4: CTA + `s4_cta_zh` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'CTA 按钮(中文)', + `s4_cta_en` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'CTA 按钮(英文)', + `s4_cta_url` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'CTA 链接', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='技术支持页面配置(单行)'; diff --git a/db/08_contact_form.sql b/db/08_contact_form.sql new file mode 100644 index 0000000..e29b008 --- /dev/null +++ b/db/08_contact_form.sql @@ -0,0 +1,64 @@ +-- ============================================================ +-- 08_contact_form.sql +-- 联系表单:全局配置、字段配置、提交记录 +-- 对应 proposal.md 第 9 节 +-- ============================================================ + +-- ----------------------------------------------------------- +-- 9.1 表单全局配置(单行) +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_form_settings` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `recipient_email` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '通知接收邮箱', + `success_message_zh` VARCHAR(300) NOT NULL DEFAULT '感谢您的留言,我们会尽快回复!' COMMENT '提交成功提示(中文)', + `success_message_en` VARCHAR(300) NOT NULL DEFAULT 'Thank you for your message. We will get back to you soon!' COMMENT '提交成功提示(英文)', + `error_message_zh` VARCHAR(300) NOT NULL DEFAULT '提交失败,请稍后重试。' COMMENT '提交失败提示(中文)', + `error_message_en` VARCHAR(300) NOT NULL DEFAULT 'Submission failed. Please try again later.' COMMENT '提交失败提示(英文)', + `cooldown_seconds` INT NOT NULL DEFAULT 60 COMMENT '防重复提交间隔(秒)', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='表单全局配置(单行)'; + +-- ----------------------------------------------------------- +-- 9.2 表单字段配置 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_form_fields` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `name_zh` VARCHAR(50) NOT NULL COMMENT '字段名称(中文)', + `name_en` VARCHAR(50) NOT NULL COMMENT '字段名称(英文)', + `field_type` VARCHAR(20) NOT NULL COMMENT '字段类型: text / textarea / email / phone / select', + `is_required` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否必填', + `placeholder_zh` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '占位文本(中文)', + `placeholder_en` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '占位文本(英文)', + `options_json` JSON DEFAULT NULL COMMENT '下拉选项 JSON: [{label_zh, label_en, value}](仅 select 类型)', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '字段排序', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='表单字段配置'; + +-- ----------------------------------------------------------- +-- 预置默认表单字段 +-- ----------------------------------------------------------- +INSERT INTO `www_form_fields` (`name_zh`, `name_en`, `field_type`, `is_required`, `placeholder_zh`, `placeholder_en`, `sort_order`) VALUES + ('姓名', 'Name', 'text', 1, '请输入您的姓名', 'Your name', 1), + ('邮箱', 'Email', 'email', 1, '请输入您的邮箱', 'Your email', 2), + ('电话', 'Phone', 'phone', 0, '请输入您的电话', 'Your phone', 3), + ('咨询内容', 'Message', 'textarea', 1, '请输入您的咨询内容', 'Your message', 4) +ON DUPLICATE KEY UPDATE `name_zh` = VALUES(`name_zh`); + +-- ----------------------------------------------------------- +-- 表单提交记录 +-- ----------------------------------------------------------- +CREATE TABLE IF NOT EXISTS `www_form_submissions` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `form_data` JSON NOT NULL COMMENT '提交数据 JSON: {field_name: value}', + `visitor_ip` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '访客 IP', + `user_agent` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '浏览器 UA', + `is_read` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已读(admin 端标记)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_created` (`created_at`), + KEY `idx_read` (`is_read`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='表单提交记录'; diff --git a/docs/dashboard/www-cms-api.md b/docs/dashboard/www-cms-api.md new file mode 100644 index 0000000..781850b --- /dev/null +++ b/docs/dashboard/www-cms-api.md @@ -0,0 +1,633 @@ +# 官网 CMS 后端接口文档 + +> **版本**: v1.0 +> **Base URL**: `/api/www` +> **关联文档**: [www-cms-menu.md](./www-cms-menu.md)、[proposal.md](../www/proposal.md) +> **认证**: 所有接口需登录(`Authorization: Bearer <token>`) + +--- + +## 通用约定 + +### 响应格式 + +```json +// 成功 +{ "code": 1, "msg": "success", "data": { ... } } +// 错误 +{ "code": 0, "msg": "error message", "data": null } +// 无数据 +{ "code": 2, "msg": "no data", "data": null } +``` + +### 分页参数(Query) + +| 参数 | 类型 | 默认 | 说明 | +|------|------|------|------| +| skip | int | 0 | 跳过条数 | +| limit | int | 20 | 每页条数(最大 1000) | + +### 分页响应 + +```json +{ "items": [...], "total": 100, "skip": 0, "limit": 20 } +``` + +### 排序参数 + +列表接口统一支持 `sort_order` 字段,前端通过批量 PATCH 更新排序值实现拖拽排序。 + +--- + +## 1. 全局配置 `/api/www/global` + +### 1.1 站点信息 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/global/site` | 获取站点信息 | +| PUT | `/api/www/global/site` | 更新站点信息 | + +**GET 响应 data:** + +```json +{ + "id": 1, + "site_title": "乐笙 Luxsin", + "favicon_url": "/uploads/brand/favicon.svg", + "logo_dark_url": "/uploads/brand/logo-dark.svg", + "logo_light_url": "/uploads/brand/logo-light.svg" +} +``` + +**PUT Body:** + +```json +{ + "site_title": "乐笙 Luxsin", + "favicon_url": "/uploads/brand/favicon.svg", + "logo_dark_url": "/uploads/brand/logo-dark.svg", + "logo_light_url": "/uploads/brand/logo-light.svg" +} +``` + +--- + +### 1.2 页脚配置 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/global/footer` | 获取页脚配置 | +| PUT | `/api/www/global/footer` | 更新页脚配置 | + +**PUT Body:** + +```json +{ + "company_name": "深圳市乐笙科技有限公司", + "company_address": "深圳市南山区...", + "contact_phone": "0755-12345678", + "contact_email": "info@luxsin.com", + "copyright_text": "© 2026 Luxsin. All rights reserved.", + "icp_number": "粤ICP备XXXXXXXX号", + "police_number": "" +} +``` + +--- + +### 1.3 社交链接 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/global/social` | 获取全部社交链接 | +| PUT | `/api/www/global/social` | 批量更新社交链接 | + +**GET 响应 data:** + +```json +[ + { "id": 1, "platform": "wechat", "url": "", "qrcode_url": "/uploads/brand/wechat-qr.png", "sort_order": 1 }, + { "id": 2, "platform": "youtube", "url": "https://youtube.com/@luxsin", "qrcode_url": "", "sort_order": 2 } +] +``` + +**PUT Body:**(全量覆盖) + +```json +[ + { "platform": "wechat", "url": "", "qrcode_url": "/uploads/brand/wechat-qr.png" }, + { "platform": "weibo", "url": "https://weibo.com/luxsin", "qrcode_url": "" }, + { "platform": "youtube", "url": "https://youtube.com/@luxsin", "qrcode_url": "" } +] +``` + +--- + +### 1.4 SEO 默认值 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/global/seo` | 获取 SEO 默认值 | +| PUT | `/api/www/global/seo` | 更新 SEO 默认值 | + +**PUT Body:** + +```json +{ + "meta_description": "Luxsin 乐笙科技 - 高保真音频设备", + "meta_keywords": "DAC,DMP,HiFi,音频,解码器", + "og_image_url": "/uploads/brand/og-default.jpg" +} +``` + +--- + +## 2. 导航管理 `/api/www/nav` + +### 2.1 导航入口 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/nav/items` | 获取导航入口列表(按 sort_order 排序) | +| POST | `/api/www/nav/items` | 新增导航入口 | +| PUT | `/api/www/nav/items/:id` | 更新导航入口 | +| DELETE | `/api/www/nav/items/:id` | 删除导航入口 | +| PATCH | `/api/www/nav/items/sort` | 批量更新排序 | + +**POST Body:** + +```json +{ + "name_zh": "产品", + "name_en": "Products", + "link": "/products", + "is_visible": true, + "display_mode": "text", + "icon_name": "", + "image_url": "", + "open_new_tab": false +} +``` + +**PATCH 排序 Body:** + +```json +[{ "id": 1, "sort_order": 1 }, { "id": 2, "sort_order": 2 }] +``` + +--- + +### 2.2 导航外观 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/nav/appearance` | 获取导航外观配置 | +| PUT | `/api/www/nav/appearance` | 更新导航外观配置 | + +**PUT Body:** + +```json +{ + "homepage_style": "transparent", + "non_homepage_style": "transparent" +} +``` + +--- + +## 3. 页面管理 `/api/www/sections` + +### 3.1 Section 区块 + +适用于首页(`page_type=home`)、产品详情页(`page_type=product`)、关于我们(`page_type=about`)、技术支持(`page_type=support`)。 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/sections?page_type=home` | 获取指定页面的 Section 列表 | +| GET | `/api/www/sections?page_type=product&product_id=1` | 获取产品详情页 Section | +| POST | `/api/www/sections` | 新增 Section | +| GET | `/api/www/sections/:id` | 获取 Section 详情(含子数据) | +| PUT | `/api/www/sections/:id` | 更新 Section | +| DELETE | `/api/www/sections/:id` | 删除 Section | +| PATCH | `/api/www/sections/sort` | 批量更新排序 | +| POST | `/api/www/sections/:id/duplicate` | 复制 Section | + +**GET 列表响应 data:** + +```json +{ + "items": [ + { + "id": 1, + "page_type": "home", + "layout": "hero_split", + "theme": "dark", + "title_zh": "聆听,本该如此", + "title_en": "Listening, Reimagined", + "is_visible": true, + "sort_order": 1 + } + ], + "total": 5 +} +``` + +**POST Body(新建 Section):** + +```json +{ + "page_type": "home", + "product_id": null, + "layout": "hero_split", + "theme": "dark", + "bg_type": "image", + "bg_value": "/uploads/banners/hero-bg.jpg", + "overlay_enabled": true, + "overlay_opacity": 0.3, + "overline_zh": "旗舰系列", + "overline_en": "FLAGSHIP SERIES", + "title_zh": "聆听,本该如此", + "title_en": "Listening, Reimagined", + "subtitle_zh": "", + "subtitle_en": "", + "content_zh": "", + "content_en": "", + "cta_primary_zh": "探索产品", + "cta_primary_en": "Explore", + "cta_primary_url": "/products", + "cta_secondary_zh": "", + "cta_secondary_en": "", + "cta_secondary_url": "", + "media_url_zh": "/uploads/products/dmp-a8.png", + "media_url_en": "/uploads/products/dmp-a8.png", + "video_url": "", + "config": {} +} +``` + +--- + +### 3.2 Spec Table 参数规格(Section 子资源) + +仅 `layout=spec_table` 的 Section 使用。 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/sections/:section_id/spec-groups` | 获取参数分组列表 | +| POST | `/api/www/sections/:section_id/spec-groups` | 新增参数分组 | +| PUT | `/api/www/sections/:section_id/spec-groups/:group_id` | 更新分组 | +| DELETE | `/api/www/sections/:section_id/spec-groups/:group_id` | 删除分组 | +| PATCH | `/api/www/sections/:section_id/spec-groups/sort` | 分组排序 | +| GET | `/api/www/spec-groups/:group_id/items` | 获取分组下的参数项 | +| POST | `/api/www/spec-groups/:group_id/items` | 新增参数项 | +| PUT | `/api/www/spec-items/:id` | 更新参数项 | +| DELETE | `/api/www/spec-items/:id` | 删除参数项 | +| PATCH | `/api/www/spec-groups/:group_id/items/sort` | 参数项排序 | + +**POST 新增分组 Body:** + +```json +{ + "title_zh": "基本规格", + "title_en": "Basic Specifications", + "default_visible": 5 +} +``` + +**POST 新增参数项 Body:** + +```json +{ + "name_zh": "信噪比", + "name_en": "SNR", + "value_zh": "≥129.5dB", + "value_en": "≥129.5dB" +} +``` + +--- + +### 3.3 产品列表页配置 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/page/product-list` | 获取配置 | +| PUT | `/api/www/page/product-list` | 更新配置 | + +### 3.4 新闻列表页配置 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/page/news-list` | 获取配置 | +| PUT | `/api/www/page/news-list` | 更新配置 | + +### 3.5 关于我们配置 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/page/about` | 获取配置 | +| PUT | `/api/www/page/about` | 更新配置 | + +### 3.6 技术支持配置 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/page/support` | 获取配置 | +| PUT | `/api/www/page/support` | 更新配置 | + +--- + +## 4. 产品管理 `/api/www/products` + +### 4.1 产品系列 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/product/series` | 获取系列列表 | +| POST | `/api/www/product/series` | 新增系列 | +| GET | `/api/www/product/series/:id` | 获取系列详情 | +| PUT | `/api/www/product/series/:id` | 更新系列 | +| DELETE | `/api/www/product/series/:id` | 删除系列(检查是否有关联产品) | +| PATCH | `/api/www/product/series/sort` | 批量排序 | + +**POST Body:** + +```json +{ + "name_zh": "DMP 流媒体系列", + "name_en": "DMP Streaming Series", + "overline": "DMP SERIES", + "subtitle_zh": "数字流媒体播放与系统控制中枢", + "subtitle_en": "Digital media player and system controller", + "cover_url": "/uploads/products/dmp-series-cover.jpg", + "is_visible": true +} +``` + +--- + +### 4.2 产品 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/products?series_id=1&is_visible=1` | 获取产品列表(支持筛选) | +| POST | `/api/www/products` | 新增产品 | +| GET | `/api/www/products/:id` | 获取产品详情 | +| PUT | `/api/www/products/:id` | 更新产品 | +| DELETE | `/api/www/products/:id` | 删除产品(级联删除 Section + 参数规格) | + +**GET 列表 Query 参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| series_id | int | 按系列筛选 | +| is_visible | 0/1 | 按显示状态筛选 | +| name | string | 按名称模糊搜索 | + +**POST Body:** + +```json +{ + "series_id": 1, + "name_zh": "DMP-A8", + "name_en": "DMP-A8", + "slug": "dmp-a8", + "intro_zh": "旗舰数字流媒体播放器", + "intro_en": "Flagship digital media player", + "cover_url": "/uploads/products/dmp-a8-cover.jpg", + "is_visible": true +} +``` + +--- + +## 5. 新闻管理 `/api/www/news` + +### 5.1 新闻文章 + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/api/www/news?status=published` | 获取文章列表 | +| POST | `/api/www/news` | 新增文章 | +| GET | `/api/www/news/:id` | 获取文章详情 | +| PUT | `/api/www/news/:id` | 更新文章 | +| DELETE | `/api/www/news/:id` | 删除文章 | + +**GET 列表 Query 参数:** + +| 参数 | 类型 | 说明 | +|------|------|------| +| status | string | 筛选: draft / published / offline | +| title | string | 按标题模糊搜索 | + +**POST Body:** + +```json +{ + "title_zh": "乐笙发布全新 DMP-A8", + "title_en": "Luxsin Launches the All-New DMP-A8", + "slug": "luxsin-dmp-a8-launch", + "summary_zh": "旗舰数字流媒体播放器正式发布...", + "summary_en": "The flagship digital media player is now available...", + "cover_url": "/uploads/news/dmp-a8-launch.jpg", + "content_zh": "<h2>产品亮点</h2><p>...</p>", + "content_en": "<h2>Highlights</h2><p>...</p>", + "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. 新闻管理