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
+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;
}