Compare commits
2 Commits
f53fa5e2a1
...
95ce49378b
| Author | SHA1 | Date | |
|---|---|---|---|
| 95ce49378b | |||
| 2f5ec1c0f0 |
@@ -373,7 +373,8 @@
|
|||||||
"success": "Textpaar zum Ordner hinzugefügt",
|
"success": "Textpaar zum Ordner hinzugefügt",
|
||||||
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
|
"error": "Fehler beim Hinzufügen des Textpaars zum Ordner"
|
||||||
},
|
},
|
||||||
"autoSave": "Autom. Speichern"
|
"autoSave": "Autom. Speichern",
|
||||||
|
"customLanguage": "oder Sprache eingeben..."
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "Wörterbuch",
|
"title": "Wörterbuch",
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
"unfavorite": "Unfavorite",
|
"unfavorite": "Unfavorite",
|
||||||
"pleaseLogin": "Please login first"
|
"pleaseLogin": "Please login first"
|
||||||
},
|
},
|
||||||
"folder_id": {
|
"folder_id": {
|
||||||
"unauthorized": "You are not the owner of this folder",
|
"unauthorized": "You are not the owner of this folder",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"textPairs": "Text Pairs",
|
"textPairs": "Text Pairs",
|
||||||
@@ -287,7 +287,7 @@
|
|||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
"settings": "Settings"
|
"settings": "Settings"
|
||||||
},
|
},
|
||||||
"ocr": {
|
"ocr": {
|
||||||
"title": "OCR Vocabulary Extractor",
|
"title": "OCR Vocabulary Extractor",
|
||||||
"description": "Upload vocabulary table screenshots from textbooks to extract word-definition pairs",
|
"description": "Upload vocabulary table screenshots from textbooks to extract word-definition pairs",
|
||||||
"uploadSection": "Upload Image",
|
"uploadSection": "Upload Image",
|
||||||
@@ -400,6 +400,7 @@
|
|||||||
"auto": "Auto",
|
"auto": "Auto",
|
||||||
"generateIPA": "generate ipa",
|
"generateIPA": "generate ipa",
|
||||||
"translateInto": "translate into",
|
"translateInto": "translate into",
|
||||||
|
"customLanguage": "or type language...",
|
||||||
"chinese": "Chinese",
|
"chinese": "Chinese",
|
||||||
"english": "English",
|
"english": "English",
|
||||||
"french": "French",
|
"french": "French",
|
||||||
|
|||||||
@@ -402,7 +402,8 @@
|
|||||||
"success": "Paire de texte ajoutée au dossier",
|
"success": "Paire de texte ajoutée au dossier",
|
||||||
"error": "Échec de l'ajout de la paire de texte au dossier"
|
"error": "Échec de l'ajout de la paire de texte au dossier"
|
||||||
},
|
},
|
||||||
"autoSave": "Sauvegarde automatique"
|
"autoSave": "Sauvegarde automatique",
|
||||||
|
"customLanguage": "ou tapez la langue..."
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "Dictionnaire",
|
"title": "Dictionnaire",
|
||||||
@@ -508,15 +509,6 @@
|
|||||||
"view": "Voir"
|
"view": "Voir"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"follow": {
|
|
||||||
"follow": "Suivre",
|
|
||||||
"following": "Abonnements",
|
|
||||||
"followers": "Abonnés",
|
|
||||||
"followersOf": "Abonnés de {username}",
|
|
||||||
"followingOf": "Abonnements de {username}",
|
|
||||||
"noFollowers": "Pas encore d'abonnés",
|
|
||||||
"noFollowing": "Ne suit personne"
|
|
||||||
},
|
|
||||||
"follow": {
|
"follow": {
|
||||||
"follow": "Suivre",
|
"follow": "Suivre",
|
||||||
"following": "Abonné",
|
"following": "Abonné",
|
||||||
|
|||||||
@@ -402,7 +402,8 @@
|
|||||||
"success": "Coppia di testo aggiunta alla cartella",
|
"success": "Coppia di testo aggiunta alla cartella",
|
||||||
"error": "Impossibile aggiungere coppia di testo alla cartella"
|
"error": "Impossibile aggiungere coppia di testo alla cartella"
|
||||||
},
|
},
|
||||||
"autoSave": "Salvataggio Automatico"
|
"autoSave": "Salvataggio Automatico",
|
||||||
|
"customLanguage": "o digita lingua..."
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "Dizionario",
|
"title": "Dizionario",
|
||||||
@@ -508,15 +509,6 @@
|
|||||||
"view": "Visualizza"
|
"view": "Visualizza"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"follow": {
|
|
||||||
"follow": "Segui",
|
|
||||||
"following": "Seguiti",
|
|
||||||
"followers": "Seguaci",
|
|
||||||
"followersOf": "Seguaci di {username}",
|
|
||||||
"followingOf": "Seguiti di {username}",
|
|
||||||
"noFollowers": "Nessun seguace ancora",
|
|
||||||
"noFollowing": "Non segui ancora nessuno"
|
|
||||||
},
|
|
||||||
"follow": {
|
"follow": {
|
||||||
"follow": "Segui",
|
"follow": "Segui",
|
||||||
"following": "Stai seguendo",
|
"following": "Stai seguendo",
|
||||||
|
|||||||
@@ -411,7 +411,8 @@
|
|||||||
"success": "テキストペアがフォルダーに追加されました",
|
"success": "テキストペアがフォルダーに追加されました",
|
||||||
"error": "テキストペアをフォルダーに追加できませんでした"
|
"error": "テキストペアをフォルダーに追加できませんでした"
|
||||||
},
|
},
|
||||||
"autoSave": "自動保存"
|
"autoSave": "自動保存",
|
||||||
|
"customLanguage": "または言語を入力..."
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "辞書",
|
"title": "辞書",
|
||||||
|
|||||||
@@ -402,7 +402,8 @@
|
|||||||
"success": "텍스트 쌍이 폴더에 추가됨",
|
"success": "텍스트 쌍이 폴더에 추가됨",
|
||||||
"error": "폴더에 텍스트 쌍 추가 실패"
|
"error": "폴더에 텍스트 쌍 추가 실패"
|
||||||
},
|
},
|
||||||
"autoSave": "자동 저장"
|
"autoSave": "자동 저장",
|
||||||
|
"customLanguage": "또는 언어 입력..."
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "사전",
|
"title": "사전",
|
||||||
@@ -508,15 +509,6 @@
|
|||||||
"view": "보기"
|
"view": "보기"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"follow": {
|
|
||||||
"follow": "팔로우",
|
|
||||||
"following": "팔로잉",
|
|
||||||
"followers": "팔로워",
|
|
||||||
"followersOf": "{username}의 팔로워",
|
|
||||||
"followingOf": "{username}의 팔로잉",
|
|
||||||
"noFollowers": "아직 팔로워가 없습니다",
|
|
||||||
"noFollowing": "아직 팔로잉하는 사람이 없습니다"
|
|
||||||
},
|
|
||||||
"follow": {
|
"follow": {
|
||||||
"follow": "팔로우",
|
"follow": "팔로우",
|
||||||
"following": "팔로잉",
|
"following": "팔로잉",
|
||||||
|
|||||||
@@ -402,7 +402,8 @@
|
|||||||
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
"success": "تېكىست جۈپى قىسقۇچقا قوشۇلدى",
|
||||||
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
|
"error": "تېكىست جۈپىنى قىسقۇچقا قوشۇش مەغلۇپ بولدى"
|
||||||
},
|
},
|
||||||
"autoSave": "ئاپتوماتىك ساقلاش"
|
"autoSave": "ئاپتوماتىك ساقلاش",
|
||||||
|
"customLanguage": "ياكى تىل تىل كىرۇڭ..."
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "لۇغەت",
|
"title": "لۇغەت",
|
||||||
@@ -508,15 +509,6 @@
|
|||||||
"view": "كۆرۈش"
|
"view": "كۆرۈش"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"follow": {
|
|
||||||
"follow": "ئەگىشىش",
|
|
||||||
"following": "ئەگىشىۋاتقانلار",
|
|
||||||
"followers": "ئەگەشكۈچىلەر",
|
|
||||||
"followersOf": "{username} نىڭ ئەگەشكۈچىلىرى",
|
|
||||||
"followingOf": "{username} نىڭ ئەگىشىۋاتقانلىرى",
|
|
||||||
"noFollowers": "تېخى ئەگەشكۈچى يوق",
|
|
||||||
"noFollowing": "تېخى ئەگىشىۋاتقان يوق"
|
|
||||||
},
|
|
||||||
"follow": {
|
"follow": {
|
||||||
"follow": "ئەگىشىش",
|
"follow": "ئەگىشىش",
|
||||||
"following": "ئەگىشىۋاتىدۇ",
|
"following": "ئەگىشىۋاتىدۇ",
|
||||||
|
|||||||
@@ -424,7 +424,8 @@
|
|||||||
"success": "文本对已添加到文件夹",
|
"success": "文本对已添加到文件夹",
|
||||||
"error": "添加文本对到文件夹失败"
|
"error": "添加文本对到文件夹失败"
|
||||||
},
|
},
|
||||||
"autoSave": "自动保存"
|
"autoSave": "自动保存",
|
||||||
|
"customLanguage": "或输入语言..."
|
||||||
},
|
},
|
||||||
"dictionary": {
|
"dictionary": {
|
||||||
"title": "词典",
|
"title": "词典",
|
||||||
|
|||||||
291
scripts/find-missing-translations.ts
Normal file
291
scripts/find-missing-translations.ts
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
/**
|
||||||
|
* 查找缺失的翻译键
|
||||||
|
*
|
||||||
|
* 扫描代码中使用的翻译键,与翻译文件对比,找出缺失的键
|
||||||
|
*
|
||||||
|
* 用法: npx tsx scripts/find-missing-translations.ts [locale]
|
||||||
|
* 示例:
|
||||||
|
* npx tsx scripts/find-missing-translations.ts # 检查所有语言
|
||||||
|
* npx tsx scripts/find-missing-translations.ts en-US # 只检查 en-US
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const SRC_DIR = "./src";
|
||||||
|
const MESSAGES_DIR = "./messages";
|
||||||
|
|
||||||
|
// 所有支持的语言
|
||||||
|
const ALL_LOCALES = [
|
||||||
|
"en-US",
|
||||||
|
"zh-CN",
|
||||||
|
"ja-JP",
|
||||||
|
"ko-KR",
|
||||||
|
"de-DE",
|
||||||
|
"fr-FR",
|
||||||
|
"it-IT",
|
||||||
|
"ug-CN",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FileTranslationUsage {
|
||||||
|
file: string;
|
||||||
|
namespace: string;
|
||||||
|
keys: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归获取目录下所有文件
|
||||||
|
*/
|
||||||
|
function getAllFiles(dir: string, extensions: string[]): string[] {
|
||||||
|
const files: string[] = [];
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...getAllFiles(fullPath, extensions));
|
||||||
|
} else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从代码文件中提取翻译使用情况
|
||||||
|
*/
|
||||||
|
function extractTranslationsFromFile(filePath: string): FileTranslationUsage[] {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
const usages: FileTranslationUsage[] = [];
|
||||||
|
|
||||||
|
// 匹配 useTranslations("namespace") 或 getTranslations("namespace")
|
||||||
|
const namespacePattern = /(?:useTranslations|getTranslations)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
|
||||||
|
|
||||||
|
// 匹配没有参数的 useTranslations() 或 getTranslations() - 使用根级别
|
||||||
|
const rootNamespacePattern = /(?:useTranslations|getTranslations)\s*\(\s*\)/g;
|
||||||
|
|
||||||
|
// 首先找到所有的 namespace 声明
|
||||||
|
const namespaces: { name: string; isRoot: boolean }[] = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = namespacePattern.exec(content)) !== null) {
|
||||||
|
namespaces.push({ name: match[1], isRoot: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有根级别的翻译
|
||||||
|
let hasRootTranslations = false;
|
||||||
|
while ((match = rootNamespacePattern.exec(content)) !== null) {
|
||||||
|
hasRootTranslations = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (namespaces.length === 0 && !hasRootTranslations) {
|
||||||
|
return usages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配 t("key") 或 t('key') 或 t(`key`) 调用
|
||||||
|
// 支持简单键、嵌套键、带插值的键
|
||||||
|
const tCallPattern = /\bt\s*\(\s*["'`]([^"'`]+)["'`]\s*(?:,|\))/g;
|
||||||
|
|
||||||
|
const allKeys = new Set<string>();
|
||||||
|
|
||||||
|
while ((match = tCallPattern.exec(content)) !== null) {
|
||||||
|
allKeys.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配模板字面量动态键 t(`prefix.${variable}`)
|
||||||
|
const dynamicTCallPattern = /\bt\s*\(\s*`([^`]+)\$\{[^}]+\}([^`]*)`\s*(?:,|\))/g;
|
||||||
|
while ((match = dynamicTCallPattern.exec(content)) !== null) {
|
||||||
|
// 标记为动态键,前缀部分
|
||||||
|
const prefix = match[1];
|
||||||
|
if (prefix) {
|
||||||
|
allKeys.add(`[DYNAMIC PREFIX: ${prefix}]`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allKeys.size === 0) {
|
||||||
|
return usages;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为每个 namespace 创建使用记录
|
||||||
|
for (const ns of namespaces) {
|
||||||
|
usages.push({
|
||||||
|
file: filePath,
|
||||||
|
namespace: ns.name,
|
||||||
|
keys: allKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有根级别翻译,添加一个特殊的 namespace
|
||||||
|
if (hasRootTranslations) {
|
||||||
|
usages.push({
|
||||||
|
file: filePath,
|
||||||
|
namespace: "__ROOT__",
|
||||||
|
keys: allKeys,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return usages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取翻译对象中所有的键路径
|
||||||
|
*/
|
||||||
|
function getAllKeysFromTranslation(obj: Record<string, unknown>, prefix = ""): Set<string> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
keys.add(fullKey);
|
||||||
|
|
||||||
|
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||||
|
const nestedKeys = getAllKeysFromTranslation(
|
||||||
|
obj[key] as Record<string, unknown>,
|
||||||
|
fullKey
|
||||||
|
);
|
||||||
|
nestedKeys.forEach(k => keys.add(k));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查键是否存在于翻译对象中
|
||||||
|
*/
|
||||||
|
function keyExistsInTranslation(key: string, namespace: string, translations: Record<string, unknown>): boolean {
|
||||||
|
// 处理根级别翻译
|
||||||
|
if (namespace === "__ROOT__") {
|
||||||
|
const parts = key.split(".");
|
||||||
|
let current: unknown = translations;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (typeof current !== "object" || current === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
current = (current as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof current === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理带命名空间的翻译
|
||||||
|
const nsTranslations = translations[namespace];
|
||||||
|
if (!nsTranslations || typeof nsTranslations !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = key.split(".");
|
||||||
|
let current: unknown = nsTranslations;
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (typeof current !== "object" || current === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
current = (current as Record<string, unknown>)[part];
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof current === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主函数
|
||||||
|
*/
|
||||||
|
function main() {
|
||||||
|
const targetLocale = process.argv[2];
|
||||||
|
const localesToCheck = targetLocale
|
||||||
|
? [targetLocale]
|
||||||
|
: ALL_LOCALES;
|
||||||
|
|
||||||
|
console.log("🔍 扫描代码中的翻译使用情况...\n");
|
||||||
|
|
||||||
|
// 获取所有源代码文件
|
||||||
|
const sourceFiles = [
|
||||||
|
...getAllFiles(SRC_DIR, [".tsx", ".ts"]),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 提取所有翻译使用
|
||||||
|
const allUsages: FileTranslationUsage[] = [];
|
||||||
|
for (const file of sourceFiles) {
|
||||||
|
allUsages.push(...extractTranslationsFromFile(file));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📁 扫描了 ${sourceFiles.length} 个文件`);
|
||||||
|
console.log(`📝 发现 ${allUsages.length} 个翻译使用声明\n`);
|
||||||
|
|
||||||
|
// 检查每种语言
|
||||||
|
for (const locale of localesToCheck) {
|
||||||
|
console.log(`\n${"=".repeat(60)}`);
|
||||||
|
console.log(`🌐 检查语言: ${locale}`);
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
|
||||||
|
const translationPath = path.join(MESSAGES_DIR, `${locale}.json`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(translationPath)) {
|
||||||
|
console.log(`❌ 翻译文件不存在: ${translationPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let translations: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(translationPath, "utf-8");
|
||||||
|
translations = JSON.parse(content);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`❌ 翻译文件解析失败: ${translationPath}`);
|
||||||
|
console.log(e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingKeys: { file: string; namespace: string; key: string }[] = [];
|
||||||
|
|
||||||
|
for (const usage of allUsages) {
|
||||||
|
for (const key of usage.keys) {
|
||||||
|
// 跳过动态键标记
|
||||||
|
if (key.startsWith("[DYNAMIC PREFIX:")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keyExistsInTranslation(key, usage.namespace, translations)) {
|
||||||
|
missingKeys.push({
|
||||||
|
file: usage.file,
|
||||||
|
namespace: usage.namespace,
|
||||||
|
key: key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingKeys.length === 0) {
|
||||||
|
console.log(`✅ 没有缺失的翻译键`);
|
||||||
|
} else {
|
||||||
|
console.log(`\n❌ 发现 ${missingKeys.length} 个缺失的翻译键:\n`);
|
||||||
|
|
||||||
|
// 按文件分组显示
|
||||||
|
const groupedByFile = new Map<string, typeof missingKeys>();
|
||||||
|
for (const item of missingKeys) {
|
||||||
|
const file = item.file;
|
||||||
|
if (!groupedByFile.has(file)) {
|
||||||
|
groupedByFile.set(file, []);
|
||||||
|
}
|
||||||
|
groupedByFile.get(file)!.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [file, items] of groupedByFile) {
|
||||||
|
console.log(`📄 ${file}`);
|
||||||
|
for (const item of items) {
|
||||||
|
const nsDisplay = item.namespace === "__ROOT__" ? "(root)" : item.namespace;
|
||||||
|
console.log(` [${nsDisplay}] ${item.key}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n✨ 检查完成!");
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
331
scripts/find-unused-translations.ts
Normal file
331
scripts/find-unused-translations.ts
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* 查找多余(未使用)的翻译键
|
||||||
|
*
|
||||||
|
* 扫描翻译文件中的所有键,与代码中实际使用的键对比,找出多余的键
|
||||||
|
*
|
||||||
|
* 用法: npx tsx scripts/find-unused-translations.ts [locale]
|
||||||
|
* 示例:
|
||||||
|
* npx tsx scripts/find-unused-translations.ts # 检查所有语言
|
||||||
|
* npx tsx scripts/find-unused-translations.ts en-US # 只检查 en-US
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "fs";
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
const SRC_DIR = "./src";
|
||||||
|
const MESSAGES_DIR = "./messages";
|
||||||
|
|
||||||
|
// 所有支持的语言
|
||||||
|
const ALL_LOCALES = [
|
||||||
|
"en-US",
|
||||||
|
"zh-CN",
|
||||||
|
"ja-JP",
|
||||||
|
"ko-KR",
|
||||||
|
"de-DE",
|
||||||
|
"fr-FR",
|
||||||
|
"it-IT",
|
||||||
|
"ug-CN",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归获取目录下所有文件
|
||||||
|
*/
|
||||||
|
function getAllFiles(dir: string, extensions: string[]): string[] {
|
||||||
|
const files: string[] = [];
|
||||||
|
|
||||||
|
if (!fs.existsSync(dir)) return files;
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...getAllFiles(fullPath, extensions));
|
||||||
|
} else if (entry.isFile() && extensions.some(ext => entry.name.endsWith(ext))) {
|
||||||
|
files.push(fullPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代码中实际使用的翻译键
|
||||||
|
*/
|
||||||
|
interface UsedTranslations {
|
||||||
|
// namespace -> Set of keys
|
||||||
|
byNamespace: Map<string, Set<string>>;
|
||||||
|
// 根级别的完整路径 (当使用 useTranslations() 无参数时)
|
||||||
|
rootKeys: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从代码文件中提取使用的翻译键
|
||||||
|
*/
|
||||||
|
function extractUsedTranslationsFromFiles(files: string[]): UsedTranslations {
|
||||||
|
const result: UsedTranslations = {
|
||||||
|
byNamespace: new Map(),
|
||||||
|
rootKeys: new Set(),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
const content = fs.readFileSync(filePath, "utf-8");
|
||||||
|
|
||||||
|
// 匹配 useTranslations("namespace") 或 getTranslations("namespace")
|
||||||
|
const namespacePattern = /(?:useTranslations|getTranslations)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)/g;
|
||||||
|
|
||||||
|
// 检查是否有根级别的翻译 (无参数调用)
|
||||||
|
const hasRootTranslations = /(?:useTranslations|getTranslations)\s*\(\s*\)/.test(content);
|
||||||
|
|
||||||
|
// 收集该文件中的所有 namespace
|
||||||
|
const namespaces: string[] = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = namespacePattern.exec(content)) !== null) {
|
||||||
|
namespaces.push(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配 t("key") 或 t('key') 或 t(`key`) 调用
|
||||||
|
const tCallPattern = /\bt\s*\(\s*["'`]([^"'`]+)["'`]\s*(?:,|\))/g;
|
||||||
|
|
||||||
|
const keysInFile = new Set<string>();
|
||||||
|
|
||||||
|
while ((match = tCallPattern.exec(content)) !== null) {
|
||||||
|
keysInFile.add(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匹配带命名空间的 t 调用 t("namespace.key") - 跨 namespace 访问
|
||||||
|
const crossNamespacePattern = /\bt\s*\(\s*["'`]([^.]+\.[^"'`]+)["'`]\s*(?:,|\))/g;
|
||||||
|
|
||||||
|
// 将键添加到对应的 namespace
|
||||||
|
for (const ns of namespaces) {
|
||||||
|
if (!result.byNamespace.has(ns)) {
|
||||||
|
result.byNamespace.set(ns, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keysInFile) {
|
||||||
|
// 检查是否是跨 namespace 访问 (包含点)
|
||||||
|
const dotIndex = key.indexOf(".");
|
||||||
|
if (dotIndex > 0) {
|
||||||
|
const possibleNs = key.substring(0, dotIndex);
|
||||||
|
const remainingKey = key.substring(dotIndex + 1);
|
||||||
|
|
||||||
|
// 如果第一部分是已知的 namespace,则是跨 namespace 访问
|
||||||
|
if (result.byNamespace.has(possibleNs) || possibleNs !== ns) {
|
||||||
|
// 添加到对应的 namespace
|
||||||
|
if (!result.byNamespace.has(possibleNs)) {
|
||||||
|
result.byNamespace.set(possibleNs, new Set());
|
||||||
|
}
|
||||||
|
result.byNamespace.get(possibleNs)!.add(remainingKey);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.byNamespace.get(ns)!.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有根级别翻译,添加到 rootKeys
|
||||||
|
if (hasRootTranslations) {
|
||||||
|
for (const key of keysInFile) {
|
||||||
|
result.rootKeys.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取翻译对象中所有的键路径(带完整路径)
|
||||||
|
*/
|
||||||
|
function getAllTranslationKeys(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
locale: string
|
||||||
|
): { namespace: string; key: string; fullPath: string }[] {
|
||||||
|
const result: { namespace: string; key: string; fullPath: string }[] = [];
|
||||||
|
|
||||||
|
// 获取顶层 namespace
|
||||||
|
for (const ns of Object.keys(obj)) {
|
||||||
|
const nsValue = obj[ns];
|
||||||
|
|
||||||
|
if (typeof nsValue === "object" && nsValue !== null) {
|
||||||
|
// 递归获取该 namespace 下的所有键
|
||||||
|
const keys = getNestedKeys(nsValue as Record<string, unknown>);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
result.push({
|
||||||
|
namespace: ns,
|
||||||
|
key: key,
|
||||||
|
fullPath: `${ns}.${key}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (typeof nsValue === "string") {
|
||||||
|
// 顶层直接是字符串值
|
||||||
|
result.push({
|
||||||
|
namespace: ns,
|
||||||
|
key: "",
|
||||||
|
fullPath: ns,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归获取嵌套对象的键路径
|
||||||
|
*/
|
||||||
|
function getNestedKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
const fullKey = prefix ? `${prefix}.${key}` : key;
|
||||||
|
|
||||||
|
if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||||
|
keys.push(...getNestedKeys(obj[key] as Record<string, unknown>, fullKey));
|
||||||
|
} else if (typeof obj[key] === "string") {
|
||||||
|
keys.push(fullKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查键是否被代码使用
|
||||||
|
*/
|
||||||
|
function isKeyUsed(
|
||||||
|
namespace: string,
|
||||||
|
key: string,
|
||||||
|
used: UsedTranslations
|
||||||
|
): boolean {
|
||||||
|
// 检查该 namespace 下是否使用了该键
|
||||||
|
const nsKeys = used.byNamespace.get(namespace);
|
||||||
|
if (nsKeys) {
|
||||||
|
// 检查精确匹配
|
||||||
|
if (nsKeys.has(key)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查前缀匹配 (父级键被使用时,子键也算被使用)
|
||||||
|
for (const usedKey of nsKeys) {
|
||||||
|
if (key.startsWith(usedKey + ".") || usedKey.startsWith(key + ".")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查根级别使用
|
||||||
|
for (const rootKey of used.rootKeys) {
|
||||||
|
if (rootKey === `${namespace}.${key}` || rootKey.startsWith(`${namespace}.${key}.`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查跨 namespace 使用 (t("namespace.key") 形式)
|
||||||
|
for (const [ns, keys] of used.byNamespace) {
|
||||||
|
for (const k of keys) {
|
||||||
|
// 检查是否是跨 namespace 访问
|
||||||
|
if (k.includes(".")) {
|
||||||
|
const fullKey = `${ns}.${k}`;
|
||||||
|
if (fullKey === `${namespace}.${key}`) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主函数
|
||||||
|
*/
|
||||||
|
function main() {
|
||||||
|
const targetLocale = process.argv[2];
|
||||||
|
const localesToCheck = targetLocale
|
||||||
|
? [targetLocale]
|
||||||
|
: ALL_LOCALES;
|
||||||
|
|
||||||
|
console.log("🔍 扫描代码中使用的翻译键...\n");
|
||||||
|
|
||||||
|
// 获取所有源代码文件
|
||||||
|
const sourceFiles = getAllFiles(SRC_DIR, [".tsx", ".ts"]);
|
||||||
|
|
||||||
|
console.log(`📁 扫描了 ${sourceFiles.length} 个文件`);
|
||||||
|
|
||||||
|
// 提取代码中使用的翻译
|
||||||
|
const usedTranslations = extractUsedTranslationsFromFiles(sourceFiles);
|
||||||
|
|
||||||
|
console.log(`📝 发现 ${usedTranslations.byNamespace.size} 个命名空间被使用`);
|
||||||
|
let totalKeys = 0;
|
||||||
|
for (const keys of usedTranslations.byNamespace.values()) {
|
||||||
|
totalKeys += keys.size;
|
||||||
|
}
|
||||||
|
console.log(`📝 发现 ${totalKeys} 个翻译键被使用\n`);
|
||||||
|
|
||||||
|
// 检查每种语言
|
||||||
|
for (const locale of localesToCheck) {
|
||||||
|
console.log(`\n${"=".repeat(60)}`);
|
||||||
|
console.log(`🌐 检查语言: ${locale}`);
|
||||||
|
console.log("=".repeat(60));
|
||||||
|
|
||||||
|
const translationPath = path.join(MESSAGES_DIR, `${locale}.json`);
|
||||||
|
|
||||||
|
if (!fs.existsSync(translationPath)) {
|
||||||
|
console.log(`❌ 翻译文件不存在: ${translationPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let translations: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(translationPath, "utf-8");
|
||||||
|
translations = JSON.parse(content);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`❌ 翻译文件解析失败: ${translationPath}`);
|
||||||
|
console.log(e);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取翻译文件中的所有键
|
||||||
|
const allTranslationKeys = getAllTranslationKeys(translations, locale);
|
||||||
|
|
||||||
|
console.log(`📊 翻译文件中共有 ${allTranslationKeys.length} 个翻译键`);
|
||||||
|
|
||||||
|
// 找出未使用的键
|
||||||
|
const unusedKeys = allTranslationKeys.filter(
|
||||||
|
item => !isKeyUsed(item.namespace, item.key, usedTranslations)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unusedKeys.length === 0) {
|
||||||
|
console.log(`✅ 没有多余的翻译键`);
|
||||||
|
} else {
|
||||||
|
console.log(`\n⚠️ 发现 ${unusedKeys.length} 个可能多余的翻译键:\n`);
|
||||||
|
|
||||||
|
// 按 namespace 分组显示
|
||||||
|
const groupedByNs = new Map<string, typeof unusedKeys>();
|
||||||
|
for (const item of unusedKeys) {
|
||||||
|
if (!groupedByNs.has(item.namespace)) {
|
||||||
|
groupedByNs.set(item.namespace, []);
|
||||||
|
}
|
||||||
|
groupedByNs.get(item.namespace)!.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [ns, items] of groupedByNs) {
|
||||||
|
console.log(`📦 ${ns}`);
|
||||||
|
for (const item of items) {
|
||||||
|
console.log(` ${item.key || "(root value)"}`);
|
||||||
|
}
|
||||||
|
console.log();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("💡 提示: 这些键可能被动态使用(如 t(`prefix.${var}`)),请人工确认后再删除。");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n✨ 检查完成!");
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { LightButton, PrimaryButton,
|
import { LightButton, PrimaryButton, IconClick } from "@/design-system/base/button";
|
||||||
IconClick } from "@/design-system/base/button";
|
|
||||||
import { Select } from "@/design-system/base/select";
|
import { Select } from "@/design-system/base/select";
|
||||||
|
import { Input } from "@/design-system/base/input";
|
||||||
import { Textarea } from "@/design-system/base/textarea";
|
import { Textarea } from "@/design-system/base/textarea";
|
||||||
import { IMAGES } from "@/config/images";
|
import { IMAGES } from "@/config/images";
|
||||||
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
import { useAudioPlayer } from "@/hooks/useAudioPlayer";
|
||||||
@@ -46,6 +46,7 @@ export default function TranslatorPage() {
|
|||||||
const taref = useRef<HTMLTextAreaElement>(null);
|
const taref = useRef<HTMLTextAreaElement>(null);
|
||||||
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
|
const [sourceLanguage, setSourceLanguage] = useState<string>("Auto");
|
||||||
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
const [targetLanguage, setTargetLanguage] = useState<string>("Chinese");
|
||||||
|
const [customTargetLanguage, setCustomTargetLanguage] = useState<string>("");
|
||||||
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
const [translationResult, setTranslationResult] = useState<TSharedTranslationResult | null>(null);
|
||||||
const [needIpa, setNeedIpa] = useState(true);
|
const [needIpa, setNeedIpa] = useState(true);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
@@ -93,18 +94,18 @@ export default function TranslatorPage() {
|
|||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
const sourceText = taref.current.value;
|
const sourceText = taref.current.value;
|
||||||
|
const effectiveTargetLanguage = customTargetLanguage.trim() || targetLanguage;
|
||||||
|
|
||||||
// 判断是否需要强制重新翻译
|
// 判断是否需要强制重新翻译
|
||||||
// 只有当源文本、源语言和目标语言都与上次相同时,才强制重新翻译
|
|
||||||
const forceRetranslate =
|
const forceRetranslate =
|
||||||
lastTranslation?.sourceText === sourceText &&
|
lastTranslation?.sourceText === sourceText &&
|
||||||
lastTranslation?.sourceLanguage === sourceLanguage &&
|
lastTranslation?.sourceLanguage === sourceLanguage &&
|
||||||
lastTranslation?.targetLanguage === targetLanguage;
|
lastTranslation?.targetLanguage === effectiveTargetLanguage;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await actionTranslateText({
|
const result = await actionTranslateText({
|
||||||
sourceText,
|
sourceText,
|
||||||
targetLanguage,
|
targetLanguage: effectiveTargetLanguage,
|
||||||
forceRetranslate,
|
forceRetranslate,
|
||||||
needIpa,
|
needIpa,
|
||||||
sourceLanguage: sourceLanguage === "Auto" ? undefined : sourceLanguage,
|
sourceLanguage: sourceLanguage === "Auto" ? undefined : sourceLanguage,
|
||||||
@@ -115,7 +116,7 @@ export default function TranslatorPage() {
|
|||||||
setLastTranslation({
|
setLastTranslation({
|
||||||
sourceText,
|
sourceText,
|
||||||
sourceLanguage,
|
sourceLanguage,
|
||||||
targetLanguage,
|
targetLanguage: effectiveTargetLanguage,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message || "翻译失败,请重试");
|
toast.error(result.message || "翻译失败,请重试");
|
||||||
@@ -246,39 +247,43 @@ export default function TranslatorPage() {
|
|||||||
<div className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
<div className="option2 w-full flex gap-1 items-center overflow-x-auto">
|
||||||
<span className="shrink-0">{t("translateInto")}</span>
|
<span className="shrink-0">{t("translateInto")}</span>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={targetLanguage === "Chinese"}
|
selected={!customTargetLanguage && targetLanguage === "Chinese"}
|
||||||
onClick={() => setTargetLanguage("Chinese")}
|
onClick={() => {
|
||||||
|
setTargetLanguage("Chinese");
|
||||||
|
setCustomTargetLanguage("");
|
||||||
|
}}
|
||||||
className="shrink-0 hidden lg:inline-flex"
|
className="shrink-0 hidden lg:inline-flex"
|
||||||
>
|
>
|
||||||
{t("chinese")}
|
{t("chinese")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={targetLanguage === "English"}
|
selected={!customTargetLanguage && targetLanguage === "English"}
|
||||||
onClick={() => setTargetLanguage("English")}
|
onClick={() => {
|
||||||
|
setTargetLanguage("English");
|
||||||
|
setCustomTargetLanguage("");
|
||||||
|
}}
|
||||||
className="shrink-0 hidden lg:inline-flex"
|
className="shrink-0 hidden lg:inline-flex"
|
||||||
>
|
>
|
||||||
{t("english")}
|
{t("english")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<LightButton
|
<LightButton
|
||||||
selected={targetLanguage === "Japanese"}
|
selected={!customTargetLanguage && targetLanguage === "Japanese"}
|
||||||
onClick={() => setTargetLanguage("Japanese")}
|
onClick={() => {
|
||||||
|
setTargetLanguage("Japanese");
|
||||||
|
setCustomTargetLanguage("");
|
||||||
|
}}
|
||||||
className="shrink-0 hidden xl:inline-flex"
|
className="shrink-0 hidden xl:inline-flex"
|
||||||
>
|
>
|
||||||
{t("japanese")}
|
{t("japanese")}
|
||||||
</LightButton>
|
</LightButton>
|
||||||
<Select
|
<Input
|
||||||
value={targetLanguage}
|
variant="bordered"
|
||||||
onChange={(e) => setTargetLanguage(e.target.value)}
|
|
||||||
variant="light"
|
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-auto min-w-[100px] shrink-0"
|
value={customTargetLanguage}
|
||||||
>
|
onChange={(e) => setCustomTargetLanguage(e.target.value)}
|
||||||
{TARGET_LANGUAGES.map((lang) => (
|
placeholder={t("customLanguage")}
|
||||||
<option key={lang.value} value={lang.value}>
|
className="w-auto min-w-[120px] shrink-0"
|
||||||
{t(lang.labelKey)}
|
/>
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user