本次提交包含多项迭代优化和问题修复: 1. 新增缩略图图片组件、数字格式化工具类,补充多语言翻译类型与本地化支持 2. 优化底部导航栏主题色统一使用动态accent色值 3. 修复多处图表动画、路由跳转、API请求相关问题 4. 简化服务器公告文案,调整默认分屏状态为关闭 5. 新增安卓/iOS桌面快捷方式配置 6. 重构多处状态管理类使用SafeNotifierInit统一异常保护 7. 替换硬编码蓝色为主题色,更新版本号获取方式为动态读取 8. 优化缓存预加载逻辑,移除无用代码 9. 调整默认设置项,优化用户体验细节
731 lines
22 KiB
Dart
731 lines
22 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 翻译代码生成与检查脚本
|
||
/// 创建时间: 2026-05-31
|
||
/// 更新时间: 2026-05-31
|
||
/// 作用: 读取zh_cn.dart基准语言,解析所有翻译类型,
|
||
/// 检查语言文件字段完整性,输出缺失字段报告
|
||
/// 上次更新: 初始创建
|
||
/// ============================================================
|
||
///
|
||
/// 用法:
|
||
/// dart run tools/gen_l10n.dart --check 检查所有语言文件的字段完整性
|
||
/// dart run tools/gen_l10n.dart --diff 输出各语言与zh_cn的差异
|
||
/// dart run tools/gen_l10n.dart --skeleton 生成类型定义文件骨架
|
||
/// dart run tools/gen_l10n.dart --report 输出完整覆盖率报告
|
||
|
||
import 'dart:io';
|
||
|
||
// ── 常量 ──────────────────────────────────────────────────────
|
||
|
||
const String projectRoot = 'e:\\project\\flutter\\f\\xianyan';
|
||
const String l10nDir = '$projectRoot\\lib\\l10n';
|
||
const String typesDir = '$l10nDir\\types';
|
||
const String languagesDir = '$l10nDir\\languages';
|
||
const String baseLanguageFile = '$languagesDir\\zh_cn.dart';
|
||
|
||
/// 所有语言文件映射
|
||
const Map<String, String> languageFiles = {
|
||
'zh_CN': 'zh_cn.dart',
|
||
'en': 'en.dart',
|
||
'ja': 'ja.dart',
|
||
'zh_TW': 'zh_tw.dart',
|
||
'ko': 'ko.dart',
|
||
'de': 'de.dart',
|
||
'it': 'it.dart',
|
||
'es': 'es.dart',
|
||
'ar': 'ar.dart',
|
||
'bn': 'bn.dart',
|
||
'hi': 'hi.dart',
|
||
'pt': 'pt.dart',
|
||
'ru': 'ru.dart',
|
||
'fr': 'fr.dart',
|
||
};
|
||
|
||
/// 所有翻译类型文件
|
||
const List<String> typeFiles = [
|
||
't_nav.dart',
|
||
't_common.dart',
|
||
't_home.dart',
|
||
't_home_base.dart',
|
||
't_sentence_detail.dart',
|
||
't_read_later.dart',
|
||
't_discover.dart',
|
||
't_profile.dart',
|
||
't_settings.dart',
|
||
't_settings_interaction.dart',
|
||
't_settings_display.dart',
|
||
't_settings_performance.dart',
|
||
't_settings_privacy.dart',
|
||
't_settings_advanced.dart',
|
||
't_settings_cache.dart',
|
||
't_settings_permission.dart',
|
||
't_settings_data_collection.dart',
|
||
't_about.dart',
|
||
't_onboarding.dart',
|
||
't_progress.dart',
|
||
't_root.dart',
|
||
];
|
||
|
||
/// 组合类型(包含子模块而非直接String字段的类型)
|
||
/// 这些类型在语言文件中以嵌套形式出现,需要特殊处理
|
||
const Set<String> compositeTypes = {'THome', 'TSettings'};
|
||
|
||
// ── 解析器 ────────────────────────────────────────────────────
|
||
|
||
/// 解析类型文件中的字段定义
|
||
/// 返回 {字段名: 注释} 的映射
|
||
Map<String, String> parseTypeFields(String filePath) {
|
||
final file = File(filePath);
|
||
if (!file.existsSync()) {
|
||
stderr.writeln(' ⚠️ 文件不存在: $filePath');
|
||
return {};
|
||
}
|
||
|
||
final content = file.readAsStringSync();
|
||
final fields = <String, String>{};
|
||
|
||
// 匹配 final String fieldName; 模式
|
||
final fieldRegex = RegExp(r'final\s+String\s+(\w+);');
|
||
// 匹配注释行 /// xxx
|
||
final commentRegex = RegExp(r'///\s*(.+)');
|
||
|
||
final lines = content.split('\n');
|
||
String? lastComment;
|
||
|
||
for (final line in lines) {
|
||
final trimmed = line.trim();
|
||
|
||
// 检查注释
|
||
final commentMatch = commentRegex.firstMatch(trimmed);
|
||
if (commentMatch != null) {
|
||
lastComment = commentMatch.group(1)?.trim();
|
||
continue;
|
||
}
|
||
|
||
// 检查字段声明
|
||
final fieldMatch = fieldRegex.firstMatch(trimmed);
|
||
if (fieldMatch != null) {
|
||
final fieldName = fieldMatch.group(1)!;
|
||
fields[fieldName] = lastComment ?? '';
|
||
lastComment = null;
|
||
continue;
|
||
}
|
||
|
||
// 非空行且非注释,重置注释
|
||
if (trimmed.isNotEmpty && !trimmed.startsWith('///')) {
|
||
lastComment = null;
|
||
}
|
||
}
|
||
|
||
return fields;
|
||
}
|
||
|
||
/// 解析类型文件中的toMap()字段映射
|
||
Map<String, String> parseToMapFields(String filePath) {
|
||
final file = File(filePath);
|
||
if (!file.existsSync()) return {};
|
||
|
||
final content = file.readAsStringSync();
|
||
final fields = <String, String>{};
|
||
|
||
// 匹配 toMap() 中的 'key': fieldName 模式
|
||
final mapEntryRegex = RegExp(r"'(\w+)':\s*(\w+)");
|
||
final inToMap = <bool>[false];
|
||
|
||
final lines = content.split('\n');
|
||
for (final line in lines) {
|
||
final trimmed = line.trim();
|
||
if (trimmed.contains('toMap()')) {
|
||
inToMap[0] = true;
|
||
continue;
|
||
}
|
||
if (inToMap[0] && trimmed.contains('};')) {
|
||
inToMap[0] = false;
|
||
continue;
|
||
}
|
||
if (inToMap[0]) {
|
||
final match = mapEntryRegex.firstMatch(trimmed);
|
||
if (match != null) {
|
||
fields[match.group(1)!] = match.group(2)!;
|
||
}
|
||
}
|
||
}
|
||
|
||
return fields;
|
||
}
|
||
|
||
/// 解析语言文件中某个类型的字段值
|
||
/// 支持嵌套类型(如 THome(base: THomeBase(...), sentenceDetail: TSentenceDetail(...)))
|
||
Map<String, String> parseLanguageTypeFields(String content, String typeName) {
|
||
final fields = <String, String>{};
|
||
|
||
final typeStartRegex = RegExp('$typeName\\s*\\(');
|
||
final typeStartMatch = typeStartRegex.firstMatch(content);
|
||
if (typeStartMatch == null) return {};
|
||
|
||
// 从 TypeName( 开始,找到匹配的 )
|
||
var depth = 0;
|
||
final start = typeStartMatch.end;
|
||
final buffer = StringBuffer();
|
||
|
||
for (var i = start; i < content.length; i++) {
|
||
final char = content[i];
|
||
if (char == '(') {
|
||
depth++;
|
||
buffer.write(char);
|
||
} else if (char == ')') {
|
||
if (depth == 0) break;
|
||
depth--;
|
||
buffer.write(char);
|
||
} else {
|
||
buffer.write(char);
|
||
}
|
||
}
|
||
|
||
final typeContent = buffer.toString();
|
||
|
||
// 匹配 key: 'value' 或 key: "value" 模式(仅直接字段,不进入嵌套类型)
|
||
final fieldRegex = RegExp(
|
||
r"""(\w+):\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)")""",
|
||
);
|
||
for (final match in fieldRegex.allMatches(typeContent)) {
|
||
final key = match.group(1)!;
|
||
final value = match.group(2) ?? match.group(3) ?? '';
|
||
fields[key] = value;
|
||
}
|
||
|
||
return fields;
|
||
}
|
||
|
||
/// 递归解析语言文件中某个类型的所有字段(包括嵌套子类型)
|
||
/// 返回扁平化的字段映射,key格式为 "子类型.字段名"
|
||
Map<String, String> parseLanguageTypeFieldsRecursive(
|
||
String content,
|
||
String typeName, {
|
||
String prefix = '',
|
||
}) {
|
||
final fields = <String, String>{};
|
||
|
||
final typeStartRegex = RegExp('$typeName\\s*\\(');
|
||
final typeStartMatch = typeStartRegex.firstMatch(content);
|
||
if (typeStartMatch == null) return {};
|
||
|
||
// 从 TypeName( 开始,找到匹配的 )
|
||
var depth = 0;
|
||
final start = typeStartMatch.end;
|
||
final buffer = StringBuffer();
|
||
|
||
for (var i = start; i < content.length; i++) {
|
||
final char = content[i];
|
||
if (char == '(') {
|
||
depth++;
|
||
buffer.write(char);
|
||
} else if (char == ')') {
|
||
if (depth == 0) break;
|
||
depth--;
|
||
buffer.write(char);
|
||
} else {
|
||
buffer.write(char);
|
||
}
|
||
}
|
||
|
||
final typeContent = buffer.toString();
|
||
|
||
// 匹配直接字符串字段
|
||
final fieldRegex = RegExp(
|
||
r"""(\w+):\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)")""",
|
||
);
|
||
for (final match in fieldRegex.allMatches(typeContent)) {
|
||
final key = match.group(1)!;
|
||
final value = match.group(2) ?? match.group(3) ?? '';
|
||
final fullKey = prefix.isNotEmpty ? '$prefix.$key' : key;
|
||
fields[fullKey] = value;
|
||
}
|
||
|
||
// 匹配嵌套类型字段 key: SubTypeName(
|
||
final nestedRegex = RegExp(r'(\w+):\s*([A-Z]\w+)\s*\(');
|
||
for (final match in nestedRegex.allMatches(typeContent)) {
|
||
final subKey = match.group(1)!;
|
||
final subTypeName = match.group(2)!;
|
||
final fullPrefix = prefix.isNotEmpty ? '$prefix.$subKey' : subKey;
|
||
|
||
// 递归解析子类型
|
||
// 需要从子类型的开始位置重新解析
|
||
final subFields = _parseNestedType(content, subTypeName, match.start);
|
||
for (final subEntry in subFields.entries) {
|
||
final fullKey = '$fullPrefix.${subEntry.key}';
|
||
fields[fullKey] = subEntry.value;
|
||
}
|
||
}
|
||
|
||
return fields;
|
||
}
|
||
|
||
/// 解析嵌套类型的字段
|
||
Map<String, String> _parseNestedType(
|
||
String content,
|
||
String typeName,
|
||
int searchFrom,
|
||
) {
|
||
final fields = <String, String>{};
|
||
|
||
// 从searchFrom位置开始查找TypeName(
|
||
final subContent = content.substring(searchFrom);
|
||
final typeStartRegex = RegExp('$typeName\\s*\\(');
|
||
final typeStartMatch = typeStartRegex.firstMatch(subContent);
|
||
if (typeStartMatch == null) return {};
|
||
|
||
var depth = 0;
|
||
final start = typeStartMatch.end;
|
||
final buffer = StringBuffer();
|
||
|
||
for (var i = start; i < subContent.length; i++) {
|
||
final char = subContent[i];
|
||
if (char == '(') {
|
||
depth++;
|
||
buffer.write(char);
|
||
} else if (char == ')') {
|
||
if (depth == 0) break;
|
||
depth--;
|
||
buffer.write(char);
|
||
} else {
|
||
buffer.write(char);
|
||
}
|
||
}
|
||
|
||
final typeContent = buffer.toString();
|
||
|
||
// 仅匹配直接字符串字段
|
||
final fieldRegex = RegExp(
|
||
r"""(\w+):\s*(?:'((?:[^'\\]|\\.)*)'|"((?:[^"\\]|\\.)*)")""",
|
||
);
|
||
for (final match in fieldRegex.allMatches(typeContent)) {
|
||
final key = match.group(1)!;
|
||
final value = match.group(2) ?? match.group(3) ?? '';
|
||
fields[key] = value;
|
||
}
|
||
|
||
return fields;
|
||
}
|
||
|
||
/// 获取类型名(从文件名)
|
||
/// t_nav.dart -> TNav, t_home_base.dart -> THomeBase
|
||
String getTypeName(String fileName) {
|
||
final name = fileName.replaceAll('.dart', '');
|
||
// 所有类型文件以 t_ 开头,去掉前缀 t_
|
||
final withoutPrefix = name.startsWith('t_') ? name.substring(2) : name;
|
||
final parts = withoutPrefix.split('_');
|
||
final result = StringBuffer('T');
|
||
for (final part in parts) {
|
||
if (part.isNotEmpty) {
|
||
result.write(part[0].toUpperCase());
|
||
result.write(part.substring(1));
|
||
}
|
||
}
|
||
return result.toString();
|
||
}
|
||
|
||
// ── 命令实现 ──────────────────────────────────────────────────
|
||
|
||
/// --check: 检查所有语言文件的字段完整性
|
||
int checkCompleteness() {
|
||
stdout.writeln('🔍 翻译字段完整性检查');
|
||
stdout.writeln('=' * 60);
|
||
|
||
// 1. 解析基准语言 zh_cn.dart
|
||
final baseFile = File(baseLanguageFile);
|
||
if (!baseFile.existsSync()) {
|
||
stderr.writeln('❌ 基准语言文件不存在: $baseLanguageFile');
|
||
return 1;
|
||
}
|
||
final baseContent = baseFile.readAsStringSync();
|
||
|
||
// 2. 解析所有类型文件的字段定义
|
||
stdout.writeln('\n📋 类型定义文件字段统计:');
|
||
stdout.writeln('-' * 40);
|
||
|
||
final typeFieldsMap = <String, Map<String, String>>{};
|
||
var totalFields = 0;
|
||
|
||
for (final typeFile in typeFiles) {
|
||
if (typeFile == 't_root.dart') continue; // 根类型不包含翻译字段
|
||
final filePath = '$typesDir\\$typeFile';
|
||
final fields = parseTypeFields(filePath);
|
||
if (fields.isNotEmpty) {
|
||
final typeName = getTypeName(typeFile);
|
||
typeFieldsMap[typeName] = fields;
|
||
totalFields += fields.length;
|
||
stdout.writeln(' $typeName: ${fields.length} 个字段');
|
||
}
|
||
}
|
||
|
||
stdout.writeln('\n 总计: $totalFields 个翻译字段');
|
||
stdout.writeln('=' * 60);
|
||
|
||
// 3. 检查每种语言的字段完整性
|
||
stdout.writeln('\n🌐 语言文件字段检查:');
|
||
stdout.writeln('-' * 60);
|
||
|
||
var hasError = false;
|
||
|
||
for (final entry in languageFiles.entries) {
|
||
final langId = entry.key;
|
||
final fileName = entry.value;
|
||
final filePath = '$languagesDir\\$fileName';
|
||
|
||
final file = File(filePath);
|
||
if (!file.existsSync()) {
|
||
stdout.writeln(' ❌ $langId: 文件不存在 ($fileName)');
|
||
hasError = true;
|
||
continue;
|
||
}
|
||
|
||
final content = file.readAsStringSync();
|
||
var missingCount = 0;
|
||
var emptyCount = 0;
|
||
final missingFields = <String>[];
|
||
final emptyFields = <String>[];
|
||
|
||
for (final typeEntry in typeFieldsMap.entries) {
|
||
final typeName = typeEntry.key;
|
||
final expectedFields = typeEntry.value;
|
||
|
||
// 组合类型使用递归解析
|
||
final langFields = compositeTypes.contains(typeName)
|
||
? parseLanguageTypeFieldsRecursive(content, typeName)
|
||
: parseLanguageTypeFields(content, typeName);
|
||
|
||
for (final fieldEntry in expectedFields.entries) {
|
||
final fieldName = fieldEntry.key;
|
||
if (!langFields.containsKey(fieldName)) {
|
||
missingFields.add('$typeName.$fieldName');
|
||
missingCount++;
|
||
} else if (langFields[fieldName]!.isEmpty) {
|
||
emptyFields.add('$typeName.$fieldName');
|
||
emptyCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
final total = totalFields;
|
||
final covered = total - missingCount - emptyCount;
|
||
final percent = (covered * 100 / total).round();
|
||
|
||
final status = percent >= 90
|
||
? '✅'
|
||
: percent >= 70
|
||
? '⚠️'
|
||
: '❌';
|
||
|
||
stdout.writeln(
|
||
' $status $langId ($fileName): $covered/$total ($percent%)'
|
||
'${missingCount > 0 ? ' | 缺失: $missingCount' : ''}'
|
||
'${emptyCount > 0 ? ' | 空值: $emptyCount' : ''}',
|
||
);
|
||
|
||
if (missingFields.isNotEmpty) {
|
||
stdout.writeln(
|
||
' 缺失字段: ${missingFields.take(10).join(', ')}'
|
||
'${missingFields.length > 10 ? ' ... (+${missingFields.length - 10})' : ''}',
|
||
);
|
||
}
|
||
|
||
if (emptyFields.isNotEmpty && emptyFields.length <= 5) {
|
||
stdout.writeln(' 空值字段: ${emptyFields.join(', ')}');
|
||
}
|
||
}
|
||
|
||
stdout.writeln('=' * 60);
|
||
return hasError ? 1 : 0;
|
||
}
|
||
|
||
/// --diff: 输出各语言与zh_cn的差异
|
||
int diffLanguages() {
|
||
stdout.writeln('📊 翻译差异报告 (与 zh_CN 对比)');
|
||
stdout.writeln('=' * 60);
|
||
|
||
final baseFile = File(baseLanguageFile);
|
||
if (!baseFile.existsSync()) {
|
||
stderr.writeln('❌ 基准语言文件不存在: $baseLanguageFile');
|
||
return 1;
|
||
}
|
||
final baseContent = baseFile.readAsStringSync();
|
||
|
||
// 解析类型文件
|
||
final typeFieldsMap = <String, Map<String, String>>{};
|
||
for (final typeFile in typeFiles) {
|
||
if (typeFile == 't_root.dart') continue;
|
||
final filePath = '$typesDir\\$typeFile';
|
||
final fields = parseToMapFields(filePath);
|
||
if (fields.isNotEmpty) {
|
||
final typeName = getTypeName(typeFile);
|
||
typeFieldsMap[typeName] = fields;
|
||
}
|
||
}
|
||
|
||
// 解析基准语言
|
||
final baseFields = <String, Map<String, String>>{};
|
||
for (final typeEntry in typeFieldsMap.entries) {
|
||
baseFields[typeEntry.key] = compositeTypes.contains(typeEntry.key)
|
||
? parseLanguageTypeFieldsRecursive(baseContent, typeEntry.key)
|
||
: parseLanguageTypeFields(baseContent, typeEntry.key);
|
||
}
|
||
|
||
// 对比每种语言
|
||
for (final entry in languageFiles.entries) {
|
||
if (entry.key == 'zh_CN') continue;
|
||
|
||
final langId = entry.key;
|
||
final filePath = '$languagesDir\\${entry.value}';
|
||
final file = File(filePath);
|
||
if (!file.existsSync()) continue;
|
||
|
||
final content = file.readAsStringSync();
|
||
stdout.writeln('\n── $langId ──');
|
||
|
||
for (final typeEntry in typeFieldsMap.entries) {
|
||
final typeName = typeEntry.key;
|
||
final baseTypeFields = baseFields[typeName] ?? {};
|
||
final langTypeFields = compositeTypes.contains(typeName)
|
||
? parseLanguageTypeFieldsRecursive(content, typeName)
|
||
: parseLanguageTypeFields(content, typeName);
|
||
|
||
final missing = <String>[];
|
||
final empty = <String>[];
|
||
final different = <String>[];
|
||
|
||
for (final baseField in baseTypeFields.entries) {
|
||
final key = baseField.key;
|
||
final baseValue = baseField.value;
|
||
|
||
if (!langTypeFields.containsKey(key)) {
|
||
missing.add(key);
|
||
} else if (langTypeFields[key]!.isEmpty) {
|
||
empty.add(key);
|
||
} else if (langTypeFields[key] != baseValue && baseValue.isNotEmpty) {
|
||
different.add(key);
|
||
}
|
||
}
|
||
|
||
if (missing.isEmpty && empty.isEmpty) continue;
|
||
|
||
stdout.writeln(' [$typeName]');
|
||
if (missing.isNotEmpty) {
|
||
stdout.writeln(' ❌ 缺失 (${missing.length}): ${missing.join(', ')}');
|
||
}
|
||
if (empty.isNotEmpty) {
|
||
stdout.writeln(' ⚠️ 空值 (${empty.length}): ${empty.join(', ')}');
|
||
}
|
||
}
|
||
}
|
||
|
||
stdout.writeln('\n' + '=' * 60);
|
||
return 0;
|
||
}
|
||
|
||
/// --skeleton: 生成类型定义文件骨架
|
||
int generateSkeleton() {
|
||
stdout.writeln('🦴 类型定义骨架生成');
|
||
stdout.writeln('=' * 60);
|
||
|
||
for (final typeFile in typeFiles) {
|
||
if (typeFile == 't_root.dart') continue;
|
||
final filePath = '$typesDir\\$typeFile';
|
||
final typeName = getTypeName(typeFile);
|
||
|
||
final fields = parseTypeFields(filePath);
|
||
if (fields.isEmpty) continue;
|
||
|
||
stdout.writeln('\n── $typeName (${fields.length} fields) ──');
|
||
stdout.writeln();
|
||
|
||
// 构造函数
|
||
stdout.writeln('const $typeName({');
|
||
for (final field in fields.keys) {
|
||
stdout.writeln(' required this.$field,');
|
||
}
|
||
stdout.writeln('});');
|
||
|
||
stdout.writeln();
|
||
|
||
// 字段声明
|
||
for (final entry in fields.entries) {
|
||
if (entry.value.isNotEmpty) {
|
||
stdout.writeln('/// ${entry.value}');
|
||
}
|
||
stdout.writeln('final String ${entry.key};');
|
||
}
|
||
|
||
stdout.writeln();
|
||
|
||
// toMap
|
||
stdout.writeln('Map<String, String> toMap() => {');
|
||
for (final field in fields.keys) {
|
||
stdout.writeln(" '$field': $field,");
|
||
}
|
||
stdout.writeln('};');
|
||
|
||
stdout.writeln();
|
||
|
||
// fromMap
|
||
stdout.writeln(
|
||
'static $typeName fromMap(Map<String, String> map) => $typeName(',
|
||
);
|
||
for (final field in fields.keys) {
|
||
stdout.writeln(" $field: map['$field'] ?? '',");
|
||
}
|
||
stdout.writeln(');');
|
||
}
|
||
|
||
stdout.writeln('\n' + '=' * 60);
|
||
return 0;
|
||
}
|
||
|
||
/// --report: 输出完整覆盖率报告
|
||
int fullReport() {
|
||
stdout.writeln('📈 翻译覆盖率完整报告');
|
||
stdout.writeln('=' * 60);
|
||
|
||
final baseFile = File(baseLanguageFile);
|
||
if (!baseFile.existsSync()) {
|
||
stderr.writeln('❌ 基准语言文件不存在: $baseLanguageFile');
|
||
return 1;
|
||
}
|
||
|
||
// 解析类型文件
|
||
final typeFieldsMap = <String, Map<String, String>>{};
|
||
var totalFields = 0;
|
||
for (final typeFile in typeFiles) {
|
||
if (typeFile == 't_root.dart') continue;
|
||
final filePath = '$typesDir\\$typeFile';
|
||
final fields = parseTypeFields(filePath);
|
||
if (fields.isNotEmpty) {
|
||
final typeName = getTypeName(typeFile);
|
||
typeFieldsMap[typeName] = fields;
|
||
totalFields += fields.length;
|
||
}
|
||
}
|
||
|
||
stdout.writeln('\n总翻译字段数: $totalFields');
|
||
stdout.writeln();
|
||
|
||
// 按模块统计
|
||
stdout.writeln('── 模块字段统计 ──');
|
||
for (final entry in typeFieldsMap.entries) {
|
||
stdout.writeln(' ${entry.key}: ${entry.value.length} 个字段');
|
||
}
|
||
|
||
stdout.writeln();
|
||
|
||
// 按语言统计
|
||
stdout.writeln('── 语言覆盖率 ──');
|
||
final results = <String, Map<String, dynamic>>{};
|
||
|
||
for (final entry in languageFiles.entries) {
|
||
final langId = entry.key;
|
||
final filePath = '$languagesDir\\${entry.value}';
|
||
final file = File(filePath);
|
||
|
||
if (!file.existsSync()) {
|
||
results[langId] = {'percent': 0, 'missing': totalFields};
|
||
stdout.writeln(' ❌ $langId: 文件不存在');
|
||
continue;
|
||
}
|
||
|
||
final content = file.readAsStringSync();
|
||
var missingCount = 0;
|
||
var emptyCount = 0;
|
||
|
||
for (final typeEntry in typeFieldsMap.entries) {
|
||
final typeName = typeEntry.key;
|
||
final expectedFields = typeEntry.value;
|
||
final langFields = compositeTypes.contains(typeName)
|
||
? parseLanguageTypeFieldsRecursive(content, typeName)
|
||
: parseLanguageTypeFields(content, typeName);
|
||
|
||
for (final fieldEntry in expectedFields.entries) {
|
||
final fieldName = fieldEntry.key;
|
||
if (!langFields.containsKey(fieldName)) {
|
||
missingCount++;
|
||
} else if (langFields[fieldName]!.isEmpty) {
|
||
emptyCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
final covered = totalFields - missingCount - emptyCount;
|
||
final percent = totalFields > 0 ? (covered * 100 / totalFields).round() : 0;
|
||
|
||
results[langId] = {
|
||
'percent': percent,
|
||
'covered': covered,
|
||
'missing': missingCount,
|
||
'empty': emptyCount,
|
||
};
|
||
|
||
final filled = percent ~/ 5;
|
||
final bar = '█' * filled + '░' * (20 - filled);
|
||
final status = percent >= 90
|
||
? '✅'
|
||
: percent >= 70
|
||
? '⚠️'
|
||
: '❌';
|
||
|
||
stdout.writeln(
|
||
' $status $langId: $bar $percent% ($covered/$totalFields)'
|
||
'${missingCount > 0 ? ' missing:$missingCount' : ''}'
|
||
'${emptyCount > 0 ? ' empty:$emptyCount' : ''}',
|
||
);
|
||
}
|
||
|
||
// 机器翻译标注
|
||
stdout.writeln();
|
||
stdout.writeln('── 机器翻译标注 (覆盖率 < 80%) ──');
|
||
final mtLangs = results.entries.where(
|
||
(e) => (e.value['percent'] as int) < 80,
|
||
);
|
||
if (mtLangs.isEmpty) {
|
||
stdout.writeln(' 无需标注 (所有语言覆盖率 >= 80%)');
|
||
} else {
|
||
for (final entry in mtLangs) {
|
||
final percent = entry.value['percent'] as int;
|
||
stdout.writeln(' 🤖 ${entry.key}: $percent% — 建议标注为机器翻译');
|
||
}
|
||
}
|
||
|
||
stdout.writeln('\n' + '=' * 60);
|
||
return 0;
|
||
}
|
||
|
||
// ── 主入口 ────────────────────────────────────────────────────
|
||
|
||
void main(List<String> args) {
|
||
if (args.isEmpty) {
|
||
stdout.writeln('闲言APP — 翻译代码生成与检查工具');
|
||
stdout.writeln();
|
||
stdout.writeln('用法:');
|
||
stdout.writeln(' dart run tools/gen_l10n.dart --check 检查所有语言文件的字段完整性');
|
||
stdout.writeln(' dart run tools/gen_l10n.dart --diff 输出各语言与zh_cn的差异');
|
||
stdout.writeln(' dart run tools/gen_l10n.dart --skeleton 生成类型定义文件骨架');
|
||
stdout.writeln(' dart run tools/gen_l10n.dart --report 输出完整覆盖率报告');
|
||
exit(0);
|
||
}
|
||
|
||
final command = args.first;
|
||
|
||
switch (command) {
|
||
case '--check':
|
||
exit(checkCompleteness());
|
||
case '--diff':
|
||
exit(diffLanguages());
|
||
case '--skeleton':
|
||
exit(generateSkeleton());
|
||
case '--report':
|
||
exit(fullReport());
|
||
default:
|
||
stderr.writeln('❌ 未知命令: $command');
|
||
stderr.writeln('可用命令: --check, --diff, --skeleton, --report');
|
||
exit(1);
|
||
}
|
||
}
|