Files
lucky_shop/scripts/i18n-check.js
ZF sun 066ca61d97 feat: 新增多语言文案与组件国际化适配
本次提交完成了大量多语言相关的新增与优化工作:
1. 新增充值、案例、会员资产、订单等页面的中文/英文翻译文案
2. 新增企业微信客服、时间选择、支付弹窗等组件的多语言包
3. 优化工具函数与自定义页面的多语言适配,替换硬编码提示文案
4. 新增会员等级规则、积分商品、退款管理等页面的国际化支持
5. 修复企业微信JS-SDK的错误提示文案硬编码问题
2026-06-26 09:09:46 +08:00

470 lines
14 KiB
JavaScript

#!/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/3】文件结构检查\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/3】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 完全一致');
}
// ---- 检查 3: 硬编码中文扫描 ----
console.log('\n【3/3】硬编码中文扫描\n');
// 跳过的目录
const SKIP_DIRS = new Set([
'node_modules', 'unpackage', 'static', 'lang',
'uni_modules', '.git', '.hbuilderx', 'dist', 'build',
'stub-app-projects', 'docs', 'hybrid', 'wxcomponents'
]);
// 跳过的文件
const SKIP_FILE_PATTERNS = [
/vendor\.js$/,
/\.min\.js$/,
/\.pack\.js$/,
/manifest\.json\.js$/,
];
// 扫描所有 vue / js 文件
const srcFiles = [];
function scanSrcDir(dir, rel) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = rel ? path.join(rel, entry.name) : entry.name;
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
scanSrcDir(fullPath, relPath);
} else if (entry.name.endsWith('.vue') || entry.name.endsWith('.js')) {
// 跳过 vendor / min / pack 等非源码文件
let skip = false;
for (const pat of SKIP_FILE_PATTERNS) {
if (pat.test(entry.name)) { skip = true; break; }
}
if (!skip) {
srcFiles.push({ fullPath, relPath: relPath.replace(/\\/g, '/') });
}
}
}
}
scanSrcDir(ROOT, '');
// 中文正则(基本汉字区)
const CN_RE = /[\u4e00-\u9fa5]+/g;
// 去掉行内注释(// 和 /* */ 块注释单行)
function stripInlineComment(line, inBlockComment) {
let result = line;
let inString = false;
let stringChar = '';
let i = 0;
while (i < result.length) {
const ch = result[i];
const next = result[i + 1];
if (inBlockComment) {
// 块注释中,找 */
if (ch === '*' && next === '/') {
inBlockComment = false;
result = result.substring(0, i) + ' ' + result.substring(i + 2);
i += 2;
continue;
}
// 替换为空格保持位置
result = result.substring(0, i) + ' ' + result.substring(i + 1);
i++;
continue;
}
if (inString) {
if (ch === '\\') { i += 2; continue; } // 转义
if (ch === stringChar) { inString = false; }
i++;
continue;
}
// 字符串开始
if (ch === "'" || ch === '"' || ch === '`') {
inString = true;
stringChar = ch;
i++;
continue;
}
// 行注释
if (ch === '/' && next === '/') {
result = result.substring(0, i);
break;
}
// 块注释开始
if (ch === '/' && next === '*') {
inBlockComment = true;
result = result.substring(0, i) + ' ' + result.substring(i + 2);
i += 2;
continue;
}
i++;
}
return { text: result, inBlockComment };
}
// 判断一行是否是 $lang 调用(已经国际化)
function isLangCall(line) {
// 匹配 $lang(' 或 $lang("
return /\$lang\(\s*['"]/.test(line);
}
const hardcodeResults = [];
let totalCnHits = 0;
for (const { fullPath, relPath } of srcFiles) {
try {
const content = fs.readFileSync(fullPath, 'utf-8');
const lines = content.split('\n');
let fileHits = 0;
let inBlockComment = false;
let inHtmlComment = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// HTML 注释块处理 (<!-- -->)
if (inHtmlComment) {
const endIdx = line.indexOf('-->');
if (endIdx >= 0) {
line = line.substring(endIdx + 3);
inHtmlComment = false;
} else {
continue; // 整行在 HTML 注释中
}
}
const htmlStart = line.indexOf('<!--');
if (htmlStart >= 0) {
const htmlEnd = line.indexOf('-->', htmlStart);
if (htmlEnd >= 0) {
line = line.substring(0, htmlStart) + line.substring(htmlEnd + 3);
} else {
line = line.substring(0, htmlStart);
inHtmlComment = true;
}
}
// JS/CSS 块注释 + 行注释处理
const stripped = stripInlineComment(line, inBlockComment);
inBlockComment = stripped.inBlockComment;
line = stripped.text;
if (isLangCall(line)) continue;
const matches = line.match(CN_RE);
if (matches) {
fileHits += matches.length;
}
}
if (fileHits > 0) {
totalCnHits += fileHits;
hardcodeResults.push({ file: relPath, count: fileHits });
}
} catch (e) {
// 跳过读不了的文件
}
}
// 按命中数排序
hardcodeResults.sort((a, b) => b.count - a.count);
console.log(` 扫描文件数: ${srcFiles.length}`);
console.log(` 含中文的文件数: ${hardcodeResults.length}`);
console.log(` 中文字符串命中数: ${totalCnHits}`);
if (hardcodeResults.length > 0) {
console.log(`\n ⚠️ 疑似硬编码中文 TOP 20:`);
const topN = hardcodeResults.slice(0, 20);
for (const r of topN) {
console.log(` ${String(r.count).padStart(4)} ${r.file}`);
}
if (hardcodeResults.length > 20) {
console.log(` ... 还有 ${hardcodeResults.length - 20} 个文件`);
}
console.log(`\n 💡 提示:这是粗略扫描,包含注释/默认值等误报,仅供参考`);
} else {
console.log(' ✅ 未发现硬编码中文(粗略扫描)');
}
// ---- 总结 ----
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();