Files
flow-center-front/src/views/flow/EmbedInstance.vue
T

290 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>