refactor(detail): 办件详情与嵌入页整合共用 InstanceBody——详情页升级三视图+流程信息五项+表单数据,嵌入页表现不变
build / build (push) Successful in 2m55s

This commit is contained in:
chenxing
2026-10-11 11:24:09 +08:00
parent a998e455d2
commit ecd4b9f4ea
3 changed files with 566 additions and 533 deletions
+540
View File
@@ -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>