diff --git a/dashboard/backend/src/config/database.ts b/dashboard/backend/src/config/database.ts index 6fcf7f3..4499ad1 100644 --- a/dashboard/backend/src/config/database.ts +++ b/dashboard/backend/src/config/database.ts @@ -14,6 +14,15 @@ const pool = mysql.createPool({ connectionLimit: 10, }); +// drizzle 约定 DATETIME 以 UTC 字符串读写(mapToDriverValue 写 toISOString, +// mapFromDriverValue 按 `+Z` 解析),因此把会话时区固定为 UTC, +// 否则 MySQL 服务器本地时区(如 +08:00)写入的 CURRENT_TIMESTAMP 会被误读为 UTC,产生 8 小时偏差 +pool.on('connection', (conn) => { + // 事件回调收到的是底层回调式连接,类型定义缺失 promise(),运行时存在 + const callbackConn = conn as unknown as { promise(): { query(sql: string): Promise } }; + callbackConn.promise().query("SET time_zone = '+00:00'").catch(() => {}); +}); + export const db = drizzle(pool, { schema, mode: 'default' }); if (isDevelopment) { diff --git a/dashboard/backend/src/routes/site.ts b/dashboard/backend/src/routes/site.ts index 9d1586a..0023b26 100644 --- a/dashboard/backend/src/routes/site.ts +++ b/dashboard/backend/src/routes/site.ts @@ -1,5 +1,5 @@ import { Router, type Request, type Response } from 'express'; -import { eq, and } from 'drizzle-orm'; +import { eq, and, desc, count, like, isNull, inArray } from 'drizzle-orm'; import { db } from '../config/database.js'; import { wwwSiteSettings, @@ -14,6 +14,16 @@ import { wwwI18nEntries, wwwProductSeries, wwwProducts, + wwwNewsArticles, + wwwNewsRecommendations, + wwwProductListSettings, + wwwNewsListSettings, + wwwAboutSettings, + wwwSupportSettings, + wwwPageSeo, + wwwFormSettings, + wwwFormFields, + wwwFormSubmissions, } from '../schemas/index.js'; import logger from '../config/logger.js'; import { ApiResponse } from '../utils/response.js'; @@ -151,4 +161,227 @@ router.get('/api/site/products', async (_req: Request, res: Response) => { } }); +// ===== 新闻:已发布文章列表(供新闻列表页使用) ===== +// GET /api/site/news?skip=0&limit=9 +router.get('/api/site/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 || '9', 10), 100); + const title = req.query.title as string | undefined; + + const conditions = [eq(wwwNewsArticles.status, 'published')]; + if (title) conditions.push(like(wwwNewsArticles.titleZh, `%${title}%`)); + const whereClause = and(...conditions); + + 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, + summaryEn: wwwNewsArticles.summaryEn, + coverUrl: wwwNewsArticles.coverUrl, + publishedAt: wwwNewsArticles.publishedAt, + createdAt: wwwNewsArticles.createdAt, + }).from(wwwNewsArticles) + .where(whereClause) + .orderBy(desc(wwwNewsArticles.publishedAt), desc(wwwNewsArticles.createdAt)) + .offset(skip) + .limit(limit); + + res.json(ApiResponse.success({ items: rows, total, skip, limit })); + } catch (e: unknown) { + logger.error(`[site/news] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取新闻列表失败')); + } +}); + +// ===== 新闻详情:按 slug 查询(含内容与推荐文章) ===== +// GET /api/site/news/:slug +router.get('/api/site/news/:slug', async (req: Request, res: Response) => { + try { + const slug = String(req.params.slug); + const [row] = await db.select().from(wwwNewsArticles) + .where(and(eq(wwwNewsArticles.slug, slug), eq(wwwNewsArticles.status, 'published'))); + if (!row) { + res.json(ApiResponse.noData('文章不存在')); + return; + } + + // 推荐文章(关联表按 sort_order 排序) + const recLinks = await db.select().from(wwwNewsRecommendations) + .where(eq(wwwNewsRecommendations.articleId, row.id)) + .orderBy(wwwNewsRecommendations.sortOrder); + let recommendations: unknown[] = []; + if (recLinks.length > 0) { + const recIds = recLinks.map((r) => r.recommendedId); + const recRows = await db.select({ + id: wwwNewsArticles.id, + titleZh: wwwNewsArticles.titleZh, + titleEn: wwwNewsArticles.titleEn, + slug: wwwNewsArticles.slug, + summaryZh: wwwNewsArticles.summaryZh, + summaryEn: wwwNewsArticles.summaryEn, + coverUrl: wwwNewsArticles.coverUrl, + publishedAt: wwwNewsArticles.publishedAt, + }).from(wwwNewsArticles) + .where(and(eq(wwwNewsArticles.status, 'published'), inArray(wwwNewsArticles.id, recIds))); + recommendations = recLinks + .map((r) => recRows.find((a) => a.id === r.recommendedId)) + .filter((a): a is NonNullable => Boolean(a)); + } + + res.json(ApiResponse.success({ ...row, recommendations })); + } catch (e: unknown) { + logger.error(`[site/news/:slug] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取文章详情失败')); + } +}); + +// ===== 页面配置:产品列表 / 新闻列表 / 关于 / 支持 ===== +// GET /api/site/page?type=product-list|news-list|about|support +router.get('/api/site/page', async (req: Request, res: Response) => { + try { + const type = req.query.type as string; + let row: unknown = null; + switch (type) { + case 'product-list': { + const [r] = await db.select().from(wwwProductListSettings).limit(1); + row = r ?? null; + break; + } + case 'news-list': { + const [r] = await db.select().from(wwwNewsListSettings).limit(1); + row = r ?? null; + break; + } + case 'about': { + const [r] = await db.select().from(wwwAboutSettings).limit(1); + row = r ?? null; + break; + } + case 'support': { + const [r] = await db.select().from(wwwSupportSettings).limit(1); + row = r ?? null; + break; + } + default: + res.json(ApiResponse.error('type 参数不合法')); + return; + } + res.json(ApiResponse.success(row)); + } catch (e: unknown) { + logger.error(`[site/page] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取页面配置失败')); + } +}); + +// ===== 页面 SEO:按 page_type + entity_id 查询 ===== +// GET /api/site/page-seo?page_type=home&entity_id=1 +router.get('/api/site/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)); + res.json(ApiResponse.success(row ?? null)); + } catch (e: unknown) { + logger.error(`[site/page-seo] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取页面 SEO 失败')); + } +}); + +// ===== 联系表单:配置 + 字段列表 ===== +// GET /api/site/contact +router.get('/api/site/contact', async (_req: Request, res: Response) => { + try { + const [settings] = await db.select().from(wwwFormSettings).limit(1); + const fields = await db.select().from(wwwFormFields).orderBy(wwwFormFields.sortOrder); + res.json(ApiResponse.success({ + settings: settings ?? null, + fields: fields ?? [], + })); + } catch (e: unknown) { + logger.error(`[site/contact] GET error: ${e instanceof Error ? e.message : e}`); + res.json(ApiResponse.error('获取表单配置失败')); + } +}); + +// ===== 联系表单提交(公开) ===== +// POST /api/site/contact/submit +// body: { form_data: Record } +router.post('/api/site/contact/submit', async (req: Request, res: Response) => { + try { + const b = req.body; + const formData = b.form_data; + if (!formData || typeof formData !== 'object' || Array.isArray(formData) || Object.keys(formData).length === 0) { + res.json(ApiResponse.error('form_data 不能为空')); + return; + } + + const [settings] = await db.select().from(wwwFormSettings).limit(1); + const fields = await db.select().from(wwwFormFields).orderBy(wwwFormFields.sortOrder); + const cooldownSeconds = settings?.cooldownSeconds ?? 60; + + // 冷却校验:同一 IP 最近一次提交时间 + const ip = (req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() || req.ip || ''; + if (cooldownSeconds > 0 && ip) { + const [last] = await db.select().from(wwwFormSubmissions) + .where(eq(wwwFormSubmissions.visitorIp, ip)) + .orderBy(desc(wwwFormSubmissions.createdAt)) + .limit(1); + if (last) { + const elapsed = (Date.now() - new Date(last.createdAt).getTime()) / 1000; + if (elapsed < cooldownSeconds) { + res.json(ApiResponse.error(`提交过于频繁,请 ${Math.ceil(cooldownSeconds - elapsed)} 秒后再试`)); + return; + } + } + } + + // 必填字段校验:提交 key 与字段 name_zh / name_en 任一匹配 + const values = Object.values(formData) as string[]; + for (const field of fields) { + if (!field.isRequired) continue; + const key = (Object.keys(formData) as string[]).find((k) => k === field.nameZh || k === field.nameEn); + if (!key || !String(formData[key]).trim()) { + res.json(ApiResponse.error(`「${field.nameZh}」为必填项`)); + return; + } + } + + // 全字段值转字符串,限制长度 + const sanitized: Record = {}; + for (const [k, v] of Object.entries(formData)) { + sanitized[k] = String(v ?? '').slice(0, 1000); + } + + const userAgent = (req.headers['user-agent'] as string || '').slice(0, 500); + await db.insert(wwwFormSubmissions).values({ + formData: sanitized, + visitorIp: ip, + userAgent, + }); + + const successMessage = req.query.lang === 'en' ? settings?.successMessageEn : settings?.successMessageZh; + res.json(ApiResponse.success(null, successMessage || '提交成功')); + } catch (e: unknown) { + logger.error(`[site/contact/submit] POST 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 index a99c235..b3aadbc 100644 --- a/dashboard/backend/src/routes/www/sections.ts +++ b/dashboard/backend/src/routes/www/sections.ts @@ -82,6 +82,9 @@ router.post('/api/www/sections', async (req: Request, res: Response) => { productId: b.product_id || null, layout: b.layout, theme: b.theme || 'light', + overlineColor: b.overline_color || '', + titleColor: b.title_color || '', + bodyColor: b.body_color || '', bgType: b.bg_type || 'color', bgValue: b.bg_value || '', overlayEnabled: b.overlay_enabled ? 1 : 0, @@ -131,6 +134,9 @@ router.put('/api/www/sections/:id', async (req: Request, res: Response) => { await db.update(wwwSectionBlocks).set({ layout: b.layout ?? existing.layout, theme: b.theme ?? existing.theme, + overlineColor: b.overline_color ?? existing.overlineColor, + titleColor: b.title_color ?? existing.titleColor, + bodyColor: b.body_color ?? existing.bodyColor, bgType: b.bg_type ?? existing.bgType, bgValue: b.bg_value ?? existing.bgValue, overlayEnabled: b.overlay_enabled !== undefined ? (b.overlay_enabled ? 1 : 0) : existing.overlayEnabled, diff --git a/dashboard/backend/src/schemas/www/sections.ts b/dashboard/backend/src/schemas/www/sections.ts index c2d030a..9ca35c6 100644 --- a/dashboard/backend/src/schemas/www/sections.ts +++ b/dashboard/backend/src/schemas/www/sections.ts @@ -7,6 +7,10 @@ export const wwwSectionBlocks = mysqlTable('www_section_blocks', { productId: bigint('product_id', { mode: 'number', unsigned: true }), layout: varchar('layout', { length: 30 }).notNull(), theme: varchar('theme', { length: 10 }).notNull().default('light'), + // 前景文案自定义颜色(空字符串 = 跟随 theme 默认配色) + overlineColor: varchar('overline_color', { length: 20 }).notNull().default(''), + titleColor: varchar('title_color', { length: 20 }).notNull().default(''), + bodyColor: varchar('body_color', { length: 20 }).notNull().default(''), bgType: varchar('bg_type', { length: 10 }).notNull().default('color'), bgValue: varchar('bg_value', { length: 500 }).notNull().default(''), overlayEnabled: tinyint('overlay_enabled').notNull().default(0), diff --git a/dashboard/frontend/src/typings/api/www.d.ts b/dashboard/frontend/src/typings/api/www.d.ts index 3ff7543..0c4aa42 100644 --- a/dashboard/frontend/src/typings/api/www.d.ts +++ b/dashboard/frontend/src/typings/api/www.d.ts @@ -76,6 +76,9 @@ declare namespace Api { product_id: number | null; layout: string; theme: string; + overline_color: string; + title_color: string; + body_color: string; bg_type: string; bg_value: string; overlay_enabled: boolean; @@ -109,6 +112,9 @@ declare namespace Api { product_id?: number | null; layout: string; theme?: string; + overline_color?: string; + title_color?: string; + body_color?: string; bg_type?: string; bg_value?: string; overlay_enabled?: boolean; diff --git a/dashboard/frontend/src/views/www/page/home/components/ColorInput.vue b/dashboard/frontend/src/views/www/page/home/components/ColorInput.vue new file mode 100644 index 0000000..810a7d0 --- /dev/null +++ b/dashboard/frontend/src/views/www/page/home/components/ColorInput.vue @@ -0,0 +1,41 @@ + + + diff --git a/dashboard/frontend/src/views/www/page/home/index.vue b/dashboard/frontend/src/views/www/page/home/index.vue index 33851c5..c4fea8b 100644 --- a/dashboard/frontend/src/views/www/page/home/index.vue +++ b/dashboard/frontend/src/views/www/page/home/index.vue @@ -1,6 +1,7 @@ + + diff --git a/www/app/components/section/layouts/LayoutFeatureGrid.vue b/www/app/components/section/layouts/LayoutFeatureGrid.vue index db8cc60..1765cf3 100644 --- a/www/app/components/section/layouts/LayoutFeatureGrid.vue +++ b/www/app/components/section/layouts/LayoutFeatureGrid.vue @@ -3,7 +3,7 @@ import type { SectionBlock } from '~/types/site'; const props = defineProps<{ section: SectionBlock }>(); -const { overline, title, subtitle, config, isEn, isDark } = useSectionContent(props.section); +const { overline, title, subtitle, config, isEn, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section); const features = computed(() => config.value.features ?? []); @@ -12,13 +12,13 @@ const features = computed(() => config.value.features ?? []);
-

