init local code
This commit is contained in:
+400
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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); });
|
||||
Reference in New Issue
Block a user