官网开发中 0803
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user