fix(gui): 补星图面板缺失的 </div>,修对话页 5 个页签空白

## 实测故障

对话页里「终端 / 运行中命令 / 记忆 / 上下文 / 知识」五个页签点进去全是
空白,只有「对话」和「星图」正常。

## 根因:少一个 </div>

构造星图面板的那段 HTML 里有 **4 个 `<div>` 开、只有 3 个 `</div>` 闭**,
它没闭合 —— 后面拼接的所有 chat-panel 都被浏览器塞进 chat-panel-starmap
**内部**。

CDP 查真实 DOM 的铁证:

    .chat-layout 的直接子节点只有 3 个:
      DIV.chat-tabs / DIV#chat-panel-chat / DIV#chat-panel-starmap

    terminal/cmd/memory/context/knowledge 的父节点
      = chat-panel-starmap     ← 不是同级的 chat-layout

后果:这些面板 display 是flex(switchChatPanel 把 active 加对了),
但父级 display:none ⇒ getBoundingClientRect() 恒为 0x0 ⇒ 显示为空白。

实测尺寸对照(修复前 → 修复后):

    terminal  0x0   → 1156x621
    cmd       0x0   → 1156x621
    memory    0x0   → 1156x621
    context   0x0   → 1156x621
    knowledge 0x0   → 1156x621

## 为什么一直没被发现

标签不配对在源码里看不出来(跨行字符串拼接的 HTML 片段),浏览器也
**不报错** —— 它只是「尽力修复」成畸形结构。现象是「点了没反应/一片空白」,
很容易被归因为数据没到或后端挂了(我最初就查了半天气网络与接口 401)。

而且它只在部分面板上发作:chat 与 starmap 恰好是前两个,位置靠前不受影响,
于是「大部分能用」掩盖了「后面全废」。

## 判据(cmd/gui/chat-panel-dom.test.mjs)

3 条:整段 div 标签配平、各面板独立配平、各面板顶层不被嵌套进前一面板。

逐面板断言能定位到**具体是哪个**面板不配平(变异测试下报
`starmap(开4/闭3)`),而不只是「总数差了 1」。

变异测试:去掉一个 `</div>` ⇒ 前两条转红并指出 starmap。

判据自身也错过一次:还原 HTML 时把 `"` 全替换成 `'`,而 marker 用的是双引号,
7 个面板全报「未找到」。已修正并记在注释里。
This commit is contained in:
JianFeeeee
2026-10-06 13:09:12 +08:00
parent 04c5883f4b
commit 5f12ea3df9
2 changed files with 150 additions and 1 deletions

View File

