1. 新增多语言资源文件,覆盖文章、预约日期、会员信息、优惠券、表单、购物车、积分商城、分销、AI聊天等多个模块 2. 优化底部导航组件,使用多语言配置替代硬编码文案 3. 重构工具函数,新增从后端数据提取多语言文案的工具方法 4. 修复邀请好友页面的保存提示文案,替换为多语言版本 5. 修复商品SKU组件的提示文案,使用多语言配置 6. 重构AI聊天页面的帮助文档和回复模板,使用多语言配置 7. 优化空状态组件,支持多语言默认文案 8. 修复跳转逻辑,补全参数传递 9. 新增本地开发环境配置
393 lines
12 KiB
JavaScript
393 lines
12 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 isCommentLine(line) {
|
|
const t = line.trim();
|
|
if (t.startsWith('//')) return true;
|
|
if (t.startsWith('/*')) return true;
|
|
if (t.startsWith('*')) return true;
|
|
if (t.startsWith('<!--')) return true;
|
|
if (t.startsWith('* ')) return true;
|
|
return false;
|
|
}
|
|
|
|
// 判断一行是否是 $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;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (isCommentLine(line)) continue;
|
|
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();
|