构建时实现多语言翻译

This commit is contained in:
jingrow 2025-12-29 02:05:40 +08:00
parent 738225a28d
commit 62cda250ae
3 changed files with 194 additions and 43 deletions

View File

@ -1,50 +1,12 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { jingrowRequest } from 'jingrow-ui';
// 从构建时配置获取语言,默认为英文 // 从构建时配置获取语言,默认为英文
const buildLocale = import.meta.env.DASHBOARD_LOCALE || 'en'; const buildLocale = import.meta.env.DASHBOARD_LOCALE || 'en';
// 当前语言(从构建配置获取,不可切换) // 当前语言(从构建配置获取,不可切换)
const currentLocale = ref(buildLocale); const currentLocale = ref(buildLocale);
// 翻译数据:构建时 Vite 插件已直接替换静态文本,这里只处理动态翻译
const translations = ref({}); const translations = ref({});
const isLoading = ref(false);
const initPromise = ref(null);
/**
* 从后端加载翻译
* @param {string} locale - 语言代码默认 'en'
*/
export async function loadTranslations(locale = 'en') {
// 如果正在加载或已加载相同语言,直接返回
if (isLoading.value) {
return initPromise.value;
}
if (currentLocale.value === locale && Object.keys(translations.value).length > 0) {
return Promise.resolve();
}
isLoading.value = true;
const promise = jingrowRequest({
url: '/api/action/jingrow.translate.get_app_translations',
method: 'GET'
})
.then((response) => {
translations.value = response || {};
currentLocale.value = locale;
})
.catch((error) => {
console.error('Failed to load translations:', error);
translations.value = {};
})
.finally(() => {
isLoading.value = false;
});
initPromise.value = promise;
return promise;
}
/** /**
* 翻译函数 * 翻译函数
@ -83,10 +45,9 @@ export function getLocale() {
*/ */
export async function initI18n() { export async function initI18n() {
const locale = buildLocale.split('-')[0] || 'en'; // 处理 'en-US' -> 'en' const locale = buildLocale.split('-')[0] || 'en'; // 处理 'en-US' -> 'en'
await loadTranslations(locale);
document.documentElement.lang = locale; document.documentElement.lang = locale;
} }
// 导出响应式状态(供 composable 使用) // 导出响应式状态(供 composable 使用)
export { currentLocale, isLoading }; export { currentLocale };

View File

@ -0,0 +1,188 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Vite 插件构建时直接读取 CSV替换代码中的 t('xxx') 为翻译文本
* 无需生成任何中间文件直接从 CSV 读取并替换
* @param {object} options - 插件选项
* @param {string} options.locale - 目标语言代码 vite.config.ts 传递
*/
function vitePluginTranslate(options = {}) {
let translations = {};
let locale = options.locale || 'en';
return {
name: 'vite-plugin-translate',
enforce: 'pre', // 在 Vue 插件之前执行,处理原始模板
configResolved(config) {
// 直接读取 CSV 翻译文件(与后端共享同一数据源)
if (locale && locale !== 'en') {
const csvPath = path.resolve(__dirname, `../jcloud/translations/${locale}.csv`);
if (fs.existsSync(csvPath)) {
translations = parseCSV(csvPath);
console.log(`[translate] Loaded ${Object.keys(translations).length} translations from ${csvPath}`);
} else {
console.warn(`[translate] CSV file not found: ${csvPath}, translations will not be replaced`);
}
}
},
transform(code, id) {
// 只处理 .vue, .js, .ts 文件
if (!/\.(vue|js|ts|jsx|tsx)$/.test(id)) {
return null;
}
// 如果是英文,不替换
if (locale === 'en') {
return null;
}
// 如果翻译为空,警告但不阻止(可能 CSV 文件不存在,使用原文)
if (Object.keys(translations).length === 0) {
console.warn(`[translate] No translations loaded for locale: ${locale}, skipping replacement`);
return null;
}
let replaced = code;
const isVueFile = id.endsWith('.vue');
if (isVueFile) {
// 分离 <template> 和 <script> 部分,分别处理
replaced = replaced.replace(
/(<template[^>]*>)([\s\S]*?)(<\/template>)/gi,
(templateMatch, openTag, templateContent, closeTag) => {
// 在模板中替换 {{ $t('xxx') }} 为静态文本
const replacedContent = templateContent.replace(
/\{\{\s*\$t\((['"])([^'"]+)\1\)\s*\}\}/g,
(match, quote, key) => {
const translation = translations[key];
if (translation) {
return translation;
}
return match;
}
);
return openTag + replacedContent + closeTag;
}
);
// 替换 <script> 中的 this.$t('xxx') 为 this.$t('翻译')
replaced = replaced.replace(
/(<script[^>]*>)([\s\S]*?)(<\/script>)/gi,
(scriptMatch, openTag, scriptContent, closeTag) => {
// 在脚本中替换 $t('xxx') 为 $t('翻译')
const replacedContent = scriptContent.replace(
/\$t\((['"])([^'"]+)\1\)/g,
(match, quote, key) => {
const translation = translations[key];
if (translation) {
const escaped = translation
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"');
return `$t(${quote}${escaped}${quote})`;
}
return match;
}
);
return openTag + replacedContent + closeTag;
}
);
} else {
// 非 Vue 文件:替换 t('xxx') 为 '翻译'
replaced = replaced.replace(
/\bt\((['"])([^'"]+)\1\)/g,
(match, quote, key) => {
const translation = translations[key];
if (translation) {
const escaped = translation
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"');
return quote + escaped + quote;
}
return match;
}
);
}
if (replaced !== code) {
return {
code: replaced,
map: null
};
}
return null;
}
};
}
/**
* 解析 CSV 文件
*/
function parseCSV(csvPath) {
if (!fs.existsSync(csvPath)) {
return {};
}
const content = fs.readFileSync(csvPath, 'utf-8');
const lines = content.split('\n');
const translations = {};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const parts = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < trimmed.length; i++) {
const char = trimmed[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
parts.push(current);
current = '';
} else {
current += char;
}
}
parts.push(current);
if (parts.length >= 2 && parts[0] && parts[1]) {
const key = parts[0].trim();
const translation = parts[1].trim();
const context = parts[2] ? parts[2].trim() : '';
if (context) {
translations[`${key}:${context}`] = translation;
} else {
translations[key] = translation;
}
}
}
return translations;
}
/**
* HTML 转义函数
*/
function escapeHtml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export default vitePluginTranslate;

View File

@ -8,15 +8,17 @@ import Components from 'unplugin-vue-components/vite';
import Icons from 'unplugin-icons/vite'; import Icons from 'unplugin-icons/vite';
import IconsResolver from 'unplugin-icons/resolver'; import IconsResolver from 'unplugin-icons/resolver';
import { sentryVitePlugin } from '@sentry/vite-plugin'; import { sentryVitePlugin } from '@sentry/vite-plugin';
import vitePluginTranslate from './vite-plugin-translate.mjs';
// 语言配置:通过环境变量 DASHBOARD_LOCALE 设置,默认为 'en' // 语言配置:设置目标语言,默认为 'en'(英文),可设置为 'zh'(中文)等
const locale = process.env.DASHBOARD_LOCALE || 'en'; const locale = process.env.DASHBOARD_LOCALE || 'zh';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
vueJsx(), vueJsx(),
pluginRewriteAll(), pluginRewriteAll(),
vitePluginTranslate({ locale }), // 传递语言配置
jingrowui(), jingrowui(),
Components({ Components({
dirs: [ dirs: [