fix(webui): 修 7 处弹窗关闭错对象 + 模板管理器变量遮蔽

上一提交只修了自己新加的两处弹窗,全站其余 7 处是同一缺陷:所有对话框
共用 id="modal-wrap"(CSS `#modal-wrap:not(:empty){display:flex}`)且可以叠
加(seed-key 提示就盖在密钥页上),而
`const w = $("#modal-wrap"); w.remove()` 移除的是**文档里第一个**,不是用户
刚提交的那一个。

逐处改为两种安全写法:
- 能拿到按钮的(saveSource / saveTemplate / sortScopeSave / scopeSave /
  keyQuotaSave / createKey / downloadStatsCsv / downloadKeysCsv /
  scrAddFromForm):`btn.closest("#modal-wrap")`
- 拿不到按钮的:新增 `closeTopModal()` 取**最后一个**(用户看到的那个),
  并作为所有 `if (w) w.remove()` 之后的兜底
- 顺带把 sortScopeSave / downloadStatsCsv / downloadKeysCsv / scrAddFromForm
  的签名补上 btn / this 参数 —— 否则 .closest 恒为 null,表单永远不关

同时修一个相邻的既有 bug:`openTemplateModal` 的 `.map((t) => ...)` 用 t 做
循环变量,模板字面量里又调 t("srcEdit"),t 被遮蔽成对象 ⇒ 打开模板管理器
直接抛 `t is not a function`,整个弹窗渲染失败(main 上就有,git show 确认)。
参数改名 tpl。修后模板管理器完整渲染(浏览器实测:DeepSeek / 智谱 / Kimi /
SiliconFlow 各行 + Edit/Delete 按钮文案全部正常,零异常)。

判据从 2 处扩到全量(internal/gateway/ui_quota_contract_test.go,+3 例):
- 全文档扫描:任何 `.remove()` 配裸 `$("#modal-wrap")` 即失败
- 9 个关闭对话框的处理器必须走 .closest 或 closeTopModal
- closeTopModal 必须取 all.length - 1(首尾颠倒就是原 bug)
- 用 .closest 的处理器,其签名必须真的有 btn 参数 —— 否则查找恒为 null,
  表单永远不关
5/5 变异全被抓:sortScopeSave 去 btn 参数、closeTopModal 取第一个、scopeSave
退回裸选择器、saveSource 退回裸选择器、downloadKeysCsv 删兜底。

浏览器实测(共享 Chromium CDP,真实进程,每处都插入一个「decoy」弹窗
占据文档首位,复现原 bug 的触发条件):
- scopeSave / keyQuotaSave / scrAddFromForm / saveSource / saveTemplate
  五个处理器:自己的表单关、decoy 保留 ✓
- 零 JS 异常
★ 测试自身踩了两个坑,都不是代码问题:① `document.querySelector(sel) && .click()`
  在 CDP 里求值为 undefined,改成箭头函数;② 编辑模板时没填名字就点保存,
  saveTemplate 因 `if (!nm)` 早退、fetch 零调用 —— 一开始我把这个误读成
  「修复失效」,加 fetch 拦截 + 读 #s-name 的值才定位到是测试数据缺失。
  ⇒ 「点按钮没反应」要先分清是「事件没触发」「请求失败」还是「早退」。

(cherry picked from commit b5c3fea0bb)
This commit is contained in:
JianFeeeee
2026-09-27 17:57:15 +08:00
parent ef631b43dd
commit f814ff7468
2 changed files with 99 additions and 23 deletions

View File

