From 50bb70f80b985c7fe4cc81a4bc790105bb05c5f3 Mon Sep 17 00:00:00 2001 From: jingrow Date: Mon, 29 Dec 2025 03:51:14 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dvite=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E4=B8=8D=E6=94=AF=E6=8C=81=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dashboard/vite-plugin-translate.mjs | 194 +++++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 7 deletions(-) diff --git a/dashboard/vite-plugin-translate.mjs b/dashboard/vite-plugin-translate.mjs index c9dd00a..3171d76 100644 --- a/dashboard/vite-plugin-translate.mjs +++ b/dashboard/vite-plugin-translate.mjs @@ -129,26 +129,175 @@ function processVueSFC(code, translations) { } /** - * 替换插值表达式 {{ $t('xxx') }} + * 替换插值表达式 {{ $t('xxx') }} 或 {{ $t('xxx', { ... }) }} */ function replaceInterpolations(code, translations) { - return code.replace( + // 先处理不带参数的调用:{{ $t('xxx') }} + code = code.replace( /\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*\)\s*\}\}/g, (match, quote, key) => translations[key] || match ); + + // 处理带参数的调用:{{ $t('key', { params }) }} + // 使用更健壮的方法匹配平衡的括号 + const matches = []; + const pattern = /\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*,\s*/g; + let match; + + // 先收集所有匹配位置(从后往前处理,避免索引错乱) + while ((match = pattern.exec(code)) !== null) { + const startIndex = match.index; + const quote = match[1]; + const key = match[2]; + const afterKeyIndex = match.index + match[0].length; + + // 找到参数部分的结束位置(匹配平衡的括号) + let depth = 1; // 已经有一个开括号 + let pos = afterKeyIndex; + let paramsEnd = -1; + + while (pos < code.length && depth > 0) { + const char = code[pos]; + if (char === '(') depth++; + else if (char === ')') { + depth--; + if (depth === 0) { + paramsEnd = pos; + break; + } + } + pos++; + } + + // 找到整个 {{ $t(...) }} 的结束位置 + let fullEnd = paramsEnd + 1; + // 跳过空格 + while (fullEnd < code.length && code[fullEnd] === ' ') { + fullEnd++; + } + // 跳过 }} + if (fullEnd < code.length && code[fullEnd] === '}') { + fullEnd++; + if (fullEnd < code.length && code[fullEnd] === '}') { + fullEnd++; + } + } + + if (paramsEnd !== -1) { + matches.push({ + startIndex, + quote, + key, + afterKeyIndex, + paramsEnd, + fullEnd + }); + } + } + + // 从后往前替换,避免索引错乱 + for (let i = matches.length - 1; i >= 0; i--) { + const m = matches[i]; + let paramsPart = code.substring(m.afterKeyIndex, m.paramsEnd); + + // 先处理参数部分中的 $t() 调用(递归处理嵌套的翻译) + paramsPart = paramsPart.replace( + /\{\{\s*[\$]t\s*\(\s*(['"])([^'"]+)\1\s*\)\s*\}\}/g, + (match, quote, key) => { + const translation = translations[key]; + return translation || match; + } + ); + + const translation = translations[m.key]; + if (translation) { + // 替换键为翻译文本,保留参数部分让运行时处理 + const replacement = `{{ $t(${m.quote}${escapeString(translation, m.quote)}${m.quote}, ${paramsPart}) }}`; + code = code.substring(0, m.startIndex) + replacement + code.substring(m.fullEnd); + } + } + + return code; } /** - * 替换 Vue 编译后的格式:_ctx.$t('xxx') 或 $t('xxx') + * 替换 Vue 编译后的格式:_ctx.$t('xxx') 或 $t('xxx'),支持带参数 */ function replaceCompiledFormat(code, translations) { - return code.replace( - /(_ctx\.)?[\$]t\((['"])([^'"]+)\2\)/g, + // 先处理带参数的调用:_ctx.$t('key', params) 或 $t('key', params) 或 t.$t('key', params) + const matches = []; + const pattern = /([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\s*,\s*/g; + let match; + + // 收集所有带参数的调用 + while ((match = pattern.exec(code)) !== null) { + const startIndex = match.index; + const ctxPrefix = match[1] || ''; + const quote = match[2]; + const key = match[3]; + const afterKeyIndex = match.index + match[0].length; + + // 找到参数部分的结束位置(匹配平衡的括号) + let depth = 1; + let pos = afterKeyIndex; + let paramsEnd = -1; + + while (pos < code.length && depth > 0) { + const char = code[pos]; + if (char === '(') depth++; + else if (char === ')') { + depth--; + if (depth === 0) { + paramsEnd = pos; + break; + } + } + pos++; + } + + if (paramsEnd !== -1) { + matches.push({ + startIndex, + ctxPrefix, + quote, + key, + afterKeyIndex, + paramsEnd + }); + } + } + + // 从后往前替换 + for (let i = matches.length - 1; i >= 0; i--) { + const m = matches[i]; + let paramsPart = code.substring(m.afterKeyIndex, m.paramsEnd); + + // 先处理参数部分中的 $t() 调用(递归处理嵌套的翻译) + paramsPart = paramsPart.replace( + /([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\)/g, + (match, ctxPrefix, quote, key) => { + const translation = translations[key]; + return translation ? `${ctxPrefix || ''}$t(${quote}${escapeString(translation, quote)}${quote})` : match; + } + ); + + const translation = translations[m.key]; + if (translation) { + const replacement = `${m.ctxPrefix}$t(${m.quote}${escapeString(translation, m.quote)}${m.quote}, ${paramsPart})`; + code = code.substring(0, m.startIndex) + replacement + code.substring(m.paramsEnd + 1); + } + } + + // 处理不带参数的调用:_ctx.$t('xxx') 或 $t('xxx') 或 t.$t('xxx') + code = code.replace( + /([a-zA-Z_$][a-zA-Z0-9_$]*\.)?[\$]t\((['"])([^'"]+)\2\)/g, (match, ctxPrefix, quote, key) => { const translation = translations[key]; - return translation ? quote + escapeString(translation, quote) + quote : match; + return translation ? `${ctxPrefix || ''}$t(${quote}${escapeString(translation, quote)}${quote})` : match; } ); + + return code; } /** @@ -186,6 +335,9 @@ function escapeString(text, quote) { /** * 解析 CSV 文件 + * 格式:key,translation,context + * 注意:如果 key 或 translation 包含逗号,需要用引号括起来 + * 但为了兼容性,也支持没有引号的情况(通过限制分割次数) */ function parseCSV(csvPath) { if (!fs.existsSync(csvPath)) { @@ -200,6 +352,7 @@ function parseCSV(csvPath) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; + // 先尝试用引号解析(标准 CSV 格式) const parts = []; let current = ''; let inQuotes = false; @@ -207,7 +360,13 @@ function parseCSV(csvPath) { for (let i = 0; i < trimmed.length; i++) { const char = trimmed[i]; if (char === '"') { - inQuotes = !inQuotes; + // 处理转义的引号 "" + if (i + 1 < trimmed.length && trimmed[i + 1] === '"') { + current += '"'; + i++; // 跳过下一个引号 + } else { + inQuotes = !inQuotes; + } } else if (char === ',' && !inQuotes) { parts.push(current); current = ''; @@ -217,6 +376,27 @@ function parseCSV(csvPath) { } parts.push(current); + // 如果解析出的部分超过2个,可能是键中包含逗号但没有引号 + // 尝试从右边开始合并,找到 translation(通常包含非 ASCII 字符) + if (parts.length > 2) { + // 从右边开始,找到第一个看起来像翻译的部分(包含非 ASCII 字符) + let translationIndex = -1; + for (let i = parts.length - 1; i >= 0; i--) { + if (parts[i] && /[^\x00-\x7F]/.test(parts[i])) { + translationIndex = i; + break; + } + } + + if (translationIndex > 0) { + // 合并 translationIndex 之前的所有部分作为 key + const key = parts.slice(0, translationIndex).join(',').trim(); + const translation = parts[translationIndex].trim(); + translations[key] = translation; + continue; + } + } + if (parts.length >= 2 && parts[0] && parts[1]) { const key = parts[0].trim(); const translation = parts[1].trim();