1. 新增多语言文件覆盖组件、页面、公共模块的翻译文案 2. 修复多处动态拼接lang key的调用,增加兜底逻辑避免空值或原始key暴露 3. 补全DIY组件、业务页面的国际化适配 4. 新增P2动态key国际化处理方案文档
307 lines
8.3 KiB
JavaScript
307 lines
8.3 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|