This commit is contained in:
Developer
2026-06-04 00:33:21 +08:00
parent 74b615afc4
commit cd1fcb7297
11 changed files with 221 additions and 1491 deletions

View File

@@ -1,368 +0,0 @@
/// ============================================================
/// 闲言APP — 鸿蒙端权限一致性校验脚本
/// 创建时间: 2026-05-31
/// 更新时间: 2026-05-31
/// 作用: 校验 module.json5 与 string.json 之间的权限声明一致性
/// - 检查 module.json5 中权限的 reason 引用是否在 string.json 中存在
/// - 检查 string.json 中 permission_*_reason 键是否被 module.json5 引用
/// - 检查 user_grant 权限是否缺少 reason 说明
/// 上次更新: 初始创建
/// ============================================================
///
/// 用法:
/// dart run tools/check_ohos_permissions.dart
///
/// 退出码:
/// 0 — 校验通过,无错误
/// 1 — 存在错误(缺失引用或未使用的键)
import 'dart:convert';
import 'dart:io';
// ── 路径常量 ──────────────────────────────────────────────────
const String projectRoot = 'e:\\project\\flutter\\f\\xianyan';
const String moduleJson5Path =
'$projectRoot\\ohos\\entry\\src\\main\\module.json5';
const String stringJsonPath =
'$projectRoot\\ohos\\entry\\src\\main\\resources\\base\\element\\string.json';
// ── 终端颜色 ──────────────────────────────────────────────────
const String _reset = '\x1B[0m';
const String _red = '\x1B[31m';
const String _green = '\x1B[32m';
const String _yellow = '\x1B[33m';
const String _cyan = '\x1B[36m';
const String _bold = '\x1B[1m';
// ── 数据模型 ──────────────────────────────────────────────────
/// module.json5 中的权限条目
class PermissionEntry {
final String name;
final String? reasonRef;
final String? when_;
PermissionEntry({
required this.name,
this.reasonRef,
this.when_,
});
}
// ── JSON5 解析 ────────────────────────────────────────────────
/// 将 JSON5 文本转换为标准 JSON
/// 处理: 单行注释、多行注释、尾逗号
String _json5ToJson(String source) {
final buffer = StringBuffer();
var i = 0;
while (i < source.length) {
// 单行注释 //
if (i < source.length - 1 && source[i] == '/' && source[i + 1] == '/') {
while (i < source.length && source[i] != '\n') {
i++;
}
continue;
}
// 多行注释 /* */
if (i < source.length - 1 && source[i] == '/' && source[i + 1] == '*') {
i += 2;
while (i < source.length - 1) {
if (source[i] == '*' && source[i + 1] == '/') {
i += 2;
break;
}
i++;
}
continue;
}
// 字符串字面量 — 原样保留,避免误判注释
if (source[i] == '"' || source[i] == "'") {
final quote = source[i];
buffer.write('"');
i++;
while (i < source.length && source[i] != quote) {
if (source[i] == '\\' && i + 1 < source.length) {
buffer.write(source[i]);
buffer.write(source[i + 1]);
i += 2;
continue;
}
if (source[i] == '"') {
buffer.write('\\"');
i++;
continue;
}
buffer.write(source[i]);
i++;
}
buffer.write('"');
i++;
continue;
}
buffer.write(source[i]);
i++;
}
var result = buffer.toString();
// 移除尾逗号: , 后跟 } 或 ]
result = result.replaceAll(RegExp(r',\s*([}\]])'), r'$1');
return result;
}
/// 解析 module.json5 文件,提取 requestPermissions 列表
List<PermissionEntry> _parseModuleJson5(String filePath) {
final file = File(filePath);
if (!file.existsSync()) {
stderr.writeln('${_red}错误: 文件不存在 $filePath$_reset');
exit(1);
}
final raw = file.readAsStringSync();
final jsonStr = _json5ToJson(raw);
late final Map<String, dynamic> root;
try {
root = json.decode(jsonStr) as Map<String, dynamic>;
} catch (e) {
stderr.writeln('${_red}错误: JSON5 解析失败 — $e$_reset');
exit(1);
}
final module = root['module'] as Map<String, dynamic>?;
if (module == null) {
stderr.writeln('${_red}错误: module.json5 缺少 module 根节点$_reset');
exit(1);
}
final permissions = module['requestPermissions'] as List<dynamic>?;
if (permissions == null) {
stderr.writeln('${_yellow}警告: module.json5 无 requestPermissions 声明$_reset');
return [];
}
return permissions.map((p) {
final map = p as Map<String, dynamic>;
String? reasonRef;
final reason = map['reason'];
if (reason is String && reason.startsWith('\$string:')) {
reasonRef = reason.substring('\$string:'.length);
}
String? when_;
final usedScene = map['usedScene'] as Map<String, dynamic>?;
if (usedScene != null) {
when_ = usedScene['when'] as String?;
}
return PermissionEntry(
name: map['name'] as String,
reasonRef: reasonRef,
when_: when_,
);
}).toList();
}
/// 解析 string.json 文件,提取所有 permission_*_reason 键
Map<String, String> _parseStringJson(String filePath) {
final file = File(filePath);
if (!file.existsSync()) {
stderr.writeln('${_red}错误: 文件不存在 $filePath$_reset');
exit(1);
}
final raw = file.readAsStringSync();
late final Map<String, dynamic> root;
try {
root = json.decode(raw) as Map<String, dynamic>;
} catch (e) {
stderr.writeln('${_red}错误: string.json 解析失败 — $e$_reset');
exit(1);
}
final strings = root['string'] as List<dynamic>;
final result = <String, String>{};
for (final item in strings) {
final map = item as Map<String, dynamic>;
final name = map['name'] as String;
if (name.startsWith('permission_') && name.endsWith('_reason')) {
result[name] = map['value'] as String;
}
}
return result;
}
// ── 已知的 user_grant 权限列表 ────────────────────────────────
/// 鸿蒙系统需要用户授权的权限
/// 参考: https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/permissions
const Set<String> _userGrantPermissions = {
'ohos.permission.CAMERA',
'ohos.permission.MICROPHONE',
'ohos.permission.LOCATION',
'ohos.permission.APPROXIMATELY_LOCATION',
'ohos.permission.READ_MEDIA',
'ohos.permission.WRITE_MEDIA',
'ohos.permission.ACCESS_BLUETOOTH',
'ohos.permission.NFC_TAG',
};
// ── 校验逻辑 ──────────────────────────────────────────────────
/// 校验结果
class CheckResult {
final List<String> matched = [];
final List<String> missingReasons = [];
final List<String> unusedReasons = [];
final List<String> permissionsWithoutReason = [];
bool get hasErrors => missingReasons.isNotEmpty || unusedReasons.isNotEmpty;
bool get hasWarnings => permissionsWithoutReason.isNotEmpty;
}
CheckResult _validate(
List<PermissionEntry> permissions,
Map<String, String> reasonStrings,
) {
final result = CheckResult();
// 收集 module.json5 中引用的所有 reason key
final referencedReasons = <String>{};
for (final perm in permissions) {
if (perm.reasonRef != null) {
referencedReasons.add(perm.reasonRef!);
if (reasonStrings.containsKey(perm.reasonRef)) {
result.matched.add(perm.name);
} else {
result.missingReasons.add(
'${perm.name}\$string:${perm.reasonRef}',
);
}
} else {
// user_grant 权限应该有 reason
if (_userGrantPermissions.contains(perm.name)) {
result.permissionsWithoutReason.add(perm.name);
}
}
}
// 检查 string.json 中未被引用的 reason key
for (final key in reasonStrings.keys) {
if (!referencedReasons.contains(key)) {
result.unusedReasons.add(key);
}
}
return result;
}
// ── 报告输出 ──────────────────────────────────────────────────
void _printReport(CheckResult result) {
stdout.writeln();
stdout.writeln('${_bold}╔══════════════════════════════════════════════════╗$_reset');
stdout.writeln('${_bold}║ 鸿蒙权限一致性校验报告 ║$_reset');
stdout.writeln('${_bold}╚══════════════════════════════════════════════════╝$_reset');
stdout.writeln();
// ✅ 匹配项
stdout.writeln(
'${_green}✅ 匹配的权限 (${result.matched.length})$_reset',
);
if (result.matched.isEmpty) {
stdout.writeln(' (无)');
} else {
for (final name in result.matched) {
stdout.writeln(' $name');
}
}
stdout.writeln();
// ❌ 缺失的 reason 字符串
stdout.writeln(
'${_red}❌ 缺失的 reason 字符串 (${result.missingReasons.length})$_reset',
);
if (result.missingReasons.isEmpty) {
stdout.writeln(' (无)');
} else {
for (final item in result.missingReasons) {
stdout.writeln(' $item');
}
}
stdout.writeln();
// ⚠️ 未使用的 reason 字符串
stdout.writeln(
'${_yellow}⚠️ 未使用的 reason 字符串 (${result.unusedReasons.length})$_reset',
);
if (result.unusedReasons.isEmpty) {
stdout.writeln(' (无)');
} else {
for (final key in result.unusedReasons) {
stdout.writeln(' $key');
}
}
stdout.writeln();
// ⚠️ 缺少 reason 的 user_grant 权限
stdout.writeln(
'${_yellow}⚠️ 缺少 reason 的 user_grant 权限 (${result.permissionsWithoutReason.length})$_reset',
);
if (result.permissionsWithoutReason.isEmpty) {
stdout.writeln(' (无)');
} else {
for (final name in result.permissionsWithoutReason) {
stdout.writeln(' $name');
}
}
stdout.writeln();
// 总结
stdout.writeln('${_cyan}──────────────────────────────────────────────────$_reset');
if (result.hasErrors) {
stdout.writeln(
'${_red}${_bold}结果: 失败 — 发现 ${result.missingReasons.length} 个缺失引用, ${result.unusedReasons.length} 个未使用键$_reset',
);
} else if (result.hasWarnings) {
stdout.writeln(
'${_yellow}${_bold}结果: 通过(有警告) — ${result.permissionsWithoutReason.length} 个 user_grant 权限缺少 reason$_reset',
);
} else {
stdout.writeln(
'${_green}${_bold}结果: 通过 — 所有权限声明一致$_reset',
);
}
stdout.writeln('${_cyan}──────────────────────────────────────────────────$_reset');
stdout.writeln();
}
// ── 入口 ──────────────────────────────────────────────────────
void main() {
stdout.writeln('${_cyan}正在解析 module.json5...$_reset');
final permissions = _parseModuleJson5(moduleJson5Path);
stdout.writeln(' 找到 ${permissions.length} 个权限声明');
stdout.writeln('${_cyan}正在解析 string.json...$_reset');
final reasonStrings = _parseStringJson(stringJsonPath);
stdout.writeln(' 找到 ${reasonStrings.length} 个 permission_*_reason 键');
final result = _validate(permissions, reasonStrings);
_printReport(result);
if (result.hasErrors) {
exit(1);
}
}

