【初始化】前端工程项目

This commit is contained in:
chudong
2025-05-09 15:11:21 +08:00
parent c012704c9a
commit d7c556c3b0
524 changed files with 55595 additions and 112 deletions

BIN
frontend/plugin/.DS_Store vendored Normal file

Binary file not shown.

BIN
frontend/plugin/plugin-i18n/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,182 @@
# vite-plugin-i18n-ai-translate
一个基于Vite的i18n自动化翻译插件支持智谱AI等多种翻译服务。
## 特性
- 支持智谱AI和传统API多种翻译服务
- 自动扫描并提取vue-i18n的$t模板变量中的中文内容
- 并发翻译处理,提高效率
- 智能缓存机制,避免重复翻译
- 完善的错误处理和重试机制
- 支持文件变更监听,实时翻译
- 可扩展的翻译适配器设计
## 安装
```bash
npm install vite-plugin-i18n-ai-translate
```
## 配置
在vite.config.js中配置插件
```javascript
import i18nAiTranslate from 'vite-plugin-i18n-ai-translate'
export default {
plugins: [
i18nAiTranslate({
apiKey: {
zhipuAI: 'your-zhipu-api-key',
api1: 'your-api1-key',
},
languages: ['zhCN', 'zhTW', 'enUS', 'jaJP', 'koKR'],
translateMethod: 'zhipuAI',
// 其他配置项...
}),
],
}
```
## 配置选项
| 配置项 | 类型 | 默认值 | 说明 |
| --------------- | -------- | ---------------------------------------- | ------------------- |
| projectPath | string | './src' | 项目扫描路径 |
| outputPath | string | './locales' | 翻译文件输出路径 |
| cachePath | string | './cache/translation_cache.json' | 缓存文件路径 |
| logPath | string | './logs' | 日志文件路径 |
| apiKey | object | {} | 各翻译服务的API密钥 |
| languages | string[] | ['zhCN', 'zhTW', 'enUS', 'jaJP', 'koKR'] | 目标语言列表 |
| concurrency | number | 100 | 并发翻译数量 |
| templateRegex | string | '\$t\\(["\']([\u4e00-\u9fa5]+)["\']\\)' | 模板变量正则表达式 |
| fileExtensions | string[] | ['.vue', '.js', '.ts'] | 扫描的文件类型 |
| interval | number | 5000 | 文件监听间隔(ms) |
| requestInterval | number | 100 | 请求间隔时间(ms) |
| maxRetries | number | 3 | 最大重试次数 |
| translateMethod | string | 'zhipuAI' | 使用的翻译服务 |
| cacheLifetime | number | 7 | 缓存保留天数 |
| logRetention | number | 30 | 日志保留天数 |
## 工作流程
1. 扫描项目文件,提取需要翻译的中文文本
2. 检查翻译缓存,跳过已翻译内容
3. 使用配置的翻译服务进行并发翻译
4. 更新翻译缓存
5. 生成翻译文件
6. 监听文件变更,触发实时翻译
## 支持的翻译服务
- 智谱AI翻译服务
- 支持多语言批量翻译
- 基于GLM大语言模型
- 高质量翻译结果
- 传统API翻译服务
- 可扩展的适配器设计
- 支持添加自定义翻译服务
## API 参考
### 核心类
#### TranslationAdapter
翻译适配器基类,定义统一的翻译接口。
方法:
- translate(text, apiKey, languages, maxRetries)
- validateApiKey(apiKey)
- getSupportedLanguages()
- isLanguageSupported(language)
#### CacheManager
缓存管理类,处理翻译结果的缓存。
方法:
- initCache()
- getCachedTranslations(texts, languages)
- updateCache(texts, translations, languages)
- cleanCache(validTexts)
#### LogManager
日志管理类,处理系统日志。
方法:
- init()
- logError(error)
- logInfo(message)
- cleanLogs(days)
- getLogs(logType, lines)
## 错误处理
插件包含完善的错误处理机制:
- 翻译失败自动重试
- 详细的错误日志记录
- 可配置的最大重试次数
- 翻译服务异常处理
- API密钥验证
## 开发扩展
### 添加新的翻译服务
1. 在 src/translation/traditional 或 src/translation/ai 目录下创建新的翻译服务模块
2. 实现必要的翻译接口
3. 创建对应的适配器类
4. 在配置中添加新的翻译方法
### 自定义适配器示例
```javascript
const TranslationAdapter = require('./index')
class CustomAdapter extends TranslationAdapter {
async translate(text, apiKey, languages, maxRetries) {
// 实现翻译逻辑
}
async validateApiKey(apiKey) {
// 实现密钥验证
}
getSupportedLanguages() {
// 返回支持的语言列表
}
}
```
## 常见问题
1. 翻译服务不可用
- 检查API密钥是否正确
- 确认网络连接正常
- 查看错误日志获取详细信息
2. 翻译缓存问题
- 检查缓存文件权限
- 适当调整缓存保留时间
- 可以手动清理缓存目录
3. 文件监听不生效
- 确认配置的文件扩展名正确
- 检查监听间隔设置
- 验证文件路径配置
## License
MIT License

View File

@@ -0,0 +1,69 @@
import { jest } from '@jest/globals'
import { TranslationAdapter } from '../src/translation/adapter/index.js'
import { AIBatchAdapter } from '../src/translation/adapter/aiBatchAdapter.js'
import { TraditionalApiAdapter } from '../src/translation/adapter/traditionalApiAdapter.js'
import { ZhipuAITranslator } from '../src/translation/ai/zhipuAI.js'
import * as api1 from '../src/translation/traditional/api1.js'
describe('翻译适配器测试', () => {
describe('TranslationAdapter基类', () => {
it('不应该允许直接实例化抽象基类', () => {
expect(() => new TranslationAdapter()).toThrow('翻译适配器:抽象类不能被直接实例化')
})
})
describe('AI批量翻译适配器', () => {
const adapter = new AIBatchAdapter()
const text = '你好'
const apiKey = 'test-key'
const languages = ['enUS', 'jaJP']
const maxRetries = 3
it('应该实现translate方法', () => {
expect(typeof adapter.translate).toBe('function')
})
it('应该正确处理翻译失败和重试机制', async () => {
const mockTranslate = jest
.spyOn(ZhipuAITranslator.prototype, 'translate')
.mockRejectedValueOnce(new Error('API调用失败'))
.mockResolvedValueOnce({
text,
translations: {
enUS: 'Hello',
jaJP: 'こんにちは',
},
})
const result = await adapter.translate(text, apiKey, languages, maxRetries)
expect(mockTranslate).toHaveBeenCalledTimes(2)
expect(result.translations.enUS).toBe('Hello')
expect(result.translations.jaJP).toBe('こんにちは')
})
})
describe('传统API翻译适配器', () => {
const adapter = new TraditionalApiAdapter(api1)
it('应该验证API模块的有效性', () => {
expect(() => new TraditionalApiAdapter()).toThrow('传统API适配器无效的API模块')
})
it('应该能够获取支持的语言列表', () => {
const supportedLanguages = adapter.getSupportedLanguages()
expect(Array.isArray(supportedLanguages)).toBe(true)
expect(supportedLanguages.length).toBeGreaterThan(0)
})
it('应该正确处理不支持的语言', async () => {
const text = '你好'
const apiKey = 'test-key'
const languages = ['invalidLang']
const maxRetries = 3
await expect(adapter.translate(text, apiKey, languages, maxRetries)).rejects.toThrow(
'传统API适配器不支持的目标语言',
)
})
})
})

