refactor(detail): 办件详情与嵌入页整合共用 InstanceBody——详情页升级三视图+流程信息五项+表单数据,嵌入页表现不变
build / build (push) Successful in 2m55s
build / build (push) Successful in 2m55s
This commit is contained in:
@@ -3,17 +3,14 @@
|
||||
// 展示"进度 + 流程示意图",零样式复制、单点维护。全程零 CAS 交互——数据走 /open/flow/embed/data
|
||||
// 免签端点(只认 token),示意图 token 两用(/open/flow/diagram/img 同一 token 出图)。
|
||||
// 本页只读(方案 §9):不做任何审批动作;办理入口仍是待办站内信 → /task/:taskId 既有链路。
|
||||
// v2.4 版式(2026-10-10):原「流程轨迹」+「审批记录」合并为「办理进度」三视图(按节点/时间线/列表),
|
||||
// 流程示意图与业务信息改为默认收缩的折叠块;业务信息只渲染约定键 bizSnapshot.summaryFields,
|
||||
// 无值整块不显示;bizSnapshot 其余键与 formDataJson 一律不再平铺(原始数据不外露)。
|
||||
// v2.5(2026-10-10):口径切换器(人员视图/科室视图)与视图切换器同排在标题行、三视图常驻——口径实际对
|
||||
// 三个视图都生效(时间线走 :mode、列表走办理人列),原先只在「按节点」模板内会切视图即消失。
|
||||
import { ref, computed, onMounted, nextTick } from "vue";
|
||||
// 2026-10-11 整合:正文(基本信息/办理进度三视图/流程示意图折叠/业务信息摘要)抽至共用组件
|
||||
// InstanceBody.vue,与管理面办件详情 /instance/:instanceId 同源同款;本页只留 token 取数、
|
||||
// 标题吸顶壳与空态——嵌入口径(只认 summaryFields、无流程信息/表单数据)由组件缺省 variant 保证。
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { Tag } from "view-ui-plus";
|
||||
import { INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||
import { fitDiagram } from "@/utils/diagramImg";
|
||||
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -22,265 +19,12 @@ const loading = ref(true);
|
||||
/** 兜底空态:缺 token / 过期 / 加载失败,全页同一文案,不留白 */
|
||||
const expired = ref(false);
|
||||
|
||||
/** 办理进度视图:node=按节点(缺省)/ timeline=时间线 / list=列表 */
|
||||
const viewMode = ref<string>("node");
|
||||
function switchView(val: string) {
|
||||
viewMode.value = val;
|
||||
}
|
||||
|
||||
/** 显示口径: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);
|
||||
}
|
||||
// 折叠时容器 clientWidth=0,fitDiagram 取不到收缩上限;展开这一刻再适配一次
|
||||
function onDiagramToggle(e: Event) {
|
||||
if ((e.target as HTMLDetailsElement).open) fitDiagram(diagramEl.value);
|
||||
}
|
||||
|
||||
/** 业务信息唯一内容 = 约定键 bizSnapshot.summaryFields;形状错 / 无值 ⇒ [] ⇒ 整块不渲染 */
|
||||
const summaryFields = computed(() => {
|
||||
const raw = detail.value?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
let obj: any;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const sf = obj?.summaryFields;
|
||||
if (!Array.isArray(sf) || !sf.length) return [];
|
||||
return sf
|
||||
.filter((x: any) => x && x.label !== null && x.label !== undefined)
|
||||
.slice(0, 6)
|
||||
.map((x: any) => ({
|
||||
label: String(x.label),
|
||||
value: Array.isArray(x.value)
|
||||
? (x.value.join("、") || "-")
|
||||
: (x.value === null || x.value === undefined || x.value === "" ? "-" : String(x.value)),
|
||||
}));
|
||||
});
|
||||
|
||||
// ===== 事件类型口径(与 TraceTimeline.vue 同源:改一处必须同步另一处) =====
|
||||
|
||||
/** 事件类型 → 动作标签主题色(逐字照抄 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",
|
||||
};
|
||||
|
||||
/** 留痕事件行:不参与节点聚合,每行独立成一步(口径同流程中心 toProgressVo) */
|
||||
const EVENT_KINDS = ["SUBMIT", "RESUBMIT", "WITHDRAW", "CC", "ROUTE"];
|
||||
|
||||
/** 多轮才显示"轮次"列(单轮不打扰,口径同 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+$/, "") : "";
|
||||
}
|
||||
|
||||
/** 行发生时间:留痕行取 time,任务行取 handleTime,未办取 arriveTime */
|
||||
function rowTime(r: any): string {
|
||||
return r.time || r.handleTime || r.arriveTime || "";
|
||||
}
|
||||
|
||||
/** 操作者(口径=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 || "-");
|
||||
}
|
||||
|
||||
/** 嵌入页展示层状态文案(设计图口径:已提交 / 已同意 / 待审核…)。
|
||||
* ⚠️ 刻意不复用全局 TASK_STATUS(0 待办 / 1 同意 / …)——它被 TraceTimeline 与管理面详情页共用,
|
||||
* 改它会把全站文案一起改掉;此处只做本页的展示层映射,三视图共用一份。 */
|
||||
const TRACE_STATUS_TEXT: Record<number, string> = {
|
||||
0: "待审核", 1: "已同意", 2: "已拒绝", 3: "失效",
|
||||
4: "已退回", 5: "已移交", 6: "已提交", 7: "已撤回",
|
||||
};
|
||||
|
||||
/** 动作标签(口径同 TraceTimeline.actionText:CC/ROUTE 专用文案,任务行查展示层映射) */
|
||||
function recordAction(t: any): string {
|
||||
if (t.kind === "CC") return "已抄送";
|
||||
if (t.kind === "ROUTE") return "系统判断";
|
||||
return TRACE_STATUS_TEXT[t.status] || String(t.status ?? "");
|
||||
}
|
||||
|
||||
/** 节点步结论:任一拒绝 > 任一同意 > 全失效 > 待审核(口径=流程中心 OapiFlowService.stepLabel) */
|
||||
function stepLabel(rows: any[]): string {
|
||||
let denied = false, approved = false, invalid = true;
|
||||
rows.forEach((r) => {
|
||||
const s = r.status === null || r.status === undefined ? -1 : r.status;
|
||||
if (s === 2) denied = true;
|
||||
if (s === 1) approved = true;
|
||||
if (s === 0 || s === 1 || s === 2 || s === 4 || s === 5) invalid = false;
|
||||
});
|
||||
if (denied) return "已拒绝";
|
||||
if (approved) return "已同意";
|
||||
return invalid ? "失效" : "待审核";
|
||||
}
|
||||
|
||||
function labelTagClass(label: string): string {
|
||||
if (label === "已同意") return "green";
|
||||
if (label === "已拒绝") return "red";
|
||||
if (label === "待审核") return "blue";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/**
|
||||
* trace 扁平行 → 步骤序列:同 nodeId 的审批行归一步,留痕事件行独立成步,步骤按时间升序。
|
||||
* ⚠️ 步内办理人行序 = 后端返回顺序(任务 id 序),不得按时间重排。
|
||||
*/
|
||||
const nodeSteps = computed(() => {
|
||||
const trace: any[] = detail.value?.trace || [];
|
||||
const act: string[] = String(detail.value?.currentNodes || "").split(",").filter(Boolean);
|
||||
const rows = trace.map((r) => ({ ...r, _t: rowTime(r) }));
|
||||
const steps: any[] = [];
|
||||
const byNode: Record<string, any> = {};
|
||||
rows.forEach((r) => {
|
||||
if (EVENT_KINDS.includes(r.kind)) {
|
||||
steps.push({ type: "event", nodeId: r.nodeId, nodeName: r.nodeName, rows: [r], t: r._t });
|
||||
return;
|
||||
}
|
||||
let s = byNode[r.nodeId];
|
||||
if (!s) {
|
||||
s = { type: "node", nodeId: r.nodeId, nodeName: r.nodeName, rows: [], t: r._t };
|
||||
byNode[r.nodeId] = s;
|
||||
steps.push(s);
|
||||
}
|
||||
s.rows.push(r);
|
||||
if ((r._t || "") > (s.t || "")) s.t = r._t;
|
||||
});
|
||||
steps.forEach((s) => {
|
||||
if (s.type === "node") {
|
||||
s.active = act.includes(s.nodeId);
|
||||
s.label = s.active ? "待审核" : stepLabel(s.rows);
|
||||
} else {
|
||||
s.label = recordAction(s.rows[0]);
|
||||
}
|
||||
});
|
||||
return steps.sort((a, b) => (a.t || "").localeCompare(b.t || ""));
|
||||
});
|
||||
|
||||
function stepClass(s: any): string {
|
||||
if (s.type !== "node") return "";
|
||||
if (s.active) return "is-active";
|
||||
if (s.label === "已拒绝") return "is-reject";
|
||||
if (s.label === "已同意") return "is-done";
|
||||
return "";
|
||||
}
|
||||
|
||||
function stepTagColor(s: any): string {
|
||||
return s.type === "node" ? labelTagClass(s.label) : (RECORD_KIND_TAG[s.rows[0].kind] || "default");
|
||||
}
|
||||
|
||||
/** 办理人行色:1 绿 / 2 红 / 3 失效置灰删除线 / 0 待办 */
|
||||
function rowClass(r: any): string {
|
||||
if (r.status === 1) return "is-done";
|
||||
if (r.status === 2) return "is-reject";
|
||||
if (r.status === 3) return "is-invalid";
|
||||
return "";
|
||||
}
|
||||
|
||||
function rowTimeText(r: any): string {
|
||||
if (r.handleTime) return `办理于 ${fmtTime(r.handleTime)}`;
|
||||
if (r.arriveTime) return `到达 ${fmtTime(r.arriveTime)}`;
|
||||
return fmtTime(rowTime(r));
|
||||
}
|
||||
|
||||
/** 基本信息「当前节点」:currentNodes 命中的节点名 + 办理中后缀 */
|
||||
const currentNodeText = computed(() => {
|
||||
const act = String(detail.value?.currentNodes || "").split(",").filter(Boolean);
|
||||
const names = nodeSteps.value
|
||||
.filter((s) => s.type === "node" && act.includes(s.nodeId))
|
||||
.map((s) => s.nodeName || s.nodeId);
|
||||
return names.length ? `${names.join("、")}(办理中)` : "-";
|
||||
});
|
||||
|
||||
const metaItems = computed(() => {
|
||||
const d = detail.value || {};
|
||||
const items = [
|
||||
{ k: "提交人", v: d.submitterName || "-" },
|
||||
{ k: "提交时间", v: fmtTime(d.submitTime) || "-" },
|
||||
{ k: "办结时间", v: fmtTime(d.finishTime) || "-" },
|
||||
];
|
||||
if (d.currentNodes) items.push({ k: "当前节点", v: currentNodeText.value });
|
||||
return items;
|
||||
});
|
||||
|
||||
/** 表格列:Drawer iframe 宽约 708px(740 抽屉),列宽按此设计;轮次列仅多轮时出现 */
|
||||
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;
|
||||
@@ -310,11 +54,6 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// 缓存命中的图可能在监听器挂上之前就 complete,补一次适配
|
||||
await nextTick();
|
||||
if (diagramEl.value?.complete) {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -325,88 +64,7 @@ onMounted(async () => {
|
||||
<Tag :color="statusColor(detail.status)">{{ statusName(detail.status) }}</Tag>
|
||||
</div>
|
||||
|
||||
<div class="embed-sec">
|
||||
<div class="embed-sec-h"><span>基本信息</span></div>
|
||||
<div class="embed-meta">
|
||||
<div v-for="m in metaItems" :key="m.k" class="embed-meta-i">
|
||||
<div class="embed-meta-k">{{ m.k }}</div>
|
||||
<div class="embed-meta-v" :title="m.v">{{ m.v }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="embed-sec">
|
||||
<div class="embed-sec-h">
|
||||
<span>办理进度</span>
|
||||
<RadioGroup class="embed-view-seg" :model-value="viewMode" size="small" type="button" @on-change="switchView">
|
||||
<Radio label="node">按节点</Radio>
|
||||
<Radio label="timeline">时间线</Radio>
|
||||
<Radio label="list">列表</Radio>
|
||||
</RadioGroup>
|
||||
<!-- 口径对三视图均生效(时间线走 :mode、列表走办理人列)⇒ 与视图切换器同排常驻;
|
||||
原先放在「按节点」模板内,切到时间线/列表就消失,与实际生效范围不符 -->
|
||||
<RadioGroup class="embed-mode-seg" :model-value="traceMode" size="small" type="button" @on-change="switchMode">
|
||||
<Radio label="user">人员视图</Radio>
|
||||
<Radio label="dept">科室视图</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<!-- ① 按节点:同节点多任务每人一行,步内保持后端任务序 -->
|
||||
<template v-if="viewMode === 'node'">
|
||||
<div class="embed-steps" v-if="nodeSteps.length">
|
||||
<div v-for="(s, i) in nodeSteps" :key="`${s.type}-${s.nodeId || 'x'}-${i}`"
|
||||
class="embed-step" :class="stepClass(s)">
|
||||
<span class="embed-step-dot"></span>
|
||||
<div class="embed-step-body">
|
||||
<div class="embed-step-head">
|
||||
<span class="embed-step-name">{{ s.nodeName || s.nodeId || "—" }}</span>
|
||||
<Tag size="small" :color="stepTagColor(s)">{{ s.label }}</Tag>
|
||||
<Tag v-if="s.active" size="small" color="blue">办理中</Tag>
|
||||
</div>
|
||||
<div v-for="(r, j) in s.rows" :key="r.rowKey || j" class="embed-assignee" :class="rowClass(r)">
|
||||
<span class="embed-an">{{ recordActor(r) }}</span>
|
||||
<span class="embed-am">{{ recordAction(r) }}</span>
|
||||
<span class="embed-am">{{ rowTimeText(r) }}</span>
|
||||
<span class="embed-ao" v-if="r.opinion">
|
||||
{{ r.kind === "REJECT" ? "拒绝意见" : "意见" }}:{{ r.opinion }}
|
||||
</span>
|
||||
<span class="embed-ao" v-if="r.edgeName">去向:{{ r.edgeName }}</span>
|
||||
<span class="embed-ao route-hung" v-if="r.kind === 'ROUTE' && !r.outlet">
|
||||
判断失败已挂起(第 {{ r.attempt || 1 }} 次),等待管理端重跑路由
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" v-else>暂无轨迹</div>
|
||||
</template>
|
||||
|
||||
<!-- ② 时间线:扁平行 + 多轮分隔(复用共用组件,口径不变) -->
|
||||
<TraceTimeline v-else-if="viewMode === 'timeline'" :trace="detail.trace || []" :mode="traceMode"
|
||||
:status-text="TRACE_STATUS_TEXT" />
|
||||
|
||||
<!-- ③ 列表:整个流程周期的完整事件流水 -->
|
||||
<Table v-else size="small" :columns="recordColumns" :data="detail.trace || []" />
|
||||
</div>
|
||||
|
||||
<details class="embed-fold" @toggle="onDiagramToggle">
|
||||
<summary>流程示意图</summary>
|
||||
<div class="embed-fold-b">
|
||||
<img class="diagram-img" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="onDiagramLoad" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details v-if="summaryFields.length" class="embed-fold">
|
||||
<summary>业务信息</summary>
|
||||
<div class="embed-fold-b">
|
||||
<div class="sf">
|
||||
<div v-for="f in summaryFields" :key="f.label" class="sf-row">
|
||||
<span class="sf-k">{{ f.label }}</span>
|
||||
<span class="sf-v">{{ f.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<InstanceBody :detail="detail" :diagram-src="diagramSrc" />
|
||||
</div>
|
||||
<div v-else-if="loading" class="embed-page muted">加载中…</div>
|
||||
<div v-else class="embed-page muted expired">链接已过期,请回到来源应用重新打开</div>
|
||||
@@ -426,78 +84,4 @@ onMounted(async () => {
|
||||
.title { font-size: 16px; font-weight: 600; flex: 1; min-width: 0; }
|
||||
.muted { color: #999; }
|
||||
.expired { padding: 48px 16px; text-align: center; }
|
||||
|
||||
/* 区块标题:左侧蓝条 + 右侧控件 */
|
||||
.embed-sec { margin-bottom: 18px; }
|
||||
.embed-sec-h { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 14px; font-weight: 600; }
|
||||
.embed-sec-h > span:first-child { position: relative; padding-left: 9px; }
|
||||
.embed-sec-h > span:first-child::before {
|
||||
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 13px; border-radius: 2px; background: var(--brand);
|
||||
}
|
||||
/* 两处切换器(视图 / 口径)现同排在标题行,同为 iView small button group、几何与配色一致
|
||||
(活动态 #0d9488、非活动 #515a6e/#dcdee2)。⚠️ 两者都落在 .embed-sec-h(600) 内 ⇒ 会继承粗体,
|
||||
必须显式回常规(管理面同款控件 InstanceDetail/TaskHandle 的 .trace-mode 也是这个写法)。 */
|
||||
.embed-view-seg { margin-left: auto; font-weight: normal; }
|
||||
/* 口径紧随视图切换器:与 .embed-sec-h 的 10px gap 叠加为 24px,避免两组按钮被看成一排五选一 */
|
||||
.embed-mode-seg { margin-left: 14px; font-weight: normal; }
|
||||
|
||||
/* 基本信息:四列等宽(740 抽屉实测放得下时间戳,超出省略号 + title 兜底) */
|
||||
.embed-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid #f2f4f7; border-radius: 8px; overflow: hidden; }
|
||||
.embed-meta-i { padding: 11px 14px; border-right: 1px solid #e8eaec; min-width: 0; }
|
||||
.embed-meta-i:last-child { border-right: none; }
|
||||
.embed-meta-k { font-size: 12px; color: #808695; margin-bottom: 4px; }
|
||||
.embed-meta-v { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 按节点视图 */
|
||||
.embed-steps { position: relative; }
|
||||
.embed-step { display: flex; gap: 11px; padding-bottom: 16px; position: relative; }
|
||||
.embed-step:last-child { padding-bottom: 2px; }
|
||||
.embed-step:not(:last-child)::before {
|
||||
content: ""; position: absolute; left: 5px; top: 16px; bottom: 0; width: 2px; background: #f2f4f7;
|
||||
}
|
||||
.embed-step-dot {
|
||||
width: 12px; height: 12px; border-radius: 50%; background: #c8ced8; margin-top: 5px; flex-shrink: 0;
|
||||
z-index: 1; border: 2px solid #fff; box-shadow: 0 0 0 1px #e8eaec;
|
||||
}
|
||||
.embed-step.is-done .embed-step-dot { background: #19be6b; box-shadow: 0 0 0 1px #b7ebc9; }
|
||||
.embed-step.is-reject .embed-step-dot { background: #ed4014; box-shadow: 0 0 0 1px #ffd2c9; }
|
||||
.embed-step.is-active .embed-step-dot { background: var(--brand); box-shadow: 0 0 0 3px rgba(45, 140, 240, .18); }
|
||||
.embed-step-body { flex: 1; min-width: 0; }
|
||||
.embed-step-head { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; flex-wrap: wrap; }
|
||||
.embed-step-name { font-size: 14px; font-weight: 600; }
|
||||
.embed-assignee {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 13px;
|
||||
padding: 6px 10px; margin-bottom: 5px; border-radius: 6px; background: #fafbfc; border: 1px solid #f0f2f5;
|
||||
}
|
||||
.embed-assignee:last-child { margin-bottom: 0; }
|
||||
.embed-an { font-weight: 600; }
|
||||
.embed-assignee.is-done .embed-an { color: #19be6b; }
|
||||
.embed-assignee.is-reject .embed-an { color: #ed4014; }
|
||||
.embed-am { color: #808695; font-size: 12px; }
|
||||
.embed-ao { color: #515a6e; width: 100%; padding-top: 2px; }
|
||||
.embed-ao.route-hung { color: #ed4014; }
|
||||
.embed-assignee.is-invalid { opacity: .5; }
|
||||
.embed-assignee.is-invalid .embed-an { text-decoration: line-through; text-decoration-color: #b9bcc4; }
|
||||
|
||||
/* 折叠块:流程示意图 / 业务信息,默认收缩 */
|
||||
.embed-fold { border: 1px solid #f2f4f7; border-radius: 8px; background: #fff; margin-bottom: 18px; }
|
||||
.embed-fold > summary {
|
||||
cursor: pointer; list-style: none; padding: 12px 16px; display: flex; align-items: center; gap: 10px;
|
||||
font-size: 14px; font-weight: 600; color: #17233d;
|
||||
}
|
||||
.embed-fold > summary::-webkit-details-marker { display: none; }
|
||||
.embed-fold > summary::after { content: "▾"; margin-left: auto; color: #808695; transition: .2s; font-size: 12px; }
|
||||
.embed-fold[open] > summary::after { transform: rotate(180deg); }
|
||||
.embed-fold-b { padding: 2px 16px 16px; }
|
||||
.diagram-img {
|
||||
max-width: 100%; height: auto; display: block; margin: 0 auto;
|
||||
border: 1px solid #f2f4f7; border-radius: 6px;
|
||||
}
|
||||
/* 业务摘要:折叠块内再收一层卡,与摘要行成组(设计基线口径) */
|
||||
.sf { border: 1px solid #e6eefb; border-radius: 8px; overflow: hidden; background: #fcfdff; }
|
||||
.sf-row { display: flex; align-items: flex-start; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #eef3fb; font-size: 13px; }
|
||||
.sf-row:last-child { border-bottom: none; }
|
||||
.sf-k { width: 84px; flex-shrink: 0; color: #808695; }
|
||||
.sf-v { flex: 1; min-width: 0; word-break: break-all; font-weight: 500; }
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
// 办件详情:状态 + 业务摘要 + 流程轨迹时间线 + 分支会签进度(概览 §9.4 进度图数据源)
|
||||
// 办件详情(管理面,2026-10-11 整合):正文升级为与嵌入页 /embed/instance 同源同款——共用
|
||||
// InstanceBody.vue(full 口径):流程信息五项(流程编码/流程版本/业务类型/业务编号/实例号,
|
||||
// 仅管理面显示、嵌入页不外露)+ 基本信息(含业务详情链接)+ 办理进度三视图 + 折叠块
|
||||
// (流程示意图/业务信息/业务表单数据)。动作文案统一为展示层映射(已同意/待审核…),
|
||||
// 全局 TASK_STATUS 码表不动。
|
||||
// 轨迹两档显示(2026-09-18):mode=user(人员,缺省)|dept(科室),写进 query 便于分享/刷新保持,
|
||||
// 并随"流程示意图"跳转带给图示页(两边口径一致)。
|
||||
import { ref, computed, onMounted, nextTick } from "vue";
|
||||
// 并随"流程示意图"跳转带给图示页(两边口径一致)——口径切换由组件 emit,本页落 URL。
|
||||
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 { fitDiagram } from "@/utils/diagramImg";
|
||||
import { Tag } from "view-ui-plus";
|
||||
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||
import InstanceBody from "@/views/flow/components/InstanceBody.vue";
|
||||
import config from "@/assets/config/host.config";
|
||||
|
||||
const route = useRoute();
|
||||
@@ -14,7 +18,7 @@ const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const detail = ref<any>(null);
|
||||
|
||||
/** 显示口径:dept=按科室 / user=按人员(不传=人员) */
|
||||
/** 显示口径:dept=按科室 / user=按人员(不传=人员);组件 emit → 本页写 query 持久化 */
|
||||
const mode = ref<string>(route.query.mode === "dept" ? "dept" : "user");
|
||||
|
||||
function switchMode(val: string) {
|
||||
@@ -22,43 +26,6 @@ function switchMode(val: string) {
|
||||
router.replace({ query: { ...route.query, mode: val } });
|
||||
}
|
||||
|
||||
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 "";
|
||||
@@ -68,17 +35,11 @@ const branchText = computed(() => {
|
||||
|
||||
/** 流程示意图图片直嵌(2026-09-16 "CAS 验证码式":后端 /diagram.png 返回 PNG 流,img 直显)。
|
||||
* img 不走 alova(无 baseUrl 拼接),须显式带 serviceHost——/{ctx}/front/ 子路径部署下
|
||||
* 裸 /api 会落到网关根导致 404。mode 跟随本页选择,缩略图与轨迹口径一致。 */
|
||||
* 裸 /api 会落到网关根导致 404。mode 跟随本页口径,缩略图与轨迹一致(src 随口径切换重新 load)。 */
|
||||
const diagramSrc = computed(
|
||||
() => `${config.serviceHost}/api/flow/diagram.png?instanceId=${route.params.instanceId}&mode=${mode.value}`,
|
||||
);
|
||||
|
||||
/** PNG 按 SCALE=2 出图:显示逻辑尺寸(自然宽一半),超宽收缩到容器(src 随口径切换会重新 load) */
|
||||
const diagramEl = ref<HTMLImageElement | null>(null);
|
||||
function onDiagramLoad() {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
|
||||
/** 跳流程图时带上当前口径(图示页读 mode,缺省人员) */
|
||||
function toDiagram() {
|
||||
router.push({ path: "/diagram", query: { instanceId: String(route.params.instanceId), mode: mode.value } });
|
||||
@@ -89,7 +50,7 @@ function statusName(s: number) {
|
||||
}
|
||||
|
||||
function statusColor(s: number) {
|
||||
return s === 10 ? "blue" : s === 20 ? "green" : s === 30 ? "red" : "gray";
|
||||
return INSTANCE_STATUS_COLOR[s] || "gray";
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -99,10 +60,6 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
await nextTick();
|
||||
if (diagramEl.value?.complete) {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -115,54 +72,12 @@ onMounted(async () => {
|
||||
<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>
|
||||
<InstanceBody full :detail="detail" :diagram-src="diagramSrc" :initial-mode="mode" @mode-change="switchMode">
|
||||
<!-- 折叠块 summary 内的入口:stop 防止点链接同时触发 details 折叠 -->
|
||||
<template #diagram-link>
|
||||
<a class="fold-link" @click.stop.prevent="toDiagram()">查看大图</a>
|
||||
</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>
|
||||
流程示意图
|
||||
<a class="diagram-more" @click="toDiagram()">查看大图</a>
|
||||
</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="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>
|
||||
</InstanceBody>
|
||||
</div>
|
||||
<div v-else class="page muted">加载中…</div>
|
||||
</template>
|
||||
@@ -172,13 +87,7 @@ onMounted(async () => {
|
||||
.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; }
|
||||
.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; }
|
||||
.trace-mode { float: right; font-weight: normal; margin-right: 16px; }
|
||||
/* 折叠块 summary 右侧入口:summary 本体 600 粗体,链接回常规 */
|
||||
.fold-link { font-size: 12px; font-weight: normal; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
<script setup lang="ts">
|
||||
// 办件正文(共用组件,2026-10-11):嵌入页 /embed/instance 与管理面办件详情 /instance/:instanceId
|
||||
// 数据源同出 InstanceDetail(OpenEmbedController 复用管理面出参),展示层共用本组件,样式单点维护。
|
||||
// - embed(缺省,关键信息):基本信息 / 办理进度三视图 / 流程示意图折叠 / 业务信息(只认 summaryFields)
|
||||
// - full(管理面,全量):另有 流程信息五项(流程编码/流程版本/业务类型/业务编号/实例号)、
|
||||
// 业务表单数据折叠、业务详情链接;业务信息无 summaryFields 时回落 bizSnapshot 全键平铺(旧数据兜底)
|
||||
// 口径:人员/科室切换 emit('mode-change') 由管理面写 query 持久化(嵌入页 URL 归子应用管,忽略即可)。
|
||||
// 宽度:版式按嵌入场景调优(740 抽屉内容约 708px),full 下根类 max-width 封顶防宽屏拉散(见 .is-full)。
|
||||
import { ref, computed, onMounted, nextTick } from "vue";
|
||||
import { Tag } from "view-ui-plus";
|
||||
import TraceTimeline from "@/views/flow/components/TraceTimeline.vue";
|
||||
import { fitDiagram } from "@/utils/diagramImg";
|
||||
|
||||
const props = defineProps<{
|
||||
/** InstanceDetail 形状对象(两页同源) */
|
||||
detail: any;
|
||||
/** true=管理面全量(流程信息/业务表单数据/业务详情/摘要回落);缺省 embed 关键信息 */
|
||||
full?: boolean;
|
||||
/** 流程示意图地址(两页端点不同:token 图 / instanceId 图,由页面生成传入) */
|
||||
diagramSrc: string;
|
||||
/** 人员/科室口径初值(管理面从 query 恢复;缺省 user) */
|
||||
initialMode?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{ (e: "mode-change", val: string): void }>();
|
||||
|
||||
/** 办理进度视图:node=按节点(缺省)/ timeline=时间线 / list=列表 */
|
||||
const viewMode = ref<string>("node");
|
||||
function switchView(val: string) {
|
||||
viewMode.value = val;
|
||||
}
|
||||
|
||||
/** 显示口径:dept=科室视图 / user=人员视图(缺省)。切换只 emit,落不落 URL 由页面决定 */
|
||||
const traceMode = ref<string>(props.initialMode === "dept" ? "dept" : "user");
|
||||
function switchMode(val: string) {
|
||||
traceMode.value = val;
|
||||
emit("mode-change", val);
|
||||
}
|
||||
|
||||
const diagramEl = ref<HTMLImageElement | null>(null);
|
||||
function onDiagramLoad() {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
// 折叠时容器 clientWidth=0,fitDiagram 取不到收缩上限;展开这一刻再适配一次
|
||||
function onDiagramToggle(e: Event) {
|
||||
if ((e.target as HTMLDetailsElement).open) fitDiagram(diagramEl.value);
|
||||
}
|
||||
|
||||
/** 业务信息唯一内容 = 约定键 bizSnapshot.summaryFields;形状错 / 无值 ⇒ [](full 再走回落) */
|
||||
const summaryFields = computed(() => {
|
||||
const raw = props.detail?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
let obj: any;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const sf = obj?.summaryFields;
|
||||
if (!Array.isArray(sf) || !sf.length) return [];
|
||||
return sf
|
||||
.filter((x: any) => x && x.label !== null && x.label !== undefined)
|
||||
.slice(0, 6)
|
||||
.map((x: any) => ({
|
||||
label: String(x.label),
|
||||
value: Array.isArray(x.value)
|
||||
? (x.value.join("、") || "-")
|
||||
: (x.value === null || x.value === undefined || x.value === "" ? "-" : String(x.value)),
|
||||
}));
|
||||
});
|
||||
|
||||
/** full 专用回落:无 summaryFields 时按 bizSnapshot 原始键平铺(旧数据/未适配子应用兜底) */
|
||||
const snapshotRows = computed(() => {
|
||||
if (!props.full || summaryFields.value.length) return [];
|
||||
const raw = props.detail?.bizSnapshot;
|
||||
if (!raw) return [];
|
||||
let obj: any = null;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [{ label: "摘要", value: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
return Object.entries(obj).map(([k, v]) => ({ label: k, value: v == null ? "-" : String(v) }));
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
/** 业务信息最终渲染行:summaryFields 优先,full 无值回落 snapshotRows */
|
||||
const bizRows = computed(() => (summaryFields.value.length ? summaryFields.value : snapshotRows.value));
|
||||
|
||||
/** full 专用:流程信息五项(专业字段只进管理面,嵌入页不外露) */
|
||||
const flowInfoRows = computed(() => {
|
||||
const d = props.detail || {};
|
||||
return [
|
||||
{ label: "流程编码", value: d.defCode || "-" },
|
||||
{ label: "流程版本", value: d.defVersion !== null && d.defVersion !== undefined ? `v${d.defVersion}` : "-" },
|
||||
{ label: "业务类型", value: d.bizType || "-" },
|
||||
{ label: "业务编号", value: d.bizId !== null && d.bizId !== undefined && d.bizId !== "" ? `#${d.bizId}` : "-" },
|
||||
{ label: "实例号", value: d.instanceId ? `#${d.instanceId}` : "-" },
|
||||
];
|
||||
});
|
||||
|
||||
/** full 专用:业务表单数据(解析失败降级一行原文;无 formData 时整块隐藏) */
|
||||
const formRows = computed(() => {
|
||||
if (!props.full) return [];
|
||||
const raw = props.detail?.formDataJson;
|
||||
if (raw === null || raw === undefined || raw === "") return [];
|
||||
let obj: any = null;
|
||||
try {
|
||||
obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
} catch {
|
||||
return [{ label: "表单原文", value: String(raw) }];
|
||||
}
|
||||
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
||||
return Object.entries(obj).map(([k, v]) => ({
|
||||
label: k,
|
||||
value: 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 [{ label: "表单原文", value: String(raw) }];
|
||||
});
|
||||
|
||||
// ===== 事件类型口径(与 TraceTimeline.vue 同源:改一处必须同步另一处) =====
|
||||
|
||||
/** 事件类型 → 动作标签主题色(逐字照抄 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",
|
||||
};
|
||||
|
||||
/** 留痕事件行:不参与节点聚合,每行独立成一步(口径同流程中心 toProgressVo) */
|
||||
const EVENT_KINDS = ["SUBMIT", "RESUBMIT", "WITHDRAW", "CC", "ROUTE"];
|
||||
|
||||
/** 多轮才显示"轮次"列(单轮不打扰,口径同 TraceTimeline 的 multiRound) */
|
||||
const multiRound = computed(() =>
|
||||
(props.detail?.trace || []).some((t: any) => (t.round || 1) > 1));
|
||||
|
||||
function fmtTime(v: string | null | undefined) {
|
||||
return v ? String(v).replace("T", " ").replace(/\.\d+$/, "") : "";
|
||||
}
|
||||
|
||||
/** 行发生时间:留痕行取 time,任务行取 handleTime,未办取 arriveTime */
|
||||
function rowTime(r: any): string {
|
||||
return r.time || r.handleTime || r.arriveTime || "";
|
||||
}
|
||||
|
||||
/** 操作者(口径=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 || "-");
|
||||
}
|
||||
|
||||
/** 展示层状态文案(设计图口径:已提交 / 已同意 / 待审核…)。
|
||||
* ⚠️ 刻意不复用全局 TASK_STATUS(0 待办 / 1 同意 / …)——它被 TraceTimeline 与其他页面共用,
|
||||
* 改它会把全站文案一起改掉;此处只做本组件的展示层映射,三视图共用一份。 */
|
||||
const TRACE_STATUS_TEXT: Record<number, string> = {
|
||||
0: "待审核", 1: "已同意", 2: "已拒绝", 3: "失效",
|
||||
4: "已退回", 5: "已移交", 6: "已提交", 7: "已撤回",
|
||||
};
|
||||
|
||||
/** 动作标签(口径同 TraceTimeline.actionText:CC/ROUTE 专用文案,任务行查展示层映射) */
|
||||
function recordAction(t: any): string {
|
||||
if (t.kind === "CC") return "已抄送";
|
||||
if (t.kind === "ROUTE") return "系统判断";
|
||||
return TRACE_STATUS_TEXT[t.status] || String(t.status ?? "");
|
||||
}
|
||||
|
||||
/** 节点步结论:任一拒绝 > 任一同意 > 全失效 > 待审核(口径=流程中心 OapiFlowService.stepLabel) */
|
||||
function stepLabel(rows: any[]): string {
|
||||
let denied = false, approved = false, invalid = true;
|
||||
rows.forEach((r) => {
|
||||
const s = r.status === null || r.status === undefined ? -1 : r.status;
|
||||
if (s === 2) denied = true;
|
||||
if (s === 1) approved = true;
|
||||
if (s === 0 || s === 1 || s === 2 || s === 4 || s === 5) invalid = false;
|
||||
});
|
||||
if (denied) return "已拒绝";
|
||||
if (approved) return "已同意";
|
||||
return invalid ? "失效" : "待审核";
|
||||
}
|
||||
|
||||
function labelTagClass(label: string): string {
|
||||
if (label === "已同意") return "green";
|
||||
if (label === "已拒绝") return "red";
|
||||
if (label === "待审核") return "blue";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/**
|
||||
* trace 扁平行 → 步骤序列:同 nodeId 的审批行归一步,留痕事件行独立成步,步骤按时间升序。
|
||||
* ⚠️ 步内办理人行序 = 后端返回顺序(任务 id 序),不得按时间重排。
|
||||
*/
|
||||
const nodeSteps = computed(() => {
|
||||
const trace: any[] = props.detail?.trace || [];
|
||||
const act: string[] = String(props.detail?.currentNodes || "").split(",").filter(Boolean);
|
||||
const rows = trace.map((r) => ({ ...r, _t: rowTime(r) }));
|
||||
const steps: any[] = [];
|
||||
const byNode: Record<string, any> = {};
|
||||
rows.forEach((r) => {
|
||||
if (EVENT_KINDS.includes(r.kind)) {
|
||||
steps.push({ type: "event", nodeId: r.nodeId, nodeName: r.nodeName, rows: [r], t: r._t });
|
||||
return;
|
||||
}
|
||||
let s = byNode[r.nodeId];
|
||||
if (!s) {
|
||||
s = { type: "node", nodeId: r.nodeId, nodeName: r.nodeName, rows: [], t: r._t };
|
||||
byNode[r.nodeId] = s;
|
||||
steps.push(s);
|
||||
}
|
||||
s.rows.push(r);
|
||||
if ((r._t || "") > (s.t || "")) s.t = r._t;
|
||||
});
|
||||
steps.forEach((s) => {
|
||||
if (s.type === "node") {
|
||||
s.active = act.includes(s.nodeId);
|
||||
s.label = s.active ? "待审核" : stepLabel(s.rows);
|
||||
} else {
|
||||
s.label = recordAction(s.rows[0]);
|
||||
}
|
||||
});
|
||||
return steps.sort((a, b) => (a.t || "").localeCompare(b.t || ""));
|
||||
});
|
||||
|
||||
function stepClass(s: any): string {
|
||||
if (s.type !== "node") return "";
|
||||
if (s.active) return "is-active";
|
||||
if (s.label === "已拒绝") return "is-reject";
|
||||
if (s.label === "已同意") return "is-done";
|
||||
return "";
|
||||
}
|
||||
|
||||
function stepTagColor(s: any): string {
|
||||
return s.type === "node" ? labelTagClass(s.label) : (RECORD_KIND_TAG[s.rows[0].kind] || "default");
|
||||
}
|
||||
|
||||
/** 办理人行色:1 绿 / 2 红 / 3 失效置灰删除线 / 0 待办 */
|
||||
function rowClass(r: any): string {
|
||||
if (r.status === 1) return "is-done";
|
||||
if (r.status === 2) return "is-reject";
|
||||
if (r.status === 3) return "is-invalid";
|
||||
return "";
|
||||
}
|
||||
|
||||
function rowTimeText(r: any): string {
|
||||
if (r.handleTime) return `办理于 ${fmtTime(r.handleTime)}`;
|
||||
if (r.arriveTime) return `到达 ${fmtTime(r.arriveTime)}`;
|
||||
return fmtTime(rowTime(r));
|
||||
}
|
||||
|
||||
/** 基本信息「当前节点」:currentNodes 命中的节点名 + 办理中后缀 */
|
||||
const currentNodeText = computed(() => {
|
||||
const act = String(props.detail?.currentNodes || "").split(",").filter(Boolean);
|
||||
const names = nodeSteps.value
|
||||
.filter((s) => s.type === "node" && act.includes(s.nodeId))
|
||||
.map((s) => s.nodeName || s.nodeId);
|
||||
return names.length ? `${names.join("、")}(办理中)` : "-";
|
||||
});
|
||||
|
||||
const metaItems = computed(() => {
|
||||
const d = props.detail || {};
|
||||
const items = [
|
||||
{ k: "提交人", v: d.submitterName || "-" },
|
||||
{ k: "提交时间", v: fmtTime(d.submitTime) || "-" },
|
||||
{ k: "办结时间", v: fmtTime(d.finishTime) || "-" },
|
||||
];
|
||||
if (d.currentNodes) items.push({ k: "当前节点", v: currentNodeText.value });
|
||||
return items;
|
||||
});
|
||||
|
||||
/** 表格列:Drawer iframe 宽约 708px(740 抽屉)按此设计,full 下容器有 max-width 封顶不会拉散;
|
||||
* 轮次列仅多轮时出现 */
|
||||
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;
|
||||
});
|
||||
|
||||
// 缓存命中的图可能在监听器挂上之前就 complete,补一次适配
|
||||
onMounted(async () => {
|
||||
await nextTick();
|
||||
if (diagramEl.value?.complete) {
|
||||
fitDiagram(diagramEl.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="embed-body" :class="{ 'is-full': full }">
|
||||
<!-- 流程信息(仅管理面):专业字段不外露给子应用嵌入页 -->
|
||||
<div v-if="full" class="embed-sec">
|
||||
<div class="embed-sec-h"><span>流程信息</span></div>
|
||||
<div class="sf">
|
||||
<div v-for="r in flowInfoRows" :key="r.label" class="sf-row">
|
||||
<span class="sf-k">{{ r.label }}</span>
|
||||
<span class="sf-v">{{ r.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="embed-sec">
|
||||
<div class="embed-sec-h"><span>基本信息</span></div>
|
||||
<div class="embed-meta">
|
||||
<div v-for="m in metaItems" :key="m.k" class="embed-meta-i">
|
||||
<div class="embed-meta-k">{{ m.k }}</div>
|
||||
<div class="embed-meta-v" :title="m.v">{{ m.v }}</div>
|
||||
</div>
|
||||
<div v-if="full && detail.bizUrl" class="embed-meta-i">
|
||||
<div class="embed-meta-k">业务详情</div>
|
||||
<div class="embed-meta-v"><a :href="detail.bizUrl" target="_blank">查看业务详情</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="embed-sec">
|
||||
<div class="embed-sec-h">
|
||||
<span>办理进度</span>
|
||||
<RadioGroup class="embed-view-seg" :model-value="viewMode" size="small" type="button" @on-change="switchView">
|
||||
<Radio label="node">按节点</Radio>
|
||||
<Radio label="timeline">时间线</Radio>
|
||||
<Radio label="list">列表</Radio>
|
||||
</RadioGroup>
|
||||
<!-- 口径对三视图均生效(时间线走 :mode、列表走办理人列)⇒ 与视图切换器同排常驻 -->
|
||||
<RadioGroup class="embed-mode-seg" :model-value="traceMode" size="small" type="button" @on-change="switchMode">
|
||||
<Radio label="user">人员视图</Radio>
|
||||
<Radio label="dept">科室视图</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<!-- ① 按节点:同节点多任务每人一行,步内保持后端任务序 -->
|
||||
<template v-if="viewMode === 'node'">
|
||||
<div class="embed-steps" v-if="nodeSteps.length">
|
||||
<div v-for="(s, i) in nodeSteps" :key="`${s.type}-${s.nodeId || 'x'}-${i}`"
|
||||
class="embed-step" :class="stepClass(s)">
|
||||
<span class="embed-step-dot"></span>
|
||||
<div class="embed-step-body">
|
||||
<div class="embed-step-head">
|
||||
<span class="embed-step-name">{{ s.nodeName || s.nodeId || "—" }}</span>
|
||||
<Tag size="small" :color="stepTagColor(s)">{{ s.label }}</Tag>
|
||||
<Tag v-if="s.active" size="small" color="blue">办理中</Tag>
|
||||
</div>
|
||||
<div v-for="(r, j) in s.rows" :key="r.rowKey || j" class="embed-assignee" :class="rowClass(r)">
|
||||
<span class="embed-an">{{ recordActor(r) }}</span>
|
||||
<span class="embed-am">{{ recordAction(r) }}</span>
|
||||
<span class="embed-am">{{ rowTimeText(r) }}</span>
|
||||
<span class="embed-ao" v-if="r.opinion">
|
||||
{{ r.kind === "REJECT" ? "拒绝意见" : "意见" }}:{{ r.opinion }}
|
||||
</span>
|
||||
<span class="embed-ao" v-if="r.edgeName">去向:{{ r.edgeName }}</span>
|
||||
<span class="embed-ao route-hung" v-if="r.kind === 'ROUTE' && !r.outlet">
|
||||
判断失败已挂起(第 {{ r.attempt || 1 }} 次),等待管理端重跑路由
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="muted" v-else>暂无轨迹</div>
|
||||
</template>
|
||||
|
||||
<!-- ② 时间线:扁平行 + 多轮分隔(复用共用组件,口径不变) -->
|
||||
<TraceTimeline v-else-if="viewMode === 'timeline'" :trace="detail.trace || []" :mode="traceMode"
|
||||
:status-text="TRACE_STATUS_TEXT" />
|
||||
|
||||
<!-- ③ 列表:整个流程周期的完整事件流水 -->
|
||||
<Table v-else size="small" :columns="recordColumns" :data="detail.trace || []" />
|
||||
</div>
|
||||
|
||||
<details class="embed-fold" @toggle="onDiagramToggle">
|
||||
<summary>流程示意图<slot name="diagram-link" /></summary>
|
||||
<div class="embed-fold-b">
|
||||
<img class="diagram-img" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="onDiagramLoad" />
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 业务信息:summaryFields 优先;full 无值回落 bizSnapshot 平铺(嵌入页无值整块不显示) -->
|
||||
<details v-if="bizRows.length" class="embed-fold">
|
||||
<summary>业务信息</summary>
|
||||
<div class="embed-fold-b">
|
||||
<div class="sf">
|
||||
<div v-for="f in bizRows" :key="f.label" class="sf-row">
|
||||
<span class="sf-k">{{ f.label }}</span>
|
||||
<span class="sf-v">{{ f.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 业务表单数据(仅管理面):嵌入页不外露原始表单 -->
|
||||
<details v-if="formRows.length" class="embed-fold">
|
||||
<summary>业务表单数据</summary>
|
||||
<div class="embed-fold-b">
|
||||
<div class="sf">
|
||||
<div v-for="f in formRows" :key="f.label" class="sf-row">
|
||||
<span class="sf-k">{{ f.label }}</span>
|
||||
<span class="sf-v">{{ f.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 根类不用 .page/.block:UnoCSS 原子类同名会以更高优先级盖掉 scoped 样式(铁律) */
|
||||
/* 宽度适配:版式按嵌入场景调优(740 抽屉内容约 708px),管理面内容区更宽,full 下封顶防拉散;
|
||||
嵌入页不加限制(iframe 内 100%,行为不变),窄窗口两场景都自然收缩 */
|
||||
.embed-body.is-full { max-width: 900px; }
|
||||
.muted { color: #999; }
|
||||
|
||||
/* 区块标题:左侧蓝条 + 右侧控件 */
|
||||
.embed-sec { margin-bottom: 18px; }
|
||||
.embed-sec-h { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; font-size: 14px; font-weight: 600; }
|
||||
.embed-sec-h > span:first-child { position: relative; padding-left: 9px; }
|
||||
.embed-sec-h > span:first-child::before {
|
||||
content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
|
||||
width: 3px; height: 13px; border-radius: 2px; background: var(--brand);
|
||||
}
|
||||
/* 两处切换器(视图 / 口径)同排在标题行,同为 iView small button group、几何与配色一致
|
||||
(活动态 #0d9488、非活动 #515a6e/#dcdee2)。⚠️ 两者都落在 .embed-sec-h(600) 内 ⇒ 会继承粗体,
|
||||
必须显式回常规(管理面同款控件 .trace-mode 也是这个写法)。 */
|
||||
.embed-view-seg { margin-left: auto; font-weight: normal; }
|
||||
/* 口径紧随视图切换器:与 .embed-sec-h 的 10px gap 叠加为 24px,避免两组按钮被看成一排五选一 */
|
||||
.embed-mode-seg { margin-left: 14px; font-weight: normal; }
|
||||
|
||||
/* 基本信息:四列等宽(740 抽屉实测放得下时间戳,超出省略号 + title 兜底;full 下业务详情作第 5 格换行) */
|
||||
.embed-meta { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid #f2f4f7; border-radius: 8px; overflow: hidden; }
|
||||
.embed-meta-i { padding: 11px 14px; border-right: 1px solid #e8eaec; min-width: 0; }
|
||||
.embed-meta-i:last-child { border-right: none; }
|
||||
.embed-meta-k { font-size: 12px; color: #808695; margin-bottom: 4px; }
|
||||
.embed-meta-v { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 按节点视图 */
|
||||
.embed-steps { position: relative; }
|
||||
.embed-step { display: flex; gap: 11px; padding-bottom: 16px; position: relative; }
|
||||
.embed-step:last-child { padding-bottom: 2px; }
|
||||
.embed-step:not(:last-child)::before {
|
||||
content: ""; position: absolute; left: 5px; top: 16px; bottom: 0; width: 2px; background: #f2f4f7;
|
||||
}
|
||||
.embed-step-dot {
|
||||
width: 12px; height: 12px; border-radius: 50%; background: #c8ced8; margin-top: 5px; flex-shrink: 0;
|
||||
z-index: 1; border: 2px solid #fff; box-shadow: 0 0 0 1px #e8eaec;
|
||||
}
|
||||
.embed-step.is-done .embed-step-dot { background: #19be6b; box-shadow: 0 0 0 1px #b7ebc9; }
|
||||
.embed-step.is-reject .embed-step-dot { background: #ed4014; box-shadow: 0 0 0 1px #ffd2c9; }
|
||||
.embed-step.is-active .embed-step-dot { background: var(--brand); box-shadow: 0 0 0 3px rgba(45, 140, 240, .18); }
|
||||
.embed-step-body { flex: 1; min-width: 0; }
|
||||
.embed-step-head { display: flex; align-items: center; gap: 8px; margin-bottom: 7px; flex-wrap: wrap; }
|
||||
.embed-step-name { font-size: 14px; font-weight: 600; }
|
||||
.embed-assignee {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 13px;
|
||||
padding: 6px 10px; margin-bottom: 5px; border-radius: 6px; background: #fafbfc; border: 1px solid #f0f2f5;
|
||||
}
|
||||
.embed-assignee:last-child { margin-bottom: 0; }
|
||||
.embed-an { font-weight: 600; }
|
||||
.embed-assignee.is-done .embed-an { color: #19be6b; }
|
||||
.embed-assignee.is-reject .embed-an { color: #ed4014; }
|
||||
.embed-am { color: #808695; font-size: 12px; }
|
||||
.embed-ao { color: #515a6e; width: 100%; padding-top: 2px; }
|
||||
.embed-ao.route-hung { color: #ed4014; }
|
||||
.embed-assignee.is-invalid { opacity: .5; }
|
||||
.embed-assignee.is-invalid .embed-an { text-decoration: line-through; text-decoration-color: #b9bcc4; }
|
||||
|
||||
/* 折叠块:流程示意图 / 业务信息 / 业务表单数据,默认收缩 */
|
||||
.embed-fold { border: 1px solid #f2f4f7; border-radius: 8px; background: #fff; margin-bottom: 18px; }
|
||||
.embed-fold > summary {
|
||||
cursor: pointer; list-style: none; padding: 12px 16px; display: flex; align-items: center; gap: 10px;
|
||||
font-size: 14px; font-weight: 600; color: #17233d;
|
||||
}
|
||||
.embed-fold > summary::-webkit-details-marker { display: none; }
|
||||
.embed-fold > summary::after { content: "▾"; margin-left: auto; color: #808695; transition: .2s; font-size: 12px; }
|
||||
.embed-fold[open] > summary::after { transform: rotate(180deg); }
|
||||
.embed-fold-b { padding: 2px 16px 16px; }
|
||||
.diagram-img {
|
||||
max-width: 100%; height: auto; display: block; margin: 0 auto;
|
||||
border: 1px solid #f2f4f7; border-radius: 6px;
|
||||
}
|
||||
/* 行式 k/v(业务信息 / 流程信息 / 业务表单数据共用):折叠块内再收一层卡,与行成组(设计基线口径) */
|
||||
.sf { border: 1px solid #e6eefb; border-radius: 8px; overflow: hidden; background: #fcfdff; }
|
||||
.sf-row { display: flex; align-items: flex-start; gap: 12px; padding: 10px 14px; border-bottom: 1px solid #eef3fb; font-size: 13px; }
|
||||
.sf-row:last-child { border-bottom: none; }
|
||||
.sf-k { width: 84px; flex-shrink: 0; color: #808695; }
|
||||
.sf-v { flex: 1; min-width: 0; word-break: break-all; font-weight: 500; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user