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

10
.gitignore vendored
View File

@@ -47,5 +47,15 @@ app.*.map.json
# Local packages (git submodules)
/packages/
# pubspec.yaml — 由模板生成,不提交
# 鸿蒙端: 运行 tools/setup_pubspec.ps1 --platform ohos
# MacBook Pro端: 运行 tools/setup_pubspec.ps1 --platform macos
/pubspec.yaml
# pubspec.lock — 两端依赖源不同lock文件也不同不提交
# 鸿蒙端: 本地包 → 生成鸿蒙端lock
# MacBook Pro端: 远程版本 → 生成MacBook Pro端lock
/pubspec.lock
# Trae IDE
.trae/

View File

@@ -4,7 +4,7 @@
***
## [v6.10.5] - 2026-06-02
## [v6.10.6] - 2026-06-02
### 🧭 修复多个页面缺少AppBar标题和返回按钮
@@ -30,6 +30,33 @@
***
## [v6.10.5] - 2026-06-02
### 🏗️ pubspec.yaml 双模板机制(重大架构变更)
**背景:** 鸿蒙端和 MacBook Pro 端共用一个 `pubspec.yaml`MacBook Pro 端每次 `git pull` 后需手动替换 82 行本地包引用,容易出错且经常互相覆盖。
**核心变更:**
- 🏗️ `pubspec.yaml` 拆分为双模板:`pubspec.ohos.yaml`(鸿蒙端)+ `pubspec.macos.yaml`MacBook Pro端
- 🔒 `pubspec.yaml``pubspec.lock` 加入 `.gitignore`,两端都不再提交
- 🛠️ 新增 `tools/setup_pubspec.ps1` 脚本,自动选择平台模板生成 `pubspec.yaml`
- 📋 新增三方库变更通知机制:新增依赖必须同时更新两个模板 + `iOS_macOS_Developer_Guide.md`
**新增文件:**
- 📄 `pubspec.ohos.yaml` — 鸿蒙端模板(使用本地 packages/ 目录)
- 📄 `pubspec.macos.yaml` — MacBook Pro 端模板(使用远程版本号)
- 🛠️ `tools/setup_pubspec.ps1` — 平台模板生成脚本
**修改文件:**
- `.gitignore` — 新增 `/pubspec.yaml` + `/pubspec.lock`
- `iOS_macOS_Developer_Guide.md` — v7 全面重写 §2 章节(双模板机制 + 变更流程)
**迁移指南:**
- 鸿蒙端:`.\tools\setup_pubspec.ps1 -Platform ohos``flutter pub get`
- MacBook Pro端`.\tools\setup_pubspec.ps1 -Platform macos``flutter pub get`
***
## [v6.10.4] - 2026-06-02
### 🌐 Me页面、缓存管理、账户洞察多语言支持

View File

@@ -18,7 +18,7 @@ import flutter_blue_plus_darwin
import flutter_image_compress_macos
import flutter_inappwebview_macos
import flutter_local_notifications
import flutter_secure_storage_darwin
import flutter_secure_storage_macos
import flutter_tts
import flutter_webrtc
import gal
@@ -57,7 +57,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterImageCompressMacosPlugin.register(with: registry.registrar(forPlugin: "FlutterImageCompressMacosPlugin"))
InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))

View File