+

{{ overline }}

-

+

{{ title }}

-

+

{{ subtitle }}

@@ -32,10 +32,10 @@ const features = computed(() => config.value.features ?? []); :class="isDark ? 'bg-white/5 border border-white/10' : 'bg-white border border-gray-100 shadow-sm hover:shadow-md dark:bg-dark-800 dark:border-dark-700'" > {{ feature.icon }} -

+

{{ isEn ? feature.title_en : feature.title_zh }}

-

+

{{ isEn ? feature.desc_en : feature.desc_zh }}

diff --git a/www/app/components/section/layouts/LayoutFullBanner.vue b/www/app/components/section/layouts/LayoutFullBanner.vue index 325c03b..3d15955 100644 --- a/www/app/components/section/layouts/LayoutFullBanner.vue +++ b/www/app/components/section/layouts/LayoutFullBanner.vue @@ -3,21 +3,21 @@ import type { SectionBlock } from '~/types/site'; const props = defineProps<{ section: SectionBlock }>(); -const { overline, title, subtitle, content } = useSectionContent(props.section); +const { overline, title, subtitle, content, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section); diff --git a/www/app/components/section/layouts/LayoutGallery.vue b/www/app/components/section/layouts/LayoutGallery.vue index ce0153c..8b4005c 100644 --- a/www/app/components/section/layouts/LayoutGallery.vue +++ b/www/app/components/section/layouts/LayoutGallery.vue @@ -3,7 +3,7 @@ import type { SectionBlock } from '~/types/site'; const props = defineProps<{ section: SectionBlock }>(); -const { overline, title, subtitle, config, isEn, isDark } = useSectionContent(props.section); +const { overline, title, subtitle, config, isEn, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section); const images = computed(() => config.value.images ?? []); @@ -12,13 +12,13 @@ const images = computed(() => config.value.images ?? []);
-

