官网开发中 0803

This commit is contained in:
eafonyang
2026-08-03 19:09:03 +08:00
parent eeccdb079c
commit f37b6451ef
36 changed files with 1906 additions and 78 deletions
+9
View File
@@ -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<unknown> } };
callbackConn.promise().query("SET time_zone = '+00:00'").catch(() => {});
});
export const db = drizzle(pool, { schema, mode: 'default' });
if (isDevelopment) {
+234 -1
View File
@@ -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<typeof a> => 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<string, string> }
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<string, string> = {};
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;
@@ -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,
@@ -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),