feat(svcitem): 事项库+办事大厅 R2 前端三页,并复用富文本组件消除双份实现
build / build (push) Successful in 3m14s

落地(依据《事项库与办事大厅详细设计 v1》§7)
- 路由:办事事项 modId=21 / 服务字典 modId=22 + 子站镜像(/sch/{code}/site/{siteCode}/svc/*)
  + 3 条 legacyRedirect 旧链兼容;SchoolLayout 菜单图标兜底
- ServiceItemList:三维筛选/关键字/分页/发布下架删除/跳转编辑
- ServiceItemEdit:三段富文本 + 封面 + 指南附件上传回显(attList)+「我要办理外链」提示
  + 部门选择器不可达时提示"可留空保存"
- ServiceDictManage:object/category 双 Tab,被引用键编辑锁定 + 删除引用守护提示
- api/类型:SvcItemApi/SvcDictApi 与 types/cms.ts 契约补齐;站点设置补 svc_hall_base 键

审计修复(docs/18)
- D3 接线:列表页新增 ServiceItemSortModal 拖拽排序(顺序源 selList,保存按下标重排 sort_no)
  与「同步部门名」按钮,此前后端 sort/syncDeptName 已备但前端无入口
- 附件场景拆分:封面与三个富文本编辑器改传 scene=svc_item_asset,指南附件保持 svc_item
  (内容资产由后端按内容派生归属,不再依赖前端收集上传 id——删除只写不读的 uploadedIds 记账)
- 部门组树:SvcDeptVo 增 children,前端 flattenDepts 缩进扁平化(后端改嵌套下发,交互不变)
- RichTextEditor 补 articleId/slateMinHeight prop 并去掉死状态 editorLoading;
  ArticleEdit 改为复用该组件(正文区最小高度按原口径传入,观感不变),消除双份 wangEditor 实现

测试与 mock
- mock 补 /api/svc/** 全量桩(事项分页/字典/部门树/排序/同步部门名/附件回显 + 站点 1、2 示例数据),
  dev:mock 下三页可用(实测 curl 各项响应正确)
- cms.spec 补 save/syncDeptName/selList/dict save+delete 契约;siteRouter.spec 补事项三条路由
  modId 与子站镜像 resolve 断言;新增 RichTextEditor.spec 5 例(上传参数三键/成功 emit/失败不 emit/工具栏排除项)
- vitest 127 用例全绿 + vite build 通过
This commit is contained in:
chenxing
2026-09-11 14:14:02 +08:00
parent 34733ec280
commit a05b0ded55
18 changed files with 2102 additions and 78 deletions
+96
View File
@@ -95,6 +95,50 @@ export interface MockLink {
status: number;
}
export interface MockSvcDict {
id: number;
siteId: number;
dictType: "object" | "category";
dictKey: string;
dictName: string;
sortNo: number;
status: number;
}
export interface MockSvcItem {
id: number;
siteId: number;
itemCode: string;
title: string;
summary?: string;
objectKey?: string;
categoryKey?: string;
deptGroupId?: number;
deptName?: string;
handleMode: number;
onlineFlag: number;
handlePlace?: string;
handleTimeLimit?: string;
promiseTimeLimit?: string;
contactPhone?: string;
supervisePhone?: string;
processHtml?: string;
materialHtml?: string;
noticeHtml?: string;
faqCategoryId?: number;
noticeCategoryId?: number;
ruleCategoryId?: number;
coverImage?: string;
sortNo: number;
status: number;
publishTime?: string;
offlineTime?: string;
viewCount: number;
extJson?: string;
attachmentIds?: number[];
createTime: string;
}
export interface MockDb {
sites: MockSite[];
categories: MockCategory[];
@@ -102,6 +146,8 @@ export interface MockDb {
attachments: MockAttachment[];
banners: MockBanner[];
links: MockLink[];
svcItems: MockSvcItem[];
svcDicts: MockSvcDict[];
/** 上传文件缓存:url 尾段 → {buffer, mime} */
files: Map<string, { buffer: Buffer; mime: string; name: string }>;
}
@@ -135,6 +181,8 @@ export function seedDb(): MockDb {
attachments: [],
banners: [],
links: [],
svcItems: [],
svcDicts: [],
files: new Map(),
};
@@ -373,6 +421,54 @@ export function seedDb(): MockDb {
l({ siteId: 2, groupName: "附中链接", name: "教育云平台", linkUrl: "https://edu.example.cn" })
);
/* ---- 事项字典 + 办事事项(事项库 R2;demo 站为主,另给附中一条便于切换站点观察) ---- */
let sdid = 0;
const sd = (o: Partial<MockSvcDict> & { dictType: "object" | "category"; dictKey: string; dictName: string }) => ({
id: ++sdid,
siteId: 1,
sortNo: sdid,
status: 1,
...o,
} as MockSvcDict);
db.svcDicts.push(
sd({ dictType: "object", dictKey: "person", dictName: "个人" }),
sd({ dictType: "object", dictKey: "legal", dictName: "法人" }),
sd({ dictType: "object", dictKey: "other", dictName: "其他组织" }),
sd({ dictType: "category", dictKey: "jw", dictName: "教务服务" }),
sd({ dictType: "category", dictKey: "hq", dictName: "后勤服务" }),
sd({ dictType: "category", dictKey: "xs", dictName: "学生事务" }),
sd({ dictType: "category", dictKey: "stop", dictName: "已停用类别", status: 0 }),
sd({ siteId: 2, dictType: "object", dictKey: "person", dictName: "个人" }),
sd({ siteId: 2, dictType: "category", dictKey: "jw", dictName: "教务服务" })
);
let siid = 0;
const si = (o: Partial<MockSvcItem> & { itemCode: string; title: string }) => ({
id: ++siid,
siteId: 1,
handleMode: 1,
onlineFlag: 1,
sortNo: siid,
status: 1,
viewCount: Math.floor(Math.random() * 800),
processHtml: para("申请人携带材料到服务窗口提出申请。", "窗口受理后 1 个工作日内完成审核。"),
materialHtml: para("1. 申请表(现场领取)", "2. 本人身份证明", "3. 相关证明材料"),
noticeHtml: para("材料齐全当日办结;如需补正,以窗口告知为准。"),
extJson: JSON.stringify({ apply_url: "https://svhall.example.com/#/apply" }),
createTime: daysAgo(siid * 3),
...o,
} as MockSvcItem);
db.svcItems.push(
si({ itemCode: "BG-ZX-001", title: "转学办理", summary: "适用于在校学生转学申请", objectKey: "person", categoryKey: "jw", deptGroupId: 10, deptName: "教务处", handleMode: 1, handleTimeLimit: "3 个工作日", contactPhone: "0571-88888888", publishTime: daysAgo(12) }),
si({ itemCode: "BG-XJ-002", title: "休学办理", summary: "因病或其他原因申请休学", objectKey: "person", categoryKey: "jw", deptGroupId: 10, deptName: "教务处", handleMode: 3, handleTimeLimit: "5 个工作日", publishTime: daysAgo(10) }),
si({ itemCode: "BG-HQ-100", title: "宿舍报修", summary: "学生宿舍水电与设施报修", objectKey: "person", categoryKey: "hq", deptGroupId: 20, deptName: "后勤处", handleMode: 2, onlineFlag: 0, handlePlace: "后勤服务大厅", publishTime: daysAgo(8) }),
si({ itemCode: "BG-XS-210", title: "学生证补办", summary: "学生证遗失后的补办申请", objectKey: "person", categoryKey: "xs", deptGroupId: 30, deptName: "学生工作处", handleMode: 1, publishTime: daysAgo(6) }),
si({ itemCode: "BG-FR-300", title: "法人年检材料受理", summary: "面向法人单位的年检材料受理", objectKey: "legal", categoryKey: "jw", deptGroupId: 10, deptName: "教务处", handleMode: 2, coverImage: "/mock-img/banner-3.svg", publishTime: daysAgo(4) }),
si({ itemCode: "BG-DRAFT-001", title: "校园卡挂失(草稿)", objectKey: "person", categoryKey: "xs", status: 0, handleMode: 1 }),
si({ itemCode: "BG-OFF-001", title: "教室借用申请(已下架)", objectKey: "person", categoryKey: "hq", status: 2, handleMode: 2, publishTime: daysAgo(20), offlineTime: daysAgo(2) }),
si({ siteId: 2, itemCode: "FZ-001", title: "附中转学办理", objectKey: "person", categoryKey: "jw", deptName: "附中教务处", status: 1, publishTime: daysAgo(5) })
);
return db;
}
+181 -1
View File
@@ -12,7 +12,7 @@
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";
import { MockAttachment, MockBanner, MockLink, MockSite, MockSvcDict, MockSvcItem } from "./data";
const db: MockDb = seedDb();
@@ -855,6 +855,186 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
return true;
}
/* ---- 事项库:服务字典 + 服务部门 ---- */
if (method === "GET" && path === "/api/svc/dict/list") {
const siteId = num(q(req, "siteId"), 0)!;
const dictType = q(req, "dictType");
const rows = db.svcDicts
.filter(d => d.siteId === siteId && (!dictType || d.dictType === dictType))
.sort((x, y) => x.sortNo - y.sortNo || x.id - y.id)
.map(d => ({
...d,
referenced: db.svcItems.filter(i =>
d.dictType === "category" ? i.categoryKey === d.dictKey : i.objectKey === d.dictKey
).length,
}));
sendJson(res, ok(rows));
return true;
}
if (method === "POST" && path === "/api/svc/dict/save") {
const body = await readJson<Partial<MockSvcDict>>(req);
if (body.id) {
const i = db.svcDicts.findIndex(d => d.id === body.id);
if (i >= 0) db.svcDicts[i] = { ...db.svcDicts[i], ...body } as MockSvcDict;
sendJson(res, ok(db.svcDicts[i]?.id ?? null));
} else {
const row: MockSvcDict = {
id: nextId(db.svcDicts),
siteId: body.siteId!,
dictType: body.dictType || "object",
dictKey: body.dictKey || "",
dictName: body.dictName || "",
sortNo: body.sortNo ?? 0,
status: body.status ?? 1,
};
db.svcDicts.push(row);
sendJson(res, ok(row.id));
}
return true;
}
if (method === "POST" && path === "/api/svc/dict/delete") {
const { id } = await readJson<{ id: number }>(req);
db.svcDicts = db.svcDicts.filter(d => d.id !== id);
sendJson(res, ok(null));
return true;
}
if (method === "GET" && path === "/api/svc/dept/tree") {
// 模拟 UserBase 组树经 type 过滤后嵌套下发:学校 → 校内处室
sendJson(res, ok([
{
id: 100,
name: "云海大学",
children: [
{ id: 10, name: "教务处", children: [] },
{ id: 20, name: "后勤处", children: [] },
{ id: 30, name: "学生工作处", children: [] },
],
},
]));
return true;
}
/* ---- 事项库:办事事项管理 ---- */
if (method === "GET" && path === "/api/svc/item/page") {
const siteId = num(q(req, "siteId"), 0)!;
const status = q(req, "status");
const objectKey = q(req, "objectKey");
const categoryKey = q(req, "categoryKey");
const keyword = q(req, "keyword");
const page = num(q(req, "page"), 1)!;
const pageSize = num(q(req, "pageSize"), 10)!;
const dictName = (type: "object" | "category", key?: string) =>
key ? db.svcDicts.find(d => d.siteId === siteId && d.dictType === type && d.dictKey === key)?.dictName : undefined;
const filtered = db.svcItems
.filter(i => i.siteId === siteId)
.filter(i => (status === undefined || status === null || status === "" ? true : i.status === Number(status)))
.filter(i => (!objectKey ? true : i.objectKey === objectKey))
.filter(i => (!categoryKey ? true : i.categoryKey === categoryKey))
.filter(i => (!keyword ? true : i.title.includes(keyword) || i.itemCode.includes(keyword)))
.sort((x, y) => x.sortNo - y.sortNo || y.id - x.id);
const start = (page - 1) * pageSize;
sendJson(res, ok({
total: filtered.length,
list: filtered.slice(start, start + pageSize).map(i => ({
...i,
objectName: dictName("object", i.objectKey),
categoryName: dictName("category", i.categoryKey),
applyUrl: i.extJson ? (JSON.parse(i.extJson).apply_url as string) : undefined,
})),
}));
return true;
}
if (method === "GET" && path === "/api/svc/item/get") {
const id = num(q(req, "id"), 0)!;
const item = db.svcItems.find(i => i.id === id);
if (!item) {
sendJson(res, { code: 2, msg: "事项不存在", data: null });
return true;
}
sendJson(res, ok({
...item,
applyUrl: item.extJson ? (JSON.parse(item.extJson).apply_url as string) : undefined,
}));
return true;
}
if (method === "POST" && path === "/api/svc/item/save") {
const body = await readJson<Partial<MockSvcItem> & { applyUrl?: string }>(req);
const extJson = body.applyUrl ? JSON.stringify({ apply_url: body.applyUrl }) : undefined;
if (body.id) {
const i = db.svcItems.findIndex(x => x.id === body.id);
if (i >= 0) db.svcItems[i] = { ...db.svcItems[i], ...body, extJson } as MockSvcItem;
sendJson(res, ok(db.svcItems[i]?.id ?? null));
} else {
const row: MockSvcItem = {
id: nextId(db.svcItems),
siteId: body.siteId!,
itemCode: body.itemCode || "",
title: body.title || "",
handleMode: body.handleMode ?? 1,
onlineFlag: body.onlineFlag ?? 1,
sortNo: body.sortNo ?? 0,
status: 0,
viewCount: 0,
createTime: daysAgo(0),
...body,
extJson,
} as MockSvcItem;
db.svcItems.push(row);
sendJson(res, ok(row.id));
}
return true;
}
const svcStateMatch = path.match(/^\/api\/svc\/item\/(publish|offline)\/(\d+)$/);
if (method === "POST" && svcStateMatch) {
const item = db.svcItems.find(i => i.id === Number(svcStateMatch[2]));
if (item) {
item.status = svcStateMatch[1] === "publish" ? 1 : 2;
if (item.status === 1) item.publishTime = daysAgo(0);
else item.offlineTime = daysAgo(0);
}
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/svc/item/delete") {
const { id } = await readJson<{ id: number }>(req);
db.svcItems = db.svcItems.filter(i => i.id !== id);
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/svc/item/sort") {
const pairs = await readJson<{ id: number; sortNo: number }[]>(req);
(pairs || []).forEach(p => {
const item = db.svcItems.find(i => i.id === p.id);
if (item) item.sortNo = p.sortNo;
});
sendJson(res, ok(null));
return true;
}
if (method === "POST" && path === "/api/svc/item/syncDeptName") {
sendJson(res, ok({ updated: 0 }));
return true;
}
if (method === "GET" && path === "/api/svc/item/selList") {
const siteId = num(q(req, "siteId"), 0)!;
const rows = db.svcItems
.filter(i => i.siteId === siteId)
.sort((x, y) => x.sortNo - y.sortNo || x.id - y.id)
.map(i => ({ id: i.id, itemCode: i.itemCode, title: i.title, status: i.status }));
sendJson(res, ok(rows));
return true;
}
if (method === "GET" && path === "/api/svc/item/attList") {
const siteId = num(q(req, "siteId"), 0)!;
const id = num(q(req, "id"), 0)!;
const ids = db.svcItems.find(i => i.id === id)?.attachmentIds || [];
sendJson(res, ok(
db.attachments
.filter(a => a.siteId === siteId && ids.includes(a.id))
.map(a => ({ id: a.id, fileName: a.fileName, webUrl: a.url, fileSize: a.fileSize }))
));
return true;
}
return false;
}