投顾工作台:修推荐结果渲染(幂等响应形状归一)+ 对齐门户版式规范

投顾页「生成推荐方案」拿不到产品的根因:该端点标了 idempotent,浏览器必带
Idempotency-Key,而后端此时返回的是 {data:{content_id, status, plan:{...}}, meta}
—— 真正的文档嵌在 data.plan 里。此前前端只处理了不带键时的裸文档形状。

- api-client:ADVISOR_RECOMMEND 撤掉 raw(带幂等键时确为信封,需正常解包);
  ADVISOR_ALLOCATION / ADVISOR_ANALYSIS 保留 raw(不标幂等,返回裸文档)
- actions-module:新增 normalizeRecommend(),把「信封 + plan 嵌套」与「裸文档」
  两种响应归一成一种形状;结果卡片显示方案编号与待审核状态
- 投顾页:hero 大图 + 4 张概览指标卡 + 左栏/主区两栏构图
- 投顾页:页头常显「退出登录」按钮;新增风评预警数、快捷问句按钮
- 投顾页:动作名统一为「生成推荐方案」(对齐软件需求文档 5.4 ⑦)
- 管理台「待审投顾内容」空态文案同步改名
- 前端模块相对 import 加 ?v= 版本号:改文件内容而 URL 不变会被浏览器缓存挡住
This commit is contained in:
2026-09-14 22:17:39 +08:00
parent 1d6c32f6b3
commit ae7a89f33d
15 changed files with 1888 additions and 879 deletions
+80 -11
View File
@@ -1,4 +1,4 @@
"""验证投顾工作台四个前端模块:语法正确、import 能解析、用到的符号有来源。
"""验证投顾工作台的前端模块:语法正确、import 能解析、用到的符号有来源。
## 为什么要这个检查
@@ -8,14 +8,20 @@
只会在浏览器里以 `ReferenceError: CONTENT_TYPE_LABELS is not defined` 的形式爆出来,
表现为「投顾工作台打开是白板」,而且**测试全绿**。
所以这里做三件事:
所以这里做四件事:
1. 每个文件交给 `node --check` 按 ES module 解析(语法错当场暴露);
2. 每个相对 import 的目标文件必须存在;
3. 每个 import 进来的名字,在目标文件里必须真的有 `export`;
4. 文件里用到的全大写常量(`FOO_BAR` 形态、且不是 `x.FOO_BAR` 属性访问),
必须在「import 进来的 + 本文件声明的 + 已知全局」里 —— 第 4 条正是抓上面那个 bug 的。
第 4 条是启发式,会刻意避开引号内的字符串与对象字面量的 key,减少误报。
## 第 4 条的取词范围(`code_only()`)
只保留**真代码**:注释、`'…'` / `"…"` 字符串、以及模板字符串里的**文字部分**都被清空,
唯独 `${…}` 里的表达式保留下来(那里是真代码,也正是原 bug 容易藏身的地方)。
早期版本只剔除引号字符串,结果注释里的 `HHI`、文案里的 `AUM`、模板串里的 `QDII`
全被当成"未声明的变量" —— 误报比真问题还多,检查会被当成摆设跳过。
"""
from __future__ import annotations
@@ -35,6 +41,9 @@ MODULES = [
"dashboard.js",
"actions-module.js",
"published-module.js",
"assistant-module.js",
"customer-module.js",
"advisor-engine.js",
"advisor-config.js",
]
@@ -57,16 +66,76 @@ EXPORT_RE = re.compile(
)
#: 全大写常量:至少两个字符、全大写/数字/下划线,且不是"前面带点"的属性访问。
CONST_USE_RE = re.compile(r"(?<![\w.$])([A-Z][A-Z0-9_]{2,})\b")
#: 单双引号包裹的内容,检查常量使用时先剔除。
#: ⚠️ **反引号模板字符串不能剔除**:`${...}` 里面是真代码,而这类 bug 恰恰
#: 常常发生在模板字符串里 —— 第一版把整段模板字符串也剔了,结果
#: `published-module.js` 少了 `CONTENT_TYPE_LABELS` 的 import 时**检查依然全绿**
#: (实测确认,差点让这个检查变成摆设)。宁可对模板里的中文文案误报,也不能漏。
STRING_RE = re.compile(r"'[^'\n]*'|\"[^\"\n]*\"")
#: 对象字面量里作为 key 的大写标识符(`{ FOO: 1 }`)不算使用。
OBJECT_KEY_RE = re.compile(r"(?<![\w.$])([A-Z][A-Z0-9_]{2,})\s*:")
def code_only(source: str) -> str:
"""只保留真代码:清空注释、引号字符串与模板字符串的文字部分(`${…}` 里的表达式保留)。
逐字符扫描而不是一把正则:模板字符串里可以嵌套 `${…}`(甚至再嵌模板),
正则匹配不了嵌套,只能老老实实走一遍。
"""
out: list[str] = []
index = 0
length = len(source)
while index < length:
char = source[index]
following = source[index + 1] if index + 1 < length else ""
if char == "/" and following == "*":
end = source.find("*/", index + 2)
index = length if end == -1 else end + 2
out.append(" ")
continue
if char == "/" and following == "/":
end = source.find("\n", index)
index = length if end == -1 else end
out.append(" ")
continue
if char in "'\"":
cursor = index + 1
while cursor < length:
if source[cursor] == "\\":
cursor += 2
continue
if source[cursor] == char:
break
cursor += 1
out.append(" ")
index = min(cursor + 1, length)
continue
if char == "`":
index += 1
while index < length:
if source[index] == "\\":
index += 2
continue
if source[index] == "`":
index += 1
break
if source[index] == "$" and index + 1 < length and source[index + 1] == "{":
depth = 1
index += 2
start = index
while index < length and depth:
if source[index] == "{":
depth += 1
elif source[index] == "}":
depth -= 1
if depth == 0:
break
index += 1
out.append(" " + source[start:index] + " ")
index += 1
continue
index += 1
out.append(" ")
continue
out.append(char)
index += 1
return "".join(out)
def exported_names(path: Path) -> set[str]:
source = path.read_text(encoding="utf-8")
names: set[str] = set()
@@ -155,8 +224,8 @@ def main() -> int:
# ---- 用到的全大写常量必须有来源 ----
declared = set(re.findall(r"(?:const|let|var|function|class)\s+([A-Z][A-Z0-9_]{2,})\b", source))
declared |= exported_names(path)
# 剔除字符串字面量与对象 key,避免把文案/枚举 key 当成变量使用。
stripped = OBJECT_KEY_RE.sub(" ", STRING_RE.sub(" ", source))
# 先清掉注释 / 字符串 / 模板文字,再排除对象字面量的 key。
stripped = OBJECT_KEY_RE.sub(" ", code_only(source))
for match in CONST_USE_RE.finditer(stripped):
name = match.group(1)
if name in imported or name in declared or name in KNOWN_GLOBALS: