Files
YschcmsFront/mock/index.ts
T
chenxing a05b0ded55
build / build (push) Successful in 3m14s
feat(svcitem): 事项库+办事大厅 R2 前端三页,并复用富文本组件消除双份实现
落地(依据《事项库与办事大厅详细设计 v1》§7)
- 路由:办事事项 modId=21 / 服务字典 modId=22 + 子站镜像(/sch/{code}/site/{siteCode}/svc/*)
  + 3 条 legacyRedirect 旧链兼容;SchoolLayout 菜单图标兜底
- ServiceItemList:三维筛选/关键字/分页/发布下架删除/跳转编辑
- ServiceItemEdit:三段富文本 + 封面 + 指南附件上传回显(attList)+「我要办理外链」提示
  + 部门选择器不可达时提示"可留空保存"
- ServiceDictManage:object/category 双 Tab,被引用键编辑锁定 + 删除引用守护提示
- api/类型:SvcItemApi/SvcDictApi 与 types/cms.ts 契约补齐;站点设置补 svc_hall_base 键

审计修复(docs/18)
- D3 接线:列表页新增 ServiceItemSortModal 拖拽排序(顺序源 selList,保存按下标重排 sort_no)
  与「同步部门名」按钮,此前后端 sort/syncDeptName 已备但前端无入口
- 附件场景拆分:封面与三个富文本编辑器改传 scene=svc_item_asset,指南附件保持 svc_item
  (内容资产由后端按内容派生归属,不再依赖前端收集上传 id——删除只写不读的 uploadedIds 记账)
- 部门组树:SvcDeptVo 增 children,前端 flattenDepts 缩进扁平化(后端改嵌套下发,交互不变)
- RichTextEditor 补 articleId/slateMinHeight prop 并去掉死状态 editorLoading;
  ArticleEdit 改为复用该组件(正文区最小高度按原口径传入,观感不变),消除双份 wangEditor 实现

测试与 mock
- mock 补 /api/svc/** 全量桩(事项分页/字典/部门树/排序/同步部门名/附件回显 + 站点 1、2 示例数据),
  dev:mock 下三页可用(实测 curl 各项响应正确)
- cms.spec 补 save/syncDeptName/selList/dict save+delete 契约;siteRouter.spec 补事项三条路由
  modId 与子站镜像 resolve 断言;新增 RichTextEditor.spec 5 例(上传参数三键/成功 emit/失败不 emit/工具栏排除项)
- vitest 127 用例全绿 + vite build 通过
2026-09-11 14:14:02 +08:00

1094 lines
48 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, MockSvcDict, MockSvcItem } 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;
}
/* ---- 事项库:服务字典 + 服务部门 ---- */
if (method === "GET" && path === "/api/svc/dict/list") {
const siteId = num(q(req, "siteId"), 0)!;
const dictType = q(req, "dictType");
const rows = db.svcDicts
.filter(d => d.siteId === siteId && (!dictType || d.dictType === dictType))
.sort((x, y) => x.sortNo - y.sortNo || x.id - y.id)
.map(d => ({
...d,
referenced: db.svcItems.filter(i =>
d.dictType === "category" ? i.categoryKey === d.dictKey : i.objectKey === d.dictKey
).length,
}));
sendJson(res, ok(rows));
return true;
}
if (method === "POST" && path === "/api/svc/dict/save") {
const body = await readJson<Partial<MockSvcDict>>(req);
if (body.id) {
const i = db.svcDicts.findIndex(d => d.id === body.id);
if (i >= 0) db.svcDicts[i] = { ...db.svcDicts[i], ...body } as MockSvcDict;
sendJson(res, ok(db.svcDicts[i]?.id ?? null));
} else {
const row: MockSvcDict = {
id: nextId(db.svcDicts),
siteId: body.siteId!,
dictType: body.dictType || "object",
dictKey: body.dictKey || "",
dictName: body.dictName || "",
sortNo: body.sortNo ?? 0,
status: body.status ?? 1,
};
db.svcDicts.push(row);
sendJson(res, ok(row.id));
}
return true;
}
if (method === "POST" && path === "/api/svc/dict/delete") {
const { id } = await readJson<{ id: number }>(req);
db.svcDicts = db.svcDicts.filter(d => d.id !== id);
sendJson(res, ok(null));
return true;
}
if (method === "GET" && path === "/api/svc/dept/tree") {
// 模拟 UserBase 组树经 type 过滤后嵌套下发:学校 → 校内处室
sendJson(res, ok([
{
id: 100,
name: "云海大学",
children: [
{ id: 10, name: "教务处", children: [] },
{ id: 20, name: "后勤处", children: [] },
{ id: 30, name: "学生工作处", children: [] },
],
},
]));
return true;
}
/* ---- 事项库:办事事项管理 ---- */
if (method === "GET" && path === "/api/svc/item/page") {
const siteId = num(q(req, "siteId"), 0)!;
const status = q(req, "status");
const objectKey = q(req, "objectKey");
const categoryKey = q(req, "categoryKey");
const keyword = q(req, "keyword");
const page = num(q(req, "page"), 1)!;
const pageSize = num(q(req, "pageSize"), 10)!;
const dictName = (type: "object" | "category", key?: string) =>
key ? db.svcDicts.find(d => d.siteId === siteId && d.dictType === type && d.dictKey === key)?.dictName : undefined;
const filtered = db.svcItems
.filter(i => i.siteId === siteId)
.filter(i => (status === undefined || status === null || status === "" ? true : i.status === Number(status)))
.filter(i => (!objectKey ? true : i.objectKey === objectKey))
.filter(i => (!categoryKey ? true : i.categoryKey === categoryKey))
.filter(i => (!keyword ? true : i.title.includes(keyword) || i.itemCode.includes(keyword)))
.sort((x, y) => x.sortNo - y.sortNo || y.id - x.id);
const start = (page - 1) * pageSize;
sendJson(res, ok({
total: filtered.length,
list: filtered.slice(start, start + pageSize).map(i => ({
...i,
objectName: dictName("object", i.objectKey),
categoryName: dictName("category", i.categoryKey),
applyUrl: i.extJson ? (JSON.parse(i.extJson).apply_url as string) : undefined,
})),
}));
return true;
}
if (method === "GET" && path === "/api/svc/item/get") {
const id = num(q(req, "id"), 0)!;
const item = db.svcItems.find(i => i.id === id);
if (!item) {
sendJson(res, { code: 2, msg: "事项不存在", data: null });
return true;
}
sendJson(res, ok({
...item,
applyUrl: item.extJson ? (JSON.parse(item.extJson).apply_url as string) : undefined,
}));
return true;
}
if (method === "POST" && path === "/api/svc/item/save") {
const body = await readJson<Partial<MockSvcItem> & { applyUrl?: string }>(req);
const extJson = body.applyUrl ? JSON.stringify({ apply_url: body.applyUrl }) : undefined;
if (body.id) {
const i = db.svcItems.findIndex(x => x.id === body.id);
if (i >= 0) db.svcItems[i] = { ...db.svcItems[i], ...body, extJson } as MockSvcItem;
sendJson(res, ok(db.svcItems[i]?.id ?? null));
} else {
const row: MockSvcItem = {
id: nextId(db.svcItems),
siteId: body.siteId!,
itemCode: body.itemCode || "",
title: body.title || "",
handleMode: body.handleMode ?? 1,
onlineFlag: body.onlineFlag ?? 1,
sortNo: body.sortNo ?? 0,
status: 0,
viewCount: 0,
createTime: daysAgo(0),
...body,
extJson,
} as MockSvcItem;
db.svcItems.push(row);
sendJson(res, ok(row.id));
}
return true;
}
const svcStateMatch = path.match(/^\/api\/svc\/item\/(publish|offline)\/(\d+)$/);
if (method === "POST" && svcStateMatch) {
const item = db.svcItems.find(i => i.id === Number(svcStateMatch[2]));
if (item) {
item.status = svcStateMatch[1] === "publish" ? 1 : 2;
if (item.status === 1) item.publishTime = daysAgo(0);
else item.offlineTime = daysAgo(0);
}
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/svc/item/delete") {
const { id } = await readJson<{ id: number }>(req);
db.svcItems = db.svcItems.filter(i => i.id !== id);
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/svc/item/sort") {
const pairs = await readJson<{ id: number; sortNo: number }[]>(req);
(pairs || []).forEach(p => {
const item = db.svcItems.find(i => i.id === p.id);
if (item) item.sortNo = p.sortNo;
});
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/svc/item/syncDeptName") {
sendJson(res, ok({ updated: 0 }));
return true;
}
if (method === "GET" && path === "/api/svc/item/selList") {
const siteId = num(q(req, "siteId"), 0)!;
const rows = db.svcItems
.filter(i => i.siteId === siteId)
.sort((x, y) => x.sortNo - y.sortNo || x.id - y.id)
.map(i => ({ id: i.id, itemCode: i.itemCode, title: i.title, status: i.status }));
sendJson(res, ok(rows));
return true;
}
if (method === "GET" && path === "/api/svc/item/attList") {
const siteId = num(q(req, "siteId"), 0)!;
const id = num(q(req, "id"), 0)!;
const ids = db.svcItems.find(i => i.id === id)?.attachmentIds || [];
sendJson(res, ok(
db.attachments
.filter(a => a.siteId === siteId && ids.includes(a.id))
.map(a => ({ id: a.id, fileName: a.fileName, webUrl: a.url, fileSize: a.fileSize }))
));
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();
}