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;
}
+22
View File
@@ -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)", () => {
+104 -1
View File
@@ -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,
});
});
});
+83 -1
View File
@@ -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<T>(url: string, params?: IObj): Method {
@@ -212,7 +222,6 @@ class ThemeApi {
list() {
return get<ThemeVo[]>(`${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<PageResult<SvcItemPageItem>>(`${SVC}/item/page`, { ...query });
}
/** 编辑回显(applyUrl 已从 ext_json 提取) */
get(siteId: number, id: number) {
return get<SvcItemEditVo>(`${SVC}/item/get`, { siteId, id });
}
/** 保存(新建草稿/编辑;富文本服务端 jsoup 清洗) */
save(data: SvcItemSaveRequest) {
return postJSON<number>(`${SVC}/item/save`, data);
}
publish(siteId: number, id: number) {
return postJSON<void>(`${SVC}/item/publish/${id}`, undefined, { siteId });
}
offline(siteId: number, id: number) {
return postJSON<void>(`${SVC}/item/offline/${id}`, undefined, { siteId });
}
delete(data: DeleteRequest) {
return postJSON<void>(`${SVC}/item/delete`, data);
}
/** 自定义排序(按传入 (id, sortNo) 落库) */
sort(siteId: number, items: { id: number; sortNo: number }[]) {
return postJSON<void>(`${SVC}/item/sort`, items, { siteId });
}
/** 同步部门名快照(UserBase 组树改名后刷新) */
syncDeptName(siteId: number) {
return postJSON<{ updated: number }>(`${SVC}/item/syncDeptName`, undefined, { siteId });
}
/** 选择器精简列表 */
selList(siteId: number) {
return get<SvcItemSimpleVo[]>(`${SVC}/item/selList`, { siteId });
}
/** 指南附件回显(编辑页列表) */
attList(siteId: number, id: number) {
return get<SvcItemAttachmentVo[]>(`${SVC}/item/attList`, { siteId, id });
}
}
/** 事项字典 + 服务部门选项(/api/svc/dict/**、/api/svc/dept/tree) */
class SvcDictApi {
list(siteId: number, dictType?: string) {
return get<SvcDictVo[]>(`${SVC}/dict/list`, { siteId, dictType });
}
save(data: SvcDictSaveRequest) {
return postJSON<number>(`${SVC}/dict/save`, data);
}
delete(data: DeleteRequest) {
return postJSON<void>(`${SVC}/dict/delete`, data);
}
/** 服务部门选项(UserBase 组树代理;平台不可达为空数组) */
deptTree(siteId: number) {
return get<SvcDeptVo[]>(`${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, string> = {}): 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,
+6 -1
View File
@@ -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) {
+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"]);
});
});
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { onBeforeUnmount, ref, shallowRef, watch } from "vue";
import { Message } from "view-ui-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import config from "@/assets/config/host.config";
/**
* 通用富文本编辑器(事项库 C-5:从 ArticleEdit 内联 wangEditor 抽取的公共组件;
* 审计批次 6 起 ArticleEdit 亦复用本组件,消除两份实现)。
* v-model 绑定 HTML;图片 customUpload 直传 /api/attachment/upload(fetch + credentials),
* 上传成功 emit uploaded(attachmentId) 供调用方收集(保存时绑定归属)。
* 销毁时 editor.destroy()(wangEditor 必须显式销毁防内存泄漏)。
*/
const props = defineProps<{
modelValue: string;
siteId?: number;
/** 附件场景(svc_item_asset 正文资产 / svc_item 指南附件等;空=不带 scene) */
scene?: string;
/** 文章 id(文章编辑器上传时的归属参数;无文章 id 的页面不传) */
articleId?: number;
placeholder?: string;
/** 建议 ≥320(长文显示不全修复口径:容器随内容增高,短文铺满 min-height) */
height?: number;
/** slate 内容区最小高度(CSS 值);缺省用事项页口径,文章页传入自己的口径保持原观感 */
slateMinHeight?: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", v: string): void;
(e: "uploaded", attachmentId: number): void;
}>();
const editorRef = shallowRef();
const serviceBase = config.isMock ? "" : config.serviceHost;
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
const editorConfig = {
placeholder: props.placeholder || "请输入内容……",
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
const fd = new FormData();
fd.append("file", file);
if (props.siteId) {
fd.append("siteId", String(props.siteId));
}
if (props.articleId) {
fd.append("articleId", String(props.articleId));
}
if (props.scene) {
fd.append("scene", props.scene);
}
try {
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const res = await resp.json();
if (res.code === 0 && res.data) {
emit("uploaded", res.data.id);
insertFn(res.data.url, res.data.fileName || "", res.data.url);
} else {
Message.error(res.msg || "图片上传失败");
}
} catch (e) {
console.warn("[rich-editor] 图片上传失败", e);
Message.error("图片上传失败");
}
},
},
},
};
function handleCreated(editor: any) {
editorRef.value = editor;
}
onBeforeUnmount(() => {
editorRef.value?.destroy();
});
// 外部回显值变化同步进编辑器(编辑页 load 后异步回填)
watch(
() => props.modelValue,
(v) => {
if (editorRef.value && v !== editorRef.value.getHtml()) {
editorRef.value.setHtml(v || "<p><br></p>");
}
}
);
</script>
<template>
<div
class="rich-editor"
:style="{ minHeight: (height || 360) + 'px', '--rich-editor-slate-min': slateMinHeight || 'max(240px, calc(100vh - 560px))' }"
>
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
<Editor
class="editor-content"
:model-value="modelValue"
:defaultConfig="editorConfig"
mode="default"
style="height: 100%"
@onCreated="handleCreated"
@update:model-value="(v: string) => emit('update:modelValue', v)"
/>
</div>
</template>
<style scoped lang="scss">
.rich-editor {
display: flex;
flex-direction: column;
border: 1px solid #dcdee2;
border-radius: 4px;
z-index: 100;
.editor-toolbar {
border-bottom: 1px solid #dcdee2;
}
.editor-content {
flex: 1;
overflow: visible;
// 长文显示不全修复(2026-09-10)同款口径:文本容器高度随内容自动增长,
// 整篇平铺由页面滚动;空态/短文 slate 区 min-height 铺满(调用方可用 slateMinHeight 覆写口径)
:deep(.w-e-text-container) {
height: auto !important;
}
:deep([data-slate-editor]) {
min-height: var(--rich-editor-slate-min) !important;
}
}
}
</style>
+5
View File
@@ -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 "";
}
};
+29
View File
@@ -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",
+153
View File
@@ -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) */
+1 -1
View File
@@ -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)", () => {
+5
View File
@@ -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 }[] = [
+17 -73
View File
@@ -1,12 +1,10 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { articleApi, categoryApi, uploadUrl } from "@/api/module/cms";
import RichTextEditor from "@/components/RichTextEditor.vue";
import { useCmsStore } from "@/store";
import config from "@/assets/config/host.config";
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
import { CategoryTreeNode } from "@/types/cms";
@@ -55,46 +53,11 @@ function flattenCategories(tree: CategoryTreeNode[], depth = 0): { id: number; l
return list;
}
/* ===== wangEditor(Notice 已趟坑配置:无视频组、自定义图片上传) ===== */
const editorRef = shallowRef();
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
const serviceBase = config.isMock ? "" : config.serviceHost;
const editorConfig = {
placeholder: "请输入正文……",
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
const fd = new FormData();
fd.append("file", file);
fd.append("siteId", String(store.siteId));
if (editId.value) fd.append("articleId", String(editId.value));
try {
const resp = await fetch(`${serviceBase}/api/attachment/upload`, {
method: "POST",
body: fd,
credentials: "include",
});
const res = await resp.json();
if (res.code === 0 && res.data) {
uploadedIds.value.push(res.data.id);
insertFn(res.data.url, res.data.fileName || "", res.data.url);
} else {
Message.error(res.msg || "图片上传失败");
}
} catch (e) {
Message.error("图片上传失败");
}
},
},
},
};
function handleCreated(editor: any) {
editorRef.value = editor;
editorLoading.value = false;
/* 富文本正文:走公共 RichTextEditor(审计批次 6 起与事项页共用一份实现,
消除原内联 wangEditor 的重复 customUpload;编辑器内贴图 id 由组件 emit uploaded 收集) */
function onEditorUploaded(attachmentId: number) {
uploadedIds.value.push(attachmentId);
}
onBeforeUnmount(() => {
editorRef.value?.destroy();
});
/* ===== 封面上传 ===== */
function coverUploaded(res: any) {
@@ -282,16 +245,15 @@ watch(() => store.siteId, () => load());
<Switch :model-value="form.topmost === 1" @on-change="(v: boolean) => (form.topmost = v ? 1 : 0)" />
</FormItem>
<FormItem label="正文">
<div class="editor-wrap">
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
<Editor
class="editor-content"
v-model="form.content"
:defaultConfig="editorConfig"
mode="default"
@onCreated="handleCreated"
/>
</div>
<RichTextEditor
v-model="form.content"
:site-id="store.siteId"
:article-id="editId"
placeholder="请输入正文……"
slate-min-height="max(480px, calc(100vh - 430px))"
class="editor-full"
@uploaded="onEditorUploaded"
/>
</FormItem>
<!-- 微信对接预留入口(方案 §7.4):本期禁用灰显,不产生任何请求 -->
<FormItem label="同步公众号">
@@ -323,25 +285,7 @@ watch(() => store.siteId, () => load());
color: #999;
font-size: 12px;
}
.editor-wrap {
border: 1px solid #dcdee2;
border-radius: 4px;
z-index: 100;
.editor-toolbar {
border-bottom: 1px solid #dcdee2;
}
.editor-content {
// 长图文显示不全修复(2026-09-10):旧做法把 .w-e-text-container 钉在
// max(480px, 100vh-430px) 固定高,长文被关进内滚窗口且 Windows overlay
// 滚动条不常显,正文尾部看似被截断。改为容器高度随内容自动增长,
// 整篇平铺进页面由窗口滚动;空态/短文用 slate 区 min-height 铺满视口余量
//(430≈壳层(56+56)+表单区高度,下限 480px)。
:deep(.w-e-text-container) {
height: auto !important;
}
:deep([data-slate-editor]) {
min-height: max(480px, calc(100vh - 430px)) !important;
}
}
.editor-full {
width: 100%;
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import Utils from "@/utils/index";
import MyConfirm from "@/components/MyConfirm";
import { svcDictApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { SvcDictVo } from "@/types/cms";
const store = useCmsStore();
const rows = ref<SvcDictVo[]>([]);
const loading = ref(false);
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true });
/** dict_type 分 Tab:object=服务对象 / category=事项类别 */
const dictType = ref("object");
const typeDict = [
{ value: "object", label: "服务对象" },
{ value: "category", label: "事项类别" },
];
const typeLabel = computed(() => typeDict.find(t => t.value === dictType.value)?.label || "");
const columns = [
{ title: "字典键", key: "dictKey", width: 200, tooltip: true },
{ title: "字典名称", key: "dictName", minWidth: 180, tooltip: true },
{ title: "排序", key: "sortNo", width: 80, align: "center" },
{
title: "被引用",
key: "referenced",
width: 100,
align: "center",
render: (h: any, p: any) =>
h("span", { style: { color: p.row.referenced > 0 ? "#2d8cf0" : "#999" } }, String(p.row.referenced)),
},
{
title: "状态",
key: "status",
width: 90,
align: "center",
render: (h: any, p: any) =>
h(Tag, { color: p.row.status === 1 ? "success" : "default" }, () => (p.row.status === 1 ? "启用" : "停用")),
},
{
title: "操作",
key: "action",
width: 120,
fixed: "right",
align: "center",
render: (h: any, p: any) => [
Utils.operatingHandle(h, "编辑", "icon-edit", () => openEdit(p.row)),
Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row)),
],
},
];
const modalVisible = ref(false);
const saving = ref(false);
const formRef = ref();
const form = reactive({
id: undefined as number | undefined,
dictKey: "",
dictName: "",
sortNo: 0,
status: 1,
});
const rules = {
dictKey: [
{ required: true, message: "字典键不得为空", trigger: "blur" },
{ pattern: /^[A-Za-z0-9_-]{1,64}$/, message: "仅限字母/数字/下划线/中划线", trigger: "blur" },
],
dictName: [{ required: true, message: "字典名称不得为空", trigger: "blur" }],
};
/** 编辑且已被引用时键不可改(服务端同口径拦截;前端仅提示性禁用) */
const keyLocked = computed(() => form.id != null && editingReferenced.value > 0);
const editingReferenced = ref(0);
async function run() {
if (!store.siteId) return;
loading.value = true;
try {
rows.value = (await svcDictApi.list(store.siteId, dictType.value).send()) || [];
} finally {
loading.value = false;
}
}
function openCreate() {
Object.assign(form, { id: undefined, dictKey: "", dictName: "", sortNo: 0, status: 1 });
editingReferenced.value = 0;
modalVisible.value = true;
}
function openEdit(row: SvcDictVo) {
Object.assign(form, {
id: row.id,
dictKey: row.dictKey,
dictName: row.dictName,
sortNo: row.sortNo,
status: row.status,
});
editingReferenced.value = row.referenced;
modalVisible.value = true;
}
async function save() {
const valid = await formRef.value?.validate();
if (!valid) return;
saving.value = true;
try {
await svcDictApi
.save({
id: form.id,
siteId: store.siteId!,
dictType: dictType.value,
dictKey: form.dictKey.trim(),
dictName: form.dictName,
sortNo: form.sortNo,
status: form.status,
})
.send();
Message.success("保存成功!");
modalVisible.value = false;
run();
} finally {
saving.value = false;
}
}
function doDelete(row: SvcDictVo) {
MyConfirm({
title: "删除字典",
msg: `确定删除「${row.dictName}(${row.dictKey})」吗?`,
info: row.referenced > 0 ? `已被 ${row.referenced} 个事项引用,不能删除;可改为停用(存量引用仍可渲染)` : "未被引用,可安全删除",
async onOk() {
await svcDictApi.delete({ id: row.id, siteId: store.siteId! }).send();
Message.success("操作成功!");
run();
},
});
}
onMounted(run);
watch(() => store.siteId, () => run());
watch(dictType, () => run());
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<div class="fix">
<RadioGroup v-model="dictType" class="fl type-switch" type="button">
<Radio v-for="t in typeDict" :key="t.value" :label="t.value">{{ t.label }}</Radio>
</RadioGroup>
<Button class="fl" type="primary" size="large" @click="openCreate">
<Icon type="ios-add" />
新增{{ typeLabel }}
</Button>
<span class="type-tip fr">
停用后不出现在门户筛选与事项选择器(存量引用仍可渲染);键被事项引用后不可修改、不可删除
</span>
</div>
<Table :columns="columns" :data="rows" :height="tableHeight" :loading="loading" :no-data-text="`暂无${typeLabel}`" />
<Modal v-model="modalVisible" :title="(form.id ? '编辑' : '新增') + typeLabel" :mask-closable="false" width="520px">
<Form ref="formRef" :model="form" :rules="rules" :label-width="100">
<FormItem label="字典键" prop="dictKey">
<Input v-model="form.dictKey" :disabled="keyLocked" :maxlength="64" placeholder="稳定键,如 person / jw" />
<span v-if="keyLocked" class="key-tip">已被 {{ editingReferenced }} 个事项引用,键不可修改(只能改名称)</span>
</FormItem>
<FormItem label="字典名称" prop="dictName">
<Input v-model="form.dictName" :maxlength="128" placeholder="展示名,如 个人 / 教务服务" />
</FormItem>
<FormItem label="排序">
<InputNumber v-model="form.sortNo" :min="0" />
</FormItem>
<FormItem label="状态">
<RadioGroup v-model="form.status">
<Radio :label="1">启用</Radio>
<Radio :label="0">停用</Radio>
</RadioGroup>
</FormItem>
</Form>
<template #footer>
<Button @click="modalVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="save">保存</Button>
</template>
</Modal>
</div>
</div>
</template>
<style scoped lang="scss">
.type-switch {
margin-right: 12px;
margin-top: 6px;
}
.type-tip {
color: #999;
line-height: 40px;
font-size: 12px;
}
.key-tip {
color: #e6a23c;
font-size: 12px;
margin-left: 8px;
}
</style>
+421
View File
@@ -0,0 +1,421 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { svcDictApi, svcItemApi, uploadUrl } from "@/api/module/cms";
import { categoryApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { adminPathOf } from "@/utils/sitePath";
import RichTextEditor from "@/components/RichTextEditor.vue";
import ImageAssetField from "@/components/ImageAssetField.vue";
import { CategoryTreeNode, SvcDeptVo, SvcDictVo, SvcItemAttachmentVo } from "@/types/cms";
const route = useRoute();
const router = useRouter();
const store = useCmsStore();
const editId = computed(() => {
const raw = route.params.id || route.query.id;
return raw ? parseInt(String(raw)) : undefined;
});
const formRef = ref();
const saving = ref(false);
const publishing = ref(false);
const form = reactive({
itemCode: "",
title: "",
summary: "",
objectKey: "" as string,
categoryKey: "" as string,
deptGroupId: undefined as number | undefined,
deptName: "",
handleMode: 1,
onlineFlag: 1,
handlePlace: "",
handleTimeLimit: "",
promiseTimeLimit: "",
contactPhone: "",
supervisePhone: "",
processHtml: "",
materialHtml: "",
noticeHtml: "",
faqCategoryId: undefined as number | undefined,
noticeCategoryId: undefined as number | undefined,
ruleCategoryId: undefined as number | undefined,
coverImage: "",
sortNo: 0,
applyUrl: "",
});
const rules = {
itemCode: [
{ required: true, message: "事项编码不得为空", trigger: "blur" },
{ pattern: /^[A-Za-z0-9_-]{2,64}$/, message: "仅限字母/数字/下划线/中划线,2~64 位", trigger: "blur" },
],
title: [{ required: true, message: "事项名称不得为空", trigger: "blur" }],
};
const objectOptions = ref<SvcDictVo[]>([]);
const categoryOptions = ref<SvcDictVo[]>([]);
const deptOptions = ref<{ id: number; label: string }[]>([]);
const categoryTreeOptions = ref<{ id: number; label: string }[]>([]);
const deptUnavailable = ref(false);
/**
* 指南附件列表(编辑回显 + 本次上传合并;保存时以本列表为准重绑 ref_type=svc_item)。
* 封面与正文内嵌图不在此列表:它们以 scene=svc_item_asset 上传,保存时由后端按内容
* (coverImage + 三段富文本里的本站上传地址)反查归属,故前端无需收集其附件 id。
*/
const attachments = ref<SvcItemAttachmentVo[]>([]);
function flattenCategories(tree: CategoryTreeNode[], depth = 0): { id: number; label: string }[] {
const list: { id: number; label: string }[] = [];
tree.forEach(node => {
list.push({ id: node.id!, label: `${" ".repeat(depth)}${node.name}` });
if (node.children?.length) list.push(...flattenCategories(node.children, depth + 1));
});
return list;
}
/** 部门组树 → 缩进扁平选项(后端下发嵌套结构,选择器仍用扁平 Select 保持既有交互) */
function flattenDepts(tree: SvcDeptVo[], depth = 0): { id: number; label: string }[] {
const list: { id: number; label: string }[] = [];
tree.forEach(node => {
list.push({ id: node.id, label: `${" ".repeat(depth)}${node.name}` });
if (node.children?.length) list.push(...flattenDepts(node.children, depth + 1));
});
return list;
}
async function load() {
if (!store.siteId) return;
const [objects, categories, deptTree, catTree] = await Promise.all([
svcDictApi.list(store.siteId, "object").send(),
svcDictApi.list(store.siteId, "category").send(),
svcDictApi.deptTree(store.siteId).send().catch(() => []),
categoryApi.tree(store.siteId).send(),
]);
objectOptions.value = (objects || []).filter(d => d.status === 1);
categoryOptions.value = (categories || []).filter(d => d.status === 1);
deptOptions.value = flattenDepts(deptTree || []);
deptUnavailable.value = !deptOptions.value.length;
categoryTreeOptions.value = flattenCategories(catTree || []);
if (!editId.value) return;
const detail = await svcItemApi.get(store.siteId, editId.value).send();
if (!detail) return;
Object.assign(form, {
itemCode: detail.itemCode,
title: detail.title,
summary: detail.summary || "",
objectKey: detail.objectKey || "",
categoryKey: detail.categoryKey || "",
deptGroupId: detail.deptGroupId,
deptName: detail.deptName || "",
handleMode: detail.handleMode ?? 1,
onlineFlag: detail.onlineFlag ?? 1,
handlePlace: detail.handlePlace || "",
handleTimeLimit: detail.handleTimeLimit || "",
promiseTimeLimit: detail.promiseTimeLimit || "",
contactPhone: detail.contactPhone || "",
supervisePhone: detail.supervisePhone || "",
processHtml: detail.processHtml || "",
materialHtml: detail.materialHtml || "",
noticeHtml: detail.noticeHtml || "",
faqCategoryId: detail.faqCategoryId,
noticeCategoryId: detail.noticeCategoryId,
ruleCategoryId: detail.ruleCategoryId,
coverImage: detail.coverImage || "",
sortNo: detail.sortNo ?? 0,
applyUrl: detail.applyUrl || "",
});
attachments.value = (await svcItemApi.attList(store.siteId, editId.value).send()) || [];
}
function attachmentUploaded(res: any) {
if (res.code === 0 && res.data) {
attachments.value.push({
id: res.data.id,
fileName: res.data.fileName,
webUrl: res.data.url,
fileSize: res.data.fileSize,
});
} else {
Message.error(res?.msg || "附件上传失败");
}
}
function attachmentError() {
Message.error("附件上传失败");
}
function removeAttachment(index: number) {
attachments.value.splice(index, 1);
}
function buildRequest() {
return {
id: editId.value,
siteId: store.siteId!,
itemCode: form.itemCode.trim(),
title: form.title,
summary: form.summary || undefined,
objectKey: form.objectKey || undefined,
categoryKey: form.categoryKey || undefined,
deptGroupId: form.deptGroupId,
handleMode: form.handleMode,
onlineFlag: form.onlineFlag,
handlePlace: form.handlePlace || undefined,
handleTimeLimit: form.handleTimeLimit || undefined,
promiseTimeLimit: form.promiseTimeLimit || undefined,
contactPhone: form.contactPhone || undefined,
supervisePhone: form.supervisePhone || undefined,
processHtml: form.processHtml,
materialHtml: form.materialHtml,
noticeHtml: form.noticeHtml,
faqCategoryId: form.faqCategoryId,
noticeCategoryId: form.noticeCategoryId,
ruleCategoryId: form.ruleCategoryId,
coverImage: form.coverImage || undefined,
sortNo: form.sortNo,
applyUrl: form.applyUrl.trim() || undefined,
attachmentIds: attachments.value.map(a => a.id),
};
}
async function save(): Promise<number | undefined> {
const valid = await formRef.value?.validate();
if (!valid) return undefined;
saving.value = true;
try {
return await svcItemApi.save(buildRequest()).send();
} finally {
saving.value = false;
}
}
function backToList() {
const code = store.schoolCode;
router.replace(code ? adminPathOf(code, store.siteSegCode, "svc/item") : "/workbench");
}
async function saveDraft() {
const id = await save();
if (id) {
Message.success("保存成功!");
if (!editId.value) {
const code = store.schoolCode;
router.replace(code ? adminPathOf(code, store.siteSegCode, `svc/item/edit/${id}`) : "/workbench");
} else {
await load();
}
}
}
async function saveAndPublish() {
const id = await save();
if (!id) return;
publishing.value = true;
try {
await svcItemApi.publish(store.siteId!, id).send();
Message.success("已发布!门户服务中心即刻可见");
backToList();
} finally {
publishing.value = false;
}
}
onMounted(load);
// 站点上下文切换即时重载(docs/09 §3.3)
watch(() => store.siteId, () => load());
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<Form ref="formRef" :model="form" :rules="rules" :label-width="110">
<FormItem label="事项编码" prop="itemCode">
<Input v-model="form.itemCode" placeholder="如 BG-ZX-001(站内唯一,进入办理页 URL)" style="width: 320px" :maxlength="64" />
<span class="form-tip">仅限字母/数字/下划线/中划线;创建后与站点编码组成跨服务唯一键</span>
</FormItem>
<FormItem label="事项名称" prop="title">
<Input v-model="form.title" placeholder="如 转学办理" style="width: 640px" :maxlength="200" />
</FormItem>
<FormItem label="一句话简介">
<Input v-model="form.summary" type="textarea" :rows="2" placeholder="列表卡片副标题(可空)" style="width: 640px" :maxlength="500" />
</FormItem>
<FormItem label="服务对象">
<Select v-model="form.objectKey" clearable placeholder="请选择(在「服务字典」维护)" style="width: 320px">
<Option v-for="d in objectOptions" :key="d.dictKey" :value="d.dictKey">{{ d.dictName }}</Option>
</Select>
</FormItem>
<FormItem label="事项类别">
<Select v-model="form.categoryKey" clearable placeholder="请选择(在「服务字典」维护)" style="width: 320px">
<Option v-for="d in categoryOptions" :key="d.dictKey" :value="d.dictKey">{{ d.dictName }}</Option>
</Select>
</FormItem>
<FormItem label="服务部门">
<Select
v-model="form.deptGroupId"
clearable
filterable
placeholder="请选择部门(来自基础平台组织机构)"
style="width: 320px"
>
<Option v-for="d in deptOptions" :key="d.id" :value="d.id">{{ d.label }}</Option>
</Select>
<span v-if="deptUnavailable" class="form-tip warn">
组织机构暂不可达或为空,可先留空保存;恢复后重新选择可刷新部门名快照
</span>
</FormItem>
<FormItem label="办理方式">
<RadioGroup v-model="form.handleMode">
<Radio :label="1">线上办理</Radio>
<Radio :label="2">线下办理</Radio>
<Radio :label="3">线上线下</Radio>
</RadioGroup>
</FormItem>
<FormItem label="可在线办理">
<Switch :model-value="form.onlineFlag === 1" @on-change="(v: boolean) => (form.onlineFlag = v ? 1 : 0)" />
<span class="form-tip">门户"可在线办理"筛选标识;与办理方式可不一致(如线上线下但暂未开通在线入口时关闭)</span>
</FormItem>
<FormItem label="办理地点">
<Input v-model="form.handlePlace" placeholder="如 行政楼 203 室" style="width: 320px" :maxlength="200" />
</FormItem>
<FormItem label="办理时限">
<Input v-model="form.handleTimeLimit" placeholder="如 3 个工作日" style="width: 320px" :maxlength="64" />
</FormItem>
<FormItem label="承诺时限">
<Input v-model="form.promiseTimeLimit" placeholder="如 1 个工作日(可空)" style="width: 320px" :maxlength="64" />
</FormItem>
<FormItem label="咨询电话">
<Input v-model="form.contactPhone" placeholder="如 0571-88888888" style="width: 320px" :maxlength="64" />
</FormItem>
<FormItem label="监督投诉电话">
<Input v-model="form.supervisePhone" placeholder="可空" style="width: 320px" :maxlength="64" />
</FormItem>
<FormItem label="我要办理外链">
<Input v-model="form.applyUrl" placeholder="https://…(可空,须为完整 http(s) 地址)" style="width: 640px" :maxlength="500" />
<span class="form-tip">
指南页「我要办理」按钮优先级:本外链 &gt; 站点设置「服务大厅基地址」自动生成 &gt; 不显示按钮。两者都配置时以本外链为准
</span>
</FormItem>
<FormItem label="常见问题栏目">
<Select v-model="form.faqCategoryId" clearable filterable placeholder="关联栏目(可空)" style="width: 320px">
<Option v-for="opt in categoryTreeOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</Option>
</Select>
</FormItem>
<FormItem label="公告信息栏目">
<Select v-model="form.noticeCategoryId" clearable filterable placeholder="关联栏目(可空)" style="width: 320px">
<Option v-for="opt in categoryTreeOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</Option>
</Select>
</FormItem>
<FormItem label="文件规定栏目">
<Select v-model="form.ruleCategoryId" clearable filterable placeholder="关联栏目(可空)" style="width: 320px">
<Option v-for="opt in categoryTreeOptions" :key="opt.id" :value="opt.id">{{ opt.label }}</Option>
</Select>
</FormItem>
<FormItem label="事项封面">
<ImageAssetField v-model="form.coverImage" :site-id="store.siteId" scene="svc_item_asset" style="width: 420px" />
</FormItem>
<FormItem label="排序值">
<InputNumber v-model="form.sortNo" :min="0" />
<span class="form-tip">升序,门户服务中心列表的第一优先级</span>
</FormItem>
<FormItem label="办理流程">
<RichTextEditor
v-model="form.processHtml"
:site-id="store.siteId"
scene="svc_item_asset"
placeholder="请输入办理流程……"
class="editor-wide"
/>
</FormItem>
<FormItem label="所需材料">
<RichTextEditor
v-model="form.materialHtml"
:site-id="store.siteId"
scene="svc_item_asset"
placeholder="请输入所需材料……"
class="editor-wide"
/>
</FormItem>
<FormItem label="注意事项">
<RichTextEditor
v-model="form.noticeHtml"
:site-id="store.siteId"
scene="svc_item_asset"
placeholder="请输入注意事项……"
class="editor-wide"
/>
</FormItem>
<FormItem label="指南附件">
<Upload
:action="uploadUrl() + '?siteId=' + store.siteId + '&scene=svc_item'"
with-credentials
:show-upload-list="false"
:on-success="attachmentUploaded"
:on-error="attachmentError"
>
<Button icon="ios-cloud-upload-outline">上传附件(表格/须知等)</Button>
</Upload>
<ul v-if="attachments.length" class="att-list">
<li v-for="(att, idx) in attachments" :key="att.id">
<Icon type="ios-document-outline" />
<span class="att-name" :title="att.fileName">{{ att.fileName }}</span>
<Button size="small" type="text" class="att-del" @click="removeAttachment(idx)">移除</Button>
</li>
</ul>
<span class="form-tip">保存时按当前列表绑定;移除即解除引用(文件保留在附件库)。正文图片与封面自动归属,无需在此登记</span>
</FormItem>
<FormItem>
<Button type="primary" :loading="publishing" @click="saveAndPublish">保存并发布</Button>
<Button style="margin-left: 8px" :loading="saving" @click="saveDraft">保存草稿</Button>
<Button style="margin-left: 8px" @click="backToList">返回</Button>
</FormItem>
</Form>
</div>
</div>
</template>
<style scoped lang="scss">
.form-tip {
margin-left: 12px;
color: #999;
font-size: 12px;
&.warn {
color: #e6a23c;
}
}
.editor-wide {
width: 860px;
max-width: 100%;
}
.att-list {
margin: 8px 0 0;
padding: 0;
list-style: none;
border: 1px solid #e5e6eb;
border-radius: 4px;
max-width: 640px;
li {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
& + li {
border-top: 1px dashed #e5e6eb;
}
.att-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.att-del {
color: #999;
}
}
}
</style>
+303
View File
@@ -0,0 +1,303 @@
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import Utils from "@/utils/index";
import MyConfirm from "@/components/MyConfirm";
import ServiceItemSortModal from "./ServiceItemSortModal.vue";
import { svcDictApi, svcItemApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { adminPathOf } from "@/utils/sitePath";
import { SvcDictVo, SvcItemPageItem } from "@/types/cms";
const router = useRouter();
const store = useCmsStore();
const rows = ref<SvcItemPageItem[]>([]);
const total = ref(0);
const loading = ref(false);
const pageNum = ref(1);
const pageSize = ref(parseInt(localStorage.getItem("customPageCount") || "10"));
const tableHeight = Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true });
const search = reactive({
status: undefined as number | undefined,
objectKey: "" as string,
categoryKey: "" as string,
keyword: "",
});
const objectOptions = ref<SvcDictVo[]>([]);
const categoryOptions = ref<SvcDictVo[]>([]);
const sortVisible = ref(false);
const syncing = ref(false);
const statusDict = [
{ value: 0, label: "草稿", color: "default" },
{ value: 1, label: "已发布", color: "success" },
{ value: 2, label: "已下架", color: "warning" },
];
const statusText = (s: number) => statusDict.find(i => i.value === s)?.label || "未知";
const statusColor = (s: number) => statusDict.find(i => i.value === s)?.color || "default";
const handleModeDict = [
{ value: 1, label: "线上办理" },
{ value: 2, label: "线下办理" },
{ value: 3, label: "线上线下" },
];
const handleModeText = (v: number) => handleModeDict.find(i => i.value === v)?.label || "-";
const columns = [
{
title: "事项名称",
key: "title",
minWidth: 220,
tooltip: true,
render: (h: any, p: any) =>
h("span", null, [
h(
"a",
{ style: { color: "#2d8cf0", cursor: "pointer" }, onClick: () => goEdit(p.row.id) },
p.row.title
),
p.row.onlineFlag === 1
? h(Tag, { color: "primary", style: { marginLeft: "6px" } }, () => "可在线办理")
: null,
]),
},
{ title: "事项编码", key: "itemCode", width: 130, tooltip: true },
{ title: "事项类别", key: "categoryName", width: 110, render: (h: any, p: any) => h("span", p.row.categoryName || "-") },
{ title: "服务对象", key: "objectName", width: 100, render: (h: any, p: any) => h("span", p.row.objectName || "-") },
{ title: "服务部门", key: "deptName", width: 120, tooltip: true, render: (h: any, p: any) => h("span", p.row.deptName || "-") },
{
title: "办理方式",
key: "handleMode",
width: 100,
align: "center",
render: (h: any, p: any) => h("span", handleModeText(p.row.handleMode)),
},
{ title: "排序", key: "sortNo", width: 70, align: "center" },
{
title: "状态",
key: "status",
width: 90,
align: "center",
render: (h: any, p: any) => h(Tag, { color: statusColor(p.row.status) }, () => statusText(p.row.status)),
},
{
title: "发布时间",
key: "publishTime",
width: 160,
render: (h: any, p: any) => h("span", p.row.publishTime || "-"),
},
{
title: "操作",
key: "action",
width: 190,
fixed: "right",
align: "center",
render: (h: any, p: any) => {
const ops: any[] = [
Utils.operatingHandle(h, "编辑", "icon-edit", () => goEdit(p.row.id)),
];
if (p.row.status !== 1) {
ops.push(Utils.operatingHandle(h, "发布", "icon-fasong", () => doPublish(p.row)));
}
if (p.row.status === 1) {
ops.push(Utils.operatingHandle(h, "下架", "icon-stop", () => doOffline(p.row)));
}
ops.push(Utils.operatingHandle(h, "删除", "icon-ashbin", () => doDelete(p.row)));
return ops;
},
},
];
async function loadDicts() {
if (!store.siteId) return;
const [objects, categories] = await Promise.all([
svcDictApi.list(store.siteId, "object").send(),
svcDictApi.list(store.siteId, "category").send(),
]);
objectOptions.value = (objects || []).filter(d => d.status === 1);
categoryOptions.value = (categories || []).filter(d => d.status === 1);
}
async function run() {
if (!store.siteId) return;
loading.value = true;
try {
const page = await svcItemApi
.page({
siteId: store.siteId,
status: search.status,
objectKey: search.objectKey || undefined,
categoryKey: search.categoryKey || undefined,
keyword: search.keyword || undefined,
page: pageNum.value,
pageSize: pageSize.value,
})
.send();
rows.value = page?.list || [];
total.value = page?.total || 0;
} finally {
loading.value = false;
}
}
function pageSizeUpdate(size: number) {
pageSize.value = size;
localStorage.setItem("customPageCount", String(size));
pageNum.value = 1;
run();
}
function goEdit(id?: number) {
const code = store.schoolCode;
if (!code) {
router.push("/workbench");
return;
}
// 站点段跟随当前上下文(docs/13):子站编辑页 URL 带 /site/{code}
router.push(adminPathOf(code, store.siteSegCode, id ? `svc/item/edit/${id}` : "svc/item/edit"));
}
async function doPublish(row: SvcItemPageItem) {
await svcItemApi.publish(store.siteId!, row.id).send();
Message.success("发布成功!门户服务中心即刻可见");
run();
}
async function doOffline(row: SvcItemPageItem) {
await svcItemApi.offline(store.siteId!, row.id).send();
Message.success("已下架");
run();
}
function doDelete(row: SvcItemPageItem) {
MyConfirm({
title: "删除事项",
msg: `确定删除「${row.title}」吗?`,
info: "删除后指南附件转为未使用,历史办件不受影响(办件持有发起时快照)",
async onOk() {
await svcItemApi.delete({ id: row.id, siteId: store.siteId! }).send();
Message.success("操作成功!");
if (rows.value.length <= 1 && pageNum.value > 1) pageNum.value -= 1;
run();
},
});
}
/** 同步部门名:UserBase 组树改名后刷新本站事项的 dept_name 快照 */
function doSyncDeptName() {
if (!store.siteId) return;
MyConfirm({
title: "同步部门名",
msg: "按基础平台组织机构刷新全部事项的服务部门名称快照,是否继续?",
info: "列表展示的部门名即刻更新;门户指南页缓存会在本次操作后整体失效重建",
async onOk() {
syncing.value = true;
try {
const res = await svcItemApi.syncDeptName(store.siteId!).send();
Message.success(`同步完成,更新 ${res?.updated ?? 0} 条`);
run();
} finally {
syncing.value = false;
}
},
});
}
onMounted(async () => {
await loadDicts();
await run();
});
// 站点上下文切换即时重载(docs/09 §3.3)
watch(() => store.siteId, async () => {
await loadDicts();
pageNum.value = 1;
await run();
});
</script>
<template>
<div class="page-wrapper">
<div class="main-container">
<div class="fix mb-12">
<Button class="fl" type="primary" size="large" @click="goEdit()">
<Icon type="ios-add" />
新建事项
</Button>
<Button class="fl ml-12" size="large" @click="sortVisible = true">
<Icon type="ios-swap" />
事项排序
</Button>
<Button class="fl ml-12" size="large" :loading="syncing" @click="doSyncDeptName">
<Icon type="ios-refresh" />
同步部门名
</Button>
</div>
<div class="search-wrap fix">
<Select
v-model="search.objectKey"
class="search-wrap_sel"
clearable
placeholder="全部服务对象"
@on-change="pageNum = 1; run()"
>
<Option v-for="d in objectOptions" :key="d.dictKey" :value="d.dictKey">{{ d.dictName }}</Option>
</Select>
<Select
v-model="search.categoryKey"
class="search-wrap_sel ml-12"
clearable
placeholder="全部事项类别"
@on-change="pageNum = 1; run()"
>
<Option v-for="d in categoryOptions" :key="d.dictKey" :value="d.dictKey">{{ d.dictName }}</Option>
</Select>
<Select
v-model="search.status"
class="search-wrap_sel ml-12"
clearable
placeholder="全部状态"
@on-change="pageNum = 1; run()"
>
<Option v-for="s in statusDict" :key="s.value" :value="s.value">{{ s.label }}</Option>
</Select>
<Input
search
enter-button
class="fr"
placeholder="名称/编码关键字"
style="width: 220px"
v-model="search.keyword"
@on-search="pageNum = 1; run()"
@on-clear="pageNum = 1; run()"
/>
</div>
<Table
:columns="columns"
:data="rows"
:height="tableHeight"
:loading="loading"
no-data-text="暂无事项"
/>
<div class="page-panel">
<Page
class="page-box fr"
:total="total"
:current="pageNum"
:page-size="pageSize"
:page-size-opts="[10, 20, 30, 40]"
@on-page-size-change="pageSizeUpdate"
@on-change="(p: number) => { pageNum = p; run(); }"
placement="top"
show-total
show-sizer
/>
</div>
</div>
<ServiceItemSortModal v-model:visible="sortVisible" @saved="run" />
</div>
</template>
+186
View File
@@ -0,0 +1,186 @@
<template>
<Modal v-model="visible" title="事项排序" :width="560" :mask-closable="false">
<Alert show-icon>
拖动左侧手柄调整顺序,保存后按此顺序在门户服务中心展示(排序值是列表第一优先级)
</Alert>
<draggable class="sort-list" v-model="localItems" item-key="id" handle=".drag-handle" @change="onDragChange">
<template #item="{ element, index }">
<div class="sort-item">
<span class="drag-handle">⠿</span>
<span class="sort-index">{{ index + 1 }}</span>
<span class="sort-item-name">{{ element.title || element.itemCode }}</span>
<span class="sort-no">{{ element.itemCode }}</span>
</div>
</template>
</draggable>
<div class="sort-empty" v-if="localItems.length === 0">暂无事项</div>
<div class="sort-tip" v-if="localItems.length >= SEL_LIST_MAX">
事项超过 {{ SEL_LIST_MAX }} 条,此处仅展示前 {{ SEL_LIST_MAX }} 条
</div>
<template #footer>
<Button type="primary" :loading="saving" @click="saveSort" :disabled="localItems.length === 0">
保存排序
</Button>
<Button @click="visible = false">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
/* 事项排序:照 CategorySortModal 形态;顺序来源用 selList(已按 sort_no 升序返回),
保存时按下标重排 sort_no(1..n),接口 /api/svc/item/sort */
import { ref, watch } from "vue";
import draggable from "vuedraggable";
import { Message } from "view-ui-plus";
import { svcItemApi } from "@/api/module/cms";
import { useCmsStore } from "@/store";
import { SvcItemSimpleVo } from "@/types/cms";
const SEL_LIST_MAX = 500;
const visible = defineModel<boolean>("visible", { default: false });
const emit = defineEmits<{ saved: [] }>();
const store = useCmsStore();
const localItems = ref<SvcItemSimpleVo[]>([]);
const saving = ref(false);
const hasChanged = ref(false);
const loading = ref(false);
function onDragChange() {
hasChanged.value = true;
}
async function resetLocal() {
if (!store.siteId) return;
loading.value = true;
try {
localItems.value = (await svcItemApi.selList(store.siteId).send()) || [];
} finally {
loading.value = false;
hasChanged.value = false;
}
}
async function saveSort() {
if (!store.siteId) return;
if (!hasChanged.value) {
Message.info("排序未发生变化");
visible.value = false;
return;
}
saving.value = true;
try {
await svcItemApi
.sort(
store.siteId,
localItems.value.map((x, index) => ({ id: x.id, sortNo: index + 1 }))
)
.send();
Message.success("保存成功");
hasChanged.value = false;
emit("saved");
visible.value = false;
} finally {
saving.value = false;
}
}
watch(visible, v => {
if (v) resetLocal();
});
</script>
<style lang="scss" scoped>
.sort-list {
max-height: 400px;
overflow-y: auto;
}
.sort-item {
display: flex;
align-items: center;
padding: 10px 12px;
margin-bottom: 6px;
background: #fafafa;
border: 1px solid #e8e8e8;
border-radius: 4px;
transition: background 0.2s;
&:hover {
background: #f0f4ff;
border-color: #c0d0ff;
}
}
.sort-list :deep(.sort-item.sortable-chosen),
.sort-list :deep(.sort-item.sortable-drag) {
background: #fff7e6;
border-color: #fa8c16;
box-shadow: 0 6px 16px rgba(250, 140, 22, 0.4);
cursor: grabbing;
.drag-handle {
color: #fa8c16;
}
}
.sort-list :deep(.sort-item.sortable-ghost) {
background: #fffbe6;
border: 1px dashed #fa8c16;
opacity: 0.6;
}
.drag-handle {
cursor: grab;
display: flex;
align-items: center;
padding: 0 8px;
color: #999;
flex-shrink: 0;
&:active {
cursor: grabbing;
}
}
.sort-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background: #577afb;
color: #fff;
font-size: 13px;
font-weight: 500;
flex-shrink: 0;
margin-right: 12px;
}
.sort-item-name {
flex: 1;
font-size: 14px;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sort-no {
color: #999;
font-size: 12px;
flex-shrink: 0;
}
.sort-empty,
.sort-tip {
padding: 12px 0;
text-align: center;
color: #999;
font-size: 13px;
}
</style>