290 lines
12 KiB
Vue
290 lines
12 KiB
Vue
<script setup lang="ts">
|
||
// 办件进度嵌入页(iframe 免签嵌入,2026-10-03 方案):子应用 Drawer 里以 iframe 打开本页,
|
||
// 展示"进度 + 流程示意图",零样式复制、单点维护。全程零 CAS 交互——数据走 /open/flow/embed/data
|
||
// 免签端点(只认 token),示意图 token 两用(/open/flow/diagram/img 同一 token 出图)。
|
||
// 本页只读(方案 §9):不做任何审批动作;办理入口仍是待办站内信 → /task/:taskId 既有链路。
|
||
import { ref, computed, onMounted, nextTick } from "vue";
|
||
import { useRoute } from "vue-router";
|
||
import { Tag } from "view-ui-plus";
|
||
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR, TASK_STATUS } from "@/api/module/flow";
|
||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||
import { fitDiagram } from "@/utils/diagramImg";
|
||
import config from "@/assets/config/host.config";
|
||
|
||
const route = useRoute();
|
||
const detail = ref<any>(null);
|
||
const loading = ref(true);
|
||
/** 兜底空态:缺 token / 过期 / 加载失败,全页同一文案,不留白 */
|
||
const expired = ref(false);
|
||
|
||
/** 显示口径:dept=按科室 / user=按人员(缺省)。纯前端本地状态——嵌入页 URL 归子应用管,
|
||
* 改 query 会破坏 token 语义,不落 URL(与管理面 InstanceDetail 写 query 的动机不同) */
|
||
const traceMode = ref<string>("user");
|
||
function switchMode(val: string) {
|
||
traceMode.value = val;
|
||
}
|
||
|
||
const token = computed(() => String(route.query.token || ""));
|
||
|
||
/** 示意图直嵌:同一 token 第二处消费(换图端点零改动即认领嵌入 token) */
|
||
const diagramSrc = computed(
|
||
() => `${config.serviceHost}/open/flow/diagram/img?token=${token.value}`,
|
||
);
|
||
const diagramEl = ref<HTMLImageElement | null>(null);
|
||
function onDiagramLoad() {
|
||
fitDiagram(diagramEl.value);
|
||
}
|
||
|
||
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 [];
|
||
});
|
||
|
||
/** 业务表单数据键值平铺(同 InstanceDetail 现状,不做精细表单渲染) */
|
||
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) }];
|
||
});
|
||
|
||
// ===== 审批记录流水表(整个流程周期,与"流程轨迹"时间线互补:时间线给进度感,表格给逐条可查流水) =====
|
||
|
||
/** 事件类型 → 动作标签主题色(逐字照抄 TraceTimeline.vue 的 KIND_TAG:两处口径,改一处必须同步另一处) */
|
||
const RECORD_KIND_TAG: Record<string, string> = {
|
||
SUBMIT: "green",
|
||
RESUBMIT: "green",
|
||
WITHDRAW: "orange",
|
||
APPROVE: "green",
|
||
REJECT: "red",
|
||
RETURN: "orange",
|
||
TRANSFER: "cyan",
|
||
INVALID: "default",
|
||
PENDING: "blue",
|
||
CC: "cyan",
|
||
ROUTE: "purple",
|
||
};
|
||
|
||
/** 多轮才显示"轮次"列(单轮不打扰,口径同 TraceTimeline 的 multiRound) */
|
||
const multiRound = computed(() =>
|
||
(detail.value?.trace || []).some((t: any) => (t.round || 1) > 1));
|
||
|
||
function fmtTime(v: string | null | undefined) {
|
||
return v ? String(v).replace("T", " ").replace(/\.\d+$/, "") : "";
|
||
}
|
||
|
||
/** 操作者(口径=TraceTimeline.actor:dept 档不泄露人名,科室节点显科室、指定人显"指定办理人") */
|
||
function recordActor(t: any): string {
|
||
if (t.kind === "ROUTE") return "流程引擎";
|
||
if (traceMode.value !== "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 || "-");
|
||
}
|
||
|
||
/** 动作标签(口径=TraceTimeline.actionText:CC/ROUTE 专用文案,任务行走 TASK_STATUS 码表) */
|
||
function recordAction(t: any): string {
|
||
if (t.kind === "CC") return "已抄送";
|
||
if (t.kind === "ROUTE") return "系统判断";
|
||
return TASK_STATUS[t.status] || String(t.status ?? "");
|
||
}
|
||
|
||
/** 表格列:Drawer iframe 宽约 828px,列宽按此设计;轮次列仅多轮时出现 */
|
||
const recordColumns = computed(() => {
|
||
const cols: any[] = [
|
||
{ title: "时间", width: 150, render: (h: any, p: any) => h("span", fmtTime(p.row.time)) },
|
||
];
|
||
if (multiRound.value) {
|
||
cols.push({
|
||
title: "轮次", width: 80, align: "center",
|
||
render: (h: any, p: any) => h("span", `第 ${p.row.round || 1} 轮`),
|
||
});
|
||
}
|
||
cols.push(
|
||
{
|
||
title: "节点", minWidth: 120,
|
||
render: (h: any, p: any) => h("span", p.row.nodeName || p.row.nodeId || "-"),
|
||
},
|
||
{ title: "办理人", width: 110, render: (h: any, p: any) => h("span", recordActor(p.row)) },
|
||
{
|
||
title: "动作", width: 90, align: "center",
|
||
render: (h: any, p: any) =>
|
||
h(Tag, { size: "small", color: RECORD_KIND_TAG[p.row.kind] || "default" },
|
||
() => recordAction(p.row)),
|
||
},
|
||
{
|
||
title: "意见 / 去向", minWidth: 180,
|
||
render: (h: any, p: any) => {
|
||
const parts: any[] = [];
|
||
if (p.row.opinion) parts.push(h("span", `「${p.row.opinion}」`));
|
||
if (p.row.edgeName) parts.push(h("span", `去向:${p.row.edgeName}`));
|
||
if (p.row.kind === "ROUTE") {
|
||
// outlet 空=判断失败挂起(同 TraceTimeline 的 route-hung 文案),等管理端重跑路由
|
||
parts.push(p.row.outlet
|
||
? h("span", `→ ${p.row.outlet}`)
|
||
: h("span", { style: "color:#ed4014" }, `判断失败已挂起(第 ${p.row.attempt || 1} 次)`));
|
||
}
|
||
return parts.length ? h("span", parts) : h("span", { style: "color:#999" }, "-");
|
||
},
|
||
},
|
||
);
|
||
return cols;
|
||
});
|
||
|
||
function statusName(s: number) {
|
||
return INSTANCE_STATUS[s] || s;
|
||
}
|
||
|
||
function statusColor(s: number) {
|
||
return INSTANCE_STATUS_COLOR[s] || "gray";
|
||
}
|
||
|
||
// 免签端点不带任何会话,alova 拦截器(token 头/信封解析)一概用不上,原生 fetch 直取
|
||
onMounted(async () => {
|
||
if (!token.value) {
|
||
expired.value = true;
|
||
loading.value = false;
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch(`${config.serviceHost}/open/flow/embed/data?token=${token.value}`);
|
||
if (!res.ok) {
|
||
// 404=过期/伪造,与缺 token 同空态(不区分,不诱导探测)
|
||
expired.value = true;
|
||
return;
|
||
}
|
||
detail.value = await res.json();
|
||
} catch {
|
||
expired.value = true;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
// 缓存命中的图可能在监听器挂上之前就 complete,补一次适配
|
||
await nextTick();
|
||
if (diagramEl.value?.complete) {
|
||
fitDiagram(diagramEl.value);
|
||
}
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="embed-page" v-if="detail">
|
||
<div class="head">
|
||
<span class="title">{{ detail.bizTitle }}</span>
|
||
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
|
||
</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>
|
||
<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>
|
||
</Card>
|
||
|
||
<Card class="block" :bordered="false" dis-hover>
|
||
<template #title>流程示意图</template>
|
||
<img class="diagram-img" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="onDiagramLoad" />
|
||
</Card>
|
||
|
||
<Card class="block" :bordered="false" dis-hover>
|
||
<template #title>
|
||
流程轨迹
|
||
<RadioGroup class="trace-mode" :model-value="traceMode" size="small" type="button" @on-change="switchMode">
|
||
<Radio label="user">按人员</Radio>
|
||
<Radio label="dept">按科室</Radio>
|
||
</RadioGroup>
|
||
</template>
|
||
<TraceTimeline :trace="detail.trace || []" :mode="traceMode" />
|
||
</Card>
|
||
|
||
<Card class="block" :bordered="false" dis-hover>
|
||
<template #title>
|
||
审批记录
|
||
<span class="record-tip">整个流程周期的完整事件流水</span>
|
||
</template>
|
||
<Table size="small" :columns="recordColumns" :data="detail.trace || []" />
|
||
</Card>
|
||
|
||
<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>
|
||
</div>
|
||
<div v-else-if="loading" class="embed-page muted">加载中…</div>
|
||
<div v-else class="embed-page muted expired">链接已过期,请回到来源应用重新打开</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 根类不用 .page:UnoCSS 原子类同名会以更高优先级盖掉 scoped 的 padding(裸渲染无 layout 壳时暴露) */
|
||
/* 嵌入页自身即滚动容器:全局 html/body overflow:hidden(App.vue 壳用),裸渲染页无 layout 滚动壳,
|
||
不在此定高滚动的话内容超一屏被裁且无滚动条(2026-10-10 用户反馈) */
|
||
.embed-page { height: 100vh; overflow-y: auto; padding: 12px 16px; box-sizing: border-box; }
|
||
.head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||
.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; }
|
||
.expired { padding: 48px 16px; text-align: center; }
|
||
.diagram-img { max-width: 100%; height: auto; display: block; margin: 0 auto; }
|
||
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
|
||
/* 类名带 record- 前缀:防 UnoCSS 原子类撞名(嵌入页裸渲染无 layout 壳,撞名实测会盖样式) */
|
||
.record-tip {
|
||
float: right;
|
||
font-size: 12px;
|
||
font-weight: normal;
|
||
margin-right: 16px;
|
||
color: #999;
|
||
}
|
||
</style>
|