@@ -1,17 +1,18 @@
# ============================================================
# 闲言APP (Xianyan) — Flutter 版 pubspec.yaml
# 闲言APP (Xianyan) — 鸿蒙端 pubspec 模板
# 创建时间: 2026-04-20
# 更新时间: 2026-05-30
# 作用: 项目依赖与资源配置
# 上次更新: 新增webfeed RSS订阅解析库
# **python**: C:\Users\无书\AppData\Local\Python\pythoncore-3.14-64\python.exe
# 更新时间: 2026-06-02
# 作用: 鸿蒙端依赖与资源配置模板(使用本地 packages/ 目录)
# 上次更新: pubspec.yaml 拆分为双模板ohos/macospubspec.yaml 不再提交
# 使用方式: 运行 tools/setup_pubspec.ps1 --platform ohos 自动生成 pubspec.yaml
# ⚠️ 此文件为模板,不要直接重命名为 pubspec.yaml 使用
# ============================================================
name: xianyan
description: "闲言 — 文字阅读更纯粹。句子阅读 + 壁纸制作 APP"
publish_to: 'none'
version: 6.6.2+26060202
# 年月日-次
version: 6.6.2+2606022
# 年月日-次 7位
environment:
sdk: ^3.11.5
@@ -48,13 +49,17 @@ dependencies:
json_annotation: ^4.9.0 # JSON序列化注解
# --- KV 存储 ---
shared_preferences: ^2.5.5 # 轻量KV持久化
flutter_secure_storage: ^10.2.0 # 加密安全存储
shared_preferences: # v2.5.5 | 轻量KV持久化(本地化-鸿蒙适配)
path: packages/shared_preferences
flutter_secure_storage: # v9.2.4 | 加密安全存储(本地化-鸿蒙适配)
path: packages/flutter_secure_storage
hive_ce: ^2.0.0 # 高性能NoSQL数据库(社区维护版)
hive_flutter: ^1.1.0 # Hive Flutter适配
hive_flutter: # v1.1.0-ohos.2 | Hive Flutter适配(本地化-鸿蒙适配)
path: packages/hive_flutter
# --- 文件路径 ---
path_provider: ^2.1.5 # 系统目录路径获取
path_provider: # v2.1.5 | 系统目录路径获取(本地化-鸿蒙适配)
path: packages/path_provider
path: ^1.9.0 # 路径操作工具
# --- 工具 ---
@@ -64,35 +69,48 @@ dependencies:
logger: ^2.5.0 # 分级日志输出
collection: ^1.19.0 # 集合操作扩展
dartx: ^1.2.0 # 集合安全扩展方法(firstOrNull/getOrNull等)
syncfusion_flutter_charts: ^28.1.36 # Syncfusion图表库(替代fl_chart)
syncfusion_flutter_charts: ^33.2.8
# Syncfusion图表库(替代fl_chart)
# --- 设备信息 ---
package_info_plus: ^10.1.0 # 应用包信息读取
connectivity_plus: ^7.1.1 # 网络连接状态监听
device_info_plus: ^13.1.0 # 设备硬件信息读取
package_info_plus: # v10.1.0 | 应用包信息读取(本地化-鸿蒙适配)
path: packages/package_info_plus
connectivity_plus: # v7.1.1 | 网络连接状态监听(本地化-鸿蒙适配)
path: packages/connectivity_plus
device_info_plus: # v13.1.0 | 设备硬件信息读取(本地化-鸿蒙适配)
path: packages/device_info_plus
# --- 日历同步 ---
device_calendar: ^4.3.3 # 跨平台日历事件读写
device_calendar: ^4.3.3 # 跨平台日历事件读写
# --- 权限 ---
permission_handler: ^12.0.1 # 运行时权限请求
permission_handler: # v12.0.1 | 运行时权限请求(本地化-鸿蒙适配)
path: packages/permission_handler
# --- 本地通知 ---
flutter_local_notifications: ^21.0.0 # 本地推送通知
flutter_local_notifications: # v21.0.0 | 本地推送通知(本地化-鸿蒙适配)
path: packages/flutter_local_notifications
# --- 后台任务调度 ---
workmanager: ^0.9.0 # 后台任务调度
workmanager: # v0.9.0-ohos.1 | 后台任务调度(本地化-鸿蒙适配)
path: packages/workmanager
# --- 外部链接 ---
url_launcher: ^6.3.2 # 打开外部URL/应用
app_links: ^7.0.0 # 深度链接处理
url_launcher: # v6.3.2 | 打开外部URL/应用(本地化-鸿蒙适配)
path: packages/url_launcher
app_links: # v7.0.0-ohos.1 | 深度链接处理(本地化-鸿蒙适配)
path: packages/app_links
# --- 快捷操作 ---
quick_actions: ^1.1.0 # 主屏幕快捷操作(iOS Quick Actions / Android App Shortcuts)
# 鸿蒙端通过EntryAbility.ets shortcuts + MethodChannel自行实现
# --- 桌面小组件 ---
home_widget: ^0.9.1 # iOS/Android桌面小组件
home_widget:
git:
url: https://gitcode.com/CPF-Flutter/fluttertpc_home_widget.git
ref: br_3.22_dev
path: packages/home_widget
# --- iOS 26 Liquid Glass 组件 ---
liquid_glass_widgets: ^0.11.0 # iOS26液态玻璃组件库
@@ -102,8 +120,10 @@ dependencies:
stupid_simple_sheet: ^0.9.1+1 # 简易底部弹出面板
heroine: ^0.7.2 # Hero过渡动画增强
file_picker: ^11.0.0 # 文件选择器
image_picker: ^1.2.2 # 相机/相册选图
file_picker: # v11.0.0-ohos.1 | 文件选择器(本地化-鸿蒙适配)
path: packages/file_picker
image_picker: # v1.2.2 | 相机/相册选图(本地化-鸿蒙适配)
path: packages/image_picker
adaptive_palette: ^3.0.0 # 图片主色提取+流体背景
# --- UI 基础 ---
@@ -114,9 +134,11 @@ dependencies:
shimmer: ^3.0.0 # 骨架屏加载占位
# --- 分享 + 导出 ---
share_plus: ^13.1.0 # 系统分享面板
share_plus: # v13.1.0 | 系统分享面板
path: packages/share_plus
qr_flutter: ^4.1.0 # 二维码渲染
gal: ^2.3.0 # 保存图片/视频到相册
gal: # v2.3.0-ohos.1 | 保存图片/视频到相册(本地化-鸿蒙适配)
path: packages/gal
archive: ^4.0.0 # ZIP压缩/解压
crypto: ^3.0.0 # 加密哈希算法
encrypt: ^5.0.3 # 对称/非对称加密
@@ -139,13 +161,15 @@ dependencies:
flutter_svg: ^2.0.0 # SVG图片渲染
# --- 富文本编辑器 ---
flutter_quill: ^11.5.0 # Quill富文本编辑器
flutter_quill: # v11.5.0 | Quill富文本编辑器
path: packages/flutter_quill
# --- 虚线边框 ---
dotted_border: ^3.1.0 # 虚线/点线边框装饰
# --- 颜色选择器 ---
flex_color_picker: ^3.8.0 # HSL颜色选择器
flex_color_picker: # v3.8.0-ohos.1 | HSL颜色选择器(本地化-鸿蒙适配)
path: packages/flex_color_picker
# --- 键盘可见性 ---
flutter_keyboard_visibility: ^6.0.0 # 键盘可见性监听(替代MediaQuery轮询)
@@ -182,13 +206,16 @@ dependencies:
pinyin: ^3.3.0 # 汉字转拼音
# --- 语音朗读 ---
flutter_tts: ^4.2.0 # TTS文本转语音朗读
flutter_tts: # v4.2.5-ohos.1 | TTS文本转语音朗读(本地化-鸿蒙适配)
path: packages/flutter_tts
# --- 语音识别 ---
speech_to_text: ^7.0.0 # 语音转文字
speech_to_text: # v7.4.0-ohos.1 | 语音转文字(本地化-鸿蒙适配)
path: packages/speech_to_text
# --- 灵动岛/实时活动 ---
live_activities: ^2.0.0 # 灵动岛/实时活动
live_activities: # v2.4.9-ohos.1 | 灵动岛/实时活动(本地化-鸿蒙适配)
path: packages/live_activities
# --- iOS风格组件 ---
pull_down_button: ^0.10.1 # iOS下拉菜单按钮
@@ -215,37 +242,54 @@ dependencies:
image_size_getter: ^2.4.1 # 图片尺寸读取(无需解码)
extended_image: ^10.0.1 # 图片缓存+缩放+裁剪
photo_view: ^0.15.0 # 图片缩放/平移查看
flutter_image_compress: ^2.4.0 # 图片压缩(保持EXIF)
flutter_image_compress: # v2.4.0 | 图片压缩(保持EXIF)
path: packages/flutter_image_compress
vector_math: any # 向量数学运算
wakelock_plus: ^1.4.0 # 屏幕常亮控制
audioplayers: ^6.5.0 # 音频播放
record: ^6.0.0 # 录音
video_compress: ^3.1.2 # 视频压缩
video_player: ^2.10.0 # 视频播放
local_auth: ^3.0.1 # 生物识别认证
sensors_plus: ^6.1.0 # 加速度传感器(摇一摇)
battery_plus: ^7.0.0 # 电池状态监听
wakelock_plus: # v1.4.0-ohos.1 | 屏幕常亮控制(本地化-鸿蒙适配)
path: packages/wakelock_plus
audioplayers: # v6.5.0-ohos.1 | 音频播放(本地化-鸿蒙适配)
path: packages/audioplayers
record: # v6.0.0-ohos.1 | 录音(本地化-鸿蒙适配)
path: packages/record
video_compress: # v3.1.2-ohos.1 | 视频压缩(本地化-鸿蒙适配)
path: packages/video_compress
video_player: # v2.10.0-ohos.1 | 视频播放(本地化-鸿蒙适配)
path: packages/video_player
local_auth: # v3.0.1 | 生物识别认证(本地化-鸿蒙适配)
path: packages/local_auth
sensors_plus: # v6.1.0-ohos.1 | 加速度传感器(摇一摇)(本地化-鸿蒙适配)
path: packages/sensors_plus
battery_plus: # v7.0.0-ohos.1 | 电池状态监听(本地化-鸿蒙适配)
path: packages/battery_plus
# --- 文件传输助手 ---
shelf: ^1.4.0 # HTTP服务器框架
shelf_router: ^1.1.0 # 路由中间件
shelf_web_socket: ^3.0.0 # WebSocket支持
network_info_plus: ^8.1.0 # WiFi网络信息
flutter_webrtc: ^1.4.0 # WebRTC音视频通信
network_info_plus: # v8.1.0-ohos.1 | WiFi网络信息(本地化-鸿蒙适配)
path: packages/network_info_plus
flutter_webrtc: # v1.4.0-ohos.1 | WebRTC音视频通信(本地化-鸿蒙适配)
path: packages/flutter_webrtc
web_socket_channel: ^3.0.3 # WebSocket客户端
flutter_blue_plus: ^2.1.0 # 蓝牙BLE通信
flutter_nfc_kit: ^3.6.0 # NFC读写
flutter_blue_plus: # v2.1.0-ohos.1 | 蓝牙BLE通信(本地化-鸿蒙适配)
path: packages/flutter_blue_plus
flutter_nfc_kit: # v3.6.0-rc.6-ohos | NFC读写(TPC官方鸿蒙适配)
path: packages/flutter_nfc_kit
mime: ^2.0.0 # MIME类型识别
mobile_scanner: ^7.1.4 # 二维码/条形码扫描
mobile_scanner: # v7.1.4-ohos.1 | 二维码/条形码扫描(本地化-鸿蒙适配)
path: packages/mobile_scanner
basic_utils: ^5.7.0 # 通用工具集(Base64/ASN1)
wifi_iot: ^0.3.19 # WiFi IoT设备连接
nearby_service: ^0.2.1 # 近场设备发现+通信
wifi_iot: # v0.3.19-ohos.1 | WiFi IoT设备连接(本地化-鸿蒙适配)
path: packages/wifi_iot
nearby_service: # v0.2.1 | 近场设备发现+通信
path: packages/nearby_service
flutter_localizations:
sdk: flutter # Flutter国际化支持
timezone: ^0.11.0 # 时区数据库
sqflite: ^2.4.1 # SQLite轻量数据库
sqflite: # v2.4.1-ohos.1 | SQLite轻量数据库(本地化-鸿蒙适配)
path: packages/sqflite
cross_file: any # 跨平台文件抽象
receive_sharing_intent:
git:
@@ -278,9 +322,10 @@ dev_dependencies:
# ============================================================
# 依赖覆写 — 解决版本冲突 (MacBook Pro 端精简版)
# 依赖覆写 — 鸿蒙端(本地包覆盖 + 版本冲突解决)
# 1. liquid_glass_widgets与flutter_test的meta版本冲突
# 2. device_calendar ^4.3.3 依赖 timezone ^0.9.0(<0.10.0)
# 2. 本地化包覆写让远程依赖的库也使用本地path版本
# 3. device_calendar ^4.3.3 依赖 timezone ^0.9.0(<0.10.0)
# 但 flutter_local_notifications 依赖 timezone ^0.11.0(<0.12.0)
# timezone 0.9→0.11 API兼容(仅时区数据更新),强制使用^0.11.0
# ============================================================
@@ -288,7 +333,92 @@ dependency_overrides:
meta: ^1.17.0
web: ^1.1.0
timezone: ^0.11.0
win32: ^6.0.1
path_provider:
path: packages/path_provider
shared_preferences:
path: packages/shared_preferences
flutter_secure_storage:
path: packages/flutter_secure_storage
flutter_secure_storage_windows:
path: packages/flutter_secure_storage_windows
connectivity_plus:
path: packages/connectivity_plus
package_info_plus:
path: packages/package_info_plus
device_info_plus:
path: packages/device_info_plus
permission_handler:
path: packages/permission_handler
flutter_local_notifications:
path: packages/flutter_local_notifications
url_launcher:
path: packages/url_launcher
file_picker:
path: packages/file_picker
image_picker:
path: packages/image_picker
local_auth:
path: packages/local_auth
quill_native_bridge_windows:
path: packages/quill_native_bridge_windows
video_player:
path: packages/video_player
video_player_ohos:
path: packages/video_player_ohos
wakelock_plus:
path: packages/wakelock_plus
audioplayers:
path: packages/audioplayers
audioplayers_ohos:
path: packages/audioplayers_ohos
record:
path: packages/record
record_ohos:
path: packages/record_ohos
mobile_scanner:
path: packages/mobile_scanner
wifi_iot:
path: packages/wifi_iot
sqflite:
path: packages/sqflite
sqflite_ohos:
path: packages/sqflite_ohos
video_compress:
path: packages/video_compress
flutter_blue_plus:
path: packages/flutter_blue_plus
flutter_blue_plus_ohos:
path: packages/flutter_blue_plus_ohos
gal:
path: packages/gal
network_info_plus:
path: packages/network_info_plus
network_info_plus_ohos:
path: packages/network_info_plus_ohos
app_links:
path: packages/app_links
app_links_ohos:
path: packages/app_links_ohos
flutter_webrtc:
path: packages/flutter_webrtc
win32:
path: packages/win32
hive_flutter:
path: packages/hive_flutter
sensors_plus:
path: packages/sensors_plus
sensors_plus_platform_interface:
path: packages/sensors_plus_platform_interface
live_activities:
path: packages/live_activities
speech_to_text:
path: packages/speech_to_text
workmanager:
path: packages/workmanager
workmanager_ohos:
path: packages/workmanager_ohos
home_widget:
path: packages/home_widget
# ============================================================
# Flutter 配置

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(),
];
}