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
+400
View File
@@ -0,0 +1,400 @@
/**
* Mock 内存数据库 + 种子数据(仅 dev mock 模式使用,vite.config 按需加载)。
* 数据形状与 com.yunhai.cms.model.vo 一一对应;时间串用 "yyyy-MM-dd HH:mm:ss"。
*/
/* ===== 行类型 ===== */
export interface MockSite {
id: number;
code: string;
/** 上级站点ID(0=顶层校站点;院系子站挂主站 id) */
parentSiteId: number;
schId: number;
schName: string;
name: string;
theme: string;
configJson: string;
status: number;
createTime: string;
}
export interface MockCategory {
id: number;
siteId: number;
parentId: number;
name: string;
type: number;
linkUrl?: string;
template?: string;
content?: string;
sortNo: number;
seoTitle?: string;
seoDescription?: string;
status: number;
}
export interface MockArticle {
id: number;
siteId: number;
categoryId: number;
title: string;
summary: string;
content: string;
coverImage?: string;
authorName: string;
source: string;
/** 0=草稿 1=已发布 2=已下架 */
status: number;
topmost: number;
publishTime?: string;
offlineTime?: string;
viewCount: number;
createTime: string;
createBy: string;
}
export interface MockAttachment {
id: number;
siteId: number;
articleId?: number;
fileName: string;
url: string;
fileSize: number;
mimeType: string;
createTime: string;
}
export interface MockBanner {
id: number;
siteId: number;
position: string;
title: string;
imageUrl: string;
linkType: number;
linkValue?: string;
sortNo: number;
startTime?: string;
endTime?: string;
status: number;
}
export interface MockLink {
id: number;
siteId: number;
groupName: string;
name: string;
logoUrl?: string;
linkUrl: string;
sortNo: number;
status: number;
}
export interface MockDb {
sites: MockSite[];
categories: MockCategory[];
articles: MockArticle[];
attachments: MockAttachment[];
banners: MockBanner[];
links: MockLink[];
/** 上传文件缓存:url 尾段 → {buffer, mime} */
files: Map<string, { buffer: Buffer; mime: string; name: string }>;
}
/* ===== 工具 ===== */
function fmt(d: Date): string {
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())}`;
}
function daysAgo(n: number, h = 9, m = 30): string {
const d = new Date();
d.setDate(d.getDate() - n);
d.setHours(h, m, 0, 0);
return fmt(d);
}
/** 简单富文本正文(栏目/文章种子共用) */
function para(...ps: string[]): string {
return ps.map(t => `<p>${t}</p>`).join("");
}
/* ===== 种子数据 ===== */
export function seedDb(): MockDb {
const db: MockDb = {
sites: [],
categories: [],
articles: [],
attachments: [],
banners: [],
links: [],
files: new Map(),
};
/* ---- 站点(demo 为主站 + jdx/xxgc 院系子站;fsz 独立顶层站无子站) ---- */
db.sites.push(
{
id: 1,
code: "demo",
parentSiteId: 0,
schId: 1001,
schName: "云海大学",
name: "云海大学门户网站",
theme: "default",
configJson: JSON.stringify(
{
domain: "www.yunhai-demo.edu.cn",
public_base_url: "http://www.yunhai-demo.edu.cn",
logo: "/mock-img/logo-univ.svg",
copyright: "云海大学 · 版权所有 © 2026(Mock 数据)",
},
null,
2
),
status: 1,
createTime: daysAgo(90),
},
{
id: 2,
code: "fsz",
parentSiteId: 0,
schId: 1002,
schName: "云海大学附属中学",
name: "附中信息门户",
theme: "blue-white",
configJson: JSON.stringify({ copyright: "云大附中 · Mock 数据" }),
status: 1,
createTime: daysAgo(60),
},
{
id: 3,
code: "jdx",
parentSiteId: 1,
schId: 1001,
schName: "云海大学",
name: "机电学院",
theme: "default",
configJson: JSON.stringify({
public_base_url: "http://www.yunhai-demo.edu.cn/college/jdx",
copyright: "机电学院 · Mock 数据",
}),
status: 1,
createTime: daysAgo(50),
},
{
id: 4,
code: "xxgc",
parentSiteId: 1,
schId: 1001,
schName: "云海大学",
name: "信息工程学院",
theme: "default",
configJson: JSON.stringify({
public_base_url: "http://www.yunhai-demo.edu.cn/college/xxgc",
copyright: "信息工程学院 · Mock 数据",
}),
status: 1,
createTime: daysAgo(45),
}
);
/* ---- 栏目(site1 大学门户) ---- */
const c1 = (o: Partial<MockCategory> & { id: number; name: string; type: number }) => ({
siteId: 1,
parentId: 0,
sortNo: 0,
status: 1,
...o,
} as MockCategory);
db.categories.push(
c1({ id: 1, name: "学校概况", type: 2, sortNo: 1, seoTitle: "云海大学简介", content: para("云海大学创建于 1958 年,是一所以工为主、多学科协调发展的综合性大学。", "学校现有全日制在校生 3.2 万余人,教职工 2800 余人,拥有两院院士 12 名。(此处为 Mock 演示数据)") }),
c1({ id: 2, name: "新闻中心", type: 1, sortNo: 2 }),
c1({ id: 3, name: "通知公告", type: 1, parentId: 2, sortNo: 1, seoTitle: "通知公告" }),
c1({ id: 4, name: "校园动态", type: 1, parentId: 2, sortNo: 2 }),
c1({ id: 5, name: "媒体报道", type: 1, parentId: 2, sortNo: 3 }),
c1({ id: 6, name: "教学科研", type: 1, sortNo: 3 }),
c1({ id: 7, name: "教学动态", type: 1, parentId: 6, sortNo: 1 }),
c1({ id: 8, name: "科研成果", type: 1, parentId: 6, sortNo: 2 }),
c1({ id: 9, name: "招生网", type: 3, sortNo: 4, linkUrl: "https://zs.example.edu.cn" }),
c1({ id: 10, name: "联系我们", type: 2, sortNo: 5, content: para("地址:云海市学府路 1 号", "邮编:100000", "电话:010-88888888(Mock)") })
);
/* ---- 栏目(site2 附中门户,验证站点数据隔离) ---- */
const c2 = (o: Partial<MockCategory> & { id: number; name: string; type: number }) => ({
siteId: 2,
parentId: 0,
sortNo: 0,
status: 1,
...o,
} as MockCategory);
db.categories.push(
c2({ id: 21, name: "附中新闻", type: 1, sortNo: 1 }),
c2({ id: 22, name: "校园风采", type: 2, sortNo: 2, content: para("附中校园风光介绍(Mock)。") })
);
/* ---- 栏目(site3/4 院系子站,门户 header 出"专业院系"下拉) ---- */
const c3 = (siteId: number) => (o: Partial<MockCategory> & { id: number; name: string; type: number }) => ({
siteId,
parentId: 0,
sortNo: 0,
status: 1,
...o,
} as MockCategory);
const c3j = c3(3);
const c3x = c3(4);
db.categories.push(
c3j({ id: 41, name: "学院概况", type: 2, sortNo: 1, seoTitle: "机电学院简介", content: para("机电学院设有机电一体化技术、电气自动化技术、机械制造及自动化、物联网应用技术等专业(Mock)。") }),
c3j({ id: 42, name: "学院新闻", type: 1, sortNo: 2 }),
c3j({ id: 43, name: "实习实训", type: 1, sortNo: 3 }),
c3x({ id: 44, name: "学院概况", type: 2, sortNo: 1, seoTitle: "信息工程学院简介", content: para("信息工程学院开设计算机应用技术、软件技术、大数据技术等专业(Mock)。") }),
c3x({ id: 45, name: "学院动态", type: 1, sortNo: 2 })
);
/* ---- 文章 ---- */
let aid = 0;
const a = (o: Partial<MockArticle> & { categoryId: number; title: string }) => ({
id: ++aid,
siteId: 1,
summary: "",
content: "",
authorName: "张老师",
source: "",
status: 1,
topmost: 0,
viewCount: Math.floor(Math.random() * 2000) + 100,
createTime: daysAgo(aid),
createBy: "admin",
...o,
} as MockArticle);
const body = (t: string) =>
para(
`${t}——本文为 Mock 演示数据,用于前端界面预览。`,
"云海大学始终坚持立德树人根本任务,深入推进教育教学改革,人才培养质量稳步提升。",
`正文第二段:近日,${t}相关活动在校园内顺利举行,广大师生积极参与,反响热烈。`,
`<p><img src="/mock-img/art-${aid}.svg" alt="配图" style="max-width:100%"/></p>`,
"正文第三段:学校相关负责人表示,将持续完善各项工作机制,以更高标准推动事业发展。"
);
db.articles.push(
a({ categoryId: 3, title: "关于 2026 年秋季学期开学注册安排的通知", topmost: 1, publishTime: daysAgo(1), content: body("关于 2026 年秋季学期开学注册安排的通知"), summary: "现将 2026 年秋季学期开学注册有关事项通知如下……" }),
a({ categoryId: 3, title: "图书馆国庆节期间开放时间调整公告", publishTime: daysAgo(3), content: body("图书馆开放时间调整") }),
a({ categoryId: 3, title: "关于开展 2026 级新生入学教育系列活动的通知", publishTime: daysAgo(6), content: body("新生入学教育活动") }),
a({ categoryId: 3, title: "校园网络升级维护通知(草稿)", status: 0, content: body("校园网络升级维护"), authorName: "李老师" }),
a({ categoryId: 4, title: "我校成功举办第 38 届田径运动会", topmost: 1, coverImage: "/mock-img/cover-1.svg", publishTime: daysAgo(2), content: body("第 38 届田径运动会"), summary: "为期三天的校运会圆满落幕,共有 16 个学院代表队参赛。" }),
a({ categoryId: 4, title: "2026 年迎新晚会精彩回顾", coverImage: "/mock-img/cover-2.svg", publishTime: daysAgo(5), content: body("迎新晚会") }),
a({ categoryId: 4, title: "大学生创新创业大赛校内选拔赛落幕", coverImage: "/mock-img/cover-3.svg", publishTime: daysAgo(9), content: body("创新创业大赛") }),
a({ categoryId: 4, title: "志愿服务月活动启动(草稿)", status: 0, content: body("志愿服务月") }),
a({ categoryId: 4, title: "2025 年优秀学生表彰大会报道(已下架)", status: 2, publishTime: daysAgo(40), offlineTime: daysAgo(10), content: body("表彰大会") }),
a({ categoryId: 5, title: "《云海日报》:云海大学深耕产教融合一线观察", publishTime: daysAgo(4), source: "云海日报", content: body("产教融合") }),
a({ categoryId: 5, title: "市电视台专访我校智能制造实验室", publishTime: daysAgo(12), source: "云海电视台", content: body("智能制造实验室") }),
a({ categoryId: 7, title: "我校 12 门课程获评省级一流本科课程", publishTime: daysAgo(7), content: body("省级一流课程") }),
a({ categoryId: 7, title: "关于组织申报 2027 年教改项目的预通知", publishTime: daysAgo(15), content: body("教改项目申报") }),
a({ categoryId: 8, title: "材料学院团队在《Nature》子刊发表最新成果", topmost: 1, publishTime: daysAgo(2), content: body("《Nature》子刊成果"), summary: "该研究首次揭示了新型钙钛矿材料的界面稳定性机制。" }),
a({ categoryId: 8, title: "我校获国家重点研发计划项目立项 3 项", publishTime: daysAgo(18), content: body("重点研发计划立项") }),
a({ categoryId: 8, title: "人工智能学院发布大模型教育应用白皮书(草稿)", status: 0, content: body("大模型教育白皮书") })
);
/* 附中站点少量文章 */
db.articles.push(
a({ siteId: 2, categoryId: 21, title: "附中举行新学期升旗仪式", publishTime: daysAgo(2), content: body("升旗仪式") }),
a({ siteId: 2, categoryId: 21, title: "附中啦啦操队获市级比赛一等奖", publishTime: daysAgo(8), content: body("啦啦操一等奖") }),
a({ siteId: 2, categoryId: 21, title: "高三年级家长会通知(草稿)", status: 0, content: body("家长会通知") })
);
/* 院系子站文章 */
db.articles.push(
a({ siteId: 3, categoryId: 42, title: "机电学院学生在省级技能大赛中斩获一等奖", topmost: 1, publishTime: daysAgo(1), content: body("省级技能大赛一等奖"), summary: "机电一体化专业学生团队获省级技能大赛一等奖。" }),
a({ siteId: 3, categoryId: 42, title: "机电学院召开 2026 届毕业生就业推进会", publishTime: daysAgo(4), content: body("就业推进会") }),
a({ siteId: 3, categoryId: 43, title: "工业机器人实训室投入使用", publishTime: daysAgo(7), content: body("实训室投用") }),
a({ siteId: 3, categoryId: 42, title: "实训设备采购论证会(草稿)", status: 0, content: body("设备采购论证") }),
a({ siteId: 4, categoryId: 45, title: "信息工程学院举办程序设计校内选拔赛", publishTime: daysAgo(3), content: body("程序设计选拔赛") }),
a({ siteId: 4, categoryId: 45, title: "软件技术专业开展企业导师课", publishTime: daysAgo(6), content: body("企业导师课") })
);
/* ---- 附件 ---- */
let atid = 0;
const at = (o: Partial<MockAttachment> & { fileName: string; url: string }) => ({
id: ++atid,
siteId: 1,
fileSize: Math.floor(Math.random() * 5 * 1024 * 1024) + 10240,
mimeType: "application/octet-stream",
createTime: daysAgo(atid * 2 + 1),
...o,
} as MockAttachment);
db.attachments.push(
at({ fileName: "2026秋季学期校历.pdf", url: "/mock-files/2026校历.pdf", mimeType: "application/pdf", articleId: 1 }),
at({ fileName: "运动会秩序册.pdf", url: "/mock-files/运动会秩序册.pdf", mimeType: "application/pdf", articleId: 5 }),
at({ fileName: "迎新晚会节目单.docx", url: "/mock-files/迎新晚会节目单.docx", articleId: 6 }),
at({ fileName: "banner-旧图.png", url: "/mock-img/banner-old.svg", mimeType: "image/png" }),
at({ fileName: "未使用配图.jpg", url: "/mock-img/orphan.svg", mimeType: "image/jpeg" })
);
/* ---- 轮播 ---- */
db.banners.push(
{ id: 1, siteId: 1, position: "home", title: "笃学慎思 明辨笃行", imageUrl: "/mock-img/banner-1.svg", linkType: 2, linkValue: "4", sortNo: 1, status: 1, startTime: daysAgo(30), endTime: undefined },
{ id: 2, siteId: 1, position: "home", title: "第 38 届田径运动会", imageUrl: "/mock-img/banner-2.svg", linkType: 1, linkValue: "5", sortNo: 2, status: 1, startTime: daysAgo(20) },
{ id: 3, siteId: 1, position: "home", title: "2026 年招生咨询", imageUrl: "/mock-img/banner-3.svg", linkType: 3, linkValue: "https://zs.example.edu.cn", sortNo: 3, status: 1, startTime: daysAgo(10) },
{ id: 4, siteId: 2, position: "home", title: "附中欢迎你", imageUrl: "/mock-img/banner-4.svg", linkType: 3, linkValue: "https://fsz.example.cn", sortNo: 1, status: 1 },
{ id: 5, siteId: 3, position: "home", title: "智能制造 匠心筑梦", imageUrl: "/mock-img/banner-2.svg", linkType: 2, linkValue: "42", sortNo: 1, status: 1 }
);
/* ---- 友情链接 ---- */
let lid = 0;
const l = (o: Partial<MockLink> & { name: string; linkUrl: string }) => ({
id: ++lid,
siteId: 1,
groupName: "常用服务",
sortNo: lid,
status: 1,
...o,
} as MockLink);
db.links.push(
l({ groupName: "上级部门", name: "教育部", linkUrl: "http://www.moe.gov.cn", logoUrl: "/mock-img/logo-1.svg" }),
l({ groupName: "上级部门", name: "省教育厅", linkUrl: "http://jyt.example.cn", logoUrl: "/mock-img/logo-2.svg" }),
l({ groupName: "兄弟院校", name: "云海理工大学", linkUrl: "https://www.yhlg.edu.cn", logoUrl: "/mock-img/logo-3.svg" }),
l({ groupName: "兄弟院校", name: "云海师范大学", linkUrl: "https://www.yhsf.edu.cn", logoUrl: "/mock-img/logo-4.svg" }),
l({ groupName: "兄弟院校", name: "云海医科大学", linkUrl: "https://www.yhyk.edu.cn", logoUrl: "/mock-img/logo-5.svg" }),
l({ name: "教务系统", linkUrl: "https://jw.yunhai-demo.edu.cn", logoUrl: "/mock-img/logo-6.svg" }),
l({ name: "图书馆", linkUrl: "https://lib.yunhai-demo.edu.cn", logoUrl: "/mock-img/logo-7.svg" }),
l({ name: "邮件系统", linkUrl: "https://mail.yunhai-demo.edu.cn", logoUrl: "/mock-img/logo-8.svg" }),
l({ siteId: 2, groupName: "附中链接", name: "教育云平台", linkUrl: "https://edu.example.cn" })
);
return db;
}
/* ===== 查询辅助 ===== */
/** 拍平表 → CategoryTreeNode 树 */
export function categoryTree(db: MockDb, siteId: number): any[] {
const list = db.categories
.filter(c => c.siteId === siteId)
.sort((x, y) => x.sortNo - y.sortNo);
const node = (parentId: number): any[] =>
list
.filter(c => c.parentId === parentId)
.map(c => ({
id: c.id,
parentId: c.parentId,
siteId: c.siteId,
name: c.name,
type: c.type,
linkUrl: c.linkUrl,
template: c.template,
content: c.content,
sortNo: c.sortNo,
seoTitle: c.seoTitle,
seoDescription: c.seoDescription,
status: c.status,
children: node(c.id),
}));
return node(0);
}
export function categoryName(db: MockDb, id: number): string {
return db.categories.find(c => c.id === id)?.name || "-";
}
/** 自增 id(各表统一走 999+,避免与种子 id 冲突混淆) */
export function nextId(rows: { id: number }[]): number {
return rows.reduce((m, r) => Math.max(m, r.id), 0) + 1;
}
+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();
}
+230
View File
@@ -0,0 +1,230 @@
/**
* 生成后端种子静态资源:D:/bsch/cms/upload/seed/
* - SVG 场景插画(校园主题分层构图:天空渐变/山峦/建筑剪影/树木/水面;无占位文字)
* - 最小合法 PDF(附件演示可真实打开)
* 轮播 1920×560 对齐主题包设计尺寸约定(themes 下的 theme.json bannerSize)。
* 用法:node seed-assets.cjs
*/
const fs = require("fs");
const path = require("path");
const OUT = "D:/bsch/cms/upload/seed";
fs.mkdirSync(OUT, { recursive: true });
function hashCode(s) {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
/** 确定性伪随机(同名资源每次生成完全一致,保证截图可复现) */
function rng(seed) {
let s = seed || 1;
return () => {
s = (s * 1664525 + 1013904223) | 0;
return ((s >>> 8) & 0xffff) / 0xffff;
};
}
/** 一栋建筑剪影(含窗户点阵) */
function building(x, baseY, w, h, fill, winFill, r) {
const cols = Math.max(2, Math.floor(w / 26));
const rows = Math.max(2, Math.floor(h / 30));
let wins = "";
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
if (r() < 0.25) continue; // 部分窗户熄灭,更像真实夜景/黄昏
wins += `<rect x="${(x + 8 + i * ((w - 16) / cols)).toFixed(1)}" y="${(baseY - h + 10 + j * ((h - 18) / rows)).toFixed(1)}" width="${(((w - 16) / cols) - 6).toFixed(1)}" height="${(((h - 18) / rows) - 8).toFixed(1)}" fill="${winFill}"/>`;
}
}
return `<rect x="${x}" y="${baseY - h}" width="${w}" height="${h}" rx="2" fill="${fill}"/>${wins}`;
}
/** 一棵树(圆形树冠 + 矩形树干) */
function tree(x, baseY, scale, leaf, trunk) {
const r = 16 * scale;
return `<rect x="${x - 2.5 * scale}" y="${baseY - 22 * scale}" width="${5 * scale}" height="${22 * scale}" fill="${trunk}"/>
<circle cx="${x}" cy="${baseY - 30 * scale}" r="${r}" fill="${leaf}"/>
<circle cx="${x - 11 * scale}" cy="${baseY - 24 * scale}" r="${r * 0.72}" fill="${leaf}"/>
<circle cx="${x + 11 * scale}" cy="${baseY - 25 * scale}" r="${r * 0.78}" fill="${leaf}"/>`;
}
/** 山峦剪影(两座) */
function mountains(w, baseY, c1, c2, r) {
const x1 = w * (0.05 + r() * 0.2);
const x2 = w * (0.55 + r() * 0.25);
const m1 = `M ${x1} ${baseY} L ${x1 + w * 0.22} ${baseY - 90 - r() * 70} L ${x1 + w * 0.46} ${baseY} Z`;
const m2 = `M ${x2} ${baseY} L ${x2 + w * 0.2} ${baseY - 70 - r() * 60} L ${x2 + w * 0.42} ${baseY} Z`;
return `<path d="${m1}" fill="${c1}"/><path d="${m2}" fill="${c2}"/>`;
}
/** 云(椭圆组) */
function cloud(cx, cy, scale, fill) {
return `<g fill="${fill}">
<ellipse cx="${cx}" cy="${cy}" rx="${38 * scale}" ry="${14 * scale}"/>
<ellipse cx="${cx - 24 * scale}" cy="${cy + 4 * scale}" rx="${26 * scale}" ry="${10 * scale}"/>
<ellipse cx="${cx + 26 * scale}" cy="${cy + 3 * scale}" rx="${28 * scale}" ry="${11 * scale}"/>
</g>`;
}
/** 完整校园场景(黄昏/清晨/白天三种氛围可选) */
function campusScene(name, w, h, palette, opts = {}) {
const seed = hashCode(name);
const r = rng(seed);
const p = {
sky1: palette[0], sky2: palette[1],
sun: palette[2] || "#fff",
far: palette[3] || "rgba(255,255,255,0.28)",
near: palette[4] || "rgba(20,40,70,0.35)",
ground: palette[5] || "#2c4a6e",
water: palette[6] || null,
building: palette[7] || "rgba(16,34,60,0.85)",
win: palette[8] || "rgba(255,224,150,0.9)",
leaf: palette[9] || "rgba(46,94,60,0.9)",
trunk: palette[10] || "rgba(66,48,30,0.9)",
cloud: palette[11] || "rgba(255,255,255,0.5)",
};
const horizon = h * (opts.horizon || 0.72);
let out = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}">
<defs>
<linearGradient id="sky-${seed}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="${p.sky1}"/><stop offset="1" stop-color="${p.sky2}"/>
</linearGradient>
<linearGradient id="water-${seed}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="${p.sky2}" stop-opacity="0.9"/><stop offset="1" stop-color="${p.ground}" stop-opacity="0.95"/>
</linearGradient>
</defs>
<rect width="${w}" height="${h}" fill="url(#sky-${seed})"/>`;
// 太阳/月亮
out += `<circle cx="${(w * (0.15 + r() * 0.7)).toFixed(0)}" cy="${(horizon * (0.35 + r() * 0.3)).toFixed(0)}" r="${(h * 0.07).toFixed(0)}" fill="${p.sun}" opacity="0.9"/>`;
// 云
out += cloud(w * (0.12 + r() * 0.25), h * (0.12 + r() * 0.15), 0.9 + r() * 0.5, p.cloud);
out += cloud(w * (0.6 + r() * 0.3), h * (0.08 + r() * 0.12), 0.7 + r() * 0.4, p.cloud);
// 远山
out += mountains(w, horizon, p.far, p.far, r);
// 教学楼主群(画面中偏右)
const groundY = opts.water ? horizon + (h - horizon) * 0.42 : horizon;
const bx = w * (opts.buildingX || 0.42);
out += building(bx, groundY, w * 0.11, (h - groundY) * (1.15 + r() * 0.5), p.building, p.win, r);
out += building(bx + w * 0.115, groundY, w * 0.075, (h - groundY) * (0.85 + r() * 0.3), p.building, p.win, r);
out += building(bx - w * 0.062, groundY, w * 0.058, (h - groundY) * (0.7 + r() * 0.25), p.building, p.win, r);
// 钟楼(细高塔 + 尖顶)
const tx = bx + w * 0.2;
const th = (h - groundY) * (1.5 + r() * 0.3);
out += `<rect x="${tx}" y="${groundY - th}" width="${w * 0.028}" height="${th}" fill="${p.building}"/>
<rect x="${tx + w * 0.004}" y="${groundY - th + 10}" width="${w * 0.02}" height="${w * 0.02}" fill="${p.win}"/>
<path d="M ${tx - w * 0.004} ${groundY - th} L ${tx + w * 0.014} ${groundY - th - h * 0.05} L ${tx + w * 0.032} ${groundY - th} Z" fill="${p.building}"/>`;
// 地面
if (opts.water) {
out += `<rect x="0" y="${horizon}" width="${w}" height="${groundY - horizon}" fill="url(#water-${seed})"/>`;
out += `<rect x="0" y="${groundY}" width="${w}" height="${h - groundY}" fill="${p.ground}"/>`;
// 水面倒影横线
for (let i = 0; i < 5; i++) {
out += `<rect x="${(w * (0.1 + r() * 0.7)).toFixed(0)}" y="${(horizon + (groundY - horizon) * (0.2 + i * 0.16)).toFixed(0)}" width="${(w * (0.06 + r() * 0.2)).toFixed(0)}" height="2" fill="rgba(255,255,255,0.25)"/>`;
}
} else {
out += `<rect x="0" y="${horizon}" width="${w}" height="${h - horizon}" fill="${p.ground}"/>`;
}
// 行道树
const treeY = opts.water ? groundY + (h - groundY) * 0.22 : horizon + (h - horizon) * 0.18;
for (let i = 0; i < 6; i++) {
const tx2 = w * (0.03 + i * 0.165 + r() * 0.04);
if (tx2 > bx - w * 0.08 && tx2 < tx + w * 0.05 && !opts.water) continue; // 避开建筑
out += tree(tx2, treeY, 1 + r() * 0.5, p.leaf, p.trunk);
}
// 前景草坪弧线
out += `<path d="M 0 ${h} Q ${w / 2} ${h - h * 0.08} ${w} ${h} Z" fill="rgba(0,0,0,0.12)"/>`;
out += `</svg>`;
return out;
}
/** 校徽(圆形徽章:书本 + 星 + 环形留白,透明背景适配页头) */
function emblem() {
return `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<defs><linearGradient id="lg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#2a6fc9"/><stop offset="1" stop-color="#12447f"/>
</linearGradient></defs>
<circle cx="48" cy="48" r="46" fill="url(#lg)"/>
<circle cx="48" cy="48" r="38.5" fill="none" stroke="#f5c542" stroke-width="1.6"/>
<circle cx="48" cy="48" r="41.5" fill="none" stroke="#ffffff" stroke-width="0.8" opacity="0.6"/>
<path d="M 26 58 Q 48 50 70 58 L 70 36 Q 48 28 26 36 Z" fill="#ffffff"/>
<path d="M 48 50 L 48 28" stroke="#2a6fc9" stroke-width="1.6"/>
<path d="M 48 14 L 50.6 21 L 58 21.4 L 52.2 26 L 54.2 33 L 48 29 L 41.8 33 L 43.8 26 L 38 21.4 L 45.4 21 Z" fill="#f5c542"/>
<rect x="24" y="62" width="48" height="3.4" rx="1.7" fill="#f5c542"/>
</svg>`;
}
/* ---- 轮播(1920×560,对齐 theme.json bannerSize 约定)---- */
fs.writeFileSync(path.join(OUT, "banner-1.svg"),
campusScene("banner-1", 1920, 560, ["#1c3f68", "#7fb2e5", "#fff2c9", "rgba(255,255,255,0.22)", "rgba(20,45,80,0.30)", "#27517f"], { water: true, buildingX: 0.40 }));
fs.writeFileSync(path.join(OUT, "banner-2.svg"),
campusScene("banner-2", 1920, 560, ["#0e4f4a", "#8fd0b0", "#fdeec1", "rgba(255,255,255,0.20)", "rgba(10,60,50,0.32)", "#1f6b52", "#2f8a68", "rgba(8,52,44,0.88)", "rgba(255,240,190,0.92)", "rgba(24,92,64,0.95)", "rgba(52,40,24,0.95)", "rgba(255,255,255,0.42)"], { water: true, buildingX: 0.46 }));
fs.writeFileSync(path.join(OUT, "banner-3.svg"),
campusScene("banner-3", 1920, 560, ["#3a1f5d", "#e08a6d", "#ffd9a0", "rgba(255,214,170,0.30)", "rgba(52,24,70,0.38)", "#4a2a63", "#6b3d84", "rgba(30,14,48,0.9)", "rgba(255,226,170,0.95)", "rgba(96,60,110,0.9)", "rgba(50,32,24,0.9)", "rgba(255,235,210,0.4)"], { horizon: 0.78, buildingX: 0.36 }));
fs.writeFileSync(path.join(OUT, "banner-4.svg"),
campusScene("banner-4", 1920, 560, ["#141c3a", "#4f7fd9", "#cfe0ff", "rgba(190,210,255,0.20)", "rgba(12,20,48,0.42)", "#1b2a52"], { horizon: 0.75, buildingX: 0.44 }));
/* ---- 文章封面(640×360)---- */
fs.writeFileSync(path.join(OUT, "cover-运动会.svg"),
campusScene("cover-sports", 640, 360, ["#0d3b66", "#69a8d8", "#fff3c4", "rgba(255,255,255,0.24)", "rgba(10,40,70,0.34)", "#1c5080", "#2e6da4", "rgba(10,34,62,0.9)", "rgba(255,230,160,0.92)", "rgba(30,90,70,0.95)", "rgba(50,38,22,0.95)", "rgba(255,255,255,0.5)"], { water: true }));
fs.writeFileSync(path.join(OUT, "cover-迎新晚会.svg"),
campusScene("cover-gala", 640, 360, ["#2a0e4e", "#c25b8a", "#ffe3a0", "rgba(255,200,220,0.26)", "rgba(40,12,64,0.4)", "#3c1a5c", "#57297a", "rgba(26,8,44,0.92)", "rgba(255,224,170,0.95)", "rgba(120,60,130,0.9)", "rgba(48,30,20,0.9)", "rgba(255,230,240,0.4)"], { horizon: 0.8, buildingX: 0.42 }));
fs.writeFileSync(path.join(OUT, "cover-创新创业.svg"),
campusScene("cover-inno", 640, 360, ["#03363d", "#4fd1c5", "#eafff9", "rgba(220,255,250,0.22)", "rgba(4,50,56,0.36)", "#065a63", "#0b7d85", "rgba(4,42,48,0.9)", "rgba(210,255,245,0.92)", "rgba(10,92,80,0.9)", "rgba(40,40,22,0.9)", "rgba(235,255,252,0.4)"], { water: true }));
/* ---- 正文配图(640×360)---- */
["art-1", "art-2", "art-3", "art-4", "art-5", "art-6"].forEach((n, i) => {
const palettes = [
["#1c3f68", "#7fb2e5", "#fff2c9", "rgba(255,255,255,0.22)", "rgba(20,45,80,0.30)", "#27517f"],
["#5d2019", "#e8a06a", "#fff0d0", "rgba(255,220,190,0.26)", "rgba(80,28,20,0.34)", "#7a3020", null, "rgba(70,24,16,0.9)", "rgba(255,235,200,0.92)", "rgba(160,70,40,0.9)", "rgba(56,36,20,0.9)", "rgba(255,240,220,0.42)"],
["#23306b", "#9fb4f0", "#f4f6ff", "rgba(230,235,255,0.24)", "rgba(24,32,86,0.32)", "#303f8c", "#4653a3", "rgba(18,24,66,0.9)", "rgba(235,240,255,0.92)", "rgba(50,70,140,0.9)", "rgba(44,36,26,0.9)", "rgba(245,248,255,0.45)"],
["#0e4f4a", "#8fd0b0", "#fdeec1", "rgba(255,255,255,0.20)", "rgba(10,60,50,0.32)", "#1f6b52", "#2f8a68", "rgba(8,52,44,0.88)", "rgba(255,240,190,0.92)", "rgba(24,92,64,0.95)", "rgba(52,40,24,0.95)", "rgba(255,255,255,0.42)"],
["#4a2a63", "#d9a0c8", "#ffe9f4", "rgba(255,230,245,0.24)", "rgba(60,32,82,0.34)", "#5c3675", "#754a8e", "rgba(44,22,60,0.9)", "rgba(255,235,245,0.92)", "rgba(110,70,120,0.9)", "rgba(50,32,22,0.9)", "rgba(255,245,250,0.42)"],
["#3d2b1f", "#d9b38c", "#fff6e3", "rgba(255,240,215,0.24)", "rgba(60,42,28,0.36)", "#5a4030", "#71523c", "rgba(52,36,24,0.9)", "rgba(255,240,210,0.92)", "rgba(120,90,50,0.9)", "rgba(46,32,18,0.9)", "rgba(255,250,235,0.42)"],
];
fs.writeFileSync(path.join(OUT, n + ".svg"), campusScene(n, 640, 360, palettes[i], { water: i % 2 === 0 }));
});
/* ---- 校徽 + 友链 logo ---- */
fs.writeFileSync(path.join(OUT, "logo-univ.svg"), emblem());
for (let i = 1; i <= 8; i++) {
const hues = [210, 160, 20, 270, 120, 340, 40, 190];
const hue = hues[i - 1];
fs.writeFileSync(path.join(OUT, `logo-${i}.svg`), `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96">
<rect x="4" y="4" width="88" height="88" rx="18" fill="hsl(${hue},52%,46%)"/>
<rect x="4" y="4" width="88" height="88" rx="18" fill="none" stroke="rgba(255,255,255,0.35)" stroke-width="2"/>
<circle cx="48" cy="38" r="15" fill="rgba(255,255,255,0.9)"/>
<path d="M 24 74 Q 48 46 72 74 Z" fill="rgba(255,255,255,0.75)"/>
</svg>`);
}
/* ---- 最小 PDF(附件) ---- */
function miniPdf(title) {
const text = `BT /F1 18 Tf 60 700 Td (${title}) Tj ET`;
const content = `1 0 0 1 0 0 cm\n${text}`;
const objs = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
`<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
let pdf = "%PDF-1.4\n";
const offsets = [];
objs.forEach((o, i) => {
offsets.push(pdf.length);
pdf += `${i + 1} 0 obj\n${o}\nendobj\n`;
});
const xref = pdf.length;
pdf += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
offsets.forEach(off => {
pdf += String(off).padStart(10, "0") + " 00000 n \n";
});
pdf += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`;
return Buffer.from(pdf, "latin1");
}
fs.writeFileSync(path.join(OUT, "2026校历.pdf"), miniPdf("2026 QiulixiaoLi (seed)"));
fs.writeFileSync(path.join(OUT, "运动会秩序册.pdf"), miniPdf("YunDongHui ZhiXuCe (seed)"));
fs.writeFileSync(path.join(OUT, "迎新晚会节目单.pdf"), miniPdf("YingXin WanHui (seed)"));
console.log("seed assets written to", OUT, ":", fs.readdirSync(OUT).length, "files");
+128
View File
@@ -0,0 +1,128 @@
/**
* 门户主题 favicon 兜底资产生成器(4 套皮肤各一份)
* - 输出 templates/themes/<theme>/assets/favicon.svg + favicon.png(128×128)
* - head.html 在 site.config['favicon'] 未配置时输出 url.asset('favicon.png')(见 fragments/head.html)
* 图形语言对齐种子 logo-8 系(圆点+书脊),主色取各主题 theme.json 的 primary 参数默认值;
* 小尺寸下高对比扁平化:无边框、无环形留白。
* 用法:node theme-favicons.cjs (在 front/ 下执行,依赖 ./node_modules/ws 与系统 Edge)
*/
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const http = require("http");
const WebSocket = require("ws");
const EDGE = "C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe";
const ROOT = path.join(__dirname, "..", "..", "service", "src", "main", "resources", "templates", "themes");
const THEMES = [
{ dir: "default", primary: "#1a5cb0" },
{ dir: "classic-red", primary: "#b01a2e" },
{ dir: "blue-white", primary: "#2f6bd8" },
{ dir: "warm-kinder", primary: "#e8722a" },
];
/** #rrggbb -> [r,g,b] */
function rgb(hex) {
return [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
}
function mix(hex, target, t) {
const c = rgb(hex).map((v, i) => Math.round(v + (target[i] - v) * t));
return "#" + c.map(v => v.toString(16).padStart(2, "0")).join("");
}
const light = hex => mix(hex, [255, 255, 255], 0.18);
const dark = hex => mix(hex, [0, 0, 0], 0.30);
function faviconSvg(primary) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="${light(primary)}"/><stop offset="1" stop-color="${dark(primary)}"/>
</linearGradient></defs>
<rect width="64" height="64" fill="url(#bg)"/>
<circle cx="32" cy="25" r="10" fill="#ffffff"/>
<path d="M 16 49.3 Q 32 30.7 48 49.3 Z" fill="#ffffff" opacity="0.92"/>
</svg>`;
}
/* ---- CDP 栅格化(Edge headless,128×128 视图内 img 渲染 svg 矢量,天然高清) ---- */
const sleep = ms => new Promise(r => setTimeout(r, ms));
function getJson(port, p) {
return new Promise((resolve, reject) => {
http.get({ host: "127.0.0.1", port, path: p }, res => {
let d = "";
res.on("data", c => d += c);
res.on("end", () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } });
}).on("error", reject);
});
}
async function waitFor(fn, timeout, label) {
const t0 = Date.now();
for (;;) {
try { if (await fn()) return; } catch (e) { /* retry */ }
if (Date.now() - t0 > timeout) throw new Error("timeout: " + label);
await sleep(200);
}
}
async function rasterizeSvg(svgPath, pngPath) {
const PORT = 9400 + Math.floor(Math.random() * 300);
const profile = path.join(require("os").tmpdir(), "fav-" + process.pid + "-" + Math.floor(Math.random() * 1e6));
const htmlPath = path.join(profile, "icon.html");
fs.mkdirSync(profile, { recursive: true });
fs.writeFileSync(htmlPath,
`<html><head><style>html,body{margin:0;padding:0;background:#fff}</style></head>
<body><img src="file:///${svgPath.replace(/\\/g, "/")}" width="128" height="128"></body></html>`);
const proc = spawn(EDGE, [
"--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars",
`--remote-debugging-port=${PORT}`, `--user-data-dir=${profile}`,
"--window-size=128,128", "--no-first-run", "--no-default-browser-check", "about:blank",
], { stdio: "ignore" });
try {
await waitFor(async () => { try { await getJson(PORT, "/json/version"); return true; } catch (e) { return false; } }, 20000, "devtools");
const pages = await getJson(PORT, "/json");
const page = pages.find(p => p.type === "page");
const ws = new WebSocket(page.webSocketDebuggerUrl, { maxPayload: 64 * 1024 * 1024 });
await new Promise(r => ws.on("open", r));
const cdp = {
id: 0,
pending: new Map(),
send(method, params = {}) {
return new Promise((resolve, reject) => {
const id = ++this.id;
this.pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
},
};
ws.on("message", m => {
const msg = JSON.parse(m);
if (msg.id && cdp.pending.has(msg.id)) {
const { resolve, reject } = cdp.pending.get(msg.id);
cdp.pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
}
});
await cdp.send("Page.enable");
await cdp.send("Emulation.setDeviceMetricsOverride", { width: 128, height: 128, deviceScaleFactor: 1, mobile: false });
const loaded = new Promise(r => {
const on = m => { const d = JSON.parse(m); if (d.method === "Page.loadEventFired") { ws.off("message", on); r(); } };
ws.on("message", on);
});
await cdp.send("Page.navigate", { url: "file:///" + htmlPath.replace(/\\/g, "/") });
await loaded;
await sleep(500);
const shot = await cdp.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: false });
fs.writeFileSync(pngPath, Buffer.from(shot.data, "base64"));
ws.close();
} finally {
proc.kill();
}
}
(async () => {
for (const t of THEMES) {
const dir = path.join(ROOT, t.dir, "assets");
fs.writeFileSync(path.join(dir, "favicon.svg"), faviconSvg(t.primary));
await rasterizeSvg(path.join(dir, "favicon.svg"), path.join(dir, "favicon.png"));
console.log("wrote", t.dir, "favicon.svg + favicon.png");
}
console.log("done");
})().catch(e => { console.error(e); process.exit(1); });