build / build (push) Successful in 1m57s
- 文案:按人员/按科室 → 人员视图/科室视图 - 排列(方案 A):口径切换器移入 .embed-sec-h 标题行、紧跟视图切换器 (.embed-mode-seg margin-left:14px,与父级 10px gap 叠加为 24px),不再独立成行右对齐 - 常驻修复:原在 <template v-if="viewMode==='node'"> 内 ⇒ 切到时间线/列表即消失, 但口径对三视图都生效(时间线走 :mode、列表办理人列走 recordActor)⇒ 移出模板、三视图常驻 - CSS:删除已无引用的 .embed-mode-row;新增 .embed-mode-seg 并显式 font-weight:normal (同 .embed-view-seg:落在 .embed-sec-h(600) 内会继承粗体) - 版式宽 740 下实测:标题 68 + 视图组 187 + 口径组 156 + 间隔 24 = 435 < 可用 700,不溢出
504 lines
24 KiB
Vue
504 lines
24 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 既有链路。
|
||
// v2.4 版式(2026-10-10):原「流程轨迹」+「审批记录」合并为「办理进度」三视图(按节点/时间线/列表),
|
||
// 流程示意图与业务信息改为默认收缩的折叠块;业务信息只渲染约定键 bizSnapshot.summaryFields,
|
||
// 无值整块不显示;bizSnapshot 其余键与 formDataJson 一律不再平铺(原始数据不外露)。
|
||
// v2.5(2026-10-10):口径切换器(人员视图/科室视图)与视图切换器同排在标题行、三视图常驻——口径实际对
|
||
// 三个视图都生效(时间线走 :mode、列表走办理人列),原先只在「按节点」模板内会切视图即消失。
|
||
import { ref, computed, onMounted, nextTick } 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 config from "@/assets/config/host.config";
|
||
|
||
const route = useRoute();
|
||
const detail = ref<any>(null);
|
||
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;
|
||
}
|
||
|
||
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>
|
||
|
||
<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>
|
||
</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: 0 20px 28px; box-sizing: border-box; }
|
||
/* 标题区吸顶(贴合设计基线):左右负外边距让白色背景铺满滚动容器,滚动时内容不从两侧留白露出 */
|
||
.head {
|
||
position: sticky; top: 0; z-index: 5; background: #fff;
|
||
display: flex; align-items: center; gap: 8px;
|
||
margin: 0 -20px 16px; padding: 14px 20px 12px; border-bottom: 1px solid #f2f4f7;
|
||
}
|
||
.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: #2d8cf0;
|
||
}
|
||
/* 两处切换器(视图 / 口径)现同排在标题行,同为 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: #2d8cf0; 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>
|