mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
fix(ui): per-key 编辑器画布画错容器、保存成功却不关弹窗
CDP 实测发现两个真 bug,都是复用画布时引入的。 一、画布画进了后台标签页。sortCanvasEl() 原本按文档顺序找,先命中 #scr-canvas(全局优先级页的画布)。而弹窗是挂在设置页之上的,两个画布 同时存在于 DOM —— 于是 per-key 的 36 条泳道被画进隐藏的全局画布,弹窗 里空空如也,回���全局页还会发现自己的链被覆盖了。 改为按 sortState.scope 解析:有 scope 就用 #key-auto-canvas,否则用 #scr-canvas。 二、保存成功却弹窗不关。!j.slots 分支里我写了 return,跳过了 keyAutoClose。写确实成功了(服务端已存),但用户看到弹窗还开着、按钮 还是禁用状态,会反复点保存。而且那条 toast 文案写成"保存后 AUTO 请求 会失败",读起来像保存失败了 —— 一并改成"已保存,但槽位无法解析…"。 判据两条,变异验证:恢复按文档顺序查找 → 第一条判红;!slots 分支加 return → 第二条判红。第二条最初只查 keyAutoClose() 是否存在,变异加了 return 仍然通过,属于判据漏放,已改为扫描 try 块内 close 调用之前的 所有 return 语句。 CDP 复测(先开全局页制造干扰,再开 per-key 弹窗): - 弹窗画布 36 泳道,全局画布保持 4 泳道未被污染 - 保存 gpt-5.2@xinjianya 后弹窗关闭、服务端存储正确、scope 清空 - 重开弹窗读回 [["gpt-5.2@xinjianya"]] Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
@ -832,7 +832,7 @@
|
|||||||
kAutoChainSave: "保存",
|
kAutoChainSave: "保存",
|
||||||
kAutoChainTier: "层级",
|
kAutoChainTier: "层级",
|
||||||
kAutoChainEmpty: "该密钥使用全局 AUTO 链",
|
kAutoChainEmpty: "该密钥使用全局 AUTO 链",
|
||||||
kAutoChainBad: "槽位无法解析(模型或源不存在),保存后 AUTO 请求会失败",
|
kAutoChainBad: "已保存,但槽位无法解析(模型与源的组合不存在),AUTO 请求会失败",
|
||||||
kName: "名称",
|
kName: "名称",
|
||||||
kRole: "角色",
|
kRole: "角色",
|
||||||
kRoleAdmin: "管理员",
|
kRoleAdmin: "管理员",
|
||||||
@ -1103,7 +1103,7 @@
|
|||||||
kAutoChainSave: "Save",
|
kAutoChainSave: "Save",
|
||||||
kAutoChainTier: "Tier",
|
kAutoChainTier: "Tier",
|
||||||
kAutoChainEmpty: "This key uses the global AUTO chain",
|
kAutoChainEmpty: "This key uses the global AUTO chain",
|
||||||
kAutoChainBad: "Slot does not resolve (unknown model or source); AUTO requests would fail",
|
kAutoChainBad: "Saved, but the slot does not resolve (no source serves that model); AUTO requests will fail",
|
||||||
kName: "Name",
|
kName: "Name",
|
||||||
kRole: "Role",
|
kRole: "Role",
|
||||||
kRoleAdmin: "Admin",
|
kRoleAdmin: "Admin",
|
||||||
@ -3694,9 +3694,17 @@ const pnote = $("#period-note");
|
|||||||
// different id inside its modal, so both share one implementation
|
// different id inside its modal, so both share one implementation
|
||||||
// instead of a reduced second copy that would drift.
|
// instead of a reduced second copy that would drift.
|
||||||
function sortCanvasEl() {
|
function sortCanvasEl() {
|
||||||
const own = document.getElementById("scr-canvas");
|
// The modal editor is mounted on top of the settings tab, so BOTH
|
||||||
if (own) return own;
|
// canvases can be in the DOM at once — #scr-canvas belongs to the
|
||||||
return document.getElementById("key-auto-canvas");
|
// hidden tab behind it. Resolve by scope, not by document order: when
|
||||||
|
// sortState.scope names a key we are editing THAT key's chain, and
|
||||||
|
// painting into the background tab's canvas both shows an empty
|
||||||
|
// dialog and corrupts the global chain the user comes back to.
|
||||||
|
if (sortState.scope) {
|
||||||
|
const k = document.getElementById("key-auto-canvas");
|
||||||
|
if (k) return k;
|
||||||
|
}
|
||||||
|
return document.getElementById("scr-canvas");
|
||||||
}
|
}
|
||||||
function paintSort(affected) {
|
function paintSort(affected) {
|
||||||
const cv = sortCanvasEl();
|
const cv = sortCanvasEl();
|
||||||
@ -5283,10 +5291,12 @@ const cv = sortCanvasEl();
|
|||||||
body: JSON.stringify({ auto: lanesToRules(sortState.lanes) }),
|
body: JSON.stringify({ auto: lanesToRules(sortState.lanes) }),
|
||||||
});
|
});
|
||||||
if (j.inherits) {
|
if (j.inherits) {
|
||||||
toast(t("kAutoChainGlobal"));
|
toast(t("kAutoChainGlobal"));
|
||||||
} else if (!j.slots) {
|
} else if (!j.slots) {
|
||||||
// Persisted, but nothing resolves — say so now instead of letting
|
// Persisted, but nothing resolves. Still close: the write
|
||||||
// the user meet it as a 503 on their next AUTO request.
|
// SUCCEEDED, and leaving the dialog open after a successful save
|
||||||
|
// makes the user click Save repeatedly. The warning carries the
|
||||||
|
// real problem — the toast survives the modal closing.
|
||||||
toast(t("kAutoChainBad"));
|
toast(t("kAutoChainBad"));
|
||||||
} else {
|
} else {
|
||||||
toast(t("sortSaved") + " (" + j.slots + ")");
|
toast(t("sortSaved") + " (" + j.slots + ")");
|
||||||
|
|||||||
@ -124,4 +124,83 @@ func TestUIStatsShowsPeriodWindow(t *testing.T) {
|
|||||||
if n := strings.Count(src, "periodRange"); n < 2 {
|
if n := strings.Count(src, "periodRange"); n < 2 {
|
||||||
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
|
t.Fatalf("i18n key periodRange appears %d time(s), want both zh and en", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Both canvases can exist in the DOM at once: the modal is mounted on top of
|
||||||
|
// the settings tab, whose #scr-canvas stays alive behind it. Resolving the
|
||||||
|
// canvas by document order (whichever comes first) painted the per-key chain
|
||||||
|
// into the hidden background tab and left the dialog empty — and worse,
|
||||||
|
// overwrote the global chain the user returns to. The lookup must follow
|
||||||
|
// sortState.scope instead.
|
||||||
|
func TestUISortCanvasResolvesByScope(t *testing.T) {
|
||||||
|
src := uiSource(t)
|
||||||
|
el := readFuncBody(t, src, "sortCanvasEl")
|
||||||
|
if !strings.Contains(el, "if (sortState.scope)") {
|
||||||
|
t.Fatal("sortCanvasEl must branch on sortState.scope, not document order")
|
||||||
|
}
|
||||||
|
if strings.Contains(el, `getElementById("scr-canvas")`) &&
|
||||||
|
strings.Index(el, `getElementById("scr-canvas")`) < strings.Index(el, "sortState.scope") {
|
||||||
|
t.Fatal("sortCanvasEl returns #scr-canvas before checking scope — the " +
|
||||||
|
"per-key editor paints into the global canvas")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful save must close the dialog even when the slots do not resolve.
|
||||||
|
// The write DID succeed; leaving the modal open makes the user click Save
|
||||||
|
// again and again. This regressed once: the !slots branch returned early.
|
||||||
|
func TestUIKeyAutoSaveClosesOnSuccess(t *testing.T) {
|
||||||
|
src := uiSource(t)
|
||||||
|
body := readFuncBody(t, src, "keyAutoSave")
|
||||||
|
// keyAutoClose() must be reached on every success path, not only in the
|
||||||
|
// happy branch.
|
||||||
|
iClose := strings.Index(body, "keyAutoClose()")
|
||||||
|
if iClose < 0 {
|
||||||
|
t.Fatal("keyAutoSave never calls keyAutoClose — the dialog would stay open")
|
||||||
|
}
|
||||||
|
iToast := strings.Index(body, "toast(")
|
||||||
|
if iToast >= 0 && iClose < iToast {
|
||||||
|
t.Fatal("keyAutoClose is called before the toast; the warning must be " +
|
||||||
|
"shown first or it is lost with the modal")
|
||||||
|
}
|
||||||
|
// An early `return` inside a branch bypasses keyAutoClose entirely, so
|
||||||
|
// merely finding the call is not enough: every `return` that sits between
|
||||||
|
// entering the try block and the close call is a path that leaves the
|
||||||
|
// dialog open after a successful write.
|
||||||
|
tryStart := strings.Index(body, "try {")
|
||||||
|
seg := body[tryStart:iClose]
|
||||||
|
for _, line := range strings.Split(seg, "\n") {
|
||||||
|
trimmed := strings.TrimSpace(line)
|
||||||
|
if trimmed == "return;" || strings.HasPrefix(trimmed, "return ") {
|
||||||
|
t.Fatal("a `return` before keyAutoClose leaves the dialog open after " +
|
||||||
|
"a successful save: " + trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFuncBody returns the source of a top-level function by name, using
|
||||||
|
// brace matching so nested blocks do not truncate it.
|
||||||
|
func readFuncBody(t *testing.T, src, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
idx := strings.Index(src, "function "+name+"(")
|
||||||
|
if idx < 0 {
|
||||||
|
t.Fatalf("function %s not found", name)
|
||||||
|
}
|
||||||
|
start := strings.Index(src[idx:], "{")
|
||||||
|
if start < 0 {
|
||||||
|
t.Fatalf("function %s has no body", name)
|
||||||
|
}
|
||||||
|
i := idx + start
|
||||||
|
depth := 0
|
||||||
|
for j := i; j < len(src); j++ {
|
||||||
|
switch src[j] {
|
||||||
|
case '{':
|
||||||
|
depth++
|
||||||
|
case '}':
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return src[i : j+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("function %s body is unterminated", name)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user