View File

@@ -1,730 +0,0 @@
/// ============================================================
/// 闲言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);
}
}

View File

@@ -1,137 +0,0 @@
# ============================================================
# 闲言APP — pubspec.yaml 平台模板生成脚本
# 创建时间: 2026-06-02
# 更新时间: 2026-06-02
# 作用: 根据平台选择模板生成 pubspec.yaml
# 上次更新: 初始版本
# 用法:
# .\tools\setup_pubspec.ps1 -Platform ohos # 鸿蒙端
# .\tools\setup_pubspec.ps1 -Platform macos # MacBook Pro端
# .\tools\setup_pubspec.ps1 # 自动检测平台
# ============================================================
param(
[ValidateSet("ohos", "macos", "auto")]
[string]$Platform = "auto"
)
$ErrorActionPreference = "Stop"
$ProjectRoot = $PSScriptRoot
if ($ProjectRoot) {
$ProjectRoot = Split-Path -Parent $ProjectRoot
}
if (-not $ProjectRoot) {
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Definition
if ($ProjectRoot) {
$ProjectRoot = Split-Path -Parent $ProjectRoot
}
}
if (-not $ProjectRoot) {
$ProjectRoot = (Get-Location).Path
}
$OhosTemplate = Join-Path $ProjectRoot "pubspec.ohos.yaml"
$MacosTemplate = Join-Path $ProjectRoot "pubspec.macos.yaml"
$OutputFile = Join-Path $ProjectRoot "pubspec.yaml"
function Write-Status {
param([string]$Message)
Write-Host "[setup_pubspec] $Message" -ForegroundColor Cyan
}
function Write-Warning {
param([string]$Message)
Write-Host "[setup_pubspec] WARNING: $Message" -ForegroundColor Yellow
}
function Write-Error {
param([string]$Message)
Write-Host "[setup_pubspec] ERROR: $Message" -ForegroundColor Red
}
# --- 自动检测平台 ---
if ($Platform -eq "auto") {
Write-Status "Auto-detecting platform..."
$flutterPath = Get-Command flutter -ErrorAction SilentlyContinue
if ($flutterPath) {
$flutterVersion = & flutter --version 2>&1 | Select-String -Pattern "ohos|HarmonyOS" -Quiet
if ($flutterVersion) {
$Platform = "ohos"
Write-Status "Detected flutter-ohos SDK -> ohos"
} else {
$Platform = "macos"
Write-Status "Detected official Flutter SDK -> macos"
}
} else {
# 检查 packages 目录是否存在
$packagesDir = Join-Path $ProjectRoot "packages"
if (Test-Path $packagesDir) {
$Platform = "ohos"
Write-Status "Found packages/ directory -> ohos"
} else {
$Platform = "macos"
Write-Status "No packages/ directory -> macos"
}
}
}
# --- 选择模板 ---
$TemplateFile = switch ($Platform) {
"ohos" { $OhosTemplate }
"macos" { $MacosTemplate }
}
if (-not (Test-Path $TemplateFile)) {
Write-Error "Template file not found: $TemplateFile"
Write-Error "Expected: pubspec.ohos.yaml or pubspec.macos.yaml in project root"
exit 1
}
# --- 备份现有 pubspec.yaml ---
if (Test-Path $OutputFile) {
$BackupFile = Join-Path $ProjectRoot "pubspec.yaml.bak"
Copy-Item $OutputFile $BackupFile -Force
Write-Status "Backed up existing pubspec.yaml -> pubspec.yaml.bak"
}
# --- 复制模板 ---
Copy-Item $TemplateFile $OutputFile -Force
$PlatformLabel = switch ($Platform) {
"ohos" { "鸿蒙端 (HarmonyOS)" }
"macos" { "MacBook Pro端 (iOS/macOS)" }
}
Write-Status "========================================"
Write-Status "Generated pubspec.yaml for: $PlatformLabel"
Write-Status "Template: $(Split-Path $TemplateFile -Leaf)"
Write-Status "Output: pubspec.yaml"
Write-Status "========================================"
# --- 验证关键差异 ---
if ($Platform -eq "ohos") {
$hasPathRef = Select-String -Path $OutputFile -Pattern "path: packages/" -Quiet
if (-not $hasPathRef) {
Write-Warning "ohos template has no 'path: packages/' references - check template!"
}
Write-Status "Next steps:"
Write-Status " 1. flutter pub get"
Write-Status " 2. flutter build hap (or your ohos build command)"
} else {
$hasPathRef = Select-String -Path $OutputFile -Pattern "path: packages/" -Quiet
if ($hasPathRef) {
Write-Warning "macos template still has 'path: packages/' references - check template!"
}
Write-Status "Next steps:"
Write-Status " 1. flutter pub get"
Write-Status " 2. Apply pub cache patches (see iOS_macOS_Developer_Guide.md section 2.6)"
Write-Status " 3. flutter build ios --no-codesign"
Write-Status " 4. flutter build macos"
}
Write-Status ""
Write-Status "NOTE: pubspec.yaml is in .gitignore and will NOT be committed."
Write-Status "When adding a new dependency, update BOTH pubspec.ohos.yaml AND pubspec.macos.yaml"
Write-Status "Then update iOS_macOS_Developer_Guide.md to notify the other platform developer."

