应用页规范(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:
+36
-1
@@ -25,6 +25,14 @@ export const INSTANCE_STATUS: Record<number, string> = {
|
||||
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> = {
|
||||
0: "待办",
|
||||
1: "同意",
|
||||
@@ -32,6 +40,21 @@ export const TASK_STATUS: Record<number, string> = {
|
||||
3: "失效",
|
||||
4: "退回",
|
||||
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 {
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, useSlots } from 'vue'
|
||||
import { computed, ref, useSlots, watch } from 'vue'
|
||||
import Utils from '@/utils'
|
||||
import { OperatorEnum } from '@/types'
|
||||
|
||||
@@ -68,18 +68,30 @@ const {
|
||||
total: res => props.formatResponse(res).total,
|
||||
data: res => props.formatResponse(res).list,
|
||||
initialPage: 1,
|
||||
initialPageSize: 10,
|
||||
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
|
||||
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
|
||||
immediate: props.autoLoad,
|
||||
}
|
||||
)
|
||||
|
||||
// 数据重载后清空勾选,避免批量操作用到旧选中行
|
||||
watch(resp, () => {
|
||||
if (selectList.value.length) {
|
||||
selectList.value = []
|
||||
emit('selection-change', selectList.value)
|
||||
}
|
||||
})
|
||||
|
||||
const list = computed(() => {
|
||||
if (!resp.value) return []
|
||||
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
||||
})
|
||||
|
||||
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() {
|
||||
page.value = 1
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** 列表增删改查后的智能翻页语义(TablePageAlovaNew.reloadData 参数,与 UserBase 同源) */
|
||||
export enum OperatorEnum {
|
||||
add,
|
||||
del,
|
||||
edit,
|
||||
sel,
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export interface Pos {
|
||||
|
||||
/** 拓扑分层布局:入参 {nodes,edges},返回 nodeId → 位置 */
|
||||
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 edges: any[] = data?.edges || [];
|
||||
if (!nodes.length) return {};
|
||||
@@ -66,3 +73,92 @@ export function layout(data: any): Record<string, 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
@@ -1,21 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
// 流程示意图(方案 §4):深链页不挂菜单;query: flowGuid|instanceId|defId(三选一)+ mode(dept|user|缺省 auto)。
|
||||
// 手写 SVG 分层布局(depth(to)=max(depth(from))+1,从左到右布列、同列按定义顺序纵排),零图形库依赖。
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
// 流程示意图(方案 §4):深链页不挂菜单;query: flowGuid|instanceId|defId(三选一)+ mode(dept|user,缺省 user)。
|
||||
// 三视图(2026-09-17):auto=示意图(手写 SVG 自动布局,零图形库)|canvas=画布视图(bpmn-js 只读渲染,
|
||||
// 复用设计器的 contentJson→BPMN 通道,与示意图视觉区分)|img=进度图(服务端 PNG 流)。
|
||||
// 显示口径(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 { flowApi } from "@/api/module/flow";
|
||||
import SourceDrawer from "@/views/flow/components/SourceDrawer.vue";
|
||||
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 router = useRouter();
|
||||
const loading = ref(false);
|
||||
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);
|
||||
|
||||
/** 出图形态(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 showImg = computed(() => chartStyle.value === "img");
|
||||
const showCanvas = computed(() => chartStyle.value === "canvas");
|
||||
|
||||
/** 进度图 img 直嵌源(同 InstanceDetail 的"CAS 验证码式",img 不走 alova 显式带 serviceHost;管理面 defId 走 admin 端点) */
|
||||
const pngSrc = computed(() => {
|
||||
@@ -29,67 +46,20 @@ const pngSrc = computed(() => {
|
||||
|
||||
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
|
||||
|
||||
// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用)
|
||||
import { layout as autoLayout, NODE_BOX, SLOT_W, SLOT_H, COL_GAP, ROW_GAP, PAD, Pos } from "@/utils/flowLayout";
|
||||
// 布局常量与拓扑分层算法在 src/utils/flowLayout.ts(与设计器画布共用;并行分支组垂直居中)
|
||||
import { layout as autoLayout, PAD, Pos } from "@/utils/flowLayout";
|
||||
|
||||
/** 位置计算:画布视图用设计器保存的画布坐标(vo.layout,bpmn 画布 x/y),示意图忽略坐标强制自动布局;
|
||||
* 画布视图缺坐标(旧定义未在设计器编辑过)时回退自动布局 */
|
||||
const positions = computed<Record<string, Pos>>(() => {
|
||||
const nodes: any[] = vo.value?.nodes || [];
|
||||
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 });
|
||||
return autoLayout({ nodes: vo.value?.nodes || [], edges: vo.value?.edges || [] });
|
||||
});
|
||||
|
||||
/** 保存坐标模式的画布总尺寸(0=未启用,回退自动布局的行列估算) */
|
||||
const vbExtent = ref({ w: 0, h: 0 });
|
||||
|
||||
const vbSize = computed(() => {
|
||||
if (vbExtent.value.w > 0) {
|
||||
return { w: vbExtent.value.w, h: vbExtent.value.h };
|
||||
}
|
||||
const ps = Object.values(positions.value);
|
||||
if (!ps.length) return { w: 480, h: 160 };
|
||||
let maxD = 0;
|
||||
let maxR = 0;
|
||||
ps.forEach((p) => { maxD = Math.max(maxD, p.depth); maxR = Math.max(maxR, p.row); });
|
||||
return {
|
||||
w: PAD * 2 + (maxD + 1) * SLOT_W + maxD * COL_GAP,
|
||||
h: PAD * 2 + (maxR + 1) * SLOT_H + maxR * ROW_GAP,
|
||||
};
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
ps.forEach((p) => { maxX = Math.max(maxX, p.x + p.w); maxY = Math.max(maxY, p.y + p.h); });
|
||||
return { w: maxX + PAD, h: maxY + PAD };
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/** 出图形态切换;进出进度图无需重拉(数据 vo 共用),只换渲染形态 */
|
||||
/** 出图形态切换;进出画布/进度图无需重拉(数据 vo 共用),只换渲染形态 */
|
||||
function switchChartStyle(val: string) {
|
||||
chartStyle.value = val;
|
||||
}
|
||||
|
||||
const viewMode = computed(() => (mode.value === "user" ? "user" : "dept"));
|
||||
|
||||
/** 口径切换:写回 query(可分享/刷新保持),并按新口径重拉出图数据 */
|
||||
function switchMode(val: string) {
|
||||
mode.value = val;
|
||||
router.replace({ query: { ...route.query, mode: val } });
|
||||
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() {
|
||||
const { flowGuid, instanceId, defId } = route.query as Record<string, string>;
|
||||
if (!flowGuid && !instanceId && !defId) {
|
||||
@@ -174,20 +178,111 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出 PNG(方案 §4.4):serialize SVG→Image→canvas 2x→toBlob 下载;失败提示右键另存 */
|
||||
function exportPng() {
|
||||
const svg = svgRef.value;
|
||||
if (!svg) return;
|
||||
// ==================== 画布视图(bpmn-js 只读渲染,2026-09-17 与示意图区分) ====================
|
||||
|
||||
const bpmnHost = ref<HTMLDivElement | null>(null);
|
||||
// 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("导出失败,请右键图片另存");
|
||||
try {
|
||||
const xml = new XMLSerializer().serializeToString(svg);
|
||||
const url = URL.createObjectURL(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }));
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const scale = 2;
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = vbSize.value.w * scale;
|
||||
canvas.height = vbSize.value.h * scale;
|
||||
canvas.width = (img.naturalWidth || vbSize.value.w) * scale;
|
||||
canvas.height = (img.naturalHeight || vbSize.value.h) * scale;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
@@ -243,6 +338,7 @@ watch(() => route.query, () => load());
|
||||
<Radio label="user">人员视图</Radio>
|
||||
</RadioGroup>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -250,6 +346,16 @@ watch(() => route.query, () => load());
|
||||
<div class="canvas img-box" v-if="vo && showImg">
|
||||
<img :src="pngSrc" alt="流程示意图" />
|
||||
</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">
|
||||
<svg :width="vbSize.w" :height="vbSize.h" :viewBox="`0 0 ${vbSize.w} ${vbSize.h}`"
|
||||
xmlns="http://www.w3.org/2000/svg" ref="svgRef"
|
||||
@@ -271,7 +377,7 @@ watch(() => route.query, () => load());
|
||||
:y="edgeLabelAnchor(e).y - 9"
|
||||
:width="e.name.length * 12 + 12" height="18"
|
||||
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>
|
||||
</g>
|
||||
</template>
|
||||
@@ -291,7 +397,7 @@ watch(() => route.query, () => load());
|
||||
<g v-else-if="n.type === 'PARALLEL_SPLIT' || n.type === 'PARALLEL_JOIN'">
|
||||
<title>{{ n.subLabel || n.label }}</title>
|
||||
<polygon :points="diamondPoints(n)"
|
||||
:fill="STATE_STROKE[nodeState(n) || ''] ? '#fff8f0' : '#fff8f0'"
|
||||
fill="#fff8f0"
|
||||
:stroke="nodeState(n) === 'active' ? '#ed4014' : '#ff9900'"
|
||||
stroke-width="1.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>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="muted empty">未指定流程或加载失败</div>
|
||||
|
||||
<!-- 源码:只读查看 + 复制(复制出来可直接做新建流程的底稿) -->
|
||||
<SourceDrawer v-model="sourceVisible" :text="sourceText" :subtitle="sourceTitle" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -337,4 +446,17 @@ watch(() => route.query, () => load());
|
||||
0%, 100% { filter: none; }
|
||||
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>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 办件详情:状态 + 业务摘要 + 流程轨迹时间线 + 分支会签进度(概览 §9.4 进度图数据源)
|
||||
// 轨迹两档显示(2026-09-18):mode=user(人员,缺省)|dept(科室),写进 query 便于分享/刷新保持,
|
||||
// 并随"流程示意图"跳转带给图示页(两边口径一致)。
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -10,6 +13,14 @@ const router = useRouter();
|
||||
const loading = ref(false);
|
||||
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 raw = detail.value?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
@@ -56,11 +67,16 @@ const branchText = computed(() => {
|
||||
|
||||
/** 流程示意图图片直嵌(2026-09-16 "CAS 验证码式":后端 /diagram.png 返回 PNG 流,img 直显)。
|
||||
* img 不走 alova(无 baseUrl 拼接),须显式带 serviceHost——/{ctx}/front/ 子路径部署下
|
||||
* 裸 /api 会落到网关根导致 404。 */
|
||||
* 裸 /api 会落到网关根导致 404。mode 跟随本页选择,缩略图与轨迹口径一致。 */
|
||||
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) {
|
||||
return INSTANCE_STATUS[s] || s;
|
||||
}
|
||||
@@ -84,7 +100,7 @@ onMounted(async () => {
|
||||
<div class="head">
|
||||
<span class="title">{{ detail.bizTitle }}</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -121,36 +137,25 @@ onMounted(async () => {
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<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>
|
||||
<img class="diagram-img" :src="diagramSrc" alt="流程示意图" />
|
||||
</Card>
|
||||
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>流程轨迹</template>
|
||||
<Timeline>
|
||||
<TimelineItem v-for="t in detail.trace" :key="t.taskId"
|
||||
:color="t.status === 1 ? 'green' : t.status === 2 ? 'red' : t.status === 0 ? 'blue' : 'gray'">
|
||||
<span class="node">{{ t.nodeName || t.nodeId }}</span>
|
||||
<Tag size="small">{{ t.assigneeName || t.assigneeId }}</Tag>
|
||||
<Tag size="small" v-if="t.status !== 1">{{ taskStatusName(t.status) }}</Tag>
|
||||
<span class="opinion" v-if="t.opinion">「{{ t.opinion }}」</span>
|
||||
<div class="time">{{ (t.handleTime || t.arriveTime || "").replace("T", " ") }}</div>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
<template #title>
|
||||
流程轨迹
|
||||
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
|
||||
<Radio label="user">按人员</Radio>
|
||||
<Radio label="dept">按科室</Radio>
|
||||
</RadioGroup>
|
||||
</template>
|
||||
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
|
||||
</Card>
|
||||
</div>
|
||||
<div v-else class="page muted">加载中…</div>
|
||||
</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>
|
||||
.page { padding: 12px 16px; }
|
||||
.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-img { max-width: 100%; }
|
||||
.diagram-more { float: right; font-size: 12px; font-weight: normal; margin-right: 16px; }
|
||||
.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; }
|
||||
</style>
|
||||
|
||||
@@ -1,58 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
// 我的办件(提交人视角):列表 + 撤回/催办/详情
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
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";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const size = ref(10);
|
||||
const total = ref(0);
|
||||
const status = ref<number | undefined>(undefined);
|
||||
const rows = ref<any[]>([]);
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isPage: true }));
|
||||
const dataParams = ref<{ status?: number }>({ status: undefined });
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||
{ title: "业务类型", key: "bizType", width: 110 },
|
||||
{ title: "状态", width: 100, render: (h: any, p: any) => {
|
||||
const color = p.row.status === 10 ? "blue" : p.row.status === 20 ? "green" : p.row.status === 30 ? "red" : "gray";
|
||||
return h("Tag", { color }, () => INSTANCE_STATUS[p.row.status] || p.row.status);
|
||||
} },
|
||||
{ title: "状态", width: 100, align: "center", render: (h: any, p: any) =>
|
||||
h("Tag", { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
|
||||
{ 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.finishTime || "").replace("T", " ") || "-") },
|
||||
{ title: "操作", width: 150, render: (h: any, p: any) => {
|
||||
const btns = [Utils.operatingTextHandle(h, "详情", "", () => router.push(`/instance/${p.row.instanceId}?pid=101`))];
|
||||
{ title: "操作", width: 180, align: "center", fixed: "right", render: (h: any, p: any) => {
|
||||
const btns = [Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=101`))];
|
||||
if (p.row.status === 10) {
|
||||
btns.push(Utils.operatingTextHandle(h, "撤回", "", () => onWithdraw(p.row)));
|
||||
btns.push(Utils.operatingTextHandle(h, "催办", "", () => onUrge(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "撤回", "icon-yaohao", () => onWithdraw(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "催办", "icon-fasong", () => onUrge(p.row)));
|
||||
}
|
||||
return btns;
|
||||
} },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data: any = await flowApi.my({ status: status.value, page: page.value, size: size.value }).send();
|
||||
rows.value = data?.content || [];
|
||||
total.value = data?.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
||||
const tableApi = (p: { pageIndex: number; pageSize: number }) =>
|
||||
flowApi.my({ status: dataParams.value.status || undefined, page: p.pageIndex, size: p.pageSize });
|
||||
|
||||
function onSearch() {
|
||||
tableRef.value?.refreshData();
|
||||
}
|
||||
|
||||
async function onWithdraw(row: any) {
|
||||
await MyConfirm({
|
||||
title: "撤回",
|
||||
msg: `确定撤回办件「${row.bizTitle}」吗?`,
|
||||
info: "撤回后流程终止,不可恢复",
|
||||
async onOk() {
|
||||
try {
|
||||
await flowApi.withdraw(row.instanceId).send();
|
||||
Message.success("已撤回");
|
||||
load();
|
||||
tableRef.value?.reloadData(OperatorEnum.edit);
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "撤回失败(可能已有任务被办理)");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onUrge(row: any) {
|
||||
@@ -63,34 +66,20 @@ async function onUrge(row: any) {
|
||||
Message.error(e?.message || "催办失败");
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
page.value = p;
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="filter">
|
||||
<Select v-model="status" clearable placeholder="状态" style="width: 140px" @on-change="load">
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="dataParams.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onSearch">
|
||||
<Option :value="10" label="审批中" />
|
||||
<Option :value="20" label="已完成" />
|
||||
<Option :value="30" label="已拒绝" />
|
||||
<Option :value="40" label="已撤回" />
|
||||
</Select>
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :loading="loading" stripe />
|
||||
<div class="pager">
|
||||
<Page :total="total" :current="page" :page-size="size" show-total @on-change="onPage" />
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
.filter { margin-bottom: 10px; }
|
||||
.pager { margin-top: 12px; text-align: right; }
|
||||
</style>
|
||||
|
||||
@@ -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<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 detail = ref<any>(null);
|
||||
const myTaskId = ref("");
|
||||
@@ -230,21 +239,16 @@ onMounted(load);
|
||||
|
||||
<!-- 流程轨迹 -->
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>流程轨迹</template>
|
||||
<Timeline>
|
||||
<TimelineItem v-for="t in detail.trace" :key="t.taskId"
|
||||
:color="t.status === 1 ? 'green' : t.status === 2 ? 'red' : t.status === 0 ? 'blue' : t.status === 4 ? 'orange' : t.status === 5 ? 'cyan' : 'gray'">
|
||||
<span class="node">{{ t.nodeName || t.nodeId }}</span>
|
||||
<Tag size="small">{{ t.assigneeName || t.assigneeId }}</Tag>
|
||||
<Tag size="small" v-if="t.status === 4" color="orange">已退回</Tag>
|
||||
<Tag size="small" v-if="t.status === 5" color="cyan">已移交</Tag>
|
||||
<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>
|
||||
<template #title>
|
||||
流程轨迹
|
||||
<RadioGroup class="trace-mode" :model-value="mode" size="small" type="button" @on-change="switchMode">
|
||||
<Radio label="user">按人员</Radio>
|
||||
<Radio label="dept">按科室</Radio>
|
||||
</RadioGroup>
|
||||
</template>
|
||||
<TraceTimeline :trace="detail.trace || []" :mode="mode" />
|
||||
<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>
|
||||
|
||||
<!-- 退回弹窗 -->
|
||||
@@ -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; }
|
||||
</style>
|
||||
|
||||
@@ -1,88 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
// 工作台:我的待办 / 我的已办(概览 §9.4 主路径查询,走 assignee 索引)
|
||||
import { ref, onMounted } from "vue";
|
||||
// 工作台:我的待办 / 我的已办(概览 §9.4 主路径查询,走 assignee 索引)。
|
||||
// 规范骨架(bsch应用页规范 §1):tabs-wrapper + Tabs type=card,Tab 切换只 v-show 内容区,分页状态各自独立
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { flowApi, TASK_STATUS, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
||||
import { OperatorEnum } from "@/types";
|
||||
import { flowApi, TASK_STATUS, TASK_STATUS_COLOR, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
import Utils from "@/utils";
|
||||
|
||||
const router = useRouter();
|
||||
const tab = ref<"todo" | "done">("todo");
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const size = ref(10);
|
||||
const total = ref(0);
|
||||
const rows = ref<any[]>([]);
|
||||
const todoTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
const doneTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
// tabs 页:表格高度在常规骨架上再扣 Tab 条
|
||||
const tableHeight = ref(Utils.useTableHeight({ isTabs: true, isOperat: true, isPage: true }));
|
||||
|
||||
const todoColumns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||
{ title: "当前节点", key: "nodeName", width: 160 },
|
||||
{ title: "提交人", key: "submitter", width: 100, render: (h: any, p: any) => h("span", p.row.submitterName || "-") },
|
||||
{ title: "到达时间", key: "arriveTime", 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, render: (h: any, p: any) => h("span", p.row.submitterName || p.row.assigneeName || "-") },
|
||||
{ title: "到达时间", width: 170, render: (h: any, p: any) => h("span", (p.row.arriveTime || "").replace("T", " ")) },
|
||||
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
|
||||
Utils.operatingHandle(h, "办理", "icon-edit", () => goHandle(p.row)) },
|
||||
];
|
||||
|
||||
const doneColumns = [
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||
{ title: "节点", key: "nodeName", width: 150 },
|
||||
{ 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: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 200 },
|
||||
{
|
||||
// 同一办件被本人办理多次(多节点办理/退回重办)时列表只出一行(后端按办件去重),
|
||||
// 累计次数在这里提示,避免以为记录丢了
|
||||
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: 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) {
|
||||
return row.submitterName || "-";
|
||||
}
|
||||
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
||||
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) {
|
||||
router.push(`/task/${row.taskId}?pid=100`);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 两个内容区 v-show 共存,已办首次切换才拉取(保持原惰性加载行为)
|
||||
const doneLoaded = ref(false);
|
||||
function switchTab(name: string) {
|
||||
tab.value = name as any;
|
||||
page.value = 1;
|
||||
load();
|
||||
if (name === "done" && !doneLoaded.value) {
|
||||
doneLoaded.value = true;
|
||||
doneTableRef.value?.reloadData(OperatorEnum.sel);
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
page.value = p;
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<Tabs :value="tab" @on-click="switchTab">
|
||||
<div class="tabs-wrapper">
|
||||
<Tabs type="card" :value="tab" @on-click="switchTab">
|
||||
<TabPane label="我的待办" name="todo" />
|
||||
<TabPane label="我的已办" name="done" />
|
||||
</Tabs>
|
||||
|
||||
<Table :columns="tab === 'todo' ? todoColumns : doneColumns" :data="rows" :loading="loading" stripe>
|
||||
<template #empty>暂无数据</template>
|
||||
</Table>
|
||||
<div class="pager">
|
||||
<Page :total="total" :current="page" :page-size="size" show-total @on-change="onPage" />
|
||||
<div class="main-container" v-show="tab === 'todo'">
|
||||
<TablePageAlovaNew ref="todoTableRef" :dataApi="todoApi" :columns="todoColumns" :tableHeight="tableHeight" />
|
||||
</div>
|
||||
<div class="main-container" v-show="tab === 'done'">
|
||||
<TablePageAlovaNew ref="doneTableRef" :dataApi="doneApi" :columns="doneColumns" :tableHeight="tableHeight" :autoLoad="false" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
.pager { margin-top: 12px; text-align: right; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<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 { 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 Utils from "@/utils";
|
||||
|
||||
@@ -11,105 +15,125 @@ const EVENT_OPTIONS = ["flow.submitted", "flow.task.created", "flow.task.handled
|
||||
const tab = ref("regs");
|
||||
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 regColumns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "应用 appKey", key: "appKey", width: 110 },
|
||||
{ title: "流程 GUID", key: "flowGuid", width: 210 },
|
||||
{ title: "回调地址", key: "callbackUrl", minWidth: 240 },
|
||||
{ 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: 220, render: (h: any, p: any) => {
|
||||
return [
|
||||
Utils.operatingTextHandle(h, p.row.status === 0 ? "停用" : "启用", "", () => onRegStatus(p.row, p.row.status === 0)),
|
||||
Utils.operatingTextHandle(h, "重置secret", "text-error", () => onResetSecret(p.row)),
|
||||
];
|
||||
} },
|
||||
{ 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: 140, align: "center", fixed: "right", render: (h: any, p: any) => [
|
||||
Utils.operatingHandle(h, p.row.status === 0 ? "停用" : "启用", p.row.status === 0 ? "icon-stop" : "icon-shiyong", () => onRegStatus(p.row, p.row.status === 0)),
|
||||
Utils.operatingHandle(h, "重置secret", "icon-shezhi", () => onResetSecret(p.row)),
|
||||
] },
|
||||
];
|
||||
|
||||
// ---- 推送日志 ----
|
||||
const logs = ref<any[]>([]);
|
||||
const logPage = ref(1);
|
||||
const logTotal = ref(0);
|
||||
const logFilter = ref<any>({ status: null, event: "", instanceId: null });
|
||||
const selectedLogIds = ref<number[]>([]);
|
||||
const logTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
const logFilter = ref<any>({ status: null, event: "", instanceId: "" });
|
||||
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: "instanceId", width: 80 },
|
||||
{ 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";
|
||||
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: "下次重推", width: 160, render: (h: any, p: any) => h("span", (p.row.nextRetryTime || "").replace("T", " ") || "-") },
|
||||
];
|
||||
|
||||
// ---- 挂起列表(持续重试中:待推且重试达阈值;人工判定无意义 → 标记死信止血) ----
|
||||
const hungs = ref<any[]>([]);
|
||||
const hungPage = ref(1);
|
||||
const hungTotal = ref(0);
|
||||
const hungLoading = ref(false);
|
||||
const hungColumns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "事件", key: "event", width: 150 },
|
||||
{ title: "办件", key: "instanceId", width: 80 },
|
||||
{ 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: "下次重推", width: 160, render: (h: any, p: any) => h("span", (p.row.nextRetryTime || "").replace("T", " ") || "-") },
|
||||
{ title: "操作", width: 120, render: (h: any, p: any) =>
|
||||
Utils.operatingTextHandle(h, "标记死信", "text-error", () => onMarkDead(p.row)) },
|
||||
{ title: "操作", width: 120, align: "center", fixed: "right", render: (h: any, p: any) =>
|
||||
Utils.operatingHandle(h, "标记死信", "icon-ashbin", () => onMarkDead(p.row)) },
|
||||
];
|
||||
|
||||
async function loadRegs() {
|
||||
regs.value = (await flowApi.regList().send()) || [];
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: any = { page: logPage.value, size: 10 };
|
||||
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
||||
const logApi = (p: { pageIndex: number; pageSize: number }) => {
|
||||
const params: any = { page: p.pageIndex, size: p.pageSize };
|
||||
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.instanceId) params.instanceId = logFilter.value.instanceId;
|
||||
const data: any = await flowApi.callbackLogs(params).send();
|
||||
logs.value = data?.content || [];
|
||||
logTotal.value = data?.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
return flowApi.callbackLogs(params);
|
||||
};
|
||||
|
||||
function onLogSearch() {
|
||||
logTableRef.value?.refreshData();
|
||||
}
|
||||
|
||||
async function loadHungs() {
|
||||
hungLoading.value = true;
|
||||
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 || [];
|
||||
hungTotal.value = data?.total || 0;
|
||||
} finally {
|
||||
hungLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegStatus(row: any, disable: boolean) {
|
||||
function onRegStatus(row: any, disable: boolean) {
|
||||
const doStatus = async () => {
|
||||
await flowApi.regStatus(row.id, !disable).send();
|
||||
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) {
|
||||
MyConfirm({
|
||||
title: "重置secret",
|
||||
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) {
|
||||
Modal.confirm({
|
||||
MyConfirm({
|
||||
title: "标记死信",
|
||||
content: `办件 ${row.instanceId} 事件 ${row.event} 已持续重试 ${row.retryCount} 次。确认该事件已无意义、停止重试并转入死信?`,
|
||||
onOk: async () => {
|
||||
msg: `办件 ${row.instanceId} 事件 ${row.event} 已持续重试 ${row.retryCount} 次。确认该事件已无意义、停止重试并转入死信?`,
|
||||
info: "标记后可在推送日志(死信)中重推复活",
|
||||
async onOk() {
|
||||
await flowApi.callbackMarkDead(row.id).send();
|
||||
Message.success("已标记死信(可在死信列表重推复活)");
|
||||
loadHungs();
|
||||
@@ -117,70 +141,82 @@ function onMarkDead(row: any) {
|
||||
});
|
||||
}
|
||||
|
||||
function onSelectionChange(selection: any[]) {
|
||||
selectedLogIds.value = selection.map((r) => r.id);
|
||||
}
|
||||
|
||||
async function onRepush() {
|
||||
if (!selectedLogIds.value.length) {
|
||||
async function onRepush(checkList: number[]) {
|
||||
if (!checkList.length) {
|
||||
Message.warning("请先勾选日志行");
|
||||
return;
|
||||
}
|
||||
await flowApi.callbackRepush(selectedLogIds.value).send();
|
||||
await flowApi.callbackRepush(checkList).send();
|
||||
Message.success("已触发重推");
|
||||
loadLogs();
|
||||
// 重推后状态会变,留在当前页刷新
|
||||
logTableRef.value?.reloadData(OperatorEnum.edit);
|
||||
}
|
||||
|
||||
// 三个内容区 v-show 共存,日志/挂起首次切换才拉取(保持原惰性加载行为)
|
||||
const logsLoaded = ref(false);
|
||||
const hungLoaded = ref(false);
|
||||
function switchTab(name: string) {
|
||||
tab.value = name;
|
||||
if (name === "logs") loadLogs();
|
||||
if (name === "hung") loadHungs();
|
||||
if (name === "logs" && !logsLoaded.value) {
|
||||
logsLoaded.value = true;
|
||||
logTableRef.value?.reloadData(OperatorEnum.sel);
|
||||
}
|
||||
if (name === "hung" && !hungLoaded.value) {
|
||||
hungLoaded.value = true;
|
||||
loadHungs();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRegs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<Tabs :value="tab" @on-click="switchTab">
|
||||
<TabPane label="回调注册" name="regs">
|
||||
<Table :columns="regColumns" :data="regs" :loading="loading" stripe />
|
||||
</TabPane>
|
||||
<TabPane label="推送日志" name="logs">
|
||||
<div class="filter">
|
||||
<Select v-model="logFilter.status" clearable placeholder="状态" style="width: 130px">
|
||||
<div class="tabs-wrapper">
|
||||
<Tabs type="card" :value="tab" @on-click="switchTab">
|
||||
<TabPane label="回调注册" name="regs" />
|
||||
<TabPane label="推送日志" name="logs" />
|
||||
<TabPane label="挂起中" name="hung" />
|
||||
</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" clearable placeholder="事件" style="width: 190px">
|
||||
<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 v-model="logFilter.instanceId" placeholder="办件ID" style="width: 120px" clearable />
|
||||
<Button @click="logPage = 1; loadLogs()">查询</Button>
|
||||
<Button type="primary" @click="onRepush">重推选中</Button>
|
||||
<Input class="fr" v-model="logFilter.instanceId" placeholder="办件ID" search suffix="ios-search"
|
||||
style="width: 200px" @on-search="onLogSearch" @on-clear="onLogSearch" />
|
||||
</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(); }" />
|
||||
<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>
|
||||
</TabPane>
|
||||
<TabPane label="挂起中" name="hung">
|
||||
|
||||
<!-- 挂起中:无分页 -->
|
||||
<div class="main-container" v-show="tab === '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(); }" />
|
||||
<Table :columns="hungColumns" :data="hungs" :loading="hungLoading" class="link-table table-bottom-box"
|
||||
:height="hungTableHeight" no-data-text="暂无数据" />
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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; }
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
// 流程定义管理:列表 + 发布/停用/升版本/版本历史/图示。
|
||||
// 新建与编辑走独立的全屏可视化编辑器(/admin/defs/editor,钉钉式卡片流,见《流程中心流程编辑器方案.md》);
|
||||
// 本页不再直接编辑 JSON 文本(原 M1 textarea 已由编辑器取代,编辑器内置"源码"高级模式兜底)。
|
||||
// 流程定义管理:列表 + 发布/启用/停用/升版本/版本历史/图示/源码。
|
||||
// 新建与编辑走独立的全屏可视化编辑器(/admin/defs/editor,钉钉式卡片流,见《流程编辑器方案.md》);
|
||||
// 本页不再直接编辑 JSON 文本(编辑器内置"源码"高级模式兜底);行内「源码」为只读查看+复制。
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
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 Utils from "@/utils";
|
||||
|
||||
@@ -15,28 +17,40 @@ const loading = ref(false);
|
||||
const rows = ref<any[]>([]);
|
||||
const versionVisible = ref(false);
|
||||
const versions = ref<any[]>([]);
|
||||
// 源码抽屉(列表行已带回 contentJson,无需再请求)
|
||||
const sourceVisible = ref(false);
|
||||
const sourceText = ref("");
|
||||
const sourceTitle = ref("");
|
||||
// 操作按钮区 + 表格,无搜索、无分页
|
||||
const tableHeight = ref(Utils.useTableHeight({ isOperat: true }));
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "流程", key: "name", minWidth: 180 },
|
||||
{ title: "编码", key: "code", width: 160 },
|
||||
{ title: "版本", key: "version", width: 70 },
|
||||
{ title: "状态", width: 90, render: (h: any, p: any) => {
|
||||
{ title: "版本", key: "version", width: 70, align: "center" },
|
||||
{ title: "状态", width: 90, align: "center", render: (h: any, p: any) => {
|
||||
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);
|
||||
} },
|
||||
{ title: "GUID", key: "flowGuid", width: 200 },
|
||||
{ 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[] = [];
|
||||
if (p.row.status === 0) {
|
||||
btns.push(Utils.operatingTextHandle(h, "编辑", "", () => onEdit(p.row)));
|
||||
btns.push(Utils.operatingTextHandle(h, "发布", "", () => onPublish(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "编辑", "icon-edit", () => onEdit(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "发布", "icon-fasong", () => onPublish(p.row)));
|
||||
} else if (p.row.status === 1) {
|
||||
btns.push(Utils.operatingTextHandle(h, "升版本", "", () => onNewVersion(p.row)));
|
||||
btns.push(Utils.operatingTextHandle(h, "停用", "text-error", () => onOffline(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "升版本", "icon-tianjia", () => onNewVersion(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.operatingTextHandle(h, "版本历史", "", () => onVersions(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "图示", "icon-tupian", () => onDiagram(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "源码", "icon-liebiao", () => onSource(p.row)));
|
||||
btns.push(Utils.operatingHandle(h, "版本历史", "icon-shijian", () => onVersions(p.row)));
|
||||
return btns;
|
||||
} },
|
||||
];
|
||||
@@ -76,10 +90,52 @@ async function onPublish(row: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onOffline(row: any) {
|
||||
function onOffline(row: any) {
|
||||
MyConfirm({
|
||||
title: "停用",
|
||||
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) {
|
||||
@@ -88,18 +144,23 @@ async function onVersions(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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="filter">
|
||||
<Button type="primary" @click="onNew">新建流程</Button>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 操作按钮区:主操作靠左(bsch应用页规范 §1) -->
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="onNew">新建流程</Button>
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :loading="loading" stripe />
|
||||
|
||||
<Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
|
||||
:height="tableHeight" no-data-text="暂无数据" />
|
||||
|
||||
<Modal v-model="versionVisible" title="版本历史" width="640">
|
||||
<Table :columns="[
|
||||
@@ -107,12 +168,10 @@ onMounted(load);
|
||||
{ title: '名称', key: 'name' },
|
||||
{ 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" />
|
||||
]" :data="versions" size="small" no-data-text="暂无数据" />
|
||||
</Modal>
|
||||
|
||||
<SourceDrawer v-model="sourceVisible" :text="sourceText" :subtitle="sourceTitle" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
.filter { margin-bottom: 10px; }
|
||||
</style>
|
||||
|
||||
@@ -1,68 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
// 办件记录(管理端):全校办件查询
|
||||
import { ref, onMounted } from "vue";
|
||||
import { ref } from "vue";
|
||||
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";
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const total = ref(0);
|
||||
const filter = ref<any>({ status: null, keyword: "" });
|
||||
const rows = ref<any[]>([]);
|
||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isPage: true }));
|
||||
const dataParams = ref<{ status?: number; keyword?: string }>({ status: undefined, keyword: "" });
|
||||
|
||||
const columns = [
|
||||
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||
{ title: "业务", width: 170, render: (h: any, p: any) => h("span", `${p.row.bizType || "-"}/${p.row.bizId || "-"}`) },
|
||||
{ title: "状态", width: 100, render: (h: any, p: any) => {
|
||||
const c = p.row.status === 10 ? "blue" : p.row.status === 20 ? "green" : p.row.status === 30 ? "red" : "gray";
|
||||
return h("Tag", { color: c }, () => INSTANCE_STATUS[p.row.status] || p.row.status);
|
||||
} },
|
||||
{ title: "状态", width: 100, align: "center", render: (h: any, p: any) =>
|
||||
h("Tag", { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
|
||||
{ title: "提交人", key: "submitterName", width: 100 },
|
||||
{ title: "提交时间", width: 160, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
|
||||
{ title: "操作", width: 90, render: (h: any, p: any) =>
|
||||
Utils.operatingTextHandle(h, "详情", "", () => router.push(`/instance/${p.row.id}?pid=102`)) },
|
||||
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
|
||||
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.id}?pid=102`)) },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: any = { page: page.value, size: 10 };
|
||||
if (filter.value.status !== null && filter.value.status !== undefined) params.status = filter.value.status;
|
||||
if (filter.value.keyword) params.keyword = filter.value.keyword;
|
||||
const data: any = await flowApi.adminInstances(params).send();
|
||||
rows.value = data?.content || [];
|
||||
total.value = data?.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
||||
const tableApi = (p: { pageIndex: number; pageSize: number }) => {
|
||||
const params: any = { page: p.pageIndex, size: p.pageSize };
|
||||
if (dataParams.value.status) params.status = dataParams.value.status;
|
||||
if (dataParams.value.keyword) params.keyword = dataParams.value.keyword.trim();
|
||||
return flowApi.adminInstances(params);
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
function onSearch() {
|
||||
tableRef.value?.refreshData();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="filter">
|
||||
<Select v-model="filter.status" clearable placeholder="状态" style="width: 130px">
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<div class="search-wrap fix">
|
||||
<Select v-model="dataParams.status" class="search-wrap_sel" clearable placeholder="状态" @on-change="onSearch">
|
||||
<Option :value="10" label="审批中" />
|
||||
<Option :value="20" label="已完成" />
|
||||
<Option :value="30" label="已拒绝" />
|
||||
<Option :value="40" label="已撤回" />
|
||||
</Select>
|
||||
<Input v-model="filter.keyword" placeholder="标题关键字" style="width: 200px" clearable @on-enter="load" />
|
||||
<Button @click="page = 1; load()">查询</Button>
|
||||
<Input class="fr" v-model="dataParams.keyword" placeholder="标题关键字" search suffix="ios-search"
|
||||
style="width: 200px" @on-search="onSearch" @on-clear="onSearch" />
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :loading="loading" stripe />
|
||||
<div class="pager">
|
||||
<Page :total="total" :current="page" :page-size="10" show-total @on-change="(p: number) => { page = p; load(); }" />
|
||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
.filter { margin-bottom: 10px; display: flex; gap: 8px; }
|
||||
.pager { margin-top: 12px; text-align: right; }
|
||||
</style>
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
|
||||
const saveVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const form = ref<any>({ 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<any[]>([]);
|
||||
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<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() {
|
||||
form.value = { deptKey: "", deptName: "", userId: null, userName: "" };
|
||||
saveVisible.value = true;
|
||||
@@ -84,11 +98,9 @@ function onUserPick(u: any) {
|
||||
form.value.userName = u?.name || "";
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!form.value.deptKey.trim() || !form.value.userId) {
|
||||
Message.warning("科室编码与办理人必填");
|
||||
return;
|
||||
}
|
||||
function onSave() {
|
||||
formRef.value?.validate(async (valid: boolean) => {
|
||||
if (!valid) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await flowApi.mapSave({
|
||||
@@ -103,36 +115,66 @@ async function onSave() {
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onDisable(row: any) {
|
||||
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);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="filter">
|
||||
<Input v-model="deptKeyFilter" placeholder="按科室编码筛选" style="width: 200px" clearable @on-enter="load" />
|
||||
<Button @click="load">查询</Button>
|
||||
<Button type="primary" @click="onAdd">新增映射</Button>
|
||||
<Button type="info" :loading="groupLoading" @click="onGroupPreview">一键拉取教师组</Button>
|
||||
<div class="page-wrapper">
|
||||
<div class="main-container">
|
||||
<!-- 操作按钮区:主操作靠左、其余靠右(bsch应用页规范 §1) -->
|
||||
<div class="fix mb-12">
|
||||
<Button class="fl" type="primary" size="large" @click="onAdd">新增映射</Button>
|
||||
<Button class="fr" size="large" :loading="groupLoading" @click="onGroupPreview">一键拉取教师组</Button>
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :loading="loading" stripe />
|
||||
|
||||
<!-- 搜索区:关键字即输即搜,不设查询按钮 -->
|
||||
<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 :label-width="90">
|
||||
<FormItem label="科室编码">
|
||||
<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="办理人">
|
||||
<FormItem label="办理人" prop="userId">
|
||||
<UserSelectInput
|
||||
:model-value="form.userId"
|
||||
:display-name="form.userName"
|
||||
@@ -144,7 +186,7 @@ onMounted(load);
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="saveVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="onSave">保存</Button>
|
||||
<Button type="primary" :loading="saving" @click="onSave">提交</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
@@ -152,16 +194,12 @@ onMounted(load);
|
||||
<p style="margin-bottom: 8px; color: #808695">
|
||||
按学校组树中类型=教师组(type 7)的容器组取其子组作为科室(无子组时容器自身即科室),科室编码=UserBase 组 id,成员=组内用户。已存在且启用中的映射自动跳过。
|
||||
</p>
|
||||
<Table :columns="groupColumns" :data="groupRows" :loading="groupLoading" max-height="360" stripe />
|
||||
<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>
|
||||
</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;
|
||||
}
|
||||
|
||||
/** 查看器 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, "&").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 ` <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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user