From 0fd4fb1f7ab339e2992db0832fe9b8a24c86365a Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 16:51:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E6=8B=93=E6=89=91=E6=8C=89?= =?UTF-8?q?=E5=AE=9E=E6=B5=8B=E5=AE=B9=E5=99=A8=E5=AE=BD=E5=B8=83=E5=B1=80?= =?UTF-8?q?=20+=20=E5=AD=97=E5=8F=B7/=E6=88=AA=E6=96=AD=EF=BC=8C=E4=BF=AE?= =?UTF-8?q?=E9=97=B4=E8=B7=9D=E5=A4=B1=E8=A1=A1=E4=B8=8E=E6=96=87=E5=AD=97?= =?UTF-8?q?=E9=9A=BE=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处实机问题: 1) viewBox 固定 640 而容器 ~920,浏览器按 'meet' 把内容顶到左上、右侧空出一大片 —— 观感就是「间距不对」。改为 viewBox 宽 = 实测容器宽、width 用像素值, 缩放恒为 1(已用 getScreenCTM().a 验证)。 2) 文字全是 9-11px + 低对比度硬编码色(#8b90a5)→ 难读。字号提到 10.5-12.5px, fill/font-size 改走 .tp-* 类,颜色交给 --text-primary/--text-muted 主题变量。 3) 列短的一侧原来顶在带上半、节点居中,连线又长又歪;长通道名还会溢出到邻居身上。 现在两列在带内各自居中、节点块高度参与带高计算(单行带不再把节点名压到下一条带), 长名按估算宽度截断加 …,完整名放 悬停可见。 另:rtSpark 用的 _rtEdgeIn/_rtEdgeOut 键与取值方式未变,光点动画照旧。 --- internal/plugins/webui/dashboard.css | 13 +++++ internal/plugins/webui/dashboard.js | 82 +++++++++++++++++++++------- 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css index 994eca5..5de23aa 100644 --- a/internal/plugins/webui/dashboard.css +++ b/internal/plugins/webui/dashboard.css @@ -2397,6 +2397,19 @@ .rt-svg text { fill: var(--text-primary); } + /* 拓扑图的文字全部走类(不用 font-size/fill 属性): + 1) 字号统一可读(原来 9–11px 且低对比度,实机很难看清); + 2) 颜色交给主题变量,切主题/配色时不用重算 SVG。 + 长名截断由 JS 的 rtClip 负责(SVG 没有 text-overflow)。 */ + .rt-topo-svg .tp-name { font-size: 12px; fill: var(--text-primary); } + .rt-topo-svg .tp-sub { font-size: 10.5px; fill: var(--text-muted); font-variant-numeric: tabular-nums; } + .rt-topo-svg .tp-val { font-size: 12.5px; font-weight: 700; fill: var(--text-primary); } + .rt-topo-svg .tp-lab { font-size: 11px; font-weight: 600; } + .rt-topo-svg .tp-warn { font-size: 10.5px; fill: #ffb86b; } + .rt-topo-svg .tp-hint { font-size: 10.5px; fill: var(--text-muted); } + .rt-topo-svg .tp-edge { fill: none; } + .rt-topo-svg .tp-edge-in { stroke-opacity: 0.45; stroke-width: 1.4; } + .rt-topo-svg .tp-edge-out { stroke-opacity: 0.35; stroke-width: 1.2; } /* 第五条:排队队列(无级别)。用虚线分隔 + 中性色,与“有级别”的四条区分开—— 它不是优先级,而是另一**类别**(排队 vs 中断),同一套配色会误导。 */ .rt-level.rt-level-queued { diff --git a/internal/plugins/webui/dashboard.js b/internal/plugins/webui/dashboard.js index cccd555..eed9a04 100644 --- a/internal/plugins/webui/dashboard.js +++ b/internal/plugins/webui/dashboard.js @@ -925,9 +925,41 @@ // 右边是它能写的 outputch。连线即路由;光点沿连线跑表示消息正在流动。 // 与旧版「归属框 → 单个内核盒」的差别:内核盒只有一个,看不出"这条输入到底 // 喂给了哪个子",而子 agent 才是运行态里最该看清的东西。 + // rtTopoWidth 用**实测容器宽**当 viewBox 宽:viewBox 宽与渲染宽一致时缩放才是 + // 1:1。此前固定 640 而容器 ~920,浏览器按 "meet" 把内容顶在左上、右侧空出一大块, + // 观感就是"间距不对"。 + function rtTopoWidth() { + var host = document.getElementById("rt-sec-topo"); + var w = host ? host.clientWidth : 0; + if (!w) { + var panel = document.getElementById("rt-panel"); + w = panel ? panel.clientWidth : 0; + } + if (!w || w < 360) w = 900; + return Math.round(w) - 4; + } + + // rtClip 按估算宽度截断长通道名(SVG 没有 text-overflow,只能自己量)。 + // 全角按 1em、其余按 0.56em 估宽:只求不越界,不求像素级精确。 + function rtClip(text, maxPx, fontPx) { + var s = String(text == null ? "" : text); + var w = 0; + var i = 0; + for (; i < s.length; i++) { + var cw = s.charCodeAt(i) > 0x2e80 ? fontPx : fontPx * 0.56; + if (w + cw > maxPx) break; + w += cw; + } + if (i >= s.length) return s; + return s.slice(0, Math.max(1, i - 1)) + "…"; + } + function rtAgentTopology(agents) { - var ROW = 26, GAP = 16, NODE_R = 19, W = 640, MAX_OUT = 8; - var IN_X = 6, IN_W = 176, NX = 272, OUT_X = 344, OUT_W = 244; + var W = rtTopoWidth(); + var ROW = 30, BOX_H = 20, GAP = 14, NODE_R = 20, MAX_OUT = 8; + // 三列按比例分:输入 0~32%,节点居中在 44%,输出 56%~100%,两侧留白对称。 + var IN_X = 0, IN_W = Math.round(W * 0.32); + var NX = Math.round(W * 0.44), OUT_X = Math.round(W * 0.56), OUT_W = W - Math.round(W * 0.56) - 2; var out = []; _rtEdgeIn = {}; _rtEdgeOut = {}; @@ -942,27 +974,37 @@ } var y = 8; if (!agents.length) { - out.push('<text x="8" y="24" font-size="11" fill="#8b90a5">' + __("暂无通道 / agent", "no channels / agents") + "</text>"); + out.push('<text class="tp-hint" x="8" y="24">' + __("暂无通道 / agent", "no channels / agents") + "</text>"); y = 44; } agents.forEach(function (a) { - var rows = Math.max(a.inputs.length, a.outputs.length, 1); - var bandH = rows * ROW + GAP; - var cy = y + (rows * ROW) / 2; + var nin = a.inputs.length; + var nout = Math.min(a.outputs.length, MAX_OUT); + var rows = Math.max(nin, nout, 1); + var truncated = a.outputs.length > MAX_OUT; + // 内容高必须**容得下节点块**(圆环 + 下方标签一行),否则单行带里节点和它的 + // 名字会溢出到上/下一个带上("间距不对"的一个来源)。 + var nodeBlock = NODE_R * 2 + (a.res && a.res.context_full ? 52 : 20); + var contentH = Math.max(rows * ROW, nodeBlock, truncated ? nout * ROW + 18 : 0); + var bandH = contentH + GAP; + var cy = y + contentH / 2; + // 短的一列在带内**居中**:连线短而对称,不再出现"左边挤在上半、右边铺满"的错位感。 + var inTop = y + (contentH - nin * ROW) / 2; + var outTop = y + (contentH - nout * ROW) / 2; out.push('<rect x="0" y="' + (y - 3) + '" width="' + W + '" height="' + (bandH - 4) + '" rx="10" fill="' + a.color + '" fill-opacity="0.05"/>'); out.push('<rect x="0" y="' + (y - 3) + '" width="3" height="' + (bandH - 4) + '" rx="1.5" fill="' + a.color + '" fill-opacity="0.7"/>'); // 输入通道 → agent a.inputs.forEach(function (c, i) { - var ry = y + i * ROW + ROW / 2; - out.push('<rect x="' + IN_X + '" y="' + (ry - 9) + '" width="' + IN_W + '" height="18" rx="6" fill="rgba(255,255,255,0.05)" stroke="' + a.color + '" stroke-opacity="0.35"/>'); - out.push('<text x="' + (IN_X + 9) + '" y="' + (ry + 4) + '" font-size="11">' + esc(c.name) + "</text>"); + var ry = inTop + i * ROW + ROW / 2; + out.push('<rect x="' + IN_X + '" y="' + (ry - BOX_H / 2) + '" width="' + IN_W + '" height="' + BOX_H + '" rx="7" fill="rgba(255,255,255,0.05)" stroke="' + a.color + '" stroke-opacity="0.35"/>'); + out.push('<text class="tp-name" x="' + (IN_X + 10) + '" y="' + (ry + 4) + '">' + esc(rtClip(c.name, IN_W - 44, 12)) + "<title>" + esc(c.name) + ""); if (c.capacity) { - out.push('' + esc(c.capacity) + ""); + out.push('' + esc(c.capacity) + ""); } var d = pathD(IN_X + IN_W, ry, NX - NODE_R - 2, cy); _rtEdgeIn[c.name] = d; - out.push(''); + out.push(''); }); // agent 节点:两圈 + 一段负载弧(stroke-dasharray 画进度) @@ -974,30 +1016,30 @@ '" stroke-width="3.5" stroke-linecap="round" stroke-dasharray="' + ((a.load / 100) * C).toFixed(1) + " " + C.toFixed(1) + '" transform="rotate(-90 ' + NX + " " + cy + ')"/>', ); - out.push('' + a.load + ""); - out.push('' + esc(a.child ? a.id : __("根 agent", "root")) + ""); + out.push('' + a.load + ""); + out.push('' + esc(rtClip(a.child ? a.id : __("根 agent", "root"), 140, 11)) + ""); if (a.res && a.res.context_full) { - out.push('' + __("上下文已满", "ctx full") + ""); + out.push('' + __("上下文已满", "ctx full") + ""); } // agent → 输出通道 a.outputs.slice(0, MAX_OUT).forEach(function (name, i) { - var ry = y + i * ROW + ROW / 2; - out.push(''); - out.push('' + esc(name) + ""); + var ry = outTop + i * ROW + ROW / 2; + out.push(''); + out.push('' + esc(rtClip(name, OUT_W - 20, 12)) + "" + esc(name) + ""); var d = pathD(NX + NODE_R + 2, cy, OUT_X, ry); _rtEdgeOut[a.id + "\u0000" + name] = d; - out.push(''); + out.push(''); }); if (a.outputs.length > MAX_OUT) { - out.push('+' + (a.outputs.length - MAX_OUT) + " " + __("更多", "more") + ""); + out.push('+' + (a.outputs.length - MAX_OUT) + " " + __("更多", "more") + ""); } y += bandH; }); var H = Math.max(60, y); return ( '
' + __("拓扑(圆环 = 负载)", "Topology (ring = load)") + "
" + - '
' + + '
' + out.join("") + "
" );