View File

@@ -1,81 +0,0 @@
import 'dart:io';
void main() {
final files = <String, String>{
't_settings_performance.dart': 'TSettingsPerformance',
't_settings_privacy.dart': 'TSettingsPrivacy',
't_settings_advanced.dart': 'TSettingsAdvanced',
't_settings_cache.dart': 'TSettingsCache',
't_settings_permission.dart': 'TSettingsPermission',
't_settings_data_collection.dart': 'TSettingsDataCollection',
};
const baseDir = 'e:\\project\\flutter\\f\\xianyan\\lib\\l10n\\types';
for (final entry in files.entries) {
final file = File('$baseDir\\${entry.key}');
final className = entry.value;
if (!file.existsSync()) {
print('NOT FOUND: ${entry.key}');
continue;
}
var content = file.readAsStringSync();
// Find the fromMap method
final startPattern = 'static $className fromMap(Map<String, String> map)';
final startIdx = content.indexOf(startPattern);
if (startIdx == -1) {
print('NO fromMap found: ${entry.key}');
continue;
}
// Find the end of the method (matching closing );
var depth = 0;
var endIdx = startIdx;
var foundOpen = false;
for (var i = startIdx; i < content.length; i++) {
if (content[i] == '(') {
depth++;
foundOpen = true;
} else if (content[i] == ')') {
depth--;
}
if (foundOpen && depth == 0) {
// Find the semicolon
endIdx = content.indexOf(';', i);
if (endIdx == -1) endIdx = i;
break;
}
}
final oldMethod = content.substring(startIdx, endIdx + 1);
// Parse all field: map['key'] ?? '' patterns
final fieldPattern = RegExp(r"(\w+):\s*map\['([^']+)'\]\s*\?\?\s*''");
final matches = fieldPattern.allMatches(oldMethod);
final newLines = <String>[];
for (final m in matches) {
final fieldName = m.group(1)!;
final mapKey = m.group(2)!;
newLines.add(' $fieldName: map[\'$mapKey\']?.isNotEmpty == true');
newLines.add(' ? map[\'$mapKey\']!');
newLines.add(' : (fallback?.$fieldName ?? \'\'),');
}
final newMethod =
'static $className fromMap(Map<String, String> map,\n'
' {$className? fallback}) =>\n'
' $className(\n'
'${newLines.join('\n')}\n'
' );';
content =
content.substring(0, startIdx) +
newMethod +
content.substring(endIdx + 1);
file.writeAsStringSync(content);
print('OK: ${entry.key} (${matches.length} fields)');
}
}