@ -0,0 +1,149 @@
// chat 子面板 DOM 结构判据(2026-10-05)。
//
// ## 要判的是什么
//
// 实测故障:对话页里「终端 / 运行中命令 / 记忆 / 上下文 / 知识」五个页签
// 点进去全是空白,只有「对话」和「星图」正常。
//
// 根因是 **HTML 少一个 </div>**:构造星图面板的那段 HTML 里有 4 个
// <div> 开、只有 3 个 </div> 闭,于是它没闭合 —— 后面拼接的所有
// chat-panel 都被浏览器塞进 chat-panel-starmap **内部**。
//
// 实测证据(CDP 查真实 DOM):
//
// .chat-layout 的直接子节点只有 3 个:
// DIV.chat-tabs / DIV#chat-panel-chat / DIV#chat-panel-starmap
//
// 而 terminal/cmd/memory/context/knowledge 的父节点是
// chat-panel-starmap —— 不是同级的 chat-layout。
//
// 后果:这些面板 display 是 flex(switchChatPanel 把 active 加对了),
// 但父级 display:none ⇒ getBoundingClientRect() 恒为 0x0 ⇒ 看起来空白。
//
// ★ 为什么需要判据:标签不配对在源码里完全看不出来(字符串拼接 +
// 跨行的 HTML 片段),浏览器也不报错 —— 它只是「尽力修复」成畸形结构。
// 只有断言「开闭标签数相等」才能挡住。
//
// 运行:node chat-panel-dom.test.mjs
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const APP = join(here, "renderer", "app.js");
let failures = 0;
const check = (name, ok, detail) => {
if (ok) {
console.log(` ✓ ${name}`);
} else {
failures++;
console.log(` ✗ ${name}${detail ? " — " + detail : ""}`);
}
};
if (!existsSync(APP)) {
console.error(`找不到 ${APP}`);
process.exit(1);
}
const src = readFileSync(APP, "utf8");
// ── 1) 抽出构造 chat 子面板的整段 HTML ─────────────────────────────
//
// 从第一个 chat-panel 面板开始,到最后一个面板结束。
const startIdx = src.indexOf(
`'<div class="chat-panel active" id="chat-panel-chat">`,
);
if (startIdx < 0) {
console.error("定位不到 chat 面板段落 —— app.js 结构可能已变");
process.exit(1);
}
const lastIdx = src.indexOf("chat-panel-knowledge");
const seg = src.slice(startIdx, lastIdx + 4000);
// 把跨行拼接的字符串还原成一段连续 HTML:
// '...' + \n '...' → '...'
const html = seg
.replace(/'\s*\+\s*\n\s*'/g, "")
.replace(/'\s*\+\s*'/g, "")
.replace(/"/g, "'");
const opens = (html.match(/<div\b/g) || []).length;
const closes = (html.match(/<\/div>/g) || []).length;
check(
"chat 面板段落 div 标签配平",
opens === closes,
`开 ${opens} / 闭 ${closes},差 ${opens - closes} —— ` +
"少一个 </div> 会让后面所有面板被塞进前一个面板内部(表现为空白)",
);
// ── 2) 每个 chat-panel 自身也必须配平 ─────────────────────────────
//
// 总数配平还不够:某处多一个 </div>、另一处少一个,总数仍为 0。
// 逐面板断言能定位到具体是哪个。
const PANELS = ["chat", "starmap", "terminal", "cmd", "memory", "context", "knowledge"];
const unbalanced = [];
for (const name of PANELS) {
const marker = `id='chat-panel-${name}'`;
const at = html.indexOf(marker);
if (at < 0) {
unbalanced.push(`${name}(未找到)`);
continue;
}
// 从该面板的 <div class="chat-panel 开始算(marker 之前最近的那个)
const from = html.lastIndexOf("<div", at);
// 到下一个 chat-panel 之前为止
const nextAt = html.indexOf("id='chat-panel-", at + marker.length);
const to = nextAt < 0 ? html.length : html.lastIndexOf("<div", nextAt);
const piece = html.slice(from, to);
const o = (piece.match(/<div\b/g) || []).length;
const c = (piece.match(/<\/div>/g) || []).length;
if (o !== c) unbalanced.push(`${name}(开${o}/闭${c})`);
}
check(
"各子面板独立配平",
unbalanced.length === 0,
unbalanced.length ? "不配平:" + unbalanced.join("、") : "",
);
// ── 3) 所有 chat-panel 必须同属一个父容器 ─────────────────────────
//
// 形态判据:源码里每个面板片段的起始处都应是独立的 <div class="chat-panel",
// 而不是接在上一段的未闭合结构里。逐面板检查它前面紧邻的字符 ——
// 合法的是 …>'(一个字符串字面量的结束)或 +(拼接)。
const nested = [];
for (const name of PANELS) {
const marker = `id='chat-panel-${name}'`;
const at = html.indexOf(marker);
if (at < 0) continue;
const from = html.lastIndexOf("<div", at);
const before = html.slice(Math.max(0, from - 24), from);
// 合法前缀:以 ' 结尾(字符串结束)或 '+ 结尾(仍在拼接)
if (!/'[+]?$/.test(before.trimEnd()) && !/\+\s*$/.test(before)) {
nested.push(`${name}(前缀=${JSON.stringify(before.slice(-12))})`);
}
}
check(
"各面板顶层不被嵌套进前一面板",
nested.length === 0,
nested.length
? "这些面板疑似接在未闭合结构里:" + nested.join("、")
: "",
);
// ── 汇总 ────────────────────────────────────────────────────────────
console.log("");
console.log(` chat 面板段落: <div> ${opens} / </div> ${closes}`);
console.log(` 面板数: ${PANELS.length}`);
console.log("");
if (failures > 0) {
console.log(`失败 ${failures} 条`);
process.exit(1);
}
console.log("全部通过");

View File

@ -1873,7 +1873,7 @@ function buildChatLayout() {
'<div class="chat-panel" id="chat-panel-starmap"><div class="card"><h2>' +
__("星图", "Star Map") +
"</h2>" +
'<div id="sm-container-chat" style="display:flex;align-items:center;justify-content:center;min-height:480px"><div class="ha-dots-panel"><span class="ha-dots ha-dots-lg"><i></i><i></i><i></i></span></div></div></div>';
'<div id="sm-container-chat" style="display:flex;align-items:center;justify-content:center;min-height:480px"><div class="ha-dots-panel"><span class="ha-dots ha-dots-lg"><i></i><i></i><i></i></span></div></div></div></div>';
html +=
'<div class="chat-panel" id="chat-panel-terminal"><div class="card"><h2>' +
__("终端", "Terminal") +