修复员工工作台顶部导航重复(入口 JS 被引两次)

## 现象

打开 `employee-console/workspace`(平台治理),页面上出现**两份一模一样的顶部栏**:
南方财富 / 模拟基金服务 / 平台治理 / 风控中心 / admin_t 管理员 —— 连同页脚一起各两份。

## 根因:同一个入口 JS 被引了两次,且 `?v=` 不同

`app/static/portal/employee-console/workspace/index.html` 里曾同时存在:

    <script type="module" src=".../workspace.js?v=20260913-3"></script>
    <script type="module" src=".../workspace.js?v=20260914"></script>

浏览器按**完整 URL** 去重,两条不同 query 被当成**两个模块**、**各执行一次**。
入口里的 `mountShell()` 因此跑了两次,而它当时是
`document.body.insertAdjacentHTML('afterbegin', ...)` —— **无条件插入**,
于是 header 与 footer 各插两份。

来源是合并事故(`git blame` 定位):

| 行 | 提交 | 作者 |
|---|---|---|
| 旧 | `e31420df` | 卿云秋月(把版本号改成 `20260913-3`)|
| 新 | `f5d1b246` | 张胜宇(把版本号改成 `20260914`)|

两人各自把**同一行**的版本号换成新的,合并时两边都被保留,成了两行。
那个提交的信息是 "merge ... and retain risk review updates" ——
"retain" 在这里保留错了地方。

## 修法(两侧都堵)

1. **HTML 收敛成一行**(保留较新的 `?v=20260914`,与 `workspace.js` 内部
   `api-client.js?v=20260914` 一致),并就地写明"改版本号是替换这一行、不是新增一行"。
2. **`mountShell` 加幂等保护**:已有 `.site-header` 就直接 return。
   之所以不满足于只修那个 HTML —— 这个 bug 的症状很难反推到原因
   (页面看起来只是"多了一块"),而以后谁加缓存版本号时很容易再犯一次。

## 防回归(两条测试,都做过负面验证)

- `test_no_portal_page_includes_the_same_script_twice`:扫 `app/static/portal` 下
  **19 个页面**,把 `<script src>` 去掉 query 后比对,同一入口出现多次即失败。
  负面验证:把重复行临时放回去,测试**精确报出**
  `employee-console\workspace\index.html: ['/static/portal/employee-console/workspace/workspace.js']`,
  恢复后通过。
- `test_mount_shell_is_idempotent`:断言 `app-shell.js` 里有那句幂等判断。

全站扫描确认**只有这一处**,不是批量问题。

## 实测

- `GET /portal/employee-console/workspace/` -> 200,页面里 `workspace.js` **只出现一次**
  (`?v=20260914`);静态 HTML 中 `site-header` 出现 **0 次**(确认由 JS 注入,
  所以 JS 执行一次就只插一份)
- `pytest tests/unit/api/test_portal_frontend.py` -> **43 passed**(41 + 新增 2 条)
- `ruff check` -> All checks passed

## 一点说明

