init local code

This commit is contained in:
chenxing
2026-09-07 09:11:37 +08:00
commit a3a75c3680
112 changed files with 24837 additions and 0 deletions
+903
View File
@@ -0,0 +1,903 @@
/**
* 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 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;
// /portal/{code} 或 /portal/{code}/...(含 /a/{id})
const m = path.replace(/^\/portal\//, "").split("/");
const site = siteByCode(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.code === site.code ? ' class="on"' : ""}><a href="/portal/${c.code}/">${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 = siteByCode(u.searchParams.get("siteCode") || "");
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"),
]));
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)!;
const categoryId = num(q(req, "categoryId"));
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 categoryIds = categoryId ? categoryWithChildren(siteId, categoryId) : null;
let rows = db.articles.filter(a => a.siteId === siteId);
if (categoryIds) rows = rows.filter(a => categoryIds.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);
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, orphanCount: db.attachments.filter(t => t.siteId === siteId && !t.articleId).length }));
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);
const row: MockAttachment = {
id: nextId(db.attachments),
siteId,
articleId,
fileName: safeName,
url,
fileSize: file.buffer.length,
mimeType: file.mime,
createTime: now(),
};
db.attachments.push(row);
sendJson(res, ok({ ...row, orphan: !row.articleId }));
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();
}