官网开发中 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),
+6
View File
@@ -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;
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { NColorPicker, NInput } from 'naive-ui';
defineOptions({ name: 'ColorInput' });
const props = defineProps<{ value: string }>();
const emit = defineEmits<{ (e: 'update:value', value: string): void }>();
// 空值 = 跟随主题默认色
const model = computed({
get: () => props.value,
set: v => emit('update:value', v || '')
});
// 输入框获取焦点后在下方显示取色器(default-show 自动展开面板);面板关闭后自动收起。
// 注意:不能把 NColorPicker 塞进 NPopover,双层弹层嵌套会导致取色面板位置跑出框外。
const showPicker = ref(false);
</script>
<template>
<div class="w-full flex flex-col gap-8px">
<NInput
v-model:value="model"
placeholder="留空则跟随主题默认色"
clearable
@focus="showPicker = true"
/>
<!-- 空值时取色器内部以白色兜底展示选中任意颜色即写回 -->
<NColorPicker
v-if="showPicker"
:value="model || '#FFFFFF'"
:show-alpha="false"
:modes="['hex']"
default-show
class="w-full"
@update:value="v => (model = v)"
@update:show="v => !v && (showPicker = false)"
/>
</div>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, h, onMounted, reactive, ref } from 'vue';
import { NButton, NSpace, NSwitch, NTag, type DataTableColumns, type FormInst, type SelectOption, type SelectRenderOption } from 'naive-ui';
import ColorInput from './components/ColorInput.vue';
import {
fetchCreateSection,
fetchDeleteSection,
@@ -64,6 +65,9 @@ const formData = reactive({
id: null as number | null,
layout: 'hero_split',
theme: 'light',
overline_color: '',
title_color: '',
body_color: '',
bg_type: 'color',
bg_value: '',
overlay_enabled: false,
@@ -85,9 +89,23 @@ const formData = reactive({
media_url_zh: '',
media_url_en: '',
video_url: '',
is_visible: true
is_visible: true,
config: { text_position: 'middle_left' } as Record<string, unknown>
});
// 九宫格文案位置(hero_split 专用)
const textPositionOptions = [
{ label: '左上', value: 'top_left' },
{ label: '中上', value: 'top_center' },
{ label: '右上', value: 'top_right' },
{ label: '左中', value: 'middle_left' },
{ label: '正中', value: 'middle_center' },
{ label: '右中', value: 'middle_right' },
{ label: '左下', value: 'bottom_left' },
{ label: '中下', value: 'bottom_center' },
{ label: '右下', value: 'bottom_right' }
];
const columns = computed((): DataTableColumns<Api.Www.SectionBlock> => [
{
title: '排序',
@@ -177,6 +195,9 @@ function handleAdd() {
formData.id = null;
formData.layout = 'hero_split';
formData.theme = 'light';
formData.overline_color = '';
formData.title_color = '';
formData.body_color = '';
formData.bg_type = 'color';
formData.bg_value = '';
formData.overlay_enabled = false;
@@ -199,6 +220,7 @@ function handleAdd() {
formData.media_url_en = '';
formData.video_url = '';
formData.is_visible = true;
formData.config = { text_position: 'middle_left' };
dialogVisible.value = true;
}
@@ -207,6 +229,9 @@ function handleEdit(row: Api.Www.SectionBlock) {
formData.id = row.id;
formData.layout = row.layout;
formData.theme = row.theme;
formData.overline_color = row.overline_color || '';
formData.title_color = row.title_color || '';
formData.body_color = row.body_color || '';
formData.bg_type = row.bg_type;
formData.bg_value = row.bg_value;
formData.overlay_enabled = Boolean(row.overlay_enabled);
@@ -229,6 +254,8 @@ function handleEdit(row: Api.Www.SectionBlock) {
formData.media_url_en = row.media_url_en;
formData.video_url = row.video_url;
formData.is_visible = Boolean(row.is_visible);
const cfg = (row.config || {}) as Record<string, unknown>;
formData.config = { ...cfg, text_position: cfg.text_position || 'middle_left' };
dialogVisible.value = true;
}
@@ -306,6 +333,16 @@ onMounted(loadData);
<NRadio v-for="opt in themeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</NRadio>
</NRadioGroup>
</NFormItem>
<NDivider>文案颜色留空=跟随主题默认色</NDivider>
<NFormItem label="Overline 颜色">
<ColorInput v-model:value="formData.overline_color" />
</NFormItem>
<NFormItem label="标题颜色">
<ColorInput v-model:value="formData.title_color" />
</NFormItem>
<NFormItem label="副标题/正文颜色">
<ColorInput v-model:value="formData.body_color" />
</NFormItem>
<NFormItem label="背景类型">
<NRadioGroup v-model:value="formData.bg_type">
<NRadioButton value="color">纯色</NRadioButton>
@@ -315,7 +352,20 @@ onMounted(loadData);
</NRadioGroup>
</NFormItem>
<NFormItem label="背景值">
<NInput v-model:value="formData.bg_value" placeholder="颜色值或图片路径" />
<NInput v-model:value="formData.bg_value" placeholder="颜色值或图片/视频路径" />
</NFormItem>
<NFormItem v-if="formData.layout === 'hero_split'" label="文案位置">
<div class="grid grid-cols-3 gap-4px">
<NButton
v-for="pos in textPositionOptions"
:key="pos.value"
size="tiny"
:type="formData.config.text_position === pos.value ? 'primary' : 'default'"
@click="formData.config.text_position = pos.value"
>
{{ pos.label }}
</NButton>
</div>
</NFormItem>
<NFormItem label="遮罩">
<NSpace align="center">