From cdf04f093040cfe9865726da4b65d3d5df93d4f7 Mon Sep 17 00:00:00 2001 From: chenxing Date: Fri, 18 Sep 2026 16:52:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=E5=BA=94=E7=94=A8=E9=A1=B5?= =?UTF-8?q?=E8=A7=84=E8=8C=83=E5=85=A8=E9=87=8F=E9=87=8D=E6=9E=84=20+=20?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E4=B8=8E=E6=B5=81=E7=A8=8B=E8=BD=A8?= =?UTF-8?q?=E8=BF=B9=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 应用页规范(bsch)重构: - 6 个列表页迁规范骨架: page-wrapper/tabs-wrapper + search-wrap 搜索区(关键字 200px 即输即搜, 去查询按钮) + link-table table-bottom-box 定高表格 + page-panel 分页栏 - 分页列表统一接 TablePageAlovaNew(页长 [10,20,30,40]/customPageCount 记忆/增删智能翻页); dataApi 须返回 Method, 调 .send() 会导致表格静默空数据 - 操作列统一图标式 operatingHandle(Tooltip+fixed right 居中); 各列表补序号列; 危险操作统一 MyConfirm 二次确认 - 弹窗表单必填红星 + 统一提示「请填写红色星号内容!」 - TablePageAlovaNew 补页长记忆与重载后清空勾选; 新增 src/types OperatorEnum 工作台与流程轨迹优化: - 已办列表去重: 每办件一行, doneCount>1 显示「共办理 N 次」标签 - 新增 TraceTimeline 轨迹时间流(时间升序/kind/轮次, 按人员/科室切换写回 URL), 接入详情/办理页 - 新增流程源码只读抽屉 SourceDrawer, 接入定义列表与图示页 - 办理人映射/流程定义新增「启用」回路(/admin/map/enable, /admin/def/enable) - 图示视图口径统一 dept/user 两档, 缺省人员视图; 状态列 Tag 色映射统一导出 --- src/api/module/flow.ts | 37 ++- src/components/TablePageAlovaNew.vue | 18 +- src/types/index.ts | 7 + src/utils/flowLayout.ts | 96 +++++++ src/views/flow/FlowDiagram.vue | 262 +++++++++++++----- src/views/flow/InstanceDetail.vue | 55 ++-- src/views/flow/MyInstances.vue | 103 +++---- src/views/flow/TaskHandle.vue | 33 ++- src/views/flow/Workbench.vue | 102 ++++--- src/views/flow/admin/Callbacks.vue | 224 ++++++++------- src/views/flow/admin/Defs.vue | 129 ++++++--- src/views/flow/admin/Instances.vue | 81 +++--- src/views/flow/admin/Maps.vue | 200 +++++++------ .../flow/admin/def-editor/bpmn/serialize.ts | 99 +++++++ src/views/flow/components/SourceDrawer.vue | 80 ++++++ src/views/flow/components/TraceTimeline.vue | 127 +++++++++ 16 files changed, 1172 insertions(+), 481 deletions(-) create mode 100644 src/types/index.ts create mode 100644 src/views/flow/components/SourceDrawer.vue create mode 100644 src/views/flow/components/TraceTimeline.vue diff --git a/src/api/module/flow.ts b/src/api/module/flow.ts index 1666014..4e67cb8 100644 --- a/src/api/module/flow.ts +++ b/src/api/module/flow.ts @@ -25,6 +25,14 @@ export const INSTANCE_STATUS: Record = { 40: "已撤回", }; +/** 状态列 Tag 主题色(与 INSTANCE_STATUS 同码表,缺省灰) */ +export const INSTANCE_STATUS_COLOR: Record = { + 10: "blue", + 20: "green", + 30: "red", + 40: "gray", +}; + export const TASK_STATUS: Record = { 0: "待办", 1: "同意", @@ -32,6 +40,21 @@ export const TASK_STATUS: Record = { 3: "失效", 4: "退回", 5: "移交", + // 6/7 为轨迹留痕事件行(发起/重新提交、撤回),不是审批动作 + 6: "提交", + 7: "撤回", +}; + +/** 任务结果 Tag 主题色(同 TASK_STATUS 码表,缺省灰) */ +export const TASK_STATUS_COLOR: Record = { + 0: "blue", + 1: "green", + 2: "red", + 3: "default", + 4: "orange", + 5: "cyan", + 6: "green", + 7: "orange", }; class FlowApi { @@ -82,10 +105,14 @@ class FlowApi { } // ==================== 用户面:流程示意图 ==================== - /** 流程示意图(flowGuid=最高已发布版;instanceId=办件 def 快照版;mode=dept|user|缺省 auto) */ + /** 流程示意图(flowGuid=最高已发布版;instanceId=办件 def 快照版;mode=dept|user,缺省 user) */ diagram(params: { flowGuid?: string; instanceId?: number | string; mode?: string }) { return get(`${ROOT}/diagram`, params); } + /** 流程源码(图示页「源码」抽屉;可见性同 /diagram) */ + defSource(params: { flowGuid?: string; instanceId?: number | string }) { + return get(`${ROOT}/def/source`, params); + } // ==================== 管理面:流程定义 ==================== defList() { @@ -103,6 +130,10 @@ class FlowApi { defOffline(id: number | string) { return post(`${ROOT}/admin/def/offline/${id}`); } + /** 启用(与 defOffline 对称:仅停用版 → 已发布,重跑发布期静态校验) */ + defEnable(id: number | string) { + return post(`${ROOT}/admin/def/enable/${id}`); + } defVersions(flowGuid: string) { return get(`${ROOT}/admin/def/versions`, { flowGuid }); } @@ -121,6 +152,10 @@ class FlowApi { mapDisable(id: number | string) { return post(`${ROOT}/admin/map/disable/${id}`); } + /** 启用映射(停用行的恢复入口;DEPT_KEY 解析只取启用行) */ + mapEnable(id: number | string) { + return post(`${ROOT}/admin/map/enable/${id}`); + } mapLookupUser(userId: number) { return get(`${ROOT}/admin/map/lookupUser`, { userId }); } diff --git a/src/components/TablePageAlovaNew.vue b/src/components/TablePageAlovaNew.vue index 410ebdc..7cc5a34 100644 --- a/src/components/TablePageAlovaNew.vue +++ b/src/components/TablePageAlovaNew.vue @@ -21,7 +21,7 @@ - diff --git a/src/views/flow/MyInstances.vue b/src/views/flow/MyInstances.vue index 780275a..2ac6630 100644 --- a/src/views/flow/MyInstances.vue +++ b/src/views/flow/MyInstances.vue @@ -1,58 +1,61 @@ - - diff --git a/src/views/flow/TaskHandle.vue b/src/views/flow/TaskHandle.vue index 9d622c9..af83113 100644 --- a/src/views/flow/TaskHandle.vue +++ b/src/views/flow/TaskHandle.vue @@ -6,11 +6,20 @@ import { ref, computed, onMounted } from "vue"; import { useRoute, useRouter } from "vue-router"; import { Message, Modal } from "view-ui-plus"; import { flowApi, INSTANCE_STATUS } from "@/api/module/flow"; +import TraceTimeline from "@/views/flow/components/TraceTimeline.vue"; const route = useRoute(); const router = useRouter(); const taskId = computed(() => String(route.params.taskId || "")); +/** 轨迹显示口径:dept=按科室 / user=按人员(不传=人员),与办件详情页、图示页同参数 */ +const mode = ref(route.query.mode === "dept" ? "dept" : "user"); + +function switchMode(val: string) { + mode.value = val; + router.replace({ query: { ...route.query, mode: val } }); +} + const loading = ref(false); const detail = ref(null); const myTaskId = ref(""); @@ -230,21 +239,16 @@ onMounted(load); - - - - {{ t.nodeName || t.nodeId }} - {{ t.assigneeName || t.assigneeId }} - 已退回 - 已移交 - 「{{ t.opinion }}」 - 去向:{{ t.edgeName }} -
{{ (t.handleTime || t.arriveTime || "").replace("T", " ") }}
-
-
+ + + @click="router.push(`/diagram?instanceId=${detail.instanceId}&mode=${mode}`)">查看流程图
@@ -300,4 +304,5 @@ onMounted(load); .node { font-weight: 600; margin-right: 6px; } .opinion { color: #666; } .time { color: #aaa; font-size: 12px; } +.trace-mode { float: right; font-weight: normal; margin-right: 16px; } diff --git a/src/views/flow/Workbench.vue b/src/views/flow/Workbench.vue index c63fb24..1d7cfd4 100644 --- a/src/views/flow/Workbench.vue +++ b/src/views/flow/Workbench.vue @@ -1,88 +1,84 @@ - - diff --git a/src/views/flow/admin/Callbacks.vue b/src/views/flow/admin/Callbacks.vue index 5a69caa..90e8e8e 100644 --- a/src/views/flow/admin/Callbacks.vue +++ b/src/views/flow/admin/Callbacks.vue @@ -1,7 +1,11 @@ diff --git a/src/views/flow/admin/Defs.vue b/src/views/flow/admin/Defs.vue index 7338586..cee1e0e 100644 --- a/src/views/flow/admin/Defs.vue +++ b/src/views/flow/admin/Defs.vue @@ -1,10 +1,12 @@ - - diff --git a/src/views/flow/admin/Instances.vue b/src/views/flow/admin/Instances.vue index d0fd063..7dffa8a 100644 --- a/src/views/flow/admin/Instances.vue +++ b/src/views/flow/admin/Instances.vue @@ -1,68 +1,55 @@ - - diff --git a/src/views/flow/admin/Maps.vue b/src/views/flow/admin/Maps.vue index 61f446d..b160a28 100644 --- a/src/views/flow/admin/Maps.vue +++ b/src/views/flow/admin/Maps.vue @@ -2,6 +2,7 @@ // 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人;办理人改为弹窗选人(参照 Notice 选人范式) import { ref, onMounted } from "vue"; import { Message } from "view-ui-plus"; +import MyConfirm from "@/components/MyConfirm"; import UserSelectInput from "@/components/UserSelectInput.vue"; import { flowApi } from "@/api/module/flow"; import Utils from "@/utils"; @@ -9,10 +10,8 @@ import Utils from "@/utils"; const loading = ref(false); const deptKeyFilter = ref(""); const rows = ref([]); - -const saveVisible = ref(false); -const saving = ref(false); -const form = ref({ deptKey: "", deptName: "", userId: null, userName: "" }); +// 操作按钮区 + 搜索区、无分页 +const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isOperat: true })); // 一键拉取教师组(预览确认后批量导入) const groupVisible = ref(false); @@ -22,7 +21,7 @@ const groupRows = ref([]); const groupColumns = [ { title: "科室编码", key: "deptKey", width: 100 }, { title: "科室名称", key: "deptName", width: 160 }, - { title: "成员数", key: "memberCount", width: 90, render: (h: any, p: any) => h("span", String(p.row.users?.length || 0)) }, + { title: "成员数", key: "memberCount", width: 90, align: "center", render: (h: any, p: any) => h("span", String(p.row.users?.length || 0)) }, { title: "成员", key: "members", render: (h: any, p: any) => h("span", (p.row.users || []).map((u: any) => `${u.userName}(${u.userId})`).join("、") || "(空,导入后可手工补)") }, ]; @@ -54,26 +53,41 @@ async function onGroupImport() { } const columns = [ + { title: "序号", type: "index", width: 70, align: "center" }, { title: "科室编码", key: "deptKey", width: 140 }, - { title: "科室名称", key: "deptName", width: 160, render: (h: any, p: any) => h("span", p.row.deptName || "-") }, + { title: "科室名称", width: 160, render: (h: any, p: any) => h("span", p.row.deptName || "-") }, { title: "办理人", key: "userName", width: 120 }, { title: "用户ID", key: "userId", width: 90 }, - { title: "状态", width: 90, render: (h: any, p: any) => h("Tag", { color: p.row.status === 0 ? "green" : "gray" }, () => (p.row.status === 0 ? "启用" : "停用")) }, - { title: "操作", width: 100, render: (h: any, p: any) => + { title: "状态", width: 90, align: "center", render: (h: any, p: any) => h("Tag", { color: p.row.status === 0 ? "green" : "gray" }, () => (p.row.status === 0 ? "启用" : "停用")) }, + { title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) => p.row.status === 0 - ? Utils.operatingTextHandle(h, "停用", "text-error", () => onDisable(p.row)) - : h("span", "-") }, + ? Utils.operatingHandle(h, "停用", "icon-stop", () => onDisable(p.row)) + : Utils.operatingHandle(h, "启用", "icon-play", () => onEnable(p.row)) }, ]; async function load() { loading.value = true; try { - rows.value = (await flowApi.mapList(deptKeyFilter.value || undefined).send()) || []; + rows.value = (await flowApi.mapList(deptKeyFilter.value.trim() || undefined).send()) || []; } finally { loading.value = false; } } +function onSearch() { + load(); +} + +// ---- 新增/更新映射弹窗(规范:必填红星标注,校验失败提示统一文案) ---- +const saveVisible = ref(false); +const saving = ref(false); +const formRef = ref(null); +const form = ref({ deptKey: "", deptName: "", userId: null, userName: "" }); +const saveRules = { + deptKey: [{ required: true, message: "请填写红色星号内容!", trigger: "blur" }], + userId: [{ required: true, message: "请填写红色星号内容!", trigger: "change" }], +}; + function onAdd() { form.value = { deptKey: "", deptName: "", userId: null, userName: "" }; saveVisible.value = true; @@ -84,84 +98,108 @@ function onUserPick(u: any) { form.value.userName = u?.name || ""; } -async function onSave() { - if (!form.value.deptKey.trim() || !form.value.userId) { - Message.warning("科室编码与办理人必填"); - return; - } - saving.value = true; - try { - await flowApi.mapSave({ - deptKey: form.value.deptKey.trim(), - deptName: form.value.deptName, - userId: Number(form.value.userId), - userName: form.value.userName, - }).send(); - Message.success("已保存"); - saveVisible.value = false; - load(); - } finally { - saving.value = false; - } +function onSave() { + formRef.value?.validate(async (valid: boolean) => { + if (!valid) return; + saving.value = true; + try { + await flowApi.mapSave({ + deptKey: form.value.deptKey.trim(), + deptName: form.value.deptName, + userId: Number(form.value.userId), + userName: form.value.userName, + }).send(); + Message.success("已保存"); + saveVisible.value = false; + load(); + } finally { + saving.value = false; + } + }); } -async function onDisable(row: any) { - await flowApi.mapDisable(row.id).send(); - Message.success("已停用"); - load(); +function onDisable(row: any) { + MyConfirm({ + title: "停用", + msg: `确定停用科室「${row.deptName || row.deptKey}」的办理人映射吗?`, + info: "停用后该科室流程节点需重新配置办理人", + async onOk() { + await flowApi.mapDisable(row.id).send(); + Message.success("已停用"); + load(); + }, + }); +} + +/** 启用(2026-09-18 补齐:原来停用后只能重新"新增映射"恢复) */ +function onEnable(row: any) { + MyConfirm({ + title: "启用", + msg: `确定启用科室「${row.deptName || row.deptKey}」的办理人「${row.userName || row.userId}」吗?`, + info: "启用后该科室的流程节点会重新解析到这名办理人", + async onOk() { + await flowApi.mapEnable(row.id).send(); + Message.success("已启用"); + load(); + }, + }); } onMounted(load); - - diff --git a/src/views/flow/admin/def-editor/bpmn/serialize.ts b/src/views/flow/admin/def-editor/bpmn/serialize.ts index 8cc5176..2980fc1 100644 --- a/src/views/flow/admin/def-editor/bpmn/serialize.ts +++ b/src/views/flow/admin/def-editor/bpmn/serialize.ts @@ -162,3 +162,102 @@ export function toCanvasJson(contentJson: string | CanvasJson): CanvasJson { } return contentJson; } + +/** 查看器 BPMN DI 形状尺寸(事件 36、网关 50、任务两行文本 150x76) */ +const DI_SIZE: Record = { + START: { w: 36, h: 36 }, + END: { w: 36, h: 36 }, + APPROVAL: { w: 150, h: 76 }, + PARALLEL_SPLIT: { w: 50, h: 50 }, + PARALLEL_JOIN: { w: 50, h: 50 }, +}; + +/** + * contentJson(+layout) → 可导入只读 Viewer 的 BPMN XML(含 DI 布局)。 + * 画布视图专用:bpmn-js Viewer 没有 modeling 模块,不能走 toBpmn 的程序化布点, + * 直接生成带 BPMNShape/BPMNEdge 的 XML 一次导入;连线给正交 waypoints(右中→中线→左中)。 + */ +export function toBpmnXml(def: CanvasJson): string { + const nodes: any[] = def.nodes || []; + const edges: any[] = def.edges || []; + // 元素中心点:layout 覆盖全部节点用 layout(编辑器保存的是中心坐标),否则回退自动布局 + const saved = def.layout || {}; + const useSaved = nodes.length > 0 && nodes.every((n) => saved[String(n.id)]); + const centers: Record = {}; + if (useSaved) { + nodes.forEach((n) => { centers[String(n.id)] = saved[String(n.id)]; }); + } else { + const auto = autoLayout({ nodes, edges }); + nodes.forEach((n) => { + const p = auto[String(n.id)]; + if (p) centers[String(n.id)] = { x: p.x + p.w / 2, y: p.y + p.h / 2 }; + }); + } + const esc = (s: unknown): string => + String(s ?? "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + const body = nodes.filter((n) => BPMN_TYPE[String(n.type)] && centers[String(n.id)]); + const flows = edges.filter((e) => centers[String(e.from)] && centers[String(e.to)]); + + const elems = body + .map((n) => { + const tag = BPMN_TYPE[String(n.type)].replace("bpmn:", ""); + const name = n.name ? ` name="${esc(n.name)}"` : ""; + return ` `; + }) + .join("\n"); + const flowElems = flows + .map((e, i) => { + const name = e.name ? ` name="${esc(e.name)}"` : ""; + return ` `; + }) + .join("\n"); + + const bounds = (id: string): { x: number; y: number; w: number; h: number } => { + const c = centers[id]; + const type = String(nodes.find((n) => String(n.id) === id)?.type || "APPROVAL"); + const s = DI_SIZE[type] || DI_SIZE.APPROVAL; + return { x: Math.round(c.x - s.w / 2), y: Math.round(c.y - s.h / 2), w: s.w, h: s.h }; + }; + const shapes = body + .map((n) => { + const b = bounds(String(n.id)); + return ` + + `; + }) + .join("\n"); + const edgeDi = flows + .map((e, i) => { + const fb = bounds(String(e.from)); + const tb = bounds(String(e.to)); + const x1 = fb.x + fb.w; + const y1 = fb.y + fb.h / 2; + const x2 = tb.x; + const y2 = tb.y + tb.h / 2; + const mid = Math.round((x1 + x2) / 2); + const pts = Math.abs(y1 - y2) < 2 + ? [[x1, y1], [x2, y2]] + : [[x1, y1], [mid, y1], [mid, y2], [x2, y2]]; + const wp = pts.map(([x, y]) => ` `).join("\n"); + return ` \n${wp}\n `; + }) + .join("\n"); + + return ` + + +${elems} +${flowElems} + + + +${shapes} +${edgeDi} + + +`; +} diff --git a/src/views/flow/components/SourceDrawer.vue b/src/views/flow/components/SourceDrawer.vue new file mode 100644 index 0000000..2613298 --- /dev/null +++ b/src/views/flow/components/SourceDrawer.vue @@ -0,0 +1,80 @@ + + + + + diff --git a/src/views/flow/components/TraceTimeline.vue b/src/views/flow/components/TraceTimeline.vue new file mode 100644 index 0000000..f4a9ba5 --- /dev/null +++ b/src/views/flow/components/TraceTimeline.vue @@ -0,0 +1,127 @@ + + + + +