安装 i18next
npm install vue-i18n@next / yarn add vue-i18n@next
package.json
"i18next": "^23.14.0",
web/utils/i18next.ts
import { createTESTI18next } from '@XXXX/XXXX-i18next-core';
import i18next from 'i18next';
import ar from '../locales/ar.json';
import de from '../locales/de.json';
import en from '../locales/en.json';
import es from '../locales/es.json';
import id from '../locales/id.json';
import it from '../locales/it.json';
import ja from '../locales/ja.json';
import ko from '../locales/ko.json';
import lo from '../locales/lo.json';
import ms from '../locales/ms.json';
import my from '../locales/my.json';
import pt from '../locales/pt.json';
import ru from '../locales/ru.json';
import th from '../locales/th.json';
import tr from '../locales/tr.json';
import vi from '../locales/vi.json';
import zh_CN from '../locales/zh_CN.json';
import zh_HK from '../locales/zh_HK.json';
import zh_TW from '../locales/zh_TW.json';
// 翻译流程
const XXXi18next = createTESTI18next();
XXXi18next.init({
resources: {
ar: {
translation: ar,
},
de: {
translation: de,
},
en: {
translation: en,
},
en_US: {
translation: en,
},
es: {
translation: es,
},
id: {
translation: id,
},
it: {
translation: it,
},
ja: {
translation: ja,
},
ko: {
translation: ko,
},
lo: {
translation: lo,
},
ms: {
translation: ms,
},
my: {
translation: my,
},
pt: {
translation: pt,
},
ru: {
translation: ru,
},
th: {
translation: th,
},
tr: {
translation: tr,
},
vi: {
translation: vi,
},
zh_CN: {
translation: zh_CN,
},
zh_HK: {
translation: zh_HK,
},
zh_TW: {
translation: zh_TW,
},
},
});
i18next.init({
// lng: 'zh_CN',
resources: {
ar: {
translation: ar,
},
de: {
translation: de,
},
en: {
translation: en,
},
en_US: {
translation: en,
},
es: {
translation: es,
},
id: {
translation: id,
},
it: {
translation: it,
},
ja: {
translation: ja,
},
ko: {
translation: ko,
},
lo: {
translation: lo,
},
ms: {
translation: ms,
},
my: {
translation: my,
},
pt: {
translation: pt,
},
ru: {
translation: ru,
},
th: {
translation: th,
},
tr: {
translation: tr,
},
vi: {
translation: vi,
},
zh_CN: {
translation: zh_CN,
},
zh_HK: {
translation: zh_HK,
},
zh_TW: {
translation: zh_TW,
},
},
})
export default XXXi18next;
wei18n.config.ts
import { defineConfig } from '@xxxtest/wei18n-cli';
export default defineConfig({
input: ["web/**/*.ts", "web/**/*.vue"],
output: "web/locales",
extractOptions: {
plural: false,
terminology: false,
},
});
配置脚本 package.json
{
"scripts": {
"i18next-check": "node web/utils/i18next-check.js",
"i18next-wrap-only": "node web/utils/i18next-wrap-only.js",
"i18next-extract-special": "node web/utils/i18next-extract-special.js",
}
}
web/utils/i18next-check.js
// npm run i18n-check
// 检查所有文件中未被包裹 i18n 的中文
const fs = require('fs');
const path = require('path');
const projectRoot = path.resolve(__dirname, '../../');
const vueDir = path.join(projectRoot, 'web/pages');
function walk(dir, ext, filelist = []) {
fs.readdirSync(dir).forEach(file => {
const filepath = path.join(dir, file);
if (fs.statSync(filepath).isDirectory()) {
walk(filepath, ext, filelist);
} else if (filepath.endsWith(ext)) {
filelist.push(filepath);
}
});
return filelist;
}
function checkVueFile(filepath, results) {
const lines = fs.readFileSync(filepath, 'utf-8').split(/\r?\n/);
lines.forEach((line, idx) => {
// 标签体中文(未被 $i18next.t/$t 包裹,且不是嵌套包裹)
const tagMatches = line.match(/>[^<{]*[\u4e00-\u9fa5][^<{]*</g);
if (tagMatches) {
tagMatches.forEach(match => {
// 跳过 {{ $i18next.t('xxxproxy.$i18next.t(') }} 或 {{ $i18next.t(')xxx') }}
if (/{{\s*$i18next.t((proxy.|)$i18next.t('[^']'))\s*}}/.test(match)) return;
if (!/$i18next.t|$t(/.test(match)) {
results.push({ file: filepath, line: idx + 1, type: '标签体', content: match.trim() });
}
});
}
// 属性值中文(未被 :xxx= 或 $i18next.t 包裹)
const attrMatches = line.match(/\s[a-zA-Z0-9-_]="[^"\n]*[\u4e00-\u9fa5][^"\n]*"/g);
if (attrMatches) {
attrMatches.forEach(match => {
if (!/[:@][a-zA-Z0-9-_]=|v-bind:[a-zA-Z0-9-_]=|$i18next.t|$t(/.test(match)) {
results.push({ file: filepath, line: idx + 1, type: '属性', content: match.trim() });
}
});
}
// 对象字面量属性
const objMatches = line.match(/(name|label|title|desc|content|buttonText|placeholder|tips|tip|error|success|fail|msg)\s*:\s*'[^'\n]*[\u4e00-\u9fa5][^'\n]*'/g);
if (objMatches) {
objMatches.forEach(match => {
if (!/$i18next.t|$t(/.test(match)) {
results.push({ file: filepath, line: idx + 1, type: '对象字面量', content: match.trim() });
}
});
}
});
}
function main() {
const vueFiles = walk(vueDir, '.vue');
const results = [];
vueFiles.forEach(file => checkVueFile(file, results));
if (results.length === 0) {
console.log('所有中文都已被 i18n 包裹');
} else {
console.log('未被 i18n 包裹的中文如下: ');
results.forEach(r => {
console.log(`[${r.type}] ${r.file}:${r.line} => ${r.content}`);
});
fs.writeFileSync(path.join(projectRoot, 'web/utils/untranslated-chinese.txt'), results.map(r => `[${r.type}] ${r.file}:${r.line} => ${r.content}`).join('\n'), 'utf-8');
console.log('详细结果已写入 web/utils/untranslated-chinese.txt');
}
}
main();
web/utils/i18next-extract-special.js
// npm run i18n-extract-special
const fs = require('fs');
const path = require('path');
// 项目根目录
const projectRoot = path.resolve(__dirname, '../../');
const webDir = path.join(projectRoot, 'web');
const localesDir = path.join(projectRoot, 'web/locales');
// 获取所有语言文件
function getLocaleFiles() {
const files = fs.readdirSync(localesDir)
.filter(file => file.endsWith('.json'))
.map(file => ({
name: file.replace('.json', ''),
path: path.join(localesDir, file)
}));
return files;
}
function walk(dir, exts, filelist = []) {
fs.readdirSync(dir).forEach(file => {
const filepath = path.join(dir, file);
if (fs.statSync(filepath).isDirectory()) {
walk(filepath, exts, filelist);
} else if (exts.some(ext => filepath.endsWith(ext))) {
filelist.push(filepath);
}
});
return filelist;
}
// 检查是否是有效的翻译键
function isValidTranslationKey(key) {
// 排除纯英文字符串(变量名、函数名、路径等)
if (/^[a-zA-Z0-9_-./#@]+$/.test(key)) return false;
// 排除空字符串或只包含标点符号的字符串
if (/^[\s.,;:!?"'`[]{}()+-*/\=<>|~@#$%^&*]+$/.test(key)) return false;
// 排除日期时间格式
if (/^[YMDHms-:/ ]+$/.test(key)) return false;
// 必须包含至少一个中文字符
if (!/[\u4e00-\u9fa5]/.test(key)) return false;
return true;
}
function extractI18nKeys(content) {
const keys = new Set();
let match;
// 匹配所有可能的i18n调用模式
const patterns = [
/(?:$i18next.)?t(['"]([^'"]+)['"])/g, // 基本的t()调用
/ref((?:$i18next.)?t(['"]([^'"]+)['"]))/g, // ref包装的调用
/proxy$i18next.t(['"]([^'"]+)['"])/g, // proxy.$i18next.t()调用
/proxy.$i18next.t(['"]([^'"]+)['"])/g // proxy.$i18next.t()调用(带点)
];
for (const pattern of patterns) {
while ((match = pattern.exec(content)) !== null) {
const key = match[1];
console.log('Found match:', key);
if (isValidTranslationKey(key)) {
console.log('Adding valid key:', key);
keys.add({ key, defaultValue: key });
}
}
}
return Array.from(keys);
}
function processFile(filepath) {
console.log('\nProcessing file:', filepath);
const content = fs.readFileSync(filepath, 'utf-8');
const keys = extractI18nKeys(content);
console.log('Found keys:', Array.from(keys));
return keys;
}
function mergeLocales(i18nKeys) {
const localeFiles = getLocaleFiles();
const locales = {};
// 读取所有现有的语言文件
localeFiles.forEach(({name, path: filePath}) => {
try {
locales[name] = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch (e) {
console.log(`Warning: Could not read locale file ${name}`);
locales[name] = {};
}
});
// 合并新的键值
i18nKeys.forEach(({ key, defaultValue }) => {
// 对于中文(zh_CN),设置键和值
if (!locales['zh_CN'][key]) {
locales['zh_CN'][key] = defaultValue;
}
// 对于其他语言,只设置键,值为空字符串
localeFiles.forEach(({name}) => {
if (name !== 'zh_CN' && !locales[name][key]) {
locales[name][key] = '';
}
});
});
// 写回所有语言文件
localeFiles.forEach(({name, path: filePath}) => {
fs.writeFileSync(filePath, JSON.stringify(locales[name], null, 2), 'utf-8');
console.log(`Updated ${filePath}`);
});
console.log(`Total keys in zh_CN: ${Object.keys(locales['zh_CN']).length}`);
}
function main() {
// 只获取 TS 文件
// const files = walk(webDir, ['.ts']);
const files = walk(webDir, ['.ts', '.vue']);
console.log(`Found ${files.length} files to process`);
// 处理所有文件并收集i18n键值
const allI18nKeys = files.reduce((acc, filepath) => {
const keys = processFile(filepath);
return acc.concat(keys);
}, []);
console.log(`Found ${allI18nKeys.length} i18n keys`);
// 合并到语言文件
mergeLocales(allI18nKeys);
}
main();
web/utils/i18next-wrap-only.js
// npm run i18n-wrap-only
const fs = require('fs');
const path = require('path');
// 项目根目录
const projectRoot = path.resolve(__dirname, '../../');
const vueDir = path.join(projectRoot, 'web/pages');
function walk(dir, ext, filelist = []) {
fs.readdirSync(dir).forEach(file => {
const filepath = path.join(dir, file);
if (fs.statSync(filepath).isDirectory()) {
walk(filepath, ext, filelist);
} else if (filepath.endsWith(ext)) {
filelist.push(filepath);
}
});
return filelist;
}
function extractChinese(text) { // 匹配中文
const reg = /[\u4e00-\u9fa5\u3002\uff1f\uff01\uff0c\u3001\uff1b\uff1a\u201c\u201d\u2018\u2019\u300a\u300b\u300e\u300f\u2014\u2026\u2013\u3010\u3011]/g;
return text.match(reg) || [];
}
const I18N_VAR = '$i18next';
function processVueFile(filepath) {
let content = fs.readFileSync(filepath, 'utf-8');
content = content.replace(/(<script[\s\S]*?>)([\s\S]*?)(</script>)/g, (match, scriptStart, scriptBody, scriptEnd) => { // <script> 部分
// 如果是 infoItem.vue,跳过 accountItems/computed 里 title 字段的自动包裹
if (/web[/]pages[/]accountDetection[/]components[/]infoItem[/]infoItem.vue$/.test(filepath)) {
// 只处理其它字段,跳过 title 字段
scriptBody = scriptBody.replace(/([\s{,])(message|desc|label|content|buttonText|placeholder|tips|tip|error|success|fail|msg)\s*:\s*'([^'\n]*[\u4e00-\u9fa5][^'\n]*)'/g, (m, prefix, key, val) => {
if (/proxy.$i18next.t|proxy.$t(/.test(val)) return m;
const merged = val.replace(/[\r\n]/g, '').replace(/\s/g, ' ').trim();
return `${prefix}${key}: proxy.$i18next.t('${merged}')`;
});
// 其它所有字符串如常处理
scriptBody = scriptBody.replace(/(['"])([^'"\n]*[\u4e00-\u9fa5][^'"\n]*)\1/g, (m, quote, val) => {
if (/proxy.$i18next.t|proxy.$t(/.test(val)) return m;
const merged = val.replace(/[\r\n]/g, '').replace(/\s/g, ' ').trim();
return `proxy.$i18next.t('${merged}')`;
});
return scriptStart + scriptBody + scriptEnd;
}
// 普通文件逻辑
scriptBody = scriptBody.replace(/([\s{,])(message|title|desc|label|content|buttonText|placeholder|tips|tip|error|success|fail|msg)\s*:\s*'([^'\n]*[\u4e00-\u9fa5][^'\n]*)'/g, (m, prefix, key, val) => {
if (/proxy.$i18next.t|proxy.$t(/.test(val)) return m;
const merged = val.replace(/[\r\n]/g, '').replace(/\s/g, ' ').trim();
return `${prefix}${key}: proxy.$i18next.t('${merged}')`;
});
scriptBody = scriptBody.replace(/(['"])([^'"\n]*[\u4e00-\u9fa5][^'"\n]*)\1/g, (m, quote, val) => { // 匹配所有 '中文' 或 "中文" 字符串
if (/proxy.$i18next.t|proxy.$t(/.test(val)) return m;
const merged = val.replace(/[\r\n]/g, '').replace(/\s/g, ' ').trim();
return `proxy.$i18next.t('${merged}')`;
});
return scriptStart + scriptBody + scriptEnd;
});
// <template> 部分
const templateMatch = content.match(/<template>([\s\S]*?)</template>/);
if (!templateMatch) return;
// eslint-disable-next-line
let template = templateMatch[1];
const comments = [];
let templateNoComments = template.replace(/<!--([\s\S]*?)-->/g, match => { // 提取注释,暂存
comments.push(match);
return `__COMMENT_PLACEHOLDER_${comments.length - 1}__`;
});
// 属性处理::title="$i18next.t('中文") -> :title="$i18next.t('中文')"
templateNoComments = templateNoComments.replace(
/(\s)([a-zA-Z0-9-_]+)="([^"\n]*[\u4e00-\u9fa5][^"\n]*)"/g,
(match, space, attr, val) => {
if (new RegExp(`[:@]${attr}=|v-bind:${attr}=`).test(match)) return match; // :attr= 或 v-bind:,跳过
if (/$i18next.t|$t(/.test(val)) return match; // 已是 $i18next.t/$t,跳过
return `${space}:${attr}="${I18N_VAR}.t('${val}')"`; // 动态绑定
}
);
// 去除 template 中 $i18next.t 嵌套(如 {{ $i18next.t('加载中') }})
templateNoComments = templateNoComments.replace(/{{\s*$i18next.t((proxy.|)$i18next.t('([^']+)'))\s*}}/g, (match, _proxy, zh) => `{{ $i18next.t('${zh}') }}`);
// 标签体中文整体贪婪替换(不拆分)
// 跳过已包裹的 $i18next.t/$t
templateNoComments = templateNoComments.replace(
/>([^<>]*[\u4e00-\u9fa5][^<>]*)</g,
(match, val) => {
if (/$i18next.t|$t(/.test(val)) return match; // 跳过已包裹
const merged = val.replace(/[\r\n]/g, '').replace(/\s/g, ' ').trim(); // 多行内容合并为一行
return `>{{$i18next.t('${merged}')}}<`;
}
);
// 恢复注释
templateNoComments = templateNoComments.replace(/__COMMENT_PLACEHOLDER_(\d+)__/g, (_, idx) => comments[idx]);
// 替换原模板
content = content.replace(/<template>[\s\S]*?</template>/, `<template>${templateNoComments}</template>`);
if (content !== fs.readFileSync(filepath, 'utf-8')) {
fs.writeFileSync(filepath, content, 'utf-8');
console.log(`Processed: ${filepath}`);
}
}
function main() {
console.log('Starting to wrap Chinese text with $i18next.t()...');
const vueFiles = walk(vueDir, '.vue');
vueFiles.forEach(file => processVueFile(file));
console.log('All done');
}
main();
模版编写
template
<div class="img_box" :class="{ img_other: content.img && content.img.alt === $i18next.t('测试') }">
<img v-if="content.img" :src="content.img.src" :alt="content.img.alt">
</div>
<p class="title">{{ $i18next.t('昵称') }}</p>
<span>{{ expandRowKeys.includes(total.key) ? $i18next.t('收起') : $i18next.t('详情') }}</span>
<Components
:title="$i18next.t('测试title')"
:desc="$i18next.t('测试desc')"
></Components>
script
import { computed, defineAsyncComponent, defineProps, getCurrentInstance, ref } from 'vue';
const { proxy } = getCurrentInstance();
const titleMap = {
[GuideType.test1]: proxy.$i18next.t('测试1'),
[GuideType.test2]: proxy.$i18next.t('测试2'),
};
const testBtn = () => {
proxy.$tips.success(proxy.$i18next.t('上传成功'), { duration: 800, offset: 20 });
proxy.$tips.error(proxy.$i18next.t('提交失败'), { duration: 800, offset: 20 });
}
constant.ts
import i18next from 'i18next';
const t = i18next.t;
export const ERR_MSG = {
10000008: t('参数异常'),
10000009: t('测试'),
} as any;