构建时实现多语言翻译
This commit is contained in:
parent
738225a28d
commit
62cda250ae
@ -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 };
|
||||||
|
|
||||||
|
|||||||
188
dashboard/vite-plugin-translate.mjs
Normal file
188
dashboard/vite-plugin-translate.mjs
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default vitePluginTranslate;
|
||||||
|
|
||||||
@ -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: [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user