View File

@@ -0,0 +1,120 @@
import { jest } from '@jest/globals'
import { promises as fs } from 'fs'
import path from 'path'
import { CacheManager } from '../src/cache/index.js'
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
readFile: jest.fn(),
writeFile: jest.fn(),
access: jest.fn(),
},
}))
describe('CacheManager', () => {
const cachePath = './test-cache.json'
let cacheManager
beforeEach(() => {
cacheManager = new CacheManager(cachePath)
})
afterEach(() => {
jest.clearAllMocks()
})
describe('initCache', () => {
it('应该正确初始化缓存', async () => {
const mockCacheData = {
test: {
text: 'test',
translations: { enUS: 'test' },
timestamp: '2023-01-01T00:00:00.000Z',
},
}
fs.access.mockResolvedValueOnce()
fs.readFile.mockResolvedValueOnce(JSON.stringify(mockCacheData))
await cacheManager.initCache()
expect(cacheManager.cache.get('test')).toEqual(mockCacheData.test)
})
it('处理缓存文件不存在的情况', async () => {
fs.access.mockRejectedValueOnce(new Error('文件不存在'))
await cacheManager.initCache()
expect(cacheManager.cache.size).toBe(0)
})
})
describe('getCachedTranslations', () => {
beforeEach(async () => {
cacheManager.cache.set('hello', {
text: 'hello',
translations: {
enUS: 'Hello',
jaJP: 'こんにちは',
},
timestamp: '2023-01-01T00:00:00.000Z',
})
})
it('应该返回缓存的翻译', async () => {
const texts = ['hello', 'world']
const languages = ['enUS', 'jaJP']
const { cached, uncached } = await cacheManager.getCachedTranslations(texts, languages)
expect(cached.hello).toBeDefined()
expect(uncached).toContain('world')
})
it('检查缓存项是否包含所有必要的语言', async () => {
const texts = ['hello']
const languages = ['enUS', 'jaJP', 'zhCN']
const { cached, uncached } = await cacheManager.getCachedTranslations(texts, languages)
expect(uncached).toContain('hello')
expect(Object.keys(cached)).toHaveLength(0)
})
})
describe('updateCache', () => {
it('应该正确更新缓存', async () => {
const texts = ['test']
const translations = [
{
text: 'test',
translations: {
enUS: 'Test',
jaJP: 'テスト',
},
},
]
const languages = ['enUS', 'jaJP']
await cacheManager.updateCache(texts, translations, languages)
const cached = cacheManager.cache.get('test')
expect(cached.translations.enUS).toBe('Test')
expect(cached.translations.jaJP).toBe('テスト')
expect(fs.writeFile).toHaveBeenCalled()
})
})
describe('cleanCache', () => {
it('应该删除无效的缓存项', async () => {
cacheManager.cache.set('valid', { text: 'valid' })
cacheManager.cache.set('invalid', { text: 'invalid' })
await cacheManager.cleanCache(['valid'])
expect(cacheManager.cache.has('valid')).toBe(true)
expect(cacheManager.cache.has('invalid')).toBe(false)
expect(fs.writeFile).toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,133 @@
import { jest } from '@jest/globals'
import { promises as fs } from 'fs'
import path from 'path'
import { LogManager } from '../src/logManagement/index.js'
jest.mock('fs', () => ({
promises: {
mkdir: jest.fn(),
appendFile: jest.fn(),
readFile: jest.fn(),
readdir: jest.fn(),
stat: jest.fn(),
unlink: jest.fn(),
},
}))
describe('LogManager', () => {
const options = {
logPath: './test-logs',
errorLogFile: 'error.log',
infoLogFile: 'info.log',
}
let logManager
beforeEach(() => {
logManager = new LogManager(options)
jest.clearAllMocks()
})
describe('init', () => {
it('应该创建日志目录', async () => {
await logManager.init()
expect(fs.mkdir).toHaveBeenCalledWith(options.logPath, { recursive: true })
})
it('处理创建目录失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.mkdir.mockRejectedValueOnce(new Error('创建目录失败'))
await logManager.init()
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('logError', () => {
it('应该写入错误日志', async () => {
const error = new Error('测试错误')
await logManager.logError(error)
expect(fs.appendFile).toHaveBeenCalledWith(
expect.stringContaining('error.log'),
expect.stringContaining('测试错误'),
)
})
it('处理写入错误日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.appendFile.mockRejectedValueOnce(new Error('写入失败'))
await logManager.logError(new Error('测试错误'))
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('logInfo', () => {
it('应该写入信息日志', async () => {
const message = '测试信息'
await logManager.logInfo(message)
expect(fs.appendFile).toHaveBeenCalledWith(expect.stringContaining('info.log'), expect.stringContaining(message))
})
it('处理写入信息日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.appendFile.mockRejectedValueOnce(new Error('写入失败'))
await logManager.logInfo('测试信息')
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('cleanLogs', () => {
it('应该删除过期的日志文件', async () => {
const now = Date.now()
const oldDate = now - 7 * 24 * 60 * 60 * 1000 - 1000 // 7天+1秒前
fs.readdir.mockResolvedValueOnce(['old.log', 'new.log'])
fs.stat.mockImplementation((path) => {
return Promise.resolve({
mtimeMs: path.includes('old') ? oldDate : now,
})
})
await logManager.cleanLogs(7)
expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining('old.log'))
expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining('new.log'))
})
it('处理清理日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.readdir.mockRejectedValueOnce(new Error('读取目录失败'))
await logManager.cleanLogs(7)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
describe('getLogs', () => {
it('应该返回指定数量的日志行', async () => {
const logContent = '行1\n行2\n行3\n行4\n行5'
fs.readFile.mockResolvedValueOnce(logContent)
const lines = await logManager.getLogs('info', 3)
expect(lines).toHaveLength(3)
expect(lines[2]).toBe('行5')
})
it('处理读取日志失败的情况', async () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation()
fs.readFile.mockRejectedValueOnce(new Error('读取文件失败'))
const lines = await logManager.getLogs('error', 5)
expect(lines).toHaveLength(0)
expect(consoleError).toHaveBeenCalled()
consoleError.mockRestore()
})
})
})

View File

@@ -0,0 +1,100 @@
import { Utils } from '../src/utils/index.js'
describe('Utils Extended Features', () => {
describe('chunkArray', () => {
it('应该正确分块数组', () => {
const array = [1, 2, 3, 4, 5, 6, 7]
const size = 3
const chunks = Utils.chunkArray(array, size)
expect(chunks).toHaveLength(3)
expect(chunks[0]).toEqual([1, 2, 3])
expect(chunks[1]).toEqual([4, 5, 6])
expect(chunks[2]).toEqual([7])
})
it('处理空数组', () => {
const chunks = Utils.chunkArray([], 2)
expect(chunks).toHaveLength(0)
})
})
describe('delay', () => {
it('应该延迟执行指定时间', async () => {
const start = Date.now()
await Utils.delay(100)
const duration = Date.now() - start
expect(duration).toBeGreaterThanOrEqual(100)
})
})
describe('extractChineseTexts', () => {
it('应该正确提取中文内容', () => {
const content = `
$t('你好世界')
$t("测试文本")
$t('Hello World')
`
const templateRegex = /\$t\(['"]([^'"]+)['"]\)/g
const texts = Utils.extractChineseTexts(content, templateRegex)
expect(texts.size).toBe(2)
expect(texts.has('你好世界')).toBe(true)
expect(texts.has('测试文本')).toBe(true)
expect(texts.has('Hello World')).toBe(false)
})
})
describe('mergeTranslations', () => {
it('应该正确合并翻译结果', () => {
const target = {
key1: 'old value 1',
key2: 'old value 2',
}
const source = {
key1: 'new value 1',
key3: 'new value 3',
}
const result = Utils.mergeTranslations(target, source)
expect(result).toEqual({
key1: 'new value 1',
key2: 'old value 2',
key3: 'new value 3',
})
})
})
describe('isValidLanguageCode', () => {
it('应该验证语言代码格式', () => {
expect(Utils.isValidLanguageCode('zhCN')).toBe(true)
expect(Utils.isValidLanguageCode('enUS')).toBe(true)
expect(Utils.isValidLanguageCode('zh-CN')).toBe(false)
expect(Utils.isValidLanguageCode('123')).toBe(false)
})
})
describe('formatError', () => {
it('应该正确格式化错误信息', () => {
const error = new Error('测试错误')
const formatted = Utils.formatError(error)
expect(formatted).toHaveProperty('message', '测试错误')
expect(formatted).toHaveProperty('stack')
expect(formatted).toHaveProperty('timestamp')
expect(new Date(formatted.timestamp)).toBeInstanceOf(Date)
})
})
describe('generateId', () => {
it('应该生成唯一的标识符', () => {
const id1 = Utils.generateId()
const id2 = Utils.generateId()
expect(id1).toMatch(/^translation_\d+$/)
expect(id2).toMatch(/^translation_\d+$/)
expect(id1).not.toBe(id2)
})
})
})

View File

@@ -0,0 +1,62 @@
import { Utils } from '../src/utils/index.js'
describe('Utils', () => {
describe('isChineseText', () => {
it('应该正确识别中文文本', () => {
expect(Utils.isChineseText('你好')).toBe(true)
expect(Utils.isChineseText('Hello')).toBe(false)
expect(Utils.isChineseText('Hello 你好')).toBe(true)
})
})
describe('validateConfig', () => {
it('应该验证配置对象', () => {
const validConfig = {
apiKey: { zhipuAI: 'test-key' },
languages: ['zhCN', 'enUS'],
concurrency: 10,
interval: 1000,
}
const errors = Utils.validateConfig(validConfig)
expect(errors).toHaveLength(0)
})
it('应该检测无效的配置', () => {
const invalidConfig = {
apiKey: 'invalid',
languages: ['invalid'],
concurrency: -1,
interval: 'invalid',
}
const errors = Utils.validateConfig(invalidConfig)
expect(errors.length).toBeGreaterThan(0)
})
})
describe('parseLanguageCode', () => {
it('应该正确解析语言代码', () => {
const result = Utils.parseLanguageCode('zhCN')
expect(result).toEqual({
language: 'zh',
region: 'CN',
})
})
})
describe('formatTranslations', () => {
it('应该正确格式化翻译结果', () => {
const translations = {
hello: ' Hello World ',
welcome: ' 欢迎 ',
}
const formatted = Utils.formatTranslations(translations)
expect(formatted).toEqual({
hello: 'Hello World',
welcome: '欢迎',
})
})
})
})

View File

@@ -0,0 +1,112 @@
import { jest } from '@jest/globals'
import axios from 'axios'
import { ZhipuAITranslator } from '../src/translation/ai/zhipuAI.js'
jest.mock('axios')
describe('ZhipuAITranslator', () => {
const apiKey = 'test-key'
let translator
beforeEach(() => {
translator = new ZhipuAITranslator(apiKey)
jest.clearAllMocks()
})
describe('generatePrompt', () => {
it('应该生成正确的提示词', () => {
const text = '你好'
const languages = ['enUS', 'jaJP']
const prompt = translator.generatePrompt(text, languages)
expect(prompt).toContain('请将以下文本翻译成')
expect(prompt).toContain('en-US, ja-JP')
expect(prompt).toContain(text)
})
})
describe('translate', () => {
const text = '你好'
const languages = ['enUS', 'jaJP']
it('应该成功调用API并返回翻译结果', async () => {
const mockResponse = {
data: {
choices: [
{
message: {
content: JSON.stringify({
'en-US': 'Hello',
'ja-JP': 'こんにちは',
}),
},
},
],
},
}
axios.post.mockResolvedValueOnce(mockResponse)
const result = await translator.translate(text, languages)
expect(axios.post).toHaveBeenCalledWith(
expect.stringContaining('/chatglm_turbo'),
expect.any(Object),
expect.objectContaining({
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
}),
)
expect(result).toEqual({
text: '你好',
translations: {
enUS: 'Hello',
jaJP: 'こんにちは',
},
})
})
it('处理API调用失败的情况', async () => {
axios.post.mockRejectedValueOnce(new Error('API调用失败'))
await expect(translator.translate(text, languages)).rejects.toThrow('智谱AI翻译失败')
})
it('处理无效的API响应', async () => {
const mockResponse = {
data: {},
}
axios.post.mockResolvedValueOnce(mockResponse)
await expect(translator.translate(text, languages)).rejects.toThrow('无效的API响应')
})
})
describe('validateApiKey', () => {
it('应该成功验证有效的API密钥', async () => {
axios.get.mockResolvedValueOnce({})
const isValid = await translator.validateApiKey()
expect(isValid).toBe(true)
expect(axios.get).toHaveBeenCalledWith(
expect.stringContaining('/validate'),
expect.objectContaining({
headers: {
Authorization: `Bearer ${apiKey}`,
},
}),
)
})
it('处理无效的API密钥', async () => {
axios.get.mockRejectedValueOnce(new Error('无效的密钥'))
const isValid = await translator.validateApiKey()
expect(isValid).toBe(false)
})
})
})

View File

@@ -0,0 +1,22 @@
import vueConfig from '@baota/eslint/vue'
import baseConfig from '@baota/eslint'
/** @type {import("eslint").Linter.Config[]} */
const config = [
// Vue 相关配置,包含 TypeScript 支持
...vueConfig,
// 基础配置,用于通用的 JavaScript/TypeScript 规则
...baseConfig,
// 项目特定的配置覆盖
{
files: ['**/*.{js,ts,tsx,jsx,vue}'],
rules: {
// 在此处添加项目特定的规则覆盖
'vue/multi-word-component-names': 'off', // 关闭组件名称必须由多个单词组成的规则
},
},
]
export default config

View File

@@ -0,0 +1,303 @@
一、概述
本项目是一个 Vite 插件,主要用于实现 i18n国际化自动化翻译功能。它会自动扫描项目文件精准抓取 vue - i18n 的 $t 模板变量里的中文内容,借助不同的翻译服务(包括传统 API 翻译和 AI 批量翻译)进行翻译,并生成对应的翻译文件。同时,插件具备缓存机制,能有效加速对重复内容的处理,还可清理缓存文件中的无效数据。翻译过程采用并发模式,并发上限为 200以提高处理效率。在扫描文件前会同步缓存的状态配置确保数据的一致性。各模块采用正交性设计保证了系统的可维护性和扩展性。
二、技术选型
项目类型Vite 插件
Vite 版本Vite 4.x 及以上,以充分利用其快速构建和热更新特性。
Vue 版本Vue 3.x借助其响应式系统和组合式 API 提升开发效率。
vue - i18n 版本vue - i18n 9.x适配 Vue 3 实现国际化支持。
翻译服务:支持智谱 AI 等多种 AI 翻译服务以及传统 API 翻译服务,提供多样化的翻译选择。
Lodash用于数组处理等操作简化复杂的数据处理逻辑。
FS Promises用于文件操作的异步处理避免阻塞主线程。
Axios用于发送 HTTP 请求,与翻译服务进行通信。
三、整体架构
目录架构图
plaintext
vite-plugin-i18n-ai-translate/
├── src/
│ ├── fileOperation/ # 文件操作模块
│ │ ├── index.js
│ │ └── ...
│ ├── translation/ # 翻译模块
│ │ ├── index.js
│ │ ├── adapter/ # 翻译适配器模块
│ │ │ ├── index.js
│ │ │ ├── traditionalApiAdapter.js # 传统 API 翻译适配器
│ │ │ ├── aiBatchAdapter.js # AI 批量翻译适配器
│ │ │ └── ...
│ │ ├── ai/ # AI 翻译模块集合
│ │ │ ├── zhipuAI.js # 智谱 AI 翻译模块
│ │ │ ├── otherAI.js # 其他 AI 翻译模块示例
│ │ │ └── ...
│ │ └── traditional/ # 传统 API 翻译模块集合
│ │ ├── api1.js # 传统 API 1 翻译模块
│ │ ├── api2.js # 传统 API 2 翻译模块
│ │ └── ...
│ ├── stateManagement/ # 状态管理模块
│ │ ├── index.js
│ │ └── ...
│ ├── cache/ # 缓存模块
│ │ ├── index.js
│ │ └── ...
│ ├── logManagement/ # 日志管理模块
│ │ ├── index.js
│ │ └── ...
│ ├── utils/ # 工具函数
│ │ ├── index.js
│ │ └── ...
├── config/
│ └── config.json # 独立的配置文件
├── cache/ # 缓存文件目录
├── logs/ # 日志文件目录
└── package.json # 项目依赖和脚本配置
功能流程图
plaintext
开始
|
|-- 同步缓存状态配置
|
|-- 扫描文件
| |
| |-- 捕获 $t 模板变量中的中文内容
| |
| |-- 清理缓存中的无效数据
| |
| |-- 过滤重复内容(通过缓存)
|
|-- 并发调用翻译服务进行翻译(上限 200
| |
| |-- 根据 translateMethod 选择对应翻译模块
| | |
| | |-- 通过适配器转换请求和响应格式
| | | |
| | | |-- 支持多个目标语言JSON 字符串数组)
| | | |-- 错误处理和重试机制
| | | |-- 并发请求精细控制(请求间隔、失败重试)
|
|-- 生成翻译文件(动态追加)
| |
| |-- 文件操作模块创建、修改、复制、读取文件
|
|-- 更新缓存
|
|-- 状态管理模块更新状态
|
|-- 日志管理模块记录日志
|
结束
功能清单
文件操作模块:负责文件和目录的创建、修改、复制、读取等操作,为翻译文件的生成和管理提供支持。
翻译模块:根据配置选择合适的翻译服务,通过适配器统一请求和响应格式,对中文内容进行并发翻译。
状态管理模块:集中管理插件的状态信息,确保在扫描文件前同步缓存状态,保证数据一致性。
缓存模块:创建和管理缓存文件,加速重复内容的处理,同时清理无效缓存,提高性能。
日志管理模块:记录插件运行过程中的关键信息,包括错误信息,方便调试和问题排查。
文件扫描模块:按照指定的文件后缀扫描项目文件,捕获 $t 模板变量中的中文内容。
文件监听模块:按配置的时间间隔监听文件变化,触发翻译流程,实现实时更新。
功能模块划分
核心功能模块:翻译模块、文件操作模块,直接实现主要业务逻辑。
辅助功能模块:状态管理模块、缓存模块、日志管理模块,为核心功能提供支持和保障。
监测模块:文件扫描模块、文件监听模块,负责监测项目文件的变化。
四、功能模块详细设计
1. 文件操作模块
函数名createFile
参数filePath文件路径content文件内容
职责:创建指定路径的文件,并将内容写入文件,同时处理文件创建过程中可能出现的错误。
函数名modifyFile
参数filePath文件路径newContent新的文件内容
职责:修改指定路径文件的内容,处理文件修改过程中的错误。
函数名copyFile
参数sourcePath源文件路径destinationPath目标文件路径
职责:将源文件复制到目标路径,处理文件复制过程中的错误。
函数名readFile
参数filePath文件路径
职责:读取指定路径文件的内容,处理文件读取过程中的错误。
函数名createDirectory
参数dirPath目录路径
职责:创建指定路径的目录,处理目录创建过程中的错误。
2. 翻译模块
函数名translateTexts
参数:
texts待翻译的中文内容列表
apiKey翻译服务的 API 密钥)
languages翻译的目标语言 JSON 字符串数组)
concurrency并发数量
requestInterval请求间隔时间
maxRetries最大重试次数
translateMethod翻译方式如 "zhipuAI"、"api1" 等)
职责:根据 translateMethod 选择对应翻译模块,通过适配器将请求和响应格式统一,以指定的并发数量调用该模块对中文内容列表进行翻译,控制请求间隔,处理请求失败重试,支持多个目标语言,返回翻译结果列表。
3. 翻译适配器模块translation/adapter
适配器基类translation/adapter/index.js
javascript
class TranslationAdapter {
constructor() {
if (this.constructor === TranslationAdapter) {
throw new Error('Abstract class cannot be instantiated directly.');
}
}
translate(text, apiKey, languages, maxRetries) {
throw new Error('Method "translate" must be implemented.');
}
}
module.exports = TranslationAdapter;
传统 API 翻译适配器translation/adapter/traditionalApiAdapter.js
javascript
const TranslationAdapter = require('./index');
const traditionalApiModule = require('../traditional/api1'); // 示例传统 API 模块
class TraditionalApiAdapter extends TranslationAdapter {
async translate(text, apiKey, languages, maxRetries) {
// 转换请求格式以适配传统 API
const requestData = {
text,
apiKey,
languages,
maxRetries
};
const result = await traditionalApiModule.translate(requestData);
// 转换响应格式以统一输出
return {
text,
translations: result.translations
};
}
}
module.exports = TraditionalApiAdapter;
AI 批量翻译适配器translation/adapter/aiBatchAdapter.js
javascript
const TranslationAdapter = require('./index');
const aiModule = require('../ai/zhipuAI'); // 示例 AI 模块
class AIBatchAdapter extends TranslationAdapter {
async translate(text, apiKey, languages, maxRetries) {
// 转换请求格式以适配 AI 批量翻译
const requestData = {
text,
apiKey,
languages,
maxRetries
};
const result = await aiModule.translate(requestData);
// 转换响应格式以统一输出
return {
text,
translations: result.translations
};
}
}
module.exports = AIBatchAdapter; 4. AI 翻译模块集合translation/ai和传统 API 翻译模块集合translation/traditional
每个具体的翻译模块(如 zhipuAI.js、api1.js 等负责与对应的翻译服务进行交互接收适配器转换后的请求数据返回翻译结果。5. 状态管理模块
函数名syncCacheState
参数:无
职责:在扫描文件前同步缓存的状态配置。
函数名updateState
参数newState新的状态信息
职责:更新插件的状态信息。
函数名getState
参数:无
职责获取插件的当前状态信息。6. 缓存模块
函数名getCachedTranslations
参数texts待检查的中文内容列表languages翻译的目标语言 JSON 字符串数组cachePath缓存文件存放地址
职责:检查指定路径的缓存文件,返回已缓存的翻译结果,同时返回未缓存的中文内容列表。
函数名updateCache
参数texts中文内容列表translations对应的翻译结果列表符合翻译模块返回值固定格式languages翻译的目标语言 JSON 字符串数组cachePath缓存文件存放地址
职责:将新的翻译结果更新到指定路径的缓存文件中。
函数名cleanCache
参数validTexts有效的中文内容列表languages翻译的目标语言 JSON 字符串数组cachePath缓存文件存放地址
职责清理指定路径缓存文件中无效的翻译结果。7. 日志管理模块
函数名logInfo
参数message日志信息
职责:记录普通日志信息。
函数名logError
参数error错误信息
职责记录错误日志信息。8. 文件扫描模块
函数名scanFiles
参数projectPath项目路径templateRegex检索模板变量的正则表达式fileExtensions支持的扫描文件后缀
职责:扫描指定项目路径下符合后缀要求的文件,依据给定的正则表达式提取 $t 模板变量中的中文内容,并返回中文内容列表。
9. 文件监听模块
函数名watchFiles
参数projectPath项目路径interval监听文件间隔时间callback文件变化时的回调函数
职责:按指定的时间间隔监听项目路径下的文件变化,触发回调函数。
五、业务 / 系统流程
项目流程
启动 Vite 项目,插件开始工作。
状态管理模块同步缓存的状态配置,确保数据一致性。
文件扫描模块按配置的文件后缀扫描项目文件,依据配置的正则表达式提取 $t 模板变量中的中文内容。
缓存模块清理缓存文件中的无效数据,提高缓存的有效性。
检查缓存,过滤掉已缓存的中文内容,减少不必要的翻译请求。
翻译模块根据 translateMethod 选择对应翻译模块,通过适配器将请求和响应格式统一,以配置的并发数量对未缓存的中文内容进行翻译,控制请求间隔,处理请求失败重试,支持多个目标语言。
文件操作模块根据翻译结果和指定的语言列表生成翻译文件,若文件已存在则动态追加内容,处理文件操作过程中的错误。
缓存模块更新缓存文件,将新的翻译结果加入缓存,加速后续翻译。
状态管理模块更新插件的状态信息,反映当前运行状态。
日志管理模块记录插件运行过程中的关键信息,包括错误信息,方便调试和问题排查。
文件监听模块按配置的时间间隔监听文件变化,若有变化则重复步骤 2 - 10实现实时更新。
交互流程
插件在 Vite 构建过程中自动运行,无需用户手动干预。
用户可以通过修改 config/config.json 文件调整插件的行为,如扫描路径、输出路径、翻译服务的 API 密钥、翻译的目标语言 JSON 字符串数组、并发数量、检索模板变量的正则表达式、缓存文件存放地址、支持的扫描文件后缀、监听文件间隔时间、请求间隔时间、最大重试次数、翻译方式等。
相关建议
在开发过程中,可提供配置文件的校验机制,确保用户输入的配置信息合法。
对于翻译结果的准确性,可提供人工审核和修正的接口,以提高翻译质量。
六、配置参数详细说明
配置文件config/config.json
json
{
"projectPath": "./src",
"outputPath": "./locales",
"apiKey": {
"zhipuAI": "your_zhipuAI_api_key",
"api1": "your_api1_api_key"
},
"cachePath": "./cache/translation_cache.json",
"languages": ["zhCN", "zhTW", "enUS", "jaJP", "koKR", "ruRU", "ptBR", "frFR", "esAR", "arDZ"],
"concurrency": 100,
"templateRegex": "/\\$t\\(['\"]([\u4e00-\u9fa5]+)['\"]\\)/g",
"fileExtensions": [".vue"],
"interval": 5000,
"requestInterval": 100,
"maxRetries": 3,
"translateMethod": "zhipuAI"
}
projectPath项目扫描路径插件将从该路径开始扫描文件。
outputPath翻译文件的输出路径生成的翻译文件将存放在此。
apiKey包含不同翻译服务的 API 密钥,根据 translateMethod 选择使用。
cachePath缓存文件的存放地址用于存储已翻译的内容。
languages翻译的目标语言列表使用 JSON 字符串数组表示。
concurrency并发翻译的数量上限控制并发请求的数量。
templateRegex检索 $t 模板变量中中文内容的正则表达式。
fileExtensions支持扫描的文件后缀列表只有符合这些后缀的文件才会被扫描。
interval文件监听的时间间隔单位为毫秒。
requestInterval翻译请求的间隔时间避免对翻译服务造成过大压力。
maxRetries请求失败后的最大重试次数。
translateMethod选择的翻译方式如 "zhipuAI"、"api1" 等。
七、部署说明
项目部署
将插件代码复制到项目的 node_modules 目录下,或者使用 npm link 进行本地链接,使项目能够找到插件。
在 Vite 配置文件vite.config.js中引入插件
javascript
const i18nAiTranslatePlugin = require('vite-plugin-i18n-ai-translate');
module.exports = {
plugins: [i18nAiTranslatePlugin()]
};
环境相关问题
确保项目中已安装 Vite、Vue 和 vue - i18n并且版本符合要求以保证插件正常运行。
确保网络连接正常,以便调用翻译服务的 API否则翻译请求将失败。
若并发数量设置过高,可能会导致网络拥堵或触发翻译服务的限流机制,需根据实际情况调整,避免影响翻译效率。
监听文件间隔时间设置过短可能会增加系统开销,需根据项目规模和文件变更频率合理设置,平衡性能和实时性。
请求间隔时间和最大重试次数应根据网络状况和翻译服务的稳定性进行调整,提高翻译的成功率。
若使用不同的翻译方式,需确保相应的 API 密钥和配置正确,否则无法正常调用翻译服务。
八、总结
项目情况
该 Vite 插件实现了 i18n 自动化翻译功能通过模块化设计各个模块采用正交性设计具备良好的扩展性和可维护性。插件在扫描文件前同步缓存的状态配置增加了错误处理和重试机制对并发请求进行更精细的控制。AI 子模块支持多个目标语言,入参和返回值采用固定格式。翻译模块采用适配器模式,兼容传统的 API 翻译和 AI 批量翻译,提高了系统的兼容性和灵活性。同时,支持文件监听,可及时响应文件变化,实现实时更新。
开发建议
持续优化各个翻译模块和适配器的性能,提高翻译效率和准确性,减少翻译时间。
增加更多的翻译服务支持,丰富翻译方式的选择,满足不同用户的需求。
完善缓存模块对不同翻译方式和目标语言的缓存管理策略,提高缓存的命中率和清理效率。
进一步细化错误处理和重试机制,针对不同翻译服务的错误类型进行更精准的处理,增强系统的稳定性。
对并发请求控制进行智能化优化,根据翻译服务的实时状态动态调整并发数量和请求间隔,提高资源利用率。
提供更友好的用户配置界面,方便用户调整插件的各项参数,降低使用门槛。
加强日志管理,记录更详细的翻译过程信息,便于问题排查和性能分析,提高开发和维护效率。

View File

@@ -0,0 +1,52 @@
{
"name": "@baota/plugin-i18n",
"version": "1.0.0",
"description": "A Vite plugin for automatic i18n translation using AI services",
"type": "module",
"main": "src/index.js",
"scripts": {
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"lint": "eslint src/**/*.js",
"format": "prettier --write src/**/*.js"
},
"exports": {
".": {
"import": "./src/index.js",
"require": "./src/index.js"
}
},
"keywords": [
"vite-plugin",
"i18n",
"translation",
"ai",
"zhipuai"
],
"author": "",
"license": "MIT",
"dependencies": {
"axios": "^1.6.2",
"fast-glob": "^3.3.3",
"crypto-js": "^4.2.0",
"lodash": "^4.17.21"
},
"devDependencies": {
"@baota/eslint": "workspace:*",
"@baota/prettier": "workspace:*",
"vite": "^4.0.0",
"@jest/globals": "^29.7.0",
"jest": "^29.7.0"
},
"engines": {
"node": ">=14.0.0"
},
"jest": {
"transform": {},
"extensionsToTreatAsEsm": [
".js"
],
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
}
}
}

View File

@@ -0,0 +1,3 @@
import prettierConfig from '@baota/prettier'
export default prettierConfig

Binary file not shown.

View 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

View File

@@ -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

View 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()

View 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',
}

View 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

View 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

View 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

View 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
}
}

Binary file not shown.

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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?.() || []
}
}

View File

@@ -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

View 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

View 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

View File

@@ -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

View 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

View File

@@ -0,0 +1,46 @@
# Vite FTP/SFTP Sync Plugin
这是一个用于 Vite 构建后自动同步文件到 SFTP 服务器的插件。
## 安装
```bash
pnpm add @tools/ftp-sync -D
```
## 使用方法
`vite.config.ts` 中配置:
```typescript
import { defineConfig } from 'vite';
import ftpSync from '@tools/ftp-sync';
export default defineConfig({
plugins: [
ftpSync({
host: 'your-sftp-host',
port: 22,
username: 'your-username',
password: 'your-password',
remotePath: '/path/on/remote/server',
localPath: 'dist' // 可选,默认为 'dist'
})
]
});
```
## 配置选项
- `host`: SFTP 服务器地址
- `port`: SFTP 端口号(默认 22
- `username`: SFTP 用户名
- `password`: SFTP 密码
- `remotePath`: 远程服务器上的目标路径
- `localPath`: 本地要上传的目录路径(可选,默认为 'dist'
## 注意事项
1. 该插件仅在构建模式下运行
2. 确保有正确的 SFTP 服务器访问权限
3. 建议将敏感信息(如密码)存储在环境变量中

View File

@@ -0,0 +1,26 @@
{
"name": "@baota/project-ftp-sync",
"version": "1.0.0",
"description": "FTP/SFTP sync plugin for build process",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "tsc",
"dev": "tsc -w"
},
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"dependencies": {
"ssh2-sftp-client": "^12.0.0",
"@types/ssh2-sftp-client": "^9.0.4"
},
"devDependencies": {
"typescript": "^5.0.0",
"@types/node": "^20.0.0",
"vite": "^5.0.0"
}
}

View File

@@ -0,0 +1,69 @@
import Client from "ssh2-sftp-client";
import { Plugin } from "vite";
export interface FtpSyncTarget {
host: string;
port: number;
username: string;
password: string;
remotePath: string;
localPath?: string;
clearRemote?: boolean;
}
export function ftpSync(options: FtpSyncTarget[] | FtpSyncTarget): Plugin {
return {
name: "vite-plugin-ftp-sync",
apply: "build",
closeBundle: async () => {
if (!Array.isArray(options)) options = [options];
const results = await Promise.allSettled(
options.map(async (target) => {
const sftp = new Client();
try {
await sftp.connect({
host: target.host,
port: target.port,
username: target.username,
password: target.password,
});
const localPath = target.localPath || "dist";
console.log(
`开始同步文件到 SFTP 服务器 ${target.host}:${target.port} -> ${target.remotePath}`,
);
if (target.clearRemote) {
console.log(`正在清除远程目录 ${target.remotePath}...`);
try {
await sftp.rmdir(target.remotePath, true);
console.log(`远程目录 ${target.remotePath} 已清除`);
} catch (err) {
console.warn(`清除远程目录失败,可能目录不存在: ${err}`);
}
}
await sftp.uploadDir(localPath, target.remotePath);
console.log(`文件同步到 ${target.host} 完成!`);
sftp.end();
return { target, success: true };
} catch (err) {
console.error(`SFTP 同步到 ${target.host} 失败:`, err);
sftp.end();
return { target, success: false, error: err };
}
}),
);
const failures = results.filter(
(result): result is PromiseRejectedResult =>
result.status === "rejected",
);
if (failures.length > 0) {
throw new Error(`部分 SFTP 同步失败: ${failures.length} 个目标`);
}
},
};
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

Binary file not shown.

View File

@@ -0,0 +1,78 @@
# vite-plugin-git-sync
一个 Vite 插件,用于将构建后的文件同步到指定的 Git 仓库。
## 功能特点
- 自动检查并克隆目标 Git 仓库
- 支持清理同步目录
- 支持自定义文件处理函数
- 交互式 Git 提交流程
## 安装
```bash
npm install vite-plugin-git-sync --save-dev
```
## 使用方法
`vite.config.ts` 中配置插件:
```typescript
import { defineConfig } from "vite";
import gitSync from "vite-plugin-git-sync";
export default defineConfig({
plugins: [
gitSync({
gitUrl: "https://github.com/username/repo.git",
syncPath: "./sync-dir",
cleanSyncDir: true,
fileProcessor: async (content, filePath) => {
// 自定义文件处理逻辑
return content;
},
}),
],
});
```
## 配置选项
| 选项 | 类型 | 必填 | 默认值 | 描述 |
| ------------- | -------- | ---- | ------ | ------------------------------------ |
| gitUrl | string | 是 | - | Git 仓库地址 |
| syncPath | string | 是 | - | 同步目标目录(相对于项目根目录) |
| cleanSyncDir | boolean | 否 | false | 是否在同步前清理目标目录 |
| fileProcessor | function | 否 | - | 自定义文件处理函数,可以修改文件内容 |
## 文件处理函数
`fileProcessor` 函数接收两个参数:
- `content`: 文件内容(字符串)
- `filePath`: 文件路径
返回处理后的文件内容(字符串或 Promise<string>)。
## 示例
```typescript
// 简单的文件处理示例
const fileProcessor = async (content: string, filePath: string) => {
if (filePath.endsWith(".js")) {
// 为 JS 文件添加版权信息
return `/* Copyright ${new Date().getFullYear()} */\n${content}`;
}
return content;
};
// 在 vite.config.ts 中使用
gitSync({
gitUrl: "https://github.com/username/repo.git",
syncPath: "./sync-dir",
cleanSyncDir: true,
fileProcessor,
});
```

View File

@@ -0,0 +1,38 @@
{
"name": "@baota/plugin-project-sync-git",
"version": "1.0.0",
"description": "A Vite plugin to sync build files to a git repository",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc -w"
},
"keywords": [
"vite",
"plugin",
"git",
"sync"
],
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"author": "",
"license": "MIT",
"dependencies": {
"inquirer": "^8.2.5",
"simple-git": "^3.22.0"
},
"devDependencies": {
"@types/inquirer": "^8.2.5",
"@types/node": "^20.8.0",
"typescript": "^5.2.2",
"vite": "^4.4.9"
},
"peerDependencies": {
"vite": "^4.0.0"
}
}

View File

@@ -0,0 +1,511 @@
import { Plugin } from "vite";
import { simpleGit, SimpleGit } from "simple-git";
import * as fs from "fs";
import * as path from "path";
import inquirer from "inquirer";
import { promisify } from "util";
import { createReadStream, createWriteStream } from "fs";
import { pipeline } from "stream/promises";
import { Transform } from "stream";
// 将 Node.js 的回调式 API 转换为 Promise 形式
const mkdir = promisify(fs.mkdir);
const readdir = promisify(fs.readdir);
const stat = promisify(fs.stat);
const rm = promisify(fs.rm);
const exists = promisify(fs.exists);
/**
* 插件配置选项接口
*/
export interface GitSyncOptions {
/** Git 仓库地址,支持同步到多个仓库 */
gitUrl: string[];
/** 同步目标目录(相对于项目根目录)的前缀,每个仓库会在此前缀下创建对应的目录 */
syncPath: string;
/** 是否在同步前清理目标目录(可选) */
cleanSyncDir?: boolean;
/** 自定义文件处理函数,可以修改文件内容(可选) */
fileProcessor?: (
content: string,
filePath: string,
) => string | Promise<string>;
/** 是否跳过提交确认(可选),直接进行提交 */
skipConfirmation?: boolean;
}
// 文件统计信息接口
interface FileStats {
size: number;
path: string;
type: "file" | "directory";
children?: FileStats[];
}
// 格式化文件大小
function formatFileSize(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
}
// 获取目录结构
async function getDirectoryStructure(dir: string): Promise<FileStats> {
const stats = await stat(dir);
const result: FileStats = {
size: stats.size,
path: path.basename(dir),
type: stats.isDirectory() ? "directory" : "file",
};
if (stats.isDirectory()) {
const files = await readdir(dir);
result.children = await Promise.all(
files.map(async (file) => {
const fullPath = path.join(dir, file);
return getDirectoryStructure(fullPath);
}),
);
result.size = result.children.reduce(
(total, child) => total + child.size,
0,
);
}
return result;
}
// 打印目录结构
function printDirectoryStructure(stats: FileStats, level = 0): void {
const indent = " ".repeat(level);
const prefix = stats.type === "directory" ? "📁" : "📄";
console.log(
`${indent}${prefix} ${stats.path} (${formatFileSize(stats.size)})`,
);
if (stats.children) {
stats.children.forEach((child) =>
printDirectoryStructure(child, level + 1),
);
}
}
// 创建进度显示流
class ProgressStream extends Transform {
private totalBytes = 0;
private processedBytes = 0;
private lastUpdate = 0;
private readonly updateInterval = 1000; // 更新间隔(毫秒)
constructor(private filePath: string) {
super();
}
_transform(
chunk: Buffer,
encoding: string,
callback: (error?: Error | null, data?: Buffer) => void,
) {
this.processedBytes += chunk.length;
this.totalBytes += chunk.length;
const now = Date.now();
if (now - this.lastUpdate >= this.updateInterval) {
const progress = ((this.processedBytes / this.totalBytes) * 100).toFixed(
2,
);
process.stdout.write(
`\r处理文件 ${this.filePath}: ${progress}% (${formatFileSize(this.processedBytes)} / ${formatFileSize(this.totalBytes)})`,
);
this.lastUpdate = now;
}
callback(null, chunk);
}
_flush(callback: (error?: Error | null) => void) {
process.stdout.write("\n");
callback();
}
}
/**
* 获取当前项目的最新Git提交信息
*
* @returns 最新的Git提交信息
*/
async function getLatestCommitMessage(): Promise<string> {
try {
// 初始化当前项目的Git
const currentProjectGit = simpleGit(process.cwd());
// 检查是否是Git仓库
const isRepo = await currentProjectGit.checkIsRepo();
if (!isRepo) {
return "Update build files"; // 默认提交信息
}
// 获取最新的提交记录
const log = await currentProjectGit.log({ maxCount: 1 });
if (log.latest) {
return `Sync: ${log.latest.message}`;
}
return "Update build files";
} catch (error) {
console.warn("获取最新提交信息失败:", error);
return "Update build files";
}
}
/**
* 处理单个Git仓库的同步
*
* @param repoUrl Git仓库URL
* @param syncBasePath 基础同步路径
* @param distDir 构建输出目录
* @param commitMessage 提交信息
* @param cleanSyncDir 是否清理同步目录
* @param fileProcessor 文件处理函数
* @returns 同步结果
*/
async function syncToRepo(
repoUrl: string,
syncBasePath: string,
distDir: string,
commitMessage: string,
cleanSyncDir: boolean,
fileProcessor?: (
content: string,
filePath: string,
) => string | Promise<string>,
): Promise<boolean> {
// 从仓库URL提取仓库名称作为目录名
const repoName = path
.basename(repoUrl, ".git")
.replace(/[^a-zA-Z0-9_-]/g, "_");
const repoSyncPath = path.join(syncBasePath, repoName);
const absoluteSyncPath = path.resolve(process.cwd(), repoSyncPath);
console.log(`\n开始同步到仓库: ${repoUrl}`);
console.log(`同步目标目录: ${repoSyncPath}`);
let git: SimpleGit;
// 检查同步目录是否存在
const syncDirExists = await exists(absoluteSyncPath);
if (!syncDirExists) {
// 目录不存在,克隆仓库
console.log(`目录 ${repoSyncPath} 不存在,正在克隆仓库...`);
git = simpleGit();
try {
await git.clone(repoUrl, absoluteSyncPath);
console.log(`仓库克隆成功: ${repoUrl}`);
} catch (error) {
console.error(`克隆仓库失败: ${repoUrl}`, error);
return false;
}
}
// 初始化Git
git = simpleGit(absoluteSyncPath);
// 检查是否是有效的Git仓库
try {
const isRepo = await git.checkIsRepo();
if (!isRepo) {
console.error(`目录 ${repoSyncPath} 不是有效的Git仓库`);
return false;
}
} catch (error) {
console.error(`检查Git仓库失败: ${repoSyncPath}`, error);
return false;
}
// 如果需要清理同步目录
if (cleanSyncDir) {
console.log(`清理同步目录: ${repoSyncPath}`);
const files = await readdir(absoluteSyncPath);
for (const file of files) {
// 保留 .git 目录,删除其他所有文件
if (file !== ".git") {
await rm(path.join(absoluteSyncPath, file), {
recursive: true,
force: true,
});
}
}
}
// 复制文件到同步目录
try {
await copyFiles(distDir, absoluteSyncPath, fileProcessor);
} catch (error) {
console.error(`复制文件失败: ${repoSyncPath}`, error);
return false;
}
try {
// 拉取远程仓库以避免冲突
console.log(`拉取远程仓库: ${repoUrl}`);
await git.pull();
// 添加所有文件
console.log(`添加文件到Git: ${repoSyncPath}`);
await git.add(".");
// 检查是否有更改
const status = await git.status();
if (status.files.length === 0) {
console.log(`没有需要提交的更改: ${repoSyncPath}`);
return true;
}
// 提交更改
console.log(`提交更改: ${repoSyncPath}`);
await git.commit(commitMessage);
// 推送到远程仓库
console.log(`推送到远程仓库: ${repoUrl}`);
await git.push();
console.log(`同步成功: ${repoUrl}`);
return true;
} catch (error) {
console.error(`Git操作失败: ${repoUrl}`, error);
return false;
}
}
/**
* Vite 插件:将构建后的文件同步到指定的多个 Git 仓库
*
* 该插件作为当前项目Git之外的同步工具主要用于将当前项目构建后的内容
* 同步到其他Git仓库并提交。
*
* @param options 插件配置选项
* @returns Vite 插件对象
*/
export function pluginProjectSyncGit(options: GitSyncOptions): Plugin {
// 解构配置选项,设置默认值
const {
gitUrl,
syncPath,
cleanSyncDir = false,
fileProcessor = undefined,
skipConfirmation = false,
} = options;
// 存储 vite 配置中的构建输出目录
let viteBuildOutDir: string;
return {
name: "vite-plugin-git-sync",
// 仅在构建模式下应用插件
apply: "build",
// 在配置解析后执行,获取 vite 配置中的构建输出目录
configResolved(config) {
// 获取 vite 配置中的构建输出目录
viteBuildOutDir = config.build.outDir || "dist";
},
// 在构建完成后执行
async closeBundle() {
// 使用 vite 配置中的构建输出目录
console.log(`\n=== 项目构建同步工具 ===`);
console.log(`使用构建输出目录: ${viteBuildOutDir}`);
console.log(`同步目标仓库数量: ${gitUrl.length}`);
// 复制文件到同步目录
const distDir = path.resolve(process.cwd(), viteBuildOutDir);
// 检查构建输出目录是否存在
try {
await stat(distDir);
} catch {
console.error(`构建输出目录 ${distDir} 不存在,请确保构建成功`);
return;
}
// 获取默认提交信息(当前项目的最新提交信息)
const defaultCommitMessage = await getLatestCommitMessage();
// 确认是否要提交
let shouldCommit = skipConfirmation;
let commitMessage = defaultCommitMessage;
if (!skipConfirmation) {
const confirmResult = await inquirer.prompt([
{
type: "confirm",
name: "shouldCommit",
message: "是否要同步并提交更改到Git仓库",
default: true,
},
]);
shouldCommit = confirmResult.shouldCommit;
}
if (shouldCommit) {
// 获取提交信息
const messageResult = await inquirer.prompt([
{
type: "input",
name: "commitMessage",
message: "请输入提交信息(留空使用最新提交信息):",
default: defaultCommitMessage,
},
]);
commitMessage = messageResult.commitMessage || defaultCommitMessage;
console.log(`使用提交信息: "${commitMessage}"`);
// 创建基础同步目录
const absoluteSyncPath = path.resolve(process.cwd(), syncPath);
await mkdir(absoluteSyncPath, { recursive: true });
// 同步到每个仓库
const results = await Promise.all(
gitUrl.map((url) =>
syncToRepo(
url,
syncPath,
distDir,
commitMessage,
cleanSyncDir,
fileProcessor,
),
),
);
// 统计结果
const successCount = results.filter(Boolean).length;
console.log(`\n=== 同步完成 ===`);
console.log(`成功: ${successCount}/${gitUrl.length}`);
if (successCount === gitUrl.length) {
console.log("所有仓库同步成功!");
} else {
console.warn(`部分仓库同步失败,请检查上面的错误信息`);
}
} else {
console.log("用户取消了同步操作");
}
},
};
}
// 优化的文件复制函数
async function copyFiles(
sourceDir: string,
targetDir: string,
fileProcessor?: (
content: string,
filePath: string,
) => string | Promise<string>,
) {
console.log(`\n开始复制文件从 ${sourceDir}${targetDir}...`);
const startTime = Date.now();
// 获取源目录结构
const sourceStructure = await getDirectoryStructure(sourceDir);
console.log("\n源目录结构:");
printDirectoryStructure(sourceStructure);
// 获取源目录中的所有文件
const files = await readdir(sourceDir);
let totalFiles = 0;
let processedFiles = 0;
for (const file of files) {
const sourcePath = path.join(sourceDir, file);
const targetPath = path.join(targetDir, file);
const stats = await stat(sourcePath);
if (stats.isDirectory()) {
// 如果是目录,递归复制
await mkdir(targetPath, { recursive: true });
await copyFiles(sourcePath, targetPath, fileProcessor);
} else {
totalFiles++;
try {
// 如果是文件,根据是否提供 fileProcessor 决定处理方式
if (fileProcessor) {
// 使用流式处理文件内容
const readStream = createReadStream(sourcePath, {
encoding: "utf-8",
});
const writeStream = createWriteStream(targetPath, {
encoding: "utf-8",
});
// 创建进度显示流
const progressStream = new ProgressStream(sourcePath);
// 创建一个转换流来处理文件内容
const transformStream = new Transform({
transform: async (
chunk: Buffer,
encoding: BufferEncoding,
callback: (error?: Error | null, data?: string) => void,
) => {
try {
const processedContent = await fileProcessor(
chunk.toString(),
sourcePath,
);
callback(null, processedContent);
} catch (error) {
callback(
error instanceof Error ? error : new Error(String(error)),
);
}
},
});
// 使用 pipeline 来处理流
await pipeline(
readStream,
progressStream,
transformStream,
writeStream,
);
} else {
// 直接复制文件
const readStream = createReadStream(sourcePath);
const writeStream = createWriteStream(targetPath);
const progressStream = new ProgressStream(sourcePath);
await pipeline(readStream, progressStream, writeStream);
}
processedFiles++;
const progress = ((processedFiles / totalFiles) * 100).toFixed(2);
console.log(`\n总进度: ${progress}% (${processedFiles}/${totalFiles})`);
} catch (error) {
console.error(`\n复制文件失败: ${sourcePath} -> ${targetPath}`, error);
throw error;
}
}
}
const endTime = Date.now();
const duration = (endTime - startTime) / 1000;
console.log(`\n文件复制完成耗时: ${duration.toFixed(2)}`);
// 获取目标目录结构
const targetStructure = await getDirectoryStructure(targetDir);
console.log("\n目标目录结构:");
printDirectoryStructure(targetStructure);
}
export default pluginProjectSyncGit;

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"lib": ["ES2020"],
"declaration": true,
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}