refactor(i18n): 完成全量硬编码中文文案替换为多语言方案

1.  替换所有页面/组件中的硬编码中文提示文案为$lang调用,包括支付、帮助中心、公告等页面
2.  重构diy.js加载失败文案处理逻辑,支持参数化替换
3.  新增多语言混入逻辑,实现导航标题随语种动态切换
4.  重构支付组件初始化逻辑,优化平台适配代码
5.  添加多语言单元测试和硬编码扫描脚本
6.  更新语言包,新增缺失的翻译词条
This commit is contained in:
2026-07-23 09:46:16 +08:00
parent 4b0aa0ac40
commit 94142268a0
21 changed files with 8846 additions and 80 deletions

View 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}`);
}
}
}

View 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
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -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": []
}

View 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')