feat: add multi-language page configs and fix i18n issues

1. 新增多套多语言页面翻译文件,覆盖文件列表、案例展示、商户相关、印章相关、分销、小程序发货等页面
2. 修复语言包拼写错误:loginTpis → loginTips
3. 重构语言包合并逻辑,使用深度合并替代浅合并避免子key丢失
4. 优化导航栏标题设置,统一使用$langConfig.title替代硬编码标题
5. 修复底部导航栏语言切换不响应的问题,改为响应式获取当前语言
6. 完善英文通用语言包,补充订单、购物车、会员相关翻译
7. 修复商品详情页默认标题为空的问题,设置为"商品详情"
This commit is contained in:
2026-06-25 14:52:39 +08:00
parent cc7125361f
commit 5d3663786c
51 changed files with 581 additions and 63 deletions

View File

@@ -86,6 +86,81 @@ function deepCloneLang(obj) {
return result;
}
/**
* 语言包深度合并:将 source 合并到 target 中,返回 target
* - 对象类型递归合并(嵌套 key 不会整体覆盖)
* - 数组/字符串/数字等基本类型直接覆盖
* - 危险 key 过滤:__proto__ / constructor / prototype
*
* 为什么需要深合并:
* 之前用 Object.assign 做顶层浅合并,如果 page 语言包定义了 common: { submit: 'x' },
* 会整体替换 common.js 中的 common 对象,导致 common.currencySymbol 等子 key 丢失。
* 深合并可以保留两边的 key,只在 leaf 节点覆盖。
*/
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;
}
/**
* 翻译参数替换:支持位置参数和命名参数
*
* 位置参数: $lang('key', 'a', 'b') → {0}=a, {1}=b
* 命名参数: $lang('key', { name: 'Tom', count: 3 }) → {name}=Tom, {count}=3
*
* 规则:
* - 第 2 个参数起如果是对象(非 null 非数组),则当作命名参数 map
* - 其他情况当作位置参数
* - 全局替换(同一个占位符出现多次都能替换)
* - 字面量花括号可以用 {{ 和 }} 转义(暂不支持,有需要再加)
*/
function applyLangParams(value, args) {
if (args.length === 0) return value;
let result = String(value);
const firstArg = args[0];
const isNamed = firstArg !== null && typeof firstArg === 'object' && !Array.isArray(firstArg);
if (isNamed) {
// 命名参数:遍历对象的每个 key
const map = firstArg;
for (const key in map) {
if (!Object.prototype.hasOwnProperty.call(map, key)) continue;
if (FORBIDDEN_KEYS.has(key)) continue;
const safeKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
result = result.replace(new RegExp('\\{' + safeKey + '\\}', 'g'), String(map[key]));
}
} else {
// 位置参数: {0}, {1}, {2}...
for (let i = 0; i < args.length; i++) {
result = result.replace(new RegExp('\\{' + i + '\\}', 'g'), String(args[i]));
}
}
return result;
}
// 远端覆写:内存缓存
// shape: { [app_lang]: { version, map, ts, global_version } }
// key 格式 `${appType}_${lang}`,天然按平台 + 语种二维隔离,避免多端 cache 串味
@@ -543,17 +618,30 @@ export default {
const fieldCacheKey = `${locale}__${langPath}__${_overrideVersion}__${field}`;
const cachedFieldResult = _fieldResultCache[fieldCacheKey];
const hasDefault = arguments.length > 1 && typeof arguments[1] === 'string' && arguments[1] !== '';
const __DEV__ = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production');
if (cachedFieldResult !== undefined) {
let value = cachedFieldResult;
// 没找到翻译且提供了默认值 → 使用默认值
if (value === field && hasDefault) {
return arguments[1];
}
// 带参数的翻译(如 hello {0})需要在缓存后仍做参数替换
if (arguments.length > 1) {
for (let i = 1; i < arguments.length; i++) {
value = String(value).replace("{" + (i - 1) + "}", arguments[i]);
// 没找到翻译
if (value === field) {
// 有默认值 → 使用默认值
if (hasDefault) {
return arguments[1];
}
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
if (!__DEV__) {
return '';
}
// 开发环境 → 返回 raw key(便于发现问题)
return value;
}
// 带参数的翻译:支持位置参数({0})和命名参数({name})
if (arguments.length > 1) {
// 从第 2 个参数开始提取参数数组
const args = new Array(arguments.length - 1);
for (let i = 1; i < arguments.length; i++) {
args[i - 1] = arguments[i];
}
value = applyLangParams(value, args);
}
return value;
}
@@ -565,7 +653,7 @@ export default {
try {
const lang = loadLangPackSync(locale, 'common');
const currentPageLang = langPath ? loadLangPackSync(locale, langPath) : {};
mergedLang = Object.assign(deepCloneLang(lang), deepCloneLang(currentPageLang));
mergedLang = deepMergeLang(deepCloneLang(lang), deepCloneLang(currentPageLang));
const ovr = overrideCache[getOverrideMemKey(locale)];
if (ovr && ovr.map) applyOverrides(mergedLang, ovr.map);
} catch (e) {
@@ -604,20 +692,28 @@ export default {
// 将结果存入字段缓存(不含参数替换部分:参数每次可能不同)
_fieldResultCache[fieldCacheKey] = value;
// 没找到翻译且提供了默认值 → 使用默认值
if (value === field && hasDefault) {
value = arguments[1];
} else if (arguments.length > 1) {
// 带参数的翻译
for (let i = 1; i < arguments.length; i++) {
value = String(value).replace("{" + (i - 1) + "}", arguments[i]);
// 没找到翻译
if (value === field) {
// 有默认值 → 使用默认值
if (hasDefault) {
value = arguments[1];
} else if (!__DEV__) {
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
value = '';
}
// 开发环境无默认值 → 保留 raw key(便于发现问题)
} else if (arguments.length > 1) {
// 带参数的翻译:支持位置参数({0})和命名参数({name})
const args = new Array(arguments.length - 1);
for (let i = 1; i < arguments.length; i++) {
args[i - 1] = arguments[i];
}
value = applyLangParams(value, args);
}
if (value == undefined || (value == 'title' && field == 'title')) value = '';
if (value == undefined) value = '';
// 多语言调试
const __DEV__ = (typeof process !== 'undefined' && process.env && process.env.NODE_ENV !== 'production');
if (__DEV__ && field == value) {
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
}

View File

@@ -28,12 +28,12 @@
<view class="label"
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'diy'"
:style="{ color: verify(item.link) ? tabBarList.textHoverColor : tabBarList.textColor }">
{{ lang == 'en-us' ? item.en_text : item.text }}
{{ currentLang == 'en-us' ? item.en_text : item.text }}
</view>
<view class="label"
v-if="(tabBarList.type == 1 || tabBarList.type == 3) && tabBarList.theme == 'default'"
:style="{ color: verify(item.link) ? 'var(--base-color)' : '#333333' }">
{{ lang == 'en-us' ? item.en_text : item.text }}
{{ currentLang == 'en-us' ? item.en_text : item.text }}
</view>
</view>
</view>
@@ -61,7 +61,6 @@ export default {
mixins: [DiyMinx],
data() {
return {
lang: uni.getStorageSync("lang"),
currentRoute: '', //当前页面路径
jumpFlag: true, //是否可以跳转,防止重复点击
cartAnimation: {}
@@ -90,6 +89,14 @@ export default {
});
},
computed: {
// 当前语言(响应式):通过 componentRefresh 建立依赖,切语言时自动更新
// 不能直接用 uni.getStorageSync 作为 data,因为它不是响应式的
currentLang() {
// 通过 componentRefresh 建立响应式依赖
// 切语言时 store 中的 componentRefresh 会 +1,触发重新计算
this.componentRefresh;
return uni.getStorageSync("lang") || 'zh-cn';
},
cartChange() {
return this.$store.state.cartChange;
},

View File

@@ -70,7 +70,7 @@
</view>
<view class="info">
<view class="nickname"><text class="name">{{ $lang('login') }}</text></view>
<view class="desc">{{ $lang('loginTpis') }}</view>
<view class="desc">{{ $lang('loginTips') }}</view>
</view>
<view v-if="ischina == 1"

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'Agreement',
}

View File

@@ -34,7 +34,10 @@ export const lang = {
copySuccess: 'Copied',
copySelected: 'Copied selected content',
copyFail: 'Copy failed',
copyFailWithReason: 'Copy failed: {reason}',
openingFile: 'Opening file...',
openingFileName: 'Opening file: {name}',
openingFileTitle: 'Opening: {title}',
fileOpenFail: 'Failed to open file',
loadHistoryFail: 'Failed to load history',
about: '0B',
@@ -64,6 +67,7 @@ export const lang = {
improveService: 'We will improve our service',
openLink: 'Open Link',
confirmOpenLink: 'Are you sure you want to open this link: ',
confirmOpenLinkText: 'Are you sure you want to open this link: {url}?',
operationFailed: 'Operation failed, please try again',
prompt: 'Prompt',
serviceTypeNotSupported: 'Current service type does not support this page',

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'Case Studies',
}

View File

@@ -34,4 +34,34 @@ export const lang = {
loginTips: 'Click to login and enjoy more exciting information',
toLogin: 'Go to login',
toGoodsCategoryPage: 'Go shopping',
// 订单状态
waitpay: 'Pending Payment',
waitsend: 'Pending Shipment',
waitconfirm: 'Pending Receipt',
activist: 'After-sales',
completed: 'Completed',
// 购买/购物车
buy: 'Buy',
buyNow: 'Buy Now',
addToCart: 'Add to Cart',
addToCartSuccess: 'Added to Cart',
addToCartFail: 'Failed to Add to Cart',
addToCartFailMax: 'Maximum purchase quantity reached',
addToCartFailMin: 'Minimum purchase quantity not met',
addToCartFailStock: 'Insufficient Stock',
// 咨询
consult: 'Consult Now',
consultSuccess: 'Consultation Successful',
consultFail: 'Consultation Failed',
// 会员相关
privacyPolicy: 'Privacy Policy',
registrationAgreement: 'Registration Agreement',
cancellation: 'Account Cancellation',
logout: 'Log Out',
logoutTitle: 'Tip',
logoutContent: 'Are you sure you want to log out?',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'Distributors',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'Ranking',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'File Preview',
}

4
lang/en-us/files/list.js Normal file
View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'View Files',
}

View File

@@ -2,5 +2,6 @@ export const lang = {
//title为每个页面的标题
title: 'Goods Category',
search: 'Search',
seeMore : 'See More'
seeMore : 'See More',
Make: 'Consult Now'
}

View File

@@ -9,6 +9,7 @@ export const lang = {
leave:'leave a message',
make:'make an appointment',
makebtn:'make',
commissiontext: 'Commission',
sales:'sales',
stock:'stock',

View File

@@ -1,6 +1,7 @@
export const lang = {
//title为每个页面的标题
title: 'Edit Account',
addTitle: 'Add Account',
name: 'Name',
namePlaceholder: 'Please enter real name',
mobilePhone: 'Mobile Number',

View File

@@ -1,6 +1,7 @@
export const lang = {
//title为每个页面的标题
title: 'Edit Shipping Address',
addTitle: 'Add Shipping Address',
consignee: 'Name',
consigneePlaceholder: 'Consignee Name',
mobile: 'Mobile',

View File

@@ -2,7 +2,7 @@ export const lang = {
//title为每个页面的标题
title: 'Member Center',
login: 'Login',
loginTpis: 'Login experience',
loginTips: 'Login experience',
memberLevel: 'Member Level',
moreAuthority: 'More Privileges',
allOrders: 'All Orders',
@@ -11,6 +11,7 @@ export const lang = {
readyDelivery: 'Pending Shipment',
waitDelivery: 'Pending Receipt',
refunding: 'Refunding',
getDynacode: 'Get Verification Code',
// 会员中心入口
sign: 'Check-in',
personInfo: 'Personal Information',

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'Merchant Home',
}

5
lang/en-us/merch/edit.js Normal file
View File

@@ -0,0 +1,5 @@
export const lang = {
//title为每个页面的标题
title: 'Edit Product',
addTitle: 'Add Product',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'Merchants',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'Search Results',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'Material Search',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'Structure Selection',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: 'Mini Program Shipping',
}

View File

@@ -1 +1,4 @@
export const lang = {}
export const lang = {
//title为每个页面的标题
title: '协议',
}

View File

@@ -34,7 +34,10 @@ export const lang = {
copySuccess: '已复制',
copySelected: '已复制选中内容',
copyFail: '复制失败',
copyFailWithReason: '复制失败:{reason}',
openingFile: '打开文件中...',
openingFileName: '正在打开文件:{name}',
openingFileTitle: '正在打开:{title}',
fileOpenFail: '文件打开失败',
loadHistoryFail: '加载历史失败',
about: '0B',
@@ -64,6 +67,7 @@ export const lang = {
improveService: '我们会改进服务',
openLink: '打开链接',
confirmOpenLink: '确定要打开链接:',
confirmOpenLinkText: '确定要打开链接:{url}?',
operationFailed: '操作失败,请重试',
prompt: '提示',
serviceTypeNotSupported: '当前客服类型不支持此页面',

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '案例展示',
}

View File

@@ -29,7 +29,7 @@ export const lang = {
},
login: '登录/注册',
loginTpis: '点击登录 享受更多精彩信息',
loginTips: '点击登录 享受更多精彩信息',
toLogin: '去登录',
toGoodsCategoryPage: '去逛逛',

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '分销商',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '排行榜',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '文件预览',
}

4
lang/zh-cn/files/list.js Normal file
View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '查看文件',
}

View File

@@ -1,6 +1,6 @@
export const lang = {
//title为每个页面的标题
title: '',
title: '商品详情',
select: '选择',
details:'商品详情',
document:'文档详情',

View File

@@ -1,6 +1,7 @@
export const lang = {
//title为每个页面的标题
title: '编辑账户',
addTitle: '新增账户',
name: '姓名',
namePlaceholder: '请输入真实姓名',
mobilePhone: '手机号码',

View File

@@ -1,6 +1,7 @@
export const lang = {
//title为每个页面的标题
title: '编辑收货地址',
addTitle: '新增收货地址',
consignee: '姓名',
consigneePlaceholder: '收货人姓名',
mobile: '手机',

View File

@@ -2,7 +2,7 @@ export const lang = {
//title为每个页面的标题
title: '会员中心',
login: '登录/注册',
loginTpis: '点击登录 享受更多精彩信息',
loginTips: '点击登录 享受更多精彩信息',
memberLevel: '会员等级',
moreAuthority: '更多权限',
allOrders: '全部订单',

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '商户首页',
}

5
lang/zh-cn/merch/edit.js Normal file
View File

@@ -0,0 +1,5 @@
export const lang = {
//title为每个页面的标题
title: '编辑商品',
addTitle: '添加商品',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '入驻商户',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '搜索结果',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '介质材料搜索',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '结构选型',
}

View File

@@ -0,0 +1,4 @@
export const lang = {
//title为每个页面的标题
title: '小程序发货',
}

View File

@@ -1,6 +1,7 @@
{
"scripts": {
"mp-weixin": "node scripts/mp-weixin.patch.js"
"mp-weixin": "node scripts/mp-weixin.patch.js",
"i18n:check": "node scripts/i18n-check.js"
},
"devDependencies": {
"dart-sass": "^1.25.0",

View File

@@ -74,9 +74,7 @@ export default {
this.langstatus = res.status
if(this.langstatus == 1){
// this.$langConfig.refresh();
uni.setNavigationBarTitle({
title:uni.getStorageSync('lang') == 'en-us'?'Goods List':'商品列表'
})
this.$langConfig.title(this.$lang('title'));
}
}
});

View File

@@ -122,16 +122,12 @@ export default {
// this.getCategoryTreeFn();
if (this.goodsData.id) {
this.isAWait = true;
uni.setNavigationBarTitle({
title: '编辑商品'
});
this.$langConfig.title(this.$lang('title'));
await this.editGetGoodsInfo();
} else {
this.isAWait = false;
uni.setNavigationBarTitle({
title: '添加商品'
});
this.$langConfig.title(this.$lang('addTitle'));
}
let date = new Date();

View File

@@ -1712,7 +1712,7 @@ _handleStreamError(msg) {
fail: (err) => {
console.error('[AI Chat] 复制失败, 详细错误:', JSON.stringify(err))
console.error('[AI Chat] 复制失败 errMsg:', err.errMsg || err.message || '无错误消息')
uni.showToast({ title: this.$lang('copyFail') + ': ' + (err.errMsg || this.$lang('serviceUnavailable')), icon: 'none', duration: 3000 })
uni.showToast({ title: this.$lang('copyFailWithReason', { reason: err.errMsg || this.$lang('serviceUnavailable') }), icon: 'none', duration: 3000 })
}
})
// #endif

View File

@@ -461,7 +461,7 @@ export default {
},
onFilePreview(message) {
uni.showToast({ title: this.$lang('openingFile') + ' ' + message.fileName, icon: 'none' });
uni.showToast({ title: this.$lang('openingFileName', { name: message.fileName }), icon: 'none' });
},
onAudioPlay(message) {
@@ -491,7 +491,7 @@ export default {
onLinkOpen(message) {
uni.showModal({
title: this.$lang('openLink'),
content: this.$lang('confirmOpenLink') + message.url + '?',
content: this.$lang('confirmOpenLinkText', { url: message.url }),
success: (res) => {
if (res.confirm) {
// #ifdef H5
@@ -506,7 +506,7 @@ export default {
},
onProductView(message) {
uni.showToast({ title: this.$lang('openingFile') + ': ' + message.title, icon: 'none' });
uni.showToast({ title: this.$lang('openingFileTitle', { title: message.title }), icon: 'none' });
},
onInputChange(value) {

View File

@@ -63,13 +63,9 @@ export default {
this.getTransferType();
}
if (this.formData.id) {
uni.setNavigationBarTitle({
title: '编辑账户'
});
this.$langConfig.title(this.$lang('title'));
} else {
uni.setNavigationBarTitle({
title: '新增账户'
});
this.$langConfig.title(this.$lang('addTitle'));
}
},
methods: {

View File

@@ -132,17 +132,13 @@
onBackPress() {
uni.setStorageSync('addressInfo', '');
},
onShow() {
if (this.formData.id) {
uni.setNavigationBarTitle({
title: '编辑收货地址'
});
} else {
uni.setNavigationBarTitle({
title: '新增收货地址'
});
}
},
onShow() {
if (this.formData.id) {
this.$langConfig.title(this.$lang('title'));
} else {
this.$langConfig.title(this.$lang('addTitle'));
}
},
onReady() {
this.$refs.loadingCover.hide();
},

View File

@@ -154,7 +154,7 @@ export default {
}
},
setNavbarTitle() {
let title = '个人资料';
let title = this.$lang('title');
switch (this.indent) {
case 'name':
title = this.$lang('modifyNickname');
@@ -175,9 +175,7 @@ export default {
title = this.$lang('mobile');
break;
}
uni.setNavigationBarTitle({
title: title
});
this.$langConfig.title(title);
},
// 初始化用户信息
getInfo() {

280
scripts/i18n-check.js Normal file
View File

@@ -0,0 +1,280 @@
#!/usr/bin/env node
/**
* i18n 国际化检查脚本
*
* 功能:
* 1. 检查 zh-cn / en-us 语言包文件结构是否一致
* 2. 检查同文件下中英文 key 是否一致
* 3. 统计 key 总数和差异数
*
* 用法:
* node scripts/i18n-check.js
*
* 退出码:
* 0 - 检查通过(无差异 或 只有警告级差异)
* 1 - 存在错误级差异
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const LANG_DIR = path.join(ROOT, 'lang');
const LOCALES = ['zh-cn', 'en-us'];
// 递归获取目录下所有 .js 文件(相对路径)
function getAllLangFiles(dir) {
const result = [];
function walk(current, rel) {
const entries = fs.readdirSync(current, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
const relPath = rel ? path.join(rel, entry.name) : entry.name;
if (entry.isDirectory()) {
walk(fullPath, relPath);
} else if (entry.name.endsWith('.js')) {
result.push(relPath.replace(/\\/g, '/'));
}
}
}
walk(dir, '');
return result.sort();
}
// 解析语言包 js 文件,提取所有 key(支持嵌套,用 . 连接)
// 注意: 用正则粗略提取,不做完整 AST 解析,对本项目语言包格式足够
function extractKeys(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const keys = new Set();
// 简单的对象 key 提取:匹配 key: 'value' 或 key: { 形式
// 用栈跟踪嵌套层级,记录路径
const stack = [];
const lines = content.split('\n');
let braceDepth = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 跳过注释
const trimmed = line.trim();
if (trimmed.startsWith('//')) continue;
if (trimmed.startsWith('/*')) continue;
if (trimmed.startsWith('*')) continue;
// 匹配 key: 形式
// 匹配: identifier: 或 'key': 或 "key":
const keyMatch = line.match(/^\s*['"]?(\w[\w]*)['"]?\s*:/);
if (keyMatch) {
const key = keyMatch[1];
// 跳过 export const lang = { 这种
if (stack.length === 0 && key === 'lang') {
// 往下找第一个 {
if (line.includes('{')) braceDepth++;
if (braceDepth > 0) stack.push('');
continue;
}
// 计算当前路径
const pathParts = stack.filter(p => p);
const fullKey = [...pathParts, key].join('.');
// 判断这是字符串值还是对象值
const afterColon = line.substring(line.indexOf(':') + 1);
const isObject = afterColon.includes('{');
const isString = afterColon.match(/['"`]/);
if (isObject && !isString) {
// 对象 key,入栈
stack.push(key);
keys.add(fullKey + '.__object__');
} else {
// 叶子 key
keys.add(fullKey);
}
}
// 处理闭合 },出栈
const closeBraces = (line.match(/\}/g) || []).length;
const openBraces = (line.match(/\{/g) || []).length;
const netClose = closeBraces - openBraces;
if (line.includes('{') && keyMatch && !line.includes('}')) {
// 这行是 key: {,已经在上面处理了入栈
}
// 简单处理:遇到 } 且栈非空就出栈
if (netClose > 0 && stack.length > 0) {
for (let j = 0; j < netClose && stack.length > 0; j++) {
stack.pop();
}
}
}
// 去掉 __object__ 标记
const result = new Set();
for (const k of keys) {
if (!k.endsWith('.__object__')) {
result.add(k);
}
}
return result;
}
// 更可靠的 key 提取:用 Node.js require + 递归遍历对象
function extractKeysByRequire(filePath) {
try {
// 清除 require 缓存
delete require.cache[require.resolve(filePath)];
const mod = require(filePath);
const langObj = mod.lang || mod.default?.lang || mod.default;
if (!langObj || typeof langObj !== 'object') return new Set();
const keys = new Set();
function walk(obj, prefix) {
for (const key of Object.keys(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const val = obj[key];
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
walk(val, fullKey);
} else if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
keys.add(fullKey);
}
}
}
walk(langObj, '');
return keys;
} catch (e) {
console.error(` 解析失败: ${filePath} - ${e.message}`);
return new Set();
}
}
function main() {
console.log('========================================');
console.log(' i18n 国际化检查');
console.log('========================================\n');
const localeDirs = {};
const localeFiles = {};
for (const locale of LOCALES) {
const dir = path.join(LANG_DIR, locale);
if (!fs.existsSync(dir)) {
console.error(`错误: 找不到语言目录 ${dir}`);
process.exit(1);
}
localeDirs[locale] = dir;
localeFiles[locale] = new Set(getAllLangFiles(dir));
}
// ---- 检查 1: 文件结构一致性 ----
console.log('【1/2】文件结构检查\n');
const zhFiles = localeFiles['zh-cn'];
const enFiles = localeFiles['en-us'];
const onlyZh = [...zhFiles].filter(f => !enFiles.has(f));
const onlyEn = [...enFiles].filter(f => !zhFiles.has(f));
const commonFiles = [...zhFiles].filter(f => enFiles.has(f));
console.log(` 中文语言包文件数: ${zhFiles.size}`);
console.log(` 英文语言包文件数: ${enFiles.size}`);
console.log(` 共同文件数: ${commonFiles.length}`);
if (onlyZh.length > 0) {
console.log(`\n ⚠️ 只有中文没有英文的文件 (${onlyZh.length}):`);
for (const f of onlyZh) {
console.log(` - ${f}`);
}
}
if (onlyEn.length > 0) {
console.log(`\n ⚠️ 只有英文没有中文的文件 (${onlyEn.length}):`);
for (const f of onlyEn) {
console.log(` - ${f}`);
}
}
if (onlyZh.length === 0 && onlyEn.length === 0) {
console.log(' ✅ 文件结构完全一致');
}
// ---- 检查 2: key 一致性 ----
console.log('\n【2/2】Key 一致性检查\n');
let totalZhKeys = 0;
let totalEnKeys = 0;
let filesWithDiff = 0;
const diffDetails = [];
for (const file of commonFiles) {
const zhPath = path.join(localeDirs['zh-cn'], file);
const enPath = path.join(localeDirs['en-us'], file);
const zhKeys = extractKeysByRequire(zhPath);
const enKeys = extractKeysByRequire(enPath);
totalZhKeys += zhKeys.size;
totalEnKeys += enKeys.size;
const missingInEn = [...zhKeys].filter(k => !enKeys.has(k));
const missingInZh = [...enKeys].filter(k => !zhKeys.has(k));
if (missingInEn.length > 0 || missingInZh.length > 0) {
filesWithDiff++;
diffDetails.push({
file,
missingInEn,
missingInZh,
zhCount: zhKeys.size,
enCount: enKeys.size,
});
}
}
console.log(` 中文 key 总数: ${totalZhKeys}`);
console.log(` 英文 key 总数: ${totalEnKeys}`);
console.log(` 有差异的文件数: ${filesWithDiff} / ${commonFiles.length}`);
if (filesWithDiff > 0) {
console.log(`\n ⚠️ Key 差异详情:`);
for (const diff of diffDetails) {
console.log(`\n 📄 ${diff.file}`);
console.log(` 中文: ${diff.zhCount} 个, 英文: ${diff.enCount} 个`);
if (diff.missingInEn.length > 0) {
console.log(` 英文缺失 (${diff.missingInEn.length}):`);
for (const k of diff.missingInEn.slice(0, 10)) {
console.log(` - ${k}`);
}
if (diff.missingInEn.length > 10) {
console.log(` ... 还有 ${diff.missingInEn.length - 10} 个`);
}
}
if (diff.missingInZh.length > 0) {
console.log(` 中文缺失 (${diff.missingInZh.length}):`);
for (const k of diff.missingInZh.slice(0, 10)) {
console.log(` - ${k}`);
}
if (diff.missingInZh.length > 10) {
console.log(` ... 还有 ${diff.missingInZh.length - 10} 个`);
}
}
}
} else {
console.log(' ✅ 所有共同文件的 key 完全一致');
}
// ---- 总结 ----
console.log('\n========================================');
console.log(' 检查完成');
console.log('========================================');
const hasError = filesWithDiff > 0 || onlyZh.length > 0;
if (hasError) {
console.log('\n ❌ 存在差异,请修复后再提交');
process.exit(1);
} else {
console.log('\n ✅ 全部通过');
process.exit(0);
}
}
main();