]> acesimba.cloud Git - StationBuilder.git/commitdiff
fix(vue): 配置站点纯白页根因 - 模板表达式中的 window 被解析为组件作用域
authorCodeBuddy <codebuddy@local>
Wed, 23 Sep 2026 02:54:18 +0000 (10:54 +0800)
committerCodeBuddy <codebuddy@local>
Wed, 23 Sep 2026 02:54:18 +0000 (10:54 +0800)
根因: 用户拖动过 AI 浮动按钮, fabPos 存入 localStorage; 该值存在时 Home.vue 模板 :style 中的
window.innerHeight 被 Vue 编译器当作组件属性 _ctx.window (undefined) 解析, 读取 .innerHeight
抛 TypeError, 整棵 Home 渲染树崩溃 -> 纯白页。React 版 JSX 不做此转换故无此问题。

修复:
- Home.vue 新增 setup 绑定 const win = window, 模板该用 win.innerHeight (替代 window.innerHeight)
- 同步修正另外两处模板内联处理器中的 window.confirm / window.history.replaceState
  (Home.vue:546 删除领域点击, Home.vue:597 站点切换) 同样规避 _ctx.window 解析
- main.js 新增屏幕可见的错误浮层(window.onerror/unhandledrejection/Vue errorHandler),
  便于后续此类运行时错误直接显示到页面

脚本内(window.open/history.replaceState/document.* 等)的 window 仍是真实全局, 无需改动。

src-vue/Home.vue
src-vue/main.js

index 9b6020ab1f764a90ddfb2a409e85ced5b5dbb680..ae86fccf380af06cee113a4843cdcab01c8074de 100644 (file)
@@ -54,6 +54,11 @@ import Placeholder from "./pages/Placeholder.vue";
 import { fetchDb, buildMuLeaves } from "./lib/api.js";
 import { meta, SITE_EDIT_STEPS, MODELS } from "./lib/meta.js";
 
+// `window` is not in Vue's template global allow-list, so referencing it inside a
+// template expression resolves to the (undefined) component scope. Expose it as a
+// setup binding instead.
+const win = window;
+
 const notify = (m) => { message.info(m, 2.2); };
 const confirm = (msg) => Promise.resolve(window.confirm(msg));
 
