build / build (push) Successful in 3m14s
落地(依据《事项库与办事大厅详细设计 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 通过
1094 lines
48 KiB
TypeScript
1094 lines
48 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|