mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
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:
@ -1714,7 +1714,20 @@
|
|||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(wrap);
|
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({
|
const q = new URLSearchParams({
|
||||||
export: "csv",
|
export: "csv",
|
||||||
from: String(Math.floor(from)),
|
from: String(Math.floor(from)),
|
||||||
@ -1722,8 +1735,7 @@
|
|||||||
});
|
});
|
||||||
if (statsKeyF) q.set("key", statsKeyF);
|
if (statsKeyF) q.set("key", statsKeyF);
|
||||||
location.href = "/api/stats?" + q.toString();
|
location.href = "/api/stats?" + q.toString();
|
||||||
const w = $("#modal-wrap");
|
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
|
||||||
if (w) w.remove();
|
|
||||||
}
|
}
|
||||||
// downloadStatsCsvFromForm reads the custom date range from the export
|
// downloadStatsCsvFromForm reads the custom date range from the export
|
||||||
// modal's date inputs and forwards to downloadStatsCsv. (Previously this
|
// modal's date inputs and forwards to downloadStatsCsv. (Previously this
|
||||||
@ -1741,12 +1753,11 @@
|
|||||||
if (!isFinite(from) || from <= 0) return toast(t("expRange"));
|
if (!isFinite(from) || from <= 0) return toast(t("expRange"));
|
||||||
downloadStatsCsv(from, to);
|
downloadStatsCsv(from, to);
|
||||||
}
|
}
|
||||||
function downloadKeysCsv() {
|
function downloadKeysCsv(btn) {
|
||||||
location.href =
|
location.href =
|
||||||
"/api/stats?export=keys-csv" +
|
"/api/stats?export=keys-csv" +
|
||||||
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
|
(statsKeyF ? "&key=" + encodeURIComponent(statsKeyF) : "");
|
||||||
const w = $("#modal-wrap");
|
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
|
||||||
if (w) w.remove();
|
|
||||||
}
|
}
|
||||||
async function paintStats() {
|
async function paintStats() {
|
||||||
try {
|
try {
|
||||||
@ -2940,9 +2951,10 @@
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
toast(t("toastSaved"));
|
toast(t("toastSaved"));
|
||||||
const w = $("#modal-wrap");
|
const w = btn && btn.closest("#modal-wrap");
|
||||||
if (w) w.remove();
|
if (w) w.remove();
|
||||||
else if (window._modal) window._modal.remove();
|
else if (window._modal) window._modal.remove();
|
||||||
|
else closeTopModal();
|
||||||
renderSources();
|
renderSources();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(tFmt("toastSaveFail", e.message));
|
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>` +
|
? `<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
|
tpls
|
||||||
.map(
|
.map(
|
||||||
(t) =>
|
(tpl) =>
|
||||||
`<tr><td><b>${esc(t.name)}</b></td><td>${esc(t.base_url)}</td><td>${esc(t.adapter)}</td>
|
`<tr><td><b>${esc(tpl.name)}</b></td><td>${esc(tpl.base_url)}</td><td>${esc(tpl.adapter)}</td>
|
||||||
<td><div class="src-models">${(t.models || []).map((m) => `<span class="tag tag-blue">${esc(m.id)}</span>`).join("")}</div></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(t.name).replace(/["<>]/g, (c) => ({ '"': """, "<": "<", ">": ">" }[c]))})">${t("srcEdit")}</button>
|
<td><button class="ghost small" onclick="editTemplate(${JSON.stringify(tpl.name).replace(/["<>]/g, (c) => ({ '"': """, "<": "<", ">": ">" }[c]))})">${t("srcEdit")}</button>
|
||||||
<button class="danger small" onclick="delTemplate('${escAttr(t.name)}')">${t("srcDel")}</button></td></tr>`,
|
<button class="danger small" onclick="delTemplate('${escAttr(tpl.name)}')">${t("srcDel")}</button></td></tr>`,
|
||||||
)
|
)
|
||||||
.join("") +
|
.join("") +
|
||||||
"</table></div>"
|
"</table></div>"
|
||||||
@ -3203,8 +3215,9 @@
|
|||||||
body: JSON.stringify({ name: nm, ...body }),
|
body: JSON.stringify({ name: nm, ...body }),
|
||||||
});
|
});
|
||||||
toast(t("toastSaved"));
|
toast(t("toastSaved"));
|
||||||
const w = $("#modal-wrap");
|
const w = btn && btn.closest("#modal-wrap");
|
||||||
if (w) w.remove();
|
if (w) w.remove();
|
||||||
|
else closeTopModal();
|
||||||
openTemplateModal();
|
openTemplateModal();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message);
|
toast(e.message);
|
||||||
@ -3910,7 +3923,7 @@
|
|||||||
</select>
|
</select>
|
||||||
<div id="a-hours-box" style="display:none"><label>${t("kPerNHint")}</label>
|
<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>
|
<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>
|
<button class="ghost" onclick="this.closest('#modal-wrap').remove()">${t("mCancel")}</button></p>
|
||||||
</div>`;
|
</div>`;
|
||||||
document.body.appendChild(wrap);
|
document.body.appendChild(wrap);
|
||||||
@ -3920,7 +3933,7 @@
|
|||||||
});
|
});
|
||||||
$("#a-model").focus();
|
$("#a-model").focus();
|
||||||
}
|
}
|
||||||
function scrAddFromForm() {
|
function scrAddFromForm(btn) {
|
||||||
const raw = $("#a-model").value.trim();
|
const raw = $("#a-model").value.trim();
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
toast(t("kName"));
|
toast(t("kName"));
|
||||||
@ -3943,14 +3956,14 @@
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const li = sortState.lanes.length - 1;
|
const li = sortState.lanes.length - 1;
|
||||||
const w = $("#modal-wrap");
|
if (btn) btn.closest("#modal-wrap")?.remove();
|
||||||
if (w) w.remove();
|
else closeTopModal();
|
||||||
paintSort([li]);
|
paintSort([li]);
|
||||||
persistAuto()
|
persistAuto()
|
||||||
.then(() => toast(t("sortHintSave")))
|
.then(() => toast(t("sortHintSave")))
|
||||||
.catch((e) => toast(e.message));
|
.catch((e) => toast(e.message));
|
||||||
}
|
}
|
||||||
function sortScopeSave(li, ji) {
|
function sortScopeSave(li, ji, btn) {
|
||||||
const it = sortState.lanes[li].models[ji];
|
const it = sortState.lanes[li].models[ji];
|
||||||
if (!it) return;
|
if (!it) return;
|
||||||
let q = parseInt($("#q-quota").value);
|
let q = parseInt($("#q-quota").value);
|
||||||
@ -3959,8 +3972,7 @@
|
|||||||
let h = parseInt($("#q-hours").value);
|
let h = parseInt($("#q-hours").value);
|
||||||
if (isNaN(h) || h < 1) h = 1;
|
if (isNaN(h) || h < 1) h = 1;
|
||||||
it.meta = { quota: q, period: p, hours: p || q ? h : 0 };
|
it.meta = { quota: q, period: p, hours: p || q ? h : 0 };
|
||||||
const w = $("#modal-wrap");
|
(btn ? btn.closest("#modal-wrap") : null) || closeTopModal();
|
||||||
if (w) w.remove();
|
|
||||||
paintSort([li]);
|
paintSort([li]);
|
||||||
persistAuto()
|
persistAuto()
|
||||||
.then(() => toast(t("sortHintSave")))
|
.then(() => toast(t("sortHintSave")))
|
||||||
@ -4508,9 +4520,10 @@
|
|||||||
});
|
});
|
||||||
// Close THIS modal, not whichever #modal-wrap comes first in the
|
// Close THIS modal, not whichever #modal-wrap comes first in the
|
||||||
// document: another dialog (e.g. the seed-key notice) may already be
|
// document: another dialog (e.g. the seed-key notice) may already be
|
||||||
// open, and $("#modal-wrap") would remove that one and leave this
|
// open, and a bare $("#modal-wrap") would remove that one and leave
|
||||||
// form stranded on screen.
|
// this form stranded on screen.
|
||||||
if (wrap) wrap.remove();
|
if (wrap) wrap.remove();
|
||||||
|
else closeTopModal();
|
||||||
toast(t("kSaved"));
|
toast(t("kSaved"));
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -4668,8 +4681,9 @@
|
|||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await putScope(key, scopes);
|
await putScope(key, scopes);
|
||||||
const w = $("#modal-wrap");
|
const w = btn && btn.closest("#modal-wrap");
|
||||||
if (w) w.remove();
|
if (w) w.remove();
|
||||||
|
else closeTopModal();
|
||||||
toast(t("kSaved"));
|
toast(t("kSaved"));
|
||||||
await loadKeys();
|
await loadKeys();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -4898,6 +4912,7 @@
|
|||||||
// would otherwise be the one that gets removed.
|
// would otherwise be the one that gets removed.
|
||||||
const w = btn ? btn.closest("#modal-wrap") : null;
|
const w = btn ? btn.closest("#modal-wrap") : null;
|
||||||
if (w) w.remove();
|
if (w) w.remove();
|
||||||
|
else closeTopModal();
|
||||||
$("#k-newbox").innerHTML = `
|
$("#k-newbox").innerHTML = `
|
||||||
<div class="key-canvas" style="background:var(--card2)">
|
<div class="key-canvas" style="background:var(--card2)">
|
||||||
<div class="kc-head">
|
<div class="kc-head">
|
||||||
|
|||||||
@ -45,6 +45,67 @@ func TestUIDialogClosesItselfNotTheFirstModal(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUIDialogClosuresGoThroughSafePaths pins the rule across the whole
|
||||||
|
// document by data flow rather than by pattern: every handler that closes a
|
||||||
|
// dialog must do it one of the two safe ways. A handler could contain a
|
||||||
|
// correct .closest() and still close the wrong dialog on another path.
|
||||||
|
func TestUIDialogClosuresGoThroughSafePaths(t *testing.T) {
|
||||||
|
src := stripJSComments(uiSource(t))
|
||||||
|
for _, fn := range []string{
|
||||||
|
"downloadStatsCsv", "downloadKeysCsv", "saveSource", "saveTemplate",
|
||||||
|
"scrAddFromForm", "sortScopeSave", "scopeSave", "keyQuotaSave", "createKey",
|
||||||
|
} {
|
||||||
|
body, ok := jsFunctionBody(src, fn)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s not found", fn)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `closest("#modal-wrap")`) && !strings.Contains(body, "closeTopModal()") {
|
||||||
|
t.Errorf("%s closes a dialog with neither .closest nor closeTopModal", fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(src, "function closeTopModal(") {
|
||||||
|
t.Error("closeTopModal helper is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The helper must pick the LAST dialog (the topmost one the user sees), not the
|
||||||
|
// first — that inversion is the whole bug.
|
||||||
|
func TestUICloseTopModalTakesTheLast(t *testing.T) {
|
||||||
|
body, ok := jsFunctionBody(uiSource(t), "closeTopModal")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("closeTopModal not found")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "all.length - 1") {
|
||||||
|
t.Errorf("closeTopModal does not take the last dialog:\n\t%s", oneLine(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers that resolve their dialog from a button must actually receive one:
|
||||||
|
// a signature without the parameter means the .closest() silently yields null
|
||||||
|
// and the save leaves its form stranded on screen.
|
||||||
|
func TestUIDialogHandlersReceiveTheirButton(t *testing.T) {
|
||||||
|
src := stripJSComments(uiSource(t))
|
||||||
|
for _, fn := range []string{
|
||||||
|
"downloadStatsCsv", "saveSource", "scrAddFromForm", "sortScopeSave",
|
||||||
|
"scopeSave", "keyQuotaSave", "createKey",
|
||||||
|
} {
|
||||||
|
body, ok := jsFunctionBody(src, fn)
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("%s not found", fn)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "closest(\"#modal-wrap\")") {
|
||||||
|
continue // uses closeTopModal only
|
||||||
|
}
|
||||||
|
sig := body[:strings.Index(body, ")")+1]
|
||||||
|
if !strings.Contains(sig, "btn") {
|
||||||
|
t.Errorf("%s uses .closest(\"#modal-wrap\") but its signature %s has no button parameter —\n"+
|
||||||
|
"the lookup would always be null and the form would never close", fn, oneLine(sig))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// stripJSComments removes // line comments and /* block */ comments from JS
|
// stripJSComments removes // line comments and /* block */ comments from JS
|
||||||
// embedded in the UI document. It is deliberately simple (no string/regex
|
// embedded in the UI document. It is deliberately simple (no string/regex
|
||||||
// awareness beyond skipping quoted spans on the same line): the document is
|
// awareness beyond skipping quoted spans on the same line): the document is
|
||||||
|
|||||||
Reference in New Issue
Block a user