Files
ECShopX_admin-frontend/build/update_i18n_from_excel.js

710 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 从 Excel 文件更新 i18n JSON 文件
* Excel 格式:多个 sheet每个 sheet 有三列:中文、原文、新文本
*/
const fs = require('fs')
const path = require('path')
const ExcelJS = require('exceljs')
/**
* 将 worksheet 转为二维数组(兼容原 xlsx sheet_to_json header:1 格式)
*/
function worksheetToArray(worksheet) {
const jsonData = []
worksheet.eachRow({ includeEmpty: true }, (row) => {
const rowData = []
const maxCol = Math.max(worksheet.columnCount || 0, row.cellCount || 0)
for (let col = 1; col <= maxCol; col++) {
const cell = row.getCell(col)
let value = cell.value
if (value == null) {
value = ''
} else if (typeof value === 'object') {
if (value.richText) {
value = value.richText.map((item) => item.text).join('')
} else if (value.text) {
value = value.text
} else if (value.result !== undefined) {
value = value.result
} else if (value instanceof Date) {
value = value.toISOString()
} else {
value = String(value)
}
}
rowData.push(value)
}
jsonData.push(rowData)
})
return jsonData
}
/**
* 读取 Excel 工作簿
*/
async function readWorkbook(excelPath) {
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.readFile(excelPath)
return workbook
}
/**
* 判断文本是否包含中文字符
*/
function isChineseText(text) {
if (!text) return false
const str = String(text)
return /[\u4e00-\u9fff]/.test(str)
}
/**
* 判断文本是否主要是英文(包含英文字母且不包含中文)
*/
function isEnglishText(text) {
if (!text) return false
const textStr = String(text).trim()
if (!textStr) return false
const hasEnglish = /[a-zA-Z]/.test(textStr)
const hasChinese = /[\u4e00-\u9fff]/.test(textStr)
return hasEnglish && !hasChinese
}
/**
* 建立索引zh-cn -> [keys] 和 en -> [keys],支持一个中文/英文对应多个 key
*/
function buildIndex(data) {
const zhCnToKeys = new Map()
const enToKeys = new Map()
const keyToEntry = new Map()
for (const [key, entry] of Object.entries(data)) {
keyToEntry.set(key, entry)
const zhCnValue = (entry['zh-cn'] || '').trim()
const enValue = (entry.en || '').trim()
// 如果 key 本身就是中文,也加入索引
if (!zhCnValue && isChineseText(key)) {
if (!zhCnToKeys.has(key)) {
zhCnToKeys.set(key, [])
}
zhCnToKeys.get(key).push(key)
} else if (zhCnValue) {
if (!zhCnToKeys.has(zhCnValue)) {
zhCnToKeys.set(zhCnValue, [])
}
zhCnToKeys.get(zhCnValue).push(key)
}
// 建立英文索引(忽略大小写和空格)
if (enValue && isEnglishText(enValue)) {
const enNormalized = enValue.toLowerCase().trim()
if (!enToKeys.has(enNormalized)) {
enToKeys.set(enNormalized, [])
}
enToKeys.get(enNormalized).push(key)
}
}
return { zhCnToKeys, enToKeys }
}
/**
* 通过中文查找匹配的 key完全匹配不使用模糊匹配
*/
function findMatchingKeysByZhCn(zhCn, zhCnToKeys, data) {
const matchedKeys = []
// 确保中文文本已trim用于完全匹配
const zhCnTrimmed = String(zhCn || '').trim()
if (!zhCnTrimmed) {
return matchedKeys
}
// 首先检查 key 本身是否就是中文文本(完全匹配)
if (data[zhCnTrimmed]) {
matchedKeys.push(zhCnTrimmed)
}
// 通过索引查找(完全匹配)
if (zhCnToKeys.has(zhCnTrimmed)) {
for (const key of zhCnToKeys.get(zhCnTrimmed)) {
if (!matchedKeys.includes(key)) {
matchedKeys.push(key)
}
}
}
return matchedKeys
}
/**
* 通过英文查找匹配的 key完全匹配不使用模糊匹配
* 只进行完全匹配,忽略大小写和首尾空格,但必须是完全相同的文本
*/
function findMatchingKeysByEn(enText, enToKeys, data) {
const matchedKeys = []
if (!enText || !isEnglishText(enText)) {
return matchedKeys
}
// 归一化:去除首尾空格并转为小写,用于完全匹配
const enNormalized = String(enText).trim().toLowerCase()
if (!enNormalized) {
return matchedKeys
}
// 只进行完全匹配(忽略大小写),确保文本完全相同
// 不进行部分匹配、包含匹配或相似度匹配
if (enToKeys.has(enNormalized)) {
for (const key of enToKeys.get(enNormalized)) {
if (!matchedKeys.includes(key)) {
matchedKeys.push(key)
}
}
}
return matchedKeys
}
/**
* 查找匹配的 key优先中文如果失败则尝试英文
* 返回匹配的keys和匹配类型
*/
function findMatchingKeys(zhCn, oldEn, zhCnToKeys, enToKeys, data) {
// 首先尝试通过中文匹配(完全匹配)
let matchedKeys = findMatchingKeysByZhCn(zhCn, zhCnToKeys, data)
let matchType = 'zhCn'
// 如果中文匹配失败,且原文是英文,则尝试通过英文匹配(完全匹配)
if (matchedKeys.length === 0 && oldEn && isEnglishText(oldEn)) {
matchedKeys = findMatchingKeysByEn(oldEn, enToKeys, data)
matchType = 'en'
}
return { matchedKeys, matchType }
}
/**
* 查找相似的中文文本(用于错误报告)
*/
function findSimilarText(zhCn, zhCnToKeys, data) {
const similar = []
const zhCnTrim = zhCn.trim()
// 查找包含相同关键词的条目
const keywords = zhCnTrim.split(/[\s。、]/).filter((k) => k.length > 1)
for (const [key, entry] of Object.entries(data)) {
const entryZhCn = (entry['zh-cn'] || '').trim()
if (!entryZhCn) continue
// 计算相似度
let similarity = 0
// 完全匹配
if (entryZhCn === zhCnTrim) {
similarity = 100
}
// 包含关系
else if (entryZhCn.includes(zhCnTrim) || zhCnTrim.includes(entryZhCn)) {
similarity = 50
}
// 关键词匹配
else {
const matchedKeywords = keywords.filter((k) => entryZhCn.includes(k))
if (matchedKeywords.length > 0) {
similarity = (matchedKeywords.length / keywords.length) * 30
}
}
if (similarity > 0) {
similar.push({
key: key,
zhCn: entryZhCn,
similarity: similarity
})
}
}
// 按相似度排序
similar.sort((a, b) => b.similarity - a.similarity)
return similar.slice(0, 5) // 返回前5个最相似的
}
/**
* 智能识别列索引
*/
function detectColumns(headers) {
let zhCnCol = null
let oldEnCol = null
let newEnCol = null
// 查找列索引
headers.forEach((header, idx) => {
const headerStr = String(header || '')
.trim()
.toLowerCase()
if (headerStr.includes('中文') || (idx === 0 && zhCnCol === null)) {
zhCnCol = idx
} else if (headerStr.includes('原文')) {
oldEnCol = idx
} else if (
headerStr.includes('新文本') ||
(headerStr.includes('新') && headerStr.includes('文本'))
) {
newEnCol = idx
}
})
// 如果没找到,使用默认顺序
if (zhCnCol === null) {
zhCnCol = 0
}
if (oldEnCol === null) {
// 尝试找到第一个非空且非中文的列作为原文列
for (let idx = 0; idx < headers.length; idx++) {
if (idx !== zhCnCol && headers[idx]) {
const headerStr = String(headers[idx]).trim()
if (headerStr && (headerStr.includes('原文') || idx === 1)) {
oldEnCol = idx
break
}
}
}
if (oldEnCol === null) {
oldEnCol = headers.length > 1 ? 1 : null
}
}
if (newEnCol === null) {
// 尝试找到"新文本"列
for (let idx = 0; idx < headers.length; idx++) {
if (idx !== zhCnCol && idx !== oldEnCol && headers[idx]) {
const headerStr = String(headers[idx]).trim()
if (headerStr.includes('新') || idx === 2) {
newEnCol = idx
break
}
}
}
if (newEnCol === null) {
newEnCol = headers.length > 2 ? 2 : null
}
}
return { zhCnCol, oldEnCol, newEnCol }
}
/**
* 从 Excel 文件更新 JSON 文件
* @param {string} excelPath - Excel文件路径
* @param {string} jsonPath - JSON文件路径
* @param {Object} data - 可选的JSON数据对象用于批量处理时共享数据
* @returns {Object} 统计信息和更新后的数据对象
*/
async function updateJsonFromExcel(excelPath, jsonPath, data = null) {
console.log(`正在读取 Excel 文件: ${excelPath}`)
const workbook = await readWorkbook(excelPath)
const sheetNames = workbook.worksheets.map((sheet) => sheet.name)
// 读取现有 JSON 文件(如果未提供数据对象)
if (!data) {
console.log(`正在读取 JSON 文件: ${jsonPath}`)
const jsonContent = fs.readFileSync(jsonPath, 'utf-8')
data = JSON.parse(jsonContent)
}
console.log(`当前 JSON 文件包含 ${Object.keys(data).length} 个条目\n`)
// 建立索引
const { zhCnToKeys, enToKeys } = buildIndex(data)
console.log(`已建立 ${zhCnToKeys.size} 个中文文本索引`)
console.log(`已建立 ${enToKeys.size} 个英文文本索引\n`)
// 统计信息
const stats = {
totalSheets: sheetNames.length,
totalRows: 0,
updated: [],
failed: [],
skipped: []
}
// 遍历所有 sheet
console.log(`\n发现 ${sheetNames.length} 个 Sheet: ${sheetNames.join(', ')}\n`)
console.log('开始处理所有 Sheet...\n')
const processedSheets = []
for (const sheetName of sheetNames) {
console.log('='.repeat(60))
console.log(
`处理 Sheet: ${sheetName} (${sheetNames.indexOf(sheetName) + 1}/${sheetNames.length})`
)
console.log('='.repeat(60))
const worksheet = workbook.getWorksheet(sheetName)
const jsonData = worksheet ? worksheetToArray(worksheet) : []
if (jsonData.length === 0) {
console.log(`Sheet '${sheetName}' 为空,跳过\n`)
continue
}
// 读取表头
const headers = jsonData[0] || []
console.log(`表头: ${JSON.stringify(headers)}\n`)
// 智能识别列索引
const { zhCnCol, oldEnCol, newEnCol } = detectColumns(headers)
console.log(`列映射: 中文=${zhCnCol}, 原文=${oldEnCol}, 新文本=${newEnCol}\n`)
// 从第二行开始读取数据
let sheetRowCount = 0
for (let rowIdx = 1; rowIdx < jsonData.length; rowIdx++) {
const row = jsonData[rowIdx]
// 跳过空行
if (!row || !row[zhCnCol]) {
continue
}
const zhCn = String(row[zhCnCol] || '').trim()
if (!zhCn) {
continue
}
sheetRowCount++
stats.totalRows++
// 获取原文和新文本
const oldEn = oldEnCol !== null && row[oldEnCol] ? String(row[oldEnCol]).trim() : ''
const newEn = newEnCol !== null && row[newEnCol] ? String(row[newEnCol]).trim() : ''
// 判断原文是否未翻译(是中文)
const isOldUntranslated = isChineseText(oldEn) || !oldEn
// 确定要使用的英文翻译(优先使用新文本)
let targetEn = newEn || (isEnglishText(oldEn) ? oldEn : '')
if (!targetEn) {
stats.skipped.push({
sheet: sheetName,
row: rowIdx + 1,
zhCn: zhCn,
reason: '没有可用的英文翻译'
})
continue
}
// 查找匹配的条目(优先中文,如果失败则尝试英文)
const { matchedKeys, matchType } = findMatchingKeys(zhCn, oldEn, zhCnToKeys, enToKeys, data)
if (matchedKeys.length === 0) {
// 尝试查找相似的中文文本
const similarKeys = findSimilarText(zhCn, zhCnToKeys, data)
stats.failed.push({
sheet: sheetName,
row: rowIdx + 1,
zhCn: zhCn,
oldEn: oldEn || '(空)',
targetEn: targetEn,
reason: '未找到匹配的中文或英文条目',
similarMatches: similarKeys.slice(0, 5) // 保存前5个相似匹配
})
continue
}
// 更新所有匹配的条目(严格验证完全匹配)
let updatedAny = false
const zhCnTrimmed = String(zhCn || '').trim()
for (const key of matchedKeys) {
const entry = data[key]
const currentEn = (entry.en || '').trim()
const currentZhCn = (entry['zh-cn'] || '').trim()
// 严格验证:根据匹配类型进行完全匹配验证
let isValidMatch = false
if (matchType === 'zhCn') {
// 通过中文匹配验证中文必须完全相等trim后
isValidMatch = currentZhCn === zhCnTrimmed
} else if (matchType === 'en' && oldEn && isEnglishText(oldEn)) {
// 通过英文匹配验证英文必须完全相等trim和toLowerCase后
const oldEnNormalized = String(oldEn).trim().toLowerCase()
const currentEnNormalized = currentEn.toLowerCase()
isValidMatch = currentEnNormalized === oldEnNormalized
}
if (!isValidMatch) {
// 匹配的key但验证失败跳过说明索引可能有问题不应该发生
console.warn(`警告: Key "${key}" 验证失败。匹配类型: ${matchType}。跳过更新。`)
continue
}
// 如果原文未翻译,或者新文本与当前不同,则更新
if (isOldUntranslated || (targetEn && currentEn !== targetEn)) {
entry.en = targetEn
// 只有当中文完全匹配时,才更新中文字段
if (matchType === 'zhCn' && currentZhCn === zhCnTrimmed) {
entry['zh-cn'] = zhCnTrimmed
}
updatedAny = true
}
}
if (updatedAny) {
stats.updated.push({
sheet: sheetName,
row: rowIdx + 1,
zhCn: zhCn,
oldEn: oldEn || '(空)',
newEn: targetEn,
matchedKeys: matchedKeys
})
} else {
stats.skipped.push({
sheet: sheetName,
row: rowIdx + 1,
zhCn: zhCn,
reason: '英文翻译已是最新或无需更新'
})
}
}
console.log(`Sheet '${sheetName}' 处理完成,共处理 ${sheetRowCount}\n`)
processedSheets.push({
name: sheetName,
rows: sheetRowCount,
updated: stats.updated.filter((u) => u.sheet === sheetName).length,
failed: stats.failed.filter((f) => f.sheet === sheetName).length,
skipped: stats.skipped.filter((s) => s.sheet === sheetName).length
})
}
// 显示所有 sheet 的处理摘要
console.log('\n' + '='.repeat(60))
console.log('所有 Sheet 处理摘要')
console.log('='.repeat(60))
processedSheets.forEach((sheet, idx) => {
console.log(`\n${idx + 1}. Sheet: ${sheet.name}`)
console.log(` 处理行数: ${sheet.rows}`)
console.log(` 成功更新: ${sheet.updated}`)
console.log(` 未匹配: ${sheet.failed}`)
console.log(` 跳过: ${sheet.skipped}`)
})
console.log('\n' + '='.repeat(60))
// 生成报告
console.log('\n' + '='.repeat(60))
console.log('更新报告')
console.log('='.repeat(60))
console.log(`总 Sheet 数: ${stats.totalSheets}`)
console.log(`总处理行数: ${stats.totalRows}`)
console.log(`成功更新: ${stats.updated.length}`)
console.log(`跳过: ${stats.skipped.length}`)
console.log(`失败: ${stats.failed.length}`)
console.log('\n' + '='.repeat(60))
// 详细报告:成功更新的条目
if (stats.updated.length > 0) {
console.log(`\n✅ 成功更新的条目 (${stats.updated.length} 条):`)
console.log('-'.repeat(60))
const displayCount = Math.min(20, stats.updated.length)
for (let i = 0; i < displayCount; i++) {
const item = stats.updated[i]
console.log(`${i + 1}. [${item.sheet}] 行${item.row}`)
console.log(` 中文: ${item.zhCn}`)
console.log(` 原文: ${item.oldEn}`)
console.log(` 新翻译: ${item.newEn}`)
const keysDisplay = item.matchedKeys.slice(0, 3).join(', ')
const moreKeys = item.matchedKeys.length > 3 ? '...' : ''
console.log(` 匹配的 Key: ${keysDisplay}${moreKeys}`)
console.log()
}
if (stats.updated.length > 20) {
console.log(` ... 还有 ${stats.updated.length - 20} 条已更新\n`)
}
}
// 详细报告:失败的条目
if (stats.failed.length > 0) {
console.log(`\n${'='.repeat(60)}`)
console.log(`❌ 未替换成功的条目 (${stats.failed.length} 条)`)
console.log(`${'='.repeat(60)}\n`)
stats.failed.forEach((item, i) => {
console.log(`${i + 1}. 中文: "${item.zhCn}"`)
console.log(` 英文: "${item.targetEn}"`)
console.log()
})
// 生成便于搜索的文本文件
const failedSearchPath = 'i18n_failed_search.txt'
let searchContent = `未替换成功的条目\n`
searchContent += `${'='.repeat(60)}\n\n`
searchContent += `${stats.failed.length}\n`
searchContent += `生成时间: ${new Date().toLocaleString('zh-CN')}\n\n`
stats.failed.forEach((item, i) => {
searchContent += `${i + 1}. 中文: "${item.zhCn}"\n`
searchContent += ` 英文: "${item.targetEn}"\n\n`
})
fs.writeFileSync(failedSearchPath, searchContent, 'utf-8')
console.log(`\n📄 已生成文件: ${failedSearchPath}\n`)
}
// 详细报告:跳过的条目
if (stats.skipped.length > 0) {
console.log(`\n⏭️ 跳过的条目 (${stats.skipped.length} 条):`)
console.log('-'.repeat(60))
const skippedByReason = {}
stats.skipped.forEach((item) => {
const reason = item.reason
if (!skippedByReason[reason]) {
skippedByReason[reason] = []
}
skippedByReason[reason].push(item)
})
for (const [reason, items] of Object.entries(skippedByReason)) {
console.log(`\n原因: ${reason} (${items.length} 条)`)
const displayCount = Math.min(10, items.length)
for (let i = 0; i < displayCount; i++) {
const item = items[i]
console.log(` ${i + 1}. [${item.sheet}] 行${item.row}: ${item.zhCn}`)
}
if (items.length > 10) {
console.log(` ... 还有 ${items.length - 10}`)
}
}
}
// 保存更新后的 JSON 文件(仅在单独调用时保存)
// 批量处理时由主函数统一保存
const shouldSave = !process.argv[2] || process.argv[2] === excelPath
if (stats.updated.length > 0 && shouldSave) {
// 备份原文件(仅在第一次保存时备份)
const backupPath = jsonPath + '.backup'
if (!fs.existsSync(backupPath)) {
console.log(`\n正在备份原文件到: ${backupPath}`)
const originalContent = fs.readFileSync(jsonPath, 'utf-8')
fs.writeFileSync(backupPath, originalContent, 'utf-8')
}
// 保存更新后的文件
console.log(`正在保存更新后的文件: ${jsonPath}`)
fs.writeFileSync(jsonPath, JSON.stringify(data, null, '\t'), 'utf-8')
console.log('✅ 文件保存成功!')
} else if (stats.updated.length === 0) {
console.log('\n⚠ 没有条目需要更新,文件未修改')
}
// 保存详细报告到文件(每个文件单独保存报告)
const reportPath = `i18n_update_report_${path.basename(excelPath, '.xlsx')}.json`
fs.writeFileSync(reportPath, JSON.stringify(stats, null, 2), 'utf-8')
console.log(`\n详细报告已保存到: ${reportPath}`)
return { stats, data }
}
// 主函数
async function main() {
const jsonPath = 'src/i18n/lang/index.json'
// 支持命令行参数指定 Excel 文件或使用默认的4个文件
const excelFiles = process.argv[2]
? [process.argv[2]]
: ['gy.xlsx', 'gyg.xlsx', 'sale.xlsx', 'yd.xlsx']
if (!fs.existsSync(jsonPath)) {
console.error(`❌ 错误: JSON 文件不存在: ${jsonPath}`)
process.exit(1)
}
let totalFailed = 0
let totalUpdated = 0
const allStats = []
// 读取JSON文件所有文件共享同一个数据对象
console.log(`正在读取 JSON 文件: ${jsonPath}`)
const jsonContent = fs.readFileSync(jsonPath, 'utf-8')
let sharedData = JSON.parse(jsonContent)
console.log(`初始 JSON 文件包含 ${Object.keys(sharedData).length} 个条目\n`)
// 处理每个Excel文件
for (const excelPath of excelFiles) {
if (!fs.existsSync(excelPath)) {
console.warn(`⚠️ 警告: Excel 文件不存在: ${excelPath},跳过`)
continue
}
console.log('\n' + '='.repeat(80))
console.log(`处理文件: ${excelPath}`)
console.log('='.repeat(80))
try {
const result = await updateJsonFromExcel(excelPath, jsonPath, sharedData)
sharedData = result.data // 更新共享的数据对象
allStats.push({ file: excelPath, stats: result.stats })
totalFailed += result.stats.failed.length
totalUpdated += result.stats.updated.length
} catch (error) {
console.error(`\n❌ 处理文件 ${excelPath} 时发生错误: ${error.message}`)
console.error(error.stack)
// 继续处理其他文件
}
}
// 批量处理时统一保存更新后的JSON文件
if (excelFiles.length > 1 && totalUpdated > 0) {
// 备份原文件
const backupPath = jsonPath + '.backup'
if (!fs.existsSync(backupPath)) {
console.log(`\n正在备份原文件到: ${backupPath}`)
fs.writeFileSync(backupPath, jsonContent, 'utf-8')
}
// 保存更新后的文件
console.log(`\n正在保存更新后的文件: ${jsonPath}`)
fs.writeFileSync(jsonPath, JSON.stringify(sharedData, null, '\t'), 'utf-8')
console.log('✅ 文件保存成功!')
}
// 汇总报告
console.log('\n' + '='.repeat(80))
console.log('所有文件处理汇总')
console.log('='.repeat(80))
console.log(`处理文件数: ${allStats.length}`)
console.log(`总更新条目: ${totalUpdated}`)
console.log(`总失败条目: ${totalFailed}`)
// 返回码如果有失败的条目返回非0
if (totalFailed > 0) {
console.log(`\n⚠️ 警告: 有 ${totalFailed} 条未找到匹配的条目`)
process.exit(1)
} else {
console.log('\n✅ 所有条目处理完成!')
process.exit(0)
}
}
// 运行主函数
if (require.main === module) {
main().catch((error) => {
console.error(`\n❌ 发生错误: ${error.message}`)
console.error(error.stack)
process.exit(1)
})
}
module.exports = { updateJsonFromExcel }