Vue3 + Vite + view-ui-plus 前端源码,含流程工作台、实例详情、 任务处理、管理端(定义/实例/映射/回调)等页面。 保留远程 .gitea/workflows/build.yml CI 配置。
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
<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";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
|
||||
const route = useRoute();
|
||||
const loading = ref(false);
|
||||
const vo = ref<any>(null);
|
||||
const mode = ref<string>((route.query.mode as string) || "auto");
|
||||
const svgRef = ref<SVGSVGElement | null>(null);
|
||||
|
||||
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
|
||||
|
||||
// ==================== 布局常量(方案 §4.2/§4.3) ====================
|
||||
const SLOT_W = 200; // 节点槽位宽
|
||||
const SLOT_H = 88; // 节点槽位高
|
||||
const COL_GAP = 72; // 列间距(留给正交折线走线)
|
||||
const ROW_GAP = 24; // 行间距
|
||||
const PAD = 24;
|
||||
|
||||
const NODE_BOX: Record<string, { w: number; h: number }> = {
|
||||
START: { w: 104, h: 40 },
|
||||
END: { w: 104, h: 40 },
|
||||
APPROVAL: { w: 184, h: 64 },
|
||||
PARALLEL_SPLIT: { w: 128, h: 72 },
|
||||
PARALLEL_JOIN: { w: 128, h: 72 },
|
||||
};
|
||||
|
||||
interface Pos {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
depth: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
/** 拓扑分层布局:Kahn 消融入边,depth(to)=max(depth(from))+1;同列按定义顺序纵排 */
|
||||
function layout(data: any): Record<string, Pos> {
|
||||
const nodes: any[] = data?.nodes || [];
|
||||
const edges: any[] = data?.edges || [];
|
||||
if (!nodes.length) return {};
|
||||
const inDeg: Record<string, number> = {};
|
||||
nodes.forEach((n) => { inDeg[n.id] = 0; });
|
||||
edges.forEach((e) => { if (inDeg[e.to] !== undefined) inDeg[e.to]++; });
|
||||
const depth: Record<string, number> = {};
|
||||
const queue = nodes.filter((n) => !inDeg[n.id]).map((n) => n.id);
|
||||
queue.forEach((id) => { depth[id] = 0; });
|
||||
const order = [...queue];
|
||||
while (order.length) {
|
||||
const id = order.shift()!;
|
||||
edges.forEach((e) => {
|
||||
if (e.from !== id || inDeg[e.to] === undefined) return;
|
||||
depth[e.to] = Math.max(depth[e.to] ?? 0, (depth[id] ?? 0) + 1);
|
||||
if (--inDeg[e.to] === 0) order.push(e.to);
|
||||
});
|
||||
}
|
||||
const rowsIn: Record<number, number> = {};
|
||||
const pos: Record<string, Pos> = {};
|
||||
nodes.forEach((n) => {
|
||||
const d = depth[n.id] ?? 0;
|
||||
depth[n.id] = d;
|
||||
const row = rowsIn[d] ?? 0;
|
||||
rowsIn[d] = row + 1;
|
||||
const box = NODE_BOX[n.type] || NODE_BOX.APPROVAL;
|
||||
pos[n.id] = {
|
||||
x: PAD + d * (SLOT_W + COL_GAP) + (SLOT_W - box.w) / 2,
|
||||
y: PAD + row * (SLOT_H + ROW_GAP) + (SLOT_H - box.h) / 2,
|
||||
w: box.w,
|
||||
h: box.h,
|
||||
depth: d,
|
||||
row,
|
||||
};
|
||||
});
|
||||
return pos;
|
||||
}
|
||||
|
||||
const positions = computed<Record<string, Pos>>(() => layout(vo.value));
|
||||
|
||||
const vbSize = computed(() => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
function pos(n: any): Pos {
|
||||
return positions.value[n.id];
|
||||
}
|
||||
|
||||
/** 正交折线:源右侧中点 → 中线 → 目标左侧中点 */
|
||||
function edgePath(e: any): string {
|
||||
const a = positions.value[e.from];
|
||||
const b = positions.value[e.to];
|
||||
if (!a || !b) return "";
|
||||
const x1 = a.x + a.w;
|
||||
const y1 = a.y + a.h / 2;
|
||||
const x2 = b.x;
|
||||
const y2 = b.y + b.h / 2;
|
||||
const mid = (x1 + x2) / 2;
|
||||
return `M ${x1} ${y1} L ${mid} ${y1} L ${mid} ${y2} L ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
function diamondPoints(n: any): string {
|
||||
const p = pos(n);
|
||||
const cx = p.x + p.w / 2;
|
||||
const cy = p.y + p.h / 2;
|
||||
return `${cx},${p.y} ${p.x + p.w},${cy} ${cx},${p.y + p.h} ${p.x},${cy}`;
|
||||
}
|
||||
|
||||
function trunc(s: string | null, n: number): string {
|
||||
if (!s) return "";
|
||||
return s.length > n ? s.slice(0, n) + "…" : s;
|
||||
}
|
||||
|
||||
const viewMode = computed(() => (mode.value === "user" ? "user" : "dept"));
|
||||
|
||||
function switchMode(val: string) {
|
||||
mode.value = val;
|
||||
load();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const { flowGuid, instanceId, defId } = route.query as Record<string, string>;
|
||||
if (!flowGuid && !instanceId && !defId) {
|
||||
Message.warning("缺少参数:flowGuid / instanceId / defId");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
let method;
|
||||
if (defId) {
|
||||
method = flowApi.adminDefDiagram({ id: defId, mode: mode.value });
|
||||
} else if (flowGuid) {
|
||||
method = flowApi.diagram({ flowGuid, mode: mode.value });
|
||||
} else {
|
||||
method = flowApi.diagram({ instanceId, mode: mode.value });
|
||||
}
|
||||
vo.value = await method.send();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出 PNG(方案 §4.4):serialize SVG→Image→canvas 2x→toBlob 下载;失败提示右键另存 */
|
||||
function exportPng() {
|
||||
const svg = svgRef.value;
|
||||
if (!svg) 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;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) {
|
||||
URL.revokeObjectURL(url);
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
ctx.fillStyle = "#fff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
fallback();
|
||||
return;
|
||||
}
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${vo.value?.name || "流程"}-流程图.png`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 3000);
|
||||
}, "image/png");
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
fallback();
|
||||
};
|
||||
img.src = url;
|
||||
} catch {
|
||||
fallback();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
// 同路由仅 query 变化(/diagram?A → /diagram?B)时组件被复用不重挂载,补一次重拉
|
||||
watch(() => route.query, () => load());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="toolbar" v-if="vo">
|
||||
<span class="title">{{ vo.name }}</span>
|
||||
<Tag color="blue">v{{ vo.version }}</Tag>
|
||||
<Tag :color="vo.status === 1 ? 'green' : vo.status === 0 ? 'orange' : 'gray'">
|
||||
{{ DEF_STATUS[vo.status] || vo.status }}</Tag>
|
||||
<RadioGroup :model-value="viewMode" size="small" @on-change="switchMode">
|
||||
<Radio label="dept">科室视图</Radio>
|
||||
<Radio label="user">人员视图</Radio>
|
||||
</RadioGroup>
|
||||
<Button size="small" @click="exportPng">导出 PNG</Button>
|
||||
<span class="muted">示意图按流程定义 v{{ vo.version }} 生成</span>
|
||||
</div>
|
||||
|
||||
<div class="canvas" v-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"
|
||||
style="font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif">
|
||||
<defs>
|
||||
<marker id="flow-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
||||
<path d="M0,0 L8,4 L0,8 z" fill="#515a6e" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<path v-for="(e, i) in vo.edges" :key="'edge-' + i" :d="edgePath(e)"
|
||||
fill="none" stroke="#515a6e" stroke-width="1.5" marker-end="url(#flow-arrow)" />
|
||||
|
||||
<template v-for="n in vo.nodes" :key="n.id">
|
||||
<!-- START/END:胶囊 -->
|
||||
<g v-if="n.type === 'START' || n.type === 'END'">
|
||||
<rect :x="pos(n).x" :y="pos(n).y" :width="pos(n).w" :height="pos(n).h"
|
||||
:rx="pos(n).h / 2" :fill="n.type === 'START' ? '#19be6b' : '#808695'" />
|
||||
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + pos(n).h / 2 + 5"
|
||||
text-anchor="middle" fill="#fff" font-size="13">{{ n.label }}</text>
|
||||
</g>
|
||||
<!-- SPLIT/JOIN:菱形 -->
|
||||
<g v-else-if="n.type === 'PARALLEL_SPLIT' || n.type === 'PARALLEL_JOIN'">
|
||||
<title>{{ n.subLabel || n.label }}</title>
|
||||
<polygon :points="diamondPoints(n)" fill="#fff8f0" stroke="#ff9900" stroke-width="1.5" />
|
||||
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + pos(n).h / 2 + 5"
|
||||
text-anchor="middle" fill="#e46a0a" font-size="12">{{ n.label }}</text>
|
||||
</g>
|
||||
<!-- APPROVAL:圆角卡片两行文本 -->
|
||||
<g v-else>
|
||||
<title>{{ n.label }}{{ n.subLabel ? '\n' + n.subLabel : '' }}</title>
|
||||
<rect :x="pos(n).x" :y="pos(n).y" :width="pos(n).w" :height="pos(n).h"
|
||||
rx="8" fill="#fff" stroke="#2d8cf0" stroke-width="1.5" />
|
||||
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 27"
|
||||
text-anchor="middle" fill="#17233d" font-size="13" font-weight="600">
|
||||
{{ trunc(n.label, 10) }}
|
||||
</text>
|
||||
<text v-if="n.subLabel" :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 47"
|
||||
text-anchor="middle" fill="#808695" font-size="11">
|
||||
{{ trunc(n.subLabel, 15) }}
|
||||
</text>
|
||||
</g>
|
||||
</template>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="muted empty">未指定流程或加载失败</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.title { font-size: 16px; font-weight: 600; }
|
||||
.muted { color: #999; font-size: 12px; }
|
||||
.canvas { overflow-x: auto; background: #fafafa; border: 1px solid #e8eaec; border-radius: 6px; }
|
||||
.empty { padding: 40px; text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
// 办件详情:状态 + 业务摘要 + 流程轨迹时间线 + 分支会签进度(概览 §9.4 进度图数据源)
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
|
||||
const snapshotRows = computed(() => {
|
||||
const raw = detail.value?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
let obj: any = null;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [{ k: "摘要", v: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object") {
|
||||
return Object.entries(obj).map(([k, v]) => ({ k, v: v == null ? "-" : String(v) }));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
/** 业务表单数据(§3.4 只读渲染;解析失败降级一行原文;无 formData 时整块隐藏) */
|
||||
const formRows = computed(() => {
|
||||
const raw = detail.value?.formDataJson;
|
||||
if (raw === null || raw === undefined || raw === "") return [];
|
||||
let obj: any = null;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [{ k: "表单原文", v: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
return Object.entries(obj).map(([k, v]) => ({
|
||||
k,
|
||||
v: v == null ? "-"
|
||||
: Array.isArray(v)
|
||||
? (v.map((item: any) => item && typeof item === "object" ? item.fileName || JSON.stringify(item) : String(item)).join("、") || "-")
|
||||
: (typeof v === "object" ? JSON.stringify(v) : String(v)),
|
||||
}));
|
||||
}
|
||||
return [{ k: "表单原文", v: String(raw) }];
|
||||
});
|
||||
|
||||
const branchText = computed(() => {
|
||||
const branches = detail.value?.branches || [];
|
||||
if (!branches.length) return "";
|
||||
const arrived = branches.filter((b: any) => b.status !== 0).length;
|
||||
return `${arrived}/${branches.length} 分支已到达汇聚点`;
|
||||
});
|
||||
|
||||
function statusName(s: number) {
|
||||
return INSTANCE_STATUS[s] || s;
|
||||
}
|
||||
|
||||
function statusColor(s: number) {
|
||||
return s === 10 ? "blue" : s === 20 ? "green" : s === 30 ? "red" : "gray";
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
detail.value = await flowApi.detail(String(route.params.instanceId)).send();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" v-if="detail">
|
||||
<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>
|
||||
<span class="muted" v-if="branchText">{{ branchText }}</span>
|
||||
</div>
|
||||
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>基本信息</template>
|
||||
<div class="grid">
|
||||
<div class="row"><span class="k">流程</span><span>{{ detail.defCode }} v{{ detail.defVersion }}</span></div>
|
||||
<div class="row"><span class="k">提交人</span><span>{{ detail.submitterName }}</span></div>
|
||||
<div class="row"><span class="k">提交时间</span><span>{{ (detail.submitTime || "").replace("T", " ") }}</span></div>
|
||||
<div class="row"><span class="k">办结时间</span><span>{{ (detail.finishTime || "").replace("T", " ") || "-" }}</span></div>
|
||||
<div class="row" v-if="detail.bizUrl"><span class="k">业务详情</span>
|
||||
<a :href="detail.bizUrl" target="_blank">查看业务详情</a></div>
|
||||
</div>
|
||||
<template v-if="snapshotRows.length">
|
||||
<Divider size="small" />
|
||||
<div class="snapshot">
|
||||
<div v-for="row in snapshotRows" :key="row.k" class="row">
|
||||
<span class="k">{{ row.k }}</span>
|
||||
<span class="v">{{ row.v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="formRows.length">
|
||||
<Divider size="small">业务表单数据</Divider>
|
||||
<div class="snapshot">
|
||||
<div v-for="row in formRows" :key="row.k" class="row">
|
||||
<span class="k">{{ row.k }}</span>
|
||||
<span class="v">{{ row.v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</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>
|
||||
</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; }
|
||||
.head a { cursor: pointer; }
|
||||
.title { font-size: 16px; font-weight: 600; }
|
||||
.block { margin-bottom: 12px; }
|
||||
.grid .row { display: flex; padding: 3px 0; }
|
||||
.k { width: 120px; color: #888; flex-shrink: 0; }
|
||||
.v { white-space: pre-wrap; word-break: break-all; }
|
||||
.muted { color: #999; }
|
||||
.node { font-weight: 600; margin-right: 6px; }
|
||||
.opinion { color: #666; }
|
||||
.time { color: #aaa; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
// 我的办件(提交人视角):列表 + 撤回/催办/详情
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
|
||||
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 columns = [
|
||||
{ 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: "当前节点", 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: 180, render: (h: any, p: any) => {
|
||||
const btns = [h("Button", { size: "small", onClick: () => router.push(`/instance/${p.row.instanceId}`) }, () => "详情")];
|
||||
if (p.row.status === 10) {
|
||||
btns.push(h("Button", { size: "small", onClick: () => onWithdraw(p.row) }, () => "撤回"));
|
||||
btns.push(h("Button", { size: "small", onClick: () => onUrge(p.row) }, () => "催办"));
|
||||
}
|
||||
return h("div", { style: "display:flex;gap:4px" }, 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;
|
||||
}
|
||||
}
|
||||
|
||||
async function onWithdraw(row: any) {
|
||||
try {
|
||||
await flowApi.withdraw(row.instanceId).send();
|
||||
Message.success("已撤回");
|
||||
load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "撤回失败(可能已有任务被办理)");
|
||||
}
|
||||
}
|
||||
|
||||
async function onUrge(row: any) {
|
||||
try {
|
||||
await flowApi.urge(row.instanceId).send();
|
||||
Message.success("已催办");
|
||||
} catch (e: 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">
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
.filter { margin-bottom: 10px; }
|
||||
.pager { margin-top: 12px; text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
// 办理页(概览 §9.4):biz_snapshot 业务摘要区块 + formData 业务表单数据只读渲染(§3.4,
|
||||
// 非表单引擎——只读展示,原文留在业务方)+ "查看业务详情"跳 biz_url + 历史意见 + 同意/拒绝。
|
||||
// 一切审批入口的收敛点都是后端同一套 /api/flow/task API。
|
||||
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";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const taskId = computed(() => String(route.params.taskId || ""));
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
const myTaskId = ref("");
|
||||
const myTask = ref<any>(null);
|
||||
const opinion = ref("");
|
||||
const submitting = ref(false);
|
||||
|
||||
const snapshotRows = computed(() => {
|
||||
const raw = detail.value?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
let obj: any = null;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [{ k: "摘要", v: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object") {
|
||||
return Object.entries(obj).map(([k, v]) => ({ k, v: v == null ? "-" : String(v) }));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
/** 业务表单数据(§3.4):formDataJson 逐键值只读渲染;数组值按文件名/项连接;解析失败降级一行原文 */
|
||||
const formRows = computed(() => {
|
||||
const raw = detail.value?.formDataJson;
|
||||
if (raw === null || raw === undefined || raw === "") return [];
|
||||
let obj: any = null;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [{ k: "表单原文", v: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
return Object.entries(obj).map(([k, v]) => ({ k, v: formValueText(v) }));
|
||||
}
|
||||
return [{ k: "表单原文", v: String(raw) }];
|
||||
});
|
||||
|
||||
function formValueText(v: any): string {
|
||||
if (v === null || v === undefined) return "-";
|
||||
if (Array.isArray(v)) {
|
||||
return v.map((item: any) => (item && typeof item === "object" ? item.fileName || JSON.stringify(item) : String(item))).join("、") || "-";
|
||||
}
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data: any = await flowApi.taskDetail(taskId.value).send();
|
||||
detail.value = data;
|
||||
const pendingIds: string[] = (data?.myPendingTaskIds || []).map(String);
|
||||
myTaskId.value = pendingIds.includes(taskId.value) ? taskId.value : "";
|
||||
myTask.value = (data?.trace || []).find((t: any) => String(t.taskId) === taskId.value) || null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openBizUrl() {
|
||||
if (detail.value?.bizUrl) {
|
||||
window.open(detail.value.bizUrl, "_blank");
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(approved: boolean) {
|
||||
if (!approved && !opinion.value.trim()) {
|
||||
Message.warning("拒绝必须填写意见");
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
try {
|
||||
if (approved) {
|
||||
await flowApi.approve(myTaskId.value, opinion.value.trim() || undefined).send();
|
||||
Message.success("已同意");
|
||||
} else {
|
||||
await flowApi.reject(myTaskId.value, opinion.value.trim()).send();
|
||||
Message.success("已拒绝");
|
||||
}
|
||||
router.push("/workbench");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmApprove() {
|
||||
Modal.confirm({
|
||||
title: "同意该任务?",
|
||||
onOk: () => submit(true),
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page" v-if="detail">
|
||||
<div class="head">
|
||||
<span class="title">{{ detail.bizTitle }}</span>
|
||||
<Tag :color="detail.status === 10 ? 'blue' : detail.status === 20 ? 'green' : detail.status === 30 ? 'red' : 'gray'">
|
||||
{{ INSTANCE_STATUS[detail.status] || detail.status }}
|
||||
</Tag>
|
||||
<Tag v-if="myTask && myTask.nodeName" color="geekblue">{{ myTask.nodeName }}</Tag>
|
||||
<Tag v-if="myTask && myTask.status === 0" color="warning">待本人办理</Tag>
|
||||
</div>
|
||||
|
||||
<!-- 业务摘要区块(M1 办理页核心;富文本正文留在业务方,经 bizUrl 查看) -->
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>业务摘要</template>
|
||||
<div v-if="snapshotRows.length === 0" class="muted">(业务方未提供摘要)</div>
|
||||
<div v-else class="snapshot">
|
||||
<div v-for="row in snapshotRows" :key="row.k" class="row">
|
||||
<span class="k">{{ row.k }}</span>
|
||||
<span class="v">{{ row.v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button v-if="detail.bizUrl" size="small" type="primary" ghost class="biz-link" @click="openBizUrl">
|
||||
查看业务详情
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<!-- 业务表单数据(§3.4 只读渲染,非表单引擎;无 formData 时整块隐藏) -->
|
||||
<Card class="block" :bordered="false" dis-hover v-if="formRows.length">
|
||||
<template #title>业务表单数据</template>
|
||||
<div class="snapshot">
|
||||
<div v-for="row in formRows" :key="row.k" class="row">
|
||||
<span class="k">{{ row.k }}</span>
|
||||
<span class="v">{{ row.v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 办理区 -->
|
||||
<Card class="block" :bordered="false" dis-hover>
|
||||
<template #title>办理意见</template>
|
||||
<Input v-model="opinion" type="textarea" :rows="3" maxlength="500" show-word-limit
|
||||
placeholder="同意可选填意见;拒绝必须填写意见" />
|
||||
<div class="actions" v-if="myTaskId">
|
||||
<Button type="primary" :loading="submitting" @click="confirmApprove">同意</Button>
|
||||
<Button type="error" :loading="submitting" @click="submit(false)">拒绝</Button>
|
||||
</div>
|
||||
<div v-else class="muted">当前任务不可办理(不存在/非本人/已办理)</div>
|
||||
</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>
|
||||
<span class="opinion" v-if="t.opinion">「{{ t.opinion }}」</span>
|
||||
<div class="time">{{ (t.handleTime || t.arriveTime || "").replace("T", " ") }}</div>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
</Card>
|
||||
</div>
|
||||
<div v-else class="page muted">加载中…</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.title { font-size: 16px; font-weight: 600; }
|
||||
.block { margin-bottom: 12px; }
|
||||
.snapshot .row { display: flex; padding: 4px 0; }
|
||||
.snapshot .k { width: 140px; color: #888; flex-shrink: 0; }
|
||||
.snapshot .v { white-space: pre-wrap; word-break: break-all; }
|
||||
.biz-link { margin-top: 8px; }
|
||||
.actions { margin-top: 10px; display: flex; gap: 8px; }
|
||||
.muted { color: #999; }
|
||||
.node { font-weight: 600; margin-right: 6px; }
|
||||
.opinion { color: #666; }
|
||||
.time { color: #aaa; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
// 工作台:我的待办 / 我的已办(概览 §9.4 主路径查询,走 assignee 索引)
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { flowApi, TASK_STATUS, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
|
||||
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 todoColumns = [
|
||||
{ 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) => h("Button", { size: "small", type: "primary", onClick: () => 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: "办理时间", 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) => h("Button", { size: "small", onClick: () => router.push(`/instance/${p.row.instanceId}`) }, () => "详情") },
|
||||
];
|
||||
|
||||
function submitterNameFallback(row: any) {
|
||||
return row.submitterName || "-";
|
||||
}
|
||||
|
||||
function goHandle(row: any) {
|
||||
router.push(`/task/${row.taskId}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(name: string) {
|
||||
tab.value = name as any;
|
||||
page.value = 1;
|
||||
load();
|
||||
}
|
||||
|
||||
function onPage(p: number) {
|
||||
page.value = p;
|
||||
load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<Tabs :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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
.pager { margin-top: 12px; text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<script setup lang="ts">
|
||||
// 回调管理(概览 §9.2):注册列表/启停/secret 重置 + 推送日志 + 挂起(持续重试可视/标记死信)+ 死信重推
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Message, Modal } from "view-ui-plus";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
|
||||
const LOG_STATUS: Record<number, string> = { 0: "待推/重试中", 1: "成功", 2: "死信" };
|
||||
const EVENT_OPTIONS = ["flow.submitted", "flow.task.created", "flow.task.handled", "flow.completed", "flow.rejected", "flow.withdrawn"];
|
||||
|
||||
const tab = ref("regs");
|
||||
const loading = ref(false);
|
||||
|
||||
// ---- 注册列表 ----
|
||||
const regs = ref<any[]>([]);
|
||||
const regColumns = [
|
||||
{ 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) => {
|
||||
const btns: any[] = [
|
||||
h("Button", { size: "small", onClick: () => onRegStatus(p.row, p.row.status === 0) }, () => (p.row.status === 0 ? "停用" : "启用")),
|
||||
h("Button", { size: "small", type: "warning", onClick: () => onResetSecret(p.row) }, () => "重置secret"),
|
||||
];
|
||||
return h("div", { style: "display:flex;gap:4px" }, btns);
|
||||
} },
|
||||
];
|
||||
|
||||
// ---- 推送日志 ----
|
||||
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 logColumns = [
|
||||
{ type: "selection", width: 50 },
|
||||
{ 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) => {
|
||||
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: "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: "事件", 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: "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) =>
|
||||
h("Button", { size: "small", type: "error", onClick: () => 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 };
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHungs() {
|
||||
hungLoading.value = true;
|
||||
try {
|
||||
const data: any = await flowApi.callbackHung({ page: hungPage.value, size: 10 }).send();
|
||||
hungs.value = data?.content || [];
|
||||
hungTotal.value = data?.total || 0;
|
||||
} finally {
|
||||
hungLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRegStatus(row: any, disable: boolean) {
|
||||
await flowApi.regStatus(row.id, !disable).send();
|
||||
Message.success("已更新");
|
||||
loadRegs();
|
||||
}
|
||||
|
||||
async function onResetSecret(row: any) {
|
||||
const d: any = await flowApi.regResetSecret(row.id).send();
|
||||
Message.info({ content: `新 secret(仅本次展示):${d?.secret}`, duration: 8 });
|
||||
loadRegs();
|
||||
}
|
||||
|
||||
function onMarkDead(row: any) {
|
||||
Modal.confirm({
|
||||
title: "标记死信",
|
||||
content: `办件 ${row.instanceId} 事件 ${row.event} 已持续重试 ${row.retryCount} 次。确认该事件已无意义、停止重试并转入死信?`,
|
||||
onOk: async () => {
|
||||
await flowApi.callbackMarkDead(row.id).send();
|
||||
Message.success("已标记死信(可在死信列表重推复活)");
|
||||
loadHungs();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function onSelectionChange(selection: any[]) {
|
||||
selectedLogIds.value = selection.map((r) => r.id);
|
||||
}
|
||||
|
||||
async function onRepush() {
|
||||
if (!selectedLogIds.value.length) {
|
||||
Message.warning("请先勾选日志行");
|
||||
return;
|
||||
}
|
||||
await flowApi.callbackRepush(selectedLogIds.value).send();
|
||||
Message.success("已触发重推");
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function switchTab(name: string) {
|
||||
tab.value = name;
|
||||
if (name === "logs") loadLogs();
|
||||
if (name === "hung") 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">
|
||||
<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>
|
||||
</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>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
// 流程定义管理(M1:JSON 文本编辑模式;发布跑静态校验,把"任务无主"拦在发布期)
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
|
||||
const router = useRouter();
|
||||
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
|
||||
|
||||
const loading = ref(false);
|
||||
const rows = ref<any[]>([]);
|
||||
const editVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const form = ref<any>({ id: null, code: "", name: "", contentJson: "" });
|
||||
const versionVisible = ref(false);
|
||||
const versions = ref<any[]>([]);
|
||||
|
||||
const SAMPLE_JSON = `{
|
||||
"code": "sample_flow",
|
||||
"name": "示例流程",
|
||||
"start": "start",
|
||||
"nodes": [
|
||||
{"id": "start", "type": "START", "name": "开始"},
|
||||
{"id": "n1", "type": "APPROVAL", "name": "科室审核",
|
||||
"assignee": {"type": "DEPT_KEY", "deptKey": "ybb"}, "countersign": "ALL"},
|
||||
{"id": "end", "type": "END"}
|
||||
],
|
||||
"edges": [
|
||||
{"from": "start", "to": "n1"},
|
||||
{"from": "n1", "to": "end"}
|
||||
],
|
||||
"settings": {"rejectPolicy": "END_REJECTED", "urge": {"enabled": true, "intervalHours": 24, "maxTimes": 3}}
|
||||
}`;
|
||||
|
||||
const columns = [
|
||||
{ title: "流程", key: "name", minWidth: 180 },
|
||||
{ title: "编码", key: "code", width: 160 },
|
||||
{ title: "版本", key: "version", width: 70 },
|
||||
{ title: "状态", width: 90, 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: 330, render: (h: any, p: any) => {
|
||||
const btns: any[] = [];
|
||||
if (p.row.status === 0) {
|
||||
btns.push(h("Button", { size: "small", type: "primary", onClick: () => onEdit(p.row) }, () => "编辑"));
|
||||
btns.push(h("Button", { size: "small", type: "success", onClick: () => onPublish(p.row) }, () => "发布"));
|
||||
} else if (p.row.status === 1) {
|
||||
btns.push(h("Button", { size: "small", onClick: () => onNewVersion(p.row) }, () => "升版本"));
|
||||
btns.push(h("Button", { size: "small", type: "warning", onClick: () => onOffline(p.row) }, () => "停用"));
|
||||
}
|
||||
btns.push(h("Button", { size: "small", onClick: () => onDiagram(p.row) }, () => "图示"));
|
||||
btns.push(h("Button", { size: "small", onClick: () => onVersions(p.row) }, () => "版本历史"));
|
||||
return h("div", { style: "display:flex;gap:4px" }, btns);
|
||||
} },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await flowApi.defList().send()) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onEdit(row: any) {
|
||||
flowApi.defGet(row.id).send().then((d: any) => {
|
||||
form.value = { id: d.id, code: d.code, name: d.name, contentJson: d.contentJson };
|
||||
editVisible.value = true;
|
||||
});
|
||||
}
|
||||
|
||||
function onNew() {
|
||||
form.value = { id: null, code: "", name: "", contentJson: SAMPLE_JSON };
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
async function onNewVersion(row: any) {
|
||||
// 升版本:以已发布内容为底稿建草稿(flowGuid 不变,version 自动 +1)
|
||||
const d: any = await flowApi.defGet(row.id).send();
|
||||
form.value = { id: null, code: row.code, name: row.name, contentJson: d.contentJson, baseGuid: row.flowGuid };
|
||||
Message.info("已载入当前版本内容,保存即创建新草稿版本");
|
||||
editVisible.value = true;
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await flowApi.defSave({
|
||||
id: form.value.id || undefined,
|
||||
code: form.value.code,
|
||||
name: form.value.name,
|
||||
contentJson: form.value.contentJson,
|
||||
}).send();
|
||||
Message.success("已保存");
|
||||
editVisible.value = false;
|
||||
load();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onPublish(row: any) {
|
||||
try {
|
||||
await flowApi.defPublish(row.id).send();
|
||||
Message.success("发布成功");
|
||||
load();
|
||||
} catch (e: any) {
|
||||
Message.error(e?.message || "发布校验失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function onOffline(row: any) {
|
||||
await flowApi.defOffline(row.id).send();
|
||||
Message.success("已停用");
|
||||
load();
|
||||
}
|
||||
|
||||
async function onVersions(row: any) {
|
||||
versions.value = (await flowApi.defVersions(row.flowGuid).send()) || [];
|
||||
versionVisible.value = true;
|
||||
}
|
||||
|
||||
function onDiagram(row: any) {
|
||||
router.push({ path: "/diagram", query: { defId: row.id, mode: "auto" } });
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="filter">
|
||||
<Button type="primary" @click="onNew">新建流程</Button>
|
||||
</div>
|
||||
<Table :columns="columns" :data="rows" :loading="loading" stripe />
|
||||
|
||||
<Modal v-model="editVisible" :title="form.id ? '编辑流程' : '新建流程'" width="760" :mask-closable="false">
|
||||
<Form :label-width="80">
|
||||
<FormItem label="编码">
|
||||
<Input v-model="form.code" :disabled="!!form.id" placeholder="流程编码(内部管理键,发布后不可改)" />
|
||||
</FormItem>
|
||||
<FormItem label="名称">
|
||||
<Input v-model="form.name" placeholder="流程名" />
|
||||
</FormItem>
|
||||
<FormItem label="定义 JSON">
|
||||
<Input v-model="form.contentJson" type="textarea" :rows="16"
|
||||
placeholder="§5.1 流程定义 JSON(START/END 唯一、无孤儿无环、DEPT_KEY 需先配好映射)" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="editVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="onSave">保存(草稿)</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-model="versionVisible" title="版本历史" width="640">
|
||||
<Table :columns="[
|
||||
{ title: '版本', key: 'version', width: 80 },
|
||||
{ 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" />
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
.filter { margin-bottom: 10px; }
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
// 办件记录(管理端):全校办件查询
|
||||
import { ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { flowApi, INSTANCE_STATUS } from "@/api/module/flow";
|
||||
|
||||
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 columns = [
|
||||
{ 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: "提交人", 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) =>
|
||||
h("Button", { size: "small", onClick: () => router.push(`/instance/${p.row.id}`) }, () => "详情") },
|
||||
];
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="filter">
|
||||
<Select v-model="filter.status" clearable placeholder="状态" style="width: 130px">
|
||||
<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>
|
||||
</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(); }" />
|
||||
</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>
|
||||
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
// 办理人映射管理(概览 §4.7/§六):DEPT_KEY → 办理人;成员检索经 UserBase /oapi/user/byId 补姓名
|
||||
import { ref, onMounted } from "vue";
|
||||
import { Message } from "view-ui-plus";
|
||||
import { flowApi } from "@/api/module/flow";
|
||||
|
||||
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 lookingUp = ref(false);
|
||||
|
||||
const columns = [
|
||||
{ title: "科室编码", key: "deptKey", width: 140 },
|
||||
{ title: "科室名称", key: "deptName", 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) =>
|
||||
p.row.status === 0
|
||||
? h("Button", { size: "small", type: "warning", onClick: () => onDisable(p.row) }, () => "停用")
|
||||
: h("span", "-") },
|
||||
];
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
rows.value = (await flowApi.mapList(deptKeyFilter.value || undefined).send()) || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
form.value = { deptKey: "", deptName: "", userId: null, userName: "" };
|
||||
saveVisible.value = true;
|
||||
}
|
||||
|
||||
async function onLookup() {
|
||||
if (!form.value.userId) {
|
||||
Message.warning("请先填用户ID");
|
||||
return;
|
||||
}
|
||||
lookingUp.value = true;
|
||||
try {
|
||||
const u: any = await flowApi.mapLookupUser(form.value.userId).send();
|
||||
form.value.userName = u?.name || "";
|
||||
Message.success(`检索到用户:${u?.name || "-"}`);
|
||||
} catch {
|
||||
Message.error("UserBase 检索失败");
|
||||
} finally {
|
||||
lookingUp.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
if (!form.value.deptKey.trim() || !form.value.userId) {
|
||||
Message.warning("科室编码与用户ID必填");
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
await flowApi.mapSave({
|
||||
deptKey: form.value.deptKey.trim(),
|
||||
deptName: form.value.deptName,
|
||||
userId: Number(form.value.userId),
|
||||
userName: form.value.userName,
|
||||
}).send();
|
||||
Message.success("已保存");
|
||||
saveVisible.value = false;
|
||||
load();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onDisable(row: any) {
|
||||
await flowApi.mapDisable(row.id).send();
|
||||
Message.success("已停用");
|
||||
load();
|
||||
}
|
||||
|
||||
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>
|
||||
</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="用户ID">
|
||||
<Input v-model="form.userId" placeholder="UserBase 用户主键" />
|
||||
</FormItem>
|
||||
<FormItem label="姓名快照">
|
||||
<Input v-model="form.userName" placeholder="可留空,点检索自动补全" style="width: 180px" />
|
||||
<Button :loading="lookingUp" @click="onLookup" style="margin-left: 8px">检索</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<template #footer>
|
||||
<Button @click="saveVisible = false">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="onSave">保存</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { padding: 12px 16px; }
|
||||
.filter { margin-bottom: 10px; display: flex; gap: 8px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user