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
+145
View File
@@ -0,0 +1,145 @@
// @vitest-environment jsdom
/**
* RichTextEditor 组件行为(审计批次 6:ArticleEdit 复用后,正文上传契约成为两个页面共用底座,
* 需要独立护栏)。覆盖:
* ① 上传 FormData 参数:siteId / scene / articleId 按传入拼装,未传的键不出现;
* ② 上传成功 → emit uploaded(附件 id) 且 insertFn 收到 (url, fileName, url);
* ③ 上传失败(code≠0)→ 不 emit uploaded;
* ④ 工具栏配置仍排除视频组与全屏(Notice 趟坑口径)。
*
* 无 @vue/test-utils 依赖:沿用项目既有 createApp 手写挂载 + vi.mock 桩范式。
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { createApp, defineComponent, h } from "vue";
const confState = vi.hoisted(() => ({
conf: { baseHost: "", ssoHost: "", serviceHost: "", isMock: false, isDebug: false },
}));
vi.mock("@/assets/config/host.config", () => ({ default: confState.conf }));
/** wangEditor 桩:把传进来的 defaultConfig 挂到全局,测试借此直接驱动 customUpload */
vi.mock("@wangeditor/editor-for-vue", () => ({
Toolbar: defineComponent({
props: ["editor", "defaultConfig", "mode"],
setup(props) {
(globalThis as any).__toolbarConfig = props.defaultConfig;
return () => h("div", { class: "toolbar-stub" });
},
}),
Editor: defineComponent({
props: ["modelValue", "defaultConfig", "mode"],
emits: ["onCreated", "update:modelValue"],
setup(props, { emit }) {
(globalThis as any).__editorConfig = props.defaultConfig;
(globalThis as any).__editorEmitUpdate = (v: string) => emit("update:modelValue", v);
(globalThis as any).__editorCreated = emit;
return () => h("div", { class: "editor-stub" });
},
}),
}));
import RichTextEditor from "@/components/RichTextEditor.vue";
let cleanups: (() => void)[] = [];
function mountEditor(props: Record<string, unknown>, onUploaded?: (id: number) => void) {
const el = document.createElement("div");
document.body.appendChild(el);
const app = createApp({
render: () => h(RichTextEditor, { modelValue: "", ...props, onUploaded }),
});
app.mount(el);
cleanups.push(() => {
app.unmount();
el.remove();
});
return el;
}
/** 直接调用组件内部构造的 customUpload(等同 wangEditor 选中图片后的回调) */
async function runUpload(file: File): Promise<{ inserted: string[] }> {
const inserted: string[] = [];
const cfg = (globalThis as any).__editorConfig;
await cfg.MENU_CONF.uploadImage.customUpload(file, (url: string) => inserted.push(url));
return { inserted };
}
afterEach(() => {
cleanups.forEach(fn => fn());
cleanups = [];
vi.unstubAllGlobals();
delete (globalThis as any).__editorConfig;
delete (globalThis as any).__toolbarConfig;
});
describe("RichTextEditor - 正文上传与事件契约", () => {
it("① 上传 FormData 带 siteId/scene/articleId(事项页口径:scene=svc_item_asset)", async () => {
const seen: FormData[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init: any) => {
seen.push(init.body as FormData);
return { json: async () => ({ code: 0, data: { id: 77, url: "/cms/upload/a.png", fileName: "a.png" } }) };
})
);
mountEditor({ siteId: 5, scene: "svc_item_asset" }, () => {});
await runUpload(new File(["x"], "a.png", { type: "image/png" }));
expect(seen).toHaveLength(1);
expect(seen[0].get("file")).toBeInstanceOf(File);
expect(seen[0].get("siteId")).toBe("5");
expect(seen[0].get("scene")).toBe("svc_item_asset");
expect(seen[0].get("articleId")).toBeNull();
});
it("①b 文章页口径:传 articleId 时随之提交(编辑已有文章)", async () => {
const seen: FormData[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (_url: string, init: any) => {
seen.push(init.body as FormData);
return { json: async () => ({ code: 0, data: { id: 78, url: "/cms/upload/b.png", fileName: "b.png" } }) };
})
);
mountEditor({ siteId: 5, articleId: 12 }, () => {});
await runUpload(new File(["x"], "b.png", { type: "image/png" }));
expect(seen[0].get("siteId")).toBe("5");
expect(seen[0].get("articleId")).toBe("12");
expect(seen[0].get("scene")).toBeNull();
});
it("② 上传成功:emit uploaded(id) 且 insertFn 收到 (url, fileName, url)", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
json: async () => ({ code: 0, data: { id: 99, url: "/cms/upload/c.png", fileName: "c.png" } }),
}))
);
const ids: number[] = [];
mountEditor({ siteId: 5, scene: "svc_item_asset" }, id => ids.push(id));
const { inserted } = await runUpload(new File(["x"], "c.png", { type: "image/png" }));
expect(ids).toEqual([99]);
expect(inserted).toEqual(["/cms/upload/c.png"]);
});
it("③ 上传失败(code≠0):不 emit uploaded、不插入图片", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => ({ json: async () => ({ code: 2, msg: "scene 非法" }) }))
);
const ids: number[] = [];
mountEditor({ siteId: 5, scene: "bad_scene" }, id => ids.push(id));
const { inserted } = await runUpload(new File(["x"], "d.png", { type: "image/png" }));
expect(ids).toEqual([]);
expect(inserted).toEqual([]);
});
it("④ 工具栏仍排除视频组与全屏(Notice 趟坑口径不回退)", () => {
mountEditor({ siteId: 5 }, () => {});
expect((globalThis as any).__toolbarConfig.excludeKeys).toEqual(["group-video", "fullScreen"]);
});
});