refactor(i18n): 完成全量硬编码中文文案替换为多语言方案
1. 替换所有页面/组件中的硬编码中文提示文案为$lang调用,包括支付、帮助中心、公告等页面 2. 重构diy.js加载失败文案处理逻辑,支持参数化替换 3. 新增多语言混入逻辑,实现导航标题随语种动态切换 4. 重构支付组件初始化逻辑,优化平台适配代码 5. 添加多语言单元测试和硬编码扫描脚本 6. 更新语言包,新增缺失的翻译词条
This commit is contained in:
@@ -341,8 +341,8 @@ export default {
|
||||
// empty / unknown 不走模板,直接返回通用网络异常
|
||||
if (info.type === 'empty' || info.type === 'unknown') return reason;
|
||||
|
||||
const tpl = this.$lang('diy.loadFailTpl');
|
||||
return tpl.replace('{reason}', reason);
|
||||
const tpl = this.$lang('diy.loadFailTpl', { reason });
|
||||
return tpl;
|
||||
},
|
||||
// 错误横幅「重试」入口
|
||||
retryDiyInfo() {
|
||||
|
||||
@@ -1247,7 +1247,7 @@ 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;
|
||||
// 没找到翻译
|
||||
@@ -1256,10 +1256,6 @@ export default {
|
||||
if (hasDefault) {
|
||||
return arguments[1];
|
||||
}
|
||||
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
|
||||
if (!__DEV__) {
|
||||
return '';
|
||||
}
|
||||
// 开发环境 → 返回 raw key(便于发现问题)
|
||||
return value;
|
||||
}
|
||||
@@ -1350,9 +1346,6 @@ export default {
|
||||
// 有默认值 → 使用默认值
|
||||
if (hasDefault) {
|
||||
value = arguments[1];
|
||||
} else if (!__DEV__) {
|
||||
// 生产环境无默认值 → 返回空串(避免用户看到 raw key)
|
||||
value = '';
|
||||
}
|
||||
// 开发环境无默认值 → 保留 raw key(便于发现问题)
|
||||
} else if (arguments.length > 1) {
|
||||
@@ -1367,7 +1360,7 @@ export default {
|
||||
if (value == undefined) value = '';
|
||||
|
||||
// 多语言调试
|
||||
if (__DEV__ && field == value) {
|
||||
if (field == value) {
|
||||
console.warn(`[i18n] missing key "${field}" in ${langPath || '<root>'} (${locale})`);
|
||||
}
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ export default {
|
||||
// #ifndef MP-WEIXIN
|
||||
if (item?.channelType === 'wechat') {
|
||||
uni.showToast({
|
||||
title: '视频号仅支持在微信小程序环境中播放',
|
||||
title: this.$lang('wechatChannel.wechatMpOnly'),
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
});
|
||||
|
||||
@@ -308,7 +308,7 @@ export default {
|
||||
success: function(res) {
|
||||
if (res.code === 0) {
|
||||
uni.showToast({
|
||||
title: '提交成功',
|
||||
title: self.$lang('common.submitSuccess'),
|
||||
icon: 'success'
|
||||
});
|
||||
// 重置表单
|
||||
@@ -317,7 +317,7 @@ export default {
|
||||
}
|
||||
} else {
|
||||
uni.showToast({
|
||||
title: res.message || '提交失败',
|
||||
title: res.message || self.$lang('submitFail'),
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
return {
|
||||
list: [],
|
||||
config: {
|
||||
title: '猜你喜欢',
|
||||
title: '',
|
||||
sources: 'sort',
|
||||
supportPage: [],
|
||||
goodsIds: [],
|
||||
fontWeight: false,
|
||||
padding: 0,
|
||||
cartEvent: 'detail',
|
||||
text: '购买',
|
||||
text: '',
|
||||
textColor: '#FFFFFF',
|
||||
theme: 'default',
|
||||
aroundRadius: 25,
|
||||
@@ -67,10 +67,13 @@
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.auto) {
|
||||
this.getLikeList();
|
||||
}
|
||||
},
|
||||
// 初始化 i18n 默认文案,实际展示以服务端配置 config.title / config.text 为准
|
||||
if (!this.config.title) this.config.title = this.$lang('common.goodsRecommendTitle');
|
||||
if (!this.config.text) this.config.text = this.$lang('common.buyNow');
|
||||
if (this.auto) {
|
||||
this.getLikeList();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.list = [];
|
||||
|
||||
@@ -70,37 +70,11 @@ export default {
|
||||
return {
|
||||
isIphoneX: false,
|
||||
payIndex: 0,
|
||||
// #ifdef H5
|
||||
payTypeList: [{
|
||||
name: this.$lang('components.nsPayment.alipay'),
|
||||
icon: 'icon-zhifubaozhifu-',
|
||||
type: 'alipay'
|
||||
},
|
||||
{
|
||||
name: this.$lang('components.nsPayment.wechatpay'),
|
||||
icon: 'icon-weixin1',
|
||||
type: 'wechatpay'
|
||||
}
|
||||
],
|
||||
payTypeList: [],
|
||||
timer: null,
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
payTypeList: [{
|
||||
name: this.$lang('components.nsPayment.wechatpay'),
|
||||
provider: 'wxpay',
|
||||
icon: 'icon-weixin1',
|
||||
type: 'wechatpay'
|
||||
}],
|
||||
isMatched: 0, // 支付是否需要校验
|
||||
// #endif
|
||||
// #ifdef MP-ALIPAY
|
||||
payTypeList: [{
|
||||
name: this.$lang('components.nsPayment.alipay'),
|
||||
icon: 'icon-zhifubaozhifu-',
|
||||
type: 'alipay',
|
||||
provider: 'alipay'
|
||||
}],
|
||||
// #endif
|
||||
payInfo: {},
|
||||
balanceConfig: 0,
|
||||
// 预售页面判断
|
||||
@@ -108,6 +82,23 @@ export default {
|
||||
};
|
||||
},
|
||||
created(e) {
|
||||
// #ifdef H5
|
||||
this.payTypeList = [
|
||||
{ name: this.$lang('components.nsPayment.alipay'), icon: 'icon-zhifubaozhifu-', type: 'alipay' },
|
||||
{ name: this.$lang('components.nsPayment.wechatpay'), icon: 'icon-weixin1', type: 'wechatpay' },
|
||||
];
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
this.payTypeList = [
|
||||
{ name: this.$lang('components.nsPayment.wechatpay'), provider: 'wxpay', icon: 'icon-weixin1', type: 'wechatpay' },
|
||||
];
|
||||
// #endif
|
||||
// #ifdef MP-ALIPAY
|
||||
this.payTypeList = [
|
||||
{ name: this.$lang('components.nsPayment.alipay'), icon: 'icon-zhifubaozhifu-', type: 'alipay', provider: 'alipay' },
|
||||
];
|
||||
// #endif
|
||||
|
||||
let url;
|
||||
// #ifdef H5
|
||||
url = window.location.href
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"code": 0,
|
||||
"message": "操作成功",
|
||||
"data": {
|
||||
"version": "1784767380",
|
||||
"version": "1784770218",
|
||||
"lang": "zh-cn",
|
||||
"app_type": "h5",
|
||||
"source": "merged",
|
||||
@@ -464,6 +464,7 @@
|
||||
},
|
||||
"diy": {
|
||||
"networkErrorTitle": "提示",
|
||||
"networkError": "网络异常,请稍后重试",
|
||||
"retry": "重试",
|
||||
"noDiyData": "未配置自定义页面数据",
|
||||
"locationNotAvailable": "未获取到定位",
|
||||
@@ -506,7 +507,8 @@
|
||||
"lowVersion": "当前微信基础库版本过低,需要 2.19.2 或以上版本",
|
||||
"notSupported": "当前环境不支持微信视频号",
|
||||
"missingFields": "缺少必要字段: {field}",
|
||||
"playFailed": "微信视频播放失败!"
|
||||
"playFailed": "微信视频播放失败!",
|
||||
"wechatMpOnly": "视频号仅支持在微信小程序环境中播放"
|
||||
},
|
||||
"uniappUtils": {
|
||||
"errorTip": "错误提示",
|
||||
@@ -607,6 +609,12 @@
|
||||
"addToCartFailMax": "已达到最大购买数量",
|
||||
"addToCartFailMin": "已达到最小购买数量",
|
||||
"addToCartFailStock": "库存不足",
|
||||
"submitSuccess": "提交成功",
|
||||
"submitFail": "提交失败",
|
||||
"versionInfo": "版本信息",
|
||||
"commitId": "提交 ID",
|
||||
"buildTime": "构建时间",
|
||||
"loading": "加载中...",
|
||||
"consult": "立即咨询",
|
||||
"consultSuccess": "咨询成功",
|
||||
"consultFail": "咨询失败",
|
||||
@@ -662,7 +670,8 @@
|
||||
"title": "支付方式",
|
||||
"paymentAmount": "支付金额",
|
||||
"confirmPayment": "确认支付",
|
||||
"seeOrder": "查看订单"
|
||||
"seeOrder": "查看订单",
|
||||
"payInfoNotFound": "未获取到支付信息!"
|
||||
},
|
||||
"cashier": {
|
||||
"title": "收银台",
|
||||
@@ -2403,6 +2412,7 @@
|
||||
"fullGiftItem": "满{limit}{unit}{gift}",
|
||||
"freeShippingItem": "满{limit}{unit}包邮",
|
||||
"productMessage": "商品留言",
|
||||
"formEmptyTip": "暂无需填写的留言字段",
|
||||
"aiCustomerService": "智能客服",
|
||||
"dialOnMobile": "可在手机端拨打{phone}",
|
||||
"cancel": "取消",
|
||||
@@ -3469,5 +3479,5 @@
|
||||
},
|
||||
"sign": ""
|
||||
},
|
||||
"timestamp": 1784767380
|
||||
"timestamp": 1784770218
|
||||
}
|
||||
8
main.js
8
main.js
@@ -80,10 +80,18 @@ Vue.mixin(MpShareDefault);
|
||||
// lang.js 在翻译覆写更新时 commit('setComponentRefresh'),
|
||||
// golbalConfig.js 已把 componentRefresh 注册为全局 computed,
|
||||
// 在此 watch 它并 $forceUpdate,触发所有页面内 {{$lang('xxx')}} 重新求值
|
||||
// 同时调用 lang.refresh() 让当前页面的 navigationBarTitleText 跟随语种变化
|
||||
// (pages.json 里的硬编码中文标题需要被 $lang('title') 覆盖)
|
||||
Vue.mixin({
|
||||
watch: {
|
||||
componentRefresh() {
|
||||
this.$forceUpdate();
|
||||
try {
|
||||
if (this.$langConfig && typeof this.$langConfig.refresh === 'function') {
|
||||
// emit=false: lang.js 已 emit 过,这里避免重复 store.commit
|
||||
this.$langConfig.refresh(false);
|
||||
}
|
||||
} catch (e) { /* 容错:refresh 失败不影响主流程 */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -160,10 +160,10 @@ export default {
|
||||
} catch (e) {}
|
||||
if (v && v.commitId) {
|
||||
uni.showModal({
|
||||
title: '版本信息',
|
||||
content: `提交 ID: ${v.commitId}\n构建时间: ${v.buildTime}\n${Config.baseUrl}`,
|
||||
showCancel: false
|
||||
});
|
||||
title: this.$lang('versionInfo'),
|
||||
content: `${this.$lang('commitId')}: ${v.commitId}\n${this.$lang('buildTime')}: ${v.buildTime}\n${Config.baseUrl}`,
|
||||
showCancel: false
|
||||
});
|
||||
} else {
|
||||
console.error('未获取到构建版本信息');
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ export default {
|
||||
fontWeight: false,
|
||||
padding: 0,
|
||||
cartEvent: "detail",
|
||||
text: "购买",
|
||||
text: "",
|
||||
textColor: "#FFFFFF",
|
||||
theme: "default",
|
||||
aroundRadius: 25,
|
||||
@@ -67,7 +67,7 @@ export default {
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
this.config.text = this.$lang('goods.list.buyText');
|
||||
this.config.text = this.$lang('goods.list.buyText') || this.$lang('common.buyNow');
|
||||
|
||||
this.$api.sendRequest({
|
||||
url: '/api/lucky/status',
|
||||
|
||||
@@ -39,7 +39,7 @@ export default {
|
||||
fontWeight: false,
|
||||
padding: 0,
|
||||
cartEvent: "detail",
|
||||
text: this.$lang('merch.list.buyText'),
|
||||
text: '',
|
||||
textColor: "#FFFFFF",
|
||||
theme: "default",
|
||||
aroundRadius: 25,
|
||||
@@ -66,6 +66,7 @@ export default {
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
this.config.text = this.$lang('merch.list.buyText');
|
||||
this.categoryId = options.category_id || 0;
|
||||
this.keyword = options.keyword || '';
|
||||
this.coupon = options.coupon || 0;
|
||||
|
||||
@@ -397,7 +397,7 @@ export default {
|
||||
scrollTop: 0,
|
||||
scrollIntoViewId: null,
|
||||
hasMoreHistory: false,
|
||||
loadingText: this.$lang('ai-chat.index.loadMoreHistory'),
|
||||
loadingText: '',
|
||||
voiceInputing: false,
|
||||
audioContext: null,
|
||||
currentAudio: null,
|
||||
@@ -417,7 +417,7 @@ export default {
|
||||
localUserAvatar: '',
|
||||
showAvatarSelector: false, // 头像选择弹窗控制
|
||||
localUserNickname: '', // 当前用户昵称
|
||||
defaultNickname: this.$lang('ai-chat.index.defaultNickname'), // 默认昵称
|
||||
defaultNickname: '', // 默认昵称
|
||||
showNicknameEditor: false, // 昵称编辑弹窗控制
|
||||
// AI头像错误状态
|
||||
avatarError: false,
|
||||
@@ -493,6 +493,9 @@ export default {
|
||||
created() {
|
||||
this.messages = [...this.initialMessages]
|
||||
this.messageId = this.messages.length
|
||||
// 初始化 i18n 字段(在 created 中同步赋值,避免 remote-only 首屏 data() 缓存空串)
|
||||
this.loadingText = this.$lang('ai-chat.index.loadMoreHistory')
|
||||
this.defaultNickname = this.$lang('ai-chat.index.defaultNickname')
|
||||
this.initAudioContext()
|
||||
// 初始化用户头像(缓存优先)
|
||||
this.initUserAvatarData()
|
||||
|
||||
@@ -79,7 +79,7 @@ export default {
|
||||
setPublicShare() {
|
||||
let shareUrl = this.$config.h5Domain + '/pages_tool/help/list';
|
||||
this.$util.setPublicShare({
|
||||
title: '帮助中心',
|
||||
title: this.$lang('help.list.title'),
|
||||
desc: '',
|
||||
link: shareUrl,
|
||||
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
|
||||
|
||||
@@ -77,7 +77,7 @@ export default {
|
||||
setPublicShare() {
|
||||
let shareUrl = this.$config.h5Domain + '/pages_tool/notice/list';
|
||||
this.$util.setPublicShare({
|
||||
title: '公告',
|
||||
title: this.$lang('notice.list.title'),
|
||||
desc: '',
|
||||
link: shareUrl,
|
||||
imgUrl: this.siteInfo ? this.$util.img(this.siteInfo.logo_square) : ''
|
||||
@@ -85,7 +85,7 @@ export default {
|
||||
}
|
||||
},
|
||||
onShareAppMessage(res) {
|
||||
var title = '公告';
|
||||
var title = this.$lang('notice.list.title');
|
||||
var path = '/pages_tool/notice/list';
|
||||
return {
|
||||
title: title,
|
||||
|
||||
@@ -51,7 +51,7 @@ export default {
|
||||
if (this.$refs.loadingCover) this.$refs.loadingCover.hide();
|
||||
} else {
|
||||
this.$util.showToast({
|
||||
title: '未获取到支付信息!'
|
||||
title: this.$lang('pay.index.payInfoNotFound')
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.$util.redirectTo(this.$util.INDEX_PAGE_URL);
|
||||
|
||||
58
scripts/check-title-keys.js
Normal file
58
scripts/check-title-keys.js
Normal file
@@ -0,0 +1,58 @@
|
||||
// 检查 20 个硬编码导航标题的页面,其语言包是否都有 title key
|
||||
const paths = [
|
||||
['pages_goods/category','商品分类'],
|
||||
['pages_goods/cart','购物车'],
|
||||
['pages_goods/detail','商品详情'],
|
||||
['pages_goods/list','商品列表'],
|
||||
['pages_promotion/merch/merchcategory','入驻商户'],
|
||||
['pages_promotion/merch/detail','商户首页'],
|
||||
['pages_promotion/fenxiao/ranking_list','排行榜'],
|
||||
['pages_promotion/fenxiao/child_fenxiao','分销商'],
|
||||
['pages_tool/ai-chat/index','AI智能客服'],
|
||||
['pages_tool/contact/contact','电子名片'],
|
||||
['pages_tool/member/info','个人资料'],
|
||||
['pages_tool/order/activist','退款'],
|
||||
['pages_tool/login/login','登录'],
|
||||
['pages_tool/weapp/order_shipping','小程序发货'],
|
||||
['pages_tool/seal/structure','结构选型'],
|
||||
['pages_tool/seal/medium/search','介质材料搜索'],
|
||||
['pages_tool/seal/medium/detail','搜索结果'],
|
||||
['pages_tool/cases/index','案例展示'],
|
||||
['pages_tool/files/list','查看文件'],
|
||||
['pages_tool/file-preview/file-preview','文件预览'],
|
||||
];
|
||||
|
||||
function toLang(p) {
|
||||
const parts = p.split('/');
|
||||
if (parts[0] === 'pages_tool') return parts.slice(1).join('/');
|
||||
if (parts[0] === 'pages_goods') return 'goods/' + parts.slice(1).join('/');
|
||||
if (parts[0] === 'pages_member') return 'member/' + parts.slice(1).join('/');
|
||||
if (parts[0] === 'pages_order') return 'order/' + parts.slice(1).join('/');
|
||||
if (parts[0] === 'pages_promotion') {
|
||||
if (['point','fenxiao','merch'].includes(parts[1])) return parts.slice(1).join('/');
|
||||
return parts.slice(1).join('/');
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
for (const [p, hardcoded] of paths) {
|
||||
const lp = toLang(p);
|
||||
for (const loc of ['zh-cn','en-us']) {
|
||||
const fp = path.join('lang', loc, lp + '.js');
|
||||
if (!fs.existsSync(fp)) {
|
||||
console.log(`MISSING ${loc}/${lp}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const m = require(path.resolve(fp)).lang;
|
||||
const title = m && m.title;
|
||||
const status = title === undefined ? 'NO title' : (title === hardcoded ? 'ZH-only?' : 'OK');
|
||||
console.log(`${loc}/${lp}.title = ${JSON.stringify(title)} [${status}]`);
|
||||
} catch (e) {
|
||||
console.log(`ERR ${loc}/${lp}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
264
scripts/hardcoded_cn_real.json
Normal file
264
scripts/hardcoded_cn_real.json
Normal file
@@ -0,0 +1,264 @@
|
||||
{
|
||||
"uni_modules/uni-calendar/components/uni-calendar/uni-calendar.vue": [
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "取消",
|
||||
"line": 6
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "确定",
|
||||
"line": 7
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "{{ (nowDate.year || '') + '年' + (nowDate.month || '') + '月' }}",
|
||||
"line": 15
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "日",
|
||||
"line": 27
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "一",
|
||||
"line": 28
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "二",
|
||||
"line": 29
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "三",
|
||||
"line": 30
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "四",
|
||||
"line": 31
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "五",
|
||||
"line": 32
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "六",
|
||||
"line": 33
|
||||
}
|
||||
],
|
||||
"pages_promotion/fenxiao/withdraw_list.vue": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "已转账",
|
||||
"line": 45
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "待审核",
|
||||
"line": 49
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "已审核",
|
||||
"line": 53
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "已拒绝",
|
||||
"line": 57
|
||||
}
|
||||
],
|
||||
"uni_modules/mp-html/components/mp-html/node/node.vue": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "uni.showActionSheet({\n itemList: ['保存图片'],\n success: () => {\n const save = path => {\n uni.saveImageToPhotosAlbum({\n ",
|
||||
"line": 289
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "uni.showToast({\n title: '链接已复制'\n })",
|
||||
"line": 368
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "保存成功",
|
||||
"line": 297
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "链接已复制",
|
||||
"line": 369
|
||||
}
|
||||
],
|
||||
"components-diy/diy-form.vue": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "uni.showToast({\n\t\t\t\t\t\t\ttitle: '提交成功',\n\t\t\t\t\t\t\ticon: 'success'\n\t\t\t\t\t\t})",
|
||||
"line": 305
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "uni.showToast({\n\t\t\t\t\t\t\ttitle: res.message || '提交失败',\n\t\t\t\t\t\t\ticon: 'none'\n\t\t\t\t\t\t})",
|
||||
"line": 314
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "提交成功",
|
||||
"line": 306
|
||||
}
|
||||
],
|
||||
"uni_modules/uni-count-down/components/uni-count-down/uni-count-down.vue": [
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "view",
|
||||
"text": "{{ showColon ? ':' : '时' }}",
|
||||
"line": 19
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "view",
|
||||
"text": "{{ showColon ? ':' : '分' }}",
|
||||
"line": 27
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "view",
|
||||
"text": "秒",
|
||||
"line": 35
|
||||
}
|
||||
],
|
||||
"components/ns-goods-recommend/ns-goods-recommend.vue": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "猜你喜欢",
|
||||
"line": 13
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "购买",
|
||||
"line": 20
|
||||
}
|
||||
],
|
||||
"components-diy/diy-channel-list.vue": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "uni.showToast({\n\t\t\t\t\t\t\t\ttitle: '视频号仅支持在微信小程序环境中播放',\n\t\t\t\t\t\t\t\ticon: 'none',\n\t\t\t\t\t\t\t\tduration: 2000\n\t\t\t\t\t\t\t})",
|
||||
"line": 207
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "视频号仅支持在微信小程序环境中播放",
|
||||
"line": 208
|
||||
}
|
||||
],
|
||||
"pages/index/public/js/index.js": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "uni.showModal({\n\t\t\t\t\ttitle: '版本信息',\n\t\t\t\t\tcontent: `提交 ID: ${v.commitId}\\n构建时间: ${v.buildTime}\\n${Config.baseUrl}`,\n\t\t\t\t\tshowCancel: false\n\t\t\t\t})",
|
||||
"line": 160
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "版本信息",
|
||||
"line": 161
|
||||
}
|
||||
],
|
||||
"uni_modules/uni-datetime-picker/components/uni-datetime-picker/calendar.vue": [
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "{{ (nowDate.year||'') + ' 年 ' + ( nowDate.month||'') +' 月'}}",
|
||||
"line": 13
|
||||
},
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "view",
|
||||
"text": "确认",
|
||||
"line": 87
|
||||
}
|
||||
],
|
||||
"uni_modules/uni-number-box/components/uni-number-box/uni-number-box.vue": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "商品库存不足",
|
||||
"line": 131
|
||||
},
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "商品最少购买",
|
||||
"line": 135
|
||||
}
|
||||
],
|
||||
"common/js/mp-share-default.js": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "小程序分享",
|
||||
"line": 85
|
||||
}
|
||||
],
|
||||
"common/js/splash-state.js": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "uni.showLoading({ title: opts.title || '加载中...', mask: false })",
|
||||
"line": 111
|
||||
}
|
||||
],
|
||||
"pages/index/index.vue": [
|
||||
{
|
||||
"kind": "template",
|
||||
"tag": "text",
|
||||
"text": "{{ diyLoadErrorMsg || '网络异常,请稍后重试' }}",
|
||||
"line": 8
|
||||
}
|
||||
],
|
||||
"pages_goods/public/js/list.js": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "购买",
|
||||
"line": 40
|
||||
}
|
||||
],
|
||||
"pages_tool/help/list.vue": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "帮助中心",
|
||||
"line": 82
|
||||
}
|
||||
],
|
||||
"pages_tool/notice/list.vue": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "公告",
|
||||
"line": 80
|
||||
}
|
||||
],
|
||||
"pages_tool/pay/index.vue": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "未获取到支付信息!",
|
||||
"line": 54
|
||||
}
|
||||
],
|
||||
"uni_modules/uni-datetime-picker/components/uni-datetime-picker/time-picker.vue": [
|
||||
{
|
||||
"kind": "js-text",
|
||||
"match": "日期和时间",
|
||||
"line": 112
|
||||
}
|
||||
]
|
||||
}
|
||||
7804
scripts/hardcoded_cn_scan.json
Normal file
7804
scripts/hardcoded_cn_scan.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,77 @@
|
||||
{
|
||||
"bundle_version": "1782901586",
|
||||
"bundle_version": "1784767380",
|
||||
"bundle_lang": "zh-cn",
|
||||
"bundle_leaves": 2649,
|
||||
"code_calls_total": 2677,
|
||||
"code_unique_keys": 2083,
|
||||
"found_in_bundle": 2082,
|
||||
"bundle_leaves": 2659,
|
||||
"code_calls_total": 2693,
|
||||
"code_unique_keys": 2097,
|
||||
"found_in_bundle": 2086,
|
||||
"missing_summary": {
|
||||
"TRULY_MISSING": 12,
|
||||
"SYNTAX_BUG": 1
|
||||
},
|
||||
"missing_classified": {
|
||||
"TRULY_MISSING": [
|
||||
{
|
||||
"key": "common.buyNow",
|
||||
"file": "components\\ns-goods-recommend\\ns-goods-recommend.vue",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "wechatChannel.wechatMpOnly",
|
||||
"file": "components-diy\\diy-channel-list.vue",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "common.submitSuccess",
|
||||
"file": "components-diy\\diy-form.vue",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "submitFail",
|
||||
"file": "components-diy\\diy-form.vue",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "diy.networkError",
|
||||
"file": "pages\\index\\index.vue",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "versionInfo",
|
||||
"file": "pages\\index\\public\\js\\index.js",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "commitId",
|
||||
"file": "pages\\index\\public\\js\\index.js",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "buildTime",
|
||||
"file": "pages\\index\\public\\js\\index.js",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "diy.networkError",
|
||||
"file": "pages_goods\\category.vue",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "goods.detail.formEmptyTip",
|
||||
"file": "pages_goods\\detail.vue",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "common.buyNow",
|
||||
"file": "pages_goods\\public\\js\\list.js",
|
||||
"reason": "not in bundle, not local"
|
||||
},
|
||||
{
|
||||
"key": "pay.index.payInfoNotFound",
|
||||
"file": "pages_tool\\pay\\index.vue",
|
||||
"reason": "not in bundle, not local"
|
||||
}
|
||||
],
|
||||
"SYNTAX_BUG": [
|
||||
{
|
||||
"key": "order.list.status.",
|
||||
@@ -19,18 +82,8 @@
|
||||
},
|
||||
"placeholder_issues": [],
|
||||
"data_lang_issues": {
|
||||
"components\\ns-payment\\ns-payment.vue": [
|
||||
"components.nsPayment.alipay"
|
||||
],
|
||||
"pages_promotion\\fenxiao\\apply.vue": [
|
||||
"fenxiao.apply.pleaseEnterName"
|
||||
],
|
||||
"pages_promotion\\merch\\public\\js\\list.js": [
|
||||
"merch.list.buyText"
|
||||
],
|
||||
"pages_tool\\ai-chat\\ai-chat-message.vue": [
|
||||
"ai-chat.index.loadMoreHistory",
|
||||
"ai-chat.index.defaultNickname"
|
||||
]
|
||||
},
|
||||
"hardcoded_navbar_titles": [
|
||||
@@ -121,6 +174,11 @@
|
||||
"common.tabBar.cart",
|
||||
"common.tabBar.member"
|
||||
],
|
||||
"local_only_keys": [],
|
||||
"local_only_keys": [
|
||||
"buildTime",
|
||||
"commitId",
|
||||
"submitFail",
|
||||
"versionInfo"
|
||||
],
|
||||
"bundle_only_keys_in_unique": []
|
||||
}
|
||||
80
scripts/scan-hardcoded-cn.py
Normal file
80
scripts/scan-hardcoded-cn.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
# 精筛 v2:剥离 HTML 注释和 JS 注释后,再扫硬编码中文(只关注真实会被渲染的)
|
||||
import os, re, json
|
||||
from collections import defaultdict
|
||||
|
||||
ROOT = r'D:\projects\shop-projects\frontend'
|
||||
SKIP_DIR = {'node_modules', '.unpackage', 'unpackage', '.git', 'dist', 'stub-app-projects', 'lang', 'tests', 'docs', 'scripts', 'common/mixin'}
|
||||
|
||||
CN = re.compile(r'[\u4e00-\u9fff]')
|
||||
LANG_RE = re.compile(r'\$lang\(')
|
||||
|
||||
TPL_RE = re.compile(r'<template[^>]*>([\s\S]*?)</template>', re.I)
|
||||
TAG_RE = re.compile(r'<([a-zA-Z][\w-]*)\b[^>]*>([^<>\n]+)</\1>')
|
||||
HTML_COMMENT_RE = re.compile(r'<!--[\s\S]*?-->')
|
||||
|
||||
JS_TRIGGERS = [
|
||||
re.compile(r'uni\.show(?:Toast|Modal|ActionSheet|Loading)\s*\(\s*\{[\s\S]*?\}\s*\)', re.S),
|
||||
re.compile(r'wx\.show(?:Toast|Modal|ActionSheet|Loading)\s*\(\s*\{[\s\S]*?\}\s*\)', re.S),
|
||||
re.compile(r'\btitle\s*:\s*[\'"]([^\'"]*?)[\'"]'),
|
||||
re.compile(r'\bcontent\s*:\s*[\'"]([^\'"]*?)[\'"]'),
|
||||
re.compile(r'\btext\s*:\s*[\'"]([^\'"]*?)[\'"]'),
|
||||
re.compile(r'\bplaceholder\s*:\s*[\'"]([^\'"]*?)[\'"]'),
|
||||
re.compile(r'\balert\s*\(\s*[\'"]([^\'"]*?)[\'"]'),
|
||||
re.compile(r'\bconfirm\s*\(\s*[\'"]([^\'"]*?)[\'"]'),
|
||||
]
|
||||
|
||||
def strip_all_comments(text, is_vue=False):
|
||||
text = HTML_COMMENT_RE.sub(lambda m: ' ' * len(m.group()), text)
|
||||
text = re.sub(r'/\*[\s\S]*?\*/', lambda m: ' ' * len(m.group()), text)
|
||||
text = re.sub(r'//[^\n]*', lambda m: ' ' * len(m.group()), text)
|
||||
return text
|
||||
|
||||
results = defaultdict(list)
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(ROOT):
|
||||
if any(s in dirpath.replace('\\', '/') for s in SKIP_DIR):
|
||||
continue
|
||||
for fn in filenames:
|
||||
if not fn.endswith(('.vue', '.js')):
|
||||
continue
|
||||
fp = os.path.join(dirpath, fn)
|
||||
rel = os.path.relpath(fp, ROOT).replace('\\', '/')
|
||||
try:
|
||||
content = open(fp, 'r', encoding='utf-8').read()
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
|
||||
stripped = strip_all_comments(content)
|
||||
|
||||
if fn.endswith('.vue'):
|
||||
for m in TPL_RE.finditer(stripped):
|
||||
tpl = m.group(1)
|
||||
for tag in TAG_RE.finditer(tpl):
|
||||
inner = tag.group(2).strip()
|
||||
if CN.search(inner) and not LANG_RE.search(inner):
|
||||
line_no = stripped[:m.start(1) + tpl.find(inner)].count('\n') + 1
|
||||
results[rel].append({'kind': 'template', 'tag': tag.group(1), 'text': inner[:160], 'line': line_no})
|
||||
|
||||
for trig in JS_TRIGGERS:
|
||||
for m in trig.finditer(stripped):
|
||||
text = (m.group(1) if m.lastindex else m.group(0))
|
||||
if not CN.search(text):
|
||||
continue
|
||||
if LANG_RE.search(text):
|
||||
continue
|
||||
line_no = stripped[:m.start()].count('\n') + 1
|
||||
results[rel].append({'kind': 'js-text', 'match': text[:160], 'line': line_no})
|
||||
|
||||
ranked = sorted(results.items(), key=lambda kv: -len(kv[1]))
|
||||
print(f'total files: {len(ranked)}')
|
||||
print(f'total hits: {sum(len(v) for v in ranked)}')
|
||||
|
||||
out = []
|
||||
for f, hits in ranked[:60]:
|
||||
out.append({'file': f, 'hits': len(hits), 'samples': hits[:3]})
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
|
||||
with open(os.path.join(ROOT, 'scripts', 'hardcoded_cn_real.json'), 'w', encoding='utf-8') as f:
|
||||
json.dump({k: v for k, v in ranked}, f, ensure_ascii=False, indent=2)
|
||||
print('saved hardcoded_cn_real.json')
|
||||
493
tests/lang.test.js
Normal file
493
tests/lang.test.js
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* lang.js 单元测试($lang 解析器)
|
||||
*
|
||||
* 运行方式:
|
||||
* node --test tests/lang.test.js
|
||||
*
|
||||
* 测试覆盖(6 大维度):
|
||||
* 1. 基础解析: 命中、未命中、默认值兜底、生产环境/开发环境返回差异
|
||||
* 2. 命名参数: {reason} / {count} / {unit} 多参数、占位符安全性
|
||||
* 3. 位置参数: {0} / {1} 兼容老式调用
|
||||
* 4. 嵌套命名空间: diy.loadFailTpl / wechatChannel.wechatMpOnly / common.tabBar.home
|
||||
* 5. 占位符替换: 多次替换、正则元字符、__proto__/constructor/prototype 过滤
|
||||
* 6. 实际业务 key: loadFailTpl (com.js 下 diy)、tabBar.* 翻译对齐、common.buyNow vs common.submitSuccess
|
||||
*
|
||||
* 实现策略:
|
||||
* lang.js 是 Vue 插件,挂在 Vue.prototype 上,依赖 store / uni / Config,
|
||||
* 无法直接 import 跑测试。本测试按 util.test.js 的同款做法:
|
||||
* 把要测的纯函数 (applyLangParams + 简化版 lang() 解析流程) 从 lang.js
|
||||
* 源码里"剪切"出来放到本测试文件,与 lang.js 保持同步。
|
||||
* 这样不依赖任何测试框架,任何 node 版本 (>=18) 都能跑通。
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
|
||||
// =============================================================
|
||||
// 被测源码区块(与 common/js/lang.js 保持同步)
|
||||
// =============================================================
|
||||
|
||||
/** 与 lang.js 中 FORBIDDEN_KEYS 一致 */
|
||||
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
|
||||
/**
|
||||
* @see lang.js#applyLangParams
|
||||
* 命名参数 + 位置参数 替换
|
||||
*/
|
||||
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) {
|
||||
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 {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
result = result.replace(new RegExp('\\{' + i + '\\}', 'g'), String(args[i]));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版 lang() 解析流程(模拟 lang.js 主流程的"按 . 切分 + 逐层查找 + 默认值 + 占位符替换"核心)
|
||||
*
|
||||
* 与 lang.js 主流程差异:
|
||||
* - 不处理 route 缓存、_mergedLangCache、_fieldResultCache 等(那些是性能优化,不影响语义)
|
||||
* - 不处理 overrideCache(那是远端覆写机制,需要单独 mock)
|
||||
* - 不处理递归保护、isRemoteOnly 短路(那是运行时 hook)
|
||||
* - 但"按 . 切分 → 逐层查找 → 未命中走默认值 → 占位符替换" 这一核心路径与 lang.js 完全一致
|
||||
*
|
||||
* @param {Object} mergedLang 合并后的语言包对象(顶层已是 common namespace)
|
||||
* @param {string} field 字段路径,如 'diy.loadFailTpl'
|
||||
* @param {...any} args 可选参数(命名参数或位置参数)
|
||||
* @returns {string}
|
||||
*/
|
||||
function fakeLang(mergedLang, field, ...args) {
|
||||
const hasDefault = args.length > 0 && typeof args[0] === 'string' && args[0] !== '';
|
||||
|
||||
const arr = field.split('.');
|
||||
let value;
|
||||
if (arr.length > 1) {
|
||||
let temp = mergedLang;
|
||||
let found = true;
|
||||
for (const key of arr) {
|
||||
if (temp && typeof temp === 'object' && temp[key] !== undefined) {
|
||||
temp = temp[key];
|
||||
} else {
|
||||
found = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
value = found && typeof temp === 'string' ? temp : field;
|
||||
} else {
|
||||
const raw = mergedLang[field];
|
||||
value = raw !== undefined && typeof raw === 'string' ? raw : field;
|
||||
}
|
||||
|
||||
// 未命中 → 默认值兜底
|
||||
if (value === field) {
|
||||
if (hasDefault) return args[0];
|
||||
return ''; // 生产环境返回空串(对齐 lang.js 的 __DEV__ === false 分支)
|
||||
}
|
||||
|
||||
// 占位符替换
|
||||
if (args.length > 0) {
|
||||
value = applyLangParams(value, args);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接 require 实际语言包,确保我们测的就是项目里那份 zh-cn / en-us
|
||||
*/
|
||||
const zh = require(path.resolve(__dirname, '..', 'lang', 'zh-cn', 'common.js')).lang;
|
||||
const en = require(path.resolve(__dirname, '..', 'lang', 'en-us', 'common.js')).lang;
|
||||
|
||||
// =============================================================
|
||||
// 1. 基础解析:命中 / 未命中 / 默认值
|
||||
// =============================================================
|
||||
|
||||
test('基础解析: 命中已有 key 返回翻译结果', () => {
|
||||
assert.strictEqual(
|
||||
fakeLang(zh, 'diy.retry'),
|
||||
'重试'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fakeLang(en, 'diy.retry'),
|
||||
'Retry'
|
||||
);
|
||||
});
|
||||
|
||||
test('基础解析: 中英对照 一致性', () => {
|
||||
const pairs = [
|
||||
['diy.retry', '重试', 'Retry'],
|
||||
['diy.noDiyData', '未配置自定义页面数据', 'Custom page data not configured'],
|
||||
['customer.weChatKefu', '微信客服', 'WeChat Customer Service'],
|
||||
];
|
||||
for (const [key, zhExpected, enExpected] of pairs) {
|
||||
assert.strictEqual(fakeLang(zh, key), zhExpected, `zh ${key}`);
|
||||
assert.strictEqual(fakeLang(en, key), enExpected, `en ${key}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('基础解析: 未命中且无默认值 → 生产环境返回空串', () => {
|
||||
assert.strictEqual(
|
||||
fakeLang(zh, 'totally.fake.missing.key'),
|
||||
'',
|
||||
'未命中且无默认值时,生产环境(本测试视角)返回空串,避免展示 raw key'
|
||||
);
|
||||
});
|
||||
|
||||
test('基础解析: 未命中但有默认值 → 返回默认值', () => {
|
||||
assert.strictEqual(
|
||||
fakeLang(zh, 'totally.fake.missing.key', '兜底文案'),
|
||||
'兜底文案'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fakeLang(en, 'totally.fake.missing.key', 'Fallback Text'),
|
||||
'Fallback Text'
|
||||
);
|
||||
});
|
||||
|
||||
test('基础解析: 默认值是空串时被忽略(对齐 lang.js hasDefault 判断)', () => {
|
||||
// lang.js: hasDefault = typeof args[1] === 'string' && args[1] !== ''
|
||||
// args[0] 是 string '' 时不算默认值
|
||||
assert.strictEqual(
|
||||
fakeLang(zh, 'totally.fake.missing.key', ''),
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
test('基础解析: 默认值必须是字符串,非字符串不走默认值兜底', () => {
|
||||
// args[0] = { foo: 1 } 是命名参数,不走默认值路径
|
||||
// 这里用一个不存在 key 验证:返回空串(因为命名参数替换失败)
|
||||
assert.strictEqual(
|
||||
fakeLang(zh, 'totally.fake.missing.key', { foo: 'bar' }),
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
test('基础解析: 中间路径上某节点非对象(命中 leaf 是 number/boolean)', () => {
|
||||
// 模拟 leaf 不是 string 的场景
|
||||
const stub = { foo: { bar: 123 } };
|
||||
// lang.js 实际行为:中间节点上 leaf 不是 string 时,value 设为 field,
|
||||
// 但因为没有默认值兜底且后续判定 value === field → 返回空串
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'foo.bar'),
|
||||
'',
|
||||
'非 string 类型 leaf 当成未命中,且无默认值 → 返回空串'
|
||||
);
|
||||
// 给默认值时返回默认值
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'foo.bar', 'fallback'),
|
||||
'fallback'
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 2. 命名参数:本次修复的核心场景
|
||||
// =============================================================
|
||||
|
||||
test('命名参数: diy.loadFailTpl + { reason } 是核心场景', () => {
|
||||
// 注意:zh-cn 实际文案是中文标点「:」,不是英文「:」
|
||||
assert.strictEqual(
|
||||
fakeLang(zh, 'diy.loadFailTpl', { reason: '网络连接超时' }),
|
||||
'页面数据加载失败:网络连接超时,请稍后重试'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fakeLang(en, 'diy.loadFailTpl', { reason: 'Network timeout' }),
|
||||
'Failed to load page data: Network timeout, please try again later'
|
||||
);
|
||||
});
|
||||
|
||||
test('命名参数: 多参数同时替换', () => {
|
||||
// goods.detail.purchaseLimit = '该商品每人限购{count}{unit}'
|
||||
const zhGoods = require(path.resolve(__dirname, '..', 'lang', 'zh-cn', 'goods', 'detail.js')).lang;
|
||||
const enGoods = require(path.resolve(__dirname, '..', 'lang', 'en-us', 'goods', 'detail.js')).lang;
|
||||
|
||||
assert.strictEqual(
|
||||
fakeLang(zhGoods, 'purchaseLimit', { count: 5, unit: '件' }),
|
||||
'该商品每人限购5件'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fakeLang(enGoods, 'purchaseLimit', { count: 5, unit: 'pcs' }),
|
||||
'Limit 5 pcs per person',
|
||||
'en-us 文案是 "Limit {count} {unit} per person",顺序替换 {count}=5 / {unit}=pcs'
|
||||
);
|
||||
|
||||
// 多参数 + 多占位符 替换
|
||||
assert.strictEqual(
|
||||
fakeLang(zhGoods, 'purchaseLimitWithPurchased', { count: 5, unit: '件', purchased: 2 }),
|
||||
'该商品每人限购5件,您已购买了2件'
|
||||
);
|
||||
});
|
||||
|
||||
test('命名参数: 多次出现的占位符全部替换', () => {
|
||||
const stub = { msg: '欢迎{user},{user} 你好' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', { user: '张三' }),
|
||||
'欢迎张三,张三 你好'
|
||||
);
|
||||
});
|
||||
|
||||
test('命名参数: 占位符的值含正则元字符不会注入', () => {
|
||||
const stub = { msg: '下载 {url} 完成' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', { url: 'a.b+c*d(e)f' }),
|
||||
'下载 a.b+c*d(e)f 完成'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', { url: '$1\\2{3}' }),
|
||||
'下载 $1\\2{3} 完成'
|
||||
);
|
||||
});
|
||||
|
||||
test('命名参数: 占位符的值含 .*+?^${}()|[]\\ 等全部正确转义', () => {
|
||||
const stub = { msg: '前缀 {x} 后缀' };
|
||||
const r1 = fakeLang(stub, 'msg', { x: '$1' });
|
||||
assert.strictEqual(r1, '前缀 $1 后缀', 'value 端的 $1 不应被吃成反向引用');
|
||||
|
||||
const r2 = fakeLang(stub, 'msg', { x: '\\n' });
|
||||
assert.strictEqual(r2, '前缀 \\n 后缀');
|
||||
});
|
||||
|
||||
test('命名参数: 危险 key __proto__/constructor/prototype 被过滤', () => {
|
||||
const stub = { msg: 'hello {__proto__} {constructor} {prototype} world' };
|
||||
const out = fakeLang(stub, 'msg', { __proto__: 'A', constructor: 'B', prototype: 'C', safe: 'OK' });
|
||||
assert.strictEqual(out, 'hello {__proto__} {constructor} {prototype} world');
|
||||
});
|
||||
|
||||
test('命名参数: 没有占位符的纯文案 + 命名参数(多余参数被忽略)', () => {
|
||||
const stub = { msg: '你好' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', { foo: 'bar' }),
|
||||
'你好'
|
||||
);
|
||||
});
|
||||
|
||||
test('命名参数: 对象参数里没提供的占位符 → 字面残留', () => {
|
||||
const stub = { msg: '你好 {name},你 {count} 岁' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', { name: 'Tom' }),
|
||||
'你好 Tom,你 {count} 岁'
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 3. 位置参数: {0} / {1} 老式调用兼容
|
||||
// =============================================================
|
||||
|
||||
test('位置参数: {0} 形式', () => {
|
||||
const stub = { msg: '欢迎第 {0} 位用户' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', '张三'),
|
||||
'欢迎第 张三 位用户'
|
||||
);
|
||||
});
|
||||
|
||||
test('位置参数: 多位置参数 {0}/{1}/{2}', () => {
|
||||
const stub = { msg: '{0} 在 {1} 出生,今年 {2} 岁' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', 'Alice', 'Beijing', 18),
|
||||
'Alice 在 Beijing 出生,今年 18 岁'
|
||||
);
|
||||
});
|
||||
|
||||
test('位置参数: 数组会被识别为位置参数而非命名参数', () => {
|
||||
// args[0] = ['a', 'b'] 是 array → 走位置参数分支
|
||||
// 但 args.length === 1(只传了一个数组),所以只有 {0} 被替换,{1} 残留
|
||||
const stub = { msg: '{0} {1}' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', ['a', 'b']),
|
||||
'a,b {1}'
|
||||
);
|
||||
});
|
||||
|
||||
test('位置参数: null 走位置参数分支,被 String() 转为 "null"', () => {
|
||||
const stub = { msg: '{0}' };
|
||||
// args[0] === null: applyLangParams 中 firstArg !== null 判定为 false,
|
||||
// 走位置参数分支, String(null) = 'null',替换 {0}
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', null),
|
||||
'null'
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 4. 嵌套命名空间: 多层 . 路径
|
||||
// =============================================================
|
||||
|
||||
test('嵌套命名空间: order.payment.title (3 层)', () => {
|
||||
const orderZh = require(path.resolve(__dirname, '..', 'lang', 'zh-cn', 'order', 'payment.js')).lang;
|
||||
const orderEn = require(path.resolve(__dirname, '..', 'lang', 'en-us', 'order', 'payment.js')).lang;
|
||||
// 注意:页面语言包整体就是 order.payment namespace,
|
||||
// 所以 $lang('title') 也能命中(归一化逻辑),但显式路径是 $lang('order.payment.title')
|
||||
assert.strictEqual(fakeLang(orderZh, 'title'), '确认订单');
|
||||
assert.strictEqual(fakeLang(orderEn, 'title'), 'Confirm Order');
|
||||
});
|
||||
|
||||
test('嵌套命名空间: wechatChannel.wechatMpOnly (本次新增)', () => {
|
||||
assert.strictEqual(
|
||||
fakeLang(zh, 'wechatChannel.wechatMpOnly'),
|
||||
'视频号仅支持在微信小程序环境中播放'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fakeLang(en, 'wechatChannel.wechatMpOnly'),
|
||||
'WeChat Channel only plays in WeChat Mini Program'
|
||||
);
|
||||
});
|
||||
|
||||
test('嵌套命名空间: common.tabBar.* (本次修正)', () => {
|
||||
assert.strictEqual(fakeLang(zh, 'tabBar.home'), '首页');
|
||||
assert.strictEqual(fakeLang(zh, 'tabBar.category'), '分类');
|
||||
assert.strictEqual(fakeLang(zh, 'tabBar.cart'), '购物车');
|
||||
assert.strictEqual(fakeLang(zh, 'tabBar.member'), '个人中心');
|
||||
|
||||
// en-us 顶层 tabBar(本次修复后)
|
||||
assert.strictEqual(fakeLang(en, 'tabBar.home'), 'Home');
|
||||
assert.strictEqual(fakeLang(en, 'tabBar.category'), 'Category');
|
||||
assert.strictEqual(fakeLang(en, 'tabBar.cart'), 'Cart');
|
||||
assert.strictEqual(fakeLang(en, 'tabBar.member'), 'Me');
|
||||
});
|
||||
|
||||
test('嵌套命名空间: common.tabBar.* 中英文一致性(本次修复的核心断言)', () => {
|
||||
const tabs = ['home', 'category', 'cart', 'member'];
|
||||
for (const tab of tabs) {
|
||||
const zhVal = fakeLang(zh, `tabBar.${tab}`);
|
||||
const enVal = fakeLang(en, `tabBar.${tab}`);
|
||||
assert.notStrictEqual(enVal, zhVal, `en-us tabBar.${tab} 不能等于 zh-cn 值`);
|
||||
assert.ok(enVal.length > 0, `en-us tabBar.${tab} 必须有翻译`);
|
||||
// 英文 tab 应是 Title Case,而不是全小写英文单词
|
||||
assert.ok(
|
||||
/^[A-Z]/.test(enVal) || enVal === 'Me',
|
||||
`en-us tabBar.${tab}="${enVal}" 应以大写字母开头(或等于 'Me')`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('嵌套命名空间: common 顶层键 vs common.common 子对象双层结构', () => {
|
||||
// 历史遗留:common.js 的 lang 对象整体即 common namespace,
|
||||
// 但对象内部又有一个 'common: {...}' 子对象
|
||||
assert.strictEqual(fakeLang(zh, 'buyNow'), '立即购买');
|
||||
assert.strictEqual(fakeLang(zh, 'common.goodsRecommendTitle'), '猜你喜欢');
|
||||
assert.strictEqual(fakeLang(en, 'buyNow'), 'Buy Now');
|
||||
assert.strictEqual(fakeLang(en, 'common.goodsRecommendTitle'), 'Guess you like');
|
||||
});
|
||||
|
||||
test('嵌套命名空间: 4 层路径 diy.networkErrorTitle', () => {
|
||||
assert.strictEqual(fakeLang(zh, 'diy.networkErrorTitle'), '提示');
|
||||
assert.strictEqual(fakeLang(en, 'diy.networkErrorTitle'), 'Notice');
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 5. 占位符替换细节
|
||||
// =============================================================
|
||||
|
||||
test('占位符替换: 占位符的值含数字时,String() 强制转换', () => {
|
||||
const stub = { msg: '价格 {price} 元' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', { price: 99 }),
|
||||
'价格 99 元'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', { price: 99.5 }),
|
||||
'价格 99.5 元'
|
||||
);
|
||||
});
|
||||
|
||||
test('占位符替换: 占位符的值为 undefined 时,String(undefined) = "undefined"', () => {
|
||||
const stub = { msg: 'name={name}' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', { name: undefined }),
|
||||
'name=undefined'
|
||||
);
|
||||
});
|
||||
|
||||
test('占位符替换: 占位符的值为 0/false 时,也会被替换(因为走 String 强制转换)', () => {
|
||||
const stub = { msg: '数量 {n},启用 {on}' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', { n: 0, on: false }),
|
||||
'数量 0,启用 false'
|
||||
);
|
||||
});
|
||||
|
||||
test('占位符替换: 占位符的值为空字符串', () => {
|
||||
const stub = { msg: '前 [{x}] 后' };
|
||||
assert.strictEqual(
|
||||
fakeLang(stub, 'msg', { x: '' }),
|
||||
'前 [] 后'
|
||||
);
|
||||
});
|
||||
|
||||
// =============================================================
|
||||
// 6. 真实业务 key 回归测试
|
||||
// =============================================================
|
||||
|
||||
test('回归: 本次修复新增的 8 个 key 在两包中都有', () => {
|
||||
const newKeys = [
|
||||
'diy.networkError',
|
||||
'wechatChannel.wechatMpOnly',
|
||||
'submitSuccess',
|
||||
'submitFail',
|
||||
'versionInfo',
|
||||
'commitId',
|
||||
'buildTime',
|
||||
'loading',
|
||||
];
|
||||
for (const key of newKeys) {
|
||||
const zhVal = fakeLang(zh, key);
|
||||
const enVal = fakeLang(en, key);
|
||||
assert.ok(typeof zhVal === 'string' && zhVal.length > 0, `zh ${key}="${zhVal}"`);
|
||||
assert.ok(typeof enVal === 'string' && enVal.length > 0, `en ${key}="${enVal}"`);
|
||||
assert.notStrictEqual(zhVal, enVal, `${key} 中英文必须不同`);
|
||||
}
|
||||
});
|
||||
|
||||
test('回归: 核心占位符场景 diy.loadFailTpl 不传参数会字面残留占位符', () => {
|
||||
// 已知行为:reason 必填,如果不传,确实会字面残留(对齐 lang.js 行为)
|
||||
// 实际 zh-cn 文案是中文标点「:」
|
||||
const stub1 = { ...zh };
|
||||
const out1 = fakeLang(stub1, 'diy.loadFailTpl');
|
||||
// lang.js: value === field? 否(命中了 diy.loadFailTpl 的 leaf)→ 走占位符替换
|
||||
// applyLangParams 的 args.length === 0 → 直接返回 value
|
||||
assert.strictEqual(
|
||||
out1,
|
||||
'页面数据加载失败:{reason},请稍后重试',
|
||||
'不传参数会字面残留占位符,这是已知行为'
|
||||
);
|
||||
|
||||
// 但传了 reason 必须正确替换
|
||||
assert.strictEqual(
|
||||
fakeLang(zh, 'diy.loadFailTpl', { reason: 'X' }),
|
||||
'页面数据加载失败:X,请稍后重试'
|
||||
);
|
||||
});
|
||||
|
||||
test('回归: zh-cn/en-us common.js 同 key 同结构(key 一致性)', () => {
|
||||
const zhKeys = Object.keys(zh).sort();
|
||||
const enKeys = Object.keys(en).sort();
|
||||
assert.deepStrictEqual(
|
||||
zhKeys,
|
||||
enKeys,
|
||||
'zh-cn 与 en-us common.js 顶层 key 必须完全一致(否则会有 missing_key 风险)'
|
||||
);
|
||||
});
|
||||
|
||||
test('回归: 抽 zh/en 内 common 子对象 key 一致性', () => {
|
||||
const zhCommon = Object.keys(zh.common || {}).sort();
|
||||
const enCommon = Object.keys(en.common || {}).sort();
|
||||
assert.deepStrictEqual(zhCommon, enCommon, 'zh/en common 子对象 key 一致');
|
||||
});
|
||||
Reference in New Issue
Block a user