feat: 新增多语言文案并修复动态国际化调用问题

1. 新增多语言文件覆盖组件、页面、公共模块的翻译文案
2. 修复多处动态拼接lang key的调用,增加兜底逻辑避免空值或原始key暴露
3. 补全DIY组件、业务页面的国际化适配
4. 新增P2动态key国际化处理方案文档
This commit is contained in:
2026-06-29 15:29:24 +08:00
parent c3956f7bcd
commit c7810f16f7
115 changed files with 1343 additions and 153 deletions

306
scripts/verify-p2-keys.js Normal file
View File

@@ -0,0 +1,306 @@
/**
* P2 动态 key 验证脚本
*
* 作用:枚举审计报告 P2 中运行时拼接的 $lang() key 的所有已知取值,
* 模拟 common/js/lang.js 的合并/解析逻辑,检查这些 key 是否都能在
* zh-cn / en-us 语言包中解析。
*
* 用法:node scripts/verify-p2-keys.js
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
function loadLangFile(filePath) {
const fullPath = path.join(ROOT, filePath);
if (!fs.existsSync(fullPath)) {
console.error('Missing language pack:', filePath);
return {};
}
const content = fs.readFileSync(fullPath, 'utf-8');
const match = content.match(/export\s+const\s+lang\s*=\s*([\s\S]*?);?\s*$/);
if (!match) return {};
try {
return eval('(' + match[1] + ')');
} catch (e) {
console.error('Failed to parse', filePath, e.message);
return {};
}
}
function getValue(obj, keys) {
let current = obj;
for (const key of keys) {
if (current && typeof current === 'object' && !Array.isArray(current) && key in current) {
current = current[key];
} else {
return undefined;
}
}
return current;
}
function toCamelCaseName(name) {
return String(name || '').replace(/[-_]([a-z])/g, function(_, letter) {
return letter.toUpperCase();
});
}
function deepCloneLang(obj) {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) {
const result = new Array(obj.length);
for (let i = 0; i < obj.length; i++) {
result[i] = deepCloneLang(obj[i]);
}
return result;
}
const result = {};
for (const k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k)) {
result[k] = deepCloneLang(obj[k]);
}
}
return result;
}
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
function deepMergeLang(target, source) {
if (target === null || typeof target !== 'object') return source;
if (source === null || typeof source !== 'object') return source;
if (Array.isArray(target) || Array.isArray(source)) return source;
for (const key in source) {
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
if (FORBIDDEN_KEYS.has(key)) continue;
const srcVal = source[key];
const tgtVal = target[key];
if (
srcVal !== null && typeof srcVal === 'object' && !Array.isArray(srcVal) &&
tgtVal !== null && typeof tgtVal === 'object' && !Array.isArray(tgtVal)
) {
target[key] = deepMergeLang(tgtVal, srcVal);
} else {
target[key] = deepCloneLang(srcVal);
}
}
return target;
}
function normalizeSharedLangPack(packPath, langData) {
if (!langData || typeof langData !== 'object') return {};
const segments = String(packPath || '').split('/');
const group = segments[0];
const fileName = segments[segments.length - 1];
if (group !== 'components' && group !== 'components-diy') return langData;
if (langData[group]) return langData;
const componentName = toCamelCaseName(fileName);
const keys = Object.keys(langData);
if (keys.length === 1 && langData[keys[0]] && typeof langData[keys[0]] === 'object') {
return {
[group]: langData
};
}
return {
[group]: {
[componentName]: langData
}
};
}
function normalizePageLangPack(packPath, langData) {
if (!packPath || !langData || typeof langData !== 'object') return langData || {};
const segments = String(packPath).split('/').filter(Boolean);
if (!segments.length) return langData;
let leaf = langData;
let hasLeaf = true;
for (const segment of segments) {
const key = toCamelCaseName(segment);
if (leaf && typeof leaf === 'object' && !Array.isArray(leaf) && leaf[key] !== undefined) {
leaf = leaf[key];
} else {
hasLeaf = false;
break;
}
}
const lastSegment = toCamelCaseName(segments[segments.length - 1]);
const currentPageLeaf =
hasLeaf && leaf && typeof leaf === 'object' && !Array.isArray(leaf)
? leaf
: (langData[lastSegment] && typeof langData[lastSegment] === 'object' && !Array.isArray(langData[lastSegment]) ? langData[lastSegment] : langData);
let result = deepCloneLang(langData);
if (currentPageLeaf !== langData) {
result = deepMergeLang(deepCloneLang(currentPageLeaf), result);
}
if (!result[lastSegment]) {
result[lastSegment] = deepCloneLang(currentPageLeaf);
}
let 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];
}
return deepMergeLang(result, namespaced);
}
function buildContext(locale, sharedPacks, pagePackPath) {
let context = deepCloneLang(loadLangFile(`lang/${locale}/common.js`));
for (const sharedPath of sharedPacks) {
const pack = loadLangFile(`lang/${locale}/${sharedPath}.js`);
const normalized = normalizeSharedLangPack(sharedPath, pack);
context = deepMergeLang(context, normalized);
}
if (pagePackPath) {
const pack = loadLangFile(`lang/${locale}/${pagePackPath}.js`);
const normalized = normalizePageLangPack(pagePackPath, pack);
context = deepMergeLang(context, normalized);
}
return context;
}
function checkKey(context, key) {
const value = getValue(context, key.split('.'));
return typeof value === 'string' ? value : undefined;
}
// ===================== 动态 key 定义 =====================
const P2_CASES = [
{
file: 'components/payment/payment.vue',
locale: 'zh-cn',
shared: ['components/payment'],
keys: ['components.payment.alipay', 'components.payment.wechatpay']
},
{
file: 'components/payment/payment.vue',
locale: 'en-us',
shared: ['components/payment'],
keys: ['components.payment.alipay', 'components.payment.wechatpay']
},
{
file: 'pages_order/list.vue',
locale: 'zh-cn',
page: 'order/list',
keys: ['status.all', 'status.waitpay', 'status.waitsend', 'status.waitconfirm', 'status.waitrate']
},
{
file: 'pages_order/list.vue',
locale: 'en-us',
page: 'order/list',
keys: ['status.all', 'status.waitpay', 'status.waitsend', 'status.waitconfirm', 'status.waitrate']
},
{
file: 'pages_promotion/fenxiao/order.vue',
locale: 'zh-cn',
page: 'fenxiao/order',
keys: ['all', 'pendingSettlement', 'settled', 'refunded']
},
{
file: 'pages_promotion/fenxiao/order.vue',
locale: 'en-us',
page: 'fenxiao/order',
keys: ['all', 'pendingSettlement', 'settled', 'refunded']
},
{
file: 'pages_promotion/fenxiao/team.vue',
locale: 'zh-cn',
page: 'fenxiao/team',
keys: ['levelOne', 'levelTwo']
},
{
file: 'pages_promotion/fenxiao/team.vue',
locale: 'en-us',
page: 'fenxiao/team',
keys: ['levelOne', 'levelTwo']
},
{
file: 'pages_promotion/point/order_list.vue',
locale: 'zh-cn',
page: 'point/order_list',
keys: ['point.orderList.statusAll', 'point.orderList.statusPendingPayment', 'point.orderList.statusCompleted']
},
{
file: 'pages_promotion/point/order_list.vue',
locale: 'en-us',
page: 'point/order_list',
keys: ['point.orderList.statusAll', 'point.orderList.statusPendingPayment', 'point.orderList.statusCompleted']
},
{
file: 'pages_tool/goods/evaluate.vue',
locale: 'zh-cn',
page: 'goods/evaluate',
keys: ['tabAll', 'tabPositive', 'tabNeutral', 'tabNegative']
},
{
file: 'pages_tool/goods/evaluate.vue',
locale: 'en-us',
page: 'goods/evaluate',
keys: ['tabAll', 'tabPositive', 'tabNeutral', 'tabNegative']
},
{
file: 'pages_tool/pay/cashier.vue',
locale: 'zh-cn',
page: 'pay/cashier',
keys: ['pay.cashier.alipay', 'pay.cashier.wechatpay']
},
{
file: 'pages_tool/pay/cashier.vue',
locale: 'en-us',
page: 'pay/cashier',
keys: ['pay.cashier.alipay', 'pay.cashier.wechatpay']
}
];
// ===================== 执行校验 =====================
let allOk = true;
let currentFile = '';
for (const c of P2_CASES) {
if (currentFile !== c.file) {
console.log(`\nChecking ${c.file}:`);
currentFile = c.file;
}
const context = buildContext(c.locale, c.shared || [], c.page);
for (const key of c.keys) {
const value = checkKey(context, key);
if (value === undefined) {
console.log(` ❌ [${c.locale}] MISSING: ${key}`);
allOk = false;
} else {
console.log(` ✅ [${c.locale}] ${key} => ${value}`);
}
}
}
console.log('\n⚠️ components-diy/diy-tab.vue 的 title 为运行时 DIY 数据,无法静态枚举,建议通过 DIY 数据规范/白名单保证。');
if (allOk) {
console.log('\n✅ 所有可枚举的 P2 动态 key 均可解析');
process.exit(0);
} else {
console.log('\n❌ 部分 P2 动态 key 缺失');
process.exit(1);
}