@@ -538,7 +543,7 @@ function onMenuOpenChange(keys) { resourceNavOpen.value = keys.includes("resourc
           <small>领域分类</small>
           <div v-for="d in domainOptions" :key="d.id" class="domain-nav-row" :class="page === 'domains' && domainFilter === d.id ? ' active' : ''">
             <UiButton :class="page === 'domains' && domainFilter === d.id ? 'active' : ''" @click="() => { domainFilter = d.id; go('domains'); }"><b>◆</b><span class="domain-nav-name">{{ d.name }}</span></UiButton>
-            <UiButton v-if="d.name !== '其他'" className="domain-nav-del" title="删除领域" @click="async () => { if (!window.confirm(`确定删除领域「${d.name}」?其下的方案将移入「其他」领域。`)) return; try { await fetchDb(`/domains/${d.id}`, { method: 'DELETE' }); notify('已删除领域,相关方案已归入「其他」'); await handleDomainDeleted(d.id); } catch (e) { notify('删除失败:' + e.message); } }">×</UiButton>
+            <UiButton v-if="d.name !== '其他'" className="domain-nav-del" title="删除领域" @click="async () => { if (!win.confirm(`确定删除领域「${d.name}」?其下的方案将移入「其他」领域。`)) return; try { await fetchDb(`/domains/${d.id}`, { method: 'DELETE' }); notify('已删除领域,相关方案已归入「其他」'); await handleDomainDeleted(d.id); } catch (e) { notify('删除失败:' + e.message); } }">×</UiButton>
           </div>
           <div v-if="addingDomain" class="domain-nav-row add">
             <UiInput className="domain-nav-input" autoFocus :value="newDomainName" placeholder="领域名称" @change="(e) => (newDomainName = e.target.value)" @keydown="async (e) => { if (e.key === 'Enter') { await doAddDomain(); } else if (e.key === 'Escape') { addingDomain = false; newDomainName = ''; } }" />
@@ -589,7 +594,7 @@ function onMenuOpenChange(keys) { resourceNavOpen.value = keys.includes("resourc
           </div>
           <div>
             <label v-if="page === 'siteEdit'" class="site-edit-switcher"><span>当前站点</span>
-              <UiSelect :value="siteId ?? ''" @change="async (e) => { const id = Number(e.target.value); if (id === siteId) return; try { await saveCurrentConfiguration(); await loadSite(id); siteEditSection = 'basic'; } catch (error) { notify('切换失败:' + error.message); return; } window.history.replaceState({}, '', `?page=siteEdit&id=${id}`); }">
+              <UiSelect :value="siteId ?? ''" @change="async (e) => { const id = Number(e.target.value); if (id === siteId) return; try { await saveCurrentConfiguration(); await loadSite(id); siteEditSection = 'basic'; } catch (error) { notify('切换失败:' + error.message); return; } win.history.replaceState({}, '', `?page=siteEdit&id=${id}`); }">
                 <option v-for="item in sites" :key="item.id" :value="item.id">{{ item.name || '未命名站点' }}</option>
               </UiSelect>
             </label>
@@ -656,7 +661,7 @@ function onMenuOpenChange(keys) { resourceNavOpen.value = keys.includes("resourc
     </UiOverlay>
     <ModalComp v-if="modal" :type="modal" :close="() => (modal = '')" :notify="notify" :prompt-data="promptData" :feedback-cube="feedbackCube" :refine-cube="refineCube" :refining="refining" :refine-log="refineLog" />
     <UiButton v-if="!aiOpen && !manualAssetActive && !domainArea" ref="fabRef" className="ai-fab" title="AI 助手 · 拖动可移动位置,双击复位" aria-label="AI 助手"
-      :style="fabPos ? { left: fabPos.x + 'px', top: page === 'siteEdit' ? Math.min(fabPos.y, window.innerHeight - 132) + 'px' : fabPos.y + 'px', right: 'auto', bottom: 'auto' } : page === 'siteEdit' ? { bottom: '76px' } : undefined"
+      :style="fabPos ? { left: fabPos.x + 'px', top: page === 'siteEdit' ? Math.min(fabPos.y, win.innerHeight - 132) + 'px' : fabPos.y + 'px', right: 'auto', bottom: 'auto' } : page === 'siteEdit' ? { bottom: '76px' } : undefined"
       @pointerdown="onFabPointerDown" @pointermove="onFabPointerMove" @pointerup="onFabPointerUp" @dblclick="resetFab">
       <img src="/t/stationbuilder/ai-icon.png" alt="AI 助手" className="ai-fab-icon" draggable="false" />
     </UiButton>
index 7efc450335b4484957980617026fb06089a77478..2dbd14a1767c8cfdbefe48c63beb953e6ff1778a 100644 (file)
@@ -21,4 +21,24 @@ import "./components/dropdown-button.less";
 
 import App from "./App.vue";
 
-createApp(App).use(Antd).mount("#root");
+// Visible error overlay — surfaces runtime errors that would otherwise blank the page.
+function showFatalError(title, detail) {
+  try {
+    let box = document.getElementById("sb-fatal-error");
+    if (!box) {
+      box = document.createElement("div");
+      box.id = "sb-fatal-error";
+      box.setAttribute("style", "position:fixed;top:0;left:0;right:0;z-index:99999;background:#fff2f0;border-bottom:2px solid #ff4d4f;padding:12px 16px;font:13px/1.6 monospace;color:#a8071a;white-space:pre-wrap;word-break:break-all;max-height:60vh;overflow:auto;");
+      document.body && document.body.appendChild(box);
+    }
+    box.textContent += `[${title}] ${detail}\n\n`;
+  } catch (e) { /* ignore */ }
+}
+window.addEventListener("error", (e) => showFatalError("error", (e.message || "") + "\n" + (e.filename || "") + ":" + (e.lineno || "") + "\n" + (e.error && e.error.stack ? e.error.stack : "")));
+window.addEventListener("unhandledrejection", (e) => showFatalError("unhandledrejection", String(e.reason && e.reason.stack ? e.reason.stack : e.reason)));
+
+const app = createApp(App);
+app.config.errorHandler = (err, instance, info) => {
+  showFatalError("vue:" + info, String(err && err.stack ? err.stack : err));
+};
+app.use(Antd).mount("#root");