refactor: 移动端TreeCanvas十字星拓扑—主干居中+分支左右发散+连线箭头+极简卡片160x80

This commit is contained in:
2026-08-10 13:52:46 +08:00
parent 38263291d1
commit 6d534960a7

View File

@ -1,6 +1,5 @@
<template>
<view class="tree-canvas-fullscreen">
<!-- 顶部控制栏 -->
<view class="tc-toolbar">
<view class="tc-toolbar-left">
<view class="tc-back-btn" @tap="$emit('back')"> 返回</view>
@ -19,45 +18,41 @@
</view>
<view v-if="!product.task_tree || !product.task_tree.length" class="zero-task">
<text class="zero-icon">📋</text>
<text class="zero-text">该产品暂无流转记录</text>
<text class="zero-icon">📋</text><text class="zero-text">该产品暂无流转记录</text>
</view>
<template v-else>
<movable-area class="tree-movable-area">
<movable-view
class="tree-movable-view"
direction="all"
:x="mvX" :y="mvY"
:scale="true" :scale-min="0.1" :scale-max="5"
:scale-value="mvScale"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
@scale="onScale"
>
<movable-view class="tree-movable-view" direction="all"
:x="mvX" :y="mvY" :scale="true" :scale-min="0.1" :scale-max="5"
:scale-value="mvScale" :style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
@scale="onScale">
<view class="canvas-inner">
<!-- 🔧 连线层 -->
<view v-for="(line, i) in lines" :key="'l'+i"
class="cn-line" :style="lineStyle(line)"
:class="line.dashed ? 'cn-line-dashed' : 'cn-line-solid'" />
<!-- 🔧 连线末端箭头 -->
<view v-for="(arrow, i) in arrows" :key="'a'+i"
class="cn-arrow" :style="{ left: arrow.x + 'px', top: arrow.y + 'px', transform: 'rotate(' + arrow.rot + 'deg)' }"
:class="arrow.dashed ? 'cn-arrow-dashed' : 'cn-arrow-solid'" />
<!-- 🚀 十字星节点 -->
<view v-for="node in treeNodes" :key="node.id"
class="canvas-node" :class="[statusColor(node.status), node.status === 'ARCHIVED' ? 'node-archived' : '']"
class="canvas-node" :class="[statusColor(node.status), node._isMain ? 'node-main' : 'node-sub']"
:style="{ left: node.x + 'px', top: node.y + 'px' }"
@tap="node.records && node.records.length && $emit('viewRecords', node)">
<view class="cn-header">
<text :class="node.parent_task_id ? 'badge-sub' : 'badge-main'">{{ node.parent_task_id ? branchLabelMap[node.id] || '分支' : '主分支' }}</text>
<text :class="['cn-badge', statusColor(node.status), node.status === 'ARCHIVED' ? 'cn-badge-archived' : '']">{{ statusLabel(node.status) }}</text>
</view>
<text class="cn-assignee">负责人: {{ node.assignee_id || '—' }}</text>
<text v-if="node.is_rework" class="tag-rework"> 返工工序</text>
<text v-if="node.status === 'ARCHIVED'" class="tag-archived">📦 已入库</text>
<text v-if="getTaskRemark(node)" class="cn-remark">📌 {{ getTaskRemark(node) }}</text>
<view class="cn-footer">
<text v-if="node.status==='COMPLETED' && (!node.child_tasks || !node.child_tasks.length)" class="cn-end">🏁 终止分支</text>
<text v-else-if="node.status==='ARCHIVED'" class="cn-end cn-end-wh">📦 入库归档</text>
<text v-if="node.records && node.records.length" class="cn-records">📋 {{ node.records.length }}条记录 </text>
<text :class="node._isMain ? 'badge-main' : 'badge-sub'">{{ node._isMain ? '主分支' : node._blabel }}</text>
<text :class="['cn-badge-mini', statusColor(node.status)]">{{ statusLabel(node.status) }}</text>
</view>
<text class="cn-assignee">{{ node.assignee_id || '—' }}</text>
<text class="cn-time"> {{ fmtDate(node.created_at) }}{{ node.completed_at ? '→' + fmtDate(node.completed_at) : '→至今' }}</text>
<text v-if="node.records && node.records.length" class="cn-records-mini">{{ node.records.length }}</text>
</view>
</view>
</movable-view>
</movable-area>
</template>
@ -67,198 +62,147 @@
<script>
export default {
name: "TreeCanvas",
props: {
product: { type: Object, required: true }
},
props: { product: { type: Object, required: true } },
emits: ["viewRecords", "back", "swipe"],
data() {
return {
mvX: 0,
mvY: 0,
mvScale: 1,
viewportW: 375,
viewportH: 600,
mvX: 0, mvY: 0, mvScale: 1,
viewportW: 375, viewportH: 600,
};
},
mounted() {
try {
const info = uni.getSystemInfoSync();
this.viewportW = info.windowWidth || 375;
this.viewportH = info.windowHeight || 600;
} catch (e) { /* ignore */ }
this.$nextTick(() => { this.fitAll(); });
try { const info = uni.getSystemInfoSync(); this.viewportW = info.windowWidth || 375; this.viewportH = info.windowHeight || 600; } catch {}
this.$nextTick(() => this.fitAll());
},
computed: {
// ============================================================
// 🚀 十字星并发拓扑算法
// ============================================================
treeNodes() {
if (!this.product || !this.product.task_tree) return [];
const CARD_W = 160, CARD_H = 80, GAP_X = 24, GAP_Y = 24;
const CENTER_X = 0; // 中央主干 X
const CARD_W = 320, CARD_H = 460, GAP_X = 80, GAP_Y = 120;
const nodes = [];
const rowMaxX = {};
let currentMaxYIdx = -1;
// 🚀 深拷贝:防止 Vue props readonly Proxy 异常导致白屏!
const cloneTree = (tasks) => {
if (!tasks) return [];
return tasks.map(t => ({
...t,
child_tasks: cloneTree(t.child_tasks),
records: t.records ? [...t.records] : []
}));
};
const workTree = cloneTree(this.product.task_tree);
// 拍平 + 深拷贝
const flatMap = {};
const flatten = (tasks) => {
if (!tasks) return;
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
};
flatten(workTree);
const layoutNode = (t) => {
if (t._visited) return;
t._visited = true;
if (!t.parent_task_id) {
// 规则A根节点 → 新行 X=0
currentMaxYIdx++;
t._yIdx = currentMaxYIdx;
t._xIdx = 0;
} else {
const parent = flatMap[t.parent_task_id];
if (parent && !parent._visited) layoutNode(parent);
const type = t.task_type || (parent && parent.status === 'CANCELED' ? 'RECOVERY' : 'SPAWN');
if (type === 'SPAWN') {
// 🚀 增加 parent 存在性防御
t._yIdx = parent ? parent._yIdx : 0;
t._xIdx = (rowMaxX[t._yIdx] !== undefined ? rowMaxX[t._yIdx] : 0) + 1;
} else if (type === 'TRANSFER' || type === 'MAIN') {
currentMaxYIdx++;
t._yIdx = currentMaxYIdx;
t._xIdx = 0;
} else if (type === 'RECOVERY') {
currentMaxYIdx++;
t._yIdx = currentMaxYIdx;
// 🚀 增加 parent 存在性防御
t._xIdx = parent ? parent._xIdx : 0;
}
}
rowMaxX[t._yIdx] = Math.max(rowMaxX[t._yIdx] || 0, t._xIdx);
t.x = 40 + t._xIdx * (CARD_W + GAP_X);
t.y = 20 + t._yIdx * (CARD_H + GAP_Y);
nodes.push(t);
if (t.child_tasks && t.child_tasks.length) {
// 🚀 先按类型排序,同类型再按创建时间(旧在上、新在下)
[...t.child_tasks]
.sort((a, b) => {
const aTrans = a.task_type === 'TRANSFER' ? -1 : 1;
const bTrans = b.task_type === 'TRANSFER' ? -1 : 1;
if (aTrans !== bTrans) return aTrans - bTrans;
return new Date(a.created_at) - new Date(b.created_at);
})
.forEach(child => layoutNode(child));
}
};
workTree.forEach(root => layoutNode(root));
return nodes;
},
branchLabelMap() {
const map = {};
if (!this.product || !this.product.task_tree) return map;
// 拍平树结构,方便随时查父节点状态
const flatMap = {};
const flatten = (tasks) => {
if (!tasks) return;
tasks.forEach(t => { flatMap[t.id] = t; flatten(t.child_tasks); });
};
flatten(this.product.task_tree);
const allTasks = [];
const walk = (tasks) => { if (!tasks) return; tasks.forEach(t => { allTasks.push(t); flatMap[t.id] = t; walk(t.child_tasks); }); };
walk(this.product.task_tree);
// 分支编号
const branchLabelMap = {};
const traverse = (tasks, prefix) => {
if (!tasks) return;
let spawnIndex = 0;
tasks.forEach((t) => {
const parent = t.parent_task_id ? flatMap[t.parent_task_id] : null;
// 🚀 终极防御判定:
// 1. 没有父节点(打底任务) -> 主分支
// 2. 明确的基因 (TRANSFER/RECOVERY) -> 主分支
// 3. 兜底:如果基因丢失,但它的父节点已经完工(COMPLETED),那它必然是接力的转交任务 -> 主分支
const isMain = !t.parent_task_id
|| t.task_type === 'TRANSFER'
|| t.task_type === 'RECOVERY'
|| (!t.task_type && parent && (parent.status === 'COMPLETED' || parent.status === 'CANCELED'));
if (isMain) {
map[t.id] = '主分支';
traverse(t.child_tasks, prefix);
} else {
spawnIndex++;
const num = prefix ? `${prefix}.${spawnIndex}` : `${spawnIndex}`;
map[t.id] = `分支 ${num}`;
traverse(t.child_tasks, num);
}
let si = 0;
tasks.forEach(t => {
const p = t.parent_task_id ? flatMap[t.parent_task_id] : null;
const isM = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || (!t.task_type && p && (p.status === 'COMPLETED' || p.status === 'CANCELED'));
if (isM) { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, prefix); }
else { si++; const n = prefix ? prefix + '.' + si : '' + si; branchLabelMap[t.id] = '分支 ' + n; traverse(t.child_tasks, n); }
});
};
this.product.task_tree.forEach((t) => {
map[t.id] = '主分支';
traverse(t.child_tasks, '');
this.product.task_tree.forEach(t => { branchLabelMap[t.id] = '主分支'; traverse(t.child_tasks, ''); });
// 主管分类
const mains = []; const subs = []; const subByParent = {};
allTasks.forEach(t => {
const p = t.parent_task_id ? flatMap[t.parent_task_id] : null;
const isM = !t.parent_task_id || t.task_type === 'TRANSFER' || t.task_type === 'RECOVERY' || (!t.task_type && p && (p.status === 'COMPLETED' || p.status === 'CANCELED'));
if (isM) { mains.push(t); }
else { subs.push(t); const pid = t.parent_task_id || ''; if (!subByParent[pid]) subByParent[pid] = []; subByParent[pid].push(t); }
});
return map;
mains.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
// 计算坐标
const nodes = [];
const childSeen = new Set();
mains.forEach((m, mi) => {
m.x = CENTER_X; m.y = mi * (CARD_H + GAP_Y) * 2;
m._isMain = true; m._blabel = '主分支';
nodes.push(m);
const children = subByParent[m.id] || [];
children.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
children.forEach((c, ci) => {
const isLeft = ci % 2 === 0;
const dist = Math.floor(ci / 2) + 1;
c.y = m.y;
c.x = isLeft ? CENTER_X - dist * (CARD_W + GAP_X) : CENTER_X + dist * (CARD_W + GAP_X);
c._isMain = false; c._blabel = branchLabelMap[c.id] || '分支';
nodes.push(c);
childSeen.add(c.id);
});
});
// 遗留分支(父任务不在 mains 中的)
subs.forEach(s => { if (!childSeen.has(s.id)) { s.x = 200; s.y = nodes.length ? nodes[nodes.length - 1].y + CARD_H + GAP_Y : 0; s._isMain = false; s._blabel = '分支'; nodes.push(s); } });
// 加上全局偏移
const padding = 40;
nodes.forEach(n => { n.x += Math.abs(CENTER_X) + CARD_W * 3; n.y += padding; });
return nodes;
},
canvasWidth() { if (!this.treeNodes.length) return 400; return Math.max(1200, Math.max(...this.treeNodes.map(n => n.x)) + 400); },
canvasHeight() { if (!this.treeNodes.length) return 400; return Math.max(1600, Math.max(...this.treeNodes.map(n => n.y)) + 400); },
// ============================================================
// 🚀 连线计算:中央 → 分支
// ============================================================
lines() {
const result = [];
const flatMap = {};
this.treeNodes.forEach(n => flatMap[n.id] = n);
this.treeNodes.forEach(n => {
if (n._isMain) return;
const parent = n.parent_task_id ? flatMap[n.parent_task_id] : null;
if (!parent) return;
const px = parent.x + 160; // 卡片右边缘
const py = parent.y + 40; // 卡片垂直中心
const nx = n.x; // 分支卡片左边缘
const ny = n.y + 40;
result.push({ x1: Math.min(px, nx), y1: py, x2: Math.max(px, nx), y2: py, dashed: parent.status === 'COMPLETED' || parent.status === 'ARCHIVED' });
});
return result;
},
arrows() {
const result = [];
const flatMap = {};
this.treeNodes.forEach(n => flatMap[n.id] = n);
this.treeNodes.forEach(n => {
if (n._isMain) return;
const parent = n.parent_task_id ? flatMap[n.parent_task_id] : null;
if (!parent) return;
const isLegacy = parent && (parent.status === 'COMPLETED' || parent.status === 'ARCHIVED');
const isLeft = n.x < parent.x;
result.push({
x: isLeft ? n.x + 165 : n.x - 12,
y: n.y + 36,
rot: isLeft ? 180 : 0,
dashed: isLegacy,
});
});
return result;
},
// ============================================================
canvasWidth() { const ns = this.treeNodes; if (!ns.length) return 400; return Math.max(...ns.map(n => n.x)) + 200; },
canvasHeight() { const ns = this.treeNodes; if (!ns.length) return 400; return Math.max(...ns.map(n => n.y)) + 200; },
fitScale() {
if (!this.treeNodes.length) return 1;
const vw = this.viewportW || 375;
const vh = this.viewportH || 600;
const cw = this.canvasWidth;
const ch = this.canvasHeight;
const sx = (vw - 32) / cw;
const sy = (vh - 100) / ch;
return Math.max(0.1, Math.min(sx, sy, 1));
return Math.max(0.1, Math.min((this.viewportW - 32) / this.canvasWidth, (this.viewportH - 100) / this.canvasHeight, 1));
}
},
methods: {
onScale(e) { if (e && e.detail && typeof e.detail.scale === 'number') this.mvScale = e.detail.scale; },
onScale(e) { if (e?.detail?.scale) this.mvScale = e.detail.scale; },
zoomIn() { this.mvScale = Math.min(5, +(this.mvScale + 0.2).toFixed(1)); },
zoomOut() { this.mvScale = Math.max(0.1, +(this.mvScale - 0.2).toFixed(1)); },
fitAll() {
const s = this.fitScale;
this.mvScale = s;
this.mvX = 0;
this.mvY = 0;
},
getTaskRemark(t) {
if (!t) return "";
if (t.remark) return t.remark;
if (t.records && t.records.length > 0) {
const rec = t.records.find(r => r.remark && r.remark.trim().length > 0);
if (rec) return rec.remark;
}
return "";
},
lineStyle(line) {
const dx = line.x2 - line.x1; const dy = line.y2 - line.y1;
const len = Math.sqrt(dx * dx + dy * dy);
const angle = Math.atan2(dy, dx) * 180 / Math.PI;
return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' };
},
statusLabel(s) { const map = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return map[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; case "CANCELED": return "s-canceled"; case "ARCHIVED": return "s-archived"; default: return "s-gray"; } }
fitAll() { this.mvScale = this.fitScale; this.mvX = 0; this.mvY = 0; },
fmtDate(d) { if (!d) return ""; const dt = new Date(d); return (dt.getMonth() + 1) + '/' + dt.getDate(); },
statusLabel(s) { const m = { PENDING: "待接收", WIP: "进行中", COMPLETED: "已完成", REJECTED: "已驳回", ARCHIVED: "已入库", CANCELED: "已撤回" }; return m[s] || s; },
statusColor(s) { switch (s) { case "PENDING": return "s-yellow"; case "WIP": return "s-blue"; case "COMPLETED": return "s-green"; case "REJECTED": return "s-red"; case "CANCELED": return "s-canceled"; case "ARCHIVED": return "s-archived"; default: return "s-gray"; } },
lineStyle(line) { const dx = line.x2 - line.x1; const dy = line.y2 - line.y1; const len = Math.sqrt(dx * dx + dy * dy); const angle = Math.atan2(dy, dx) * 180 / Math.PI; return { left: line.x1 + 'px', top: line.y1 + 'px', width: len + 'px', transform: `rotate(${angle}deg)`, transformOrigin: '0 0' }; }
}
};
</script>
<style scoped>
.tree-canvas-fullscreen { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 100; display: flex; flex-direction: column; background: #e8ecf0; }
/* 工具栏 */
.tc-toolbar { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; padding-top: calc(10px + env(safe-area-inset-top)); background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); border-bottom: 1px solid #e5e7eb; flex-shrink: 0; z-index: 10; }
.tc-toolbar-left { display: flex; align-items: center; gap: 12px; }
.tc-back-btn { font-size: 14px; font-weight: 700; color: #2563eb; padding: 6px 12px; background: #eff6ff; border-radius: 8px; }
@ -270,41 +214,39 @@ export default {
.tc-zoom-label { font-size: 12px; font-weight: 600; color: #6b7280; min-width: 42px; text-align: center; }
.tc-mode-btn { width: auto; padding: 0 10px; font-size: 12px; background: #ede9fe; color: #7c3aed; }
.tc-fit-btn { width: auto; padding: 0 12px; font-size: 13px; background: #dbeafe; color: #2563eb; }
.zero-task { display: flex; flex-direction: column; align-items: center; padding: 60px 0; }
.zero-icon { font-size: 40px; margin-bottom: 10px; }
.zero-text { font-size: 14px; color: #9ca3af; }
.tree-movable-area { flex: 1; width: 100%; background-color: #f0f2f5; background-image: linear-gradient(#dde1e6 1px, transparent 1px), linear-gradient(90deg, #dde1e6 1px, transparent 1px); background-size: 24px 24px; }
.canvas-inner { position: absolute; left: 0; top: 0; }
/* 节点卡片 */
.canvas-node { position: absolute; width: 320px; min-height: 460px; background: #ffffff; border-radius: 20px; padding: 24px; box-shadow: 0 10px 30px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.06); border-left: 12px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 10px; transition: opacity 0.2s; }
/* 🔧 连线 */
.cn-line { position: absolute; height: 2px; }
.cn-line-solid { background: #9ca3af; }
.cn-line-dashed { background: repeating-linear-gradient(90deg, #fdba74 0, #fdba74 6px, transparent 6px, transparent 10px); }
/* 🔧 连线箭头 */
.cn-arrow { position: absolute; width: 0; height: 0; border-left: 6px solid #9ca3af; border-top: 4px solid transparent; border-bottom: 4px solid transparent; }
.cn-arrow-dashed { border-left-color: #fdba74; }
/* 🔧 极简卡片 160x80 */
.canvas-node { position: absolute; width: 160px; min-height: auto; padding: 10px 12px; background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); border-left: 6px solid #3b82f6; z-index: 10; display: flex; flex-direction: column; gap: 4px; font-size: 11px; }
.canvas-node.node-sub { border-left-color: #7c3aed; }
.canvas-node.s-yellow { border-left-color: #f59e0b; }
.canvas-node.s-blue { border-left-color: #3b82f6; }
.canvas-node.s-green { border-left-color: #22c55e; }
.canvas-node.s-red { border-left-color: #ef4444; }
.canvas-node.s-canceled { border-left-color: #9ca3af; opacity: 0.5; filter: grayscale(0.6); }
/* 🚀 ARCHIVED 入库卡片专属样式 */
.canvas-node.s-archived { border-left-color: #8b5cf6; opacity: 0.72; }
.canvas-node.s-archived::after { content: "📦"; position: absolute; right: 20px; bottom: 20px; font-size: 60px; opacity: 0.08; pointer-events: none; }
.canvas-node.s-canceled { border-left-color: #9ca3af; opacity: 0.4; }
.canvas-node.s-archived { border-left-color: #8b5cf6; opacity: 0.65; }
.cn-header { display: flex; align-items: center; justify-content: space-between; }
.badge-main { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #2563eb; color: #fff; font-weight: 700; }
.badge-sub { font-size: 10px; padding: 2px 8px; border-radius: 6px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
.cn-badge { font-size: 14px; padding: 4px 12px; border-radius: 8px; font-weight: 700; }
.cn-badge.s-yellow { background: #fef3c7; color: #b45309; }
.cn-badge.s-blue { background: #dbeafe; color: #1d4ed8; }
.cn-badge.s-green { background: #dcfce7; color: #15803d; }
.cn-badge.s-red { background: #fce4ec; color: #be123c; }
.cn-badge-archived { background: #ede9fe; color: #7c3aed; }
.cn-assignee { font-size: 16px; color: #6b7280; font-weight: 500; margin-top: 10px; }
.tag-rework { font-size: 13px; color: #fff; background: #ef4444; padding: 4px 10px; border-radius: 6px; display: inline-block; width: max-content; }
.tag-archived { font-size: 14px; color: #7c3aed; background: #ede9fe; padding: 6px 12px; border-radius: 8px; display: inline-block; width: max-content; font-weight: 700; border: 2px dashed #a78bfa; }
.cn-remark { font-size: 16px; color: #a16207; background: #fefce8; padding: 16px; border-radius: 8px; border: 1px solid #fef08a; line-height: 1.5; word-break: break-all; min-height: 100px; white-space: normal; margin-top: 16px; }
.cn-footer { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 14px; border-top: 1px dashed #e5e7eb; }
.cn-end { font-size: 13px; font-weight: 700; color: #16a34a; }
.cn-end-wh { color: #7c3aed; }
.cn-records { font-size: 14px; color: #2563eb; font-weight: 700; background: #eff6ff; padding: 4px 12px; border-radius: 12px; }
.badge-main { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #2563eb; color: #fff; font-weight: 700; }
.badge-sub { font-size: 9px; padding: 1px 6px; border-radius: 4px; background: #ede9fe; color: #7c3aed; font-weight: 700; }
.cn-badge-mini { font-size: 9px; padding: 1px 6px; border-radius: 4px; font-weight: 700; }
.cn-badge-mini.s-yellow { background: #fef3c7; color: #b45309; }
.cn-badge-mini.s-blue { background: #dbeafe; color: #1d4ed8; }
.cn-badge-mini.s-green { background: #dcfce7; color: #15803d; }
.cn-badge-mini.s-red { background: #fce4ec; color: #be123c; }
.cn-badge-mini.s-archived { background: #ede9fe; color: #7c3aed; }
.cn-assignee { font-size: 10px; color: #6b7280; }
.cn-time { font-size: 9px; color: #9ca3af; }
.cn-records-mini { font-size: 9px; color: #2563eb; background: #eff6ff; padding: 1px 6px; border-radius: 6px; display: inline-block; width: max-content; }
</style>