官网开发中 0731

This commit is contained in:
eafonyang
2026-07-31 19:02:00 +08:00
parent fda4fa8627
commit eeccdb079c
36 changed files with 1944 additions and 105 deletions
+8
View File
@@ -5,9 +5,11 @@ import './config/loadEnv.js';
import express from 'express';
import cors from 'cors';
import path from 'path';
import logger from './config/logger.js';
import routes from './routes/index.js';
import { bodyLimit } from './middleware/bodyLimit.js';
import { wwwSnakeCase } from './middleware/snakeCase.js';
import { ensureBootstrapSuperAdmin } from './services/userBootstrap.js';
const app = express();
@@ -18,6 +20,12 @@ app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(bodyLimit);
// 静态文件服务:素材库上传目录
app.use('/uploads', express.static(path.resolve(process.cwd(), 'uploads')));
// 官网 CMS 响应字段名统一转为 snake_caseDrizzle 返回 camelCase,前端约定 snake_case
app.use(wwwSnakeCase);
// 根路径(无需登录)
app.get('/', (_req, res) => {
res.json({
@@ -0,0 +1,40 @@
import type { Request, Response, NextFunction } from 'express';
/**
* 官网 CMS 响应字段名转换中间件
*
* Drizzle ORM 查询返回的行对象使用 schema 中定义的 camelCase 属性名(如 fileType、fileSize),
* 而前端与 API 约定统一使用 snake_case(如 file_type、file_size,与数据库列名一致)。
* 该中间件拦截 /api/www/ 路由的 res.json 响应,递归地将对象键从 camelCase 转为 snake_case。
*
* 说明:
* - 仅转换对象键,不改动字符串/数值等基本类型值。
* - JSON 列(config、form_data、options_json 等)由前端以 snake_case 写入,转换对其为无操作。
* - Date 实例原样保留,交由 JSON.stringify 序列化为 ISO 字符串。
*/
function toSnake(key: string): string {
return key.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`);
}
function convertToSnake(value: unknown): unknown {
if (value === null || value === undefined) return value;
if (value instanceof Date) return value;
if (Array.isArray(value)) return value.map(convertToSnake);
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>)) {
out[toSnake(key)] = convertToSnake((value as Record<string, unknown>)[key]);
}
return out;
}
return value;
}
export function wwwSnakeCase(req: Request, res: Response, next: NextFunction) {
if (req.path.startsWith('/api/www') || req.path.startsWith('/api/site')) {
const originalJson = res.json.bind(res);
res.json = (body: unknown) => originalJson(convertToSnake(body));
}
return next();
}
+5
View File
@@ -8,6 +8,7 @@ import otaTargetDeviceRouter from './otaTargetDevice.js';
import shareCodeLogsRouter from './shareCodeLogs.js';
import usersRouter from './users.js';
import dashboardRouter from './dashboard.js';
import siteRouter from './site.js';
import {
wwwGlobalRouter,
wwwNavRouter,
@@ -22,6 +23,10 @@ import {
} from './www/index.js';
const routes: Router[] = [
// 官网前台公开只读接口(免鉴权)
// 注意:必须注册在最前面。其他路由文件使用无路径 router.use(authMiddleware)
// 且通过 app.use(router) 挂载于 /,会拦截一切进入该路由器的请求(含不匹配的)。
siteRouter,
authRouter,
brandsRouter,
modelsRouter,
+154
View File
@@ -0,0 +1,154 @@
import { Router, type Request, type Response } from 'express';
import { eq, and } from 'drizzle-orm';
import { db } from '../config/database.js';
import {
wwwSiteSettings,
wwwFooterSettings,
wwwSocialLinks,
wwwSeoDefaults,
wwwNavItems,
wwwNavAppearance,
wwwSectionBlocks,
wwwSpecGroups,
wwwSpecItems,
wwwI18nEntries,
wwwProductSeries,
wwwProducts,
} from '../schemas/index.js';
import logger from '../config/logger.js';
import { ApiResponse } from '../utils/response.js';
/**
* 官网前台公开只读接口(供 Nuxt 官网消费)
*
* 与 dashboard 管理端接口(/api/www/*,需 JWT 鉴权)完全隔离:
* - 无需登录,公开访问
* - 仅提供 GET 只读能力
* - 仅返回对前台可见的内容(is_visible=1、已发布等)
*/
const router = Router();
// ===== 全局配置:站点信息 + 页脚 + 社交链接 + SEO 默认值 =====
// GET /api/site/global
router.get('/api/site/global', async (_req: Request, res: Response) => {
try {
const [site] = await db.select().from(wwwSiteSettings).limit(1);
const [footer] = await db.select().from(wwwFooterSettings).limit(1);
const social = await db.select().from(wwwSocialLinks).orderBy(wwwSocialLinks.sortOrder);
const [seo] = await db.select().from(wwwSeoDefaults).limit(1);
res.json(ApiResponse.success({
site: site ?? null,
footer: footer ?? null,
social: social ?? [],
seo: seo ?? null,
}));
} catch (e: unknown) {
logger.error(`[site/global] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取全局配置失败'));
}
});
// ===== 导航:可见入口 + 外观 =====
// GET /api/site/nav
router.get('/api/site/nav', async (_req: Request, res: Response) => {
try {
const items = await db.select().from(wwwNavItems)
.where(eq(wwwNavItems.isVisible, 1))
.orderBy(wwwNavItems.sortOrder);
const [appearance] = await db.select().from(wwwNavAppearance).limit(1);
res.json(ApiResponse.success({
items: items ?? [],
appearance: appearance ?? null,
}));
} catch (e: unknown) {
logger.error(`[site/nav] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取导航配置失败'));
}
});
// ===== Section 区块:按页面类型返回可见区块(含参数规格子数据) =====
// GET /api/site/sections?page_type=home&product_id=1
router.get('/api/site/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),
eq(wwwSectionBlocks.isVisible, 1),
];
if (productId) conditions.push(eq(wwwSectionBlocks.productId, productId));
const sections = await db.select().from(wwwSectionBlocks)
.where(and(...conditions))
.orderBy(wwwSectionBlocks.sortOrder);
// 为 spec_table 布局的区块内联参数分组数据,避免前端 N+1 请求
const items = await Promise.all(sections.map(async (section) => {
if (section.layout !== 'spec_table') return section;
const groups = await db.select().from(wwwSpecGroups)
.where(eq(wwwSpecGroups.sectionBlockId, section.id))
.orderBy(wwwSpecGroups.sortOrder);
const specGroups = await Promise.all(groups.map(async (g) => {
const specItems = await db.select().from(wwwSpecItems)
.where(eq(wwwSpecItems.specGroupId, g.id))
.orderBy(wwwSpecItems.sortOrder);
return { ...g, items: specItems };
}));
return { ...section, specGroups };
}));
res.json(ApiResponse.success({ items, total: items.length }));
} catch (e: unknown) {
logger.error(`[site/sections] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取 Section 列表失败'));
}
});
// ===== i18n 词条:返回全部词条(前台用于动态翻译 UI 文案) =====
// GET /api/site/i18n
router.get('/api/site/i18n', async (_req: Request, res: Response) => {
try {
const items = await db.select().from(wwwI18nEntries);
res.json(ApiResponse.success({ items: items ?? [], total: items.length }));
} catch (e: unknown) {
logger.error(`[site/i18n] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取词条列表失败'));
}
});
// ===== 产品:可见系列及其下可见产品(供导航 Mega Menu 与产品页使用) =====
// GET /api/site/products
router.get('/api/site/products', async (_req: Request, res: Response) => {
try {
const series = await db.select().from(wwwProductSeries)
.where(eq(wwwProductSeries.isVisible, 1))
.orderBy(wwwProductSeries.sortOrder);
const products = await db.select().from(wwwProducts)
.where(eq(wwwProducts.isVisible, 1))
.orderBy(wwwProducts.sortOrder);
const seriesWithProducts = series.map((s) => ({
...s,
products: products.filter((p) => p.seriesId === s.id),
}));
res.json(ApiResponse.success({ series: seriesWithProducts, total: seriesWithProducts.length }));
} catch (e: unknown) {
logger.error(`[site/products] GET error: ${e instanceof Error ? e.message : e}`);
res.json(ApiResponse.error('获取产品列表失败'));
}
});
export default router;