Files
flow-center-front/src/views/flow/EmbedInstance.vue
T
chenxing a4cc8ea2fa
build / build (push) Successful in 1m57s
fix(flow-embed): 口径切换器改「人员视图/科室视图」,与视图切换器同排常驻
- 文案:按人员/按科室 → 人员视图/科室视图
- 排列(方案 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,不溢出
2026-10-10 17:56:14 +08:00

504 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 办件进度嵌入页(iframe 免签嵌入,2026-10-03 方案):子应用 Drawer 里以 iframe 打开本页,
// 展示"进度 + 流程示意图",零样式复制、单点维护。全程零 CAS 交互——数据走 /open/flow/embed/data
// 免签端点(只认 token),示意图 token 两用(/open/flow/diagram/img 同一 token 出图)。
// 本页只读(方案 §9):不做任何审批动作;办理入口仍是待办站内信 → /task/:taskId 既有链路。
// 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>