mirror of
https://gitee.com/mirrors/AllinSSL.git
synced 2026-03-11 17:20:10 +08:00
【新增】插件git同步模块,用于同步项目内容,加速项目开发
【调整】前端暗色问题
This commit is contained in:
131
frontend/plugin/vite-plugin-i18n/src/cache/index.js
vendored
Normal file
131
frontend/plugin/vite-plugin-i18n/src/cache/index.js
vendored
Normal file
@@ -0,0 +1,131 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { Utils } from '../utils/index.js'
|
||||
|
||||
export class CacheManager {
|
||||
constructor(cachePath) {
|
||||
this.cachePath = cachePath
|
||||
this.cache = new Map() // 缓存
|
||||
this.dirty = false // 是否需要保存缓存
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化缓存
|
||||
*/
|
||||
async initCache() {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(this.cachePath), { recursive: true })
|
||||
if (await this.fileExists(this.cachePath)) {
|
||||
const data = await fs.readFile(this.cachePath, 'utf8')
|
||||
const cacheData = JSON.parse(data)
|
||||
this.cache = new Map(Object.entries(cacheData))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化缓存失败:', error)
|
||||
this.cache = new Map()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的翻译
|
||||
* @param {string[]} texts - 待检查的中文内容列表
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
* @returns {Promise<{cached: Object, uncached: string[]}>}
|
||||
*/
|
||||
async getCachedTranslations(texts, languages) {
|
||||
const cached = {}
|
||||
const uncached = []
|
||||
|
||||
texts.forEach((text) => {
|
||||
const cachedItem = this.cache.get(text)
|
||||
// 检查缓存项是否存在且有效
|
||||
if (cachedItem && this.isValidCacheItem(cachedItem, languages)) {
|
||||
cached[text] = cachedItem
|
||||
} else {
|
||||
uncached.push(text)
|
||||
}
|
||||
})
|
||||
|
||||
return { cached, uncached }
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新缓存
|
||||
* @param {string[]} texts - 中文内容列表
|
||||
* @param {Object[]} translations - 翻译结果列表
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
*/
|
||||
async updateCache(texts, translations) {
|
||||
translations.forEach((translation, index) => {
|
||||
const text = texts[index]
|
||||
this.cache.set(text, {
|
||||
text,
|
||||
key: translation.key,
|
||||
translations: Utils.formatTranslations(translation.translations),
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
this.dirty = true
|
||||
await this.saveCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存
|
||||
* @param {string[]} validTexts - 有效的中文内容列表
|
||||
*/
|
||||
async cleanCache(validTexts) {
|
||||
const validTextSet = new Set(validTexts)
|
||||
for (const [text] of this.cache) {
|
||||
if (!validTextSet.has(text)) {
|
||||
this.cache.delete(text)
|
||||
this.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
if (this.dirty) {
|
||||
await this.saveCache()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存缓存
|
||||
*/
|
||||
async saveCache() {
|
||||
if (!this.dirty) return
|
||||
|
||||
try {
|
||||
const cacheData = Object.fromEntries(this.cache)
|
||||
await fs.writeFile(this.cachePath, JSON.stringify(cacheData, null, 2))
|
||||
this.dirty = false
|
||||
} catch (error) {
|
||||
console.error('保存缓存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存项是否有效
|
||||
* @param {Object} cacheItem - 缓存项
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isValidCacheItem(cacheItem, languages) {
|
||||
return cacheItem && cacheItem.translations && languages.every((lang) => cacheItem.translations[lang])
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async fileExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default CacheManager
|
||||
@@ -0,0 +1,207 @@
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* 未使用翻译检测器
|
||||
* 负责检测和移除未使用的翻译内容
|
||||
*/
|
||||
export class UnusedTranslationDetector {
|
||||
/**
|
||||
* @param {Object} fileOperation - 文件操作实例
|
||||
* @param {Object} cacheManager - 缓存管理实例
|
||||
*/
|
||||
constructor(fileOperation, cacheManager) {
|
||||
this.fileOperation = fileOperation
|
||||
this.cacheManager = cacheManager
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描项目中实际使用的翻译键
|
||||
* @param {string[]} files - 要扫描的文件列表
|
||||
* @param {RegExp} keyUsageRegex - 匹配翻译键使用的正则表达式
|
||||
* @returns {Promise<Set<string>>} - 项目中使用的翻译键集合
|
||||
*/
|
||||
async scanUsedTranslationKeys(files, keyUsageRegex) {
|
||||
const usedKeys = new Set()
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await this.fileOperation.readFile(file)
|
||||
// 重置正则表达式的lastIndex,确保从头开始匹配
|
||||
keyUsageRegex.lastIndex = 0
|
||||
let match
|
||||
while ((match = keyUsageRegex.exec(content)) !== null) {
|
||||
if (match[1]) {
|
||||
usedKeys.add(match[1].trim())
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[i18n插件] 扫描文件 ${file} 中使用的翻译键失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return usedKeys
|
||||
}
|
||||
|
||||
/**
|
||||
* 从翻译文件中加载所有翻译键
|
||||
* @param {string} translationDir - 翻译文件目录
|
||||
* @param {string[]} languages - 语言列表
|
||||
* @returns {Promise<Map<string, Object>>} - 键到翻译对象的映射
|
||||
*/
|
||||
async loadAllTranslations(translationDir, languages) {
|
||||
const allTranslations = new Map()
|
||||
|
||||
for (const language of languages) {
|
||||
const filePath = path.join(translationDir, `${language}.json`)
|
||||
|
||||
try {
|
||||
if (await this.fileOperation.fileExists(filePath)) {
|
||||
const content = await this.fileOperation.readFile(filePath)
|
||||
const translations = JSON.parse(content)
|
||||
|
||||
// 将每个键加入到总映射中
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
if (!allTranslations.has(key)) {
|
||||
allTranslations.set(key, { key, translations: {} })
|
||||
}
|
||||
|
||||
const translationObj = allTranslations.get(key)
|
||||
translationObj.translations[language] = value
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[i18n插件] 加载翻译文件 ${filePath} 失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return allTranslations
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测未使用的翻译
|
||||
* @param {Set<string>} usedKeys - 使用的翻译键集合
|
||||
* @param {Map<string, Object>} allTranslations - 所有翻译
|
||||
* @returns {Set<string>} - 未使用的翻译键集合
|
||||
*/
|
||||
detectUnusedTranslations(usedKeys, allTranslations) {
|
||||
const unusedKeys = new Set()
|
||||
|
||||
for (const [key] of allTranslations.entries()) {
|
||||
if (!usedKeys.has(key)) {
|
||||
unusedKeys.add(key)
|
||||
}
|
||||
}
|
||||
|
||||
return unusedKeys
|
||||
}
|
||||
|
||||
/**
|
||||
* 从翻译文件中移除未使用的翻译
|
||||
* @param {Set<string>} unusedKeys - 未使用的翻译键集合
|
||||
* @param {string} translationDir - 翻译文件目录
|
||||
* @param {string[]} languages - 语言列表
|
||||
* @returns {Promise<number>} - 移除的翻译数量
|
||||
*/
|
||||
async removeUnusedTranslations(unusedKeys, translationDir, languages) {
|
||||
let removedCount = 0
|
||||
|
||||
for (const language of languages) {
|
||||
const filePath = path.join(translationDir, `${language}.json`)
|
||||
|
||||
try {
|
||||
if (await this.fileOperation.fileExists(filePath)) {
|
||||
const content = await this.fileOperation.readFile(filePath)
|
||||
const translations = JSON.parse(content)
|
||||
let hasChanges = false
|
||||
|
||||
// 移除未使用的翻译
|
||||
for (const key of unusedKeys) {
|
||||
if (key in translations) {
|
||||
delete translations[key]
|
||||
hasChanges = true
|
||||
|
||||
if (language === languages[0]) {
|
||||
// 只在处理第一种语言时计数,避免重复计数
|
||||
removedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有变更,更新文件
|
||||
if (hasChanges) {
|
||||
await this.fileOperation.modifyFile(filePath, JSON.stringify(translations, null, 2))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[i18n插件] 更新翻译文件 ${filePath} 失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return removedCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 从缓存中移除未使用的翻译
|
||||
* @param {Map<string, Object>} allTranslations - 所有翻译
|
||||
* @param {Set<string>} unusedKeys - 未使用的翻译键集合
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async removeUnusedFromCache(allTranslations, unusedKeys) {
|
||||
// 构建需要保留的中文文本列表
|
||||
const validTexts = []
|
||||
|
||||
for (const [key, translationObj] of allTranslations.entries()) {
|
||||
if (!unusedKeys.has(key)) {
|
||||
// 如果有原始中文文本,添加到有效列表中
|
||||
if (translationObj.text) {
|
||||
validTexts.push(translationObj.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理缓存
|
||||
await this.cacheManager.cleanCache(validTexts)
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行未使用翻译检查和清理
|
||||
* @param {Object} config - 配置对象
|
||||
* @param {string[]} files - 要扫描的文件列表
|
||||
* @returns {Promise<{removedCount: number}>} - 清理结果
|
||||
*/
|
||||
async cleanUnusedTranslations(config, files) {
|
||||
console.log(`[i18n插件] 开始检测未使用的翻译...`)
|
||||
|
||||
// 创建匹配翻译键使用的正则表达式: $t('key') 或 $t("key")
|
||||
const keyUsageRegex = new RegExp(/\$t\(['"](.+?)['"]\)/g)
|
||||
|
||||
// 扫描使用的翻译键
|
||||
const usedKeys = await this.scanUsedTranslationKeys(files, keyUsageRegex)
|
||||
console.log(`[i18n插件] 扫描到 ${usedKeys.size} 个使用中的翻译键`)
|
||||
|
||||
// 加载所有翻译
|
||||
const translationDir = path.join(config.outputPath, 'model')
|
||||
const allTranslations = await this.loadAllTranslations(translationDir, config.languages)
|
||||
console.log(`[i18n插件] 加载了 ${allTranslations.size} 个翻译键`)
|
||||
|
||||
// 检测未使用的翻译
|
||||
const unusedKeys = this.detectUnusedTranslations(usedKeys, allTranslations)
|
||||
console.log(`[i18n插件] 检测到 ${unusedKeys.size} 个未使用的翻译键`)
|
||||
|
||||
if (unusedKeys.size === 0) {
|
||||
console.log(`[i18n插件] 没有发现未使用的翻译,无需清理`)
|
||||
return { removedCount: 0 }
|
||||
}
|
||||
|
||||
// 移除未使用的翻译
|
||||
const removedCount = await this.removeUnusedTranslations(unusedKeys, translationDir, config.languages)
|
||||
|
||||
// 从缓存中移除未使用的翻译
|
||||
await this.removeUnusedFromCache(allTranslations, unusedKeys)
|
||||
|
||||
console.log(`[i18n插件] 已从翻译文件和缓存中移除 ${removedCount} 个未使用的翻译`)
|
||||
|
||||
return { removedCount }
|
||||
}
|
||||
}
|
||||
|
||||
export default UnusedTranslationDetector
|
||||
84
frontend/plugin/vite-plugin-i18n/src/cli/cleanup.js
Normal file
84
frontend/plugin/vite-plugin-i18n/src/cli/cleanup.js
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { vitePluginI18nAiTranslate } from '../index.js'
|
||||
import { FileOperation } from '../fileOperation/index.js'
|
||||
import path from 'path'
|
||||
import minimist from 'minimist'
|
||||
|
||||
/**
|
||||
* CLI工具:清理未使用的翻译
|
||||
*
|
||||
* 使用方法:
|
||||
* node cleanup.js --config=<配置文件路径>
|
||||
*/
|
||||
async function cleanup() {
|
||||
try {
|
||||
// 解析命令行参数
|
||||
const argv = minimist(process.argv.slice(2))
|
||||
|
||||
// 显示帮助信息
|
||||
if (argv.help || argv.h) {
|
||||
console.log(`
|
||||
未使用翻译清理工具
|
||||
|
||||
选项:
|
||||
--config, -c 指定配置文件路径 (默认: ./i18n.config.js)
|
||||
--verbose, -v 显示详细日志
|
||||
--help, -h 显示帮助信息
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// 获取配置文件路径
|
||||
const configPath = argv.config || argv.c || './i18n.config.js'
|
||||
const verbose = argv.verbose || argv.v || false
|
||||
|
||||
console.log(`[i18n清理工具] 正在加载配置文件: ${configPath}`)
|
||||
|
||||
// 动态导入配置文件
|
||||
let config
|
||||
try {
|
||||
const configModule = await import(path.resolve(process.cwd(), configPath))
|
||||
config = configModule.default
|
||||
} catch (error) {
|
||||
console.error(`[i18n清理工具] 加载配置文件失败: ${error.message}`)
|
||||
console.log('[i18n清理工具] 使用默认配置...')
|
||||
// 使用默认配置
|
||||
config = {}
|
||||
}
|
||||
|
||||
console.log('[i18n清理工具] 初始化插件...')
|
||||
const plugin = vitePluginI18nAiTranslate(config)
|
||||
|
||||
// 确保初始化缓存
|
||||
await plugin.configResolved()
|
||||
|
||||
// 获取要扫描的文件
|
||||
const fileOperation = new FileOperation()
|
||||
const globFiles = config.fileExtensions?.map((ext) => `**/*${ext}`) || [
|
||||
'**/*.js',
|
||||
'**/*.jsx',
|
||||
'**/*.ts',
|
||||
'**/*.tsx',
|
||||
'**/*.vue',
|
||||
]
|
||||
|
||||
console.log(`[i18n清理工具] 扫描文件中...`)
|
||||
const files = await fileOperation.scanFiles(globFiles, config.projectPath || process.cwd())
|
||||
|
||||
if (verbose) {
|
||||
console.log(`[i18n清理工具] 找到 ${files.length} 个文件需要扫描`)
|
||||
}
|
||||
|
||||
console.log('[i18n清理工具] 开始检查和清理未使用的翻译...')
|
||||
const result = await plugin.cleanupUnusedTranslations(files)
|
||||
|
||||
console.log(`[i18n清理工具] 完成! 已移除 ${result.removedCount} 个未使用的翻译`)
|
||||
} catch (error) {
|
||||
console.error(`[i18n清理工具] 发生错误:`, error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行清理
|
||||
cleanup()
|
||||
24
frontend/plugin/vite-plugin-i18n/src/config/config.js
Normal file
24
frontend/plugin/vite-plugin-i18n/src/config/config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
export default {
|
||||
projectPath: './src',
|
||||
outputPath: './src/locales/',
|
||||
logPath: './logs',
|
||||
cachePath: './cache/translation_cache.json',
|
||||
apiKey: {
|
||||
zhipuAI: 'a160afdbea1644e68de5e5b014bea0f7.zZuSidvDSYOD7oJT',
|
||||
qianwenAI: 'sk-1b4f64a523814e33a6221bfccc676be6',
|
||||
api1: '',
|
||||
},
|
||||
languages: ['zhCN', 'zhTW', 'enUS', 'jaJP', 'koKR', 'ruRU', 'ptBR', 'frFR', 'esAR', 'arDZ'],
|
||||
concurrency: 100,
|
||||
exclude: ['node_modules', 'dist', 'build', 'locales', 'cache', 'logs'],
|
||||
templateRegex: '\\$t\\([\\\'"](?!t_)([^\\\'"]+)[\\\'"]',
|
||||
fileExtensions: ['.vue', '.js', '.ts', '.jsx', '.tsx'],
|
||||
interval: 5000,
|
||||
requestInterval: 100,
|
||||
maxRetries: 3,
|
||||
translateMethod: 'qianwenAI',
|
||||
cacheLifetime: 7,
|
||||
logRetention: 30,
|
||||
createFileExt: '.json',
|
||||
createEntryFileExt: '.ts',
|
||||
}
|
||||
128
frontend/plugin/vite-plugin-i18n/src/fileOperation/index.js
Normal file
128
frontend/plugin/vite-plugin-i18n/src/fileOperation/index.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import fastGlob from 'fast-glob'
|
||||
import config from '../config/config.js'
|
||||
|
||||
export class FileOperation {
|
||||
/**
|
||||
* 创建文件
|
||||
* @param {string} filePath - 文件路径
|
||||
* @param {string} content - 文件内容
|
||||
*/
|
||||
async createFile(filePath, content) {
|
||||
try {
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.writeFile(filePath, content)
|
||||
} catch (error) {
|
||||
throw new Error(`创建文件失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改文件
|
||||
* @param {string} filePath - 文件路径
|
||||
* @param {string} newContent - 新的文件内容
|
||||
*/
|
||||
async modifyFile(filePath, newContent) {
|
||||
try {
|
||||
await fs.writeFile(filePath, newContent)
|
||||
} catch (error) {
|
||||
throw new Error(`修改文件失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制文件
|
||||
* @param {string} sourcePath - 源文件路径
|
||||
* @param {string} destinationPath - 目标文件路径
|
||||
*/
|
||||
async copyFile(sourcePath, destinationPath) {
|
||||
try {
|
||||
const dir = path.dirname(destinationPath)
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await fs.copyFile(sourcePath, destinationPath)
|
||||
} catch (error) {
|
||||
throw new Error(`复制文件失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取文件
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Promise<string>} - 文件内容
|
||||
*/
|
||||
async readFile(filePath) {
|
||||
try {
|
||||
return await fs.readFile(filePath, 'utf8')
|
||||
} catch (error) {
|
||||
throw new Error(`读取文件失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建目录
|
||||
* @param {string} dirPath - 目录路径
|
||||
*/
|
||||
async createDirectory(dirPath) {
|
||||
try {
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
} catch (error) {
|
||||
throw new Error(`创建目录失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成翻译文件
|
||||
* @param {string} outputPath - 输出路径
|
||||
* @param {Object} translations - 翻译结果
|
||||
* @param {string} language - 目标语言
|
||||
*/
|
||||
async generateTranslationFile(outputPath, translations, language) {
|
||||
try {
|
||||
const filePath = path.join(outputPath, `${language}${config.createFileExt}`)
|
||||
const dir = path.dirname(filePath)
|
||||
await fs.mkdir(dir, { recursive: true }) // 确保目录存在
|
||||
let content = {}
|
||||
Object.assign(content, translations)
|
||||
content = `${JSON.stringify(content, null, 2)}`
|
||||
await fs.writeFile(filePath, content)
|
||||
} catch (error) {
|
||||
throw new Error(`生成翻译文件失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 glob 模式扫描文件内容
|
||||
* @param {string} pattern - glob 匹配模式
|
||||
* @param {string} basePath - 基础路径
|
||||
* @returns {Promise<Array<{path: string, content: string}>>} - 匹配文件的路径和内容
|
||||
*/
|
||||
async scanFiles(pattern, basePath = process.cwd()) {
|
||||
try {
|
||||
const files = await fastGlob(pattern, { cwd: basePath })
|
||||
const results = files.map((file) => {
|
||||
return path.join(basePath, file)
|
||||
})
|
||||
return results
|
||||
} catch (error) {
|
||||
throw new Error(`扫描文件失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async fileExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default FileOperation
|
||||
416
frontend/plugin/vite-plugin-i18n/src/index.js
Normal file
416
frontend/plugin/vite-plugin-i18n/src/index.js
Normal file
@@ -0,0 +1,416 @@
|
||||
import { CacheManager } from './cache/index.js'
|
||||
import { FileOperation } from './fileOperation/index.js'
|
||||
import { AIBatchAdapter } from './translation/adapter/aiBatchAdapter.js'
|
||||
import { TranslationState } from './stateManagement/index.js'
|
||||
import { Utils } from './utils/index.js'
|
||||
import { UnusedTranslationDetector } from './cleanUp/unusedTranslationDetector.js'
|
||||
import configFile from './config/config.js'
|
||||
import path from 'path'
|
||||
|
||||
/**
|
||||
* Vite i18n 自动翻译插件
|
||||
* @param {Object} options - 插件配置
|
||||
*/
|
||||
export function vitePluginI18nAiTranslate(options = {}) {
|
||||
const config = {
|
||||
...configFile,
|
||||
...options,
|
||||
templateRegex: new RegExp(configFile.templateRegex, 'g'), // Convert string to RegExp
|
||||
}
|
||||
|
||||
const cacheManager = new CacheManager(config.cachePath) // 缓存管理
|
||||
const fileOperation = new FileOperation() // 文件操作
|
||||
const translator = new AIBatchAdapter() // AI 批量翻译
|
||||
const translationState = new TranslationState() // 翻译状态管理
|
||||
const unusedDetector = new UnusedTranslationDetector(fileOperation, cacheManager) // 未使用翻译检测器
|
||||
|
||||
let watcher = null
|
||||
let outputDirCreated = false // 跟踪输出目录是否已创建
|
||||
let isProcessing = false // 跟踪是否正在进行批量处理
|
||||
|
||||
/**
|
||||
* 处理文件并提取中文文本
|
||||
* @param {string[]} files - 要处理的文件路径列表
|
||||
*/
|
||||
const processFiles = async (files) => {
|
||||
// 如果已经在处理中,则跳过
|
||||
if (isProcessing) {
|
||||
console.log(`[i18n插件] 已有处理正在进行中,跳过本次请求`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 设置处理标志
|
||||
isProcessing = true
|
||||
|
||||
console.log(`[i18n插件] 开始处理 ${files.length} 个文件...`)
|
||||
|
||||
// 第一步:扫描所有文件并提取中文文本
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await fileOperation.readFile(file) // 读取文件内容
|
||||
const chineseTexts = extractChineseTexts(content) // 提取中文文本
|
||||
// console.log(`[i18n插件] 提取 ${chineseTexts} 个中文文本`)
|
||||
translationState.recordFileProcessed(file, chineseTexts) // 记录处理的文件
|
||||
} catch (error) {
|
||||
console.error(`[i18n插件] 处理文件 ${file} 失败:`, error)
|
||||
}
|
||||
}
|
||||
// 第二步:对比缓存,确定需要翻译的内容
|
||||
await translateAndProcess()
|
||||
} finally {
|
||||
// 无论处理成功还是失败,都重置处理标志
|
||||
isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译文本并处理结果
|
||||
*/
|
||||
const translateAndProcess = async () => {
|
||||
// 如果没有需要翻译的文本,直接返回
|
||||
const textsArray = Array.from(translationState.textsToTranslate)
|
||||
|
||||
// 获取缓存的翻译
|
||||
const { cached, uncached } = await cacheManager.getCachedTranslations(textsArray, config.languages)
|
||||
|
||||
// 记录缓存命中情况
|
||||
translationState.recordCacheHit(Object.keys(cached).length)
|
||||
translationState.recordCacheMiss(uncached.length)
|
||||
console.log(`[i18n插件] 缓存命中: ${Object.keys(cached).length} 个, 需要翻译: ${uncached.length} 个`)
|
||||
|
||||
// 所有翻译结果(包括缓存和新翻译)
|
||||
let allTranslations = { ...cached }
|
||||
|
||||
// 如果有未缓存的内容,进行翻译
|
||||
if (uncached.length > 0) {
|
||||
const translations = await translateTexts(uncached)
|
||||
// 更新缓存
|
||||
await cacheManager.updateCache(uncached, translations, config.languages)
|
||||
|
||||
// 合并新翻译结果
|
||||
translations.forEach((translation) => {
|
||||
allTranslations[translation.text] = translation
|
||||
})
|
||||
|
||||
// 记录新翻译的数量
|
||||
translationState.recordTranslated(translations.length)
|
||||
}
|
||||
|
||||
// 如果没有新的翻译内容或缓存,获取完整的缓存内容
|
||||
if (!Object.keys(allTranslations).length) {
|
||||
console.log(`[i18n插件] 没有新的翻译内容,使用完整缓存`)
|
||||
const cacheEntries = Array.from(cacheManager.cache.entries())
|
||||
cacheEntries.forEach(([text, data]) => {
|
||||
allTranslations[text] = {
|
||||
text,
|
||||
key: data.key,
|
||||
translations: data.translations,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 合并历史缓存和当前批次翻译内容
|
||||
const cacheEntries = Array.from(cacheManager.cache.entries())
|
||||
cacheEntries.forEach(([text, data]) => {
|
||||
if (!allTranslations[text]) {
|
||||
allTranslations[text] = {
|
||||
text,
|
||||
key: data.key,
|
||||
translations: data.translations,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 第三步:为每个中文文本生成唯一的键名,并建立映射关系
|
||||
for (const [text, translation] of Object.entries(allTranslations)) {
|
||||
translationState.setTextToKeyMapping(text, translation.key)
|
||||
}
|
||||
|
||||
// 第四步:一次性生成翻译文件(不再每次都检测目录)
|
||||
await generateTranslationFiles(allTranslations)
|
||||
|
||||
// 第五步:替换源文件中的中文文本为翻译键名
|
||||
await replaceSourceTexts()
|
||||
|
||||
// 完成并输出统计信息
|
||||
translationState.complete()
|
||||
outputStatistics()
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取中文文本
|
||||
* @param {string} content - 文件内容
|
||||
* @returns {Set<string>} - 中文文本集合
|
||||
*/
|
||||
const extractChineseTexts = (content) => {
|
||||
const texts = new Set()
|
||||
// 重置正则表达式的lastIndex,确保从头开始匹配
|
||||
config.templateRegex.lastIndex = 0
|
||||
let match
|
||||
while ((match = config.templateRegex.exec(content)) !== null) {
|
||||
texts.add(match[1])
|
||||
console.log(`[i18n插件] 提取中文文本: ${match[1]}`)
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译文本
|
||||
* @param {string[]} texts - 待翻译的文本列表
|
||||
* @returns {Promise<Object[]>} - 翻译结果列表
|
||||
*/
|
||||
const translateTexts = async (texts) => {
|
||||
const results = []
|
||||
const chunks = chunkArray(texts, config.concurrency)
|
||||
|
||||
console.log(`[i18n插件] 开始翻译 ${texts.length} 个文本,分为 ${chunks.length} 批处理`)
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i]
|
||||
console.log(`[i18n插件] 正在处理第 ${i + 1}/${chunks.length} 批 (${chunk.length} 个文本)`)
|
||||
|
||||
const promises = chunk.map((text, index) => {
|
||||
return translator.translate(text, config.languages, config.maxRetries, index)
|
||||
})
|
||||
|
||||
const chunkResults = await Promise.all(promises)
|
||||
results.push(...chunkResults)
|
||||
|
||||
// 等待请求间隔
|
||||
if (config.requestInterval > 0 && i < chunks.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, config.requestInterval))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成翻译文件
|
||||
* @param {Object} translations - 翻译结果
|
||||
*/
|
||||
const generateTranslationFiles = async (translations) => {
|
||||
// 确保输出目录存在(仅检查一次)
|
||||
if (!outputDirCreated) {
|
||||
await fileOperation.createDirectory(path.join(config.outputPath, 'model'))
|
||||
outputDirCreated = true
|
||||
}
|
||||
|
||||
console.log(`[i18n插件] 正在生成 ${config.languages.length} 个语言的翻译文件`)
|
||||
|
||||
// 构建每种语言的翻译结构
|
||||
const languageTranslations = {}
|
||||
|
||||
// 初始化每种语言的翻译对象
|
||||
for (const language of config.languages) {
|
||||
languageTranslations[language] = {}
|
||||
}
|
||||
|
||||
console.log(translations, Object.entries(translations).length)
|
||||
// 构建翻译键值对
|
||||
for (const [text, data] of Object.entries(translations)) {
|
||||
// 生成翻译键名
|
||||
const key = translationState.textToKeyMap.get(text) || Utils.renderTranslateName(text)
|
||||
|
||||
console.log(`[i18n插件] 生成翻译键名: ${key} -> ${text}`)
|
||||
// 为每种语言添加翻译
|
||||
for (const language of config.languages) {
|
||||
languageTranslations[language][key] = data.translations[language]
|
||||
}
|
||||
}
|
||||
// console.log(languageTranslations)
|
||||
// 一次性写入每种语言的翻译文件
|
||||
const writePromises = config.languages.map((language) =>
|
||||
fileOperation.generateTranslationFile(
|
||||
path.join(config.outputPath, 'model'),
|
||||
languageTranslations[language],
|
||||
language,
|
||||
),
|
||||
)
|
||||
await Promise.all(writePromises)
|
||||
console.log(`[i18n插件] 翻译文件生成完成`)
|
||||
// 创建入口文件
|
||||
await createI18nEntryFile()
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换源文件中的中文文本为翻译键名
|
||||
*/
|
||||
const replaceSourceTexts = async () => {
|
||||
// 获取所有需要更新的文件
|
||||
const filesToUpdate = translationState.getFilesToUpdate()
|
||||
|
||||
console.log(`[i18n插件] 正在替换 ${filesToUpdate.size} 个文件中的中文文本`)
|
||||
|
||||
// 处理每个需要更新的文件
|
||||
for (const [filePath, replacements] of filesToUpdate.entries()) {
|
||||
try {
|
||||
// 读取文件内容
|
||||
let content = await fileOperation.readFile(filePath)
|
||||
|
||||
// 获取文件相对于项目的命名空间
|
||||
// const namespace = Utils.getNamespace(filePath, config.projectPath);
|
||||
|
||||
// 替换每个中文文本为$t('键名')
|
||||
for (const [text, baseKey] of replacements.entries()) {
|
||||
// 在替换时为每个文件中的键添加命名空间前缀
|
||||
// const key = namespace ? `${namespace}.${baseKey}` : baseKey;
|
||||
// 创建正则表达式,匹配$t('中文文本')或$t("中文文本")
|
||||
const regex = new RegExp(`\\$t\\(['"]${escapeRegExp(text)}['"]`, 'g')
|
||||
content = content.replace(regex, `$t('${baseKey}'`)
|
||||
}
|
||||
|
||||
// 写入更新后的文件内容
|
||||
await fileOperation.modifyFile(filePath, content)
|
||||
} catch (error) {
|
||||
console.error(`[i18n插件] 替换文件 ${filePath} 内容失败:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建i18n入口文件
|
||||
*/
|
||||
const createI18nEntryFile = async () => {
|
||||
try {
|
||||
// 创建i18n入口文件内容
|
||||
const entryFileContent = `// 自动生成的i18n入口文件
|
||||
// 自动生成的i18n入口文件
|
||||
import { useLocale } from '@baota/i18n'
|
||||
import zhCN from './model/zhCN${config.createFileExt}'
|
||||
import enUS from './model/enUS${config.createFileExt}'
|
||||
|
||||
// 使用 i18n 插件
|
||||
export const { i18n, $t, locale, localeOptions } = useLocale(
|
||||
{
|
||||
messages: { zhCN, enUS },
|
||||
locale: 'zhCN',
|
||||
fileExt: 'json'
|
||||
},
|
||||
import.meta.glob([\`./model/*${config.createFileExt}\`], {
|
||||
eager: false,
|
||||
}),
|
||||
)
|
||||
|
||||
`
|
||||
|
||||
// 写入i18n入口文件
|
||||
const entryFilePath = path.join(config.outputPath, `index${config.createEntryFileExt}`)
|
||||
await fileOperation.createFile(entryFilePath, entryFileContent)
|
||||
console.log(`[i18n插件] 已创建i18n入口文件: ${entryFilePath}`)
|
||||
} catch (error) {
|
||||
console.error(`[i18n插件] 创建i18n入口文件失败:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出翻译统计信息
|
||||
*/
|
||||
const outputStatistics = () => {
|
||||
const summary = translationState.getSummary()
|
||||
console.log('\n======= i18n翻译插件执行统计 =======')
|
||||
console.log(`总耗时: ${summary.duration}`)
|
||||
console.log(`处理文件数: ${summary.filesProcessed}`)
|
||||
console.log(`包含中文文本的文件数: ${summary.filesWithChineseText}`)
|
||||
console.log(`唯一中文文本数: ${summary.uniqueChineseTexts}`)
|
||||
console.log(`命中缓存: ${summary.cacheHits} 条`)
|
||||
console.log(`新翻译: ${summary.translatedTexts} 条`)
|
||||
console.log(`缓存命中率: ${summary.cacheHitRate}`)
|
||||
console.log('===================================\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 将数组分块
|
||||
* @param {Array} array - 待分块的数组
|
||||
* @param {number} size - 块大小
|
||||
* @returns {Array[]} - 分块后的数组
|
||||
*/
|
||||
const chunkArray = (array, size) => {
|
||||
const chunks = []
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
/**
|
||||
* 转义正则表达式特殊字符
|
||||
* @param {string} string - 需要转义的字符串
|
||||
* @returns {string} - 转义后的字符串
|
||||
*/
|
||||
const escapeRegExp = (string) => {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理未使用的翻译
|
||||
* @param {string[]} files - 要扫描的文件列表
|
||||
* @returns {Promise<{removedCount: number}>} - 清理结果
|
||||
*/
|
||||
const cleanupUnusedTranslations = async (files) => {
|
||||
if (isProcessing) {
|
||||
console.log(`[i18n插件] 已有处理正在进行中,跳过未使用翻译清理`)
|
||||
return { removedCount: 0 }
|
||||
}
|
||||
|
||||
try {
|
||||
isProcessing = true
|
||||
// 执行未使用翻译检查和清理
|
||||
const result = await unusedDetector.cleanUnusedTranslations(config, files)
|
||||
return result
|
||||
} finally {
|
||||
isProcessing = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'vite-plugin-i18n-ai-translate',
|
||||
|
||||
// 解析配置时的钩子
|
||||
async configResolved() {
|
||||
// 初始化缓存
|
||||
await cacheManager.initCache()
|
||||
|
||||
// 确保输出目录存在(仅初始化一次)
|
||||
await fileOperation.createDirectory(path.join(config.outputPath, 'model'))
|
||||
outputDirCreated = true
|
||||
},
|
||||
|
||||
// 配置服务器时的钩子
|
||||
async configureServer(server) {
|
||||
// 生成规则
|
||||
const globFiles = config.fileExtensions.map((ext) => `**/*${ext}`)
|
||||
|
||||
// 获取所有文件
|
||||
const files = await fileOperation.scanFiles(globFiles, config.projectPath)
|
||||
|
||||
// 批量处理所有文件
|
||||
await processFiles(files)
|
||||
|
||||
// 设置文件监听
|
||||
// watcher = server.watcher
|
||||
// watcher.on('change', async (file) => {
|
||||
// // 只有在未处理状态且文件扩展名匹配时才处理变更
|
||||
// // 排除指定目录
|
||||
// if (config.exclude.some((item) => file.includes(item))) return
|
||||
// if (!isProcessing && config.fileExtensions.some((ext) => file.endsWith(ext))) {
|
||||
// // console.log(`[i18n插件] 检测到文件变更: ${file}`);
|
||||
// await processFiles([file])
|
||||
// }
|
||||
// })
|
||||
},
|
||||
|
||||
// 关闭打包时的钩子
|
||||
async closeBundle() {
|
||||
if (watcher) {
|
||||
watcher.close()
|
||||
}
|
||||
},
|
||||
|
||||
// 导出额外功能
|
||||
cleanupUnusedTranslations,
|
||||
}
|
||||
}
|
||||
|
||||
export default vitePluginI18nAiTranslate
|
||||
95
frontend/plugin/vite-plugin-i18n/src/logManagement/index.js
Normal file
95
frontend/plugin/vite-plugin-i18n/src/logManagement/index.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { Utils } from '../utils/index.js'
|
||||
|
||||
export class LogManager {
|
||||
constructor(options = {}) {
|
||||
const { logPath = './logs', errorLogFile = 'error.log', infoLogFile = 'info.log' } = options
|
||||
|
||||
this.logPath = logPath
|
||||
this.errorLogFile = path.join(logPath, errorLogFile)
|
||||
this.infoLogFile = path.join(logPath, infoLogFile)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化日志目录
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
await fs.mkdir(this.logPath, { recursive: true })
|
||||
} catch (error) {
|
||||
console.error('初始化日志目录失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误日志
|
||||
* @param {Error} error - 错误对象
|
||||
*/
|
||||
async logError(error) {
|
||||
try {
|
||||
const formattedError = Utils.formatError(error)
|
||||
const logEntry = `[${formattedError.timestamp}] ERROR: ${formattedError.message}\n${formattedError.stack}\n\n`
|
||||
await fs.appendFile(this.errorLogFile, logEntry)
|
||||
} catch (err) {
|
||||
console.error('写入错误日志失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录信息日志
|
||||
* @param {string} message - 日志信息
|
||||
*/
|
||||
async logInfo(message) {
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
const logEntry = `[${timestamp}] INFO: ${message}\n`
|
||||
await fs.appendFile(this.infoLogFile, logEntry)
|
||||
} catch (error) {
|
||||
console.error('写入信息日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期日志
|
||||
* @param {number} days - 保留天数
|
||||
*/
|
||||
async cleanLogs(days) {
|
||||
try {
|
||||
const now = Date.now()
|
||||
const files = await fs.readdir(this.logPath)
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(this.logPath, file)
|
||||
const stats = await fs.stat(filePath)
|
||||
const age = (now - stats.mtimeMs) / (1000 * 60 * 60 * 24)
|
||||
|
||||
if (age > days) {
|
||||
await fs.unlink(filePath)
|
||||
await this.logInfo(`已删除过期日志文件: ${file}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清理日志失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志内容
|
||||
* @param {string} logType - 日志类型 ('error' | 'info')
|
||||
* @param {number} lines - 返回的行数
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
async getLogs(logType, lines) {
|
||||
try {
|
||||
const logFile = logType === 'error' ? this.errorLogFile : this.infoLogFile
|
||||
const content = await fs.readFile(logFile, 'utf8')
|
||||
return content.split('\n').slice(-lines)
|
||||
} catch (error) {
|
||||
console.error('读取日志失败:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LogManager
|
||||
317
frontend/plugin/vite-plugin-i18n/src/stateManagement/index.js
Normal file
317
frontend/plugin/vite-plugin-i18n/src/stateManagement/index.js
Normal file
@@ -0,0 +1,317 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
export class StateManager {
|
||||
constructor(options = {}) {
|
||||
const { statePath = './state', stateFile = 'plugin-state.json' } = options
|
||||
|
||||
this.statePath = statePath
|
||||
this.stateFile = path.join(statePath, stateFile)
|
||||
this.state = {
|
||||
lastUpdate: null,
|
||||
processedFiles: new Set(),
|
||||
pendingTranslations: new Set(),
|
||||
failedTranslations: new Map(),
|
||||
statistics: {
|
||||
totalProcessed: 0,
|
||||
totalSuccess: 0,
|
||||
totalFailed: 0,
|
||||
cacheHits: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化状态
|
||||
*/
|
||||
async init() {
|
||||
try {
|
||||
await fs.mkdir(this.statePath, { recursive: true })
|
||||
if (await this.fileExists(this.stateFile)) {
|
||||
const data = await fs.readFile(this.stateFile, 'utf8')
|
||||
const savedState = JSON.parse(data)
|
||||
// 恢复集合和映射
|
||||
this.state = {
|
||||
...savedState,
|
||||
processedFiles: new Set(savedState.processedFiles),
|
||||
pendingTranslations: new Set(savedState.pendingTranslations),
|
||||
failedTranslations: new Map(savedState.failedTranslations),
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存状态
|
||||
*/
|
||||
async save() {
|
||||
try {
|
||||
const serializedState = {
|
||||
...this.state,
|
||||
lastUpdate: new Date().toISOString(),
|
||||
processedFiles: Array.from(this.state.processedFiles),
|
||||
pendingTranslations: Array.from(this.state.pendingTranslations),
|
||||
failedTranslations: Array.from(this.state.failedTranslations),
|
||||
}
|
||||
await fs.writeFile(this.stateFile, JSON.stringify(serializedState, null, 2))
|
||||
} catch (error) {
|
||||
console.error('保存状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新状态
|
||||
* @param {Object} newState - 新的状态
|
||||
*/
|
||||
async updateState(newState) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
...newState,
|
||||
lastUpdate: new Date().toISOString(),
|
||||
}
|
||||
await this.save()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加已处理文件
|
||||
* @param {string} filePath - 文件路径
|
||||
*/
|
||||
async addProcessedFile(filePath) {
|
||||
this.state.processedFiles.add(filePath)
|
||||
this.state.statistics.totalProcessed++
|
||||
await this.save()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加待处理翻译
|
||||
* @param {string} text - 待翻译文本
|
||||
*/
|
||||
async addPendingTranslation(text) {
|
||||
this.state.pendingTranslations.add(text)
|
||||
await this.save()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加失败的翻译
|
||||
* @param {string} text - 待翻译文本
|
||||
* @param {Error} error - 错误信息
|
||||
*/
|
||||
async addFailedTranslation(text, error) {
|
||||
this.state.failedTranslations.set(text, {
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
this.state.statistics.totalFailed++
|
||||
await this.save()
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录翻译成功
|
||||
* @param {string} text - 翻译文本
|
||||
*/
|
||||
async recordTranslationSuccess(text) {
|
||||
this.state.pendingTranslations.delete(text)
|
||||
this.state.failedTranslations.delete(text)
|
||||
this.state.statistics.totalSuccess++
|
||||
await this.save()
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录缓存命中
|
||||
*/
|
||||
async recordCacheHit() {
|
||||
this.state.statistics.cacheHits++
|
||||
await this.save()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态
|
||||
* @returns {Object} - 当前状态
|
||||
*/
|
||||
getState() {
|
||||
return this.state
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取统计信息
|
||||
* @returns {Object} - 统计信息
|
||||
*/
|
||||
getStatistics() {
|
||||
return this.state.statistics
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param {string} filePath - 文件路径
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async fileExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置状态
|
||||
*/
|
||||
async reset() {
|
||||
this.state = {
|
||||
lastUpdate: null,
|
||||
processedFiles: new Set(),
|
||||
pendingTranslations: new Set(),
|
||||
failedTranslations: new Map(),
|
||||
statistics: {
|
||||
totalProcessed: 0,
|
||||
totalSuccess: 0,
|
||||
totalFailed: 0,
|
||||
cacheHits: 0,
|
||||
},
|
||||
}
|
||||
await this.save()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 翻译状态管理
|
||||
* 用于跟踪翻译进度和统计信息
|
||||
*/
|
||||
export class TranslationState {
|
||||
constructor() {
|
||||
// 文件处理统计
|
||||
this.filesProcessed = 0
|
||||
this.filesWithChineseText = 0
|
||||
|
||||
// 翻译统计
|
||||
this.textsToTranslate = new Set() // 所有需要翻译的中文文本
|
||||
this.translatedTexts = 0 // 已翻译的中文文本数量
|
||||
this.cacheHits = 0 // 缓存命中次数
|
||||
this.cacheMisses = 0 // 缓存未命中次数
|
||||
|
||||
// 中文文本到源文件的映射
|
||||
this.textToFiles = new Map() // 记录每个中文文本出现在哪些文件中
|
||||
this.fileTexts = new Map() // 记录每个文件包含哪些中文文本
|
||||
|
||||
// 翻译键名映射
|
||||
this.textToKeyMap = new Map() // 中文文本到翻译键名的映射
|
||||
|
||||
// 待处理的文件队列
|
||||
this.pendingFiles = []
|
||||
|
||||
// 性能指标
|
||||
this.startTime = Date.now()
|
||||
this.endTime = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录文件处理
|
||||
* @param {string} filePath - 处理的文件路径
|
||||
* @param {Set<string>} chineseTexts - 文件中提取的中文文本
|
||||
*/
|
||||
recordFileProcessed(filePath, chineseTexts) {
|
||||
this.filesProcessed++
|
||||
|
||||
if (chineseTexts.size > 0) {
|
||||
this.filesWithChineseText++
|
||||
this.fileTexts.set(filePath, new Set(chineseTexts))
|
||||
|
||||
// 更新中文文本到文件的映射
|
||||
chineseTexts.forEach((text) => {
|
||||
this.textsToTranslate.add(text)
|
||||
|
||||
if (!this.textToFiles.has(text)) {
|
||||
this.textToFiles.set(text, new Set())
|
||||
}
|
||||
this.textToFiles.get(text).add(filePath)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录缓存命中
|
||||
* @param {number} hitCount - 命中缓存的数量
|
||||
*/
|
||||
recordCacheHit(hitCount) {
|
||||
this.cacheHits += hitCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录缓存未命中
|
||||
* @param {number} missCount - 未命中缓存的数量
|
||||
*/
|
||||
recordCacheMiss(missCount) {
|
||||
this.cacheMisses += missCount
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录翻译完成
|
||||
* @param {number} count - 翻译完成的数量
|
||||
*/
|
||||
recordTranslated(count) {
|
||||
this.translatedTexts += count
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文本到键名的映射
|
||||
* @param {string} text - 中文文本
|
||||
* @param {string} key - 生成的翻译键名
|
||||
*/
|
||||
setTextToKeyMapping(text, key) {
|
||||
this.textToKeyMap.set(text, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成翻译过程
|
||||
*/
|
||||
complete() {
|
||||
this.endTime = Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取翻译状态摘要
|
||||
* @returns {Object} - 翻译状态摘要
|
||||
*/
|
||||
getSummary() {
|
||||
const duration = (this.endTime || Date.now()) - this.startTime
|
||||
|
||||
return {
|
||||
duration: `${(duration / 1000).toFixed(2)}秒`,
|
||||
filesProcessed: this.filesProcessed,
|
||||
filesWithChineseText: this.filesWithChineseText,
|
||||
uniqueChineseTexts: this.textsToTranslate.size,
|
||||
translatedTexts: this.translatedTexts,
|
||||
cacheHits: this.cacheHits,
|
||||
cacheMisses: this.cacheMisses,
|
||||
cacheHitRate:
|
||||
this.textsToTranslate.size > 0 ? `${((this.cacheHits / this.textsToTranslate.size) * 100).toFixed(2)}%` : '0%',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有需要更新的文件及其对应的文本替换映射
|
||||
* @returns {Map<string, Map<string, string>>} - 文件路径到文本替换映射的映射
|
||||
*/
|
||||
getFilesToUpdate() {
|
||||
const filesToUpdate = new Map()
|
||||
|
||||
this.fileTexts.forEach((texts, filePath) => {
|
||||
const fileReplacements = new Map()
|
||||
texts.forEach((text) => {
|
||||
const key = this.textToKeyMap.get(text)
|
||||
if (key) {
|
||||
fileReplacements.set(text, key)
|
||||
}
|
||||
})
|
||||
|
||||
if (fileReplacements.size > 0) {
|
||||
filesToUpdate.set(filePath, fileReplacements)
|
||||
}
|
||||
})
|
||||
|
||||
return filesToUpdate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { TranslationAdapter } from './index.js'
|
||||
import { ZhipuAITranslator } from '../ai/zhipuAI.js'
|
||||
import { QianwenAITranslator } from '../ai/qianwenAI.js'
|
||||
import { DeepSeekAITranslator } from '../ai/deepseekAI.js'
|
||||
import config from '../../config/config.js'
|
||||
|
||||
/**
|
||||
* AI批量翻译适配器 - 用于处理大规模AI翻译服务
|
||||
*/
|
||||
export class AIBatchAdapter extends TranslationAdapter {
|
||||
constructor() {
|
||||
super()
|
||||
this.translator = new DeepSeekAITranslator(config.apiKey[config.translateMethod])
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染翻译名称
|
||||
* @returns {Promise<string>} 生成的唯一翻译名称
|
||||
*/
|
||||
renderTranslateName(index) {
|
||||
const timestamp = Date.now()
|
||||
return `t_${index}_${timestamp}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行AI批量翻译 - 包含错误重试机制
|
||||
* @param {string} text - 待翻译的文本内容
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
* @param {number} maxRetries - 最大重试次数
|
||||
* @param {number} index - 翻译名称索引
|
||||
* @returns {Promise<{text: string, translations: Record<string, string}>} 翻译结果对象
|
||||
* @throws {Error} 当所有重试都失败时抛出错误
|
||||
*/
|
||||
async translate(text, languages, maxRetries, index) {
|
||||
let lastError = null
|
||||
let retryCount = 0
|
||||
while (retryCount <= maxRetries) {
|
||||
try {
|
||||
const result = await this.translator.translate({
|
||||
text,
|
||||
languages,
|
||||
})
|
||||
const key = this.renderTranslateName(index)
|
||||
return {
|
||||
text,
|
||||
key,
|
||||
translations: result.translations,
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
retryCount++
|
||||
// 如果还有重试机会,等待一段时间后重试
|
||||
if (retryCount <= maxRetries) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * retryCount))
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`AI批量翻译失败(已重试${retryCount}次) - ${lastError.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取AI翻译服务支持的语言列表
|
||||
* @returns {string[]} 支持的语言代码列表
|
||||
*/
|
||||
getSupportedLanguages() {
|
||||
return this.translator.getSupportedLanguages()
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证API密钥是否有效
|
||||
* @param {string} apiKey - 待验证的API密钥
|
||||
* @returns {Promise<boolean>} 密钥是否有效
|
||||
*/
|
||||
async validateApiKey(apiKey) {
|
||||
try {
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 翻译适配器基类 - 用于统一不同翻译服务的接口实现
|
||||
*/
|
||||
export class TranslationAdapter {
|
||||
constructor() {
|
||||
if (this.constructor === TranslationAdapter) {
|
||||
throw new Error('翻译适配器:抽象类不能被直接实例化')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行翻译 - 将给定文本翻译为目标语言
|
||||
* @param {string} text - 待翻译的文本内容
|
||||
* @param {string} apiKey - 翻译服务的API密钥
|
||||
* @param {string[]} languages - 目标语言代码列表,如 ['enUS', 'jaJP']
|
||||
* @param {number} maxRetries - 翻译失败时的最大重试次数
|
||||
* @returns {Promise<{text: string, translations: Record<string, string}>} 翻译结果对象
|
||||
* @throws {Error} 当翻译失败且超过重试次数时抛出错误
|
||||
*/
|
||||
async translate(text, apiKey, languages, maxRetries) {
|
||||
throw new Error('翻译适配器:translate 方法必须在子类中实现')
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前适配器支持的语言列表
|
||||
* @returns {string[]} 支持的语言代码列表
|
||||
*/
|
||||
getSupportedLanguages() {
|
||||
throw new Error('翻译适配器:getSupportedLanguages 方法必须在子类中实现')
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查指定语言是否被当前适配器支持
|
||||
* @param {string} language - 需要检查的语言代码
|
||||
* @returns {boolean} 是否支持该语言
|
||||
*/
|
||||
isLanguageSupported(language) {
|
||||
return this.getSupportedLanguages().includes(language)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { TranslationAdapter } from './index.js'
|
||||
import { translate as traditionalApiTranslate } from '../traditional/api1.js'
|
||||
|
||||
/**
|
||||
* 传统API翻译适配器 - 用于适配常规REST API类型的翻译服务
|
||||
*/
|
||||
export class TraditionalApiAdapter extends TranslationAdapter {
|
||||
constructor(apiModule) {
|
||||
super()
|
||||
if (!apiModule?.translate || typeof apiModule.translate !== 'function') {
|
||||
throw new Error('传统API适配器:无效的API模块,必须提供translate方法')
|
||||
}
|
||||
this.apiModule = apiModule
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行翻译请求 - 将数据转换为传统API格式并处理响应
|
||||
* @param {string} text - 待翻译的文本内容
|
||||
* @param {string} apiKey - API密钥
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
* @param {number} maxRetries - 最大重试次数
|
||||
* @returns {Promise<{text: string, translations: Record<string, string>}>} 标准化的翻译结果
|
||||
* @throws {Error} 当翻译失败或语言不支持时抛出错误
|
||||
*/
|
||||
async translate(text, apiKey, languages, maxRetries) {
|
||||
// 检查所有目标语言是否支持
|
||||
for (const lang of languages) {
|
||||
if (!this.isLanguageSupported(lang)) {
|
||||
throw new Error(`传统API适配器:不支持的目标语言 "${lang}"`)
|
||||
}
|
||||
}
|
||||
|
||||
// 转换为API期望的请求格式
|
||||
const requestData = {
|
||||
text,
|
||||
apiKey,
|
||||
targetLanguages: languages,
|
||||
retryCount: maxRetries,
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.apiModule.translate(requestData)
|
||||
return {
|
||||
text,
|
||||
translations: result.translations,
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`传统API适配器:翻译失败 - ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取API支持的语言列表
|
||||
* @returns {string[]} 支持的语言代码数组
|
||||
*/
|
||||
getSupportedLanguages() {
|
||||
return this.apiModule.getSupportedLanguages?.() || []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import axios from 'axios'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { Utils } from '../../utils/index.js'
|
||||
|
||||
export class DeepSeekAITranslator {
|
||||
constructor(apiKey) {
|
||||
this.apiKey = 'sk-cdhgecffemwndfqfiohtzhzkqxkjtstqflnoeoazqxzhfswd'
|
||||
this.baseURL = 'https://api.siliconflow.cn/v1/chat/completions'
|
||||
this.model = 'deepseek-ai/DeepSeek-V3'
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成翻译提示词
|
||||
* @param {string} text - 待翻译文本
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
* @returns {string}
|
||||
*/
|
||||
generatePrompt(text, languages) {
|
||||
const targetLanguages = languages
|
||||
.map((code) => {
|
||||
const { language, region } = Utils.parseLanguageCode(code)
|
||||
return `${language}${region}`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
return `你是专业的翻译,根据用户提供的翻译文本,生成不同的翻译结果,请将以下文本翻译成${targetLanguages}多种语言,\r\n
|
||||
如果翻译文本包含{riskNum}包裹的字符,保持{}和包裹的字符,以及翻译文本本身是英文的时候,直接跳过翻译,输出原文按当前格式返回即可,\r\n
|
||||
其他的内容继续翻译,返回JSON格式,注意要严格按照JSON格式返回,返回前先检查是否符合JSON格式,字符串内部不能有换行,输出格式示例:\n{
|
||||
"zhCN": "中文",
|
||||
"enUS": "English"
|
||||
}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用智谱AI进行翻译
|
||||
* @param {string} text - 待翻译文本
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
* @returns {Promise<{text: string, translations: Object}>}
|
||||
*/
|
||||
async translate({ text, languages }) {
|
||||
try {
|
||||
const translations = {}
|
||||
// 判断当前翻译内容是否为纯英文,如果是,则直接返回原文
|
||||
if (/^[\x00-\x7F]*$/.test(text)) {
|
||||
for (const code of languages) {
|
||||
translations[code] = text
|
||||
}
|
||||
} else {
|
||||
const prompt = this.generatePrompt(text, languages)
|
||||
|
||||
const response = await axios({
|
||||
method: 'post',
|
||||
url: this.baseURL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt,
|
||||
},
|
||||
{ role: 'user', content: `翻译文本:${text}` },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.data || !response.data.choices || !response.data.choices[0]) {
|
||||
throw new Error('无效的API响应')
|
||||
}
|
||||
|
||||
// 解析智谱AI翻译结果
|
||||
const rawTranslations = this.parseTranslations(response.data.choices[0].message.content)
|
||||
|
||||
// console.log(rawTranslations, text)
|
||||
// 转换语言代码格式
|
||||
for (const [code, value] of Object.entries(rawTranslations)) {
|
||||
translations[code] = value
|
||||
}
|
||||
}
|
||||
return {
|
||||
text,
|
||||
translations: Utils.formatTranslations(translations),
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`DeepSeek-V3翻译失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析智谱AI翻译结果,转换为标准格式
|
||||
* @param {string} text - 待翻译文本
|
||||
* @returns {Object} - 标准格式的翻译结果
|
||||
*/
|
||||
parseTranslations(text) {
|
||||
text = text.replace('```json\n', '').replace('```', '')
|
||||
return JSON.parse(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查API密钥是否有效
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async validateApiKey() {
|
||||
try {
|
||||
await axios.get(`${this.baseURL}/validate`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DeepSeekAITranslator
|
||||
145
frontend/plugin/vite-plugin-i18n/src/translation/ai/qianwenAI.js
Normal file
145
frontend/plugin/vite-plugin-i18n/src/translation/ai/qianwenAI.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import axios from 'axios'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { Utils } from '../../utils/index.js'
|
||||
|
||||
export class QianwenAITranslator {
|
||||
constructor(apiKey) {
|
||||
this.apiKey = apiKey
|
||||
this.baseURL = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions'
|
||||
this.model = 'qwen-max'
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成翻译提示词
|
||||
* @param {string} text - 待翻译文本
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
* @returns {string}
|
||||
*/
|
||||
generatePrompt(text, languages) {
|
||||
const targetLanguages = languages
|
||||
.map((code) => {
|
||||
const { language, region } = Utils.parseLanguageCode(code)
|
||||
return `${language}${region}`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
return `你是专业的翻译,根据用户提供的翻译文本,生成不同的翻译结果,请将以下文本翻译成${targetLanguages}多种语言,\r\n
|
||||
如果翻译文本包含{riskNum}包裹的字符,保持{}和包裹的字符,以及翻译文本本身是英文的时候,直接跳过翻译,输出原文按当前格式返回即可,\r\n
|
||||
其他的内容继续翻译,返回JSON格式,注意要严格按照JSON格式返回,返回前先检查是否符合JSON格式,字符串内部不能有换行,输出格式示例:\n{
|
||||
"zhCN": "中文",
|
||||
"enUS": "English"
|
||||
}`
|
||||
}
|
||||
|
||||
// 生成智谱AI API所需的JWT token
|
||||
async getToken() {
|
||||
const [id, secret] = this.apiKey.split('.')
|
||||
const header = { alg: 'HS256', sign_type: 'SIGN' }
|
||||
const payload = {
|
||||
api_key: id,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
const headerBase64 = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(JSON.stringify(header))).replace(
|
||||
/=/g,
|
||||
'',
|
||||
)
|
||||
const payloadBase64 = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(JSON.stringify(payload))).replace(
|
||||
/=/g,
|
||||
'',
|
||||
)
|
||||
|
||||
const signature = CryptoJS.enc.Base64.stringify(
|
||||
CryptoJS.HmacSHA256(`${headerBase64}.${payloadBase64}`, secret),
|
||||
).replace(/=/g, '')
|
||||
return `${headerBase64}.${payloadBase64}.${signature}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用智谱AI进行翻译
|
||||
* @param {string} text - 待翻译文本
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
* @returns {Promise<{text: string, translations: Object}>}
|
||||
*/
|
||||
async translate({ text, languages }) {
|
||||
try {
|
||||
const translations = {}
|
||||
// 判断当前翻译内容是否为纯英文,如果是,则直接返回原文
|
||||
if (/^[\x00-\x7F]*$/.test(text)) {
|
||||
for (const code of languages) {
|
||||
translations[code] = text
|
||||
}
|
||||
} else {
|
||||
const prompt = this.generatePrompt(text, languages)
|
||||
// const token = await this.getToken()
|
||||
|
||||
const response = await axios({
|
||||
method: 'post',
|
||||
url: this.baseURL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt,
|
||||
},
|
||||
{ role: 'user', content: `翻译文本:${text}` },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.data || !response.data.choices || !response.data.choices[0]) {
|
||||
throw new Error('无效的API响应')
|
||||
}
|
||||
|
||||
// 解析智谱AI翻译结果
|
||||
const rawTranslations = this.parseTranslations(response.data.choices[0].message.content)
|
||||
|
||||
// console.log(rawTranslations, text)
|
||||
// 转换语言代码格式
|
||||
for (const [code, value] of Object.entries(rawTranslations)) {
|
||||
translations[code] = value
|
||||
}
|
||||
}
|
||||
return {
|
||||
text,
|
||||
translations: Utils.formatTranslations(translations),
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`千问AI翻译失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析智谱AI翻译结果,转换为标准格式
|
||||
* @param {string} text - 待翻译文本
|
||||
* @returns {Object} - 标准格式的翻译结果
|
||||
*/
|
||||
parseTranslations(text) {
|
||||
text = text.replace('```json\n', '').replace('```', '')
|
||||
return JSON.parse(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查API密钥是否有效
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async validateApiKey() {
|
||||
try {
|
||||
await axios.get(`${this.baseURL}/validate`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default QianwenAITranslator
|
||||
145
frontend/plugin/vite-plugin-i18n/src/translation/ai/zhipuAI.js
Normal file
145
frontend/plugin/vite-plugin-i18n/src/translation/ai/zhipuAI.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import axios from 'axios'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { Utils } from '../../utils/index.js'
|
||||
|
||||
export class ZhipuAITranslator {
|
||||
constructor(apiKey) {
|
||||
this.apiKey = apiKey
|
||||
this.baseURL = 'https://open.bigmodel.cn/api/paas/v4/chat/completions'
|
||||
this.model = 'glm-4-flash'
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成翻译提示词
|
||||
* @param {string} text - 待翻译文本
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
* @returns {string}
|
||||
*/
|
||||
generatePrompt(text, languages) {
|
||||
const targetLanguages = languages
|
||||
.map((code) => {
|
||||
const { language, region } = Utils.parseLanguageCode(code)
|
||||
return `${language}${region}`
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
return `你是专业的翻译,根据用户提供的翻译文本,生成不同的翻译结果,请将以下文本翻译成${targetLanguages}多种语言,\r\n
|
||||
如果翻译文本包含{riskNum}包裹的字符,保持{}和包裹的字符,以及翻译文本本身是英文的时候,直接跳过翻译,输出原文按当前格式返回即可,\r\n
|
||||
其他的内容继续翻译,返回JSON格式,注意要严格按照JSON格式返回,返回前先检查是否符合JSON格式,字符串内部不能有换行,输出格式示例:\n{
|
||||
"zhCN": "中文",
|
||||
"enUS": "English"
|
||||
}`
|
||||
}
|
||||
|
||||
// 生成智谱AI API所需的JWT token
|
||||
async getToken() {
|
||||
const [id, secret] = this.apiKey.split('.')
|
||||
const header = { alg: 'HS256', sign_type: 'SIGN' }
|
||||
const payload = {
|
||||
api_key: id,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
}
|
||||
const headerBase64 = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(JSON.stringify(header))).replace(
|
||||
/=/g,
|
||||
'',
|
||||
)
|
||||
const payloadBase64 = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(JSON.stringify(payload))).replace(
|
||||
/=/g,
|
||||
'',
|
||||
)
|
||||
|
||||
const signature = CryptoJS.enc.Base64.stringify(
|
||||
CryptoJS.HmacSHA256(`${headerBase64}.${payloadBase64}`, secret),
|
||||
).replace(/=/g, '')
|
||||
return `${headerBase64}.${payloadBase64}.${signature}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用智谱AI进行翻译
|
||||
* @param {string} text - 待翻译文本
|
||||
* @param {string[]} languages - 目标语言列表
|
||||
* @returns {Promise<{text: string, translations: Object}>}
|
||||
*/
|
||||
async translate({ text, languages }) {
|
||||
try {
|
||||
const translations = {}
|
||||
// 判断当前翻译内容是否为纯英文,如果是,则直接返回原文
|
||||
if (/^[\x00-\x7F]*$/.test(text)) {
|
||||
for (const code of languages) {
|
||||
translations[code] = text
|
||||
}
|
||||
} else {
|
||||
const prompt = this.generatePrompt(text, languages)
|
||||
const token = await this.getToken()
|
||||
|
||||
const response = await axios({
|
||||
method: 'post',
|
||||
url: this.baseURL,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: {
|
||||
model: this.model,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: prompt,
|
||||
},
|
||||
{ role: 'user', content: `翻译文本:${text}` },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.data || !response.data.choices || !response.data.choices[0]) {
|
||||
throw new Error('无效的API响应')
|
||||
}
|
||||
|
||||
// 解析智谱AI翻译结果
|
||||
const rawTranslations = this.parseTranslations(response.data.choices[0].message.content)
|
||||
|
||||
// console.log(rawTranslations, text)
|
||||
// 转换语言代码格式
|
||||
for (const [code, value] of Object.entries(rawTranslations)) {
|
||||
translations[code] = value
|
||||
}
|
||||
}
|
||||
return {
|
||||
text,
|
||||
translations: Utils.formatTranslations(translations),
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`智谱AI翻译失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析智谱AI翻译结果,转换为标准格式
|
||||
* @param {string} text - 待翻译文本
|
||||
* @returns {Object} - 标准格式的翻译结果
|
||||
*/
|
||||
parseTranslations(text) {
|
||||
text = text.replace('```json\n', '').replace('```', '')
|
||||
return JSON.parse(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查API密钥是否有效
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async validateApiKey() {
|
||||
try {
|
||||
await axios.get(`${this.baseURL}/validate`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ZhipuAITranslator
|
||||
@@ -0,0 +1,85 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export class TraditionalApi1 {
|
||||
constructor() {
|
||||
this.baseURL = 'https://api.example.com/translate'
|
||||
this.supportedLanguages = ['zhCN', 'zhTW', 'enUS', 'jaJP', 'koKR']
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行翻译
|
||||
* @param {Object} requestData - 请求数据
|
||||
* @returns {Promise<{translations: Object}>}
|
||||
*/
|
||||
async translate(requestData) {
|
||||
const { text, apiKey, languages } = requestData
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
this.baseURL,
|
||||
{
|
||||
q: text,
|
||||
target: languages.map((lang) => this.formatLanguageCode(lang)),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.data || !response.data.translations) {
|
||||
throw new Error('无效的API响应')
|
||||
}
|
||||
|
||||
// 转换响应格式
|
||||
const translations = {}
|
||||
response.data.translations.forEach((translation, index) => {
|
||||
translations[languages[index]] = translation.text
|
||||
})
|
||||
|
||||
return { translations }
|
||||
} catch (error) {
|
||||
throw new Error(`API请求失败: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证API密钥
|
||||
* @param {string} apiKey - API密钥
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async validateApiKey(apiKey) {
|
||||
try {
|
||||
await axios.get(`${this.baseURL}/validate`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取支持的语言列表
|
||||
* @returns {string[]}
|
||||
*/
|
||||
getSupportedLanguages() {
|
||||
return this.supportedLanguages
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化语言代码
|
||||
* @param {string} code - 语言代码
|
||||
* @returns {string}
|
||||
*/
|
||||
formatLanguageCode(code) {
|
||||
return `${code.slice(0, 2).toLowerCase()}-${code.slice(2).toUpperCase()}`
|
||||
}
|
||||
}
|
||||
|
||||
export const api1 = new TraditionalApi1()
|
||||
export default api1
|
||||
196
frontend/plugin/vite-plugin-i18n/src/utils/index.js
Normal file
196
frontend/plugin/vite-plugin-i18n/src/utils/index.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import _ from 'lodash'
|
||||
|
||||
/**
|
||||
* 工具函数集合
|
||||
*/
|
||||
export class Utils {
|
||||
/**
|
||||
* 将数组分块
|
||||
* @param {Array} array - 待分块的数组
|
||||
* @param {number} size - 块大小
|
||||
* @returns {Array[]} - 分块后的数组
|
||||
*/
|
||||
static chunkArray(array, size) {
|
||||
return _.chunk(array, size)
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟执行
|
||||
* @param {number} ms - 延迟时间(毫秒)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
static delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为中文字符
|
||||
* @param {string} text - 待检查的文本
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isChineseText(text) {
|
||||
return /[\u4e00-\u9fa5]/.test(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文本中提取中文内容
|
||||
* @param {string} content - 文件内容
|
||||
* @param {RegExp} templateRegex - 模板变量正则表达式
|
||||
* @returns {Set<string>} - 中文内容集合
|
||||
*/
|
||||
static extractChineseTexts(content, templateRegex) {
|
||||
const texts = new Set()
|
||||
let match
|
||||
while ((match = templateRegex.exec(content)) !== null) {
|
||||
if (this.isChineseText(match[1])) {
|
||||
texts.add(match[1])
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化翻译结果
|
||||
* @param {Object} translations - 翻译结果
|
||||
* @returns {Object} - 格式化后的翻译结果
|
||||
*/
|
||||
static formatTranslations(translations) {
|
||||
const formatted = {}
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
formatted[key] = typeof value === 'string' ? value.trim() : value
|
||||
}
|
||||
return formatted
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成翻译键名
|
||||
* @param {string} text - 原始中文文本
|
||||
* @param {string} namespace - 命名空间,通常是文件路径
|
||||
* @returns {string} - 生成的键名
|
||||
*/
|
||||
static renderTranslateName() {
|
||||
const time = Date.now()
|
||||
return `t_${time}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 简单的哈希函数,用于为文本生成唯一标识
|
||||
* @param {string} str - 输入字符串
|
||||
* @returns {string} - 哈希值(十六进制)
|
||||
*/
|
||||
static simpleHash(str) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i)
|
||||
hash = (hash << 5) - hash + char
|
||||
hash = hash & hash // 转换为32位整数
|
||||
}
|
||||
return Math.abs(hash).toString(16).substring(0, 6)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取相对于项目源代码目录的路径
|
||||
* @param {string} filePath - 完整文件路径
|
||||
* @param {string} projectPath - 项目源代码根目录
|
||||
* @returns {string} - 相对路径,用作命名空间
|
||||
*/
|
||||
static getNamespace(filePath, projectPath) {
|
||||
// 移除项目路径前缀并转换为点分隔的路径
|
||||
const relativePath = filePath.replace(projectPath, '').replace(/^\/+/, '')
|
||||
// 移除文件扩展名,并将目录分隔符转为点
|
||||
return relativePath
|
||||
.replace(/\.[^/.]+$/, '')
|
||||
.split('/')
|
||||
.join('.')
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并翻译结果
|
||||
* @param {Object} target - 目标对象
|
||||
* @param {Object} source - 源对象
|
||||
* @returns {Object} - 合并后的对象
|
||||
*/
|
||||
static mergeTranslations(target, source) {
|
||||
return _.mergeWith(target, source, (objValue, srcValue) => {
|
||||
if (_.isString(objValue) && _.isString(srcValue)) {
|
||||
return srcValue // 使用新的翻译结果
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证语言代码
|
||||
* @param {string} code - 语言代码
|
||||
* @returns {boolean}
|
||||
*/
|
||||
static isValidLanguageCode(code) {
|
||||
const languageCodePattern = /^[a-z]{2}[A-Z]{2}$/
|
||||
return languageCodePattern.test(code)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证配置对象
|
||||
* @param {Object} config - 配置对象
|
||||
* @returns {string[]} - 错误信息数组
|
||||
*/
|
||||
static validateConfig(config) {
|
||||
const errors = []
|
||||
|
||||
if (!config.apiKey || typeof config.apiKey !== 'object') {
|
||||
errors.push('apiKey 必须是一个对象')
|
||||
}
|
||||
|
||||
if (config.languages && Array.isArray(config.languages)) {
|
||||
const invalidCodes = config.languages.filter((code) => !this.isValidLanguageCode(code))
|
||||
if (invalidCodes.length > 0) {
|
||||
errors.push(`无效的语言代码: ${invalidCodes.join(', ')}`)
|
||||
}
|
||||
} else {
|
||||
errors.push('languages 必须是一个数组')
|
||||
}
|
||||
|
||||
if (typeof config.concurrency !== 'number' || config.concurrency <= 0) {
|
||||
errors.push('concurrency 必须是一个正数')
|
||||
}
|
||||
|
||||
if (typeof config.interval !== 'number' || config.interval < 0) {
|
||||
errors.push('interval 必须是一个非负数')
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一标识符
|
||||
* @returns {string}
|
||||
*/
|
||||
static generateId() {
|
||||
return _.uniqueId('translation_')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化错误信息
|
||||
* @param {Error} error - 错误对象
|
||||
* @returns {Object}
|
||||
*/
|
||||
static formatError(error) {
|
||||
return {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析语言代码
|
||||
* @param {string} code - 语言代码
|
||||
* @returns {Object}
|
||||
*/
|
||||
static parseLanguageCode(code) {
|
||||
const language = code.slice(0, 2).toLowerCase()
|
||||
const region = code.slice(2).toUpperCase()
|
||||
return { language, region }
|
||||
}
|
||||
}
|
||||
|
||||
export default Utils
|
||||
Reference in New Issue
Block a user