落地(依据《事项库与办事大厅详细设计 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:
@@ -95,6 +95,50 @@ export interface MockLink {
|
|||||||
status: number;
|
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 {
|
export interface MockDb {
|
||||||
sites: MockSite[];
|
sites: MockSite[];
|
||||||
categories: MockCategory[];
|
categories: MockCategory[];
|
||||||
@@ -102,6 +146,8 @@ export interface MockDb {
|
|||||||
attachments: MockAttachment[];
|
attachments: MockAttachment[];
|
||||||
banners: MockBanner[];
|
banners: MockBanner[];
|
||||||
links: MockLink[];
|
links: MockLink[];
|
||||||
|
svcItems: MockSvcItem[];
|
||||||
|
svcDicts: MockSvcDict[];
|
||||||
/** 上传文件缓存:url 尾段 → {buffer, mime} */
|
/** 上传文件缓存:url 尾段 → {buffer, mime} */
|
||||||
files: Map<string, { buffer: Buffer; mime: string; name: string }>;
|
files: Map<string, { buffer: Buffer; mime: string; name: string }>;
|
||||||
}
|
}
|
||||||
@@ -135,6 +181,8 @@ export function seedDb(): MockDb {
|
|||||||
attachments: [],
|
attachments: [],
|
||||||
banners: [],
|
banners: [],
|
||||||
links: [],
|
links: [],
|
||||||
|
svcItems: [],
|
||||||
|
svcDicts: [],
|
||||||
files: new Map(),
|
files: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -373,6 +421,54 @@ export function seedDb(): MockDb {
|
|||||||
l({ siteId: 2, groupName: "附中链接", name: "教育云平台", linkUrl: "https://edu.example.cn" })
|
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;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+181
-1
@@ -12,7 +12,7 @@
|
|||||||
import type { Plugin } from "vite";
|
import type { Plugin } from "vite";
|
||||||
import type { IncomingMessage, ServerResponse } from "http";
|
import type { IncomingMessage, ServerResponse } from "http";
|
||||||
import { MockDb, categoryTree, categoryName, nextId, seedDb } from "./data";
|
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();
|
const db: MockDb = seedDb();
|
||||||
|
|
||||||
@@ -855,6 +855,186 @@ async function handleApi(req: IncomingMessage, res: ServerResponse, path: string
|
|||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,28 @@ describe("docs/13 路由结构(真实 routes 数组,docs/15 T1)", () => {
|
|||||||
expect(main.matched).toHaveLength(2);
|
expect(main.matched).toHaveLength(2);
|
||||||
expect(main.name).toBe("CategoryManage");
|
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)", () => {
|
describe("docs/13 守卫与旧链兼容(真实 router.push,docs/15 T4)", () => {
|
||||||
|
|||||||
+104
-1
@@ -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";
|
import { alovaInstance } from "@/utils/alova/alova-iview";
|
||||||
|
|
||||||
const JSON_HEADERS = { "Content-Type": "application/json;charset=UTF-8" };
|
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=");
|
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
@@ -17,6 +17,15 @@ import {
|
|||||||
SchoolCard,
|
SchoolCard,
|
||||||
SchoolOption,
|
SchoolOption,
|
||||||
SiteVo,
|
SiteVo,
|
||||||
|
SvcDeptVo,
|
||||||
|
SvcDictSaveRequest,
|
||||||
|
SvcDictVo,
|
||||||
|
SvcItemAttachmentVo,
|
||||||
|
SvcItemEditVo,
|
||||||
|
SvcItemPageItem,
|
||||||
|
SvcItemPageQuery,
|
||||||
|
SvcItemSaveRequest,
|
||||||
|
SvcItemSimpleVo,
|
||||||
ThemeVo,
|
ThemeVo,
|
||||||
} from "@/types/cms";
|
} from "@/types/cms";
|
||||||
|
|
||||||
@@ -35,6 +44,7 @@ const ATTACHMENT = "/api/attachment";
|
|||||||
const BANNER = "/api/banner";
|
const BANNER = "/api/banner";
|
||||||
const LINK = "/api/link";
|
const LINK = "/api/link";
|
||||||
const THEME = "/api/theme";
|
const THEME = "/api/theme";
|
||||||
|
const SVC = "/api/svc";
|
||||||
|
|
||||||
/** GET(query 参数) */
|
/** GET(query 参数) */
|
||||||
function get<T>(url: string, params?: IObj): Method {
|
function get<T>(url: string, params?: IObj): Method {
|
||||||
@@ -212,7 +222,6 @@ class ThemeApi {
|
|||||||
list() {
|
list() {
|
||||||
return get<ThemeVo[]>(`${THEME}/list`);
|
return get<ThemeVo[]>(`${THEME}/list`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** W1:上传合规 zip(multipart,超管;免重启立即注册)。
|
/** W1:上传合规 zip(multipart,超管;免重启立即注册)。
|
||||||
* 任务四:meta.title/description 选填,落位前写入包内 theme.json(留空沿用包内声明) */
|
* 任务四:meta.title/description 选填,落位前写入包内 theme.json(留空沿用包内声明) */
|
||||||
upload(file: File, meta?: { title?: string; description?: string }) {
|
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_ 前缀)。
|
/** 主题预览 iframe URL(方案 §5.5:/api/theme/preview,CAS 会话同源,参数 p_ 前缀)。
|
||||||
* W4:内容源站点参数 sitePath(嵌套路径语义,后端 siteSnapshotByPath 解析)。 */
|
* W4:内容源站点参数 sitePath(嵌套路径语义,后端 siteSnapshotByPath 解析)。 */
|
||||||
export function themePreviewUrl(sitePath: string, theme: string, themeParams: Record<string, string> = {}): string {
|
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 bannerApi = new BannerApi();
|
||||||
export const linkApi = new LinkApi();
|
export const linkApi = new LinkApi();
|
||||||
export const themeApi = new ThemeApi();
|
export const themeApi = new ThemeApi();
|
||||||
|
export const svcItemApi = new SvcItemApi();
|
||||||
|
export const svcDictApi = new SvcDictApi();
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
authApi,
|
authApi,
|
||||||
@@ -279,6 +359,8 @@ export default {
|
|||||||
bannerApi,
|
bannerApi,
|
||||||
linkApi,
|
linkApi,
|
||||||
themeApi,
|
themeApi,
|
||||||
|
svcItemApi,
|
||||||
|
svcDictApi,
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
portalBase,
|
portalBase,
|
||||||
themePreviewUrl,
|
themePreviewUrl,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ const props = defineProps<{
|
|||||||
modelValue: string;
|
modelValue: string;
|
||||||
/** 上传归属站点(附件场景引用;缺省不携带) */
|
/** 上传归属站点(附件场景引用;缺省不携带) */
|
||||||
siteId?: number;
|
siteId?: number;
|
||||||
|
/** 附件场景(svc_item 等;缺省不带 scene,与既有调用一致) */
|
||||||
|
scene?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}>();
|
}>();
|
||||||
const emit = defineEmits<{ (e: "update:modelValue", v: string): void }>();
|
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 action = computed(() => {
|
||||||
const base = uploadUrl();
|
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) {
|
function onUploaded(res: any) {
|
||||||
|
|||||||
@@ -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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -194,6 +194,9 @@ const getMenuIcon = (item?: any): string => {
|
|||||||
if (page === "/link") return "icon-cshxd";
|
if (page === "/link") return "icon-cshxd";
|
||||||
if (page === "/attachment") return "icon-sjlm";
|
if (page === "/attachment") return "icon-sjlm";
|
||||||
if (page === "/site") return "icon-zhandian";
|
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) {
|
switch (item?.modId ?? item?.id) {
|
||||||
case 1: return "icon-wenzhang";
|
case 1: return "icon-wenzhang";
|
||||||
case 2: return "icon-liebiao";
|
case 2: return "icon-liebiao";
|
||||||
@@ -201,6 +204,8 @@ const getMenuIcon = (item?: any): string => {
|
|||||||
case 4: return "icon-cshxd";
|
case 4: return "icon-cshxd";
|
||||||
case 5: return "icon-sjlm";
|
case 5: return "icon-sjlm";
|
||||||
case 6: return "icon-zhandian";
|
case 6: return "icon-zhandian";
|
||||||
|
case 21: return "icon-shenqing";
|
||||||
|
case 22: return "icon-liebiao";
|
||||||
default: return "";
|
default: return "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -106,6 +106,32 @@ const cmsChildren: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/cms/PortalPreview.vue"),
|
component: () => import("@/views/cms/PortalPreview.vue"),
|
||||||
meta: { title: "门户预览", modId: 99 },
|
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 拼装) */
|
/** 子站形态:同一批页面,去掉 name(防重复注册告警;子站导航一律走 path 拼装) */
|
||||||
@@ -160,6 +186,9 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
{ path: "/site/setting/:id", redirect: legacyRedirect("site/setting") },
|
{ path: "/site/setting/:id", redirect: legacyRedirect("site/setting") },
|
||||||
{ path: "/theme", redirect: "/theme-manage" },
|
{ path: "/theme", redirect: "/theme-manage" },
|
||||||
{ path: "/portal-preview", redirect: legacyRedirect("portal-preview") },
|
{ 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(.*)*",
|
path: "/:pathMatch(.*)*",
|
||||||
redirect: "/workbench",
|
redirect: "/workbench",
|
||||||
|
|||||||
@@ -227,6 +227,159 @@ export interface LinkVo {
|
|||||||
status: number;
|
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) */
|
/** 删除入参(DeleteRequest) */
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe("siteConfigForm", () => {
|
|||||||
"social_wechat", "social_weibo", "social_douyin", "social_bilibili",
|
"social_wechat", "social_weibo", "social_douyin", "social_bilibili",
|
||||||
"copyright", "beian",
|
"copyright", "beian",
|
||||||
"seo_description",
|
"seo_description",
|
||||||
"domain", "public_base_url",
|
"domain", "public_base_url", "svc_hall_base",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
it("docs/12:页脚增强新键 parse/serialize/校验(qr_* asset、social_* url)", () => {
|
it("docs/12:页脚增强新键 parse/serialize/校验(qr_* asset、social_* url)", () => {
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ export const SITE_CONFIG_FIELD_DEFS: SiteConfigFieldDef[] = [
|
|||||||
placeholder: "如 https://www.xxx.edu.cn",
|
placeholder: "如 https://www.xxx.edu.cn",
|
||||||
tip: "绑定独立域名后必填;未填时全站链接走 base 域子路径形态(/yschcms/portal/{站点编码})。",
|
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 }[] = [
|
export const SITE_CONFIG_GROUPS: { name: SiteConfigGroup; title: string }[] = [
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from "vue-router";
|
||||||
import { Message } from "view-ui-plus";
|
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 { articleApi, categoryApi, uploadUrl } from "@/api/module/cms";
|
||||||
|
import RichTextEditor from "@/components/RichTextEditor.vue";
|
||||||
import { useCmsStore } from "@/store";
|
import { useCmsStore } from "@/store";
|
||||||
import config from "@/assets/config/host.config";
|
|
||||||
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
|
import { portalUrlOf, adminPathOf } from "@/utils/sitePath";
|
||||||
import { CategoryTreeNode } from "@/types/cms";
|
import { CategoryTreeNode } from "@/types/cms";
|
||||||
|
|
||||||
@@ -55,46 +53,11 @@ function flattenCategories(tree: CategoryTreeNode[], depth = 0): { id: number; l
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== wangEditor(Notice 已趟坑配置:无视频组、自定义图片上传) ===== */
|
/* 富文本正文:走公共 RichTextEditor(审计批次 6 起与事项页共用一份实现,
|
||||||
const editorRef = shallowRef();
|
消除原内联 wangEditor 的重复 customUpload;编辑器内贴图 id 由组件 emit uploaded 收集) */
|
||||||
const toolbarConfig = { excludeKeys: ["group-video", "fullScreen"] };
|
function onEditorUploaded(attachmentId: number) {
|
||||||
const serviceBase = config.isMock ? "" : config.serviceHost;
|
uploadedIds.value.push(attachmentId);
|
||||||
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;
|
|
||||||
}
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
editorRef.value?.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ===== 封面上传 ===== */
|
/* ===== 封面上传 ===== */
|
||||||
function coverUploaded(res: any) {
|
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)" />
|
<Switch :model-value="form.topmost === 1" @on-change="(v: boolean) => (form.topmost = v ? 1 : 0)" />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="正文">
|
<FormItem label="正文">
|
||||||
<div class="editor-wrap">
|
<RichTextEditor
|
||||||
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
|
|
||||||
<Editor
|
|
||||||
class="editor-content"
|
|
||||||
v-model="form.content"
|
v-model="form.content"
|
||||||
:defaultConfig="editorConfig"
|
:site-id="store.siteId"
|
||||||
mode="default"
|
:article-id="editId"
|
||||||
@onCreated="handleCreated"
|
placeholder="请输入正文……"
|
||||||
|
slate-min-height="max(480px, calc(100vh - 430px))"
|
||||||
|
class="editor-full"
|
||||||
|
@uploaded="onEditorUploaded"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<!-- 微信对接预留入口(方案 §7.4):本期禁用灰显,不产生任何请求 -->
|
<!-- 微信对接预留入口(方案 §7.4):本期禁用灰显,不产生任何请求 -->
|
||||||
<FormItem label="同步公众号">
|
<FormItem label="同步公众号">
|
||||||
@@ -323,25 +285,7 @@ watch(() => store.siteId, () => load());
|
|||||||
color: #999;
|
color: #999;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.editor-wrap {
|
.editor-full {
|
||||||
border: 1px solid #dcdee2;
|
width: 100%;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
|
指南页「我要办理」按钮优先级:本外链 > 站点设置「服务大厅基地址」自动生成 > 不显示按钮。两者都配置时以本外链为准
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user