fix(remote lang tree): 修复命名空间被基本类型值覆盖的bug

修复remote-only模式下语言包归一化的历史bug:当页面leaf顶层存在与命名空间同名的字符串键时,会覆盖整个namespace导致子key失效。通过对齐normalizePageLangPack的两层合并逻辑来修复:
1. 先将页面语言包按路径段转为命名空间结构再合并
2. 额外平铺leaf的基本类型键到根节点以兼容短key调用
This commit is contained in:
2026-07-04 09:25:01 +08:00
parent 783c10fc62
commit 2d7d7f731c
7 changed files with 12220 additions and 16 deletions

View File

@@ -170,11 +170,35 @@ function normalizePageLangPack(path, langData) {
}
let namespaced = {};
let cursor = namespaced;
{
let cursor = namespaced;
for (let i = 0; i < segments.length; i++) {
const camelSegment = toCamelCaseName(segments[i]);
cursor[camelSegment] = i === segments.length - 1 ? deepCloneLang(currentPageLeaf) : {};
cursor = cursor[camelSegment];
}
}
// 同步建立 snake_case 镜像:让 camelCase 与 snake_case 路径都能解析到同一份 leaf
// (与 bundle 服务器侧 emit 形式对齐 —— bundle 用 snake_case 段名,如 point.order_list.statusAll)
// 实现:在每个 namespace 节点同时挂一个 snake 别名键,与 camel 节点共享 leaf 引用(0 拷贝)
const nested = [];
{
let cursor = namespaced;
nested.push(cursor);
for (let i = 0; i < segments.length - 1; i++) {
cursor = cursor[toCamelCaseName(segments[i])];
nested.push(cursor);
}
}
for (let i = 0; i < segments.length; i++) {
const segment = toCamelCaseName(segments[i]);
cursor[segment] = i === segments.length - 1 ? deepCloneLang(currentPageLeaf) : {};
cursor = cursor[segment];
const raw = segments[i];
const camel = toCamelCaseName(raw);
const snake = toSnakeCaseName(raw);
if (snake !== camel) {
const isLeaf = i === segments.length - 1;
nested[i][snake] = isLeaf ? deepCloneLang(currentPageLeaf) : {};
}
}
return deepMergeLang(result, namespaced);
@@ -811,6 +835,19 @@ function emitUpdated(lang) {
/**
* remote-only 模式下,为当前页面做 tree 归一化
* 把页面路径对应的 subtree 合并到顶层,兼容 $lang('title') 等短 key 调用
*
* 历史 bug: 之前直接 `deepMergeLang(tree, currentPageLeaf)`,
* 如果 currentPageLeaf 顶层有一个与 `tree.login` 命名空间同名(key)且值为字符串的键
* (例如 `login.login.js` 里的 `login: '登录/注册'` 会出现在 leaf 顶层),
* 会触发 `merge` 的"基本类型覆盖对象"分支,把 `tree.login` 整个 namespace 替换为字符串,
* 导致 `tree.login.login.welcomeText` 等所有子页 key 全部失效。
*
* 修复方案(对齐 `normalizePageLangPack` 的两层合并):
* 1) namespaced merge: 把 currentPageLeaf 按 langPath 段逐层包成 `{seg1: {seg2: leaf}}`,
* 再 merge 进 tree —— 同样的 key 仍能递归合并,而 namespace 不会被字符串覆盖。
* 2) 在 result 上额外"轻量 overlay":把 leaf 里的 SCALAR key 平铺到 root,
* 但跳过 target 已经是 namespace 对象的 key,避免再次触发 clobber。
* 这一步让 `$lang('title')` / `$lang('welcomeText')` 这类短 key 仍能在 root 命中页面副本。
*/
function normalizeRemoteTreeForPage(tree, langPath) {
if (!tree || typeof tree !== 'object') return tree || {};
@@ -839,8 +876,37 @@ function normalizeRemoteTreeForPage(tree, langPath) {
if (!currentPageLeaf) return tree;
// 页面 leaf 合并到顶层,同时也保留完整路径
return deepMergeLang(deepCloneLang(tree), deepCloneLang(currentPageLeaf));
// (1) namespaced merge
const namespaced = {};
let cursor = namespaced;
for (let i = 0; i < segments.length; i++) {
const segment = toCamelCaseName(segments[i]);
cursor[segment] = i === segments.length - 1 ? deepCloneLang(currentPageLeaf) : {};
cursor = cursor[segment];
}
const merged = deepMergeLang(deepCloneLang(tree), namespaced);
// (2) 短 key 兼容: 把 leaf 里的 SCALAR 平铺到 root,但跳过 target 已有 namespace 的 key
try {
for (const k of Object.prototype.hasOwnProperty.call(currentPageLeaf, '__proto__') ? [] : Object.keys(currentPageLeaf)) {
if (FORBIDDEN_KEYS.has(k)) continue;
const srcVal = currentPageLeaf[k];
const tgtVal = merged[k];
// 源非对象(基本类型)才平铺;源是对象则让上一步 namespaced merge 处理
if (srcVal === null || typeof srcVal !== 'object' || Array.isArray(srcVal)) {
// 若 target 已是 namespace 对象,跳过,避免 string-vs-object clobber
if (tgtVal && typeof tgtVal === 'object' && !Array.isArray(tgtVal)) continue;
merged[k] = srcVal;
} else {
// 源是对象 —— 递归合并到 merged[k](若存在)或跳过
if (tgtVal && typeof tgtVal === 'object' && !Array.isArray(tgtVal)) {
merged[k] = deepMergeLang(deepCloneLang(tgtVal), deepCloneLang(srcVal));
}
}
}
} catch (e) { /* 静默 */ }
return merged;
}
// 导出主体