这次是"改同一个版本号"的合并冲突处理失误,属于**流程问题**而非个人疏忽:
两边都想把缓存版本号推新,冲突解决时很容易两边都留下。
测试补上之后,这类错误会在 `pytest tests/unit` 里当场暴露。
This commit is contained in:
2026-09-14 12:06:38 +08:00
parent e59a9905a0
commit e6d74059f2
3 changed files with 50 additions and 1 deletions
@@ -73,6 +73,17 @@ function footerMarkup() {
} }
export function mountShell({ active, mode = 'public' }) { export function mountShell({ active, mode = 'public' }) {
// ⚠️ 幂等保护:同一个页面被挂两次 shell,就会看到**两份一模一样的顶部导航与页脚**。
//
// 已知的触发方式只有一种:HTML 里引了两条入口 <script type="module">,且 `?v=` 不同。
// 浏览器按**完整 URL** 去重,两条会被当成两个模块、**各执行一次** ——
// 2026-09-14 的 `employee-console/workspace/index.html` 正是如此
// (合并时把两个分支各自改的版本号都保留下来,成了两行)。
//
// 那次已经把那两行收敛成一行;这里再挡一道,理由是这个错误**从症状上很难联想到原因**
// (页面看起来只是"多了一块"),而且以后别人加缓存版本号时很容易再犯一次。
if (document.querySelector('.site-header')) return;
startAuthSync(); startAuthSync();
document.body.classList.add(`portal-${mode}`); document.body.classList.add(`portal-${mode}`);
const context = getAuthContext(); const context = getAuthContext();
@@ -45,7 +45,11 @@
</main> </main>
<dialog class="operations-dialog" data-admin-detail><div class="operations-dialog__header"><h2 data-detail-title>详情</h2><button class="button icon-button" type="button" data-close-detail aria-label="关闭">×</button></div><div class="operations-dialog__body" data-detail-body></div><div class="operations-dialog__footer"><button class="button" type="button" data-close-detail>关闭</button></div></dialog> <dialog class="operations-dialog" data-admin-detail><div class="operations-dialog__header"><h2 data-detail-title>详情</h2><button class="button icon-button" type="button" data-close-detail aria-label="关闭">×</button></div><div class="operations-dialog__body" data-detail-body></div><div class="operations-dialog__footer"><button class="button" type="button" data-close-detail>关闭</button></div></dialog>
<dialog class="operations-dialog" data-admin-action><form data-admin-action-form><div class="operations-dialog__header"><h2 data-admin-action-title>确认操作</h2><button class="button icon-button" type="button" data-close-admin-action aria-label="关闭">×</button></div><div class="operations-dialog__body"><p class="admin-action-copy" data-admin-action-copy></p><label class="form-field" data-admin-comment-field hidden><span class="form-field__label">审核意见</span><textarea class="form-field__input admin-comment" name="comment" maxlength="1000"></textarea></label><div class="form-alert" data-admin-action-alert></div></div><div class="operations-dialog__footer"><button class="button" type="button" data-close-admin-action>取消</button><button class="button button--primary" type="submit">确认提交</button></div></form></dialog> <dialog class="operations-dialog" data-admin-action><form data-admin-action-form><div class="operations-dialog__header"><h2 data-admin-action-title>确认操作</h2><button class="button icon-button" type="button" data-close-admin-action aria-label="关闭">×</button></div><div class="operations-dialog__body"><p class="admin-action-copy" data-admin-action-copy></p><label class="form-field" data-admin-comment-field hidden><span class="form-field__label">审核意见</span><textarea class="form-field__input admin-comment" name="comment" maxlength="1000"></textarea></label><div class="form-alert" data-admin-action-alert></div></div><div class="operations-dialog__footer"><button class="button" type="button" data-close-admin-action>取消</button><button class="button button--primary" type="submit">确认提交</button></div></form></dialog>
<script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260913-3"></script> <!-- ⚠️ 入口 JS 只能引一次。这里曾经同时引了两条,且 `?v=` 不同
(20260913-3 与 20260914 —— 合并时两个分支各自改了版本号,都被保留下来):
浏览器按**完整 URL** 去重,两条不同 query 会被当成两个模块、**各执行一次**,
于是入口里的 `mountShell()` 跑两遍,页面上出现**两份顶部导航与页脚**。
改版本号时是**替换**这一行,不是新增一行。 -->
<script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260914"></script> <script type="module" src="/static/portal/employee-console/workspace/workspace.js?v=20260914"></script>
</body> </body>
</html> </html>
+34
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import re
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@@ -129,6 +130,39 @@ def test_advisor_dashboard_is_composed_from_feature_modules() -> None:
assert "ACTION_LABELS" in config assert "ACTION_LABELS" in config
def test_no_portal_page_includes_the_same_script_twice() -> None:
"""同一个入口 JS 被引两次(哪怕 `?v=` 不同)会让页面出现两份顶部导航。
浏览器按**完整 URL** 去重:`x.js?v=A` 与 `x.js?v=B` 是两个模块、**各执行一次**。
入口里的 `mountShell()` 于是跑两遍,插入两份 header / footer ——
2026-09-14 `employee-console/workspace/index.html` 就这么写过:合并时
两个分支各自把同一行的版本号换成新的,两边都被保留,成了一条重复的 `<script>`。
"""
pattern = re.compile(r"<script[^>]*\ssrc=[\"']([^\"']+)[\"']", re.IGNORECASE)
duplicated: list[str] = []
for page in sorted(PORTAL.rglob("*.html")):
# 只比 `<script>`;站内绝对路径去掉 query 再归并
sources = [
url.split("?", 1)[0] if url.startswith("/") else url
for url in pattern.findall(page.read_text(encoding="utf-8"))
]
repeated = sorted({src for src in sources if sources.count(src) > 1})
if repeated:
duplicated.append(f"{page.relative_to(PORTAL)}: {repeated}")
assert not duplicated, f"同一入口脚本被引入多次:{duplicated}"
def test_mount_shell_is_idempotent() -> None:
"""`mountShell` 要自带「已经挂过就不再挂」的保护。
上一条测试守住 HTML,这一条守住代码 —— 两侧都挡一道,
因为这个 bug 的症状很难反推原因(页面看起来只是"多了一块"),
而以后加缓存版本号时很容易再犯。
"""
source = (PORTAL / "common" / "layout" / "app-shell.js").read_text(encoding="utf-8")
assert "if (document.querySelector('.site-header')) return;" in source
def test_portal_feature_modules_have_consistent_imports() -> None: def test_portal_feature_modules_have_consistent_imports() -> None:
"""拆分前端模块时最容易漏 import:定义搬走了,使用处却留在原文件。 """拆分前端模块时最容易漏 import:定义搬走了,使用处却留在原文件。