fix(webui): 聊天图片"加载成功却显示加载失败"——HTML 属性里的反斜杠转义

## 缺陷

生产截图:图片加载成功了(img 元素在、src 正确),界面却多显示一行
蓝字「图片加载失败"/>」。

根因是内联事件属性里用了反斜杠转义:

    onerror="this.parentElement.innerHTML='<span class=\"att-err\">图片加载失败</span>'"

**HTML 属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,属性在
`<span class=` 之后就闭合了。浏览器切出来的是:

    onerror = "this.parentElement.innerHTML='<span class="

剩下的 `att-err">图片加载失败</span>'"/>` 变成**文本节点**直接显示。

症状极具误导性:看起来像"图片坏了",实际图片是好的;而那个 onerror
因为语法错误**永不执行** —— 真失败时反而什么都不显示。

## 修法

不用内联属性(那要求以后每次拼 HTML 都记得转义规则),改成
**捕获阶段的事件委托**:

- error 事件**不冒泡**,只能在捕获阶段拿到(注释里写明了)
- 用 createElement + textContent 构造占位,不再拼 innerHTML 字符串
- 只处理 .chat-attachment-img,不影响正文里的 markdown 图片

## 顺带:两个类没有 CSS

`chat-attachment-img` 与 `att-err` 只出现在 JS 里,CSS 里一条都没有。
图片靠内联 style 撑着还能看,失败占位则完全没有样式。已补。

## 判据(2 条,变异验证)

- `TestChatAttachmentImgHasNoBrokenInlineHandler`:不许内联 onerror、
  必须用 DOM API、必须有 error 监听
- `TestDashboardNoEscapedQuoteInHTMLAttributes`:通用扫描,
  任何 `name="...\"...` 形态都会红(下次别人写内联事件也拦得住)

变异:把内联 onerror 放回去 ⇒ 两条同时红。

★ 写判据时踩到的坑(都已修):判据被**自己的注释**打红(注释里必须写出
错误写法来解释),需先用已有的 stripJSComments 剥离;另外我先自己写了一个
stripJSComments,与 dashboard_js_scope_test.go 的重名 —— 应复用已有的。

## 顺带修一条脆弱判据

`TestRTChild_RapidInputCollapsesToLatest` 全量跑时偶发红
("连发 50 条却跑了 5 轮")。

探针查实:连发 50 条只花 ~80µs,而测试 provider 的 ChatStream **同步瞬间
返回**,两者同量级 ⇒ 消费方能在连发过程中抢到几次执行。探针显示
dropped=48,collapse 明明生效了。

所以 `Rounds > 3` 是在测调度抖动,不是测契约。改为断言**覆盖主导**
(dropped >= burst/2)。真实通话每轮几十毫秒,连发微秒级,生产里
"轮次≈1" 自然成立。

变异验证:让 dropped 不计数 ⇒ 判据红(dropped=0(连发 50 条))。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
This commit is contained in:
JianFeeeee
2026-10-31 10:03:43 +08:00
parent 843563120b
commit acaf0887c7
4 changed files with 176 additions and 2 deletions

View File

@ -2370,6 +2370,44 @@ function renderChat() {
},
true,
);
// 图片加载失败:**必须用捕获阶段**的委托,不能写成内联 onerror。
//
// ## 原来坏在哪(2026-10-30 实测)
//
// 原代码是:
// onerror="this.parentElement.innerHTML='<span class=\"att-err\">图片加载失败</span>'"
//
// HTML **属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,
// 于是 onerror 属性在 <span class= 之后就闭合了。浏览器切出来的属性是:
// onerror = "this.parentElement.innerHTML='<span class="
// 剩下的 `att-err">图片加载失败</span>'"/>` 变成**文本节点**直接显示。
//
// 症状因此极具误导性:图片明明加载成功了(img 元素在、src 正确),
// 界面上却多出一行「图片加载失败"/>」;而那个 onerror 本身因为语法
// 错误永远不会执行 —— 真失败时反而什么都不显示。
//
// ## 为什么用 error 捕获而不是重写内联
//
// error 事件**不冒泡**,只有在捕获阶段才能委托到容器上。
// 改内联也能修(用单引号包属性值 + 不嵌套引号),但那仍然要求
// 以后每次拼 HTML 都记得转义规则;委托把这条规则从"每次都要做对"
// 变成"只在一处写对"。
msgsEl.addEventListener(
"error",
(e) => {
var t = e.target;
if (!t || t.tagName !== "IMG" || !t.classList.contains("chat-attachment-img")) {
return;
}
var box = t.parentElement;
if (!box) return;
var span = document.createElement("span");
span.className = "att-err";
span.textContent = __("图片加载失败", "Image failed to load");
box.replaceChildren(span);
},
true,
);
}
var msgs = state.messages;
var sig =
@ -2470,7 +2508,7 @@ function renderChat() {
escHtml(att.url) +
'" ' +
'alt="image" loading="lazy" style="max-width:320px;max-height:240px;border-radius:10px;display:block;cursor:zoom-in" ' +
'onerror="this.parentElement.innerHTML=\'<span class=\\"att-err\\">图片加载失败</span>\'"/></a>';
'/></a>';
} else if (att.type === "audio") {
// 音频用原生播放器:与图片同理,附件能在聊天里直接消费才算可见。
// preload="metadata" 只拉时长不拉全部字节,避免历史消息满屏时并发下载。