Files
YschcmsFront/mock/index.ts
T
chenxing 6ee9ab5684
build / build (push) Successful in 2m43s
feat(site-layer): 学校站点分层方案C落地(docs/13)+栏目管理增强+管理端三修复+首页视觉升级管理端+审计复检T1~T5(docs/15)整合批次-URL站点段/site/{siteCode}无component镜像层(去name防重名,router-view跳过推进渲染)+身份条SiteSwitcher自绘(徽章#577afb/#ff9900+分组下拉✓+path次级列+Esc/外点收起)+URL唯一事实源syncSiteFromRoute(非法/跨校/主站码当子站段告警打回同页主站,localStorage降级刷新首帧兜底)+菜单剥段高亮stripSiteSeg/withSiteSeg补段+menuRoute镜像层防重影+跳转收敛adminPathOf(工作台进入/全部站点总览/站点管理行进入管理纯URL切站);栏目管理-上级栏目可选可改(禁选自身及后代防成环)+批量添加BatchAddCategory(行序写排序号单行失败不中断)+单页wangEditor富文本预览字数分区+categoryIds契约前端/types/mock三处统一+SchoolLayout高度链.layout-wrap修复+ArticleListPanel fixed模式;管理端三修复-皮肤预览Modal全屏(ThemePreviewFrame可选fill prop)+SiteManage补themeUnregistered声明+长图文编辑器随内容增高;管理端视觉升级配套-SiteConfigForm 12新键+BannerManage gallery展示位切换+栏目homeStyle下拉+PortalPreview侧栏同步;审计复检-新增siteRouter.spec真实路由集成7用例(镜像层matched3层/守卫子站码定向主站前缀/越权回工作台/旧链带id重定向)+sitePath空白容错+siteLayer持久化round-trip与身份条次级入口(docs/15);vitest 99/99+build通过+S1~S10 GUI走查全过
2026-09-10 11:11:23 +08:00

914 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* CMS dev mock 中间件(vite 插件,仅 mock 模式启用)。
*
* 在 vite 内部中间件(含 /api 代理)之前拦截请求,内存数据库直接应答,
* 使前端 SPA 无需后端/CAS/MySQL 即可完整跑通界面演示:
* - /api/** 管理端全部接口({code:0,msg,data} 包装,写操作真实改内存库)
* - /api/attachment/upload multipart 粗解析:提取文件字节缓存到 /mock-files/
* - /mock-img/:name 确定性 SVG 占位图(按名字 hash 配色,离线可用)
* - /mock-files/:name 回放上传的文件字节;未命中的种子路径回 SVG 占位
* - /portal/** 简版门户预览页(首页 + /a/{id} 文章详情)
*/
import type { Plugin } from "vite";
import type { IncomingMessage, ServerResponse } from "http";
import { MockDb, categoryTree, categoryName, nextId, seedDb } from "./data";
import { MockAttachment, MockBanner, MockLink, MockSite } from "./data";
const db: MockDb = seedDb();
/* ===== 主题 fixture(方案 §6.4:字段 schema 与后端 /api/theme/list 一致) ===== */
interface MockThemeParam {
key: string;
cssVar?: string;
label: string;
type: "color" | "image" | "select" | "text";
defaultValue?: string;
options?: { value: string; label: string }[];
}
interface MockTheme {
name: string;
title: string;
description: string;
version: string;
author: string;
bannerSize: string;
screenshotUrl: string;
params: MockThemeParam[];
}
const THEMES: MockTheme[] = [
{
name: "default",
title: "高校蓝(默认)",
description: "高校蓝主题:顶栏 + 通栏轮播布局,现行基准皮肤。",
version: "1.0.0",
author: "yunhai",
bannerSize: "1920x560",
screenshotUrl: "/api/theme/default/asset/screenshot.png",
params: [
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#1a5cb0" },
{ key: "primaryDark", cssVar: "--primary-dark", label: "主色加深", type: "color", defaultValue: "#12447f" },
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#f5f6f8" },
],
},
{
name: "classic-red",
title: "红蓝经典",
description: "深红横幅头图 + 通栏轮播 + 宋体标题,适合中小学与党政风格。",
version: "1.0.0",
author: "yunhai",
bannerSize: "1920x560",
screenshotUrl: "/api/theme/classic-red/asset/screenshot.png",
params: [
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#b01a2e" },
{ key: "primaryDark", cssVar: "--primary-dark", label: "主色加深", type: "color", defaultValue: "#7f1224" },
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#f7f5f2" },
{
key: "headerStyle",
label: "头部样式",
type: "select",
defaultValue: "banner",
options: [{ value: "banner", label: "横幅头图" }, { value: "plain", label: "简洁顶栏" }],
},
],
},
{
name: "blue-white",
title: "蓝白简约",
description: "细线顶栏 + 卡片化列表 + 大留白,适合高校与职业院校。",
version: "1.0.0",
author: "yunhai",
bannerSize: "1920x560",
screenshotUrl: "/api/theme/blue-white/asset/screenshot.png",
params: [
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#2f6bd8" },
{ key: "primaryDark", cssVar: "--primary-dark", label: "主色加深", type: "color", defaultValue: "#1d4fae" },
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#f7f9fc" },
],
},
{
name: "warm-kinder",
title: "幼儿园暖色",
description: "圆角卡片 + 暖色系 + 插画头图位,适合幼儿园与托育机构。",
version: "1.0.0",
author: "yunhai",
bannerSize: "1920x560",
screenshotUrl: "/api/theme/warm-kinder/asset/screenshot.png",
params: [
{ key: "primary", cssVar: "--primary", label: "主色", type: "color", defaultValue: "#e8722a" },
{ key: "primaryDark", cssVar: "--primary-dark", label: "主色加深", type: "color", defaultValue: "#c25a1a" },
{ key: "bg", cssVar: "--bg", label: "页面底色", type: "color", defaultValue: "#fff8f0" },
],
},
];
function themeByName(name: string): MockTheme | undefined {
return THEMES.find(t => t.name === name);
}
/** 解析 configJson(非法 JSON 按空对象,与后端 ExtJsonUtils.parse 同语义) */
function parseConfig(configJson: string): Record<string, any> {
try {
const parsed = JSON.parse(configJson);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
/** 按主题 schema 过滤微调值(与后端 ThemeInfo.filterValidValues 同语义,§5.3) */
function filterThemeParams(theme: MockTheme, raw: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
const colorRe = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
theme.params.forEach(p => {
const v = typeof raw[p.key] === "string" ? (raw[p.key] as string) : "";
if (v === "") return;
let valid = false;
if (p.type === "color") valid = colorRe.test(v);
else if (p.type === "image") valid = v.startsWith("/upload/");
else if (p.type === "select") valid = !!p.options?.some(o => o.value === v);
else if (p.type === "text") valid = v.length <= 256;
if (valid) out[p.key] = v;
});
return out;
}
/* ===== 基础工具 ===== */
function ok(data: unknown) {
return { code: 0, msg: "success", data };
}
function sendJson(res: ServerResponse, body: unknown, status = 200) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json;charset=UTF-8");
res.end(JSON.stringify(body));
}
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise(resolve => {
const chunks: Buffer[] = [];
req.on("data", (c: Buffer) => chunks.push(c));
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", () => resolve(Buffer.alloc(0)));
});
}
async function readJson<T = any>(req: IncomingMessage): Promise<T> {
const buf = await readBody(req);
try {
return JSON.parse(buf.toString("utf8") || "{}") as T;
} catch {
return {} as T;
}
}
function q(req: IncomingMessage, key: string): string | undefined {
return new URL(req.url!, "http://mock.local").searchParams.get(key) ?? undefined;
}
function num(v: string | undefined, def?: number): number | undefined {
if (v === undefined || v === "") return def;
const n = parseInt(v, 10);
return isNaN(n) ? def : n;
}
function now(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/** 栏目自身 + 全部子孙 id(与后端 categoryIds 过滤语义一致) */
function categoryWithChildren(siteId: number, categoryId: number): number[] {
const ids = [categoryId];
const walk = (pid: number) => {
db.categories
.filter(c => c.siteId === siteId && c.parentId === pid)
.forEach(c => {
ids.push(c.id);
walk(c.id);
});
};
walk(categoryId);
return ids;
}
/* ===== SVG 占位图(确定性配色) ===== */
function hashCode(s: string): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
function placeholderSvg(name: string): string {
const h = hashCode(name);
const hue = h % 360;
const hue2 = (hue + 40) % 360;
const label = name.replace(/\.(svg|png|jpg|jpeg|gif|webp|pdf|docx?|xlsx?|pptx?)$/i, "");
const short = label.length > 18 ? label.slice(0, 18) + "…" : label;
return `<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 640 360">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="hsl(${hue},62%,58%)"/><stop offset="1" stop-color="hsl(${hue2},55%,42%)"/>
</linearGradient></defs>
<rect width="640" height="360" fill="url(#g)"/>
<circle cx="${80 + (h % 480)}" cy="${60 + ((h >> 3) % 240)}" r="120" fill="rgba(255,255,255,0.12)"/>
<circle cx="${640 - (80 + ((h >> 5) % 480))}" cy="${360 - (60 + ((h >> 7) % 240))}" r="90" fill="rgba(255,255,255,0.10)"/>
<text x="320" y="188" text-anchor="middle" font-family="'Microsoft YaHei',sans-serif" font-size="30" fill="rgba(255,255,255,0.95)">${short}</text>
<text x="320" y="230" text-anchor="middle" font-family="'Microsoft YaHei',sans-serif" font-size="16" fill="rgba(255,255,255,0.65)">Mock 占位图</text>
</svg>`;
}
function sendSvg(res: ServerResponse, name: string) {
res.statusCode = 200;
res.setHeader("Content-Type", "image/svg+xml; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.end(placeholderSvg(name));
}
/* ===== multipart 粗解析(单文件场景足够) ===== */
function extractFile(body: Buffer): { name: string; mime: string; buffer: Buffer } | null {
const headEnd = body.indexOf("\r\n\r\n");
if (headEnd < 0) return null;
const head = body.slice(0, headEnd).toString("utf8");
const boundary = head.split("\r\n")[0];
if (!boundary.startsWith("--")) return null;
const nameMatch = head.match(/filename="([^"]*)"/);
const mimeMatch = head.match(/Content-Type:\s*([^\r\n]+)/i);
const end = body.lastIndexOf(`\r\n${boundary}`);
if (end <= headEnd + 4) return null;
return {
name: nameMatch?.[1] ? decodeURIComponent(nameMatch[1]) : `upload-${Date.now()}`,
mime: mimeMatch?.[1]?.trim() || "application/octet-stream",
buffer: body.slice(headEnd + 4, end),
};
}
/* ===== 简版门户预览页 ===== */
function siteByPath(sitePath: string): MockSite | undefined {
return db.sites.find(s => s.path === sitePath || (s.parentSiteId === 0 && s.code === sitePath));
}
function siteByCode(code: string): MockSite | undefined {
return db.sites.find(s => s.code === code);
}
function portalPage(req: IncomingMessage, res: ServerResponse) {
const path = new URL(req.url!, "http://mock.local").pathname;
// W4:/portal/{path}/ 或 /portal/{path}/...(path 支持嵌套两段;含 /a/{id})
const m = path.replace(/^\/portal\//, "").split("/");
const site = siteByPath(m[0]);
const nav = ["学校概况", "新闻中心", "教学科研", "招生网", "联系我们"]
.map(n => `<a href="javascript:void(0)">${n}</a>`)
.join("");
if (!site) {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html;charset=utf-8");
res.end(`<html><body style="font-family:'Microsoft YaHei';padding:40px"><h3>Mock 预览</h3><p>站点 code「${m[0]}」不存在,请先在站点管理创建。</p></body></html>`);
return;
}
// 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身),与真实门户 header 同语义
const anchorId = site.parentSiteId || site.id;
const colleges = db.sites.filter(s => s.parentSiteId === anchorId && s.status === 1);
const collegesHtml = colleges.length
? `<div class="colleges"><span class="colleges-toggle">专业院系 ▾</span><ul class="colleges-menu">
${colleges.map(c => `<li${c.path === site.path ? ' class="on"' : ""}><a href="/portal/${c.path}/">${c.name}</a></li>`).join("")}
</ul></div>`
: "";
const articleMatch = path.match(/\/a\/(\d+)/);
let main: string;
if (articleMatch) {
const art = db.articles.find(x => x.id === parseInt(articleMatch[1], 10));
main = art
? `<article class="card"><h1>${art.title}</h1>
<p class="meta">发布时间:${art.publishTime || "未发布"} 浏览量:${art.viewCount} 来源:${art.source || "本站"}(Mock 数据)</p>
<div class="content">${art.content || "<p>(无正文)</p>"}</div></article>`
: `<article class="card"><h1>文章不存在或未发布</h1><p>Mock 提示:仅「已发布」文章在门户可见。</p></article>`;
} else {
const banners = db.banners
.filter(b => b.siteId === site.id && b.position === "home" && b.status === 1)
.sort((x, y) => x.sortNo - y.sortNo);
const pubs = db.articles
.filter(x => x.siteId === site.id && x.status === 1)
.sort((x, y) => (y.topmost - x.topmost) || (y.publishTime || "").localeCompare(x.publishTime || ""));
main = `
<div class="banner"><img src="${banners[0]?.imageUrl || "/mock-img/banner-default.svg"}" alt="banner"/>
<div class="banner-list">${banners.map(b => `<img src="${b.imageUrl}" alt="${b.title}"/>`).join("")}</div>
</div>
<article class="card"><h2>文章列表(仅已发布,置顶优先)</h2><ul>
${pubs.map(a => `<li><a href="/portal/${site.code}/a/${a.id}">${a.topmost === 1 ? "【置顶】" : ""}${a.title}</a><span class="meta">${a.publishTime || ""}</span></li>`).join("") || "<li>暂无已发布文章</li>"}
</ul></article>`;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html;charset=utf-8");
// 防止 dev 缓存把 SPA index.html 与门户页互相串味
res.setHeader("Cache-Control", "no-store");
res.end(`<!doctype html><html lang="zh"><head><meta charset="utf-8"/><title>${site.name}(Mock 预览)</title>
<style>
body{font-family:'Microsoft YaHei',sans-serif;margin:0;background:#f5f6f8;color:#333}
header{background:#fff;border-bottom:1px solid #e5e6eb;padding:0 32px;display:flex;align-items:center;justify-content:space-between;height:64px}
header h1{font-size:20px} header .tip{color:#999;font-size:13px}
.colleges{position:relative}
.colleges-toggle{display:flex;align-items:center;gap:4px;border:1px solid #e5e6eb;padding:7px 14px;color:#2d8cf0;font-weight:600;cursor:pointer}
.colleges-menu{display:none;position:absolute;top:100%;right:0;min-width:200px;max-height:420px;overflow-y:auto;margin:0;padding:6px 0;list-style:none;background:#fff;border:1px solid #e5e6eb;border-top:2px solid #2d8cf0;box-shadow:0 6px 16px rgba(0,0,0,.12);z-index:110}
.colleges:hover .colleges-menu{display:block}
.colleges-menu li a{display:block;padding:8px 14px;font-size:14px;color:#333;white-space:nowrap;text-decoration:none}
.colleges-menu li a:hover{background:#f2f6fc;color:#2d8cf0}
.colleges-menu li.on a{color:#2d8cf0;font-weight:600}
nav{background:#2d8cf0;padding:0 32px} nav a{color:#fff;text-decoration:none;margin-right:24px;line-height:44px;font-size:15px}
.wrap{max-width:960px;margin:20px auto;padding:0 16px}
.card{background:#fff;border-radius:8px;padding:24px 28px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
.banner img{width:100%;border-radius:8px;display:block}
.banner-list{display:flex;gap:10px;margin-top:10px} .banner-list img{width:200px;border-radius:6px}
h1{font-size:22px} h2{font-size:18px} ul{padding-left:0;list-style:none} li{padding:8px 0;border-bottom:1px dashed #eee;display:flex;justify-content:space-between}
li a{color:#2d8cf0;text-decoration:none} .meta{color:#999;font-size:13px} .content p{text-indent:2em;line-height:1.9} .content img{max-width:100%}
</style></head><body>
<header><h1>${site.name}</h1><span class="tip">Mock 模式门户预览(数据为内存模拟,不影响真实后端)</span>${collegesHtml}</header>
<nav>${nav}</nav><div class="wrap">${main}</div></body></html>`);
}
/* ===== 主题预览(简化渲染::root 变量驱动配色示意布局;完整效果以真实后端为准) ===== */
function sendThemePreview(req: IncomingMessage, res: ServerResponse) {
const u = new URL(req.url!, "http://mock.local");
const site = siteByPath(u.searchParams.get("sitePath") || "");
const themeName = u.searchParams.get("theme") || "default";
const theme = themeByName(themeName);
const params: Record<string, string> = {};
if (theme) {
theme.params.forEach(p => {
const v = u.searchParams.get("p_" + p.key);
if (v !== null && v !== "") params[p.key] = v;
});
}
const vars: Record<string, string> = {};
if (theme) {
theme.params.forEach(p => {
const v = params[p.key] || p.defaultValue || "";
if (p.cssVar && v) vars[p.cssVar] = v;
});
}
const rootVars = Object.keys(vars).map(k => k + ":" + vars[k]).join(";");
const primary = vars["--primary"] || "#1a5cb0";
const primaryDark = vars["--primary-dark"] || "#12447f";
if (!site) {
sendHtml(res, "<html><body style='font-family:Microsoft YaHei;padding:40px'><h3>Mock 主题预览</h3><p>内容源站点不存在。预览需要 content site:请先创建并启用站点。</p></body></html>");
return;
}
const themeTitle = theme ? theme.title + "(" + theme.name + ")" : themeName;
const pubs = db.articles
.filter(x => x.siteId === site.id && x.status === 1)
.sort((x, y) => (y.topmost - x.topmost) || (y.publishTime || "").localeCompare(x.publishTime || ""));
const bannerSize = theme ? theme.bannerSize : "1920x560";
const listItems = pubs.map(a =>
"<li><a href='/portal/" + site.code + "/a/" + a.id + "'>" + (a.topmost === 1 ? "【置顶】" : "") + a.title
+ "</a><span class='meta'>" + (a.publishTime || "") + "</span></li>"
).join("") || "<li>暂无已发布文章</li>";
sendHtml(res, "<!doctype html><html lang='zh'><head><meta charset='utf-8'/>"
+ "<title>" + themeTitle + " · 预览(Mock)</title><style>"
+ ":root{--primary:" + primary + ";--primary-dark:" + primaryDark + ";" + rootVars + "}"
+ "body{font-family:Microsoft YaHei,sans-serif;margin:0;background:var(--bg,#f5f6f8);color:#333}"
+ "header{background:#fff;border-bottom:1px solid #e5e6eb;padding:0 32px;display:flex;align-items:center;justify-content:space-between;height:64px}"
+ "nav{background:var(--primary);padding:0 32px}nav a{color:#fff;text-decoration:none;margin-right:24px;line-height:44px;font-size:15px}"
+ ".wrap{max-width:960px;margin:20px auto;padding:0 16px}"
+ ".banner{background:linear-gradient(135deg,var(--primary),var(--primary-dark));border-radius:10px;height:180px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:22px}"
+ ".card{background:#fff;border-radius:8px;padding:20px 24px;margin:14px 0}"
+ "li{padding:8px 0;border-bottom:1px dashed #eee;display:flex;justify-content:space-between}"
+ "li a{color:var(--primary);text-decoration:none}.meta{color:#999;font-size:13px}"
+ ".tip-bar{background:#fff8e6;border:1px solid #ffe1a8;color:#8a6d3b;font-size:12px;padding:6px 12px;border-radius:6px;margin-top:10px}"
+ "</style></head><body>"
+ "<header><h1>" + site.name + "</h1><span style='color:#999;font-size:13px'>Mock 简化预览(内容源:" + site.code + ")</span></header>"
+ "<nav><a href='javascript:void(0)'>首页</a><a href='javascript:void(0)'>学校概况</a><a href='javascript:void(0)'>新闻中心</a><a href='javascript:void(0)'>教学科研</a></nav>"
+ "<div class='wrap'>"
+ "<div class='tip-bar'>正在预览皮肤:" + themeTitle + "(Mock 仅示意配色与布局;p_* 参数已应用,真实效果以连后端预览为准)</div>"
+ "<div class='banner'>通栏轮播示意(bannerSize " + bannerSize + ")</div>"
+ "<div class='card'><h2 style='margin:4px 0 8px'>文章列表(内容源 " + site.code + ",仅已发布)</h2><ul>" + listItems + "</ul></div>"
+ "</div></body></html>");
}
function sendHtml(res: ServerResponse, html: string) {
res.statusCode = 200;
res.setHeader("Content-Type", "text/html;charset=utf-8");
res.setHeader("Cache-Control", "no-store");
res.end(html);
}
/* ===== /api 路由 ===== */
async function handleApi(req: IncomingMessage, res: ServerResponse, path: string): Promise<boolean> {
const method = (req.method || "GET").toUpperCase();
/* ---- 主题(方案 §6.4 mock:list / 资产占位 / preview 简化渲染) ---- */
if (method === "GET" && path === "/api/theme/list") {
sendJson(res, ok(THEMES));
return true;
}
const assetMatch = path.match(/^\/api\/theme\/([a-z0-9-]+)\/asset\/(.+)$/);
if (method === "GET" && assetMatch) {
if (!themeByName(assetMatch[1])) {
res.statusCode = 404;
res.end("not found");
return true;
}
// 截图占位:SVG(名字 hash 配色),足够卡片缩略图示意
sendSvg(res, assetMatch[1] + "-" + assetMatch[2]);
return true;
}
if (method === "GET" && path === "/api/theme/preview") {
sendThemePreview(req, res);
return true;
}
/* ---- 认证 ---- */
if (method === "GET" && path === "/api/auth/user") {
sendJson(res, ok({
account: "admin",
name: "系统管理员",
currentRole: { roleId: 1, roleName: "平台超级管理员", schId: 1001, schName: "云海大学" },
superAdmin: true,
sites: db.sites.filter(s => s.status === 1),
roleIds: [1],
}));
return true;
}
/* ---- 平台代理域(Notice BaseApiController 同款契约) ---- */
if (method === "GET" && path === "/api/base/GetUserInfo") {
sendJson(res, ok({
id: 1,
account: "admin",
name: "系统管理员",
profilePicture: "",
currentRole: { roleId: 1, roleName: "平台超级管理员", schId: 1001, schName: "云海大学" },
}));
return true;
}
if (method === "GET" && path === "/api/base/GetUserMod") {
// 形状对齐基础平台真实返回:id/appId/modId/name/parentId/path/level/page/icon/orderNumber/usable/children/modules
const mod = (id: number, modId: number, name: string, page: string | null,
children: any[] = [], parentId = 0, orderNumber = 0) => ({
id,
appId: 800,
modId,
name,
parentId,
path: null,
level: 0,
page,
icon: null,
orderNumber,
usable: true,
children,
modules: children,
});
sendJson(res, ok([
// 「新建文章」已收敛为文章列表子页面(docs/09 §3.2),菜单树不再下发编辑入口
mod(2101, 1, "内容管理", null, [
mod(2102, 11, "文章列表", "/article", [], 2101, 2102),
]),
mod(2104, 2, "栏目管理", "/category"),
mod(2105, 3, "轮播管理", "/banner"),
mod(2106, 4, "友情链接", "/link"),
mod(2107, 5, "附件管理", "/attachment"),
mod(2108, 6, "站点管理", "/site"),
// 「皮肤包管理」已收敛为平台级页面 /theme-manage(超管工作台按钮进入),不再挂左侧菜单
]));
return true;
}
/* ---- 站点 ---- */
if (method === "GET" && path === "/api/site/list") {
sendJson(res, ok(db.sites.map(s => ({
...s,
parentName: db.sites.find(p => p.id === s.parentSiteId)?.name,
}))));
return true;
}
/* ---- 学校工作台(docs/09 §3.3,SchoolService 同款聚合契约) ---- */
if (method === "GET" && path === "/api/school/list") {
sendJson(res, ok(db.sites
.filter(s => !s.parentSiteId)
.sort((a, b) => a.id - b.id)
.map(main => {
const children = db.sites.filter(s => s.parentSiteId === main.id);
const all = [main, ...children];
return {
schId: main.schId,
schName: main.schName,
mainSiteId: main.id,
mainSiteCode: main.code,
mainSiteName: main.name,
theme: main.theme,
siteCount: all.length,
articleCount: all.reduce((n, s) => n + db.articles.filter(a => a.siteId === s.id).length, 0),
status: main.status,
};
})));
return true;
}
if (method === "GET" && path === "/api/school/options") {
// UserBase oapi getGroupTree(type=2 学校组) 的 mock 等价物
sendJson(res, ok([
{ id: 1001, name: "云海大学" },
{ id: 1002, name: "云海大学附属中学" },
{ id: 1003, name: "云海师范学院" },
{ id: 1004, name: "云海幼师高等专科学校" },
]));
return true;
}
if (method === "POST" && path === "/api/site/save") {
const body: any = await readJson<Partial<MockSite>>(req);
// themeParams 服务端合并(方案 §5.3 同语义:仅替换 theme_params 键,保留其余键,丢弃未声明/非法值)
const themeForParams = themeByName(body.theme || "default");
if (themeForParams && body.themeParams && typeof body.themeParams === "object") {
const config = parseConfig(body.configJson || "{}");
const filtered = filterThemeParams(themeForParams, body.themeParams);
if (Object.keys(filtered).length === 0) delete config.theme_params;
else config.theme_params = filtered;
body.configJson = JSON.stringify(config);
}
delete body.themeParams; // 不落库(configJson 已合并)
if (body.id) {
const i = db.sites.findIndex(s => s.id === body.id);
if (i >= 0) db.sites[i] = { ...db.sites[i], ...body, parentSiteId: body.parentSiteId ?? 0 } as MockSite;
sendJson(res, ok(db.sites[i]));
} else {
const row: MockSite = {
id: nextId(db.sites),
code: body.code || `site${Date.now()}`,
parentSiteId: body.parentSiteId ?? 0,
schId: body.schId ?? 0,
schName: body.schName || "",
name: body.name || "未命名站点",
theme: body.theme || "default",
configJson: body.configJson || "{}",
status: body.status ?? 1,
createTime: now(),
};
db.sites.push(row);
sendJson(res, ok(row));
}
return true;
}
/* ---- 栏目 ---- */
if (method === "GET" && path === "/api/category/tree") {
const siteId = num(q(req, "siteId"), 0)!;
sendJson(res, ok(categoryTree(db, siteId)));
return true;
}
if (method === "POST" && path === "/api/category/save") {
const body = await readJson<any>(req);
if (body.id) {
const i = db.categories.findIndex(c => c.id === body.id);
if (i >= 0) db.categories[i] = { ...db.categories[i], ...body };
sendJson(res, ok(i >= 0 ? db.categories[i] : null));
} else {
const row = {
id: nextId(db.categories),
siteId: body.siteId,
parentId: body.parentId ?? 0,
name: body.name,
type: body.type ?? 1,
linkUrl: body.linkUrl,
template: body.template,
content: body.content,
sortNo: body.sortNo ?? 0,
seoTitle: body.seoTitle,
seoDescription: body.seoDescription,
status: body.status ?? 1,
};
db.categories.push(row);
sendJson(res, ok(row));
}
return true;
}
if (method === "POST" && path === "/api/category/delete") {
const { id } = await readJson<{ id: number }>(req);
const hasChildren = db.categories.some(c => c.parentId === id);
const hasArticles = db.articles.some(a => a.categoryId === id);
if (hasChildren || hasArticles) {
sendJson(res, { code: 4, msg: hasChildren ? "存在子栏目,不允许删除" : "栏目下存在文章,不允许删除", data: null });
return true;
}
db.categories = db.categories.filter(c => c.id !== id);
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/category/sort") {
// UserBase SortChildren 同语义:ids 顺序即 sortNo 1..n
const body = await readJson<{ siteId: number; parentId: number; ids: number[] }>(req);
const children = db.categories
.filter(c => c.siteId === body.siteId && c.parentId === body.parentId)
.sort((x, y) => x.sortNo - y.sortNo);
const idSet = new Set(children.map(c => c.id));
if (new Set(body.ids).size !== body.ids.length || body.ids.some(id => !idSet.has(id))) {
sendJson(res, { code: 4, msg: "ids 中存在不属于该栏目的直接子栏目", data: null });
return true;
}
body.ids.forEach((id, i) => {
const row = db.categories.find(c => c.id === id);
if (row) row.sortNo = i + 1;
});
sendJson(res, ok(null));
return true;
}
/* ---- 文章 ---- */
if (method === "GET" && path === "/api/article/page") {
const siteId = num(q(req, "siteId"), 0)!;
// 与后端 ArticlePageQuery 契约一致:categoryIds(逗号分隔多选;栏目工作台传单值)
const categoryId = num(q(req, "categoryIds"));
const status = num(q(req, "status"));
const keyword = q(req, "keyword") || "";
const page = num(q(req, "page"), 1)!;
const pageSize = num(q(req, "pageSize"), 10)!;
const filterCategoryIds = categoryId ? categoryWithChildren(siteId, categoryId) : null;
let rows = db.articles.filter(a => a.siteId === siteId);
if (filterCategoryIds) rows = rows.filter(a => filterCategoryIds.includes(a.categoryId));
if (status !== undefined) rows = rows.filter(a => a.status === status);
if (keyword) rows = rows.filter(a => a.title.includes(keyword) || a.summary.includes(keyword));
rows = [...rows].sort(
(x, y) => y.topmost - x.topmost || (y.createTime || "").localeCompare(x.createTime || "")
);
const total = rows.length;
const list = rows.slice((page - 1) * pageSize, page * pageSize).map(a => ({
...a,
categoryName: categoryName(db, a.categoryId),
}));
sendJson(res, ok({ total, list }));
return true;
}
if (method === "GET" && path === "/api/article/get") {
const id = num(q(req, "id"), 0)!;
const art = db.articles.find(a => a.id === id);
sendJson(res, ok(art ? { ...art, extJson: "{}" } : null));
return true;
}
if (method === "POST" && path === "/api/article/save") {
const body = await readJson<any>(req);
let id: number;
if (body.id) {
const i = db.articles.findIndex(a => a.id === body.id);
if (i < 0) {
sendJson(res, { code: 4, msg: "文章不存在", data: null });
return true;
}
db.articles[i] = { ...db.articles[i], ...body, id: body.id };
id = body.id;
} else {
const row = {
id: nextId(db.articles),
offlineTime: undefined,
viewCount: 0,
createTime: now(),
createBy: "admin",
status: 0,
topmost: 0,
summary: "",
content: "",
authorName: "",
source: "",
...body,
};
db.articles.push(row);
id = row.id;
}
// 本次编辑上传的附件挂到该文章(与后端 attachmentIds 绑定语义一致)
if (Array.isArray(body.attachmentIds) && body.attachmentIds.length) {
db.attachments.forEach(t => {
if (body.attachmentIds.includes(t.id)) t.articleId = id;
});
}
sendJson(res, ok(id));
return true;
}
const opMatch = path.match(/^\/api\/article\/(publish|offline|topmost)\/(\d+)$/);
if (method === "POST" && opMatch) {
const id = parseInt(opMatch[2], 10);
const art = db.articles.find(a => a.id === id);
if (!art) {
sendJson(res, { code: 4, msg: "文章不存在", data: null });
return true;
}
if (opMatch[1] === "publish") {
art.status = 1;
art.publishTime = now();
} else if (opMatch[1] === "offline") {
art.status = 2;
art.offlineTime = now();
} else {
art.topmost = q(req, "enable") === "false" ? 0 : 1;
}
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/article/delete") {
const { id } = await readJson<{ id: number }>(req);
db.articles = db.articles.filter(a => a.id !== id);
// 正文附件孤儿化(与后端语义一致)
db.attachments.forEach(t => {
if (t.articleId === id) t.articleId = undefined;
});
sendJson(res, ok(null));
return true;
}
/* ---- 附件 ---- */
if (method === "GET" && path === "/api/attachment/page") {
const siteId = num(q(req, "siteId"), 0)!;
const orphanOnly = q(req, "orphanOnly") === "true";
const page = num(q(req, "page"), 1)!;
const pageSize = num(q(req, "pageSize"), 10)!;
let rows = db.attachments.filter(t => t.siteId === siteId);
if (orphanOnly) rows = rows.filter(t => !t.articleId && !t.refType); // B2 未使用语义
const total = rows.length;
const list = rows
.slice((page - 1) * pageSize, page * pageSize)
.map((t: MockAttachment) => ({ ...t, orphan: !t.articleId }));
sendJson(res, ok({ total, list, unusedCount: db.attachments.filter(t => t.siteId === siteId && !t.articleId && !t.refType).length })); // B2:orphanCount→unusedCount
return true;
}
if (method === "POST" && path === "/api/attachment/upload") {
const body = await readBody(req);
const file = extractFile(body);
if (!file) {
sendJson(res, { code: 4, msg: "Mock 上传解析失败", data: null });
return true;
}
const safeName = file.name.replace(/[\\/:*?"<>|]/g, "_");
const url = `/mock-files/${Date.now()}-${encodeURIComponent(safeName)}`;
db.files.set(url, { buffer: file.buffer, mime: file.mime, name: safeName });
const siteId = num(q(req, "siteId"), 0) ?? num(body.toString("latin1").match(/name="siteId"\r\n\r\n(\d+)/)?.[1], 0)!;
const articleId = num(q(req, "articleId")) ?? num(body.toString("latin1").match(/name="articleId"\r\n\r\n(\d+)/)?.[1], undefined);
// B2:scene 初步标记(banner/link/site 白名单,其余忽略)
const sceneRaw = q(req, "scene");
const scene = ["banner", "link", "site"].includes(sceneRaw) ? sceneRaw : undefined;
const row: MockAttachment = {
id: nextId(db.attachments),
siteId,
articleId,
refType: scene,
fileName: safeName,
url,
fileSize: file.buffer.length,
mimeType: file.mime,
createTime: now(),
};
db.attachments.push(row);
sendJson(res, ok({ ...row, orphan: !row.articleId && !row.refType }));
return true;
}
if (method === "POST" && path === "/api/attachment/delete") {
const { id } = await readJson<{ id: number }>(req);
db.attachments = db.attachments.filter(t => t.id !== id);
sendJson(res, ok(null));
return true;
}
/* ---- 轮播 ---- */
if (method === "GET" && path === "/api/banner/list") {
const siteId = num(q(req, "siteId"), 0)!;
const position = q(req, "position");
let rows = db.banners.filter(b => b.siteId === siteId);
if (position) rows = rows.filter(b => b.position === position);
rows = [...rows].sort((x, y) => x.sortNo - y.sortNo || x.id - y.id);
sendJson(res, ok(rows));
return true;
}
if (method === "POST" && path === "/api/banner/save") {
const body = await readJson<Partial<MockBanner>>(req);
if (body.id) {
const i = db.banners.findIndex(b => b.id === body.id);
if (i >= 0) db.banners[i] = { ...db.banners[i], ...body } as MockBanner;
sendJson(res, ok(db.banners[i]));
} else {
const row: MockBanner = {
id: nextId(db.banners),
siteId: body.siteId!,
position: body.position || "home",
title: body.title || "",
imageUrl: body.imageUrl || "",
linkType: body.linkType ?? 3,
linkValue: body.linkValue,
sortNo: body.sortNo ?? 0,
startTime: body.startTime,
endTime: body.endTime,
status: body.status ?? 1,
};
db.banners.push(row);
sendJson(res, ok(row));
}
return true;
}
if (method === "POST" && path === "/api/banner/delete") {
const { id } = await readJson<{ id: number }>(req);
db.banners = db.banners.filter(b => b.id !== id);
sendJson(res, ok(null));
return true;
}
/* ---- 友情链接 ---- */
if (method === "GET" && path === "/api/link/list") {
const siteId = num(q(req, "siteId"), 0)!;
const rows = db.links
.filter(l => l.siteId === siteId)
.sort((x, y) => x.groupName.localeCompare(y.groupName) || x.sortNo - y.sortNo || x.id - y.id);
sendJson(res, ok(rows));
return true;
}
if (method === "POST" && path === "/api/link/save") {
const body = await readJson<Partial<MockLink>>(req);
if (body.id) {
const i = db.links.findIndex(l => l.id === body.id);
if (i >= 0) db.links[i] = { ...db.links[i], ...body } as MockLink;
sendJson(res, ok(db.links[i]));
} else {
const row: MockLink = {
id: nextId(db.links),
siteId: body.siteId!,
groupName: body.groupName || "default",
name: body.name || "",
logoUrl: body.logoUrl,
linkUrl: body.linkUrl || "",
sortNo: body.sortNo ?? 0,
status: body.status ?? 1,
};
db.links.push(row);
sendJson(res, ok(row));
}
return true;
}
if (method === "POST" && path === "/api/link/delete") {
const { id } = await readJson<{ id: number }>(req);
db.links = db.links.filter(l => l.id !== id);
sendJson(res, ok(null));
return true;
}
return false;
}
/* ===== vite 插件 ===== */
export function cmsMockPlugin(): Plugin {
return {
name: "cms-dev-mock",
apply: "serve",
configureServer(server) {
server.middlewares.use((req, res, next) => {
void handleMock(req, res, next);
});
},
};
}
async function handleMock(req: IncomingMessage, res: ServerResponse, next: () => void) {
const url = req.url || "/";
const path = new URL(url, "http://mock.local").pathname;
try {
if (path === "/api/attachment/upload") {
await handleApi(req, res, path);
return;
}
if (path.startsWith("/api/")) {
if (await handleApi(req, res, path)) return;
}
if (path.startsWith("/mock-img/")) {
sendSvg(res, decodeURIComponent(path.slice("/mock-img/".length)));
return;
}
if (path.startsWith("/mock-files/")) {
const hit = db.files.get(path);
if (hit) {
res.statusCode = 200;
res.setHeader("Content-Type", hit.mime);
res.setHeader("Content-Disposition", `inline; filename*=UTF-8''${encodeURIComponent(hit.name)}`);
res.end(hit.buffer);
} else {
// 种子数据指向的占位文件:SVG 兜底(含文件名文字)
sendSvg(res, decodeURIComponent(path.slice("/mock-files/".length)));
}
return;
}
if (path === "/portal" || path.startsWith("/portal/")) {
portalPage(req, res);
return;
}
} catch (e: any) {
sendJson(res, { code: 500, msg: `Mock error: ${e?.message}`, data: null }, 200);
return;
}
next();
}