feat(ui): 应用页规范全量重构 + 工作台与流程轨迹优化
build / build (push) Successful in 3m17s

应用页规范(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 色映射统一导出
This commit is contained in:
chenxing
2026-09-18 16:52:40 +08:00
parent 0083246039
commit cdf04f0930
16 changed files with 1172 additions and 481 deletions
+36 -1
View File
@@ -25,6 +25,14 @@ export const INSTANCE_STATUS: Record<number, string> = {
40: "已撤回", 40: "已撤回",
}; };
/** 状态列 Tag 主题色(与 INSTANCE_STATUS 同码表,缺省灰) */
export const INSTANCE_STATUS_COLOR: Record<number, string> = {
10: "blue",
20: "green",
30: "red",
40: "gray",
};
export const TASK_STATUS: Record<number, string> = { export const TASK_STATUS: Record<number, string> = {
0: "待办", 0: "待办",
1: "同意", 1: "同意",
@@ -32,6 +40,21 @@ export const TASK_STATUS: Record<number, string> = {
3: "失效", 3: "失效",
4: "退回", 4: "退回",
5: "移交", 5: "移交",
// 6/7 为轨迹留痕事件行(发起/重新提交、撤回),不是审批动作
6: "提交",
7: "撤回",
};
/** 任务结果 Tag 主题色(同 TASK_STATUS 码表,缺省灰) */
export const TASK_STATUS_COLOR: Record<number, string> = {
0: "blue",
1: "green",
2: "red",
3: "default",
4: "orange",
5: "cyan",
6: "green",
7: "orange",
}; };
class FlowApi { 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 }) { diagram(params: { flowGuid?: string; instanceId?: number | string; mode?: string }) {
return get(`${ROOT}/diagram`, params); return get(`${ROOT}/diagram`, params);
} }
/** 流程源码(图示页「源码」抽屉;可见性同 /diagram) */
defSource(params: { flowGuid?: string; instanceId?: number | string }) {
return get(`${ROOT}/def/source`, params);
}
// ==================== 管理面:流程定义 ==================== // ==================== 管理面:流程定义 ====================
defList() { defList() {
@@ -103,6 +130,10 @@ class FlowApi {
defOffline(id: number | string) { defOffline(id: number | string) {
return post(`${ROOT}/admin/def/offline/${id}`); return post(`${ROOT}/admin/def/offline/${id}`);
} }
/** 启用(与 defOffline 对称:仅停用版 → 已发布,重跑发布期静态校验) */
defEnable(id: number | string) {
return post(`${ROOT}/admin/def/enable/${id}`);
}
defVersions(flowGuid: string) { defVersions(flowGuid: string) {
return get(`${ROOT}/admin/def/versions`, { flowGuid }); return get(`${ROOT}/admin/def/versions`, { flowGuid });
} }
@@ -121,6 +152,10 @@ class FlowApi {
mapDisable(id: number | string) { mapDisable(id: number | string) {
return post(`${ROOT}/admin/map/disable/${id}`); return post(`${ROOT}/admin/map/disable/${id}`);
} }
/** 启用映射(停用行的恢复入口;DEPT_KEY 解析只取启用行) */
mapEnable(id: number | string) {
return post(`${ROOT}/admin/map/enable/${id}`);
}
mapLookupUser(userId: number) { mapLookupUser(userId: number) {
return get(`${ROOT}/admin/map/lookupUser`, { userId }); return get(`${ROOT}/admin/map/lookupUser`, { userId });
} }
+15 -3
View File
@@ -21,7 +21,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, useSlots } from 'vue' import { computed, ref, useSlots, watch } from 'vue'
import Utils from '@/utils' import Utils from '@/utils'
import { OperatorEnum } from '@/types' import { OperatorEnum } from '@/types'
@@ -68,18 +68,30 @@ const {
total: res => props.formatResponse(res).total, total: res => props.formatResponse(res).total,
data: res => props.formatResponse(res).list, data: res => props.formatResponse(res).list,
initialPage: 1, initialPage: 1,
initialPageSize: 10, // 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
immediate: props.autoLoad, immediate: props.autoLoad,
} }
) )
// 数据重载后清空勾选,避免批量操作用到旧选中行
watch(resp, () => {
if (selectList.value.length) {
selectList.value = []
emit('selection-change', selectList.value)
}
})
const list = computed(() => { const list = computed(() => {
if (!resp.value) return [] if (!resp.value) return []
return props.dataFilter ? props.dataFilter(resp.value) : resp.value return props.dataFilter ? props.dataFilter(resp.value) : resp.value
}) })
const onPageChange = (p: number) => (page.value = p) const onPageChange = (p: number) => (page.value = p)
const onSizeChange = (s: number) => (pageSize.value = s) const onSizeChange = (s: number) => {
pageSize.value = s
localStorage.setItem('customPageCount', String(s))
}
function refreshData() { function refreshData() {
page.value = 1 page.value = 1
+7
View File
@@ -0,0 +1,7 @@
/** 列表增删改查后的智能翻页语义(TablePageAlovaNew.reloadData 参数,与 UserBase 同源) */
export enum OperatorEnum {
add,
del,
edit,
sel,
}
+96
View File
@@ -29,6 +29,13 @@ export interface Pos {
/** 拓扑分层布局:入参 {nodes,edges},返回 nodeId → 位置 */ /** 拓扑分层布局:入参 {nodes,edges},返回 nodeId → 位置 */
export function layout(data: any): Record<string, Pos> { export function layout(data: any): Record<string, Pos> {
const pos = layered(data);
centerParallelGroups(data, pos);
return pos;
}
/** Kahn 分层 + 同列按定义顺序纵排(基础网格,保证无重叠) */
function layered(data: any): Record<string, Pos> {
const nodes: any[] = data?.nodes || []; const nodes: any[] = data?.nodes || [];
const edges: any[] = data?.edges || []; const edges: any[] = data?.edges || [];
if (!nodes.length) return {}; if (!nodes.length) return {};
@@ -66,3 +73,92 @@ export function layout(data: any): Record<string, Pos> {
}); });
return pos; return pos;
} }
/**
* 并行分支组垂直居中:分支不再从 SPLIT 行向下堆叠,而是以 SPLIT 中心线为轴上下对称展开
* (与钉钉/参考稿一致)。SPLIT↔JOIN 配对沿用编辑器口径(各出边可达集求交,取 BFS 序最早的
* PARALLEL_JOIN),组员 = SPLIT 到 JOIN 之间可达的全部节点;JOIN 中线吸附回 SPLIT 中线。
* 内层组先于外层组处理(嵌套并行整体平移不破坏内层居中);结束统一把最顶行抬回 PAD。
*/
function centerParallelGroups(data: any, pos: Record<string, Pos>): void {
const nodes: any[] = data?.nodes || [];
const edges: any[] = data?.edges || [];
const out: Record<string, string[]> = {};
edges.forEach((e) => {
if (pos[e.from] && pos[e.to]) (out[e.from] = out[e.from] || []).push(e.to);
});
const byId: Record<string, any> = {};
nodes.forEach((n) => { byId[n.id] = n; });
const groups = nodes
.filter((n) => n.type === "PARALLEL_SPLIT" && pos[n.id])
.map((split) => {
// BFS 序 + 各分支可达集,用于定位配对 JOIN
const order = new Map<string, number>([[split.id, 0]]);
const queue: string[] = [split.id];
while (queue.length) {
const cur = queue.shift()!;
(out[cur] || []).forEach((to) => {
if (!order.has(to)) {
order.set(to, order.size);
queue.push(to);
}
});
}
const reachSets = (out[split.id] || []).map((head) => {
const set = new Set<string>();
const q: string[] = [head];
while (q.length) {
const cur = q.shift()!;
if (set.has(cur)) continue;
set.add(cur);
(out[cur] || []).forEach((to) => q.push(to));
}
return set;
});
let common = reachSets[0] || new Set<string>();
reachSets.forEach((set) => { common = new Set([...common].filter((x) => set.has(x))); });
const join = nodes
.filter((n) => n.type === "PARALLEL_JOIN" && pos[n.id] && common.has(n.id))
.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0))[0];
if (!join) return null;
// 组员:SPLIT 起步、不穿过 JOIN 的可达集(含嵌套 SPLIT/JOIN)
const members: string[] = [];
const seen = new Set<string>([join.id]);
const q: string[] = [split.id];
while (q.length) {
const cur = q.shift()!;
(out[cur] || []).forEach((to) => {
if (seen.has(to)) return;
seen.add(to);
members.push(to);
q.push(to);
});
}
return { split: split.id, join: join.id, members };
})
.filter(Boolean)
.sort((a: any, b: any) => Math.max(...b.members.map((m: string) => pos[m].depth))
- Math.max(...a.members.map((m: string) => pos[m].depth)));
groups.forEach((g: any) => {
const axisY = pos[g.split].y + pos[g.split].h / 2;
let top = Infinity;
let bottom = -Infinity;
g.members.forEach((id: string) => {
top = Math.min(top, pos[id].y);
bottom = Math.max(bottom, pos[id].y + pos[id].h);
});
const delta = axisY - (top + bottom) / 2;
g.members.forEach((id: string) => { pos[id].y += delta; });
pos[g.join].y += axisY - (pos[g.join].y + pos[g.join].h / 2);
});
// 上移出界的组把整图平移回 PAD(同 delta 平移不破坏各组居中)
let minY = Infinity;
Object.values(pos).forEach((p) => { minY = Math.min(minY, p.y); });
if (minY < PAD) {
const lift = PAD - minY;
Object.values(pos).forEach((p) => { p.y += lift; });
}
}
+192 -70
View File
@@ -1,21 +1,38 @@
<script setup lang="ts"> <script setup lang="ts">
// 流程示意图(方案 §4):深链页不挂菜单;query: flowGuid|instanceId|defId(三选一)+ mode(dept|user|缺省 auto)。 // 流程示意图(方案 §4):深链页不挂菜单;query: flowGuid|instanceId|defId(三选一)+ mode(dept|user,缺省 user)。
// 手写 SVG 分层布局(depth(to)=max(depth(from))+1,从左到右布列、同列按定义顺序纵排),零图形库依赖。 // 三视图(2026-09-17):auto=示意图(手写 SVG 自动布局,零图形库)|canvas=画布视图(bpmn-js 只读渲染,
import { ref, computed, onMounted, watch } from "vue"; // 复用设计器的 contentJson→BPMN 通道,与示意图视觉区分)|img=进度图(服务端 PNG 流)。
import { useRoute } from "vue-router"; // 显示口径(2026-09-18):只两档 dept=科室/user=人员,不传=人员;与办件详情/办理页的轨迹口径同参数。
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { flowApi } from "@/api/module/flow"; import { flowApi } from "@/api/module/flow";
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
import config from "@/assets/config/host.config"; import config from "@/assets/config/host.config";
import NavigatedViewer from "bpmn-js/lib/NavigatedViewer";
import { toBpmnXml, CanvasJson } from "@/views/flow/admin/def-editor/bpmn/serialize";
// bpmn-js 渲染样式(设计器 custom.ts 已全局引过,此处再引由打包器去重;漏引连线/图标异常)
import "diagram-js/assets/diagram-js.css";
import "bpmn-js/dist/assets/bpmn-js.css";
import "bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css";
const route = useRoute(); const route = useRoute();
const router = useRouter();
const loading = ref(false); const loading = ref(false);
const vo = ref<any>(null); const vo = ref<any>(null);
const mode = ref<string>((route.query.mode as string) || "auto"); const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
const svgRef = ref<SVGSVGElement | null>(null); const svgRef = ref<SVGSVGElement | null>(null);
/** 出图形态(2026-09-17 三选一):auto=示意图(自动布局)|canvas=画布视图(设计器保存坐标)|img=进度图(服务端 PNG 流) */ // 源码抽屉(图示页只读查看 + 复制)
const sourceVisible = ref(false);
const sourceText = ref("");
const sourceTitle = ref("");
const sourceLoading = ref(false);
/** 出图形态(2026-09-17 三选一):auto=示意图(自动布局)|canvas=画布视图(bpmn-js 只读)|img=进度图(服务端 PNG 流) */
const chartStyle = ref<string>((route.query.chart as string) || "auto"); const chartStyle = ref<string>((route.query.chart as string) || "auto");
const showImg = computed(() => chartStyle.value === "img"); const showImg = computed(() => chartStyle.value === "img");
const showCanvas = computed(() => chartStyle.value === "canvas");
/** 进度图 img 直嵌源(同 InstanceDetail 的"CAS 验证码式",img 不走 alova 显式带 serviceHost;管理面 defId 走 admin 端点) */ /** 进度图 img 直嵌源(同 InstanceDetail 的"CAS 验证码式",img 不走 alova 显式带 serviceHost;管理面 defId 走 admin 端点) */
const pngSrc = computed(() => { const pngSrc = computed(() => {
@@ -29,67 +46,20 @@ const pngSrc = computed(() => {
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" }; const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用) // 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用;并行分支组垂直居中)
import { layout as autoLayout, NODE_BOX, SLOT_W, SLOT_H, COL_GAP, ROW_GAP, PAD, Pos } from "@/utils/flowLayout"; import { layout as autoLayout, PAD, Pos } from "@/utils/flowLayout";
/** 位置计算:画布视图用设计器保存的画布坐标(vo.layout,bpmn 画布 x/y),示意图忽略坐标强制自动布局;
* 画布视图缺坐标(旧定义未在设计器编辑过)时回退自动布局 */
const positions = computed<Record<string, Pos>>(() => { const positions = computed<Record<string, Pos>>(() => {
const nodes: any[] = vo.value?.nodes || []; return autoLayout({ nodes: vo.value?.nodes || [], edges: vo.value?.edges || [] });
const edges: any[] = vo.value?.edges || [];
const saved = chartStyle.value === "canvas" ? vo.value?.layout as Record<string, { x: number; y: number }> | undefined : undefined;
if (saved && Object.keys(saved).length) {
const pos: Record<string, Pos> = {};
let complete = true;
nodes.forEach((n, i) => {
const p = saved[n.id];
const box = NODE_BOX[n.type] || NODE_BOX.APPROVAL;
if (!p) {
complete = false;
return;
}
// depth 供 vbSize 估算列号(x 换算);row 仅占位
pos[n.id] = { x: p.x, y: p.y, w: box.w, h: box.h, depth: 0, row: i };
});
if (complete) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
Object.values(pos).forEach((p) => {
minX = Math.min(minX, p.x); minY = Math.min(minY, p.y);
maxX = Math.max(maxX, p.x + p.w); maxY = Math.max(maxY, p.y + p.h);
});
// 平移到与自动布局一致的留白(防设计器负坐标/超边界出血)
const dx = PAD - minX;
const dy = PAD - minY;
Object.values(pos).forEach((p) => { p.x += dx; p.y += dy; });
vbExtent.w = maxX - minX + PAD + COL_GAP + SLOT_W;
vbExtent.h = maxY - minY + PAD + ROW_GAP + SLOT_H;
return pos;
}
}
vbExtent.w = 0;
vbExtent.h = 0;
return autoLayout({ nodes, edges });
}); });
/** 保存坐标模式的画布总尺寸(0=未启用,回退自动布局的行列估算) */
const vbExtent = ref({ w: 0, h: 0 });
const vbSize = computed(() => { const vbSize = computed(() => {
if (vbExtent.value.w > 0) {
return { w: vbExtent.value.w, h: vbExtent.value.h };
}
const ps = Object.values(positions.value); const ps = Object.values(positions.value);
if (!ps.length) return { w: 480, h: 160 }; if (!ps.length) return { w: 480, h: 160 };
let maxD = 0; let maxX = 0;
let maxR = 0; let maxY = 0;
ps.forEach((p) => { maxD = Math.max(maxD, p.depth); maxR = Math.max(maxR, p.row); }); ps.forEach((p) => { maxX = Math.max(maxX, p.x + p.w); maxY = Math.max(maxY, p.y + p.h); });
return { return { w: maxX + PAD, h: maxY + PAD };
w: PAD * 2 + (maxD + 1) * SLOT_W + maxD * COL_GAP,
h: PAD * 2 + (maxR + 1) * SLOT_H + maxR * ROW_GAP,
};
}); });
function pos(n: any): Pos { function pos(n: any): Pos {
@@ -140,18 +110,52 @@ function trunc(s: string | null, n: number): string {
return s.length > n ? s.slice(0, n) + "…" : s; return s.length > n ? s.slice(0, n) + "…" : s;
} }
/** 出图形态切换;进出进度图无需重拉(数据 vo 共用),只换渲染形态 */ /** 出图形态切换;进出画布/进度图无需重拉(数据 vo 共用),只换渲染形态 */
function switchChartStyle(val: string) { function switchChartStyle(val: string) {
chartStyle.value = val; chartStyle.value = val;
} }
const viewMode = computed(() => (mode.value === "user" ? "user" : "dept")); const viewMode = computed(() => (mode.value === "user" ? "user" : "dept"));
/** 口径切换:写回 query(可分享/刷新保持),并按新口径重拉出图数据 */
function switchMode(val: string) { function switchMode(val: string) {
mode.value = val; mode.value = val;
router.replace({ query: { ...route.query, mode: val } });
load(); load();
} }
/**
* 源码:defId 走管理面 def/get(任意版本可看,草稿也行);flowGuid/instanceId 走用户面
* /def/source(可见性同 /diagram:instanceId 需办件可见、flowGuid 只给最高已发布版)。
*/
async function openSource() {
const { flowGuid, instanceId, defId } = route.query as Record<string, string>;
sourceVisible.value = true;
sourceLoading.value = true;
try {
const data: any = defId
? await flowApi.defGet(defId).send()
: await flowApi.defSource(flowGuid ? { flowGuid } : { instanceId }).send();
sourceText.value = prettyJson(data?.contentJson);
sourceTitle.value = `${data?.name || ""}(${data?.code || ""} v${data?.version ?? ""})`;
} catch (e: any) {
sourceVisible.value = false;
Message.error(e?.message || "源码获取失败");
} finally {
sourceLoading.value = false;
}
}
/** contentJson 字符串 → 缩进 JSON(解析失败原样展示,不让展示把页面弄崩) */
function prettyJson(raw: any): string {
if (!raw) return "";
try {
return JSON.stringify(typeof raw === "string" ? JSON.parse(raw) : raw, null, 2);
} catch {
return String(raw);
}
}
async function load() { async function load() {
const { flowGuid, instanceId, defId } = route.query as Record<string, string>; const { flowGuid, instanceId, defId } = route.query as Record<string, string>;
if (!flowGuid && !instanceId && !defId) { if (!flowGuid && !instanceId && !defId) {
@@ -174,20 +178,111 @@ async function load() {
} }
} }
/** 导出 PNG(方案 §4.4):serialize SVG→Image→canvas 2x→toBlob 下载;失败提示右键另存 */ // ==================== 画布视图(bpmn-js 只读渲染,2026-09-17 与示意图区分) ====================
function exportPng() {
const svg = svgRef.value; const bpmnHost = ref<HTMLDivElement | null>(null);
if (!svg) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any
let bpmnViewer: any = null;
/** DiagramVo → 画布视图数据(layout 为后端透传的设计器中心坐标;缺省回退共享自动布局) */
const bpmnDef = computed<CanvasJson | null>(() => {
const nodes: any[] = vo.value?.nodes || [];
if (!nodes.length) return null;
return {
code: "",
name: vo.value?.name || "",
start: "start",
nodes: nodes.map((n) => ({
id: String(n.id),
type: n.type,
name: n.subLabel ? `${n.label}\n${n.subLabel}` : n.label,
})),
edges: ((vo.value?.edges || []) as any[]).map((e) => ({
from: String(e.from), to: String(e.to), name: e.name,
})),
settings: {},
layout: vo.value?.layout as CanvasJson["layout"],
};
});
async function initBpmn() {
const host = bpmnHost.value;
const def = bpmnDef.value;
if (!host || !def) return;
bpmnViewer?.destroy();
bpmnViewer = new NavigatedViewer({ container: host });
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (bpmnViewer as any).importXML(toBpmnXml(def));
} catch (e: any) {
Message.error(`画布视图载入失败:${e?.message || e}`);
return;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const canvas = bpmnViewer.get("canvas") as any;
canvas.zoom("fit-viewport", "auto");
// 运行时状态着色(instance 口径):marker 类 + <style> 里 .fc-* 规则
nodesWithState().forEach(([id, state]) => canvas.addMarker(id, `fc-${state}`));
}
function nodesWithState(): Array<[string, string]> {
return ((vo.value?.nodes || []) as any[])
.filter((n) => n.state)
.map((n) => [String(n.id), String(n.state)]);
}
watch([showCanvas, bpmnDef], ([on]) => {
if (on) void nextTick(initBpmn);
});
onUnmounted(() => {
bpmnViewer?.destroy();
bpmnViewer = null;
});
function bpmnZoom(delta: number) {
if (!bpmnViewer) return;
const c = bpmnViewer.get("canvas");
c.zoom(c.zoom() + delta);
}
function bpmnFit() {
bpmnViewer?.get("canvas").zoom("fit-viewport", "auto");
}
// ==================== 导出 PNG(方案 §4.4) ====================
/** 当前形态的 SVG 源:示意图取 DOM,画布视图取 bpmn saveSVG(补 width/height 供 Image 取自然尺寸) */
async function currentSvg(): Promise<string | null> {
if (chartStyle.value === "canvas") {
if (!bpmnViewer) return null;
const { svg } = await bpmnViewer.saveSVG();
if (!/width="/.test(svg)) {
const m = svg.match(/viewBox="([\d.\s-]+)"/);
if (m) {
const parts = m[1].trim().split(/\s+/);
return svg.replace(/<svg /, `<svg width="${parts[2]}" height="${parts[3]}" `);
}
}
return svg;
}
if (!svgRef.value) return null;
return new XMLSerializer().serializeToString(svgRef.value);
}
/** serialize SVG→Image→canvas 2x→toBlob 下载;失败提示右键另存 */
async function exportPng() {
const xml = await currentSvg();
if (!xml) return;
const fallback = () => Message.warning("导出失败,请右键图片另存"); const fallback = () => Message.warning("导出失败,请右键图片另存");
try { try {
const xml = new XMLSerializer().serializeToString(svg);
const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" })); const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
const scale = 2; const scale = 2;
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = vbSize.value.w * scale; canvas.width = (img.naturalWidth || vbSize.value.w) * scale;
canvas.height = vbSize.value.h * scale; canvas.height = (img.naturalHeight || vbSize.value.h) * scale;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) { if (!ctx) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
@@ -243,6 +338,7 @@ watch(() => route.query, () => load());
<Radio label="user">人员视图</Radio> <Radio label="user">人员视图</Radio>
</RadioGroup> </RadioGroup>
<Button v-if="!showImg" size="small" @click="exportPng">导出 PNG</Button> <Button v-if="!showImg" size="small" @click="exportPng">导出 PNG</Button>
<Button size="small" :loading="sourceLoading" @click="openSource">源码</Button>
<span class="muted">示意图按流程定义 v{{ vo.version }} 生成</span> <span class="muted">示意图按流程定义 v{{ vo.version }} 生成</span>
</div> </div>
@@ -250,6 +346,16 @@ watch(() => route.query, () => load());
<div class="canvas img-box" v-if="vo && showImg"> <div class="canvas img-box" v-if="vo && showImg">
<img :src="pngSrc" alt="流程示意图" /> <img :src="pngSrc" alt="流程示意图" />
</div> </div>
<!-- 画布视图:bpmn-js 只读渲染(bpmn.io 水印为协议要求,勿删) -->
<div class="canvas bpmn-box" v-else-if="vo && showCanvas">
<div ref="bpmnHost" class="bpmn-host"></div>
<div class="bpmn-ops">
<Button size="small" @click="bpmnZoom(-0.1)">−</Button>
<Button size="small" @click="bpmnFit">复位</Button>
<Button size="small" @click="bpmnZoom(0.1)">+</Button>
</div>
</div>
<!-- 示意图:手写 SVG 自动布局 -->
<div class="canvas" v-else-if="vo"> <div class="canvas" v-else-if="vo">
<svg :width="vbSize.w" :height="vbSize.h" :viewBox="`0 0 ${vbSize.w} ${vbSize.h}`" <svg :width="vbSize.w" :height="vbSize.h" :viewBox="`0 0 ${vbSize.w} ${vbSize.h}`"
xmlns="http://www.w3.org/2000/svg" ref="svgRef" xmlns="http://www.w3.org/2000/svg" ref="svgRef"
@@ -271,7 +377,7 @@ watch(() => route.query, () => load());
:y="edgeLabelAnchor(e).y - 9" :y="edgeLabelAnchor(e).y - 9"
:width="e.name.length * 12 + 12" height="18" :width="e.name.length * 12 + 12" height="18"
rx="4" fill="#fff" stroke="#e8eaec" /> rx="4" fill="#fff" stroke="#e8eaec" />
<text :x="edgeLabelAnchor(e).x" :y="edgeLabelAnchor(e).y + 4" <text :x="edgeLabelAnchor(e)!.x" :y="edgeLabelAnchor(e).y + 4"
text-anchor="middle" fill="#515a6e" font-size="11">{{ e.name }}</text> text-anchor="middle" fill="#515a6e" font-size="11">{{ e.name }}</text>
</g> </g>
</template> </template>
@@ -291,7 +397,7 @@ watch(() => route.query, () => load());
<g v-else-if="n.type === 'PARALLEL_SPLIT' || n.type === 'PARALLEL_JOIN'"> <g v-else-if="n.type === 'PARALLEL_SPLIT' || n.type === 'PARALLEL_JOIN'">
<title>{{ n.subLabel || n.label }}</title> <title>{{ n.subLabel || n.label }}</title>
<polygon :points="diamondPoints(n)" <polygon :points="diamondPoints(n)"
:fill="STATE_STROKE[nodeState(n) || ''] ? '#fff8f0' : '#fff8f0'" fill="#fff8f0"
:stroke="nodeState(n) === 'active' ? '#ed4014' : '#ff9900'" :stroke="nodeState(n) === 'active' ? '#ed4014' : '#ff9900'"
stroke-width="1.5" /> stroke-width="1.5" />
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + pos(n).h / 2 + 5" <text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + pos(n).h / 2 + 5"
@@ -319,6 +425,9 @@ watch(() => route.query, () => load());
</svg> </svg>
</div> </div>
<div v-else-if="!loading" class="muted empty">未指定流程或加载失败</div> <div v-else-if="!loading" class="muted empty">未指定流程或加载失败</div>
<!-- 源码:只读查看 + 复制(复制出来可直接做新建流程的底稿) -->
<SourceDrawer v-model="sourceVisible" :text="sourceText" :subtitle="sourceTitle" />
</div> </div>
</template> </template>
@@ -337,4 +446,17 @@ watch(() => route.query, () => load());
0%, 100% { filter: none; } 0%, 100% { filter: none; }
50% { filter: drop-shadow(0 0 6px rgba(237, 64, 20, 0.8)); } 50% { filter: drop-shadow(0 0 6px rgba(237, 64, 20, 0.8)); }
} }
/* 画布视图 */
.bpmn-box { position: relative; }
.bpmn-host { height: 560px; }
.bpmn-ops { position: absolute; top: 10px; right: 12px; display: flex; gap: 6px; z-index: 5; }
/* bpmn.io 水印按协议保留 */
.bpmn-box :deep(.bjs-powered-by) { display: block; }
/* 运行时状态着色(instance 口径 marker:done/active/rejected/pending) */
.bpmn-box :deep(.fc-done > .djs-visual > :nth-child(1)) { stroke: #19be6b !important; }
.bpmn-box :deep(.fc-active > .djs-visual > :nth-child(1)) { stroke: #ed4014 !important; stroke-width: 2.5px !important; }
.bpmn-box :deep(.fc-rejected > .djs-visual > :nth-child(1)) { stroke: #ed4014 !important; fill: #fff1f0 !important; }
.bpmn-box :deep(.fc-pending > .djs-visual > :nth-child(1)) { stroke: #b7bcc4 !important; }
.bpmn-box :deep(.fc-done > .djs-visual > .djs-label) { fill: #4f5b66; }
</style> </style>
+29 -26
View File
@@ -1,8 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
// 办件详情:状态 + 业务摘要 + 流程轨迹时间线 + 分支会签进度(概览 §9.4 进度图数据源) // 办件详情:状态 + 业务摘要 + 流程轨迹时间线 + 分支会签进度(概览 §9.4 进度图数据源)
// 轨迹两档显示(2026-09-18):mode=user(人员,缺省)|dept(科室),写进 query 便于分享/刷新保持,
// 并随"流程示意图"跳转带给图示页(两边口径一致)。
import { ref, computed, onMounted } from "vue"; import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow"; import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
import config from "@/assets/config/host.config"; import config from "@/assets/config/host.config";
const route = useRoute(); const route = useRoute();
@@ -10,6 +13,14 @@ const router = useRouter();
const loading = ref(false); const loading = ref(false);
const detail = ref<any>(null); const detail = ref<any>(null);
/** 显示口径:dept=按科室 / user=按人员(不传=人员) */
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
function switchMode(val: string) {
mode.value = val;
router.replace({ query: { ...route.query, mode: val } });
}
const snapshotRows = computed(() => { const snapshotRows = computed(() => {
const raw = detail.value?.bizSnapshot; const raw = detail.value?.bizSnapshot;
if (!raw) return []; if (!raw) return [];
@@ -56,11 +67,16 @@ const branchText = computed(() => {
/** 流程示意图图片直嵌(2026-09-16 "CAS 验证码式":后端 /diagram.png 返回 PNG 流,img 直显)。 /** 流程示意图图片直嵌(2026-09-16 "CAS 验证码式":后端 /diagram.png 返回 PNG 流,img 直显)。
* img 不走 alova(无 baseUrl 拼接),须显式带 serviceHost——/{ctx}/front/ 子路径部署下 * img 不走 alova(无 baseUrl 拼接),须显式带 serviceHost——/{ctx}/front/ 子路径部署下
* 裸 /api 会落到网关根导致 404。 */ * 裸 /api 会落到网关根导致 404。mode 跟随本页选择,缩略图与轨迹口径一致。 */
const diagramSrc = computed( const diagramSrc = computed(
() => `${config.serviceHost}/api/flow/diagram.png?instanceId=${route.params.instanceId}`, () => `${config.serviceHost}/api/flow/diagram.png?instanceId=${route.params.instanceId}&mode=${mode.value}`,
); );
/** 跳流程图时带上当前口径(图示页读 mode,缺省人员) */
function toDiagram() {
router.push({ path: "/diagram", query: { instanceId: String(route.params.instanceId), mode: mode.value } });
}
function statusName(s: number) { function statusName(s: number) {
return INSTANCE_STATUS[s] || s; return INSTANCE_STATUS[s] || s;
} }
@@ -84,7 +100,7 @@ onMounted(async () => {
<div class="head"> <div class="head">
<span class="title">{{ detail.bizTitle }}</span> <span class="title">{{ detail.bizTitle }}</span>
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag> <Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
<a @click="router.push({ path: '/diagram', query: { instanceId: String(route.params.instanceId) } })">流程示意图</a> <a @click="toDiagram()">流程示意图</a>
<span class="muted" v-if="branchText">{{ branchText }}</span> <span class="muted" v-if="branchText">{{ branchText }}</span>
</div> </div>
@@ -121,36 +137,25 @@ onMounted(async () => {
<Card class="block" :bordered="false" dis-hover> <Card class="block" :bordered="false" dis-hover>
<template #title> <template #title>
流程示意图 流程示意图
<a class="diagram-more" @click="router.push({ path: '/diagram', query: { instanceId: String(route.params.instanceId) } })">查看大图</a> <a class="diagram-more" @click="toDiagram()">查看大图</a>
</template> </template>
<img class="diagram-img" :src="diagramSrc" alt="流程示意图" /> <img class="diagram-img" :src="diagramSrc" alt="流程示意图" />
</Card> </Card>
<Card class="block" :bordered="false" dis-hover> <Card class="block" :bordered="false" dis-hover>
<template #title>流程轨迹</template> <template #title>
<Timeline> 流程轨迹
<TimelineItem v-for="t in detail.trace" :key="t.taskId" <RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
:color="t.status === 1 ? 'green' : t.status === 2 ? 'red' : t.status === 0 ? 'blue' : 'gray'"> <Radio label="user">按人员</Radio>
<span class="node">{{ t.nodeName || t.nodeId }}</span> <Radio label="dept">按科室</Radio>
<Tag size="small">{{ t.assigneeName || t.assigneeId }}</Tag> </RadioGroup>
<Tag size="small" v-if="t.status !== 1">{{ taskStatusName(t.status) }}</Tag> </template>
<span class="opinion" v-if="t.opinion">「{{ t.opinion }}」</span> <TraceTimeline :trace="detail.trace || []" :mode="mode" />
<div class="time">{{ (t.handleTime || t.arriveTime || "").replace("T", " ") }}</div>
</TimelineItem>
</Timeline>
</Card> </Card>
</div> </div>
<div v-else class="page muted">加载中…</div> <div v-else class="page muted">加载中…</div>
</template> </template>
<script lang="ts">
// 任务状态名(避免引入整表;口径同 api/module/flow TASK_STATUS)
function taskStatusName(s: number) {
return ({ 0: "待办", 2: "拒绝", 3: "失效" } as Record<number, string>)[s] || "同意";
}
export default { methods: { taskStatusName } };
</script>
<style scoped> <style scoped>
.page { padding: 12px 16px; } .page { padding: 12px 16px; }
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; } .head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
@@ -164,7 +169,5 @@ export default { methods: { taskStatusName } };
.diagram-box { display: flex; justify-content: center; } .diagram-box { display: flex; justify-content: center; }
.diagram-img { max-width: 100%; } .diagram-img { max-width: 100%; }
.diagram-more { float: right; font-size: 12px; font-weight: normal; margin-right: 16px; } .diagram-more { float: right; font-size: 12px; font-weight: normal; margin-right: 16px; }
.node { font-weight: 600; margin-right: 6px; } .trace-mode { float: right; font-weight: normal; margin-right: 16px; }
.opinion { color: #666; }
.time { color: #aaa; font-size: 12px; }
</style> </style>
+46 -57
View File
@@ -1,58 +1,61 @@
<script setup lang="ts"> <script setup lang="ts">
// 我的办件(提交人视角):列表 + 撤回/催办/详情 // 我的办件(提交人视角):列表 + 撤回/催办/详情
import { ref, onMounted } from "vue"; import { ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow"; import MyConfirm from "@/components/MyConfirm";
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
import { OperatorEnum } from "@/types";
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
import Utils from "@/utils"; import Utils from "@/utils";
const router = useRouter(); const router = useRouter();
const loading = ref(false); const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const page = ref(1); const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isPage: true }));
const size = ref(10); const dataParams = ref<{ status?: number }>({ status: undefined });
const total = ref(0);
const status = ref<number | undefined>(undefined);
const rows = ref<any[]>([]);
const columns = [ const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "办件标题", key: "bizTitle", minWidth: 220 }, { title: "办件标题", key: "bizTitle", minWidth: 220 },
{ title: "业务类型", key: "bizType", width: 110 }, { title: "业务类型", key: "bizType", width: 110 },
{ title: "状态", width: 100, render: (h: any, p: any) => { { title: "状态", width: 100, align: "center", render: (h: any, p: any) =>
const color = p.row.status === 10 ? "blue" : p.row.status === 20 ? "green" : p.row.status === 30 ? "red" : "gray"; h("Tag", { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
return h("Tag", { color }, () => INSTANCE_STATUS[p.row.status] || p.row.status);
} },
{ title: "当前节点", key: "currentNodes", width: 150 }, { title: "当前节点", key: "currentNodes", width: 150 },
{ title: "提交时间", width: 170, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) }, { title: "提交时间", width: 170, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
{ title: "办结时间", width: 170, render: (h: any, p: any) => h("span", (p.row.finishTime || "").replace("T", " ") || "-") }, { title: "办结时间", width: 170, render: (h: any, p: any) => h("span", (p.row.finishTime || "").replace("T", " ") || "-") },
{ title: "操作", width: 150, render: (h: any, p: any) => { { title: "操作", width: 180, align: "center", fixed: "right", render: (h: any, p: any) => {
const btns = [Utils.operatingTextHandle(h, "详情", "", () => router.push(`/instance/${p.row.instanceId}?pid=101`))]; const btns = [Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=101`))];
if (p.row.status === 10) { if (p.row.status === 10) {
btns.push(Utils.operatingTextHandle(h, "撤回", "", () => onWithdraw(p.row))); btns.push(Utils.operatingHandle(h, "撤回", "icon-yaohao", () => onWithdraw(p.row)));
btns.push(Utils.operatingTextHandle(h, "催办", "", () => onUrge(p.row))); btns.push(Utils.operatingHandle(h, "催办", "icon-fasong", () => onUrge(p.row)));
} }
return btns; return btns;
} }, } },
]; ];
async function load() { // dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
loading.value = true; const tableApi = (p: { pageIndex: number; pageSize: number }) =>
try { flowApi.my({ status: dataParams.value.status || undefined, page: p.pageIndex, size: p.pageSize });
const data: any = await flowApi.my({ status: status.value, page: page.value, size: size.value }).send();
rows.value = data?.content || []; function onSearch() {
total.value = data?.total || 0; tableRef.value?.refreshData();
} finally {
loading.value = false;
}
} }
async function onWithdraw(row: any) { async function onWithdraw(row: any) {
try { await MyConfirm({
await flowApi.withdraw(row.instanceId).send(); title: "撤回",
Message.success("已撤回"); msg: `确定撤回办件「${row.bizTitle}」吗?`,
load(); info: "撤回后流程终止,不可恢复",
} catch (e: any) { async onOk() {
Message.error(e?.message || "撤回失败(可能已有任务被办理)"); try {
} await flowApi.withdraw(row.instanceId).send();
Message.success("已撤回");
tableRef.value?.reloadData(OperatorEnum.edit);
} catch (e: any) {
Message.error(e?.message || "撤回失败(可能已有任务被办理)");
}
},
});
} }
async function onUrge(row: any) { async function onUrge(row: any) {
@@ -63,34 +66,20 @@ async function onUrge(row: any) {
Message.error(e?.message || "催办失败"); Message.error(e?.message || "催办失败");
} }
} }
function onPage(p: number) {
page.value = p;
load();
}
onMounted(load);
</script> </script>
<template> <template>
<div class="page"> <div class="page-wrapper">
<div class="filter"> <div class="main-container">
<Select v-model="status" clearable placeholder="状态" style="width: 140px" @on-change="load"> <div class="search-wrap fix">
<Option :value="10" label="审批中" /> <Select v-model="dataParams.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onSearch">
<Option :value="20" label="已完成" /> <Option :value="10" label="审批中" />
<Option :value="30" label="已拒绝" /> <Option :value="20" label="已完成" />
<Option :value="40" label="已撤回" /> <Option :value="30" label="已拒绝" />
</Select> <Option :value="40" label="已撤回" />
</div> </Select>
<Table :columns="columns" :data="rows" :loading="loading" stripe /> </div>
<div class="pager"> <TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
<Page :total="total" :current="page" :page-size="size" show-total @on-change="onPage" />
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.page { padding: 12px 16px; }
.filter { margin-bottom: 10px; }
.pager { margin-top: 12px; text-align: right; }
</style>
+19 -14
View File
@@ -6,11 +6,20 @@ import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus"; import { Message, Modal } from "view-ui-plus";
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow"; import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const taskId = computed(() => String(route.params.taskId || "")); const taskId = computed(() => String(route.params.taskId || ""));
/** 轨迹显示口径:dept=按科室 / user=按人员(不传=人员),与办件详情页、图示页同参数 */
const mode = ref<string>(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 loading = ref(false);
const detail = ref<any>(null); const detail = ref<any>(null);
const myTaskId = ref(""); const myTaskId = ref("");
@@ -230,21 +239,16 @@ onMounted(load);
<!-- 流程轨迹 --> <!-- 流程轨迹 -->
<Card class="block" :bordered="false" dis-hover> <Card class="block" :bordered="false" dis-hover>
<template #title>流程轨迹</template> <template #title>
<Timeline> 流程轨迹
<TimelineItem v-for="t in detail.trace" :key="t.taskId" <RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
:color="t.status === 1 ? 'green' : t.status === 2 ? 'red' : t.status === 0 ? 'blue' : t.status === 4 ? 'orange' : t.status === 5 ? 'cyan' : 'gray'"> <Radio label="user">按人员</Radio>
<span class="node">{{ t.nodeName || t.nodeId }}</span> <Radio label="dept">按科室</Radio>
<Tag size="small">{{ t.assigneeName || t.assigneeId }}</Tag> </RadioGroup>
<Tag size="small" v-if="t.status === 4" color="orange">已退回</Tag> </template>
<Tag size="small" v-if="t.status === 5" color="cyan">已移交</Tag> <TraceTimeline :trace="detail.trace || []" :mode="mode" />
<span class="opinion" v-if="t.opinion">「{{ t.opinion }}」</span>
<span class="opinion" v-if="t.edgeName">去向:{{ t.edgeName }}</span>
<div class="time">{{ (t.handleTime || t.arriveTime || "").replace("T", " ") }}</div>
</TimelineItem>
</Timeline>
<Button size="small" ghost type="primary" <Button size="small" ghost type="primary"
@click="router.push(`/diagram?instanceId=${detail.instanceId}`)">查看流程图</Button> @click="router.push(`/diagram?instanceId=${detail.instanceId}&mode=${mode}`)">查看流程图</Button>
</Card> </Card>
<!-- 退回弹窗 --> <!-- 退回弹窗 -->
@@ -300,4 +304,5 @@ onMounted(load);
.node { font-weight: 600; margin-right: 6px; } .node { font-weight: 600; margin-right: 6px; }
.opinion { color: #666; } .opinion { color: #666; }
.time { color: #aaa; font-size: 12px; } .time { color: #aaa; font-size: 12px; }
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
</style> </style>
+49 -53
View File
@@ -1,88 +1,84 @@
<script setup lang="ts"> <script setup lang="ts">
// 工作台:我的待办 / 我的已办(概览 §9.4 主路径查询,走 assignee 索引) // 工作台:我的待办 / 我的已办(概览 §9.4 主路径查询,走 assignee 索引)。
import { ref, onMounted } from "vue"; // 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区,分页状态各自独立
import { ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
import { flowApi, TASK_STATUS, INSTANCE_STATUS } from "@/api/module/flow"; import { OperatorEnum } from "@/types";
import { flowApi, TASK_STATUS, TASK_STATUS_COLOR, INSTANCE_STATUS } from "@/api/module/flow";
import Utils from "@/utils"; import Utils from "@/utils";
const router = useRouter(); const router = useRouter();
const tab = ref<"todo" | "done">("todo"); const tab = ref<"todo" | "done">("todo");
const loading = ref(false); const todoTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const page = ref(1); const doneTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const size = ref(10); // tabs 页:表格高度在常规骨架上再扣 Tab 条
const total = ref(0); const tableHeight = ref(Utils.useTableHeight({ isTabs: true, isOperat: true, isPage: true }));
const rows = ref<any[]>([]);
const todoColumns = [ const todoColumns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "办件标题", key: "bizTitle", minWidth: 220 }, { title: "办件标题", key: "bizTitle", minWidth: 220 },
{ title: "当前节点", key: "nodeName", width: 160 }, { title: "当前节点", key: "nodeName", width: 160 },
{ title: "提交人", key: "submitter", width: 100, render: (h: any, p: any) => h("span", p.row.submitterName || "-") }, { title: "提交人", width: 100, render: (h: any, p: any) => h("span", p.row.submitterName || p.row.assigneeName || "-") },
{ title: "到达时间", key: "arriveTime", width: 170, render: (h: any, p: any) => h("span", (p.row.arriveTime || "").replace("T", " ")) }, { title: "到达时间", width: 170, render: (h: any, p: any) => h("span", (p.row.arriveTime || "").replace("T", " ")) },
{ title: "操作", width: 90, render: (h: any, p: any) => Utils.operatingTextHandle(h, "办理", "", () => goHandle(p.row)) }, { title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
Utils.operatingHandle(h, "办理", "icon-edit", () => goHandle(p.row)) },
]; ];
const doneColumns = [ const doneColumns = [
{ title: "办件标题", key: "bizTitle", minWidth: 220 }, { title: "序号", type: "index", width: 70, align: "center" },
{ title: "节点", key: "nodeName", width: 150 }, { title: "办件标题", key: "bizTitle", minWidth: 200 },
{ title: "结果", width: 80, render: (h: any, p: any) => h("Tag", { color: p.row.status === 1 ? "green" : "red" }, () => TASK_STATUS[p.row.status] || p.row.status) }, {
{ title: "意见", key: "opinion", minWidth: 140 }, // 同一办件被本人办理多次(多节点办理/退回重办)时列表只出一行(后端按办件去重),
// 累计次数在这里提示,避免以为记录丢了
title: "节点", minWidth: 150, render: (h: any, p: any) => {
const kids: any[] = [h("span", p.row.nodeName || p.row.nodeId || "-")];
if (p.row.doneCount > 1) {
kids.push(h("Tag", { color: "default", size: "small", style: "margin-left:6px" },
() => `共办理 ${p.row.doneCount} 次`));
}
return h("div", kids);
},
},
{ title: "结果", width: 90, align: "center", render: (h: any, p: any) => h("Tag", { color: TASK_STATUS_COLOR[p.row.status] || "default" }, () => TASK_STATUS[p.row.status] || p.row.status) },
{ title: "意见", key: "opinion", minWidth: 120 },
{ title: "办理时间", width: 170, render: (h: any, p: any) => h("span", (p.row.handleTime || "").replace("T", " ")) }, { title: "办理时间", width: 170, render: (h: any, p: any) => h("span", (p.row.handleTime || "").replace("T", " ")) },
{ title: "办件状态", width: 100, render: (h: any, p: any) => h("span", INSTANCE_STATUS[p.row.instanceStatus] || p.row.instanceStatus) }, { title: "办件状态", width: 100, render: (h: any, p: any) => h("span", INSTANCE_STATUS[p.row.instanceStatus] || p.row.instanceStatus) },
{ title: "操作", width: 90, render: (h: any, p: any) => Utils.operatingTextHandle(h, "详情", "", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) }, { title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) },
]; ];
function submitterNameFallback(row: any) { // dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
return row.submitterName || "-"; const todoApi = (p: { pageIndex: number; pageSize: number }) => flowApi.todo({ page: p.pageIndex, size: p.pageSize });
} const doneApi = (p: { pageIndex: number; pageSize: number }) => flowApi.done({ page: p.pageIndex, size: p.pageSize });
function goHandle(row: any) { function goHandle(row: any) {
router.push(`/task/${row.taskId}?pid=100`); router.push(`/task/${row.taskId}?pid=100`);
} }
async function load() { // 两个内容区 v-show 共存,已办首次切换才拉取(保持原惰性加载行为)
loading.value = true; const doneLoaded = ref(false);
try {
const api = tab.value === "todo" ? flowApi.todo : flowApi.done;
const data: any = await api({ page: page.value, size: size.value }).send();
rows.value = (data?.content || []).map((r: any) => ({ ...r, submitter: r.submitterName || r.assigneeName }));
total.value = data?.total || 0;
} finally {
loading.value = false;
}
}
function switchTab(name: string) { function switchTab(name: string) {
tab.value = name as any; tab.value = name as any;
page.value = 1; if (name === "done" && !doneLoaded.value) {
load(); doneLoaded.value = true;
doneTableRef.value?.reloadData(OperatorEnum.sel);
}
} }
function onPage(p: number) {
page.value = p;
load();
}
onMounted(load);
</script> </script>
<template> <template>
<div class="page"> <div class="tabs-wrapper">
<Tabs :value="tab" @on-click="switchTab"> <Tabs type="card" :value="tab" @on-click="switchTab">
<TabPane label="我的待办" name="todo" /> <TabPane label="我的待办" name="todo" />
<TabPane label="我的已办" name="done" /> <TabPane label="我的已办" name="done" />
</Tabs> </Tabs>
<Table :columns="tab === 'todo' ? todoColumns : doneColumns" :data="rows" :loading="loading" stripe> <div class="main-container" v-show="tab === 'todo'">
<template #empty>暂无数据</template> <TablePageAlovaNew ref="todoTableRef" :dataApi="todoApi" :columns="todoColumns" :tableHeight="tableHeight" />
</Table> </div>
<div class="pager"> <div class="main-container" v-show="tab === 'done'">
<Page :total="total" :current="page" :page-size="size" show-total @on-change="onPage" /> <TablePageAlovaNew ref="doneTableRef" :dataApi="doneApi" :columns="doneColumns" :tableHeight="tableHeight" :autoLoad="false" />
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.page { padding: 12px 16px; }
.pager { margin-top: 12px; text-align: right; }
</style>
+130 -94
View File
@@ -1,7 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
// 回调管理(概览 §9.2):注册列表/启停/secret 重置 + 推送日志 + 挂起(持续重试可视/标记死信)+ 死信重推 // 回调管理(概览 §9.2):注册列表/启停/secret 重置 + 推送日志 + 挂起(持续重试可视/标记死信)+ 死信重推。
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区;「重推选中」落在分页栏左侧批量操作位
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { Message, Modal } from "view-ui-plus"; import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
import { OperatorEnum } from "@/types";
import { flowApi } from "@/api/module/flow"; import { flowApi } from "@/api/module/flow";
import Utils from "@/utils"; import Utils from "@/utils";
@@ -11,105 +15,125 @@ const EVENT_OPTIONS = ["flow.submitted", "flow.task.created", "flow.task.handled
const tab = ref("regs"); const tab = ref("regs");
const loading = ref(false); const loading = ref(false);
// tabs 页高度:在常规骨架上再扣 Tab 条;日志页含搜索区+分页,挂起页含提示条
const regsTableHeight = ref(Utils.useTableHeight({ isTabs: true }));
const logTableHeight = ref(Utils.useTableHeight({ isTabs: true, isSearch: true, isPage: true }));
const hungTableHeight = ref(Utils.useTableHeight({ isTabs: true, otherH: 46 }));
// ---- 注册列表 ---- // ---- 注册列表 ----
const regs = ref<any[]>([]); const regs = ref<any[]>([]);
const regColumns = [ const regColumns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "应用 appKey", key: "appKey", width: 110 }, { title: "应用 appKey", key: "appKey", width: 110 },
{ title: "流程 GUID", key: "flowGuid", width: 210 }, { title: "流程 GUID", key: "flowGuid", width: 210 },
{ title: "回调地址", key: "callbackUrl", minWidth: 240 }, { title: "回调地址", key: "callbackUrl", minWidth: 240 },
{ title: "订阅事件", key: "eventTypes", width: 200, render: (h: any, p: any) => h("span", p.row.eventTypes || "全部") }, { title: "订阅事件", key: "eventTypes", width: 200, render: (h: any, p: any) => h("span", p.row.eventTypes || "全部") },
{ title: "状态", width: 80, render: (h: any, p: any) => h("Tag", { color: p.row.status === 0 ? "green" : "gray" }, () => (p.row.status === 0 ? "启用" : "停用")) }, { title: "状态", width: 80, align: "center", render: (h: any, p: any) => h("Tag", { color: p.row.status === 0 ? "green" : "gray" }, () => (p.row.status === 0 ? "启用" : "停用")) },
{ title: "操作", width: 220, render: (h: any, p: any) => { { title: "操作", width: 140, align: "center", fixed: "right", render: (h: any, p: any) => [
return [ Utils.operatingHandle(h, p.row.status === 0 ? "停用" : "启用", p.row.status === 0 ? "icon-stop" : "icon-shiyong", () => onRegStatus(p.row, p.row.status === 0)),
Utils.operatingTextHandle(h, p.row.status === 0 ? "停用" : "启用", "", () => onRegStatus(p.row, p.row.status === 0)), Utils.operatingHandle(h, "重置secret", "icon-shezhi", () => onResetSecret(p.row)),
Utils.operatingTextHandle(h, "重置secret", "text-error", () => onResetSecret(p.row)), ] },
];
} },
]; ];
// ---- 推送日志 ---- // ---- 推送日志 ----
const logs = ref<any[]>([]); const logTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const logPage = ref(1); const logFilter = ref<any>({ status: null, event: "", instanceId: "" });
const logTotal = ref(0);
const logFilter = ref<any>({ status: null, event: "", instanceId: null });
const selectedLogIds = ref<number[]>([]);
const logColumns = [ const logColumns = [
{ type: "selection", width: 50 }, { type: "selection", width: 50, align: "center" },
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "事件", key: "event", width: 150 }, { title: "事件", key: "event", width: 150 },
{ title: "办件", key: "instanceId", width: 80 }, { title: "办件", key: "instanceId", width: 80 },
{ title: "biz", width: 160, render: (h: any, p: any) => h("span", `${p.row.bizType}/${p.row.bizId}`) }, { title: "biz", width: 160, render: (h: any, p: any) => h("span", `${p.row.bizType}/${p.row.bizId}`) },
{ title: "状态", width: 110, render: (h: any, p: any) => { { title: "状态", width: 110, align: "center", render: (h: any, p: any) => {
const c = p.row.status === 1 ? "green" : p.row.status === 2 ? "red" : "orange"; const c = p.row.status === 1 ? "green" : p.row.status === 2 ? "red" : "orange";
return h("Tag", { color: c }, () => LOG_STATUS[p.row.status]); return h("Tag", { color: c }, () => LOG_STATUS[p.row.status]);
} }, } },
{ title: "重试", key: "retryCount", width: 60 }, { title: "重试", key: "retryCount", width: 60, align: "center" },
{ title: "末次结果", key: "lastError", minWidth: 200, render: (h: any, p: any) => h("span", p.row.lastError || p.row.lastHttpStatus || "-") }, { title: "末次结果", key: "lastError", minWidth: 200, render: (h: any, p: any) => h("span", p.row.lastError || p.row.lastHttpStatus || "-") },
{ title: "下次重推", width: 160, render: (h: any, p: any) => h("span", (p.row.nextRetryTime || "").replace("T", " ") || "-") }, { title: "下次重推", width: 160, render: (h: any, p: any) => h("span", (p.row.nextRetryTime || "").replace("T", " ") || "-") },
]; ];
// ---- 挂起列表(持续重试中:待推且重试达阈值;人工判定无意义 → 标记死信止血) ---- // ---- 挂起列表(持续重试中:待推且重试达阈值;人工判定无意义 → 标记死信止血) ----
const hungs = ref<any[]>([]); const hungs = ref<any[]>([]);
const hungPage = ref(1);
const hungTotal = ref(0);
const hungLoading = ref(false); const hungLoading = ref(false);
const hungColumns = [ const hungColumns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "事件", key: "event", width: 150 }, { title: "事件", key: "event", width: 150 },
{ title: "办件", key: "instanceId", width: 80 }, { title: "办件", key: "instanceId", width: 80 },
{ title: "biz", width: 160, render: (h: any, p: any) => h("span", `${p.row.bizType}/${p.row.bizId}`) }, { title: "biz", width: 160, render: (h: any, p: any) => h("span", `${p.row.bizType}/${p.row.bizId}`) },
{ title: "重试", key: "retryCount", width: 70, render: (h: any, p: any) => h("Tag", { color: "orange" }, () => `${p.row.retryCount} 次`) }, { title: "重试", key: "retryCount", width: 70, align: "center", render: (h: any, p: any) => h("Tag", { color: "orange" }, () => `${p.row.retryCount} 次`) },
{ title: "末次结果", key: "lastError", minWidth: 220, render: (h: any, p: any) => h("span", p.row.lastError || p.row.lastHttpStatus || "-") }, { title: "末次结果", key: "lastError", minWidth: 220, render: (h: any, p: any) => h("span", p.row.lastError || p.row.lastHttpStatus || "-") },
{ title: "下次重推", width: 160, render: (h: any, p: any) => h("span", (p.row.nextRetryTime || "").replace("T", " ") || "-") }, { title: "下次重推", width: 160, render: (h: any, p: any) => h("span", (p.row.nextRetryTime || "").replace("T", " ") || "-") },
{ title: "操作", width: 120, render: (h: any, p: any) => { title: "操作", width: 120, align: "center", fixed: "right", render: (h: any, p: any) =>
Utils.operatingTextHandle(h, "标记死信", "text-error", () => onMarkDead(p.row)) }, Utils.operatingHandle(h, "标记死信", "icon-ashbin", () => onMarkDead(p.row)) },
]; ];
async function loadRegs() { async function loadRegs() {
regs.value = (await flowApi.regList().send()) || []; regs.value = (await flowApi.regList().send()) || [];
} }
async function loadLogs() { // dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
loading.value = true; const logApi = (p: { pageIndex: number; pageSize: number }) => {
try { const params: any = { page: p.pageIndex, size: p.pageSize };
const params: any = { page: logPage.value, size: 10 }; if (logFilter.value.status !== null && logFilter.value.status !== undefined) params.status = logFilter.value.status;
if (logFilter.value.status !== null && logFilter.value.status !== undefined) params.status = logFilter.value.status; if (logFilter.value.event) params.event = logFilter.value.event;
if (logFilter.value.event) params.event = logFilter.value.event; if (logFilter.value.instanceId) params.instanceId = logFilter.value.instanceId;
if (logFilter.value.instanceId) params.instanceId = logFilter.value.instanceId; return flowApi.callbackLogs(params);
const data: any = await flowApi.callbackLogs(params).send(); };
logs.value = data?.content || [];
logTotal.value = data?.total || 0; function onLogSearch() {
} finally { logTableRef.value?.refreshData();
loading.value = false;
}
} }
async function loadHungs() { async function loadHungs() {
hungLoading.value = true; hungLoading.value = true;
try { try {
const data: any = await flowApi.callbackHung({ page: hungPage.value, size: 10 }).send(); const data: any = await flowApi.callbackHung({ page: 1, size: 100 }).send();
hungs.value = data?.content || []; hungs.value = data?.content || [];
hungTotal.value = data?.total || 0;
} finally { } finally {
hungLoading.value = false; hungLoading.value = false;
} }
} }
async function onRegStatus(row: any, disable: boolean) { function onRegStatus(row: any, disable: boolean) {
await flowApi.regStatus(row.id, !disable).send(); const doStatus = async () => {
Message.success("已更新"); await flowApi.regStatus(row.id, !disable).send();
loadRegs(); Message.success("已更新");
loadRegs();
};
if (!disable) {
doStatus();
return;
}
MyConfirm({
title: "停用",
msg: `确定停用应用「${row.appKey}」的回调注册吗?`,
info: "停用后该应用的流程事件将不再推送",
async onOk() {
await doStatus();
},
});
} }
async function onResetSecret(row: any) { function onResetSecret(row: any) {
const d: any = await flowApi.regResetSecret(row.id).send(); MyConfirm({
Message.info({ content: `新 secret(仅本次展示):${d?.secret}`, duration: 8 }); title: "重置secret",
loadRegs(); msg: `确定重置应用「${row.appKey}」的 secret 吗?`,
info: "重置后原 secret 立即失效",
async onOk() {
const d: any = await flowApi.regResetSecret(row.id).send();
Message.info({ content: `新 secret(仅本次展示):${d?.secret}`, duration: 8 });
loadRegs();
},
});
} }
function onMarkDead(row: any) { function onMarkDead(row: any) {
Modal.confirm({ MyConfirm({
title: "标记死信", title: "标记死信",
content: `办件 ${row.instanceId} 事件 ${row.event} 已持续重试 ${row.retryCount} 次。确认该事件已无意义、停止重试并转入死信?`, msg: `办件 ${row.instanceId} 事件 ${row.event} 已持续重试 ${row.retryCount} 次。确认该事件已无意义、停止重试并转入死信?`,
onOk: async () => { info: "标记后可在推送日志(死信)中重推复活",
async onOk() {
await flowApi.callbackMarkDead(row.id).send(); await flowApi.callbackMarkDead(row.id).send();
Message.success("已标记死信(可在死信列表重推复活)"); Message.success("已标记死信(可在死信列表重推复活)");
loadHungs(); loadHungs();
@@ -117,70 +141,82 @@ function onMarkDead(row: any) {
}); });
} }
function onSelectionChange(selection: any[]) { async function onRepush(checkList: number[]) {
selectedLogIds.value = selection.map((r) => r.id); if (!checkList.length) {
}
async function onRepush() {
if (!selectedLogIds.value.length) {
Message.warning("请先勾选日志行"); Message.warning("请先勾选日志行");
return; return;
} }
await flowApi.callbackRepush(selectedLogIds.value).send(); await flowApi.callbackRepush(checkList).send();
Message.success("已触发重推"); Message.success("已触发重推");
loadLogs(); // 重推后状态会变,留在当前页刷新
logTableRef.value?.reloadData(OperatorEnum.edit);
} }
// 三个内容区 v-show 共存,日志/挂起首次切换才拉取(保持原惰性加载行为)
const logsLoaded = ref(false);
const hungLoaded = ref(false);
function switchTab(name: string) { function switchTab(name: string) {
tab.value = name; tab.value = name;
if (name === "logs") loadLogs(); if (name === "logs" && !logsLoaded.value) {
if (name === "hung") loadHungs(); logsLoaded.value = true;
logTableRef.value?.reloadData(OperatorEnum.sel);
}
if (name === "hung" && !hungLoaded.value) {
hungLoaded.value = true;
loadHungs();
}
} }
onMounted(loadRegs); onMounted(loadRegs);
</script> </script>
<template> <template>
<div class="page"> <div class="tabs-wrapper">
<Tabs :value="tab" @on-click="switchTab"> <Tabs type="card" :value="tab" @on-click="switchTab">
<TabPane label="回调注册" name="regs"> <TabPane label="回调注册" name="regs" />
<Table :columns="regColumns" :data="regs" :loading="loading" stripe /> <TabPane label="推送日志" name="logs" />
</TabPane> <TabPane label="挂起中" name="hung" />
<TabPane label="推送日志" name="logs">
<div class="filter">
<Select v-model="logFilter.status" clearable placeholder="状态" style="width: 130px">
<Option :value="0" label="待推/重试中" />
<Option :value="1" label="成功" />
<Option :value="2" label="死信" />
</Select>
<Select v-model="logFilter.event" clearable placeholder="事件" style="width: 190px">
<Option v-for="e in EVENT_OPTIONS" :key="e" :value="e" :label="e" />
</Select>
<Input v-model="logFilter.instanceId" placeholder="办件ID" style="width: 120px" clearable />
<Button @click="logPage = 1; loadLogs()">查询</Button>
<Button type="primary" @click="onRepush">重推选中</Button>
</div>
<Table :columns="logColumns" :data="logs" :loading="loading" stripe @on-selection-change="onSelectionChange" />
<div class="pager">
<Page :total="logTotal" :current="logPage" :page-size="10" show-total @on-change="(p: number) => { logPage = p; loadLogs(); }" />
</div>
</TabPane>
<TabPane label="挂起中" name="hung">
<div class="tip">
持续重试中的事件(消费方疑似停机;消费方恢复后自动追平,无需干预)。仅当人工判定事件已无意义时才标记死信。
</div>
<Table :columns="hungColumns" :data="hungs" :loading="hungLoading" stripe />
<div class="pager">
<Page :total="hungTotal" :current="hungPage" :page-size="10" show-total @on-change="(p: number) => { hungPage = p; loadHungs(); }" />
</div>
</TabPane>
</Tabs> </Tabs>
<!-- 回调注册:无分页 -->
<div class="main-container" v-show="tab === 'regs'">
<Table :columns="regColumns" :data="regs" :loading="loading" class="link-table table-bottom-box"
:height="regsTableHeight" no-data-text="暂无数据" />
</div>
<!-- 推送日志:搜索区 + 分页,重推选中落分页栏左侧批量操作位 -->
<div class="main-container" v-show="tab === 'logs'">
<div class="search-wrap fix">
<Select v-model="logFilter.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onLogSearch">
<Option :value="0" label="待推/重试中" />
<Option :value="1" label="成功" />
<Option :value="2" label="死信" />
</Select>
<Select v-model="logFilter.event" class="search-wrap_sel ml-12" clearable placeholder="事件" @on-change="onLogSearch">
<Option v-for="e in EVENT_OPTIONS" :key="e" :value="e" :label="e" />
</Select>
<Input class="fr" v-model="logFilter.instanceId" placeholder="办件ID" search suffix="ios-search"
style="width: 200px" @on-search="onLogSearch" @on-clear="onLogSearch" />
</div>
<TablePageAlovaNew ref="logTableRef" :dataApi="logApi" :columns="logColumns"
:tableHeight="logTableHeight" :autoLoad="false">
<template #default="{ checkList }">
<Button size="small" type="primary" :disabled="!checkList.length" @click="onRepush(checkList)">重推选中</Button>
</template>
</TablePageAlovaNew>
</div>
<!-- 挂起中:无分页 -->
<div class="main-container" v-show="tab === 'hung'">
<div class="tip">
持续重试中的事件(消费方疑似停机;消费方恢复后自动追平,无需干预)。仅当人工判定事件已无意义时才标记死信。
</div>
<Table :columns="hungColumns" :data="hungs" :loading="hungLoading" class="link-table table-bottom-box"
:height="hungTableHeight" no-data-text="暂无数据" />
</div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.page { padding: 12px 16px; }
.filter { margin-bottom: 10px; display: flex; gap: 8px; }
.pager { margin-top: 12px; text-align: right; }
.tip { margin-bottom: 10px; padding: 6px 10px; background: #f8f8f9; border-radius: 4px; color: #515a6e; font-size: 12px; } .tip { margin-bottom: 10px; padding: 6px 10px; background: #f8f8f9; border-radius: 4px; color: #515a6e; font-size: 12px; }
</style> </style>
+94 -35
View File
@@ -1,10 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
// 流程定义管理:列表 + 发布/停用/升版本/版本历史/图示。 // 流程定义管理:列表 + 发布/启用/停用/升版本/版本历史/图示/源码。
// 新建与编辑走独立的全屏可视化编辑器(/admin/defs/editor,钉钉式卡片流,见《流程中心流程编辑器方案.md》); // 新建与编辑走独立的全屏可视化编辑器(/admin/defs/editor,钉钉式卡片流,见《流程编辑器方案.md》);
// 本页不再直接编辑 JSON 文本(原 M1 textarea 已由编辑器取代,编辑器内置"源码"高级模式兜底)。 // 本页不再直接编辑 JSON 文本(编辑器内置"源码"高级模式兜底);行内「源码」为只读查看+复制。
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
import { flowApi } from "@/api/module/flow"; import { flowApi } from "@/api/module/flow";
import Utils from "@/utils"; import Utils from "@/utils";
@@ -15,28 +17,40 @@ const loading = ref(false);
const rows = ref<any[]>([]); const rows = ref<any[]>([]);
const versionVisible = ref(false); const versionVisible = ref(false);
const versions = ref<any[]>([]); const versions = ref<any[]>([]);
// 源码抽屉(列表行已带回 contentJson,无需再请求)
const sourceVisible = ref(false);
const sourceText = ref("");
const sourceTitle = ref("");
// 操作按钮区 + 表格,无搜索、无分页
const tableHeight = ref(Utils.useTableHeight({ isOperat: true }));
const columns = [ const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "流程", key: "name", minWidth: 180 }, { title: "流程", key: "name", minWidth: 180 },
{ title: "编码", key: "code", width: 160 }, { title: "编码", key: "code", width: 160 },
{ title: "版本", key: "version", width: 70 }, { title: "版本", key: "version", width: 70, align: "center" },
{ title: "状态", width: 90, render: (h: any, p: any) => { { title: "状态", width: 90, align: "center", render: (h: any, p: any) => {
const c = p.row.status === 1 ? "green" : p.row.status === 0 ? "orange" : "gray"; const c = p.row.status === 1 ? "green" : p.row.status === 0 ? "orange" : "gray";
return h("Tag", { color: c }, () => DEF_STATUS[p.row.status] || p.row.status); return h("Tag", { color: c }, () => DEF_STATUS[p.row.status] || p.row.status);
} }, } },
{ title: "GUID", key: "flowGuid", width: 200 }, { title: "GUID", key: "flowGuid", width: 200 },
{ title: "发布时间", width: 160, render: (h: any, p: any) => h("span", (p.row.publishTime || "").replace("T", " ") || "-") }, { title: "发布时间", width: 160, render: (h: any, p: any) => h("span", (p.row.publishTime || "").replace("T", " ") || "-") },
{ title: "操作", width: 320, fixed: "right", render: (h: any, p: any) => { { title: "操作", width: 250, align: "center", fixed: "right", render: (h: any, p: any) => {
const btns: any[] = []; const btns: any[] = [];
if (p.row.status === 0) { if (p.row.status === 0) {
btns.push(Utils.operatingTextHandle(h, "编辑", "", () => onEdit(p.row))); btns.push(Utils.operatingHandle(h, "编辑", "icon-edit", () => onEdit(p.row)));
btns.push(Utils.operatingTextHandle(h, "发布", "", () => onPublish(p.row))); btns.push(Utils.operatingHandle(h, "发布", "icon-fasong", () => onPublish(p.row)));
} else if (p.row.status === 1) { } else if (p.row.status === 1) {
btns.push(Utils.operatingTextHandle(h, "升版本", "", () => onNewVersion(p.row))); btns.push(Utils.operatingHandle(h, "升版本", "icon-tianjia", () => onNewVersion(p.row)));
btns.push(Utils.operatingTextHandle(h, "停用", "text-error", () => onOffline(p.row))); btns.push(Utils.operatingHandle(h, "停用", "icon-stop", () => onOffline(p.row)));
} else if (p.row.status === 2) {
// 与「停用」对称的专用启用通道(后端 /def/enable:仅停用版→已发布,重跑发布校验;
// 草稿不走这里,草稿用「发布」)
btns.push(Utils.operatingHandle(h, "启用", "icon-play", () => onEnable(p.row)));
} }
btns.push(Utils.operatingTextHandle(h, "图示", "", () => onDiagram(p.row))); btns.push(Utils.operatingHandle(h, "图示", "icon-tupian", () => onDiagram(p.row)));
btns.push(Utils.operatingTextHandle(h, "版本历史", "", () => onVersions(p.row))); btns.push(Utils.operatingHandle(h, "源码", "icon-liebiao", () => onSource(p.row)));
btns.push(Utils.operatingHandle(h, "版本历史", "icon-shijian", () => onVersions(p.row)));
return btns; return btns;
} }, } },
]; ];
@@ -76,10 +90,52 @@ async function onPublish(row: any) {
} }
} }
async function onOffline(row: any) { function onOffline(row: any) {
await flowApi.defOffline(row.id).send(); MyConfirm({
Message.success("已停用"); title: "停用",
load(); msg: `确定停用流程「${row.name}」吗?`,
info: "停用后无法发起新办件,进行中的办件不受影响",
async onOk() {
await flowApi.defOffline(row.id).send();
Message.success("已停用");
load();
},
});
}
/** 启用停用版:专用 enable 通道(与停用对称;重跑静态校验后置回已发布) */
function onEnable(row: any) {
MyConfirm({
title: "启用",
msg: `确定启用流程「${row.name}」吗?`,
info: "启用将重新执行发布校验",
async onOk() {
try {
await flowApi.defEnable(row.id).send();
Message.success("已启用");
load();
} catch (e: any) {
Message.error(e?.message || "启用失败");
}
},
});
}
/** 源码:列表行已带回 contentJson(defList 返回全量实体),直接开抽屉,无需再拉接口 */
function onSource(row: any) {
sourceText.value = prettyJson(row.contentJson);
sourceTitle.value = `${row.name}(${row.code} v${row.version})`;
sourceVisible.value = true;
}
/** contentJson 字符串 → 缩进 JSON(解析失败则原样展示,不让展示把页面弄崩) */
function prettyJson(raw: any): string {
if (!raw) return "";
try {
return JSON.stringify(typeof raw === "string" ? JSON.parse(raw) : raw, null, 2);
} catch {
return String(raw);
}
} }
async function onVersions(row: any) { async function onVersions(row: any) {
@@ -88,31 +144,34 @@ async function onVersions(row: any) {
} }
function onDiagram(row: any) { function onDiagram(row: any) {
router.push({ path: "/diagram", query: { defId: row.id, mode: "auto" } }); // 不传 mode:后端缺省=人员视图(两档由图示页自身的 科室/人员 切换控制)
router.push({ path: "/diagram", query: { defId: row.id } });
} }
onMounted(load); onMounted(load);
</script> </script>
<template> <template>
<div class="page"> <div class="page-wrapper">
<div class="filter"> <div class="main-container">
<Button type="primary" @click="onNew">新建流程</Button> <!-- 操作按钮区:主操作靠左(bsch应用页规范 §1) -->
</div> <div class="fix mb-12">
<Table :columns="columns" :data="rows" :loading="loading" stripe /> <Button class="fl" type="primary" size="large" @click="onNew">新建流程</Button>
</div>
<Modal v-model="versionVisible" title="版本历史" width="640"> <Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
<Table :columns="[ :height="tableHeight" no-data-text="暂无数据" />
{ title: '版本', key: 'version', width: 80 },
{ title: '名称', key: 'name' }, <Modal v-model="versionVisible" title="版本历史" width="640">
{ title: '状态', width: 90, render: (h, p) => h('Tag', () => DEF_STATUS[p.row.status]) }, <Table :columns="[
{ title: '发布时间', width: 160, render: (h, p) => (p.row.publishTime || '').replace('T', ' ') || '-' }, { title: '版本', key: 'version', width: 80 },
]" :data="versions" size="small" /> { title: '名称', key: 'name' },
</Modal> { title: '状态', width: 90, render: (h, p) => h('Tag', () => DEF_STATUS[p.row.status]) },
{ title: '发布时间', width: 160, render: (h, p) => (p.row.publishTime || '').replace('T', ' ') || '-' },
]" :data="versions" size="small" no-data-text="暂无数据" />
</Modal>
<SourceDrawer v-model="sourceVisible" :text="sourceText" :subtitle="sourceTitle" />
</div>
</div> </div>
</template> </template>
<style scoped>
.page { padding: 12px 16px; }
.filter { margin-bottom: 10px; }
</style>
+34 -47
View File
@@ -1,68 +1,55 @@
<script setup lang="ts"> <script setup lang="ts">
// 办件记录(管理端):全校办件查询 // 办件记录(管理端):全校办件查询
import { ref, onMounted } from "vue"; import { ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow"; import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
import Utils from "@/utils"; import Utils from "@/utils";
const router = useRouter(); const router = useRouter();
const loading = ref(false); const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const page = ref(1); const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isPage: true }));
const total = ref(0); const dataParams = ref<{ status?: number; keyword?: string }>({ status: undefined, keyword: "" });
const filter = ref<any>({ status: null, keyword: "" });
const rows = ref<any[]>([]);
const columns = [ const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "办件标题", key: "bizTitle", minWidth: 220 }, { title: "办件标题", key: "bizTitle", minWidth: 220 },
{ title: "业务", width: 170, render: (h: any, p: any) => h("span", `${p.row.bizType || "-"}/${p.row.bizId || "-"}`) }, { title: "业务", width: 170, render: (h: any, p: any) => h("span", `${p.row.bizType || "-"}/${p.row.bizId || "-"}`) },
{ title: "状态", width: 100, render: (h: any, p: any) => { { title: "状态", width: 100, align: "center", render: (h: any, p: any) =>
const c = p.row.status === 10 ? "blue" : p.row.status === 20 ? "green" : p.row.status === 30 ? "red" : "gray"; h("Tag", { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
return h("Tag", { color: c }, () => INSTANCE_STATUS[p.row.status] || p.row.status);
} },
{ title: "提交人", key: "submitterName", width: 100 }, { title: "提交人", key: "submitterName", width: 100 },
{ title: "提交时间", width: 160, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) }, { title: "提交时间", width: 160, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
{ title: "操作", width: 90, render: (h: any, p: any) => { title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
Utils.operatingTextHandle(h, "详情", "", () => router.push(`/instance/${p.row.id}?pid=102`)) }, Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.id}?pid=102`)) },
]; ];
async function load() { // dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
loading.value = true; const tableApi = (p: { pageIndex: number; pageSize: number }) => {
try { const params: any = { page: p.pageIndex, size: p.pageSize };
const params: any = { page: page.value, size: 10 }; if (dataParams.value.status) params.status = dataParams.value.status;
if (filter.value.status !== null && filter.value.status !== undefined) params.status = filter.value.status; if (dataParams.value.keyword) params.keyword = dataParams.value.keyword.trim();
if (filter.value.keyword) params.keyword = filter.value.keyword; return flowApi.adminInstances(params);
const data: any = await flowApi.adminInstances(params).send(); };
rows.value = data?.content || [];
total.value = data?.total || 0;
} finally {
loading.value = false;
}
}
onMounted(load); function onSearch() {
tableRef.value?.refreshData();
}
</script> </script>
<template> <template>
<div class="page"> <div class="page-wrapper">
<div class="filter"> <div class="main-container">
<Select v-model="filter.status" clearable placeholder="状态" style="width: 130px"> <div class="search-wrap fix">
<Option :value="10" label="审批中" /> <Select v-model="dataParams.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onSearch">
<Option :value="20" label="已完成" /> <Option :value="10" label="审批中" />
<Option :value="30" label="已拒绝" /> <Option :value="20" label="已完成" />
<Option :value="40" label="已撤回" /> <Option :value="30" label="已拒绝" />
</Select> <Option :value="40" label="已撤回" />
<Input v-model="filter.keyword" placeholder="标题关键字" style="width: 200px" clearable @on-enter="load" /> </Select>
<Button @click="page = 1; load()">查询</Button> <Input class="fr" v-model="dataParams.keyword" placeholder="标题关键字" search suffix="ios-search"
</div> style="width: 200px" @on-search="onSearch" @on-clear="onSearch" />
<Table :columns="columns" :data="rows" :loading="loading" stripe /> </div>
<div class="pager"> <TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
<Page :total="total" :current="page" :page-size="10" show-total @on-change="(p: number) => { page = p; load(); }" />
</div> </div>
</div> </div>
</template> </template>
<style scoped>
.page { padding: 12px 16px; }
.filter { margin-bottom: 10px; display: flex; gap: 8px; }
.pager { margin-top: 12px; text-align: right; }
</style>
+119 -81
View File
@@ -2,6 +2,7 @@
// 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人;办理人改为弹窗选人(参照 Notice 选人范式) // 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人;办理人改为弹窗选人(参照 Notice 选人范式)
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
import MyConfirm from "@/components/MyConfirm";
import UserSelectInput from "@/components/UserSelectInput.vue"; import UserSelectInput from "@/components/UserSelectInput.vue";
import { flowApi } from "@/api/module/flow"; import { flowApi } from "@/api/module/flow";
import Utils from "@/utils"; import Utils from "@/utils";
@@ -9,10 +10,8 @@ import Utils from "@/utils";
const loading = ref(false); const loading = ref(false);
const deptKeyFilter = ref(""); const deptKeyFilter = ref("");
const rows = ref<any[]>([]); const rows = ref<any[]>([]);
// 操作按钮区 + 搜索区、无分页
const saveVisible = ref(false); const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isOperat: true }));
const saving = ref(false);
const form = ref<any>({ deptKey: "", deptName: "", userId: null, userName: "" });
// 一键拉取教师组(预览确认后批量导入) // 一键拉取教师组(预览确认后批量导入)
const groupVisible = ref(false); const groupVisible = ref(false);
@@ -22,7 +21,7 @@ const groupRows = ref<any[]>([]);
const groupColumns = [ const groupColumns = [
{ title: "科室编码", key: "deptKey", width: 100 }, { title: "科室编码", key: "deptKey", width: 100 },
{ title: "科室名称", key: "deptName", width: 160 }, { 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("、") || "(空,导入后可手工补)") }, { 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 = [ const columns = [
{ title: "序号", type: "index", width: 70, align: "center" },
{ title: "科室编码", key: "deptKey", width: 140 }, { 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: "办理人", key: "userName", width: 120 },
{ title: "用户ID", key: "userId", width: 90 }, { 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: 90, align: "center", 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: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
p.row.status === 0 p.row.status === 0
? Utils.operatingTextHandle(h, "停用", "text-error", () => onDisable(p.row)) ? Utils.operatingHandle(h, "停用", "icon-stop", () => onDisable(p.row))
: h("span", "-") }, : Utils.operatingHandle(h, "启用", "icon-play", () => onEnable(p.row)) },
]; ];
async function load() { async function load() {
loading.value = true; loading.value = true;
try { try {
rows.value = (await flowApi.mapList(deptKeyFilter.value || undefined).send()) || []; rows.value = (await flowApi.mapList(deptKeyFilter.value.trim() || undefined).send()) || [];
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
function onSearch() {
load();
}
// ---- 新增/更新映射弹窗(规范:必填红星标注,校验失败提示统一文案) ----
const saveVisible = ref(false);
const saving = ref(false);
const formRef = ref<any>(null);
const form = ref<any>({ deptKey: "", deptName: "", userId: null, userName: "" });
const saveRules = {
deptKey: [{ required: true, message: "请填写红色星号内容!", trigger: "blur" }],
userId: [{ required: true, message: "请填写红色星号内容!", trigger: "change" }],
};
function onAdd() { function onAdd() {
form.value = { deptKey: "", deptName: "", userId: null, userName: "" }; form.value = { deptKey: "", deptName: "", userId: null, userName: "" };
saveVisible.value = true; saveVisible.value = true;
@@ -84,84 +98,108 @@ function onUserPick(u: any) {
form.value.userName = u?.name || ""; form.value.userName = u?.name || "";
} }
async function onSave() { function onSave() {
if (!form.value.deptKey.trim() || !form.value.userId) { formRef.value?.validate(async (valid: boolean) => {
Message.warning("科室编码与办理人必填"); if (!valid) return;
return; saving.value = true;
} try {
saving.value = true; await flowApi.mapSave({
try { deptKey: form.value.deptKey.trim(),
await flowApi.mapSave({ deptName: form.value.deptName,
deptKey: form.value.deptKey.trim(), userId: Number(form.value.userId),
deptName: form.value.deptName, userName: form.value.userName,
userId: Number(form.value.userId), }).send();
userName: form.value.userName, Message.success("已保存");
}).send(); saveVisible.value = false;
Message.success("已保存"); load();
saveVisible.value = false; } finally {
load(); saving.value = false;
} finally { }
saving.value = false; });
}
} }
async function onDisable(row: any) { function onDisable(row: any) {
await flowApi.mapDisable(row.id).send(); MyConfirm({
Message.success("已停用"); title: "停用",
load(); 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); onMounted(load);
</script> </script>
<template> <template>
<div class="page"> <div class="page-wrapper">
<div class="filter"> <div class="main-container">
<Input v-model="deptKeyFilter" placeholder="按科室编码筛选" style="width: 200px" clearable @on-enter="load" /> <!-- 操作按钮区:主操作靠左、其余靠右(bsch应用页规范 §1) -->
<Button @click="load">查询</Button> <div class="fix mb-12">
<Button type="primary" @click="onAdd">新增映射</Button> <Button class="fl" type="primary" size="large" @click="onAdd">新增映射</Button>
<Button type="info" :loading="groupLoading" @click="onGroupPreview">一键拉取教师组</Button> <Button class="fr" size="large" :loading="groupLoading" @click="onGroupPreview">一键拉取教师组</Button>
</div>
<!-- 搜索区:关键字即输即搜,不设查询按钮 -->
<div class="search-wrap fix">
<Input class="fr" v-model="deptKeyFilter" placeholder="按科室编码筛选" search suffix="ios-search"
style="width: 200px" @on-search="onSearch" @on-clear="onSearch" />
</div>
<Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
:height="tableHeight" no-data-text="暂无数据" />
<Modal v-model="saveVisible" title="新增/更新映射" :mask-closable="false">
<Form ref="formRef" :model="form" :rules="saveRules" :label-width="90">
<FormItem label="科室编码" prop="deptKey">
<Input v-model="form.deptKey" placeholder="流程 JSON 中 DEPT_KEY 引用的编码,如 ybb" />
</FormItem>
<FormItem label="科室名称">
<Input v-model="form.deptName" placeholder="如 医保办(可空)" />
</FormItem>
<FormItem label="办理人" prop="userId">
<UserSelectInput
:model-value="form.userId"
:display-name="form.userName"
@update:model-value="form.userId = $event"
@update:display-name="form.userName = $event"
@on-change="onUserPick"
/>
</FormItem>
</Form>
<template #footer>
<Button @click="saveVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="onSave">提交</Button>
</template>
</Modal>
<Modal v-model="groupVisible" title="一键拉取教师组(UserBase 学校组树)" width="720" :mask-closable="false">
<p style="margin-bottom: 8px; color: #808695">
按学校组树中类型=教师组(type 7)的容器组取其子组作为科室(无子组时容器自身即科室),科室编码=UserBase 组 id,成员=组内用户。已存在且启用中的映射自动跳过。
</p>
<Table :columns="groupColumns" :data="groupRows" :loading="groupLoading" max-height="360" no-data-text="暂无数据" />
<template #footer>
<Button @click="groupVisible = false">取消</Button>
<Button type="primary" :loading="groupImporting" :disabled="!groupRows.length" @click="onGroupImport">确认导入</Button>
</template>
</Modal>
</div> </div>
<Table :columns="columns" :data="rows" :loading="loading" stripe />
<Modal v-model="saveVisible" title="新增/更新映射" :mask-closable="false">
<Form :label-width="90">
<FormItem label="科室编码">
<Input v-model="form.deptKey" placeholder="流程 JSON 中 DEPT_KEY 引用的编码,如 ybb" />
</FormItem>
<FormItem label="科室名称">
<Input v-model="form.deptName" placeholder="如 医保办(可空)" />
</FormItem>
<FormItem label="办理人">
<UserSelectInput
:model-value="form.userId"
:display-name="form.userName"
@update:model-value="form.userId = $event"
@update:display-name="form.userName = $event"
@on-change="onUserPick"
/>
</FormItem>
</Form>
<template #footer>
<Button @click="saveVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="onSave">保存</Button>
</template>
</Modal>
<Modal v-model="groupVisible" title="一键拉取教师组(UserBase 学校组树)" width="720" :mask-closable="false">
<p style="margin-bottom: 8px; color: #808695">
按学校组树中类型=教师组(type 7)的容器组取其子组作为科室(无子组时容器自身即科室),科室编码=UserBase 组 id,成员=组内用户。已存在且启用中的映射自动跳过。
</p>
<Table :columns="groupColumns" :data="groupRows" :loading="groupLoading" max-height="360" stripe />
<template #footer>
<Button @click="groupVisible = false">取消</Button>
<Button type="primary" :loading="groupImporting" :disabled="!groupRows.length" @click="onGroupImport">确认导入</Button>
</template>
</Modal>
</div> </div>
</template> </template>
<style scoped>
.page { padding: 12px 16px; }
.filter { margin-bottom: 10px; display: flex; gap: 8px; }
</style>
@@ -162,3 +162,102 @@ export function toCanvasJson(contentJson: string | CanvasJson): CanvasJson {
} }
return contentJson; return contentJson;
} }
/** 查看器 BPMN DI 形状尺寸(事件 36、网关 50、任务两行文本 150x76) */
const DI_SIZE: Record<string, { w: number; h: number }> = {
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<string, { x: number; y: number }> = {};
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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 ` <bpmn:${tag} id="${esc(n.id)}"${name} />`;
})
.join("\n");
const flowElems = flows
.map((e, i) => {
const name = e.name ? ` name="${esc(e.name)}"` : "";
return ` <bpmn:sequenceFlow id="flow_${i}"${name} sourceRef="${esc(e.from)}" targetRef="${esc(e.to)}" />`;
})
.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 ` <bpmndi:BPMNShape id="di_${esc(n.id)}" bpmnElement="${esc(n.id)}">
<dc:Bounds x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" />
</bpmndi:BPMNShape>`;
})
.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]) => ` <di:waypoint x="${Math.round(x)}" y="${Math.round(y)}" />`).join("\n");
return ` <bpmndi:BPMNEdge id="di_flow_${i}" bpmnElement="flow_${i}">\n${wp}\n </bpmndi:BPMNEdge>`;
})
.join("\n");
return `<?xml version="1.0" encoding="UTF-8"?>
<bpmn:definitions xmlns:bpmn="http://www.omg.org/spec/BPMN/20100524/MODEL"
xmlns:bpmndi="http://www.omg.org/spec/BPMN/20100524/DI"
xmlns:dc="http://www.omg.org/spec/DD/20100524/DC"
xmlns:di="http://www.omg.org/spec/DD/20100524/DI"
targetNamespace="http://bpmn.io/schema/bpmn">
<bpmn:process id="lc_process" isExecutable="true">
${elems}
${flowElems}
</bpmn:process>
<bpmndi:BPMNDiagram id="BPMNDiagram_1">
<bpmndi:BPMNPlane id="BPMNPlane_1" bpmnElement="lc_process">
${shapes}
${edgeDi}
</bpmndi:BPMNPlane>
</bpmndi:BPMNDiagram>
</bpmn:definitions>`;
}
@@ -0,0 +1,80 @@
<script setup lang="ts">
// 流程源码抽屉(2026-09-18):只读展示流程定义的 contentJson,支持一键复制(文本复用)。
// 数据来源由调用方给(图示页走 /api/flow/def/source 或 /admin/def/get;列表页直接用行上的 contentJson),
// 本组件只管展示与复制,不做任何解析/回写(编辑走流程编辑器的"源码"高级模式)。
import { Message } from "view-ui-plus";
import Utils from "@/utils";
const props = defineProps<{
modelValue: boolean;
/** 展示文本(已格式化的 JSON 字符串) */
text: string;
/** 标题补充信息,如「流程名(code v1)」 */
subtitle?: string;
}>();
const emit = defineEmits<{
"update:modelValue": [boolean];
}>();
async function onCopy(): Promise<void> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(props.text);
Message.success("已复制");
return;
}
await Utils.copyToClipboard(props.text);
Message.success("已复制");
} catch {
Message.error("复制失败,请手动选择文本复制");
}
}
</script>
<template>
<Drawer
:model-value="modelValue"
title="流程源码"
:width="680"
:mask-closable="false"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="tip">
<span v-if="subtitle">{{ subtitle }} · </span>
以下是流程定义的 JSON(后端运行时格式:nodes/edges/settings/layout)。只读展示;
需要改结构请在流程编辑器里用「源码」高级模式,或以此文本为底稿新建流程。
</div>
<Input :model-value="text" type="textarea" :rows="26" readonly class="json-area" spellcheck="false" />
<div class="dw-foot">
<span style="flex: 1" />
<Button @click="emit('update:modelValue', false)">关闭</Button>
<Button type="primary" @click="onCopy">复制</Button>
</div>
</Drawer>
</template>
<style scoped>
.tip {
color: #808695;
font-size: 12px;
line-height: 1.7;
margin-bottom: 10px;
}
.json-area :deep(textarea) {
font-family: Consolas, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
}
.dw-foot {
position: sticky;
bottom: -16px;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0;
margin-top: 8px;
background: #fff;
border-top: 1px solid #f0f0f0;
}
</style>
+127
View File
@@ -0,0 +1,127 @@
<script setup lang="ts">
// 流程轨迹时间线(2026-09-18 新增,办件详情页与办理页共用一个实现):
// ① 时间流:后端已按"该行实际发生的时间"升序返回(time 字段),此处不再排序,只按 round 分轮展示;
// ② 两档显示:user=按人员(默认,显示办理人姓名);dept=按科室(DEPT_KEY 节点显科室名、USERS 节点显
// "指定办理人"、发起/撤回/重提显"提交人"),口径与流程示意图的科室视图一致;
// ③ 撤回重走留痕:kind=WITHDRAW/RESUBMIT 的行由后端留痕事件行提供,重提处插入"第 N 轮"分隔。
import { computed } from "vue";
import { TASK_STATUS } from "@/api/module/flow";
const props = defineProps<{
/** 后端 detail.trace(已按时间升序,含 kind/time/round/科室富化字段) */
trace: any[];
/** user=按人员(缺省)|dept=按科室 */
mode?: string;
}>();
/** 事件类型 → 时间线圆点颜色 */
const KIND_DOT: Record<string, string> = {
SUBMIT: "green",
RESUBMIT: "blue",
WITHDRAW: "orange",
APPROVE: "green",
REJECT: "red",
RETURN: "orange",
TRANSFER: "cyan",
INVALID: "gray",
PENDING: "blue",
};
/** 事件类型 → 动作标签主题色(标签文案取 TASK_STATUS,避免两处口径漂移) */
const KIND_TAG: Record<string, string> = {
SUBMIT: "green",
RESUBMIT: "green",
WITHDRAW: "orange",
APPROVE: "green",
REJECT: "red",
RETURN: "orange",
TRANSFER: "cyan",
INVALID: "default",
PENDING: "blue",
};
const rows = computed(() => props.trace || []);
/** 多轮才有轮次分隔(单轮不打扰) */
const multiRound = computed(() => rows.value.some((t: any) => (t.round || 1) > 1));
/** 轮次分隔标题:第 N 轮 + 该轮重提时间 */
function roundTitle(round: number): string {
let at = "";
for (const t of rows.value) {
if ((t.round || 1) === round && t.kind === "RESUBMIT") {
at = fmtTime(t.time);
break;
}
}
return at ? `第 ${round} 轮审批(重新提交于 ${at})` : `第 ${round} 轮审批`;
}
/** 操作者:按人员=姓名;按科室=科室名/指定办理人/提交人(不暴露具体人名) */
function actor(t: any): string {
if (props.mode !== "dept") {
return t.assigneeName || t.assigneeId || "-";
}
if (t.assigneeType === "DEPT_KEY") {
return t.deptName || t.deptKey || t.assigneeName || "-";
}
if (t.assigneeType === "USERS") {
return "指定办理人";
}
// 留痕事件行(发起/撤回/重新提交)与定义里已找不到的老节点:不泄露人名,统一称"提交人"
return t.kind === "SUBMIT" || t.kind === "RESUBMIT" || t.kind === "WITHDRAW"
? "提交人"
: (t.assigneeName || "-");
}
/** 动作标签:失效行标"失效"而不是笼统的"提交" */
function actionText(t: any): string {
return TASK_STATUS[t.status] || String(t.status ?? "");
}
function fmtTime(v: string | null | undefined): string {
if (!v) return "";
return String(v).replace("T", " ").replace(/\.\d+$/, "");
}
</script>
<template>
<div>
<Timeline>
<template v-for="(t, i) in rows" :key="t.rowKey || i">
<div v-if="multiRound && (t.round || 1) !== (rows[i - 1]?.round || 1)" class="round">
{{ roundTitle(t.round || 1) }}
</div>
<TimelineItem :color="KIND_DOT[t.kind] || 'gray'">
<div class="line">
<span class="node">{{ t.nodeName || t.nodeId }}</span>
<Tag size="small">{{ actor(t) }}</Tag>
<Tag size="small" :color="KIND_TAG[t.kind] || 'default'">{{ actionText(t) }}</Tag>
<span class="opinion" v-if="t.opinion">「{{ t.opinion }}」</span>
<span class="opinion" v-if="t.edgeName">去向:{{ t.edgeName }}</span>
</div>
<div class="time">{{ fmtTime(t.time || t.handleTime || t.arriveTime) }}</div>
</TimelineItem>
</template>
</Timeline>
<div class="muted" v-if="!rows.length">暂无轨迹</div>
</div>
</template>
<style scoped>
.round {
margin: 4px 0 10px 6px;
padding: 2px 8px;
display: inline-block;
font-size: 12px;
color: #2d8cf0;
background: #f0f7ff;
border-left: 3px solid #2d8cf0;
border-radius: 2px;
}
.line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
.node { font-weight: 600; }
.opinion { color: #666; }
.time { color: #aaa; font-size: 12px; }
.muted { color: #999; }
</style>