Files
xianyan/tools/gen_l10n.dart
Developer 9ea8d3d606 chore: 汇总批量提交的功能优化与bug修复
本次提交包含多项迭代优化和问题修复:
1. 新增缩略图图片组件、数字格式化工具类,补充多语言翻译类型与本地化支持
2. 优化底部导航栏主题色统一使用动态accent色值
3. 修复多处图表动画、路由跳转、API请求相关问题
4. 简化服务器公告文案,调整默认分屏状态为关闭
5. 新增安卓/iOS桌面快捷方式配置
6. 重构多处状态管理类使用SafeNotifierInit统一异常保护
7. 替换硬编码蓝色为主题色,更新版本号获取方式为动态读取
8. 优化缓存预加载逻辑,移除无用代码
9. 调整默认设置项,优化用户体验细节
2026-05-31 12:24:05 +08:00

731 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
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.
/// ============================================================
/// 闲言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);
}
}