View File

@@ -1,46 +0,0 @@
/// ============================================================
/// 闲言APP — 双书名号检测规则
/// 创建时间: 2026-06-02
/// 更新时间: 2026-06-02
/// 作用: 检测字符串中的双书名号《《,预防多人协作风格不一致
/// 上次更新: 初始创建
/// ============================================================
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/error/listener.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';
class DoubleAngleBracketsRule extends DartLintRule {
DoubleAngleBracketsRule() : super(code: _code);
static const _code = LintCode(
name: 'double_angle_brackets',
problemMessage: '检测到双书名号《《,应为单书名号《》',
correctionMessage: '将《《替换为《,确保书名号正确配对',
);
@override
void run(
CustomLintResolver resolver,
DiagnosticReporter reporter,
CustomLintContext context,
) {
context.registry.addSimpleStringLiteral((node) {
_check(node.value, node, reporter);
});
context.registry.addStringInterpolation((node) {
for (final element in node.elements) {
if (element is InterpolationString) {
_check(element.value, element, reporter);
}
}
});
}
void _check(String value, AstNode node, DiagnosticReporter reporter) {
if (value.contains('《《')) {
reporter.atNode(node, _code);
}
}
}

View File

@@ -1,53 +0,0 @@
/// ============================================================
/// 闲言APP — 硬编码颜色检测规则
/// 创建时间: 2026-06-02
/// 更新时间: 2026-06-02
/// 作用: 检测非主题系统的硬编码颜色值,确保使用统一设计令牌
/// 上次更新: 初始创建
/// ============================================================
import 'package:analyzer/error/listener.dart';
import 'package:custom_lint_builder/custom_lint_builder.dart';
class HardcodedColorRule extends DartLintRule {
HardcodedColorRule() : super(code: _code);
static const _code = LintCode(
name: 'hardcoded_color',
problemMessage: '检测到硬编码颜色值,应使用主题系统变量',
correctionMessage: '使用 AppTheme.ext(context) 获取颜色,或添加到 app_colors.dart',
);
static final _hexColorPattern = RegExp(r'0x[0-9A-Fa-f]{8}');
static const _excludedFiles = <String>[
'app_colors.dart',
'app_theme.dart',
'color_weak_filter.dart',
'glass_tokens.dart',
'app_radius.dart',
'app_shadow.dart',
];
@override
void run(
CustomLintResolver resolver,
DiagnosticReporter reporter,
CustomLintContext context,
) {
final filePath = resolver.path;
if (_excludedFiles.any((e) => filePath.contains(e))) return;
context.registry.addInstanceCreationExpression((node) {
final typeName = node.constructorName.type.name.lexeme;
if (typeName != 'Color') return;
for (final arg in node.argumentList.arguments) {
final argStr = arg.toSource();
if (_hexColorPattern.hasMatch(argStr)) {
reporter.atNode(arg, _code);
}
}
});
}
}

View File

@@ -1,22 +0,0 @@
/// ============================================================
/// 闲言APP — 自定义Lint规则入口
/// 创建时间: 2026-06-02
/// 更新时间: 2026-06-02
/// 作用: 注册所有自定义lint规则插件
/// 上次更新: 移除硬编码中文检测规则
/// ============================================================
import 'package:custom_lint_builder/custom_lint_builder.dart';
import 'src/rules/double_angle_brackets.dart';
import 'src/rules/hardcoded_color.dart';
PluginBase createPlugin() => _XianyanLintPlugin();
class _XianyanLintPlugin extends PluginBase {
@override
List<LintRule> getLintRules(CustomLintConfigs configs) => [
DoubleAngleBracketsRule(),
HardcodedColorRule(),
];
}