+

{{ overline }}

-

+

{{ title }}

-

+

{{ subtitle }}

diff --git a/www/app/components/section/layouts/LayoutHeroSplit.vue b/www/app/components/section/layouts/LayoutHeroSplit.vue index 6dffd13..a6d1955 100644 --- a/www/app/components/section/layouts/LayoutHeroSplit.vue +++ b/www/app/components/section/layouts/LayoutHeroSplit.vue @@ -3,28 +3,57 @@ import type { SectionBlock } from '~/types/site'; const props = defineProps<{ section: SectionBlock }>(); -const { overline, title, subtitle, content, mediaUrl, isDark } = useSectionContent(props.section); +const { overline, title, subtitle, content, mediaUrl, isDark, overlineStyle, titleStyle, bodyStyle } = useSectionContent(props.section); + +// 九宫格文案位置(config.text_position,默认左中):top/middle/bottom + left/center/right +const textPosition = computed(() => (props.section.config as Record | null)?.text_position ?? 'middle_left'); + +// 垂直对齐(flex-col 主轴):top->start / middle->center / bottom->end +const vAlign = computed(() => ({ + top: 'justify-start', + middle: 'justify-center', + bottom: 'justify-end' +} as Record)[textPosition.value.split('_')[0] ?? ''] ?? 'justify-center'); + +// 水平对齐(flex-col 交叉轴):left->start / center->center / right->end +const hAlign = computed(() => ({ + left: 'items-start', + center: 'items-center', + right: 'items-end' +} as Record)[textPosition.value.split('_')[1] ?? ''] ?? 'items-start'); + +// 水平内边距:左列贴左、右列贴右、中列居中留白 +const hPad = computed(() => ({ + left: 'md:pl-128px md:pr-96px', + center: 'md:px-64px', + right: 'md:pl-96px md:pr-128px' +} as Record)[textPosition.value.split('_')[1] ?? ''] ?? 'md:px-64px');