Files
flow-center-front/src/views/flow/components/InstanceBody.vue
T
chenxing 8506ad80d7
build / build (push) Successful in 3m11s
feat(detail): 列表详情改为抽屉弹出+业务信息只认 summaryFields
- 新增 InstanceDrawer.vue(Drawer 740px + InstanceBody full 口径,非 iframe)
- Workbench/MyInstances/admin.Instances 详情按钮改为打开抽屉
- InstanceBody 移除 snapshotRows 回落,业务信息只显示子应用传递的
  summaryFields(中文 label),无值整块隐藏不再平铺英文 key
2026-10-11 12:01:43 +08:00

525 lines
25 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">
// 办件正文(共用组件,2026-10-11):嵌入页 /embed/instance 与管理面办件详情 /instance/:instanceId
// 数据源同出 InstanceDetail(OpenEmbedController 复用管理面出参),展示层共用本组件,样式单点维护。
// - embed(缺省,关键信息):基本信息 / 办理进度三视图 / 流程示意图折叠 / 业务信息(只认 summaryFields)
// - full(管理面,全量):另有 流程信息五项(流程编码/流程版本/业务类型/业务编号/实例号)、
// 业务表单数据折叠、业务详情链接;业务信息只认子应用传递的 summaryFields,无则隐藏
// 口径:人员/科室切换 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;由发起流程的子应用决定展示字段与中文 label。
* 形状错 / 无值 ⇒ [],不再 fallback 平铺 bizSnapshot 原始英文 key(避免详情页出现 itemTitle / deptName 等)。 */
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)),
}));
});
/** 业务信息最终渲染行:只认子应用传递的 summaryFields */
const bizRows = computed(() => summaryFields.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,无值整块隐藏 -->
<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>