From a05b0ded556587b1b34c76f93a16d29f7c62ee41 Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 11 Sep 2026 14:14:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(svcitem):=20=E4=BA=8B=E9=A1=B9=E5=BA=93+?= =?UTF-8?q?=E5=8A=9E=E4=BA=8B=E5=A4=A7=E5=8E=85=20R2=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E4=B8=89=E9=A1=B5=EF=BC=8C=E5=B9=B6=E5=A4=8D=E7=94=A8=E5=AF=8C?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E7=BB=84=E4=BB=B6=E6=B6=88=E9=99=A4=E5=8F=8C?= =?UTF-8?q?=E4=BB=BD=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 落地(依据《事项库与办事大厅详细设计 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 通过 --- mock/data.ts | 96 ++++++ mock/index.ts | 182 ++++++++++- src/__tests__/siteRouter.spec.ts | 22 ++ src/api/module/cms.spec.ts | 105 +++++- src/api/module/cms.ts | 84 ++++- src/components/ImageAssetField.vue | 7 +- src/components/RichTextEditor.spec.ts | 145 +++++++++ src/components/RichTextEditor.vue | 135 ++++++++ src/components/SchoolLayout.vue | 5 + src/router/routes.ts | 29 ++ src/types/cms.ts | 153 +++++++++ src/utils/siteConfigForm.spec.ts | 2 +- src/utils/siteConfigForm.ts | 5 + src/views/cms/ArticleEdit.vue | 90 +----- src/views/cms/ServiceDictManage.vue | 210 ++++++++++++ src/views/cms/ServiceItemEdit.vue | 421 +++++++++++++++++++++++++ src/views/cms/ServiceItemList.vue | 303 ++++++++++++++++++ src/views/cms/ServiceItemSortModal.vue | 186 +++++++++++ 18 files changed, 2102 insertions(+), 78 deletions(-) create mode 100644 src/components/RichTextEditor.spec.ts create mode 100644 src/components/RichTextEditor.vue create mode 100644 src/views/cms/ServiceDictManage.vue create mode 100644 src/views/cms/ServiceItemEdit.vue create mode 100644 src/views/cms/ServiceItemList.vue create mode 100644 src/views/cms/ServiceItemSortModal.vue diff --git a/mock/data.ts b/mock/data.ts index 7518010..30cd7e1 100644 --- a/mock/data.ts +++ b/mock/data.ts @@ -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; } @@ -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 & { 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 & { 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; } diff --git a/mock/index.ts b/mock/index.ts index 3f46ce0..6fdce6b 100644 --- a/mock/index.ts +++ b/mock/index.ts @@ -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>(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 & { 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; } diff --git a/src/__tests__/siteRouter.spec.ts b/src/__tests__/siteRouter.spec.ts index fc184a7..5838a35 100644 --- a/src/__tests__/siteRouter.spec.ts +++ b/src/__tests__/siteRouter.spec.ts @@ -57,6 +57,28 @@ describe("docs/13 路由结构(真实 routes 数组,docs/15 T1)", () => { expect(main.matched).toHaveLength(2); expect(main.name).toBe("CategoryManage"); }); + + it("②c 事项库路由(审计批次 3 补口):办事事项 modId=21、服务字典 modId=22,子站镜像可解析", () => { + const item = router.resolve("/sch/demo/svc/item"); + expect(item.name).toBe("SvcItemList"); + expect(item.meta.modId).toBe(21); + + const dict = router.resolve("/sch/demo/svc/dict"); + expect(dict.name).toBe("SvcDictManage"); + expect(dict.meta.modId).toBe(22); + + // 编辑页:新建与带 id 两种形态都命中同一编辑组件(subOf 归属事项列表) + const editNew = router.resolve("/sch/demo/svc/item/edit"); + const edit = router.resolve("/sch/demo/svc/item/edit/12"); + expect(editNew.name).toBe("SvcItemEditNew"); + expect(edit.name).toBe("SvcItemEdit"); + expect(edit.params.id).toBe("12"); + + // 子站镜像形态可解析(去 name、meta.modId 保留) + const sub = router.resolve("/sch/demo/site/jdxy/svc/item/edit/12"); + expect(sub.matched).toHaveLength(3); + expect(sub.meta.modId).toBe(21); + }); }); describe("docs/13 守卫与旧链兼容(真实 router.push,docs/15 T4)", () => { diff --git a/src/api/module/cms.spec.ts b/src/api/module/cms.spec.ts index b4eecad..8241fa4 100644 --- a/src/api/module/cms.spec.ts +++ b/src/api/module/cms.spec.ts @@ -23,7 +23,7 @@ vi.mock("@/utils/alova/alova-iview", () => ({ }, })); -import { articleApi, assetUrl, themeApi, themePreviewUrl } from "./cms"; +import { articleApi, assetUrl, svcDictApi, svcItemApi, themeApi, themePreviewUrl } from "./cms"; import { alovaInstance } from "@/utils/alova/alova-iview"; const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" }; @@ -148,3 +148,106 @@ describe("themePreviewUrl 参数语义(T-W4-17:W4 起 sitePath)", () => { expect(url).not.toContain("siteCode="); }); }); + +describe("svc 事项库 API 路径与参数(事项库 §3.3 契约)", () => { + beforeEach(() => { + vi.mocked(alovaInstance.Get).mockClear(); + vi.mocked(alovaInstance.Post).mockClear(); + }); + + it("item: page/get/attList 走 GET query;publish/offline/sort 的 siteId 走 query,delete 走 body", () => { + svcItemApi.page({ siteId: 5, keyword: "转学" }); + expect(alovaInstance.Get).toHaveBeenCalledWith("/api/svc/item/page", { + params: { siteId: 5, keyword: "转学" }, + }); + + svcItemApi.get(5, 2); + expect(alovaInstance.Get).toHaveBeenCalledWith("/api/svc/item/get", { params: { siteId: 5, id: 2 } }); + + svcItemApi.attList(5, 2); + expect(alovaInstance.Get).toHaveBeenCalledWith("/api/svc/item/attList", { params: { siteId: 5, id: 2 } }); + + svcItemApi.publish(5, 2); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/svc/item/publish/2", undefined, { + headers: JSON_HEADERS, + params: { siteId: 5 }, + }); + + svcItemApi.offline(5, 2); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/svc/item/offline/2", undefined, { + headers: JSON_HEADERS, + params: { siteId: 5 }, + }); + + svcItemApi.sort(5, [{ id: 1, sortNo: 2 }]); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/svc/item/sort", [{ id: 1, sortNo: 2 }], { + headers: JSON_HEADERS, + params: { siteId: 5 }, + }); + + svcItemApi.delete({ id: 2, siteId: 5 }); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/svc/item/delete", { id: 2, siteId: 5 }, { + headers: JSON_HEADERS, + params: undefined, + }); + }); + + it("dict: list 带 dictType 透传;dept/tree 路径正确", () => { + svcDictApi.list(5, "object"); + expect(alovaInstance.Get).toHaveBeenCalledWith("/api/svc/dict/list", { + params: { siteId: 5, dictType: "object" }, + }); + + svcDictApi.deptTree(5); + expect(alovaInstance.Get).toHaveBeenCalledWith("/api/svc/dept/tree", { params: { siteId: 5 } }); + }); + + it("item: save/syncDeptName/selList 契约(审计批次 3 补口)", () => { + const payload = { + id: 2, + siteId: 5, + itemCode: "BG-ZX-001", + title: "转学办理", + handleMode: 1, + onlineFlag: 1, + sortNo: 0, + attachmentIds: [7, 8], + }; + svcItemApi.save(payload); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/svc/item/save", payload, { + headers: JSON_HEADERS, + params: undefined, + }); + + svcItemApi.syncDeptName(5); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/svc/item/syncDeptName", undefined, { + headers: JSON_HEADERS, + params: { siteId: 5 }, + }); + + svcItemApi.selList(5); + expect(alovaInstance.Get).toHaveBeenCalledWith("/api/svc/item/selList", { params: { siteId: 5 } }); + }); + + it("dict: save/delete 契约(审计批次 3 补口)", () => { + const payload = { + siteId: 5, + dictType: "object" as const, + dictKey: "person", + dictName: "个人", + sortNo: 0, + status: 1, + }; + svcDictApi.save(payload); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/svc/dict/save", payload, { + headers: JSON_HEADERS, + params: undefined, + }); + + svcDictApi.delete({ id: 3, siteId: 5 }); + expect(alovaInstance.Post).toHaveBeenCalledWith("/api/svc/dict/delete", { id: 3, siteId: 5 }, { + headers: JSON_HEADERS, + params: undefined, + }); + }); +}); diff --git a/src/api/module/cms.ts b/src/api/module/cms.ts index 36588fc..97ec553 100644 --- a/src/api/module/cms.ts +++ b/src/api/module/cms.ts @@ -17,6 +17,15 @@ import { SchoolCard, SchoolOption, SiteVo, + SvcDeptVo, + SvcDictSaveRequest, + SvcDictVo, + SvcItemAttachmentVo, + SvcItemEditVo, + SvcItemPageItem, + SvcItemPageQuery, + SvcItemSaveRequest, + SvcItemSimpleVo, ThemeVo, } from "@/types/cms"; @@ -35,6 +44,7 @@ const ATTACHMENT = "/api/attachment"; const BANNER = "/api/banner"; const LINK = "/api/link"; const THEME = "/api/theme"; +const SVC = "/api/svc"; /** GET(query 参数) */ function get(url: string, params?: IObj): Method { @@ -212,7 +222,6 @@ class ThemeApi { list() { return get(`${THEME}/list`); } - /** W1:上传合规 zip(multipart,超管;免重启立即注册)。 * 任务四:meta.title/description 选填,落位前写入包内 theme.json(留空沿用包内声明) */ upload(file: File, meta?: { title?: string; description?: string }) { @@ -249,6 +258,75 @@ class ThemeApi { } } +/** 事项管理(事项库详细设计 §3.3:/api/svc/item/**) */ +class SvcItemApi { + page(query: SvcItemPageQuery) { + return get>(`${SVC}/item/page`, { ...query }); + } + + /** 编辑回显(applyUrl 已从 ext_json 提取) */ + get(siteId: number, id: number) { + return get(`${SVC}/item/get`, { siteId, id }); + } + + /** 保存(新建草稿/编辑;富文本服务端 jsoup 清洗) */ + save(data: SvcItemSaveRequest) { + return postJSON(`${SVC}/item/save`, data); + } + + publish(siteId: number, id: number) { + return postJSON(`${SVC}/item/publish/${id}`, undefined, { siteId }); + } + + offline(siteId: number, id: number) { + return postJSON(`${SVC}/item/offline/${id}`, undefined, { siteId }); + } + + delete(data: DeleteRequest) { + return postJSON(`${SVC}/item/delete`, data); + } + + /** 自定义排序(按传入 (id, sortNo) 落库) */ + sort(siteId: number, items: { id: number; sortNo: number }[]) { + return postJSON(`${SVC}/item/sort`, items, { siteId }); + } + + /** 同步部门名快照(UserBase 组树改名后刷新) */ + syncDeptName(siteId: number) { + return postJSON<{ updated: number }>(`${SVC}/item/syncDeptName`, undefined, { siteId }); + } + + /** 选择器精简列表 */ + selList(siteId: number) { + return get(`${SVC}/item/selList`, { siteId }); + } + + /** 指南附件回显(编辑页列表) */ + attList(siteId: number, id: number) { + return get(`${SVC}/item/attList`, { siteId, id }); + } +} + +/** 事项字典 + 服务部门选项(/api/svc/dict/**、/api/svc/dept/tree) */ +class SvcDictApi { + list(siteId: number, dictType?: string) { + return get(`${SVC}/dict/list`, { siteId, dictType }); + } + + save(data: SvcDictSaveRequest) { + return postJSON(`${SVC}/dict/save`, data); + } + + delete(data: DeleteRequest) { + return postJSON(`${SVC}/dict/delete`, data); + } + + /** 服务部门选项(UserBase 组树代理;平台不可达为空数组) */ + deptTree(siteId: number) { + return get(`${SVC}/dept/tree`, { siteId }); + } +} + /** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀)。 * W4:内容源站点参数 sitePath(嵌套路径语义,后端 siteSnapshotByPath 解析)。 */ export function themePreviewUrl(sitePath: string, theme: string, themeParams: Record = {}): string { @@ -267,6 +345,8 @@ export const attachmentApi = new AttachmentApi(); export const bannerApi = new BannerApi(); export const linkApi = new LinkApi(); export const themeApi = new ThemeApi(); +export const svcItemApi = new SvcItemApi(); +export const svcDictApi = new SvcDictApi(); export default { authApi, @@ -279,6 +359,8 @@ export default { bannerApi, linkApi, themeApi, + svcItemApi, + svcDictApi, uploadUrl, portalBase, themePreviewUrl, diff --git a/src/components/ImageAssetField.vue b/src/components/ImageAssetField.vue index 336e388..bbc0131 100644 --- a/src/components/ImageAssetField.vue +++ b/src/components/ImageAssetField.vue @@ -12,6 +12,8 @@ const props = defineProps<{ modelValue: string; /** 上传归属站点(附件场景引用;缺省不携带) */ siteId?: number; + /** 附件场景(svc_item 等;缺省不带 scene,与既有调用一致) */ + scene?: string; placeholder?: string; }>(); const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>(); @@ -24,7 +26,10 @@ const previewSrc = computed(() => (props.modelValue ? assetUrl(props.modelValue) const action = computed(() => { const base = uploadUrl(); - return props.siteId ? `${base}?siteId=${props.siteId}` : base; + const params: string[] = []; + if (props.siteId) params.push(`siteId=${props.siteId}`); + if (props.scene) params.push(`scene=${props.scene}`); + return params.length ? `${base}?${params.join("&")}` : base; }); function onUploaded(res: any) { diff --git a/src/components/RichTextEditor.spec.ts b/src/components/RichTextEditor.spec.ts new file mode 100644 index 0000000..6491af3 --- /dev/null +++ b/src/components/RichTextEditor.spec.ts @@ -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, 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"]); + }); +}); diff --git a/src/components/RichTextEditor.vue b/src/components/RichTextEditor.vue new file mode 100644 index 0000000..f785e70 --- /dev/null +++ b/src/components/RichTextEditor.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/src/components/SchoolLayout.vue b/src/components/SchoolLayout.vue index 8753ed3..fec9834 100644 --- a/src/components/SchoolLayout.vue +++ b/src/components/SchoolLayout.vue @@ -194,6 +194,9 @@ const getMenuIcon = (item?: any): string => { if (page === "/link") return "icon-cshxd"; if (page === "/attachment") return "icon-sjlm"; if (page === "/site") return "icon-zhandian"; + // 事项库 R2(§7.5):办事事项取"申请"图标,服务字典取列表图标(前缀分支,优先于 modId 对照) + if (page === "/svc/item" || page.startsWith("/svc/item")) return "icon-shenqing"; + if (page === "/svc/dict") return "icon-liebiao"; switch (item?.modId ?? item?.id) { case 1: return "icon-wenzhang"; case 2: return "icon-liebiao"; @@ -201,6 +204,8 @@ const getMenuIcon = (item?: any): string => { case 4: return "icon-cshxd"; case 5: return "icon-sjlm"; case 6: return "icon-zhandian"; + case 21: return "icon-shenqing"; + case 22: return "icon-liebiao"; default: return ""; } }; diff --git a/src/router/routes.ts b/src/router/routes.ts index 969ab1c..941824a 100644 --- a/src/router/routes.ts +++ b/src/router/routes.ts @@ -106,6 +106,32 @@ const cmsChildren: RouteRecordRaw[] = [ component: () => import("@/views/cms/PortalPreview.vue"), meta: { title: "门户预览", modId: 99 }, }, + { + // 事项库(事项库详细设计 §7.2):办事事项列表 + 编辑(modId=21 对应 UserBase app_module cms_svc_item) + path: "svc/item", + name: "SvcItemList", + component: () => import("@/views/cms/ServiceItemList.vue"), + meta: { title: "办事事项", modId: 21 }, + }, + { + path: "svc/item/edit", + name: "SvcItemEditNew", + component: () => import("@/views/cms/ServiceItemEdit.vue"), + meta: { title: "新建事项", modId: 21, subOf: "/svc/item" }, + }, + { + path: "svc/item/edit/:id", + name: "SvcItemEdit", + component: () => import("@/views/cms/ServiceItemEdit.vue"), + meta: { title: "编辑事项", modId: 21, subOf: "/svc/item" }, + }, + { + // 服务字典(modId=22 对应 UserBase app_module cms_svc_dict) + path: "svc/dict", + name: "SvcDictManage", + component: () => import("@/views/cms/ServiceDictManage.vue"), + meta: { title: "服务字典", modId: 22 }, + }, ]; /** 子站形态:同一批页面,去掉 name(防重复注册告警;子站导航一律走 path 拼装) */ @@ -160,6 +186,9 @@ export const routes: RouteRecordRaw[] = [ { path: "/site/setting/:id", redirect: legacyRedirect("site/setting") }, { path: "/theme", redirect: "/theme-manage" }, { path: "/portal-preview", redirect: legacyRedirect("portal-preview") }, + { path: "/svc/item", redirect: legacyRedirect("svc/item") }, + { path: "/svc/item/edit/:id?", redirect: legacyRedirect("svc/item/edit") }, + { path: "/svc/dict", redirect: legacyRedirect("svc/dict") }, { path: "/:pathMatch(.*)*", redirect: "/workbench", diff --git a/src/types/cms.ts b/src/types/cms.ts index 6389824..12ccd67 100644 --- a/src/types/cms.ts +++ b/src/types/cms.ts @@ -227,6 +227,159 @@ export interface LinkVo { status: number; } +/* ===== 事项库(svc,事项库详细设计 §7.4)===== */ + +/** 事项分页行(SvcItemPageItem) */ +export interface SvcItemPageItem { + id: number; + siteId: number; + itemCode: string; + title: string; + summary?: string; + objectKey?: string; + objectName?: string; + categoryKey?: string; + categoryName?: string; + deptGroupId?: number; + deptName?: string; + /** 1=线上 2=线下 3=线上线下 */ + handleMode: number; + /** 1/0("可在线办理"筛选维度) */ + onlineFlag: number; + /** 0=草稿 1=已发布 2=已下架 */ + status: number; + sortNo: number; + publishTime?: string; + viewCount: number; + createTime?: string; + createBy?: string; +} + +/** 事项编辑回显(SvcItemEditVo) */ +export interface SvcItemEditVo { + 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; + /** ext_json.apply_url(我要办理手填外链,可空) */ + applyUrl?: string; +} + +/** 事项保存入参(SvcItemSaveRequest;applyUrl 服务端写入 ext_json) */ +export interface SvcItemSaveRequest { + id?: number; + siteId: number; + itemCode: string; + title: string; + summary?: string; + objectKey?: string; + categoryKey?: string; + deptGroupId?: number; + 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; + applyUrl?: string; + /** 指南页附件 id(保存时全量重绑) */ + attachmentIds?: number[]; +} + +/** 事项分页查询(SvcItemPageQuery) */ +export interface SvcItemPageQuery { + siteId: number; + status?: number; + keyword?: string; + objectKey?: string; + categoryKey?: string; + deptGroupId?: number; + page?: number; + pageSize?: number; +} + +/** 事项选择器精简行(SvcItemSimpleVo) */ +export interface SvcItemSimpleVo { + id: number; + itemCode: string; + title: string; + status: number; +} + +/** 事项指南附件(SvcItemAttachmentVo) */ +export interface SvcItemAttachmentVo { + id: number; + fileName: string; + webUrl: string; + fileSize?: number; +} + +/** 服务部门选项(SvcDeptVo:UserBase 组树节点,嵌套下发) */ +export interface SvcDeptVo { + id: number; + name: string; + /** 子部门(后端返回嵌套组树;选择器按层级缩进展示) */ + children?: SvcDeptVo[]; +} + +/** 事项字典(SvcDictVo;referenced=被事项引用条数) */ +export interface SvcDictVo { + id: number; + siteId: number; + /** object=服务对象 category=事项类别 */ + dictType: string; + dictKey: string; + dictName: string; + sortNo: number; + /** 0=停用 1=启用 */ + status: number; + referenced: number; +} + +/** 事项字典保存入参(SvcDictSaveRequest) */ +export interface SvcDictSaveRequest { + id?: number; + siteId: number; + dictType: string; + dictKey: string; + dictName: string; + sortNo?: number; + status?: number; +} + /* ===== 通用 ===== */ /** 删除入参(DeleteRequest) */ diff --git a/src/utils/siteConfigForm.spec.ts b/src/utils/siteConfigForm.spec.ts index 148da8b..dea0a11 100644 --- a/src/utils/siteConfigForm.spec.ts +++ b/src/utils/siteConfigForm.spec.ts @@ -114,7 +114,7 @@ describe("siteConfigForm", () => { "social_wechat", "social_weibo", "social_douyin", "social_bilibili", "copyright", "beian", "seo_description", - "domain", "public_base_url", + "domain", "public_base_url", "svc_hall_base", ]); }); it("docs/12:页脚增强新键 parse/serialize/校验(qr_* asset、social_* url)", () => { diff --git a/src/utils/siteConfigForm.ts b/src/utils/siteConfigForm.ts index 09f8274..cae7bcc 100644 --- a/src/utils/siteConfigForm.ts +++ b/src/utils/siteConfigForm.ts @@ -95,6 +95,11 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [ placeholder: "如 https://www.xxx.edu.cn", tip: "绑定独立域名后必填;未填时全站链接走 base 域子路径形态(/yschcms/portal/{站点编码})。", }, + { + key: "svc_hall_base", label: "服务大厅基地址", group: "domain", kind: "url", maxLength: 500, + placeholder: "如 https://svhall.xxx.edu.cn(留空不生成「我要办理」链接)", + tip: "事项指南页「我要办理」按钮:事项单独配置的外链优先;未单独配置时用本地址生成服务大厅发起页(/#/apply?siteCode=…&itemCode=…)。", + }, ]; export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [ diff --git a/src/views/cms/ArticleEdit.vue b/src/views/cms/ArticleEdit.vue index 918aaa7..e1338d4 100644 --- a/src/views/cms/ArticleEdit.vue +++ b/src/views/cms/ArticleEdit.vue @@ -1,12 +1,10 @@ + + + + diff --git a/src/views/cms/ServiceItemEdit.vue b/src/views/cms/ServiceItemEdit.vue new file mode 100644 index 0000000..446399c --- /dev/null +++ b/src/views/cms/ServiceItemEdit.vue @@ -0,0 +1,421 @@ + + + + + diff --git a/src/views/cms/ServiceItemList.vue b/src/views/cms/ServiceItemList.vue new file mode 100644 index 0000000..1755a1b --- /dev/null +++ b/src/views/cms/ServiceItemList.vue @@ -0,0 +1,303 @@ + + + diff --git a/src/views/cms/ServiceItemSortModal.vue b/src/views/cms/ServiceItemSortModal.vue new file mode 100644 index 0000000..5cbdb87 --- /dev/null +++ b/src/views/cms/ServiceItemSortModal.vue @@ -0,0 +1,186 @@ + + + + +