官网开发中 0731
This commit is contained in:
@@ -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_case(Drizzle 返回 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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user