@@ -58,26 +58,11 @@ describe("docs/13 路由结构(真实 routes 数组,docs/15 T1)", () => {
|
|||||||
expect(main.name).toBe("CategoryManage");
|
expect(main.name).toBe("CategoryManage");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("②c 事项库路由(审计批次 3 补口):办事事项 modId=21、服务字典 modId=22,子站镜像可解析", () => {
|
it("②c 事项库路由已随 V2.0 拆分移除(svc/item、svc/dict 不再注册,直达落入 404 重定向)", () => {
|
||||||
|
// 事项管理能力已迁独立办事大厅(svc-hall 应用);CMS 前端仅保留展示壳,
|
||||||
|
// 旧菜单路径由 UserBase 停用 + 通配重定向兜底。
|
||||||
const item = router.resolve("/sch/demo/svc/item");
|
const item = router.resolve("/sch/demo/svc/item");
|
||||||
expect(item.name).toBe("SvcItemList");
|
expect(item.name).toBeUndefined();
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-104
@@ -23,7 +23,7 @@ vi.mock("@/utils/alova/alova-iview", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { articleApi, assetUrl, svcDictApi, svcItemApi, themeApi, themePreviewUrl } from "./cms";
|
import { articleApi, assetUrl, 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,106 +148,3 @@ 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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -17,15 +17,6 @@ import {
|
|||||||
SchoolCard,
|
SchoolCard,
|
||||||
SchoolOption,
|
SchoolOption,
|
||||||
SiteVo,
|
SiteVo,
|
||||||
SvcDeptVo,
|
|
||||||
SvcDictSaveRequest,
|
|
||||||
SvcDictVo,
|
|
||||||
SvcItemAttachmentVo,
|
|
||||||
SvcItemEditVo,
|
|
||||||
SvcItemPageItem,
|
|
||||||
SvcItemPageQuery,
|
|
||||||
SvcItemSaveRequest,
|
|
||||||
SvcItemSimpleVo,
|
|
||||||
ThemeVo,
|
ThemeVo,
|
||||||
} from "@/types/cms";
|
} from "@/types/cms";
|
||||||
|
|
||||||
@@ -44,7 +35,6 @@ 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 {
|
||||||
@@ -258,75 +248,6 @@ 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 {
|
||||||
@@ -345,8 +266,6 @@ 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,
|
||||||
@@ -359,8 +278,6 @@ export default {
|
|||||||
bannerApi,
|
bannerApi,
|
||||||
linkApi,
|
linkApi,
|
||||||
themeApi,
|
themeApi,
|
||||||
svcItemApi,
|
|
||||||
svcDictApi,
|
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
portalBase,
|
portalBase,
|
||||||
themePreviewUrl,
|
themePreviewUrl,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const props = defineProps<{
|
|||||||
modelValue: string;
|
modelValue: string;
|
||||||
/** 上传归属站点(附件场景引用;缺省不携带) */
|
/** 上传归属站点(附件场景引用;缺省不携带) */
|
||||||
siteId?: number;
|
siteId?: number;
|
||||||
/** 附件场景(svc_item 等;缺省不带 scene,与既有调用一致) */
|
/** 附件场景(空=不带 scene;svc 场景已迁办事大厅,CMS 内为 article/banner 等) */
|
||||||
scene?: string;
|
scene?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}>();
|
}>();
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("RichTextEditor - 正文上传与事件契约", () => {
|
describe("RichTextEditor - 正文上传与事件契约", () => {
|
||||||
it("① 上传 FormData 带 siteId/scene/articleId(事项页口径:scene=svc_item_asset)", async () => {
|
it("① 上传 FormData 带 siteId/scene/articleId(scene 透传契约)", async () => {
|
||||||
const seen: FormData[] = [];
|
const seen: FormData[] = [];
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import config from "@/assets/config/host.config";
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: string;
|
modelValue: string;
|
||||||
siteId?: number;
|
siteId?: number;
|
||||||
/** 附件场景(svc_item_asset 正文资产 / svc_item 指南附件等;空=不带 scene) */
|
/** 附件场景(article_asset 正文资产等;空=不带 scene;svc 场景已迁办事大厅) */
|
||||||
scene?: string;
|
scene?: string;
|
||||||
/** 文章 id(文章编辑器上传时的归属参数;无文章 id 的页面不传) */
|
/** 文章 id(文章编辑器上传时的归属参数;无文章 id 的页面不传) */
|
||||||
articleId?: number;
|
articleId?: number;
|
||||||
|
|||||||
@@ -195,8 +195,6 @@ const getMenuIcon = (item?: any): string => {
|
|||||||
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 对照)
|
// 事项库 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";
|
||||||
|
|||||||
@@ -106,32 +106,6 @@ 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 拼装) */
|
||||||
@@ -186,9 +160,6 @@ 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",
|
||||||
|
|||||||
@@ -228,209 +228,3 @@ export interface LinkVo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== 事项库(svc,事项库详细设计 §7.4)===== */
|
/* ===== 事项库(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) */
|
|
||||||
export interface DeleteRequest {
|
|
||||||
id: number;
|
|
||||||
siteId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 后端分页包装 {total, list} */
|
|
||||||
export interface PageResult<T> {
|
|
||||||
total: number;
|
|
||||||
list: T[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 主题 ===== */
|
|
||||||
|
|
||||||
/** theme.json 可调参数选项(select 类型) */
|
|
||||||
export interface ThemeParamOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 主题包参数 schema(ThemeParam) */
|
|
||||||
export interface ThemeParamVo {
|
|
||||||
key: string;
|
|
||||||
/** 聚合进 :root{} 的 CSS 变量名(仅 color 类型有) */
|
|
||||||
cssVar?: string;
|
|
||||||
label: string;
|
|
||||||
/** color / image / select / text */
|
|
||||||
type: string;
|
|
||||||
defaultValue?: string;
|
|
||||||
options?: ThemeParamOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 主题包(ThemeVo,GET /api/theme/list) */
|
|
||||||
export interface ThemeVo {
|
|
||||||
name: string;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
version?: string;
|
|
||||||
author?: string;
|
|
||||||
bannerSize?: string;
|
|
||||||
/** 预览图(管理端资产代理 /api/theme/{name}/asset/screenshot.png) */
|
|
||||||
screenshotUrl: string;
|
|
||||||
params: ThemeParamVo[];
|
|
||||||
/** 包来源(W1):builtin=内置只读 / external=外部可管 */
|
|
||||||
source?: string;
|
|
||||||
/** 被用站点数(W1,删除保护口径与后端一致) */
|
|
||||||
inUseCount?: number;
|
|
||||||
/** 是否可编辑/删除(W1:仅外部包) */
|
|
||||||
editable?: boolean;
|
|
||||||
/** 引用站点名列表(W1:删除保护提示/tooltip) */
|
|
||||||
usedBySiteNames?: string[];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -112,25 +112,28 @@ function findNode(list: any[], id: number): any {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** target 是否 node 自身或其子孙(上级下拉排除,防把栏目挂到自己子树下成环) */
|
/** target 是否 node 自身或其子孙(成环判定:把栏目挂到自己子树下会形成环) */
|
||||||
function isSelfOrDescendant(node: any, targetId: number | undefined): boolean {
|
function isSelfOrDescendant(node: any, targetId: number | undefined): boolean {
|
||||||
if (!node || targetId == null) return false;
|
if (!node || targetId == null) return false;
|
||||||
if (node.id === targetId) return true;
|
if (node.id === targetId) return true;
|
||||||
return (node.children || []).some((c: any) => isSelfOrDescendant(c, targetId));
|
return (node.children || []).some((c: any) => isSelfOrDescendant(c, targetId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上级栏目选项:虚拟根(0=rootLabel)+ 真实树拍平缩进;编辑态禁选自身及后代(后端仅防一级成环) */
|
/**
|
||||||
|
* 上级栏目选项:虚拟根(0=rootLabel)+ 真实树拍平缩进。
|
||||||
|
*
|
||||||
|
* 2026-09-11 口径调整:**不再置灰任何选项**(含外链型栏目、含自身及其子栏目)——
|
||||||
|
* 类型与"能否作父"无关,而且灰色项只显示一个无解释的锁,用户看到的往往是
|
||||||
|
* "办事大厅被禁用了"这类误解。成环改为保存前校验(见 save())并给出明确原因,
|
||||||
|
* 后端 CategoryService.validateParent 同步升级为沿祖先链防任意层级成环。
|
||||||
|
*/
|
||||||
const parentOptions = computed(() => {
|
const parentOptions = computed(() => {
|
||||||
const list: { id: number; label: string; disabled: boolean }[] = [
|
const list: { id: number; label: string }[] = [
|
||||||
{ id: 0, label: rootLabel.value || "根栏目", disabled: false },
|
{ id: 0, label: rootLabel.value || "根栏目" },
|
||||||
];
|
];
|
||||||
const walk = (nodes: any[], depth: number) => {
|
const walk = (nodes: any[], depth: number) => {
|
||||||
(nodes || []).forEach((n: any) => {
|
(nodes || []).forEach((n: any) => {
|
||||||
list.push({
|
list.push({ id: n.id, label: `${" ".repeat(depth)}${n.name}` });
|
||||||
id: n.id,
|
|
||||||
label: `${" ".repeat(depth)}${n.name}`,
|
|
||||||
disabled: isSelfOrDescendant(n, form.id),
|
|
||||||
});
|
|
||||||
walk(n.children || [], depth + 1);
|
walk(n.children || [], depth + 1);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -217,9 +220,32 @@ function showDeleteNode() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存前成环校验:上级栏目不能是自身或自己的子栏目。
|
||||||
|
* 返回提示文案(命中)或空串(通过)。
|
||||||
|
*/
|
||||||
|
function findCycleParent(): string {
|
||||||
|
const parentId = Number(form.parentId);
|
||||||
|
if (!parentId) return "";
|
||||||
|
if (form.id != null && parentId === Number(form.id)) {
|
||||||
|
return "上级栏目不能是自身,请另选一个上级栏目";
|
||||||
|
}
|
||||||
|
const parentNode = findNode(treeData.value, parentId);
|
||||||
|
if (parentNode && isSelfOrDescendant(parentNode, form.id)) {
|
||||||
|
return `「${parentNode.name}」是当前栏目的子栏目,挂到它下面会形成循环,请另选上级栏目`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
const valid = await refFormData.value?.validate();
|
const valid = await refFormData.value?.validate();
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
// 成环校验(下拉已不再置灰,这里给出可读原因;后端 CategoryService.validateParent 同口径兜底)
|
||||||
|
const cycleHit = findCycleParent();
|
||||||
|
if (cycleHit) {
|
||||||
|
Message.warning(cycleHit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await categoryApi
|
await categoryApi
|
||||||
@@ -474,10 +500,11 @@ function openLink(url: string) {
|
|||||||
<Divider size="small" orientation="left">基本信息</Divider>
|
<Divider size="small" orientation="left">基本信息</Divider>
|
||||||
<FormItem label="上级栏目" prop="parentId">
|
<FormItem label="上级栏目" prop="parentId">
|
||||||
<Select v-model="form.parentId" transfer>
|
<Select v-model="form.parentId" transfer>
|
||||||
<Option v-for="opt in parentOptions" :key="opt.id" :value="opt.id" :disabled="opt.disabled">
|
<Option v-for="opt in parentOptions" :key="opt.id" :value="opt.id">
|
||||||
{{ opt.label }}
|
{{ opt.label }}
|
||||||
</Option>
|
</Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
<p class="form-tip">任何类型的栏目都可作为上级(外链型也可以);不能选自身或自己的子栏目(会成环)。</p>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
<FormItem label="栏目名称" prop="name">
|
<FormItem label="栏目名称" prop="name">
|
||||||
<Input ref="refNodeNameInput" v-model="form.name" :maxlength="64" placeholder="栏目名称" />
|
<Input ref="refNodeNameInput" v-model="form.name" :maxlength="64" placeholder="栏目名称" />
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,421 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<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