@ -1714,7 +1714,20 @@
</div>`;
document.body.appendChild(wrap);
}
function downloadStatsCsv(from, to) {
// closeTopModal removes the topmost dialog. Several dialogs share the id
// "modal-wrap" and more than one can be open at once (the seed-key notice
// sits on top of the keys page), so `$("#modal-wrap")` resolves to
// whichever comes FIRST in the document — not necessarily the one the
// user is looking at. Handlers that receive the clicked button resolve
// their own dialog with .closest(); handlers with no button reference
// use this, which matches what the user sees.
function closeTopModal() {
const all = [...document.querySelectorAll("#modal-wrap")];
const w = all[all.length - 1];
if (w) w.remove();
return w;
}
function downloadStatsCsv(from, to, btn) {
const q = new URLSearchParams({
export: "csv",
from: String(Math.floor(from)),
@ -1722,8 +1735,7 @@
});
if (statsKeyF) q.set("key", statsKeyF);
location.href = "/api/stats?" + q.toString();
const w = $("#modal-wrap");
if (w) w.remove();
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
}
// downloadStatsCsvFromForm reads the custom date range from the export
// modal's date inputs and forwards to downloadStatsCsv. (Previously this
@ -1741,12 +1753,11 @@
if (!isFinite(from) || from <= 0) return toast(t("expRange"));
downloadStatsCsv(from, to);
}
function downloadKeysCsv() {
function downloadKeysCsv(btn) {
location.href =
"/api/stats?export=keys-csv" +
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
const w = $("#modal-wrap");
if (w) w.remove();
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
}
async function paintStats() {
try {
@ -2940,9 +2951,10 @@
body: JSON.stringify(payload),
});
toast(t("toastSaved"));
const w = $("#modal-wrap");
const w = btn && btn.closest("#modal-wrap");
if (w) w.remove();
else if (window._modal) window._modal.remove();
else closeTopModal();
renderSources();
} catch (e) {
toast(tFmt("toastSaveFail", e.message));
@ -3099,11 +3111,11 @@
? `<div class="tbl-wrap"><table><tr><th>${t("tName")}</th><th>${t("tURL")}</th><th>${t("tAdapter")}</th><th>${t("tModels")}</th><th></th></tr>` +
tpls
.map(
(t) =>
`<tr><td><b>${esc(t.name)}</b></td><td>${esc(t.base_url)}</td><td>${esc(t.adapter)}</td>
<td><div class="src-models">${(t.models || []).map((m) => `<span class="tag tag-blue">${esc(m.id)}</span>`).join("")}</div></td>
<td><button class="ghost small" onclick="editTemplate(${JSON.stringify(t.name).replace(/["<>]/g, (c) => ({ '"': "&quot;", "<": "&lt;", ">": "&gt;" }[c]))})">${t("srcEdit")}</button>
<button class="danger small" onclick="delTemplate('${escAttr(t.name)}')">${t("srcDel")}</button></td></tr>`,
(tpl) =>
`<tr><td><b>${esc(tpl.name)}</b></td><td>${esc(tpl.base_url)}</td><td>${esc(tpl.adapter)}</td>
<td><div class="src-models">${(tpl.models || []).map((m) => `<span class="tag tag-blue">${esc(m.id)}</span>`).join("")}</div></td>
<td><button class="ghost small" onclick="editTemplate(${JSON.stringify(tpl.name).replace(/["<>]/g, (c) => ({ '"': "&quot;", "<": "&lt;", ">": "&gt;" }[c]))})">${t("srcEdit")}</button>
<button class="danger small" onclick="delTemplate('${escAttr(tpl.name)}')">${t("srcDel")}</button></td></tr>`,
)
.join("") +
"</table></div>"
@ -3203,8 +3215,9 @@
body: JSON.stringify({ name: nm, ...body }),
});
toast(t("toastSaved"));
const w = $("#modal-wrap");
const w = btn && btn.closest("#modal-wrap");
if (w) w.remove();
else closeTopModal();
openTemplateModal();
} catch (e) {
toast(e.message);
@ -3910,7 +3923,7 @@
</select>
<div id="a-hours-box" style="display:none"><label>${t("kPerNHint")}</label>
<input id="a-hours" type="number" min="1" step="1" value="24"></div>
<p><button onclick="scrAddFromForm()">${t("kSaveScope")}</button>
<p><button onclick="scrAddFromForm(this)">${t("kSaveScope")}</button>
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
</div>`;
document.body.appendChild(wrap);
@ -3920,7 +3933,7 @@
});
$("#a-model").focus();
}
function scrAddFromForm() {
function scrAddFromForm(btn) {
const raw = $("#a-model").value.trim();
if (!raw) {
toast(t("kName"));
@ -3943,14 +3956,14 @@
],
});
const li = sortState.lanes.length - 1;
const w = $("#modal-wrap");
if (w) w.remove();
if (btn) btn.closest("#modal-wrap")?.remove();
else closeTopModal();
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
.catch((e) => toast(e.message));
}
function sortScopeSave(li, ji) {
function sortScopeSave(li, ji, btn) {
const it = sortState.lanes[li].models[ji];
if (!it) return;
let q = parseInt($("#q-quota").value);
@ -3959,8 +3972,7 @@
let h = parseInt($("#q-hours").value);
if (isNaN(h) || h < 1) h = 1;
it.meta = { quota: q, period: p, hours: p || q ? h : 0 };
const w = $("#modal-wrap");
if (w) w.remove();
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
paintSort([li]);
persistAuto()
.then(() => toast(t("sortHintSave")))
@ -4508,9 +4520,10 @@
});
// Close THIS modal, not whichever #modal-wrap comes first in the
// document: another dialog (e.g. the seed-key notice) may already be
// open, and $("#modal-wrap") would remove that one and leave this
// form stranded on screen.
// open, and a bare $("#modal-wrap") would remove that one and leave
// this form stranded on screen.
if (wrap) wrap.remove();
else closeTopModal();
toast(t("kSaved"));
await loadKeys();
} catch (e) {
@ -4668,8 +4681,9 @@
if (btn) btn.disabled = true;
try {
await putScope(key, scopes);
const w = $("#modal-wrap");
const w = btn && btn.closest("#modal-wrap");
if (w) w.remove();
else closeTopModal();
toast(t("kSaved"));
await loadKeys();
} catch (e) {
@ -4898,6 +4912,7 @@
// would otherwise be the one that gets removed.
const w = btn ? btn.closest("#modal-wrap") : null;
if (w) w.remove();
else closeTopModal();
$("#k-newbox").innerHTML = `
<div class="key-canvas" style="background:var(--card2)">
<div class="kc-head">