/** * 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" }, ], }, { name: "academic-navy", title: "学府藏蓝", description: "高校官网门户:藏蓝顶栏 + 金线标题 + 日期方块列表 + 双栏要闻,适合综合类大学/工科学院。", version: "1.0.0", author: "yunhai", bannerSize: "1920x560", screenshotUrl: "/api/theme/academic-navy/asset/screenshot.png", params: [ { key: "primary", cssVar: "--an-primary", label: "主色", type: "color", defaultValue: "#0b4a8f" }, { key: "primaryDark", cssVar: "--an-primary-dark", label: "主色加深", type: "color", defaultValue: "#063161" }, { key: "accent", cssVar: "--an-accent", label: "点缀金", type: "color", defaultValue: "#b8934a" }, ], }, { name: "campus-azure", title: "青蓝校园", description: "高校官网门户:青蓝顶栏 + 居中双线标题 + 全宽封面头条 + 圆点密行,适合师范/农林类院校。", version: "1.0.0", author: "yunhai", bannerSize: "1920x560", screenshotUrl: "/api/theme/campus-azure/asset/screenshot.png", params: [ { key: "primary", cssVar: "--cz-primary", label: "主色", type: "color", defaultValue: "#0d6a9e" }, { key: "primaryDark", cssVar: "--cz-primary-dark", label: "主色加深", type: "color", defaultValue: "#084c73" }, { key: "accent", cssVar: "--cz-accent", label: "点缀青绿", type: "color", defaultValue: "#2b9c8f" }, ], }, ]; function themeByName(name: string): MockTheme | undefined { return THEMES.find(t => t.name === name); } /** 解析 configJson(非法 JSON 按空对象,与后端 ExtJsonUtils.parse 同语义) */ function parseConfig(configJson: string): Record { 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): Record { const out: Record = {}; 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 { 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(req: IncomingMessage): Promise { 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 ` ${short} Mock 占位图 `; } 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 => `${n}`) .join(""); if (!site) { res.statusCode = 200; res.setHeader("Content-Type", "text/html;charset=utf-8"); res.end(`

Mock 预览

站点 code「${m[0]}」不存在,请先在站点管理创建。

`); return; } // 专业院系下拉:顶层站取其子站、院系子站取兄弟站(含自身),与真实门户 header 同语义 const anchorId = site.parentSiteId || site.id; const colleges = db.sites.filter(s => s.parentSiteId === anchorId && s.status === 1); const collegesHtml = colleges.length ? `
专业院系 ▾
` : ""; 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 ? `

${art.title}

发布时间:${art.publishTime || "未发布"} 浏览量:${art.viewCount} 来源:${art.source || "本站"}(Mock 数据)

${art.content || "

(无正文)

"}
` : `

文章不存在或未发布

Mock 提示:仅「已发布」文章在门户可见。

`; } 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 = ` `; } res.statusCode = 200; res.setHeader("Content-Type", "text/html;charset=utf-8"); // 防止 dev 缓存把 SPA index.html 与门户页互相串味 res.setHeader("Cache-Control", "no-store"); res.end(`${site.name}(Mock 预览)

${site.name}

Mock 模式门户预览(数据为内存模拟,不影响真实后端)${collegesHtml}
${main}
`); } /* ===== 主题预览(简化渲染::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 = {}; 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 = {}; 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, "

Mock 主题预览

内容源站点不存在。预览需要 content site:请先创建并启用站点。

"); 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 => "
  • " + (a.topmost === 1 ? "【置顶】" : "") + a.title + "" + (a.publishTime || "") + "
  • " ).join("") || "
  • 暂无已发布文章
  • "; sendHtml(res, "" + "" + themeTitle + " · 预览(Mock)" + "

    " + site.name + "

    Mock 简化预览(内容源:" + site.code + ")
    " + "" + "
    " + "
    正在预览皮肤:" + themeTitle + "(Mock 仅示意配色与布局;p_* 参数已应用,真实效果以连后端预览为准)
    " + "" + "

    文章列表(内容源 " + site.code + ",仅已发布)

      " + listItems + "
    " + "
    "); } 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 { 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>(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(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(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>(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>(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>(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 & { 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(); }