diff --git a/CHANGELOG.md b/CHANGELOG.md index 34e36b4..846c011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,255 +3,1017 @@ All notable changes to this project will be documented in this file. -## [0.99.42] - 2026-04-25 +## [0.99.67] - 2026-04-25 -### ✨ 新增 — 导出功能增强(4项改进) +### 🎨 新增: 鸿蒙(HarmonyOS)分层图标 -#### 新增: 用户自选保存路径 -- **变更**: 导出文件时弹出系统文件选择器,用户自行选择保存位置 -- **实现**: 使用 `FilePicker.saveFile()` 替代静默写入 Download 目录 -- **影响**: DOCX/PDF/自定义导出 均已支持路径选择 -- **文件**: `lib/src/widgets/recipe_detail/interaction/recipe_export_button.dart` +#### 问题 +- 华为应用审核警告:`应用未配置图标的前景图和后景图,标准要求尺寸1024px*1024px` +- 原因:使用单层 `app_icon.png`,未采用鸿蒙分层图标规范 -#### 新增: 系统分享功能 -- **变更**: 导出成功后弹出对话框,支持「📤 分享给朋友」按钮 -- **实现**: 使用 `Share.shareXFiles()` 调用系统分享接口 -- **支持格式**: PDF、DOCX 均可分享至微信/邮件/其他应用 -- **文件**: `lib/src/widgets/recipe_detail/interaction/recipe_export_button.dart` +#### 解决方案 +按照华为UX设计规范,生成 **foreground + background** 双层图标: -#### 修复: PDF中文乱码(??? 问题) -- **问题**: PDF使用 Helvetica + WinAnsiEncoding,不支持中文字符 → 全部显示为 ??? -- **根因**: `docs_gee/pdf_generator.dart` 仅支持PDF Base 14字体(Helvetica/Times/Courier),无CJK支持 -- **修复**: - - 扩展 `PdfGenerator` 构造函数,新增 `embeddedFontBytes` / `embeddedBoldFontBytes` 参数 - - 当传入TTF/OTF字体时,自动创建 CIDFont Type0 + Identity-H 编码 - - 内嵌 NotoSansSC 字体到PDF文档 -- **验证**: `scripts/test_pdf_export_verify.dart` 验证脚本确认 FontDescriptor/CIDFontType2/Identity-H 正确嵌入 -- **文件**: `packages/docs_gee/docx_generator/lib/src/pdf_generator.dart` +| 文件 | 尺寸 | 说明 | +|------|------|------| +| `background_icon.png` | 1024×1024 | 纯色不透明背景 RGB(237,189,109) 金黄色 | +| `foreground_icon.png` | 1024×1024 | 透明PNG,图标主体内容(含圆角遮罩) | +| `layered_image.json` | - | 分层图标配置文件 | -#### 新增: PDF封面图片嵌入 -- **功能**: 导出PDF时自动在文档顶部嵌入菜品封面图 -- **实现**: - - 从 `DefaultCacheManager` 缓存读取已下载的封面图(不重新下载) - - 若无缓存则输出空白(不阻塞导出流程) - - 图片居中显示,尺寸根据文件大小自适应(200-450pt) -- **新增参数**: `coverImageBytes` / `coverImageWidth` / `coverImageHeight` -- **文件**: `packages/docs_gee/docx_generator/lib/src/pdf_generator.dart`, `lib/src/widgets/recipe_detail/interaction/recipe_export_button.dart` +#### 配置变更 +- `AppScope/app.json5`: `"icon"` → `$media:layered_image` +- `entry/src/main/module.json5`: `"icon"` + `"startWindowIcon"` → `$media:layered_image` +#### 生成位置 +``` +ohos/AppScope/resources/base/media/ +├── background_icon.png # 背景层 +├── foreground_icon.png # 前景层 +├── layered_image.json # 分层配置 +└── app_icon.png # (保留) -## [0.99.41] - 2026-04-24 +ohos/entry/src/main/resources/base/media/ +├── background_icon.png +├── foreground_icon.png +├── layered_image.json +└── icon.png # (保留) +``` -### 🐛 修复 — 3项重要Bug修复 +#### 工具脚本 +- [gen_hmos_icons.py](scripts/gen_hmos_icons.py): 从源图标自动提取前景/背景层 -#### 修复: 主题跟随系统切换不实时生效 -- **问题**: 开启"跟随系统"后,切换系统深色模式,应用未实时切换 -- **根因**: `GetCupertinoApp` 的 `Obx` 闭包内未直接访问 `isDarkMode.value`,导致 GetX 未追踪到主题状态变化 -- **修复**: 在 `main.dart` 的 `Obx` 闭包内显式引用 `themeService.isDarkMode.value`,确保主题变化触发重建 -- **文件**: `lib/main.dart` - -#### 修复: 统计面板数字显示0 -- **问题**: 运营数据大屏中,当数据为0时直接显示"0",不符合展示要求 -- **修复**: `_formatNumber` 方法增加 `n <= 0` 判断,最少显示"1" -- **文件**: `lib/src/pages/profile/data/stats_dashboard_page.dart` - -#### 修复: 数据导出JSON导入无法识别 (3个子问题) -- **问题1: UTF-8编码错误** — `FilePicker` 读取文件使用 `String.fromCharCodes`,不正确处理UTF-8中文 - - **修复**: 改用 `utf8.decode(bytes, allowMalformed: true)`,确保中文内容正确解码 - - **文件**: `lib/src/pages/profile/tools/data_export_page.dart` -- **问题2: MealRecordModel缺少toJson** — `exportToJson()` 直接序列化 Model 对象,输出 `Instance of 'MealRecordModel'` 而非有效JSON - - **修复**: 为 `MealRecordModel` 添加 `toJson()` 和 `fromJson()` 方法;`MealRecordController.exportToJson()` 改用 `.map((e) => e.toJson())` 序列化 - - **文件**: `lib/src/models/data/record/meal_record_model.dart`, `lib/src/controllers/data/meal_record_controller.dart` -- **问题3: _inferDataSource字段名不匹配** — 收藏导出使用 `favorite_type`(下划线),但推断逻辑只检查 `favoriteType`(驼峰) - - **修复**: `_inferDataSource` 同时检查 `favoriteType` 和 `favorite_type` - - **文件**: `lib/src/services/data/business/data_export_service.dart` - -### ✨ 优化 — 数据导出格式重构 - -#### 重构: 导出JSON增加元数据标识 -- **新增**: 全量导出和单源导出均添加 `_meta` 元数据字段 +#### 🔧 修复: layered_image.json 格式 +- **问题**: 初始生成的 JSON 格式不符合华为规范,警告仍存在 +- **原因**: 缺少外层 `"layered-image"` 包装键 +- **修复前** (❌): ```json - { - "_meta": { - "app": "cute_kitchen", - "version": 2, - "exportTime": "2026-04-24T12:00:00", - "format": "full" | "single", - "source": "favorites" - }, - "favorites": [...] + { "foreground": "...", "background": "...", "size": {...} } + ``` +- **修复后** (✅): + ```json + { "layered-image": { "foreground": "$media:foreground_icon", "background": "$media:background_icon" } } + ``` + +--- + +## [0.99.66] - 2026-04-25 + +### 🐛 修复: PDF导出"Invalid characters"错误 + +#### 问题 +- PDF导出含中文(如"小妈厨房")时报错:`Invalid argument (string): Contains invalid characters.. "小妈厨房"` +- **根因**: 之前子集化脚本从OTF(CFF轮廓)源文件生成TTF,但`--flavor ttf`只改文件头标记,**未真正转换轮廓格式** + - 输出文件虽有`.ttf`后缀,内部仍是CFF格式(有`CFF `表,无`glyf`表) + - dart_pdf的`Font.ttf()`要求真正的TrueType轮廓(`glyf`+`loca`表),遇到CFF数据报错 + +#### 解决方案 +1. 从Google Fonts下载官方 **NotoSansSC变量字体(TTF)** —— 真正的TrueType格式(有glyf表) +2. 使用`fontTools.varLib.instancer`提取特定字重实例(Regular=400, Bold=700) +3. 使用fontTools API直接子集化(非CLI),保留glyf表 +4. CLI方式的`--flavor ttf`对CJK变量字体无效,必须用API + +#### 字体文件变更 +| 文件 | 修改前 | 修改后 | 格式 | +|------|--------|--------|------| +| NotoSansSC-Regular.ttf | 2.81MB (CFF❌) | 9.40MB (glyf✅) | TrueType | +| NotoSansSC-Bold.ttf | 2.79MB (CFF❌) | 9.38MB (glyf✅) | TrueType | +| NotoSansSC-Variable.ttf | - | 16.95MB | 源文件(新增) | + +#### 技术要点 +- **OTF vs TTF本质区别**: OTF用CFF轮廓(PostScript曲线),TTF用glyf轮廓(二次贝塞尔曲线) +- **fontTools限制**: `pyftsubset --flavor ttf`不转换轮廓格式,仅改sfVersion标记 +- **正确做法**: 必须从TTF源文件子集化,或使用pen机制逐字形转换(复杂且易出错) +- **推荐方案**: 使用Google Fonts官方TTF版本作为源文件 + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `assets/fonts/NotoSansSC-Regular.ttf` | 替换 | 真正的TTf格式(9.40MB) | +| `assets/fonts/NotoSansSC-Bold.ttf` | 替换 | 真正的TTf格式(9.38MB) | +| `assets/fonts/NotoSansSC-Variable.ttf` | 新增 | 官方变量字体源文件 | +| `scripts/subset_noto_font.py` | 重写 | 使用varLib instancer + API子集化 | + +--- + +## [0.99.65] - 2026-04-25 + +### 🔧 优化: NotoSansSC字体子集化压缩 + +#### 修改说明 +- **目的**: 减小APK体积,字体文件从 40.16MB 缩减至 12.06MB(减少 70%) +- **效果**: APK从 96.9MB 降至 81.5MB(减少 15.4MB) + +#### 字体文件变更 +| 文件 | 修改前 | 修改后 | 说明 | +|------|--------|--------|------| +| NotoSansSC-Regular.otf | 3.19MB | 3.19MB | Flutter UI使用,保持不变 | +| NotoSansSC-Bold.otf | 3.27MB | 3.27MB | Flutter UI使用,保持不变 | +| NotoSansSC-Regular.ttf | 16.94MB | 2.81MB | PDF导出用,子集化压缩83% | +| NotoSansSC-Bold.ttf | 16.76MB | 2.79MB | PDF导出用,子集化压缩83% | + +#### 子集化策略 +- 字符集:CJK统一汉字(0x4E00-0x9FFF) + CJK扩展A(0x3400-0x4DBF) + ASCII + 拉丁扩展 + 中日韩标点 + 全角字符 +- 共 28,377 个字符(覆盖99.9%常用中文) +- 去除hinting信息 + desubroutinize优化 +- 工具:fontTools pyftsubset +- 脚本:`scripts/subset_noto_font.py` + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `assets/fonts/NotoSansSC-Regular.ttf` | 替换 | 子集化版本(2.81MB) | +| `assets/fonts/NotoSansSC-Bold.ttf` | 替换 | 子集化版本(2.79MB) | +| `scripts/subset_noto_font.py` | 新增 | 字体子集化脚本 | + + +## [0.99.64] - 2026-04-25 + +### 🔧 优化: Android打包去除armeabi-v7a架构 + +#### 修改说明 +- **目的**: 减小APK体积,去除老旧的32位ARM架构 +- **保留架构**: `arm64-v8a`(64位ARM,覆盖现代设备)+ `x86_64`(模拟器) +- **去除架构**: `armeabi-v7a`(32位ARM)+ `x86`(32位模拟器) +- **效果**: APK从 100.2MB 减小到 96.9MB(减少约3.3MB) + +#### 实现方式(双重过滤) +1. **`--target-platform`**: 排除Flutter引擎的v7编译(`libapp.so`、`libflutter.so`) +2. **`packaging.jniLibs.excludes`**: 排除第三方插件预编译的v7 .so文件 + +```kotlin +// build.gradle.kts +packaging { + jniLibs { + excludes += "lib/armeabi-v7a/**" + } +} +``` + +```bash +# 打包命令 +flutter build apk --release --target-platform android-arm64,android-x64 +``` + +#### 注意事项 +- ⚠️ 必须使用 `--target-platform android-arm64,android-x64` 参数打包 +- ⚠️ 去除v7后不支持32位老旧设备(2015年前的设备) + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `android/app/build.gradle.kts` | 修改 | 添加packaging.jniLibs.excludes排除v7 | + + +## [0.99.63] - 2026-04-25 + +### ✨ 新增: "评价应用"按钮支持鸿蒙端跳转应用商店 + +#### 功能说明 +- **鸿蒙端**: 点击"评价应用"后弹出五星好评请求对话框,确认后跳转华为应用商店 +- **其他平台**: 保持原有提示(未找到应用商店链接) + +#### 对话框设计 +``` +┌─────────────────────────────┐ +│ ⭐ 给个五星好评吧 │ +├─────────────────────────────┤ +│ │ +│ 如果您喜欢小妈厨房, │ +│ 请给我们一个好评! │ +│ │ +│ 您的支持是我们前进的动力 💪 │ +│ │ +│ ⭐⭐⭐⭐⭐ │ +│ │ +├─────────────────────────────┤ +│ [下次再说] [去评价 ⭐] │ +└─────────────────────────────┘ +``` + +#### 跳转地址 +- **华为应用商店**: `https://appgallery.huawei.com/app/detail?id=cute.major.kitchen` +- **打开方式**: `LaunchMode.externalApplication`(外部浏览器) + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `about_page.dart` | 修改 | 新增 `_onRateApp` 方法 + 导入 PlatformUtils | + +#### 编译状态 +- ✅ flutter analyze: 1个info(无error) + + +## [0.99.62] - 2026-04-25 + +### 🐛 修复: 自定义导出对话框开关无法点击 + +#### 问题描述 +- **症状**: "自定义导出"对话框中的7个开关按钮(封面图片、食材清单等)无法点击 +- **影响**: 用户无法选择要导出的内容 + +#### 根因分析 +**问题**: 使用 `CupertinoAlertDialog` + `SingleChildScrollView` 实现自定义导出对话框 + +**原因**: `CupertinoAlertDialog` 的 content 区域有固定的高度限制,当包含7个开关的内容超出限制时: +1. 触摸事件被对话框的边界裁剪 +2. 开关组件无法接收触摸事件 +3. 导致开关无法切换状态 + +#### 修复方案 +将 `CupertinoDialog` 改为 **底部弹出面板 (Bottom Sheet)**: + +```dart +// ❌ 修复前:CupertinoAlertDialog(有高度限制) +showCupertinoDialog( + builder: (ctx) => CupertinoAlertDialog( + content: SingleChildScrollView(...), // 开关无法点击 + actions: [...], + ), +) + +// ✅ 修复后:底部弹出面板(无高度限制) +showCupertinoModalPopup( + builder: (ctx) => Container( + constraints: BoxConstraints(maxHeight: screenHeight * 0.85), + child: Column([ + // 拖动指示器 + // 标题栏 + 取消按钮 + // 开关列表(可正常点击) + // 导出按钮(Word/PDF 并排) + ]), + ), +) +``` + +#### 新UI特性 +- ✅ iOS风格底部弹出面板(带拖动指示器) +- ✅ 圆角顶部设计 (Radius.circular(14)) +- ✅ 支持深色/浅色模式自动适配 +- ✅ 导出按钮并排显示(Word + PDF) +- ✅ 取消按钮移至标题栏右侧 +- ✅ 最大高度限制为屏幕85%(防止小屏幕溢出) + +#### 编译状态 +- ✅ flutter analyze: 4个info级别提示(无error) + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 重写 `_showCustomExportOptions` 方法 | + + +## [0.99.61] - 2026-04-25 + +### 🐛 修复: JSON导入报错 "Unexpected end of input (line 88, char 4)" + +#### 问题描述 +- **错误**: `FormatException: Unexpected end of input (at line 88, character 4)` +- **场景**: 鸿蒙端导出的JSON数据,原班不动导入时报错 +- **影响**: 无法导入浏览记录等数据 + +#### 根因分析 +**BUG位置**: `_cleanJsonContent()` 方法中的 `]` 处理逻辑 + +```dart +// ❌ 错误代码 (修复前) +final lastBracket = content.lastIndexOf(']'); +if (lastBracket >= 0 && lastBracket < content.length - 1) { + final trailing = content.substring(lastBracket + 1).trim(); + if (trailing.isNotEmpty) { // ← 只要非空就截取! + content = content.substring(0, lastBracket + 1); // 删除了最后的 } + } +} +``` + +**问题流程**: +``` +原始JSON结构: +{ + ... + "browse_history": [...] ← ] 在这里 (位置2000) +} ← } 在最后 (位置2002) + +BUG触发: +1. 找到最后一个 ] (位置2000) +2. ] 之后的内容: "\n}" → trim后 → "}" +3. 判断: 非空 → 截断到位置2001 +4. 结果: JSON变成 [...]\n ← 缺少外层的 } +5. 解析失败: line 88 (就是 ]) 的第4个字符处意外结束 +``` + +#### 修复方案 +在 `]` 的处理逻辑中,添加与 `}` 相同的合法性检查: + +```dart +// ✅ 正确代码 (修复后) +if (trailing.isNotEmpty && + !trailing.startsWith(',') && // 允许逗号 + !trailing.startsWith('}') && // ← 新增:允许花括号 + !trailing.startsWith(']')) { // ← 新增:允许方括号 + // 只有真正的脏数据才截取 +} +``` + +#### 修改文件 +| 文件 | 行号 | 修改内容 | +|------|------|----------| +| `data_export_page.dart` | L389-L392 | 添加 `]` 后的合法性检查 | +| `data_export_service.dart` | L321-L324 | 同步修复相同的BUG | + +#### 验证结果 +✅ 脚本验证通过: +- 文件大小: 2053 bytes +- 字符数: 2003 chars +- 解析状态: ✅ 成功 +- 数据统计: 浏览记录3条 + 每周菜单7天 + +✅ 编译检查: +- flutter analyze: No issues found! + +#### 诊断脚本 +创建了4个诊断脚本来定位和验证问题: +1. [test_json_import_diagnosis.dart](scripts/test_json_import_diagnosis.dart) - 基础格式诊断 +2. [test_json_import_flow.dart](scripts/test_json_import_flow.dart) - 完整流程模拟 +3. [test_json_debug_clean.dart](scripts/test_json_debug_clean.dart) - 详细调试清理逻辑 +4. [test_json_fixed.dart](scripts/test_json_fixed.dart) - 修复验证 + + +## [0.99.60] - 2026-04-25 + +### 🎨 优化: 导出成功对话框文案 + +#### 修改 +- **删除**: "文件已保存到:\n${path}" 路径显示 +- **新增**: "📁 可前往对应文件夹管理文档" 友好提示 +- **原因**: 用户反馈路径显示不正确/不友好 + +#### 效果 +``` +┌─────────────────────┐ +│ ✅ 导出成功 │ +│ │ +│ 📁 可前往对应文件夹 │ +│ 管理文档 │ +│ │ +│ [📤 分享给朋友] │ +│ [完成] │ +└─────────────────────┘ +``` + +#### 编译状态 +- ✅ 通过 flutter analyze (3个info级别提示) + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 对话框文案优化 | + + +## [0.99.59] - 2026-04-25 + +### ✂️ 删除: PDF【简介】字段 (解决乱码问题) + +#### 决策 +- **用户要求**: "pdf不要输出 简介 字段" +- **原因**: `displayIntro` 字段包含无法过滤的乱码字符 +- **方案**: **直接删除PDF中的【简介】字段显示代码** + +#### 修改内容 +1. **删除** PDF导出方法1中的【简介】显示代码(原第1001-1008行) +2. **删除** PDF导出方法2中的【简介】显示代码(原第1710-1717行) +3. **删除** 相关变量声明: + - `final cleanIntro = _safeDisplayIntro;` + - `final cleanIntro = _cleanPdfText(_displayIntro!);` +4. **清理** 不再使用的方法: + - `_safeDisplayIntro` getter + - `_containsGarbledChars()` 方法 + - `_isUnsafeChar()` 方法 + +#### 效果 +- ✅ PDF不再显示【简介】字段 +- ✅ 彻底消除作者下方的乱码问题 +- ✅ 代码更简洁(减少~60行无用代码) + +#### 编译状态 +- ✅ 通过 flutter analyze (3个info级别提示,无error/warning) + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 删除【简介】字段+清理相关代码 | + + +## [0.99.58] - 2026-04-25 + +### 🐛 修复v4: 彻底解决PDF乱码 + 路径显示优化 + +#### 问题1: PDF作者下方乱码 (经过3次修复仍存在) +- **用户反馈**: "为什么不把pdf里面乱码的字段删除,而是一直过滤" +- **根本原因**: `recipe.displayIntro` 字段包含无法显示的 Unicode 字符 +- **解决方案**: **直接隐藏包含乱码的字段**(而非尝试过滤) + +**新策略: `_safeDisplayIntro`** +```dart +String? get _safeDisplayIntro { + final raw = _displayIntro; + if (raw == null || raw.isEmpty) return null; + if (_containsGarbledChars(raw)) { + debugPrint('⚠️ displayIntro 包含乱码字符,已隐藏'); + return null; // 直接返回null,不显示此字段 + } + return raw; +} +``` + +**效果**: +- ✅ 如果简介是正常中文 → 显示 +- ✅ 如果简介包含乱码字符 → **完全不显示**(不是显示过滤后的内容) +- ✅ PDF中不再出现任何菱形/交叉/方块等乱码 + +**新增方法**: +- `_containsGarbledChars()`: 检测文本是否包含不安全字符 +- `_isUnsafeChar()`: 判断单个字符是否安全 + +#### 问题2: 导出对话框"文件已保存到"路径不正确 +- **修复**: 添加 `_formatPath()` 方法验证路径有效性 +- **优化**: 所有路径显示都使用格式化后的路径 + +#### 问题3: Word文档仍使用原始displayIntro(不过滤) +- **状态**: ⚠️ 待处理(Word文档第719行仍使用 `_displayIntro`) +- **建议**: 下次统一修改为 `_safeDisplayIntro` + +#### 编译状态 +- ✅ 通过 flutter analyze (4个info级别提示) + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 简化乱码处理策略 | +| `test_pdf_garbled_chars.dart` | 新增 | 诊断脚本(23个测试用例) | + + +## [0.99.57] - 2026-04-25 + +### 🐛 修复v3: PDF作者下方乱码终极解决方案 + +#### 问题: 【作者】小妈厨房APP 下方仍显示菱形交叉乱码 (XXXXXXXXX) +- **症状**: 经过前两次修复后,PDF中作者下方仍有菱形/交叉形状的乱码字符 +- **根因分析**: + 1. `recipe.displayIntro` 包含 Private Use Area (PUA) 字符 (U+E000-U+F8FF) + 2. 可能包含 Variation Selectors (U+FE00-U+FE0F) + 3. 某些特殊Unicode字符不在已知符号列表中 + 4. 整段文本可能就是乱码数据(非CJK/非ASCII比例过高) + +#### 修复方案: 三层防御机制 + +**第一层 - 扩展字符过滤 (`_shouldFilterChar`)**: +```dart +// 新增过滤范围: +- U+E000 ~ U+F8FF (Private Use Area - 私用区) +- U+FFF0 ~ U+FFFB (Specials - 特殊字符) +- U+FE00 ~ U+FE0F (Variation Selectors - 变体选择器) +- U+FDD0 ~ U+FDEF (Noncharacters - 非字符) +``` + +**第二层 - 特殊符号黑名单 (`_isSpecialSymbol`)**: +- 保持原有的 200+ 特殊符号过滤(方块、箭头、制表符等) + +**第三层 - 乱码文本检测算法 (`_isGarbledText`)**: +```dart +// 算法逻辑: +1. 统计文本中每个字符的类型 +2. 有效字符 = CJK + ASCII字母 + 数字 + 空格 + 标点符号 +3. 无效字符 = 其他所有字符 +4. 计算无效字符占比 +5. 若无效字符 > 40%,判定为乱码,返回空字符串 + +// 覆盖范围: +- CJK统一汉字: U+4E00~U+9FFF, U+3400~U+4DBF, U+20000~U+2CEAF +- CJK兼容汉字: U+F900~U+FAFF, U+2F800~U+2FA1F +- ASCII字母: a-z, A-Z +- 数字: 0-9 +- 标点: 中英文标点、括号、引号等 +``` + +#### 技术亮点 +- ✅ 不依赖正则表达式,逐字符精确判断 +- ✅ 支持所有 Unicode CJK 区块(包括扩展A/B/C/D/E) +- ✅ 自适应阈值:40% 无效字符即判定为乱码 +- ✅ 保留正常的中英文混合内容(如 "Hello世界") +- ✅ 完全过滤纯乱码内容(如 "✖✖✖✖✖✖✖✖✖") + +#### 编译状态 +- ✅ 通过 (3个 info 级别提示,无 error/warning) + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | PDF乱码终极修复 | + + +## [0.99.56] - 2026-04-25 + +### 🐛 修复v2: 彻底解决三个问题 + +#### 问题1: 文件尾缀依旧重复 (xxx.pdf.pdf) +- **原因**: 之前修复只检测一次双重扩展名,鸿蒙可能添加多次 +- **修复**: 使用 `while` 循环持续检测并移除重复扩展名 + ```dart + while (RegExp(r'\.(pdf|docx)\.(pdf|docx)$').hasMatch(finalPath)) { + finalPath = finalPath.replaceFirst(RegExp(r'\.(pdf|docx)$'), ''); } ``` -- **优化**: `previewImport` 增加元数据验证,拒绝非本应用导出的数据 -- **优化**: `ImportPreview` 增加 `error` 字段,导入失败时显示具体原因 -- **优化**: V1格式(无_meta)向下兼容,仍可识别 -- **验证**: `scripts/test_export_import.dart` 45项测试全部通过 +- **保存前也清理**: 文件名传入前先 `while` 循环移除所有已有扩展名 + +#### 问题2: Word不显示二维码 +- **原因**: `_addQrCodeSection()` 调用 `_addTextQrCode()` 但内容重复 +- **修复**: 合并为单一方法 `_addQrCodeSection()`,直接显示: + - 分隔线 + 标题 🔲 扫码查看菜谱详情 + - 提示文字 + URL链接 + +#### 问题3: PDF作者下方菱形方块乱码 (▯▯▯▯▯▯▯▯▯▯) +- **原因**: 正则表达式无法完全匹配所有特殊Unicode字符 +- **修复**: 重写 `_cleanPdfText()` 为逐字符过滤: + - 遍历每个字符的 codeUnit + - 过滤控制字符 (0x00-0x1F, 0x7F, 0x80-0x9F) + - 过滤 Unicode 替换字符 (U+FFFD) + - 过滤非字符代码点 (U+FDD0-U+FDEF, U+FFFE/U+FFFF) + - 过滤 200+ 特殊符号 (方块、箭头、制表符、绘图字符等) + +#### 编译状态 +- ✅ 通过 (仅2个 info 级别警告:项目依赖 + 字符串插值) + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 3个Bug彻底修复 | -## [0.99.40] - 2026-04-24 +## [0.99.55] - 2026-04-25 -### ✨ 新增 — 血液酒精含量(BAC)计算器 +### 🐛 修复: Word二维码 + 文件尾缀 + PDF乱码 -#### 新增: BAC计算器页面 -- **功能**: 输入体重/饮酒量/酒精度/已过时间,计算血液酒精含量 -- **算法**: Widmark公式,男性r=0.68,女性r=0.55,代谢率0.015%/小时 -- **选填**: 性别、空腹状态,提高准确度 -- **单位切换**: 已喝酒量支持 毫升↔斤↔公斤 三态切换(1斤=500ml, 1公斤=1000ml) -- **快捷选择**: 啤酒(5°)/葡萄酒(12°)/白酒(52°)/烈酒(40°)/酒心巧克力(5°)/自酿米酒(15°)/自酿果酒(12°)/自定义 -- **结果卡片**: BAC核心结果、醉酒等级条、法律标准(中国)、醒酒时间、酒精摄入详情、安全提示 -- **知识弹窗**: 📖按钮展示自我意识判断/醉酒症状识别/科学解酒方法/酒驾危害/免责声明 -- **数据保存**: 💾按钮保存输入数据到本地 -- **文件**: `lib/src/pages/tools/health/bac/` (bac_calculator.dart, bac_cards.dart, bac_page.dart) -- **验证**: `scripts/test_bac_calculator.dart` 40项测试全部通过 +#### 问题1: Word ASCII二维码显示为竖长方形 +- **症状**: 导出的DOCX中二维码变成竖向长条 +- **根因**: Word默认字体不是等宽字体,`█` 字符宽度不一致导致变形 +- **修复**: 移除ASCII art二维码,改为纯文本显示: + - 标题: `🔲 二维码扫码` + - 提示: 使用「小妈厨房」APP 扫码打开菜谱详情页 + - 链接: 直接显示URL -#### 新增: 参考文献页面增加酒精计算数据来源 -- Widmark公式与BAC (Wikipedia) -- 中国酒驾醉驾标准 (GB 19522-2010) -- WHO酒精代谢与健康 -- 🍺 酒精计算分类条目增加「计算器」跳转按钮 +#### 问题2: 文件尾缀重复 (xxx.pdf.pdf / xxx.docx.docx) +- **症状**: 保存的文件扩展名重复 +- **根因**: 鸿蒙平台 file_picker_ohos 自动添加扩展名时与手动添加的重叠 +- **修复**: + - 保存前先清理文件名中的已有扩展名: `fileName.replaceAll(RegExp(r'\.(pdf|docx)$'), '')` + - 保存后检测双重扩展名: `RegExp(r'\.(pdf|docx)\.\1$')` + - 若检测到则修正路径并重新写入文件 -### ✨ 优化 — 身体分析页面5项需求实现 + 溢出修复 +#### 问题3: PDF作者下方乱码 (菱形方块 ▯▯▯) +- **症状**: 【作者】小妈厨房APP 下方显示一行菱形方块 +- **根因**: `recipe.displayIntro` 包含特殊Unicode字符无法在PDF字体中显示 +- **修复**: 增强 `_cleanPdfText()` 方法: + - 新增过滤: 方块符号、箭头、制表符、绘图字符等100+特殊符号 + - 空内容检测: 清理后若只剩空白/特殊字符则返回空字符串 -#### 修复: Tab页面底部溢出 1084px -- **根因**: 3个Tab使用 `Column` 布局,内容超出 `SizedBox` 固定高度(900-1200px) -- **修复**: Tab内部改用 `SingleChildScrollView` 支持滚动 -- **新增**: 每个Tab底部增加 👈 左右滑动切换提示 +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 3个Bug修复 | -#### 修复: Tab滑到底后无法滑回顶部 -- **根因**: 外层 `ListView` 与内层 `SingleChildScrollView` 嵌套滚动冲突,向上滑动时外层抢占手势 -- **修复**: 分析结果展示时改用 `Column` + `Expanded` 布局,PageView独立占据剩余空间,彻底消除嵌套滚动 -- **优化**: 结果页面顶部改为紧凑信息栏(性别/年龄/身高/体重)+ 🔄重新分析按钮,节省空间 -#### 新增: 趣味统计 - 各星球体重 -- **功能**: 展示用户在9个天体(水星/金星/地球/火星/木星/土星/天王星/海王星/月球)上的体重 -- **数据**: 基于各行星相对地球的平均重力加速度计算 -- **UI**: 使用 Wrap 布局的 Chip 样式展示,地球高亮标记 +## [0.99.54] - 2026-04-25 -#### 新增: 有氧运动(燃脂)心率计算卡片 -- **功能**: 在核心指标Tab中BMI卡片后新增 ❤️‍🔥 燃脂最佳心率卡片 -- **展示**: 渐变背景大字显示最佳心率区间 xx-xx bpm,标注为最大心率的60%~70% +### 🔧 清理: 警告修复 + Word二维码优化 -#### 优化: 摄入需求/蛋白质根据BMI智能显示 -- **逻辑**: 偏瘦(BMI<18.5)隐藏减脂选项,肥胖(BMI≥28)隐藏增肌选项 -- **影响**: GoalCaloriesCard 和 ProteinCard 均已适配 +#### 警告清理 (recipe_export_button.dart) +- **原始**: 31个警告 +- **清理后**: 2个 info (项目级别,非代码错误) -#### 新增: Tab滑动边缘发光动画 -- **效果**: 在第1个Tab继续左滑时,左边缘显示主题色渐变发光;最后1个Tab右滑同理 -- **实现**: NotificationListener监听OverscrollNotification,Stack+Positioned叠加边缘发光层 +| 警告类型 | 数量 | 修复方式 | +|----------|------|----------| +| `curly_braces_in_flow_control_structures` | 24 | if/for 添加花括号 | +| `invalid_null_aware_operator` | 1 | 移除多余的 `?.` | +| `unnecessary_import` | 1 | 删除 `dart:typed_data` | +| `use_build_context_synchronously` | 1 | 添加 `mounted` 检查 | -#### 删除: BMI计算器页面 -- **原因**: 身体分析页面已完整覆盖BMI计算功能及更多指标 -- **影响文件**: 删除 bmi_calculator_page.dart,移除 app_routes.dart 中 bmiCalculator/toolsBmi 路由和 PageRegistry -- **联动**: 体重管理页面 BMI 跳转按钮改为跳转身体分析页面 +#### Word二维码优化 +- **问题**: ASCII 二维码占一整页 (25-29行 × 双字符宽度) +- **解决方案**: + - 纠错等级: `M` → `L` (减少模块数) + - 字符: `██` → `█` (单字符) + - 添加边框: `┌─┐│ │└─┘` -#### 优化: 重新分析按钮展开编辑框 -- **改动**: 点击"🔄 重新分析"时,同时展开输入区域并取消隐藏状态 +**效果**: 二维码从 ~60行 减少到 ~29行 -#### 需求1: 修复按钮状态不实时更新 -- **问题**: 输入身高/年龄/体重后,"开始分析"按钮仍为禁用状态,需额外操作才能激活 -- **根因**: `onChanged` 仅在 `_hasResult == true` 时调用 `setState()`,首次输入不触发重建 -- **修复**: `onChanged` 始终调用 `setState()`,按钮状态实时响应输入 +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 清理31个警告 + 二维码紧凑化 | +| `mini_card_page.dart` | 修改 | 清理5个unnecessary_underscores警告 | -#### 需求2: AppBar 增加隐私与算法说明 -- 在导航栏右侧增加 ℹ️ 图标按钮 -- 点击弹出 `CupertinoAlertDialog`,包含: - - 🔒 隐私声明:所有数据仅在本地计算,不会联网上传 - - 📐 算法来源:Harris-Benedict / Mifflin-St Jeor / US Navy / Karvonen 等 - - ⚠️ 免责声明:仅供学习参考,禁止医用、禁止商用 -#### 需求3: 参考文献页面增加身体分析数据来源 -- 新增 7 条 🧬 身体分析分类参考文献: - - Harris-Benedict BMR 公式 (Wikipedia) - - Mifflin-St Jeor BMR 公式 (PubMed) - - US Navy 体脂率估算法 (US Navy) - - Karvonen 心率区间公式 (Wikipedia) - - WHO BMI 分类与肥胖标准 (WHO) - - 中国居民膳食营养素参考摄入量 2023版 (中国营养学会) - - Mosteller 体表面积计算公式 (Wikipedia) +## [0.99.53] - 2026-04-25 -#### 需求4: 基础信息卡片增加保存/隐藏按钮 -- 💾 保存按钮:将填写信息写入 SharedPreferences,下次打开自动填充 -- 👁️ 隐藏/显示按钮:切换输入区域显示状态,数据保留仅隐藏表单 +### 🐛 修复 + ✨ 优化: Word二维码显示 + 自定义导出增强 -#### 需求5: 核心指标/科学指数/趣味统计支持左右滑动 -- 将 Tab 切换改为 `PageView` 实现,支持手势左右滑动 -- `GlassSegmentedControl` 与 `PageView` 双向联动 +#### 问题1: Word文档没有ASCII二维码 +- **症状**: 导出的DOCX只有URL链接,没有二维码图形 +- **根因**: `DocumentXml.generate()` 只处理 Paragraph 和 Table,**不处理 Image** + - `doc.addImage()` 静默添加了 DocxImage 对象,但生成XML时被忽略 +- **修复**: `_addQrCodeSection()` 直接调用 `_addTextQrCode()` 跳过图片路径 -#### 涉及文件 -- `body_analysis_page.dart` — 全部5项需求实现 -- `references_page.dart` — 新增7条身体分析参考文献 +#### 问题2: 自定义导出选项太少 +- **旧版**: 只有3个选项(食材/步骤/标签),只有Word格式 +- **新版**: 7个开关选项 + 双格式导出 + +| 开关 | 说明 | 默认 | +|------|------|------| +| 📷 封面图片 | 是否包含菜品封面图 | ✅ | +| 🥘 食材清单 | 是否包含食材表格 | ✅ | +| 👨‍🍳 制作步骤 | 是否包含步骤列表 | ✅ | +| 📊 营养成分 | 是否包含营养数据 | ✅ | +| 📈 数据统计 | 是否包含评分/浏览/点赞 | ✅ | +| 🏷️ 标签信息 | 是否包含标签 | ✅ | +| 🔲 二维码 | 是否包含扫码区域 | ✅ | + +**新增按钮**: +- `📝 导出Word` - 自定义Word文档 +- `📄 导出PDF` - 自定义PDF文件 + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 强制ASCII二维码 + 7开关 + 双格式 | + + +## [0.99.52] - 2026-04-25 + +### 🐛 修复: PDF无反应 + Word二维码显示 + +#### 问题1: 无图片菜品导出PDF无反应 +- **症状**: 点击导出PDF后无任何响应,不报错也不生成文件 +- **根因**: `_generateQrCodeImage()` 使用 `dart:ui` Canvas 在某些情况下静默失败 + - `picture.toImage()` 或 `image.toByteData()` 可能返回 null + - 异常被 catch 吞掉,没有向上传播 +- **修复**: + - 增加详细 debug 日志输出每个步骤 + - 检查 `moduleCount <= 0` 边界情况 + - 检查 `byteData == null` 返回值 + - 捕获 `stackTrace` 方便定位问题 + +#### 问题2: Word文档没有显示二维码 +- **症状**: 导出的DOCX只有文字链接,没有二维码图形 +- **根因**: docs_gee 库不支持图片插入 (`addImage` 方法不存在) +- **解决方案**: **ASCII文本二维码** (双倍宽度字符) + ``` + ██ ██ ████ ██ ██ + ████ ████████ ████ + ██ ██ ██ ██ ██ ██ + ... + ``` +- **实现**: 新增 `_addTextQrCode()` 方法 + - 使用 `qr` 包生成矩阵数据 + - 用 `██`(黑色) 和 ` `(空白) 绘制 + - 双倍宽度字符使二维码更清晰可扫 + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 二维码错误处理 + ASCII文本二维码 | +| `docx_image.dart` | 新增 | 图片模型(备用) | +| `docx_document.dart` | 修改 | 添加addImage方法(备用) | + + +## [0.99.51] - 2026-04-25 + +### 🐛 修复: PDF乱码残留 + Word导出崩溃 + +#### 问题1: PDF【作者】下方仍有菱形方块乱码 +- **症状**: `【作者】小妈厨房APP` 下方出现一行 `▯▯▯▯▯▯▯▯▯` 乱码 +- **根因**: `_displayIntro` (简介) 字段包含**零宽字符**或**特殊Unicode字符** + - 如: `\u200B`(零宽空格)、`\uFEFF`(BOM)、`\u2028`(行分隔符) + - NotoSansSC 字体无法渲染 → 显示为菱形 tofu +- **修复**: + - `_cleanPdfText()` 新增过滤:零宽字符 + 菱形方块字符 + - 对 `_displayIntro` 显式调用清理,空结果则不渲染 + +#### 问题2: Word导出报错 `NoSuchMethodError: '>'` +- **症状**: 导出Word时崩溃 `Class 'RecipeRating' has no instance method '>'` +- **根因**: `recipe.rating` 是 **RecipeRating 对象**,不是数字 + - 错误代码: `if (rating != null && rating > 0)` + - RecipeRating 没有 `>` 运算符重载 +- **修复**: 使用对象属性判断 + ```dart + // 错误 ❌ + if (rating > 0) ... + + // 正确 ✅ + if (rating.hasRating == true) ... + items.add('评分: ${rating.displayText}'); + ``` + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | _displayIntro清理 + rating对象修复 | + + +## [0.99.50] - 2026-04-25 + +### ✨ 新增: PDF/Word 导出二维码功能 + +#### 功能描述 +- **PDF底部**新增二维码区域,显示「小妈厨房APP」扫码提示 +- **Word文档**底部新增扫码区域(文字+链接) +- 二维码内容为菜品详情页URL + +#### 二维码URL格式 +``` +https://eat.wktyl.com/?id={recipeId} +``` +- `recipe.id` 格式为 `cp033793` → URL中去掉`cp`前缀 → `id=033793` + +#### PDF二维码区域样式 +``` +┌─────────────────────────────────┐ +│ ───────────────────────────── │ +│ 扫码查看菜谱详情 │ +│ ┌──────────────┐ │ +│ │ │ │ +│ │ QR CODE │ 120x120 │ +│ │ │ │ +│ └──────────────┘ │ +│ 使用「小妈厨房」APP 扫码打开 │ +│ https://eat.wktyl.com/?id=xxx │ +└─────────────────────────────────┘ +``` + +#### Word文档扫码区域 +- 标题: 「扫码查看菜谱详情」 +- 提示文字: APP扫码 / 浏览器查看 +- 链接: 完整URL + +#### 技术实现 +| 方法 | 功能 | +|------|------| +| `_qrUrl` | 生成二维码URL(自动去掉cp前缀) | +| `_generateQrCodeImage()` | 使用qr包生成PNG二维码图片 | +| `_buildPdfQrCode()` | PDF二维码widget(带边框容器) | +| `_addQrCodeSection()` | Word文档扫码区域 | + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 新增二维码生成 + PDF/Word集成 | + + +## [0.99.49] - 2026-04-25 + +### 🐛 修复: PDF作者乱码 + Word文档完善字段 + +#### 问题1: PDF导出作者下方显示乱码 +- **症状**: 【菜品信息】区域出现无法复制的乱码字符 +- **根因**: `meta` 字段中包含**控制字符或超出 BMP 范围的 Unicode 字符** +- **修复**: + - 新增 `_cleanPdfText()` 方法过滤控制字符 (`\x00-\x1F\x7F`) 和非 BMP 字符 + - 对 `meta.process/taste/difficulty/time/eatingTime` 全部应用清理 + +#### 问题2: 作者名 "520kiss" 需要替换 +- **需求**: 若作者为 `520kiss` 等无效名称,统一显示为 **小妈厨房APP** +- **实现**: + - 新增 `_displayAuthor` getter + `_invalidAuthors` 黑名单列表 + - 自动过滤:空名称、黑名单、纯数字/长度<2 的名称 + +#### 问题3: Word文档内容不完整 +- **症状**: DOCX 仅含基础7个字段(标题/分类/作者/简介/食材/步骤/标签) +- **修复**: 同步新增5个Word模块(与PDF一致) + +| 新增方法 | 功能 | +|----------|------| +| `_addMetaInfo()` | 菜品信息(做法/口味/难度/时间/用餐) | +| `_addNutritionInfo()` | 营养成分表格 | +| `_addStatisticsInfo()` | 数据统计(评分/浏览/点赞/评论) | +| `_addAllergenWarning()` | 过敏原警告 | +| `_addFooterInfo()` | 页脚时间戳+署名 | + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | 乱码清理 + 作者过滤 + 5个Word新模块 | + + +## [0.99.48] - 2026-04-25 + +### 🐛 修复: PDF导出3项问题 (emoji乱码 + 内容不全 + 分享失败) + +#### 问题1: PDF中emoji显示为"口字内X"乱码 +- **症状**: 分类、作者等字段左侧显示无法复制的乱码字符 +- **根因**: NotoSansSC 字体**不包含 emoji 字形**,PDF阅读器用 tofu(缺失字符)替代 +- **修复**: 将所有 emoji 前缀替换为 `【】` 格式纯文本标签 + - `📂 分类:` → `【分类】` + - `✍️ 作者:` → `【作者】` + - `🥘 食材` → `【食材清单】` + - `👨‍🍳 制作步骤` → `【制作步骤】` + - `🏷️ 标签` → `【标签】` + +#### 问题2: PDF内容太少,缺少菜品详情页大量信息 +- **症状**: PDF仅包含标题/分类/作者/简介/食材/步骤/标签 +- **修复**: 新增5个内容模块,导出详情页全部信息 + +| 新增模块 | 数据来源 | 显示内容 | +|----------|----------|----------| +| 【菜品信息】 | `recipe.meta` | 做法、口味、难度、时间、用餐时间 | +| 【营养成分】 | `recipe.nutrition` | 热量(kcal)、蛋白质、脂肪、碳水、纤维(g) | +| 【数据统计】 | `recipe.rating` + `statistics` | 评分(星级)、浏览量、点赞数、评论数 | +| [注意]过敏原 | `recipe.allergens` | 橙色警告框显示过敏原列表 | +| 页脚信息 | `createdAt/updatedAt` | 创建时间、更新时间、来源署名 | + +#### 问题3: 导出后点击"分享给朋友"报错"文件不存在" +- **症状**: 文件保存成功,但分享时提示文件不存在 +- **根因**: 鸿蒙平台 `FilePicker.saveFile(bytes:)` 可能未正确写入文件 +- **修复**: `_pickAndSaveFile()` 增加写入验证:若返回路径但文件不存在/为空,自动手动 `writeAsBytes` 补写 + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `recipe_export_button.dart` | 修改 | emoji→纯文本 + 5个新PDF模块 + 分享写入验证 | + + +## [0.99.47] - 2026-04-25 + +### 🐛 修复: PDF导出中文乱码 + 封面图片缺失 + +#### 问题1: 中文显示为空白/乱码 +- **症状**: PDF导出后中文全部不显示,只有线条和序号,文件大小仅 6KB +- **根因链**: + 1. 原字体为 **OpenType/CFF (.otf)** 格式 → pdf 包不识别为 Unicode 字体 + 2. 使用 fontTools 转换 .otf → .ttf 后 **缺少 `loca` (Location) 表** + 3. `loca` 表存储字形偏移索引 → 缺失导致 PDF 无法定位中文字形 → 空白 +- **诊断过程**: + - 创建 `scripts/test_pdf_cjk_debug.dart` 检测字体格式 + - 创建 `scripts/check_ttf_integrity.py` 验证字体表完整性 + - 发现转换后的 TTF 有 glyf 但**无 loca 表** +- **最终方案**: 从 GitHub [cjk-fonts-ttf](https://github.com/life888888/cjk-fonts-ttf) 下载**官方 TrueType 版本** + - NotoSansCJKsc-Regular.ttf (16.9MB, 65533 字形, glyf+loca 完整) + - NotoSansCJKsc-Bold.ttf (16.8MB, 同上) +- **验证结果**: PDF 从 7KB → **22.5KB** (中文字形成功嵌入),中文正常显示 + +#### 问题2: 封面图片不显示 (缓存有图但PDF无图) +- **症状**: 菜品详情页已显示封面图(缓存命中),但 PDF 导出无图片 +- **根因**: `_getCachedCoverImage()` 仅用 `recipe.cover.toString()` 查缓存,与 **RecipeImage 实际使用的 URL 不匹配** + - RecipeImage 使用 URL 链: `_ensureHttps(cover)` → `pic/{picId}a.jpg` → `pic/{picId}b.jpg` → fallback + - 若 cover 为相对路径 `/pic/123a.jpg` 或 HTTP URL,缓存 key 不同 → **查不到** +- **修复**: 重写为**多候选 URL 链 + 缓存/下载双通道** + - 构建 3~5 个候选 URL(与 RecipeImage._buildUrlChain 一致) + - 每个 URL 先查缓存,未命中则网络下载 + - 任一成功即返回,全部失败才放弃 + +#### 文件变更 +| 文件 | 操作 | 说明 | +|------|------|------| +| `assets/fonts/NotoSansSC-Regular.ttf` | 替换 | 官方TTF版本(16.9MB),替换损坏的fontTools转换版(3.6MB) | +| `assets/fonts/NotoSansSC-Bold.ttf` | 替换 | 同上 | +| `recipe_export_button.dart` | 修改 | 图片加载重写: URL链(与RecipeImage一致) + 缓存/下载双通道 + 详细日志 | +| `scripts/test_pdf_app_flow.dart` | 新增 | App端完整PDF流程模拟脚本 | +| `scripts/check_ttf_integrity.py` | 新增 | 字体完整性验证脚本(glyf/loca/CJK映射) | + +#### 保留的诊断工具 +- `scripts/test_pdf_cjk_debug.dart` — 字体格式检测 + PDF生成测试 +- `scripts/otf_to_ttf.py`, `otf_to_ttf_v2.py` — OTF→TTF转换脚本(备用) + + +## [0.99.46] - 2026-04-25 + +### 🐛 修复: PDF导出中文崩溃 "Invalid argument (string): Contains invalid characters" + +- **错误**: 导出含中文菜谱PDF时崩溃,`Invalid argument (string): Contains invalid characters` +- **根因链**: + 1. pdf 包的 `TtfParser.unicode` 仅当字体文件头为 `0x00010000` (TrueType) 时返回 `true` + 2. 当前使用的 NotoSansSC 字体是 **OpenType/CFF (.otf)** 格式,文件头为 `0x4F54544F` ('OTTO') + 3. `unicode=false` → pdf 包使用 **latin1 编码** → 中文字符超出范围 → 崩溃 +- **诊断工具**: 新增 `scripts/test_pdf_cjk_debug.dart` 脚本,检测字体格式和PDF生成能力 +- **解决方案**: 使用 Python fontTools 将 .otf (CFF轮廓) 转换为 .ttf (TrueType轮廓) + - 转换脚本: `scripts/otf_to_ttf.py` (CFF→glyf字形转换 + 文件头修补) + - 13086个CJK字形全部成功转换 +- **文件变更**: + - `assets/fonts/NotoSansSC-Regular.ttf` — 新增(从.otf转换) + - `assets/fonts/NotoSansSC-Bold.ttf` — 新增(从.otf转换) + - `recipe_export_button.dart` — 字体路径 .otf → .ttf + - `scripts/otf_to_ttf.py` — 新增(OTF→TTF转换脚本) + - `scripts/test_pdf_cjk_debug.dart` — 新增(PDF CJK诊断脚本) + + +## [0.99.45] - 2026-04-25 + +### 📄 PDF导出重构: 替换 docs_gee.PdfGenerator → pdf ^3.12.0 (纯Dart包) + +- **问题**: docs_gee 自带的 PdfGenerator 导出PDF内容空白,中文字体渲染异常 +- **方案**: 引入成熟的 `pdf` 包(Dart生态最流行的PDF生成库,GitHub 2k+ stars) +- **适配**: 纯Dart包,仅改版本号 `3.12.0` → `3.12.0-ohos.1`,无需ohos目录 +- **依赖冲突解决**: pdf→barcode→qr ^3.0.0 与本地qr包冲突,通过 dependency_overrides 解决 +- **代码变更**: + - 新增 import: `package:pdf/pdf.dart`, `package:pdf/widgets.dart as pw` + - 重写 `_exportAsPdf()`: 使用 pw.Document + pw.MultiPage API + - 新增辅助方法: `_buildPdfTitle()`, `_pdfText()`, `_buildPdfIngredients()`, `_buildPdfSteps()`, `_buildPdfTags()` + - 支持封面图片、中文TTF字体、自动分页、页脚、食材表格、步骤编号圆圈、标签胶囊样式 +- **文件变更**: + - `packages/pdf/` — 新增(从 GitHub 克隆 dart_pdf) + - `lib/src/widgets/recipe_detail/interaction/recipe_export_button.dart` — 重写PDF导出逻辑 + - `pubspec.yaml` — 添加 pdf 本地依赖 + qr dependency_overrides + + +## [0.99.44] - 2026-04-25 + +### 🔴 紧急修复: 鸿蒙端启动闪退 (Invalid relative path 9001005) — 全面排查 + +- **错误**: `Error message: Invalid relative path, Error code: 9001005` +- **堆栈**: `copyResource → startInitialization → checkLoader → setupFlutterEngine → onCreate` +- **根因**: 9个纯Dart包被错误添加了ohos目录,引用不存在的`libs/flutter.har`,Flutter引擎启动时逐一扫描全部模块资源 +- **排查过程**: + 1. 删除 docs_gee/ohos(第一轮)→ 仍闪退 + 2. 全量扫描所有 packages 的 ohos-package.json5 → 发现共12个包有ohos目录 + 3. 逐个检查 Plugin 代码 → 区分空壳 vs 真插件 + +#### 删除的纯Dart空壳包 (9个) +| 包名 | Plugin代码 | 原因 | +|------|-----------|------| +| docs_gee | `DocsGeePlugin {}` | 纯Dart文档生成库 | +| mailer | `MailerPlugin {}` | 纯Dart邮件库 | +| qr | `QrPlugin {}` | 纯Dart二维码生成 | +| flutter_card_swiper | `FlutterCardSwiperPlugin {}` | 纯Dart卡片滑动组件 | +| flutter_markdown_plus | `FlutterMarkdownPlusPlugin {}` | 纯Dart Markdown渲染 | +| cached_network_image | `CachedNetworkImagePlugin {}` | 纯Dart网络图片缓存 | +| flutter_staggered_grid_view | `FlutterStaggeredGridViewPlugin {}` | 纯Dart瀑布流布局 | +| badges | `BadgesPlugin {}` | 纯Dart徽章组件 | +| fl_chart | `FlChartPlugin {}` | 纯Dart图表库 | + +#### 修复的真插件 (3个) +| 包名 | 问题 | 修复 | +|------|------|------| +| mobile_scanner | 缺 libs/flutter.har (Camera/Barcode真实原生代码) | 从 fluttertoast_ohos 复制 har 文件到 libs/ | +| file_picker | 缺 har/flutter.har (文件选择器原生代码) | 创建 har/ 目录并复制 flutter.har | +| fluttertoast_ohos | 原始正确,无需修改 | ✅ 已有 libs/flutter.har | + +#### 清理的example目录 (3个) +- get/example_nav2/ohos, get/example/ohos, file_picker/example/ohos(不参与主项目编译) + +#### 最终保留的ohos目录 (仅3个真插件) +``` +packages/ +├── mobile_scanner/ohos/ ← libs/flutter.har ✅ +├── file_picker/ohos/ ← har/flutter.har ✅ +└── fluttertoast_ohos/ohos/ ← libs/flutter.har ✅ +``` + +### 📝 文档更新: 修正纯Dart包适配指导 + +- **文件**: `packages/本地已适配鸿蒙的库.md` +- **修正内容**: + 1. 删除「创建空壳 ohos 目录」的完整模板(Index.ets / Plugin.ets / module.json5 等) + 2. 纯Dart包适配步骤简化为:拉取源码 → 改版本号 → 引用项目 → 完成 + 3. 新增「⛔ 血泪教训」章节,记录 9001005 闪退根因和排查过程 + 4. 总览表新增「类型」「ohos目录」列,明确区分 🟢纯Dart vs 🔴原生插件 + 5. 兼容性总览表新增类型列,补充 file_picker / fluttertoast_ohos 条目 +- **核心原则**: 纯Dart包 = 不需要任何ohos文件!Flutter AOT编译直接运行 --- -## [0.99.39] - 2026-04-24 +## [0.99.43] - 2026-04-25 -### 🐛 修复 — 排敏助手页面一直转圈 + 闪退 + Obx异常 +### 🐛 修复 — 3项关键Bug(鸿蒙端) -#### 问题1: 页面一直转圈(无法加载) -- **根因**: `GetBuilder` 不监听 `RxBool isLoading` 变化,`_loadData()` 完成后 `isLoading.value = false` 无法触发重建 -- **修复**: `AntiController._loadData()` 完成后调用 `update()`;`anti_main_page` 中 `isLoading` 判断改用 `Obx` 局部包裹 +#### 🔴 修复: 鸿蒙端启动闪退 (Invalid relative path 9001005) +- **错误**: `Error message: Invalid relative path, Error code: 9001005` +- **堆栈**: `copyResource → startInitialization → checkLoader → setupFlutterEngine → onCreate` +- **根因**: `docs_gee` 是纯Dart库,但被错误配置为鸿蒙原生har模块 + - `ohos/oh-package.json5` 引用不存在的路径 `"@ohos/flutter_ohos": "file:libs/flutter.har"` + - Flutter引擎启动时尝试复制该模块资源 → 路径不存在 → 闪退 +- **修复**: 删除 `packages/docs_gee/docx_generator/ohos/` 整个目录 + - 纯Dart库不需要原生适配,删除后不再触发资源复制 +- **教训**: 纯Dart包**禁止**添加 ohos 目录,会导致启动阶段崩溃 +- **文件**: `packages/docs_gee/docx_generator/ohos/` (已删除) -#### 问题2: Obx "improper use" 异常 -- **根因**: 将整个页面用 `Obx` 包裹,但部分页面未直接访问 `.obs` 变量,GetX 无法追踪 -- **修复**: 所有页面改回 `GetBuilder` + controller 中所有数据修改方法添加 `update()` 调用 +#### 🟠 修复: 鸿蒙端文件选择器不弹出 +- **问题**: 导出PDF/Word时点击保存 → 不弹选择器 → 直接报"已取消保存" +- **根因**: `FilePickerPlugin.ets` 第101行读取了 bytes 参数但未传给 delegate + - `delegate.saveFile()` 检查 `this.bytesList` 为空 → 报错返回 null +- **修复**: + - `FilePickerPlugin.ets`: 调用 saveFile 前调用 `delegate?.setSaveBytes(fileName, [bytes])` + - `FilePlckerDelegate.ets`: 新增 `setSaveBytes()` 方法设置 savaFilePath 和 bytesList +- **文件**: `packages/file_picker/ohos/src/main/ets/components/plugin/FilePickerPlugin.ets` +- **文件**: `packages/file_picker/ohos/src/main/ets/components/plugin/FilePlckerDelegate.ets` -#### 问题3: 点击"开始记录"闪退 -- **根因**: 只有 `antiMain` 路由有 `binding` 注册 `AntiController`,其他6个子路由缺少 binding,导航时 `Get.find()` 找不到 controller -- **修复**: 所有 anti 子路由添加 `binding: BindingsBuilder(() => Get.lazyPut(() => AntiController(), fenix: true))` - -#### 问题4: Opacity 断言错误 -- **根因**: `Curves.easeOutBack` 回弹曲线值超过1.0,`Opacity` 要求值在 [0.0, 1.0] 范围 -- **修复**: `opacity: value.clamp(0.0, 1.0)` - -#### 涉及文件 -- `anti_controller.dart` — 所有修改数据方法添加 `update()` 调用 -- `anti_main_page.dart` — `GetBuilder` + 局部 `Obx` 包裹 `isLoading` -- `anti_detail/add/heatmap/rating/tags/list_page.dart` — 改回 `GetBuilder` -- `app_routes.dart` — 所有 anti 路由添加 binding + fenix: true -- `anti_empty_state.dart` — opacity clamp 修复 +#### 🔴 修复: PDF全空白(10MB) +- **问题**: 导出PDF文件大小10MB但内容完全空白 +- **根因**: CIDFont + Identity-H 编码的字体不能用 `(text)` 格式输出 Tj 操作符 + - WinAnsiEncoding 的括号字符串语法与 Identity-H 十六进制编码冲突 + - PDF阅读器无法解析 → 显示空白页 +- **修复**: + - 新增 `_toHexEncodedText(text)`: 将字符串转为 UTF-16BE 十六进制 + - 新增 `_formatTextForTj(text)`: 有嵌入字体用 ``,无嵌入用 `(text)` + - 修改 `_renderParagraph()` 和表格渲染中的 Tj 输出 +- **验证**: `scripts/test_pdf_export_full.dart` 全流程验证脚本(7项测试) +- **文件**: `packages/docs_gee/docx_generator/lib/src/pdf_generator.dart` --- - -## [0.99.38] - 2026-04-24 - -### 🧹 清理 — 排敏模块(anti) 135项分析警告清零 - -#### 修复Warnings (5项) -- `_isSubmitting` 未使用 → 添加到保存按钮防重复提交逻辑 -- `dead_code` / `dead_null_aware_expression` → `as String` 改为 `as String?` -- `_hoveredIndex` 未使用 → 移除 -- `isHalfFilled` 未使用 → 移除 - -#### 修复Deprecated (119项) -- `withOpacity()` 全部替换为 `withValues(alpha:)` (14个文件) - -#### 修复Info (11项) -- `use_build_context_synchronously` → 添加 `mounted` 检查 + `// ignore` 注释 -- `prefer_final_fields` → `_selectedTags` 改为 `final` -- `unnecessary_import` → 移除冗余 `cupertino.dart` 导入 -- `strict_top_level_inference` → `var` 改为具体类型 `AllergenElimination` - ---- - -## [0.99.37] - 2026-04-24 - -### ✨ 新增 + 🐛 修复 — 身体分析新增9项指标 + Conicity Index单位修复 - -#### 新增指标 -- 🔬 **科学指数Tab** +4项: - - 🏋️ FFMI 无脂肪质量指数 + 标准化FFMI + 等级判定 - - 📐 Ponderal 体重指数(Rohrer指数,BMI改进版) - - ⚡ 基础代谢效率 (BMR/kg) - - 🔔 锥度指数 (Conicity Index,需腰围) -- 🎮 **趣味统计Tab** +5项: - - 🫁 一生呼吸总次数 - - 👁️ 一生眨眼总次数 - - 🧴 一生皮肤细胞脱落量 - - 🍽️ 一生消耗食物总重量 - - 🚶 一生行走距离估算 - -#### 🐛 Bug修复 -- 修复 Conicity Index 计算公式中 waist 单位错误(cm→m),原结果偏大约1000倍 - -#### 验证 -- 新增 `scripts/test_body_analysis.dart` 算法验证脚本(59项测试全部通过) -- 运行: `dart run scripts/test_body_analysis.dart` diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 2065b60..c3a04e0 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,16 +20,19 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "cute.major.kitchen" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName } + packaging { + jniLibs { + excludes += "lib/armeabi-v7a/**" + } + } + buildTypes { release { // 启用代码压缩和混淆(修复 release 包缓存失效问题) diff --git a/assets/fonts/NotoSansSC-Bold.ttf b/assets/fonts/NotoSansSC-Bold.ttf new file mode 100644 index 0000000..5997453 Binary files /dev/null and b/assets/fonts/NotoSansSC-Bold.ttf differ diff --git a/assets/fonts/NotoSansSC-Regular.ttf b/assets/fonts/NotoSansSC-Regular.ttf new file mode 100644 index 0000000..877b892 Binary files /dev/null and b/assets/fonts/NotoSansSC-Regular.ttf differ diff --git a/dist/小妈厨房_Setup_1.4.0.exe b/dist/小妈厨房_Setup_1.4.0.exe new file mode 100644 index 0000000..32891b9 Binary files /dev/null and b/dist/小妈厨房_Setup_1.4.0.exe differ diff --git a/lib/src/pages/discover/mini_card/mini_card_page.dart b/lib/src/pages/discover/mini_card/mini_card_page.dart index 32d141d..d904c56 100644 --- a/lib/src/pages/discover/mini_card/mini_card_page.dart +++ b/lib/src/pages/discover/mini_card/mini_card_page.dart @@ -4,6 +4,7 @@ * 作用: 交友软件风格左右滑动浏览菜品,支持分类筛选/搜索/收藏/全屏查看/分享 * 创建: 2026-04-14 * 更新: 2026-04-14 引入flutter_card_swiper重构滑动系统,移除自定义手势/动画控制器 + * 更新: 2026-04-25 清理5个unnecessary_underscores警告(双下划线→单下划线) */ import 'dart:async'; @@ -845,7 +846,7 @@ class _MiniCardPageState extends State { horizontal: DesignTokens.space4, ), itemCount: _searchResults.length, - separatorBuilder: (_, __) => const SizedBox(height: 4), + separatorBuilder: (_, _) => const SizedBox(height: 4), itemBuilder: (context, index) { final recipe = _searchResults[index]; final catInfo = _meta?.categories[recipe.category]; @@ -877,14 +878,14 @@ class _MiniCardPageState extends State { memCacheHeight: 96, maxWidthDiskCache: 200, maxHeightDiskCache: 200, - errorWidget: (_, __, _) => Container( + errorWidget: (_, _, _) => Container( color: DesignTokens.background, child: const Icon( CupertinoIcons.photo, size: 20, ), ), - progressIndicatorBuilder: (_, __, _) => + progressIndicatorBuilder: (_, _, _) => Container( color: DesignTokens.background, child: const CupertinoActivityIndicator( @@ -947,7 +948,7 @@ class _MiniCardPageState extends State { maxHeightDiskCache: 800, fadeInDuration: const Duration(milliseconds: 300), fadeInCurve: Curves.easeOut, - errorWidget: (_, __, _) => Container( + errorWidget: (_, _, _) => Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, @@ -966,7 +967,7 @@ class _MiniCardPageState extends State { ), ), ), - progressIndicatorBuilder: (_, __, _) => Container( + progressIndicatorBuilder: (_, _, _) => Container( color: DesignTokens.background, child: const Center(child: CupertinoActivityIndicator()), ), diff --git a/lib/src/pages/profile/data/stats_dashboard_page.dart b/lib/src/pages/profile/data/stats_dashboard_page.dart index 9f54c55..0334364 100644 --- a/lib/src/pages/profile/data/stats_dashboard_page.dart +++ b/lib/src/pages/profile/data/stats_dashboard_page.dart @@ -281,7 +281,7 @@ class _StatsDashboardPageState extends State { _formatNumber(nutritionRecords), DesignTokens.teal, isDark, - subtitle: '$nutritionTypes 项日志', + subtitle: '$nutritionTypes 项日志(归档)', ), ), const SizedBox(width: DesignTokens.space2), diff --git a/lib/src/pages/profile/info/about_page.dart b/lib/src/pages/profile/info/about_page.dart index 17b01f6..799506f 100644 --- a/lib/src/pages/profile/info/about_page.dart +++ b/lib/src/pages/profile/info/about_page.dart @@ -20,6 +20,7 @@ import 'package:cute_kitchen/src/pages/profile/info/learn_us_page.dart'; import 'package:cute_kitchen/src/pages/profile/info/privacy_policy_page.dart'; import 'package:cute_kitchen/src/pages/profile/tools/permission_page.dart'; import 'package:cute_kitchen/src/pages/profile/info/references_page.dart'; +import 'package:cute_kitchen/src/utils/platform_utils.dart'; import 'package:cute_kitchen/src/services/core/app_info_service.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -251,13 +252,7 @@ class AboutPage extends StatelessWidget { title: '评价应用', subtitle: '在应用商店给我们评分', isDark: isDark, - onTap: () { - Get.snackbar( - '提示', - '未找到应用商店链接', - snackPosition: SnackPosition.BOTTOM, - ); - }, + onTap: () => _onRateApp(context, isDark), ), _buildDivider(isDark), _buildActionTile( @@ -448,6 +443,67 @@ class AboutPage extends StatelessWidget { ); } + void _onRateApp(BuildContext context, bool isDark) { + if (PlatformUtils().isHarmonyOS) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('⭐', style: TextStyle(fontSize: 24)), + SizedBox(width: 8), + Text('给个五星好评吧'), + ], + ), + content: const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('如果您喜欢小妈厨房,请给我们一个好评!'), + Text( + '您的支持是我们前进的动力 💪', + style: TextStyle( + fontSize: 13, + color: CupertinoColors.secondaryLabel, + ), + ), + SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [Text('⭐⭐⭐⭐⭐', style: TextStyle(fontSize: 28))], + ), + ], + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + isDestructiveAction: true, + child: const Text('下次再说'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + Navigator.pop(ctx); + launchUrl( + Uri.parse( + 'https://appgallery.huawei.com/app/detail?id=cute.major.kitchen', + ), + mode: LaunchMode.externalApplication, + ); + }, + child: const Text('去评价 ⭐'), + ), + ], + ), + ); + } else { + Get.snackbar('提示', '未找到应用商店链接', snackPosition: SnackPosition.BOTTOM); + } + } + void _showEmailSheet(BuildContext context, bool isDark) { final emails = [ _ContactEmail( @@ -471,6 +527,11 @@ class AboutPage extends StatelessWidget { icon: '📬', ), _ContactEmail(address: '5147662@qq.com', label: '任意邮箱均可联系', icon: '📬'), + _ContactEmail( + address: 'lzy20010304@gmail.com', + label: '任意邮箱均可联系', + icon: '📧', + ), ]; showCupertinoModalPopup( diff --git a/lib/src/pages/profile/info/app_info_page.dart b/lib/src/pages/profile/info/app_info_page.dart index b1fe916..2cbbd0e 100644 --- a/lib/src/pages/profile/info/app_info_page.dart +++ b/lib/src/pages/profile/info/app_info_page.dart @@ -391,7 +391,8 @@ class _AppInfoPageState extends State { String buildSdk = 'Unknown'; try { final platform = PlatformUtils(); - if (platform.isHarmonyOS || defaultTargetPlatform == TargetPlatform.ohos) { + if (platform.isHarmonyOS || + defaultTargetPlatform == TargetPlatform.ohos) { buildSdk = 'Deveco API 23'; } else if (platform.isAndroid) { buildSdk = 'Android Target 36'; @@ -629,13 +630,7 @@ class _AppInfoPageState extends State { _buildDivider(isDark), _buildInfoItem('Web 服务器', 'Nginx', CupertinoIcons.globe, isDark), _buildDivider(isDark), - _buildCopyableItem( - 'App 在线版(Beta测试)', - 'https://www.wktyl.com/app', - CupertinoIcons.link, - isDark, - primaryColor, - ), + _buildWebAppItem(isDark, primaryColor), ], ), ); @@ -795,16 +790,21 @@ class _AppInfoPageState extends State { children: [ _buildUpdateItem( '版本 ${AppConfig.appVersion}', - '2026-04-23', - ['升级鸿蒙api 23', '优化关于页面布局与交互体验'], + '2026-04-25', + [ + '修复鸿蒙端数据导入问题', + + '现在,你从APP导出的备份数据,在小妈厨房 android,Harmony,Web,Windows 可直接导入了', + '现已支持将菜品导出为PDF,Word (实验)', + ], isDark, primaryColor, ), const SizedBox(height: DesignTokens.space3), _buildUpdateItem( - '版本 0.99.6', - '2026-04-18', - ['工具中心', '工具下拉页面使用new UI ,旧版ui停止维护'], + '版本 1.0.1', + '2026-04-20', + ['优化工具中心下拉动画', '工具中心暂时归档,不再增加功能'], isDark, primaryColor, ), @@ -1232,6 +1232,107 @@ class _AppInfoPageState extends State { ); } + Widget _buildWebAppItem(bool isDark, Color primaryColor) { + return GestureDetector( + onTap: () => _showWebAppDialog(isDark), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Icon( + CupertinoIcons.link, + size: 20, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'App 在线版(Beta测试)', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + 'https://www.wktyl.com/app', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.arrow_up_right_circle, + size: 16, + color: primaryColor.withValues(alpha: 0.6), + ), + ], + ), + ), + ); + } + + void _showWebAppDialog(bool isDark) { + showCupertinoDialog( + context: Get.context!, + builder: (ctx) => CupertinoAlertDialog( + title: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('🌐', style: TextStyle(fontSize: 22)), + SizedBox(width: 8), + Text('打开在线版'), + ], + ), + content: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 8), + Text('首次打开需要加载资源,预计等待'), + Text( + '30~60 秒', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 16), + ), + Text('请耐心等待,后续访问会明显加快 🚀'), + ], + ), + actions: [ + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + Navigator.pop(ctx); + launchUrl( + Uri.parse('https://www.wktyl.com/app'), + mode: LaunchMode.externalApplication, + ); + }, + child: const Text('确认打开'), + ), + ], + ), + ); + } + Widget _buildCopyableItem( String title, String value, diff --git a/lib/src/pages/profile/info/learn_us_page.dart b/lib/src/pages/profile/info/learn_us_page.dart index 1f0a8b1..85e4891 100644 --- a/lib/src/pages/profile/info/learn_us_page.dart +++ b/lib/src/pages/profile/info/learn_us_page.dart @@ -394,7 +394,7 @@ class LearnUsPage extends StatelessWidget { children: [ _buildTeamMember('💻', '程序设计', '纯情小妈', '喜欢发呆', isDark), _buildDivider(isDark), - _buildTeamMember('🎨', 'UI/UX/Testing', 'Freetime', '关于你的风景。', isDark), + _buildTeamMember('🎨', 'UI/UX/Tools', 'Freetime', '关于你的风景。', isDark), _buildDivider(isDark), _buildTeamMember('⚙️', '后端开发', '伯乐不相马', '还是做不到吗?', isDark), _buildDivider(isDark), @@ -830,6 +830,11 @@ class LearnUsPage extends StatelessWidget { icon: '📬', ), _ContactEmail(address: '5147662@qq.com', label: '任意邮箱均可联系', icon: '📬'), + _ContactEmail( + address: 'lzy20010304@gmail.com', + label: '任意邮箱均可联系', + icon: '📧', + ), ]; showCupertinoModalPopup( diff --git a/lib/src/pages/profile/known_issues_sheet.dart b/lib/src/pages/profile/known_issues_sheet.dart new file mode 100644 index 0000000..7e9dd37 --- /dev/null +++ b/lib/src/pages/profile/known_issues_sheet.dart @@ -0,0 +1,290 @@ +/* + * 文件: known_issues_sheet.dart + * 名称: 已知问题底部弹出面板 + * 作用: 展示应用已知问题与说明的独立组件,从 profile_settings.dart 分流提取 + * 创建: 2026-04-25 + * 更新: 2026-04-25 从 profile_settings.dart 提取,降低主文件体积 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:cute_kitchen/src/config/design_tokens.dart'; + +enum IssueLevel { important, warning, info } + +class KnownIssueItem { + final String icon; + final String title; + final IssueLevel level; + final String description; + + const KnownIssueItem({ + required this.icon, + required this.title, + required this.level, + required this.description, + }); +} + +class KnownIssuesSheet { + static List get issues => [ + KnownIssueItem( + icon: '🌐', + title: 'VPN/代理环境部分功能异常', + level: IssueLevel.warning, + description: '使用代理或 VPN 时,部分接口请求可能超时或失败,建议在直连环境或者国内网络环境下使用以获得最佳体验。', + ), + KnownIssueItem( + icon: '🧊', + title: 'Win10/11端 页面返回问题', + level: IssueLevel.warning, + description: '部分页面未设置返回逻辑,需使用快捷键 同时按下 ALT+方向左键/Escape(Esc键) 返回上一页。', + ), + KnownIssueItem( + icon: '📂', + title: '分类列表默认显示前20项', + level: IssueLevel.info, + description: + '为节省服务器资源,大部分分类(菜系、标签等)默认只展示前20条。低于20条的分类会显示全部数量。后续将支持"加载更多"。', + ), + KnownIssueItem( + icon: '🔑', + title: 'Key 码获取方式', + level: IssueLevel.important, + description: '当前暂未开放注册登录,需通过邀请获取 Key 码激活。后续版本将开放付费购买渠道。', + ), + KnownIssueItem( + icon: '🧊', + title: '液态玻璃效果 GPU 占用较高', + level: IssueLevel.warning, + description: + '液态玻璃(Liquid Glass)效果对 GPU 要求较高,长时间使用可能出现轻微发热。低性能设备建议在设置中关闭毛玻璃效果。', + ), + KnownIssueItem( + icon: '🚧', + title: '开发中功能免费,完成后可能收费', + level: IssueLevel.important, + description: '当前处于开发阶段的功能均可免费使用。功能开发完成并稳定后,部分高级功能可能转为付费订阅制。', + ), + KnownIssueItem( + icon: '📱', + title: '大部分页面横屏适配未完成', + level: IssueLevel.info, + description: '当前主要针对竖屏优化,横屏或折叠屏展开状态下部分页面布局可能不够理想,后续版本将逐步适配。', + ), + KnownIssueItem( + icon: '🖼️', + title: '图片加载偶现延迟', + level: IssueLevel.info, + description: + '服务器带宽6MB/s,换算后加载资源每秒约500Kb,首次加载可能较慢。高并发时段服务器会触发自我保护机制,限制单连接速率;带宽达到上限时也会出现加载缓慢或失败。已访问过的图片会自动缓存,命中缓存加载将显著加快。后续会增加无图模式。', + ), + KnownIssueItem( + icon: '📧', + title: '邮箱发送受限说明', + level: IssueLevel.warning, + description: + '邮箱发送功能仅支持国内网络环境。使用代理、VPN或在海外网络下无法发送,即使显示发送成功也会失败。路由器端代理同样会导致发送失败。部分WiFi环境下可能因服务商拦截而无法发送,可切换移动数据重试。建议在稳定的国内网络下使用此功能。', + ), + KnownIssueItem( + icon: '🔍', + title: 'web端 部分功能未开放', + level: IssueLevel.info, + description: '由于服务器占用较大,部分功能在线版暂未开放,无法加载。', + ), + KnownIssueItem( + icon: '💾', + title: '离线模式功能有限', + level: IssueLevel.warning, + description: '无网络时仅可查看已缓存的菜谱和分类数据,筛选、推荐等需要联网的功能暂不可用。后续将增强离线体验。', + ), + KnownIssueItem( + icon: '📄', + title: '部分平台导出文件后缀重复', + level: IssueLevel.warning, + description: + '已知鸿蒙端导出文件后缀会出现重复(如 xxx.pdf.pdf、xxx.json.json ....),此为系统文件选择器自动追加后缀导致。当前问题排查中,暂无修复计划。用户可在导出页面手动删除多余后缀。', + ), + ]; + + static void show(BuildContext context, bool isDark) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(ctx).size.height * 0.75, + ), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(DesignTokens.radiusXl), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: DesignTokens.space2), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.3), + borderRadius: DesignTokens.borderRadiusSm, + ), + ), + const SizedBox(height: DesignTokens.space3), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: Row( + children: [ + const Text('⚠️', style: TextStyle(fontSize: 20)), + const SizedBox(width: DesignTokens.space2), + Text( + '已知问题与说明', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const Spacer(), + GestureDetector( + onTap: () => Navigator.pop(ctx), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: + (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3) + .withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon( + CupertinoIcons.xmark, + size: 14, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ), + ], + ), + ), + const SizedBox(height: DesignTokens.space3), + Flexible( + child: ListView.separated( + shrinkWrap: true, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + itemCount: issues.length, + separatorBuilder: (_, __) => + const SizedBox(height: DesignTokens.space2), + itemBuilder: (_, index) => + _buildIssueCard(issues[index], isDark), + ), + ), + const SizedBox(height: DesignTokens.space4), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: SizedBox( + width: double.infinity, + child: CupertinoButton( + borderRadius: DesignTokens.borderRadiusLg, + color: DesignTokens.primaryLight, + onPressed: () => Navigator.pop(ctx), + child: Text( + '我知道了', + style: TextStyle( + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + SizedBox( + height: MediaQuery.of(ctx).padding.bottom + DesignTokens.space3, + ), + ], + ), + ), + ); + } + + static Widget _buildIssueCard(KnownIssueItem issue, bool isDark) { + final levelColor = switch (issue.level) { + IssueLevel.important => CupertinoColors.destructiveRed, + IssueLevel.warning => CupertinoColors.activeOrange, + IssueLevel.info => DesignTokens.dynamicPrimary, + }; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: levelColor.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all(color: levelColor.withValues(alpha: 0.15)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(issue.icon, style: const TextStyle(fontSize: 16)), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + issue.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: levelColor.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + switch (issue.level) { + IssueLevel.important => '重要', + IssueLevel.warning => '注意', + IssueLevel.info => '提示', + }, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: levelColor, + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Text( + issue.description, + style: TextStyle( + fontSize: DesignTokens.fontXs, + height: 1.5, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/profile/profile_settings.dart b/lib/src/pages/profile/profile_settings.dart index 2fc5d1c..3b3f4d1 100644 --- a/lib/src/pages/profile/profile_settings.dart +++ b/lib/src/pages/profile/profile_settings.dart @@ -1,4 +1,4 @@ -/* +/* * 文件: profile_settings.dart * 名称: 个人中心设置标签 * 作用: iOS 26 风格的设置选项,使用 DesignTokens 和 GlassSettingsTile @@ -19,6 +19,7 @@ import 'package:cute_kitchen/src/pages/tools/cooking/cooking_note_page.dart'; import 'package:cute_kitchen/src/pages/profile/social/footprints_page.dart'; import 'package:cute_kitchen/src/pages/profile/data/cache_manage_page.dart'; import 'package:cute_kitchen/src/pages/profile/tools/data_export_page.dart'; +import 'package:cute_kitchen/src/pages/profile/known_issues_sheet.dart'; class ProfileSettingsTab extends StatelessWidget { const ProfileSettingsTab({super.key}); @@ -94,7 +95,7 @@ class ProfileSettingsTab extends StatelessWidget { icon: CupertinoIcons.exclamationmark_triangle, title: '已知问题 ⚠️', isDark: isDark, - onTap: () => _showKnownIssuesSheet(context, isDark), + onTap: () => KnownIssuesSheet.show(context, isDark), ), // _buildTile( // icon: CupertinoIcons.flame, @@ -481,277 +482,6 @@ class ProfileSettingsTab extends StatelessWidget { ), ); } - - void _showKnownIssuesSheet(BuildContext context, bool isDark) { - final issues = [ - _KnownIssue( - icon: '🌐', - title: 'VPN/代理环境部分功能异常', - level: _IssueLevel.warning, - description: '使用代理或 VPN 时,部分接口请求可能超时或失败,建议在直连环境或者国内网络环境下使用以获得最佳体验。', - ), - _KnownIssue( - icon: '🧊', - title: 'Win10/11端 页面返回问题', - level: _IssueLevel.warning, - description: '部分页面未设置返回逻辑,需使用快捷键 同时按下 ALT+方向左键/Escape(Esc键) 返回上一页。', - ), - _KnownIssue( - icon: '📂', - title: '分类列表默认显示前20项', - level: _IssueLevel.info, - description: - '为节省服务器资源,大部分分类(菜系、标签等)默认只展示前20条。低于20条的分类会显示全部数量。后续将支持"加载更多"。', - ), - _KnownIssue( - icon: '🔑', - title: 'Key 码获取方式', - level: _IssueLevel.important, - description: '当前暂未开放注册登录,需通过邀请获取 Key 码激活。后续版本将开放付费购买渠道。', - ), - _KnownIssue( - icon: '🧊', - title: '液态玻璃效果 GPU 占用较高', - level: _IssueLevel.warning, - description: - '液态玻璃(Liquid Glass)效果对 GPU 要求较高,长时间使用可能出现轻微发热。低性能设备建议在设置中关闭毛玻璃效果。', - ), - _KnownIssue( - icon: '🚧', - title: '开发中功能免费,完成后可能收费', - level: _IssueLevel.important, - description: '当前处于开发阶段的功能均可免费使用。功能开发完成并稳定后,部分高级功能可能转为付费订阅制。', - ), - _KnownIssue( - icon: '📱', - title: '大部分页面横屏适配未完成', - level: _IssueLevel.info, - description: '当前主要针对竖屏优化,横屏或折叠屏展开状态下部分页面布局可能不够理想,后续版本将逐步适配。', - ), - _KnownIssue( - icon: '🖼️', - title: '图片加载偶现延迟', - level: _IssueLevel.info, - description: - '服务器带宽6MB/s,换算后加载资源每秒约500Kb,首次加载可能较慢。高并发时段服务器会触发自我保护机制,限制单连接速率;带宽达到上限时也会出现加载缓慢或失败。已访问过的图片会自动缓存,命中缓存加载将显著加快。后续会增加无图模式。', - ), - _KnownIssue( - icon: '📧', - title: '邮箱发送受限说明', - level: _IssueLevel.warning, - description: - '邮箱发送功能仅支持国内网络环境。使用代理、VPN或在海外网络下无法发送,即使显示发送成功也会失败。路由器端代理同样会导致发送失败。部分WiFi环境下可能因服务商拦截而无法发送,可切换移动数据重试。建议在稳定的国内网络下使用此功能。', - ), - _KnownIssue( - icon: '🔍', - title: 'web端 部分功能未开放', - level: _IssueLevel.info, - description: '由于服务器占用较大,部分功能在线版暂未开放,无法加载。', - ), - _KnownIssue( - icon: '💾', - title: '离线模式功能有限', - level: _IssueLevel.warning, - description: '无网络时仅可查看已缓存的菜谱和分类数据,筛选、推荐等需要联网的功能暂不可用。后续将增强离线体验。', - ), - ]; - - showCupertinoModalPopup( - context: context, - builder: (ctx) => Container( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(ctx).size.height * 0.75, - ), - decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(DesignTokens.radiusXl), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: DesignTokens.space2), - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) - .withValues(alpha: 0.3), - borderRadius: DesignTokens.borderRadiusSm, - ), - ), - const SizedBox(height: DesignTokens.space3), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - child: Row( - children: [ - const Text('⚠️', style: TextStyle(fontSize: 20)), - const SizedBox(width: DesignTokens.space2), - Text( - '已知问题与说明', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w700, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - ), - const Spacer(), - GestureDetector( - onTap: () => Navigator.pop(ctx), - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: - (isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3) - .withValues(alpha: 0.12), - shape: BoxShape.circle, - ), - child: Icon( - CupertinoIcons.xmark, - size: 14, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - ), - ), - ], - ), - ), - const SizedBox(height: DesignTokens.space3), - Flexible( - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - itemCount: issues.length, - separatorBuilder: (_, __) => - const SizedBox(height: DesignTokens.space2), - itemBuilder: (_, index) => - _buildIssueCard(issues[index], isDark), - ), - ), - const SizedBox(height: DesignTokens.space4), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - child: SizedBox( - width: double.infinity, - child: CupertinoButton( - borderRadius: DesignTokens.borderRadiusLg, - color: DesignTokens.primaryLight, - onPressed: () => Navigator.pop(ctx), - child: Text( - '我知道了', - style: TextStyle( - color: DesignTokens.dynamicPrimary, - fontWeight: FontWeight.w600, - ), - ), - ), - ), - ), - SizedBox( - height: MediaQuery.of(ctx).padding.bottom + DesignTokens.space3, - ), - ], - ), - ), - ); - } - - Widget _buildIssueCard(_KnownIssue issue, bool isDark) { - final levelColor = switch (issue.level) { - _IssueLevel.important => CupertinoColors.destructiveRed, - _IssueLevel.warning => CupertinoColors.activeOrange, - _IssueLevel.info => DesignTokens.dynamicPrimary, - }; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: levelColor.withValues(alpha: 0.06), - borderRadius: DesignTokens.borderRadiusLg, - border: Border.all(color: levelColor.withValues(alpha: 0.15)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text(issue.icon, style: const TextStyle(fontSize: 16)), - const SizedBox(width: DesignTokens.space2), - Expanded( - child: Text( - issue.title, - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: levelColor.withValues(alpha: 0.12), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Text( - switch (issue.level) { - _IssueLevel.important => '重要', - _IssueLevel.warning => '注意', - _IssueLevel.info => '提示', - }, - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: levelColor, - ), - ), - ), - ], - ), - const SizedBox(height: DesignTokens.space2), - Text( - issue.description, - style: TextStyle( - fontSize: DesignTokens.fontXs, - height: 1.5, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], - ), - ); - } -} - -enum _IssueLevel { important, warning, info } - -class _KnownIssue { - final String icon; - final String title; - final _IssueLevel level; - final String description; - - const _KnownIssue({ - required this.icon, - required this.title, - required this.level, - required this.description, - }); } enum _PlanStatus { coming, dev, plan } diff --git a/lib/src/pages/profile/tools/data_export_page.dart b/lib/src/pages/profile/tools/data_export_page.dart index 3caf094..db2e84a 100644 --- a/lib/src/pages/profile/tools/data_export_page.dart +++ b/lib/src/pages/profile/tools/data_export_page.dart @@ -388,7 +388,10 @@ class _DataExportPageState extends State { final lastBracket = content.lastIndexOf(']'); if (lastBracket >= 0 && lastBracket < content.length - 1) { final trailing = content.substring(lastBracket + 1).trim(); - if (trailing.isNotEmpty) { + if (trailing.isNotEmpty && + !trailing.startsWith(',') && + !trailing.startsWith('}') && + !trailing.startsWith(']')) { debugPrint('🧹 检测到尾部脏数据(${trailing.length}字符),已截取有效JSON'); content = content.substring(0, lastBracket + 1); } diff --git a/lib/src/services/data/business/data_export_service.dart b/lib/src/services/data/business/data_export_service.dart index 08eb177..a87b9be 100644 --- a/lib/src/services/data/business/data_export_service.dart +++ b/lib/src/services/data/business/data_export_service.dart @@ -317,7 +317,10 @@ class DataExportService extends GetxService { final lastBracket = content.lastIndexOf(']'); if (lastBracket >= 0 && lastBracket < content.length - 1) { final trailing = content.substring(lastBracket + 1).trim(); - if (trailing.isNotEmpty) { + if (trailing.isNotEmpty && + !trailing.startsWith(',') && + !trailing.startsWith('}') && + !trailing.startsWith(']')) { debugPrint( 'DataExportService: 检测到尾部脏数据(${trailing.length}字符),已截取有效JSON', ); diff --git a/lib/src/widgets/recipe_detail/interaction/recipe_export_button.dart b/lib/src/widgets/recipe_detail/interaction/recipe_export_button.dart index 8b05b2f..8a7eb20 100644 --- a/lib/src/widgets/recipe_detail/interaction/recipe_export_button.dart +++ b/lib/src/widgets/recipe_detail/interaction/recipe_export_button.dart @@ -1,9 +1,18 @@ // 2026-04-25 | recipe_export_button.dart | 菜谱导出按钮组件 // 2026-04-24 | 创建: 从recipe_detail_page.dart拆分导出逻辑 -// 2026-04-25 | 重构: 文件选择器保存路径 + 系统分享 + PDF中文修复 + 封面图片 +// 2026-04-25 | 重构: 文件选择器保存路径 + 系统分享 + PDF中文修复(TTF字体) + 封面图片(URL链+缓存+下载) +// 2026-04-25 | 优化: emoji→纯文本标签 + 完善PDF内容(5个新模块) + 分享写入验证 +// 2026-04-25 | 修复: 作者乱码清理 + 无效作者过滤(520kiss→小妈厨房APP) + Word文档同步完善字段 +// 2026-04-25 | 新增: PDF/Word底部二维码区域(小妈厨房APP扫码) + 二维码URL格式(id去掉cp前缀) +// 2026-04-25 | 修复: _displayIntro乱码(菱形方块) + RecipeRating对象类型错误 +// 2026-04-25 | 修复: PDF无反应(二维码生成增强错误处理) + Word文本二维码(ASCII art) +// 2026-04-25 | 优化: 自定义导出(7个开关+Word/PDF双格式) + Word强制ASCII二维码 +// 2026-04-25 | 清理: 31个警告(花括号+null操作符+import) + Word二维码紧凑化(单字符+低纠错) +// 2026-04-25 | 修复: Word ASCII二维码改为纯文本URL(解决等宽字体变形) + 文件尾缀重复(双重扩展名检测) + PDF作者乱码增强清理(菱形方块+特殊符号) +// 2026-04-25 | 修复v3: PDF乱码终极修复(Private Use Area过滤 + 乱码文本检测算法 + 40%阈值判定) import 'dart:io'; -import 'dart:typed_data'; +import 'dart:ui' as ui; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -16,15 +25,21 @@ import 'package:docs_gee/docs_gee.dart' as gee; import 'package:file_picker_ohos/file_picker_ohos.dart'; import 'package:share_plus/share_plus.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:qr/qr.dart'; class RecipeExportButton extends StatelessWidget { final dynamic recipe; const RecipeExportButton({super.key, required this.recipe}); + static const _invalidAuthors = ['520kiss', 'admin', 'test', '未知']; + String get _title => recipe.title?.toString() ?? '菜谱'; String? get _categoryName => recipe.categoryName?.toString(); String? get _displayIntro => recipe.displayIntro?.toString(); + String? get _content => recipe.content?.toString(); List get _ingredients => (recipe.ingredients as List?) ?? []; @@ -35,6 +50,177 @@ class RecipeExportButton extends StatelessWidget { recipe.cover.toString().isNotEmpty && recipe.cover.toString().startsWith('http'); + String get _displayAuthor { + final name = recipe.author?.name?.toString().trim() ?? ''; + if (name.isEmpty) return '小妈厨房APP'; + if (_invalidAuthors.contains(name.toLowerCase())) return '小妈厨房APP'; + if (name.length < 2 || RegExp(r'^[\d\s]+$').hasMatch(name)) { + return '小妈厨房APP'; + } + return name; + } + + static String _cleanPdfText(String text) { + if (text.isEmpty) return ''; + var cleaned = StringBuffer(); + for (var i = 0; i < text.length; i++) { + final codeUnit = text.codeUnitAt(i); + final char = text[i]; + if (_shouldFilterChar(codeUnit, char)) continue; + cleaned.write(char); + } + var result = cleaned.toString().trim(); + if (result.isEmpty) return ''; + if (_isGarbledText(result)) return ''; + return result; + } + + static bool _shouldFilterChar(int codeUnit, String char) { + if (codeUnit < 0x20 && + codeUnit != 0x09 && + codeUnit != 0x0A && + codeUnit != 0x0D) { + return true; + } + if (codeUnit == 0x7F) return true; + if (codeUnit >= 0x80 && codeUnit <= 0x9F) return true; + if ((codeUnit & 0xFFFE) == 0xFFFE || (codeUnit & 0xFFFE) == 0xFFFF) + return true; + if (codeUnit == 0xFFFD) return true; + if (codeUnit >= 0xFDD0 && codeUnit <= 0xFDEF) return true; + if (codeUnit >= 0xE000 && codeUnit <= 0xF8FF) return true; + if (codeUnit >= 0xFFF0 && codeUnit <= 0xFFFB) return true; + if (codeUnit >= 0xFE00 && codeUnit <= 0xFE0F) return true; + if (_isSpecialSymbol(char)) return true; + return false; + } + + static bool _isGarbledText(String text) { + if (text.length < 2) return false; + int specialCount = 0; + for (int i = 0; i < text.length; i++) { + final cp = text.codeUnitAt(i); + final isCjk = + (cp >= 0x4E00 && cp <= 0x9FFF) || + (cp >= 0x3400 && cp <= 0x4DBF) || + (cp >= 0x20000 && cp <= 0x2A6DF) || + (cp >= 0x2A700 && cp <= 0x2B73F) || + (cp >= 0x2B740 && cp <= 0x2B81F) || + (cp >= 0x2B820 && cp <= 0x2CEAF) || + (cp >= 0xF900 && cp <= 0xFAFF) || + (cp >= 0x2F800 && cp <= 0x2FA1F); + final isAsciiLetter = + (cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A); + final isDigit = cp >= 0x30 && cp <= 0x39; + final isSpace = cp == 0x20 || cp == 0x09 || cp == 0x0A || cp == 0x0D; + final isPunctuation = + (cp >= 0x2000 && cp <= 0x206F) || + (cp >= 0x3000 && cp <= 0x303F) || + (cp >= 0xFF00 && cp <= 0xFFEF) || + cp == 0x2E || + cp == 0x2C || + cp == 0x3B || + cp == 0x3A || + cp == 0x21 || + cp == 0x3F || + cp == 0x28 || + cp == 0x29 || + cp == 0x5B || + cp == 0x5D || + cp == 0x7B || + cp == 0x7D || + cp == 0x201C || + cp == 0x201D || + cp == 0x2018 || + cp == 0x2019; + if (!isCjk && !isAsciiLetter && !isDigit && !isSpace && !isPunctuation) { + specialCount++; + } + } + final ratio = specialCount / text.length; + return ratio > 0.4; + } + + static bool _isSpecialSymbol(String char) { + const rawSymbols = + '▯□■◯○●◇◆▪▫◻◼◽◾▱░▒▓█▄▌▐▀▸▂▁▃▅▆▇▉▊▋▎▏▕▖▗▘▙▚▛▜▝▞▟╭╮╯╰╱╲╳╴╵╶╷╸╹╺╻╼╽╾╿┌┐└┘├┤┬┴┼─│┈┉┊┋━┃┅┆┇┍┎┏┐┑▒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋☐☑☒✓✔✗✘→←↑↓↔⇒⇐⇑⇓⇔⇕⇖⇗⇘⇙♠♣♥♦★☆▲▼◐◑◒◓◔◕◖◗❤❥❦❧❝❞❟❰❱❲❳❴❵❶❷❸❹❺❻❼❽❾❿➔➘➙➚➛➜➝➞➟➠➡➢➣➤➥➦➧➨➩➪➫➬➭➮➯➱➲➳➴➵➶➷➸➹➺➻➼➽➾➿⟦⟧⟨⟩⟪⟫⟬⟭⟮⟯⬅⬆⬇⬈⬉⬊⬋⬌⬍⬎⬏⬐⬑⬒⬓⬘⬙⬚⬛⬜⬝⬞⬟⬠⬡⭢⭣⭤⭥⭦⭧⭨⭩⭪⭫⭬⭭⭮⭯⭐⭕⭘⭙⭚⭛⭜⭝⭞⭟⭠⭡⭢⭣⭤⭥'; + return rawSymbols.contains(char); + } + + String _formatPath(String path) { + try { + final file = File(path); + if (file.existsSync()) { + return path; + } + } catch (_) {} + return path; + } + + String get _qrUrl { + final id = recipe.id?.toString() ?? ''; + if (id.isEmpty) return 'https://eat.wktyl.com'; + final cleanId = id.replaceFirst(RegExp(r'^cp'), ''); + return 'https://eat.wktyl.com/?id=$cleanId'; + } + + Future _generateQrCodeImage({int size = 200}) async { + try { + debugPrint('🔲 开始生成二维码: $_qrUrl, size=$size'); + final qrCode = QrCode.fromData( + data: _qrUrl, + errorCorrectLevel: QrErrorCorrectLevel.M, + ); + final qrImage = QrImage(qrCode); + final moduleCount = qrImage.moduleCount; + if (moduleCount <= 0) { + debugPrint('❌ 二维码模块数量异常: $moduleCount'); + return null; + } + final pixelRatio = size / moduleCount; + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + final paint = Paint()..color = const Color(0xFF000000); + final whitePaint = Paint()..color = const Color(0xFFFFFFFF); + + canvas.drawRect( + Rect.fromLTWH(0, 0, size.toDouble(), size.toDouble()), + whitePaint, + ); + + for (var row = 0; row < moduleCount; row++) { + for (var col = 0; col < moduleCount; col++) { + if (qrImage.isDark(row, col)) { + canvas.drawRect( + Rect.fromLTWH( + col * pixelRatio, + row * pixelRatio, + pixelRatio, + pixelRatio, + ), + paint, + ); + } + } + } + + final picture = recorder.endRecording(); + final image = await picture.toImage(size, size); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData == null) { + debugPrint('❌ 二维码图片转换失败: byteData为null'); + return null; + } + final result = byteData.buffer.asUint8List(); + debugPrint('✅ 二维码生成成功: ${result.length} bytes'); + return result; + } catch (e, stackTrace) { + debugPrint('❌ 生成二维码失败: $e'); + debugPrint('StackTrace: $stackTrace'); + return null; + } + } + @override Widget build(BuildContext context) { return Padding( @@ -151,19 +337,65 @@ class RecipeExportButton extends StatelessWidget { } Future _getCachedCoverImage() async { - if (!_hasCover) return null; try { - final url = recipe.cover.toString(); final cacheManager = DefaultCacheManager(); - final fileInfo = await cacheManager.getFileFromCache(url); - if (fileInfo != null && await fileInfo.file.exists()) { - final bytes = await fileInfo.file.readAsBytes(); - debugPrint('从缓存读取封面图: ${bytes.length} bytes'); - return bytes; + + // 构建候选 URL 链 (与 RecipeImage._buildUrlChain 保持一致) + final candidates = []; + const picBase = 'https://eat.wktyl.com/api/assets/pic'; + + // 1. 原始 cover URL (确保 HTTPS) + if (_hasCover) { + var url = recipe.cover.toString(); + if (url.startsWith('http://')) { + url = url.replaceFirst('http://', 'https://'); + } + candidates.add(url); } - debugPrint('封面图未在缓存中: $url'); + + // 2. 基于 picId 的 fallback URL + final pid = recipe.effectivePicId; + if (pid > 0) { + candidates.add('$picBase/${pid}a.jpg'); + candidates.add('$picBase/${pid}b.jpg'); + } + + // 3. 最终 fallback: 基于 recipeId + candidates.add('$picBase/${recipe.id}a.jpg'); + + debugPrint('🖼️ 封面图候选URL (${candidates.length}个):'); + for (var i = 0; i < candidates.length; i++) { + debugPrint(' [$i] ${candidates[i]}'); + } + + // 依次尝试每个候选 URL: 先查缓存,再网络下载 + for (final url in candidates) { + try { + // Step A: 尝试从缓存读取 + final fileInfo = await cacheManager.getFileFromCache(url); + if (fileInfo != null && await fileInfo.file.exists()) { + final bytes = await fileInfo.file.readAsBytes(); + debugPrint('✅ 从缓存读取封面图: $url (${bytes.length} bytes)'); + return bytes; + } + + // Step B: 缓存未命中,从网络下载 + debugPrint('⏳ 缓存未命中,尝试下载: $url'); + final file = await cacheManager.getSingleFile(url); + if (await file.exists()) { + final bytes = await file.readAsBytes(); + debugPrint('✅ 网络下载封面图成功: $url (${bytes.length} bytes)'); + return bytes; + } + } catch (e) { + debugPrint('⚠️ URL 失败: $url → $e'); + continue; + } + } + + debugPrint('❌ 所有候选URL均失败'); } catch (e) { - debugPrint('读取缓存图片失败: $e'); + debugPrint('❌ 获取封面图异常: $e'); } return null; } @@ -174,15 +406,59 @@ class RecipeExportButton extends StatelessWidget { Uint8List? bytes, }) async { try { + var cleanName = fileName; + final extPattern = RegExp(r'\.(pdf|docx)$', caseSensitive: false); + while (extPattern.hasMatch(cleanName)) { + cleanName = cleanName.replaceFirst(extPattern, ''); + } final result = await FilePicker.platform.saveFile( dialogTitle: '保存文件', - fileName: '$fileName.$extensionType', + fileName: '$cleanName.$extensionType', type: _fileTypeFromExt(extensionType), bytes: bytes, ); if (result != null && result.isNotEmpty) { - debugPrint('文件已保存: $result'); - return result; + String finalPath = result; + bool needsFix = false; + while (RegExp( + r'\.(pdf|docx)\.(pdf|docx)$', + caseSensitive: false, + ).hasMatch(finalPath)) { + finalPath = finalPath.replaceFirst( + RegExp(r'\.(pdf|docx)$', caseSensitive: false), + '', + ); + needsFix = true; + } + if (needsFix) { + debugPrint('检测到多重扩展名,修正为: $finalPath'); + if (bytes != null) { + try { + final newFile = File(finalPath); + await newFile.writeAsBytes(bytes); + try { + final oldFile = File(result); + if (await oldFile.exists()) { + await oldFile.delete(); + } + } catch (_) {} + } catch (e) { + debugPrint('修正路径写入失败: $e, 使用原路径'); + finalPath = result; + } + } + } + if (bytes != null) { + final file = File(finalPath); + if (!await file.exists() || await file.length() == 0) { + debugPrint('saveFile返回路径但文件不存在/为空,尝试手动写入: $finalPath'); + await file.writeAsBytes(bytes); + final size = await file.length(); + debugPrint('手动写入完成: $finalPath ($size bytes)'); + } + } + debugPrint('文件已保存: $finalPath'); + return finalPath; } } on ArgumentError catch (e) { debugPrint('saveFile参数错误(可能缺少bytes): $e'); @@ -297,7 +573,17 @@ class RecipeExportButton extends StatelessWidget { } else { final ctx = Get.overlayContext; if (ctx == null) { - ToastService.show(message: '✅ 已保存到: $path', type: ToastType.success); + ToastService.show( + message: '✅ 已保存到: ${_formatPath(path)}', + type: ToastType.success, + ); + return; + } + if (!ctx.mounted) { + ToastService.show( + message: '✅ 已保存到: ${_formatPath(path)}', + type: ToastType.success, + ); return; } showCupertinoDialog( @@ -308,7 +594,7 @@ class RecipeExportButton extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), - Text('文件已保存到:\n$path', style: const TextStyle(fontSize: 12)), + const Text('📁 可前往对应文件夹管理文档', style: TextStyle(fontSize: 13)), const SizedBox(height: 12), CupertinoButton.filled( onPressed: () { @@ -379,41 +665,192 @@ class RecipeExportButton extends StatelessWidget { bool includeIngredients = true, bool includeSteps = true, bool includeTags = true, + Uint8List? coverImage, + Uint8List? qrImage, }) { doc.addParagraph(gee.Paragraph.heading(_title, level: 1)); - if (_categoryName != null && _categoryName!.isNotEmpty) - doc.addParagraph(gee.Paragraph.text('📂 分类: $_categoryName')); - final authorName = recipe.author?.name?.toString(); - if (authorName != null && authorName.isNotEmpty) - doc.addParagraph(gee.Paragraph.text('✍️ 作者: $authorName')); - if (_displayIntro != null && _displayIntro!.isNotEmpty) - doc.addParagraph(gee.Paragraph.quote(_displayIntro!)); + if (_categoryName != null && _categoryName!.isNotEmpty) { + doc.addParagraph(gee.Paragraph.text('【分类】$_categoryName')); + } + final authorName = _displayAuthor; + if (authorName.isNotEmpty) { + doc.addParagraph(gee.Paragraph.text('【作者】$authorName')); + } + if (_displayIntro != null && _displayIntro!.isNotEmpty) { + doc.addParagraph(gee.Paragraph.quote('【简介】$_displayIntro')); + } + + _addMetaInfo(doc); + _addNutritionInfo(doc); + _addStatisticsInfo(doc); + _addAllergenWarning(doc); + if (includeIngredients && _ingredients.isNotEmpty) { - doc.addParagraph(gee.Paragraph.heading('🥘 食材', level: 2)); - doc.addTable(gee.Table(rows: _buildIngredientRows())); + doc.addParagraph(gee.Paragraph.heading('【食材清单】', level: 2)); + doc.addTable(gee.DocxTable(rows: _buildIngredientRows())); } if (includeSteps) { final steps = _parseSteps(); if (steps.isNotEmpty) { - doc.addParagraph(gee.Paragraph.heading('👨‍🍳 制作步骤', level: 2)); - for (final step in steps) + doc.addParagraph(gee.Paragraph.heading('【制作步骤】', level: 2)); + for (final step in steps) { doc.addParagraph(gee.Paragraph.numberedItem(step)); + } } } if (includeTags && _tags.isNotEmpty) { final tagText = _joinTagNames(); if (tagText.isNotEmpty) { - doc.addParagraph(gee.Paragraph.heading('🏷️ 标签', level: 2)); + doc.addParagraph(gee.Paragraph.heading('【标签】', level: 2)); doc.addParagraph(gee.Paragraph.bulletItem(tagText)); } } + + _addFooterInfo(doc); + _addQrCodeSection(doc, qrImage); + } + + void _addMetaInfo(gee.Document doc) { + final meta = recipe.meta; + if (meta == null) return; + final parts = []; + if (meta.process != null && meta.process!.isNotEmpty) { + parts.add('做法: ${meta.process}'); + } + if (meta.taste != null && meta.taste!.isNotEmpty) { + parts.add('口味: ${meta.taste}'); + } + if (meta.difficulty != null && meta.difficulty!.isNotEmpty) { + parts.add('难度: ${meta.difficulty}'); + } + if (meta.time != null && meta.time!.isNotEmpty) { + parts.add('时间: ${meta.time}'); + } + if (meta.eatingTime.isNotEmpty) { + parts.add('用餐: ${meta.eatingTime.join('、')}'); + } + if (parts.isEmpty) return; + doc.addParagraph(gee.Paragraph.heading('【菜品信息】', level: 2)); + for (final part in parts) { + doc.addParagraph(gee.Paragraph.bulletItem(part)); + } + } + + void _addNutritionInfo(gee.Document doc) { + final nutrition = recipe.nutrition; + if (nutrition == null) return; + doc.addParagraph(gee.Paragraph.heading('【营养成分】', level: 2)); + final rows = [ + gee.DocxTableRow( + cells: [gee.DocxTableCell.text('营养项'), gee.DocxTableCell.text('含量')], + ), + gee.DocxTableRow( + cells: [ + gee.DocxTableCell.text('热量'), + gee.DocxTableCell.text( + '${nutrition.calories?.toStringAsFixed(0) ?? "无"} kcal', + ), + ], + ), + gee.DocxTableRow( + cells: [ + gee.DocxTableCell.text('蛋白质'), + gee.DocxTableCell.text( + '${nutrition.protein?.toStringAsFixed(1) ?? "无"}g', + ), + ], + ), + gee.DocxTableRow( + cells: [ + gee.DocxTableCell.text('脂肪'), + gee.DocxTableCell.text( + '${nutrition.fat?.toStringAsFixed(1) ?? "无"}g', + ), + ], + ), + gee.DocxTableRow( + cells: [ + gee.DocxTableCell.text('碳水化合物'), + gee.DocxTableCell.text( + '${nutrition.carbs?.toStringAsFixed(1) ?? "无"}g', + ), + ], + ), + gee.DocxTableRow( + cells: [ + gee.DocxTableCell.text('膳食纤维'), + gee.DocxTableCell.text( + '${nutrition.fiber?.toStringAsFixed(1) ?? "无"}g', + ), + ], + ), + ]; + doc.addTable(gee.DocxTable(rows: rows)); + } + + void _addStatisticsInfo(gee.Document doc) { + final rating = recipe.rating; + final stats = recipe.statistics; + final items = []; + if (rating != null && rating.hasRating == true) { + items.add('评分: ${rating.displayText ?? rating.toString()}'); + } + if (stats != null) { + if ((stats.views ?? 0) > 0) { + items.add('浏览: ${stats.views}'); + } + if ((stats.likes ?? 0) > 0) { + items.add('点赞: ${stats.likes}'); + } + if ((stats.comments ?? 0) > 0) { + items.add('评论: ${stats.comments}'); + } + } + if (items.isEmpty) return; + doc.addParagraph(gee.Paragraph.heading('【数据统计】', level: 2)); + for (final item in items) { + doc.addParagraph(gee.Paragraph.bulletItem(item)); + } + } + + void _addAllergenWarning(gee.Document doc) { + final allergens = recipe.allergens; + if (allergens.isEmpty) return; + doc.addParagraph(gee.Paragraph.text('[注意] 可能含过敏原: ${allergens.join('、')}')); + } + + void _addFooterInfo(gee.Document doc) { + final createdAt = recipe.createdAt; + final updatedAt = recipe.updatedAt; + if (createdAt == null && updatedAt == null) return; + doc.addParagraph(gee.Paragraph.text('─────────────────────────────')); + if (createdAt != null) { + doc.addParagraph(gee.Paragraph.text('创建时间: $createdAt')); + } + if (updatedAt != null) { + doc.addParagraph(gee.Paragraph.text('更新时间: $updatedAt')); + } + doc.addParagraph(gee.Paragraph.text('-- 来自小妈厨房 Cute Kitchen --')); + } + + void _addQrCodeSection(gee.Document doc, Uint8List? qrImage) { + doc.addParagraph(gee.Paragraph.text('')); + doc.addParagraph(gee.Paragraph.text('─────────────────────────────')); + doc.addParagraph(gee.Paragraph.heading('🔲 扫码查看菜谱详情', level: 2)); + doc.addParagraph(gee.Paragraph.text('')); + doc.addParagraph(gee.Paragraph.text('使用「小妈厨房」APP 扫码打开菜谱详情页')); + doc.addParagraph(gee.Paragraph.text('其他软件扫码可在浏览器中查看')); + doc.addParagraph(gee.Paragraph.text('')); + doc.addParagraph(gee.Paragraph.text("链接: $_qrUrl")); } Future _exportAsDocx() async { try { ToastService.show(message: '正在生成 Word 文档…'); final doc = gee.Document(title: _title, author: '小妈厨房'); - _buildRecipeContent(doc); + final coverBytes = await _getCachedCoverImage(); + final qrBytes = await _generateQrCodeImage(size: 180); + _buildRecipeContent(doc, coverImage: coverBytes, qrImage: qrBytes); final bytes = gee.DocxGenerator().generate(doc); await _saveAndMaybeShare(bytes, _title, 'docx'); } catch (e) { @@ -427,37 +864,109 @@ class RecipeExportButton extends StatelessWidget { ToastService.show(message: '正在生成 PDF(加载中文字体和图片)…'); final fontBytes = await _loadCJKFont( - 'assets/fonts/NotoSansSC-Regular.otf', + 'assets/fonts/NotoSansSC-Regular.ttf', ); final boldFontBytes = await _loadCJKFont( - 'assets/fonts/NotoSansSC-Bold.otf', + 'assets/fonts/NotoSansSC-Bold.ttf', ); final imgBytes = await _getCachedCoverImage(); - final doc = gee.Document(title: _title, author: '小妈厨房'); - _buildRecipeContent(doc); + pw.Font? ttfFont; + pw.Font? ttfBold; + bool hasCJK = false; - double imgW = 400, imgH = 300; - if (imgBytes != null) { - final ratio = imgBytes.length / (500 * 1024); - if (ratio > 1) { - imgW /= ratio; - imgH /= ratio; + if (fontBytes != null) { + try { + ttfFont = pw.Font.ttf(fontBytes.buffer.asByteData()); + hasCJK = true; + debugPrint('PDF Regular TTF 加载成功, ${fontBytes.length} bytes'); + } catch (e) { + debugPrint('PDF Regular TTF 解析失败: $e'); } - imgW = imgW.clamp(200, 450); - imgH = imgH.clamp(150, 350); + } else { + debugPrint('PDF Regular 字体文件加载返回 null'); + } + if (boldFontBytes != null) { + try { + ttfBold = pw.Font.ttf(boldFontBytes.buffer.asByteData()); + debugPrint('PDF Bold TTF 加载成功, ${boldFontBytes.length} bytes'); + } catch (e) { + debugPrint('PDF Bold TTF 解析失败: $e'); + } + } else { + debugPrint('PDF Bold 字体文件加载返回 null'); } - final pdfGen = gee.PdfGenerator( - fontSize: 13, - embeddedFontBytes: fontBytes, - embeddedBoldFontBytes: boldFontBytes, - coverImageBytes: imgBytes, - coverImageWidth: imgW, - coverImageHeight: imgH, + if (!hasCJK) { + debugPrint('⚠️ 无可用中文字体,PDF生成可能失败'); + ToastService.show( + message: '⚠️ 中文字体加载失败,PDF可能显示异常', + type: ToastType.warning, + ); + } + + final coverImage = imgBytes != null ? pw.MemoryImage(imgBytes) : null; + final qrBytes = await _generateQrCodeImage(size: 180); + final qrImage = qrBytes != null ? pw.MemoryImage(qrBytes) : null; + + final pdf = pw.Document( + theme: pw.ThemeData.withFont( + base: ttfFont ?? pw.Font.helvetica(), + bold: ttfBold ?? pw.Font.helveticaBold(), + ), ); - final bytes = pdfGen.generate(doc); + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(48), + header: (context) { + if (context.pageNumber == 1 && coverImage != null) { + return pw.Center( + child: pw.Image(coverImage, width: 380, height: 280), + ); + } + return pw.SizedBox(); + }, + footer: (context) => pw.Align( + alignment: pw.Alignment.centerRight, + child: pw.Text( + hasCJK + ? '小妈厨房 · 第 ${context.pageNumber} 页' + : 'Cute Kitchen · Page ${context.pageNumber}', + style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey), + ), + ), + build: (context) { + final authorName = _displayAuthor; + return [ + _buildPdfTitle(ttfBold, hasCJK), + ..._buildPdfMetaInfo(hasCJK), + if (_categoryName != null && _categoryName!.isNotEmpty) + _pdfText( + hasCJK ? '【分类】$_categoryName' : 'Category: $_categoryName', + fontSize: 11, + ), + if (authorName.isNotEmpty) + _pdfText( + hasCJK ? '【作者】$authorName' : 'Author: $authorName', + fontSize: 11, + ), + pw.SizedBox(height: 16), + ..._buildPdfIngredients(hasCJK), + ..._buildPdfSteps(hasCJK), + ..._buildPdfNutrition(hasCJK), + ..._buildPdfStatistics(hasCJK), + ..._buildPdfAllergens(hasCJK), + ..._buildPdfTags(hasCJK), + _buildPdfFooterInfo(hasCJK), + if (qrImage != null) _buildPdfQrCode(qrImage, hasCJK), + ]; + }, + ), + ); + + final bytes = await pdf.save(); await _saveAndMaybeShare(bytes, _title, 'pdf'); } catch (e) { debugPrint('导出PDF失败: $e'); @@ -465,63 +974,623 @@ class RecipeExportButton extends StatelessWidget { } } - void _showCustomExportOptions(BuildContext context) { - bool includeIngredients = true; - bool includeSteps = true; - bool includeTags = true; - - showCupertinoDialog( - context: context, - builder: (ctx) => StatefulBuilder( - builder: (context, setDialogState) => CupertinoAlertDialog( - title: const Text('⚙️ 自定义导出选项'), - content: SizedBox( - width: double.maxFinite, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildSwitchRow( - setDialogState, - '包含食材列表', - includeIngredients, - (v) => includeIngredients = v, - ), - _buildSwitchRow( - setDialogState, - '包含制作步骤', - includeSteps, - (v) => includeSteps = v, - ), - _buildSwitchRow( - setDialogState, - '包含标签信息', - includeTags, - (v) => includeTags = v, - ), - ], - ), + pw.Widget _buildPdfTitle(pw.Font? boldFont, bool hasCJK) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + _title, + style: pw.TextStyle( + font: boldFont, + fontSize: 24, + fontWeight: pw.FontWeight.bold, ), - actions: [ - CupertinoDialogAction( - isDefaultAction: true, - onPressed: () async { - Navigator.pop(ctx); - await _customExport( - includeIngredients: includeIngredients, - includeSteps: includeSteps, - includeTags: includeTags, - ); - }, - child: const Text('开始导出'), + ), + pw.Divider(color: PdfColors.grey300, thickness: 1), + pw.SizedBox(height: 8), + ], + ); + } + + pw.Widget _pdfText( + String text, { + double fontSize = 12, + pw.FontStyle? fontStyle, + PdfColor? color, + }) { + return pw.Text( + text, + style: pw.TextStyle( + fontSize: fontSize, + fontStyle: fontStyle, + color: color ?? PdfColors.black, + ), + ); + } + + List _buildPdfIngredients(bool hasCJK) { + if (_ingredients.isEmpty) return []; + return [ + pw.SizedBox(height: 12), + pw.Text( + hasCJK ? '【食材清单】' : 'Ingredients', + style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 8), + pw.TableHelper.fromTextArray( + context: null, + data: [ + for (final ing in _ingredients) + [ + ing.name?.toString() ?? '', + '${ing.amount?.toString() ?? ''}${ing.unit?.toString() ?? ''}' + .trim(), + ], + ], + headers: [hasCJK ? '食材名称' : 'Ingredient', hasCJK ? '用量' : 'Amount'], + border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5), + headerStyle: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold), + cellStyle: const pw.TextStyle(fontSize: 10), + cellAlignment: pw.Alignment.centerLeft, + headerAlignment: pw.Alignment.center, + ), + ]; + } + + List _buildPdfSteps(bool hasCJK) { + final steps = _parseSteps(); + if (steps.isEmpty) return []; + return [ + pw.SizedBox(height: 16), + pw.Text( + hasCJK ? '【制作步骤】' : 'Cooking Steps', + style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 8), + for (int i = 0; i < steps.length; i++) + pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 6), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Container( + width: 24, + height: 24, + decoration: const pw.BoxDecoration( + color: PdfColors.blue, + shape: pw.BoxShape.circle, + ), + child: pw.Center( + child: pw.Text( + '${i + 1}', + style: const pw.TextStyle( + color: PdfColors.white, + fontSize: 11, + ), + ), + ), + ), + pw.SizedBox(width: 10), + pw.Expanded( + child: pw.Text( + steps[i], + style: const pw.TextStyle(fontSize: 11), + ), + ), + ], + ), + ), + ]; + } + + List _buildPdfTags(bool hasCJK) { + final tagText = _joinTagNames(); + if (tagText.isEmpty) return []; + return [ + pw.SizedBox(height: 16), + pw.Text( + hasCJK ? '【标签】' : 'Tags', + style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 6), + pw.Wrap( + spacing: 8, + runSpacing: 4, + children: tagText + .split(' · ') + .map( + (tag) => pw.Container( + padding: const pw.EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: const pw.BoxDecoration( + color: PdfColor(0.81, 0.92, 1), + borderRadius: pw.BorderRadius.all(pw.Radius.circular(12)), + ), + child: pw.Text( + tag, + style: const pw.TextStyle( + color: PdfColors.blue800, + fontSize: 10, + ), + ), + ), + ) + .toList(), + ), + ]; + } + + List _buildPdfMetaInfo(bool hasCJK) { + final meta = recipe.meta; + if (meta == null) return []; + final items = >[]; + if (meta.process != null && meta.process!.isNotEmpty) { + items.add(MapEntry('做法', _cleanPdfText(meta.process!))); + } + if (meta.taste != null && meta.taste!.isNotEmpty) { + items.add(MapEntry('口味', _cleanPdfText(meta.taste!))); + } + if (meta.difficulty != null && meta.difficulty!.isNotEmpty) { + items.add(MapEntry('难度', _cleanPdfText(meta.difficulty!))); + } + if (meta.time != null && meta.time!.isNotEmpty) { + items.add(MapEntry('时间', _cleanPdfText(meta.time!))); + } + if (meta.eatingTime.isNotEmpty) { + items.add( + MapEntry('用餐', meta.eatingTime.map((e) => _cleanPdfText(e)).join('、')), + ); + } + if (items.isEmpty) return []; + return [ + pw.SizedBox(height: 12), + pw.Text( + hasCJK ? '【菜品信息】' : 'Recipe Info', + style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 8), + pw.Wrap( + spacing: 12, + runSpacing: 6, + children: items + .map( + (item) => pw.Container( + padding: const pw.EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: const pw.BoxDecoration( + color: PdfColor(0.95, 0.95, 0.97), + borderRadius: pw.BorderRadius.all(pw.Radius.circular(6)), + ), + child: pw.Text( + '${item.key}: ${item.value ?? "无"}', + style: const pw.TextStyle(fontSize: 10), + ), + ), + ) + .toList(), + ), + ]; + } + + List _buildPdfNutrition(bool hasCJK) { + final nutrition = recipe.nutrition; + if (nutrition == null) return []; + return [ + pw.SizedBox(height: 16), + pw.Text( + hasCJK ? '【营养成分】' : 'Nutrition Info', + style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 8), + pw.TableHelper.fromTextArray( + context: null, + data: [ + [ + hasCJK ? '热量' : 'Calories', + '${nutrition.calories?.toStringAsFixed(0) ?? "无"} ${hasCJK ? "kcal" : "kcal"}', + ], + [ + hasCJK ? '蛋白质' : 'Protein', + '${nutrition.protein?.toStringAsFixed(1) ?? "无"}g', + ], + [ + hasCJK ? '脂肪' : 'Fat', + '${nutrition.fat?.toStringAsFixed(1) ?? "无"}g', + ], + [ + hasCJK ? '碳水化合物' : 'Carbs', + '${nutrition.carbs?.toStringAsFixed(1) ?? "无"}g', + ], + [ + hasCJK ? '膳食纤维' : 'Fiber', + '${nutrition.fiber?.toStringAsFixed(1) ?? "无"}g', + ], + ], + headers: [hasCJK ? '营养项' : 'Item', hasCJK ? '含量' : 'Amount'], + border: pw.TableBorder.all(color: PdfColors.grey400, width: 0.5), + headerStyle: pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold), + cellStyle: const pw.TextStyle(fontSize: 10), + cellAlignment: pw.Alignment.centerLeft, + headerAlignment: pw.Alignment.center, + ), + ]; + } + + List _buildPdfStatistics(bool hasCJK) { + final stats = recipe.statistics; + final rating = recipe.rating; + if (stats == null && rating == null) return []; + final items = []; + if (rating != null && rating.hasRating) { + items.add( + hasCJK + ? '评分: ${rating.displayText} (${rating.star}星)' + : 'Rating: ${rating.score} (${rating.star} stars)', + ); + } + if (stats != null) { + if (stats.views > 0) { + items.add(hasCJK ? '浏览: ${stats.views}' : 'Views: ${stats.views}'); + } + if (stats.likes > 0) { + items.add(hasCJK ? '点赞: ${stats.likes}' : 'Likes: ${stats.likes}'); + } + if (stats.comments > 0) { + items.add( + hasCJK ? '评论: ${stats.comments}' : 'Comments: ${stats.comments}', + ); + } + } + if (items.isEmpty) return []; + return [ + pw.SizedBox(height: 16), + pw.Text( + hasCJK ? '【数据统计】' : 'Statistics', + style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 8), + ...items.map((item) => _pdfText(item, fontSize: 10)), + ]; + } + + List _buildPdfAllergens(bool hasCJK) { + final allergens = recipe.allergens; + if (allergens.isEmpty) return []; + return [ + pw.SizedBox(height: 16), + pw.Container( + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + color: PdfColor(1, 0.95, 0.9), + borderRadius: pw.BorderRadius.all(pw.Radius.circular(6)), + ), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + hasCJK ? '[注意] 可能含过敏原' : '[Warning] Potential Allergens', + style: pw.TextStyle( + fontSize: 11, + fontWeight: pw.FontWeight.bold, + color: PdfColor(0.8, 0.4, 0), + ), ), - CupertinoDialogAction( - isDestructiveAction: true, - onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), + pw.SizedBox(height: 4), + pw.Text( + allergens.join('、'), + style: pw.TextStyle(fontSize: 10, color: PdfColor(0.7, 0.35, 0)), ), ], ), ), + ]; + } + + pw.Widget _buildPdfFooterInfo(bool hasCJK) { + final createdAt = recipe.createdAt; + final updatedAt = recipe.updatedAt; + if (createdAt == null && updatedAt == null) return pw.SizedBox(); + return pw.Column( + children: [ + pw.SizedBox(height: 24), + pw.Divider(color: PdfColors.grey300), + pw.SizedBox(height: 8), + if (createdAt != null) + _pdfText( + hasCJK ? '创建时间: $createdAt' : 'Created: $createdAt', + fontSize: 9, + color: PdfColors.grey, + ), + if (updatedAt != null) + _pdfText( + hasCJK ? '更新时间: $updatedAt' : 'Updated: $updatedAt', + fontSize: 9, + color: PdfColors.grey, + ), + pw.SizedBox(height: 8), + _pdfText( + hasCJK + ? '-- 来自小妈厨房 Cute Kitchen --' + : '-- Powered by Cute Kitchen --', + fontSize: 9, + color: PdfColors.grey400, + ), + ], + ); + } + + pw.Widget _buildPdfQrCode(pw.ImageProvider qrImage, bool hasCJK) { + return pw.Column( + children: [ + pw.SizedBox(height: 24), + pw.Divider(color: PdfColors.grey300), + pw.SizedBox(height: 12), + pw.Center( + child: pw.Column( + children: [ + pw.Text( + hasCJK ? '扫码查看菜谱详情' : 'Scan to View Recipe', + style: pw.TextStyle( + fontSize: 12, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.SizedBox(height: 8), + pw.Container( + padding: const pw.EdgeInsets.all(8), + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey300, width: 1), + borderRadius: pw.BorderRadius.all(pw.Radius.circular(8)), + ), + child: pw.Image(qrImage, width: 120, height: 120), + ), + pw.SizedBox(height: 6), + _pdfText( + hasCJK ? '使用「小妈厨房」APP 扫码打开' : 'Scan with "Cute Kitchen" APP', + fontSize: 9, + color: PdfColors.grey600, + ), + _pdfText(_qrUrl, fontSize: 8, color: PdfColors.grey400), + ], + ), + ), + pw.SizedBox(height: 16), + ], + ); + } + + void _showCustomExportOptions(BuildContext context) { + bool includeCover = true; + bool includeIngredients = true; + bool includeSteps = true; + bool includeNutrition = true; + bool includeStats = true; + bool includeTags = true; + bool includeQrCode = true; + + showCupertinoModalPopup( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (context, setDialogState) { + final screenHeight = MediaQuery.of(context).size.height; + final isDark = + CupertinoTheme.brightnessOf(context) == Brightness.dark; + final bgColor = isDark + ? const Color(0xFF1C1C1E) + : CupertinoColors.systemBackground; + final separatorColor = isDark + ? const Color(0xFF38383A) + : CupertinoColors.separator; + + return Container( + constraints: BoxConstraints(maxHeight: screenHeight * 0.85), + decoration: BoxDecoration( + color: bgColor, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(14), + ), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 拖动指示器 + Container( + margin: const EdgeInsets.only(top: 8, bottom: 12), + width: 36, + height: 5, + decoration: BoxDecoration( + color: isDark + ? CupertinoColors.systemGrey + : const Color(0xFFD1D1D6), + borderRadius: BorderRadius.circular(2.5), + ), + ), + + // 标题 + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 8, + ), + child: Row( + children: [ + Icon( + CupertinoIcons.slider_horizontal_3, + size: 18, + color: CupertinoColors.activeBlue, + ), + const SizedBox(width: 8), + const Text( + '⚙️ 自定义导出', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + CupertinoButton( + padding: EdgeInsets.zero, + minSize: 30, + onPressed: () => Navigator.pop(ctx), + child: Text( + '取消', + style: TextStyle( + fontSize: 16, + color: CupertinoColors.activeBlue, + ), + ), + ), + ], + ), + ), + + Container(height: 1, color: separatorColor), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildSwitchRow( + setDialogState, + '📷 封面图片', + includeCover, + (v) => setDialogState(() => includeCover = v), + ), + _buildSwitchRow( + setDialogState, + '🥘 食材清单', + includeIngredients, + (v) => setDialogState(() => includeIngredients = v), + ), + _buildSwitchRow( + setDialogState, + '👨‍🍳 制作步骤', + includeSteps, + (v) => setDialogState(() => includeSteps = v), + ), + _buildSwitchRow( + setDialogState, + '📊 营养成分', + includeNutrition, + (v) => setDialogState(() => includeNutrition = v), + ), + _buildSwitchRow( + setDialogState, + '📈 数据统计', + includeStats, + (v) => setDialogState(() => includeStats = v), + ), + _buildSwitchRow( + setDialogState, + '🏷️ 标签信息', + includeTags, + (v) => setDialogState(() => includeTags = v), + ), + _buildSwitchRow( + setDialogState, + '🔲 二维码', + includeQrCode, + (v) => setDialogState(() => includeQrCode = v), + ), + const SizedBox(height: 8), + ], + ), + ), + ), + + Container(height: 1, color: separatorColor), + + // 导出按钮 + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + Expanded( + child: CupertinoButton.filled( + padding: const EdgeInsets.symmetric(vertical: 12), + borderRadius: BorderRadius.circular(10), + onPressed: () { + Navigator.pop(ctx); + _customExportWord( + includeCover: includeCover, + includeIngredients: includeIngredients, + includeSteps: includeSteps, + includeNutrition: includeNutrition, + includeStats: includeStats, + includeTags: includeTags, + includeQrCode: includeQrCode, + ); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.doc_text, size: 18), + SizedBox(width: 6), + Text( + '导出Word', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + ], + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: CupertinoButton.filled( + padding: const EdgeInsets.symmetric(vertical: 12), + borderRadius: BorderRadius.circular(10), + onPressed: () { + Navigator.pop(ctx); + _customExportPdf( + includeCover: includeCover, + includeIngredients: includeIngredients, + includeSteps: includeSteps, + includeNutrition: includeNutrition, + includeStats: includeStats, + includeTags: includeTags, + includeQrCode: includeQrCode, + ); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.doc_richtext, size: 18), + SizedBox(width: 6), + Text( + '导出PDF', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ), ); } @@ -543,25 +1612,189 @@ class RecipeExportButton extends StatelessWidget { ); } - Future _customExport({ + Future _customExportWord({ + required bool includeCover, required bool includeIngredients, required bool includeSteps, + required bool includeNutrition, + required bool includeStats, required bool includeTags, + required bool includeQrCode, }) async { try { - ToastService.show(message: '正在自定义导出…'); + ToastService.show(message: '正在生成自定义 Word 文档…'); final doc = gee.Document(title: '${_title}_自定义版', author: '小妈厨房'); - _buildRecipeContent( - doc, - includeIngredients: includeIngredients, - includeSteps: includeSteps, - includeTags: includeTags, - ); + + doc.addParagraph(gee.Paragraph.heading(_title, level: 1)); + + if (_categoryName != null && _categoryName!.isNotEmpty) { + doc.addParagraph(gee.Paragraph.text('【分类】$_categoryName')); + } + + final authorName = _displayAuthor; + if (authorName.isNotEmpty) { + doc.addParagraph(gee.Paragraph.text('【作者】$authorName')); + } + + if (_displayIntro != null && _displayIntro!.isNotEmpty) { + doc.addParagraph(gee.Paragraph.quote('【简介】$_displayIntro')); + } + + _addMetaInfo(doc); + + if (includeNutrition) _addNutritionInfo(doc); + if (includeStats) _addStatisticsInfo(doc); + _addAllergenWarning(doc); + + if (includeIngredients && _ingredients.isNotEmpty) { + doc.addParagraph(gee.Paragraph.heading('【食材清单】', level: 2)); + doc.addTable(gee.DocxTable(rows: _buildIngredientRows())); + } + + if (includeSteps) { + final steps = _parseSteps(); + if (steps.isNotEmpty) { + doc.addParagraph(gee.Paragraph.heading('【制作步骤】', level: 2)); + for (final step in steps) { + doc.addParagraph(gee.Paragraph.numberedItem(step)); + } + } + } + + if (includeTags && _tags.isNotEmpty) { + final tagText = _joinTagNames(); + if (tagText.isNotEmpty) { + doc.addParagraph(gee.Paragraph.heading('【标签】', level: 2)); + doc.addParagraph(gee.Paragraph.bulletItem(tagText)); + } + } + + _addFooterInfo(doc); + + if (includeQrCode) { + final qrBytes = await _generateQrCodeImage(size: 180); + _addQrCodeSection(doc, qrBytes); + } + final bytes = gee.DocxGenerator().generate(doc); await _saveAndMaybeShare(bytes, '${_title}_自定义', 'docx'); - ToastService.show(message: '自定义导出完成 ✅', type: ToastType.success); + ToastService.show(message: 'Word 导出完成 ✅', type: ToastType.success); } catch (e) { - debugPrint('自定义导出失败: $e'); + debugPrint('自定义Word导出失败: $e'); + ToastService.show(message: '导出失败: $e', type: ToastType.error); + } + } + + Future _customExportPdf({ + required bool includeCover, + required bool includeIngredients, + required bool includeSteps, + required bool includeNutrition, + required bool includeStats, + required bool includeTags, + required bool includeQrCode, + }) async { + try { + ToastService.show(message: '正在生成自定义 PDF…'); + + final fontBytes = await _loadCJKFont( + 'assets/fonts/NotoSansSC-Regular.ttf', + ); + final boldFontBytes = await _loadCJKFont( + 'assets/fonts/NotoSansSC-Bold.ttf', + ); + final imgBytes = includeCover ? await _getCachedCoverImage() : null; + + pw.Font? ttfFont; + pw.Font? ttfBold; + bool hasCJK = false; + + if (fontBytes != null) { + try { + ttfFont = pw.Font.ttf(fontBytes.buffer.asByteData()); + hasCJK = true; + } catch (e) { + debugPrint('TTF解析失败: $e'); + } + } + if (boldFontBytes != null) { + try { + ttfBold = pw.Font.ttf(boldFontBytes.buffer.asByteData()); + } catch (e) { + debugPrint('Bold TTF失败: $e'); + } + } + + final coverImage = imgBytes != null ? pw.MemoryImage(imgBytes) : null; + final qrBytes = includeQrCode + ? await _generateQrCodeImage(size: 180) + : null; + final qrImage = qrBytes != null ? pw.MemoryImage(qrBytes) : null; + + final pdf = pw.Document( + theme: pw.ThemeData.withFont( + base: ttfFont ?? pw.Font.helvetica(), + bold: ttfBold ?? pw.Font.helveticaBold(), + ), + ); + + pdf.addPage( + pw.MultiPage( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(48), + header: (context) { + if (context.pageNumber == 1 && coverImage != null) { + return pw.Center( + child: pw.Image(coverImage, width: 380, height: 280), + ); + } + return pw.SizedBox(); + }, + footer: (context) => pw.Align( + alignment: pw.Alignment.centerRight, + child: pw.Text( + hasCJK + ? '小妈厨房 · 第 ${context.pageNumber} 页' + : 'Cute Kitchen · Page ${context.pageNumber}', + style: const pw.TextStyle(fontSize: 9, color: PdfColors.grey), + ), + ), + build: (context) { + final authorName = _displayAuthor; + + return [ + _buildPdfTitle(ttfBold, hasCJK), + ..._buildPdfMetaInfo(hasCJK), + if (_categoryName != null && _categoryName!.isNotEmpty) + _pdfText( + hasCJK ? '【分类】$_categoryName' : 'Category: $_categoryName', + fontSize: 11, + ), + if (authorName.isNotEmpty) + _pdfText( + hasCJK ? '【作者】$authorName' : 'Author: $authorName', + fontSize: 11, + ), + pw.SizedBox(height: 16), + + if (includeIngredients) ..._buildPdfIngredients(hasCJK), + if (includeSteps) ..._buildPdfSteps(hasCJK), + if (includeNutrition) ..._buildPdfNutrition(hasCJK), + if (includeStats) ..._buildPdfStatistics(hasCJK), + ..._buildPdfAllergens(hasCJK), + if (includeTags) ..._buildPdfTags(hasCJK), + _buildPdfFooterInfo(hasCJK), + if (qrImage != null) _buildPdfQrCode(qrImage, hasCJK), + ]; + }, + ), + ); + + final bytes = await pdf.save(); + await _saveAndMaybeShare(bytes, '${_title}_自定义', 'pdf'); + ToastService.show(message: 'PDF 导出完成 ✅', type: ToastType.success); + } catch (e) { + debugPrint('自定义PDF导出失败: $e'); ToastService.show(message: '导出失败: $e', type: ToastType.error); } } diff --git a/ohos/AppScope/app.json5 b/ohos/AppScope/app.json5 index 3080fe7..771fb48 100644 --- a/ohos/AppScope/app.json5 +++ b/ohos/AppScope/app.json5 @@ -4,7 +4,7 @@ "vendor": "微风暴", "versionCode": 26042001, "versionName": "1.1.0", - "icon": "$media:app_icon", + "icon": "$media:layered_image", "label": "$string:app_name" } } diff --git a/ohos/AppScope/resources/base/media/app_icon.png b/ohos/AppScope/resources/base/media/app_icon.png index aa376f1..51213ab 100644 Binary files a/ohos/AppScope/resources/base/media/app_icon.png and b/ohos/AppScope/resources/base/media/app_icon.png differ diff --git a/ohos/AppScope/resources/base/media/background_icon.png b/ohos/AppScope/resources/base/media/background_icon.png new file mode 100644 index 0000000..b56b512 Binary files /dev/null and b/ohos/AppScope/resources/base/media/background_icon.png differ diff --git a/ohos/AppScope/resources/base/media/foreground_icon.png b/ohos/AppScope/resources/base/media/foreground_icon.png new file mode 100644 index 0000000..0915570 Binary files /dev/null and b/ohos/AppScope/resources/base/media/foreground_icon.png differ diff --git a/ohos/AppScope/resources/base/media/layered_image.json b/ohos/AppScope/resources/base/media/layered_image.json new file mode 100644 index 0000000..5f88771 --- /dev/null +++ b/ohos/AppScope/resources/base/media/layered_image.json @@ -0,0 +1 @@ +{"layered-image": {"background": "$media:background_icon", "foreground": "$media:foreground_icon"}} \ No newline at end of file diff --git a/ohos/entry/src/main/module.json5 b/ohos/entry/src/main/module.json5 index f5a6b5d..565bc9d 100644 --- a/ohos/entry/src/main/module.json5 +++ b/ohos/entry/src/main/module.json5 @@ -18,7 +18,7 @@ "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "description": "$string:EntryAbility_desc", - "icon": "$media:icon", + "icon": "$media:layered_image", "label": "$string:EntryAbility_label", "startWindowIcon": "$media:icon", "startWindowBackground": "$color:start_window_background", diff --git a/ohos/entry/src/main/resources/base/media/background_icon.png b/ohos/entry/src/main/resources/base/media/background_icon.png new file mode 100644 index 0000000..b56b512 Binary files /dev/null and b/ohos/entry/src/main/resources/base/media/background_icon.png differ diff --git a/ohos/entry/src/main/resources/base/media/foreground_icon.png b/ohos/entry/src/main/resources/base/media/foreground_icon.png new file mode 100644 index 0000000..0915570 Binary files /dev/null and b/ohos/entry/src/main/resources/base/media/foreground_icon.png differ diff --git a/ohos/entry/src/main/resources/base/media/icon.png b/ohos/entry/src/main/resources/base/media/icon.png index aa376f1..51213ab 100644 Binary files a/ohos/entry/src/main/resources/base/media/icon.png and b/ohos/entry/src/main/resources/base/media/icon.png differ diff --git a/ohos/entry/src/main/resources/base/media/layered_image.json b/ohos/entry/src/main/resources/base/media/layered_image.json new file mode 100644 index 0000000..5f88771 --- /dev/null +++ b/ohos/entry/src/main/resources/base/media/layered_image.json @@ -0,0 +1 @@ +{"layered-image": {"background": "$media:background_icon", "foreground": "$media:foreground_icon"}} \ No newline at end of file diff --git a/packages/Flutter包鸿蒙适配通用指南.md b/packages/Flutter包鸿蒙适配通用指南.md new file mode 100644 index 0000000..7857ad1 --- /dev/null +++ b/packages/Flutter包鸿蒙适配通用指南.md @@ -0,0 +1,239 @@ +# Flutter 包鸿蒙适配指南 + +> 文档创建: 2026-04-25 | 最后更新: 2026-04-25 +> 适用对象: 所有需要在 HarmonyOS 上使用 Flutter 第三方包的开发者 +> 核心原则: **纯Dart包零适配,原生插件需ets实现** + +--- + +## 一、核心结论(先看这个) + +| 包类型 | 需要做什么 | ohos目录 | +|--------|-----------|---------| +| 🟢 **纯 Dart 包** | 仅改版本号后缀 `-ohos.1` | ❌ **禁止创建!** | +| 🔴 **原生插件** | 写 ets 原生代码 + DevEco Studio 打 har | ✅ 必须有 | + +> ⛔ **最重要的一条规则**: 纯 Dart 包绝对不能创建 `ohos/` 目录! +> Flutter 引擎启动时会扫描所有 ohos 模块并 copyResource, +> 空壳模块引用不存在的 `libs/flutter.har` → 直接崩溃(错误码 9001005)。 + +--- + +## 二、如何判断包类型 + +### 2.1 快速判断 + +``` +打开包根目录,检查以下三项: + +├── 是否有 android/ 或 ios/ 目录? +│ ├── 无 → 🟢 纯Dart包 → 跳到第三章 +│ └── 有 → 继续判断 +│ +├── pubspec.yaml 中是否有 flutter.plugin 配置? +│ ├── 无 → 🟢 纯Dart包(有平台目录但无插件声明)→ 跳到第三章 +│ └── 有 → 🔴 原生插件 → 跳到第四章 +│ +└── 代码中是否使用了 MethodChannel / EventChannel / FFI? + ├── 无 → 🟢 纯Dart包 → 跳到第三章 + └── 有 → 🔴 原生插件 → 跳到第四章 +``` + +### 2.2 判断对照表 + +| 检查项 | 🟢 纯 Dart 包 | 🔴 原生插件 | +|--------|-------------|-----------| +| `android/` 目录 | ❌ 无 | ✅ 有 | +| `ios/` 目录 | ❌ 无 | ✅ 有 | +| `flutter.plugin` 声明 | ❌ 无 | ✅ 有 | +| MethodChannel 使用 | ❌ 无 | ✅ 有 | +| EventChannel 使用 | ❌ 无 | ✅ 有 | +| FFI (dart:ffi) 使用 | ❌ 无 | ✅ 有 | +| 典型例子 | fl_chart, badges, qr, mailer | mobile_scanner, file_picker, camera | + +### 2.3 常见纯 Dart 包类别 + +这些类型的包 **100% 是纯 Dart**,无需任何原生适配: + +| 类别 | 代表包 | 说明 | +|------|--------|------| +| UI 组件 | fl_chart, badges, card_swiper | 纯 Widget 渲染 | +| 布局组件 | staggered_grid_view | 纯布局算法 | +| 图片处理 | cached_network_image | 网络+缓存(条件导入) | +| 文本渲染 | flutter_markdown | Markdown 解析+渲染 | +| 编码工具 | qr (二维码生成) | 纯算法实现 | +| 网络协议 | mailer (SMTP) | Socket 通信 | +| 文档生成 | docs_gee (DOCX/PDF) | 字节流组装 | + +--- + +## 三、纯 Dart 包适配步骤(3步搞定) + +### 步骤 1: 拉取源码 + +```bash +cd packages +git clone --depth 1 --branch <版本号> <包名> +``` + +### 步骤 2: 修改版本号 + +编辑 `pubspec.yaml`: + +```yaml +# 修改前 +version: 1.2.0 + +# 修改后(添加 -ohos.1 后缀标识已适配) +version: 1.2.0-ohos.1 +``` + +### 步骤 3: 项目引用 + +编辑项目根目录的 `pubspec.yaml`: + +```yaml +dependencies: + <包名>: + path: packages/<包名> +``` + +然后执行: + +```bash +flutter pub get && flutter analyze --no-pub +``` + +### ✅ 完成! + +> 就这么简单。不需要创建任何 ohos 文件,不需要写 Plugin.ets,不需要配置 module.json5。 +> Flutter 的 AOT 编译器会将纯 Dart 代码直接编译为各平台的机器码。 + +--- + +## 四、原生插件适配流程 + +> 详细步骤请参考 [ohos平台适配flutter三方库指导.md](./ohos平台适配flutter三方库指导.md) + +### 4.1 概览流程 + +``` +1. flutter create --platforms ohos _ohos # 创建ohos模块骨架 +2. 复制 android 版 lib/dart 代码,将 android 改为 ohos +3. DevEco Studio 编写 ets 原生代码(参考 android/ios 实现) +4. pubspec.yaml 添加 flutter.plugin.platforms.ohos 声明 +5. DevEco Studio → Build → Make Module 打 .har 包 +6. 确保 libs/flutter.har 存在(从其他已适配插件复制) +7. flutter create --platforms ohos example 创建验证工程 +8. flutter run -d 验证功能正常 +``` + +### 4.2 关键文件清单 + +``` +/ohos/ +├── Index.ets # 插件入口,export Plugin 类 +├── oh-package.json5 # 依赖声明,必须引用 libs/flutter.har +├── build-profile.json5 # 构建配置 +├── libs/ +│ └── flutter.har # ⚠️ 必须存在!Flutter引擎库 +└── src/main/ + ├── module.json5 # 模块声明,type: "har" + ├── resources/base/profile/ # 资源配置 + └── ets/components/plugin/ + └── Plugin.ets # 原生实现(MethodChannel Handler) +``` + +### 4.3 oh-package.json5 必要配置 + +```json +{ + "name": "", + "version": "1.0.0", + "description": "HarmonyOS implementation of ", + "main": "Index.ets", + "dependencies": { + "@ohos/flutter_ohos": "file:libs/flutter.har" + } +} +``` + +> ⚠️ `libs/flutter.har` 必须真实存在!否则编译通过但运行时闪退(9001005)。 + +--- + +## 五、⛔ 踩坑记录(血泪教训) + +### 5.1 最严重: 给纯Dart包创建了空壳 ohos 目录 + +**时间**: 2026-04-25 +**错误**: 为 9 个纯 Dart 包创建了空壳 ohos 目录(含 Index.ets、空壳 Plugin.ets 等) +**现象**: 鸿蒙端启动即闪退,无法进入任何页面 +**错误码**: `9001005` (Invalid relative path) +**堆栈**: `copyResource → startInitialization → checkLoader → setupFlutterEngine → onCreate` + +**根因分析**: +``` +Flutter 引擎启动流程: +1. 扫描项目中所有含 ohos/ 目录的模块 +2. 读取每个模块的 oh-package.json5 +3. 解析 dependencies 中的 @ohos/flutter_ohos: "file:libs/flutter.har" +4. 尝试 copyResource 将 flutter.har 复制到应用沙箱 +5. libs/flutter.har 不存在 → 9001005 崩溃 +``` + +**修复**: 删除全部 9 个纯 Dart 包的 ohos 目录 + +**预防**: +``` +在创建 ohos 目录之前,先确认: +✅ 该包是否真的有原生代码(MethodChannel/FFI)? +✅ 该包的 android/ios 目录下是否有真实的 Kotlin/Swift 实现? +如果都是 NO → 禁止创建 ohos 目录! +``` + +### 5.2 其他常见问题 + +| 问题 | 原因 | 解决方案 | +|------|------|---------| +| 编译通过但运行闪退 | ohos目录存在但 libs/flutter.har 缺失 | 从已适配插件复制 har 文件 | +| analyze 报错找不到类 | pubspec.yaml 中 pluginClass 配置错误 | 检查 dart 侧注册的类名 | +| MethodChannel 无响应 | ets 侧 handler 未正确注册 | 检查 onAttachedToEngine 中的 setMethodCallHandler | +| har 包体积过大 | example/ohos 未删除 | 删除 example 下的 ohos 目录 | + +--- + +## 六、检查清单(适配完成后逐项确认) + +### 纯 Dart 包 + +- [ ] 已修改 version 后缀为 `-ohos.1` +- [ ] **没有** ohos/ 目录 +- [ ] **没有** Index.ets / Plugin.ets / module.json5 +- [ ] `flutter pub get` 通过 +- [ ] `flutter analyze --no-pub` 通过 + +### 原生插件 + +- [ ] 已修改 version 后缀为 `-ohos.1` +- [ ] ohos/ 目录结构完整 +- [ ] `libs/flutter.har` 文件真实存在(非空文件) +- [ ] `oh-package.json5` 正确引用 flutter.har +- [ ] `module.json5` 中 type 为 `"har"` +- [ ] Index.ets 正确 export Plugin 类 +- [ ] ets 代码实现了 MethodChannel handler +- [ ] pubspec.yaml 中声明了 `flutter.plugin.platforms.ohos` +- [ ] DevEco Studio 编译 har 成功 +- [ ] example 工程可正常运行 + +--- + +## 七、参考文档 + +| 文档 | 用途 | +|------|------| +| [ohos平台适配flutter三方库指导.md](./ohos平台适配flutter三方库指导.md) | 原生插件完整适配流程(含截图和详细步骤) | +| [开发FFI plugin.md](./开发FFI plugin.md) | FFI 插件开发指南(C/C++ 原生库) | +| [OpenHarmony应用如何集成Flutter模块.md](./OpenHarmony应用如何集成Flutter模块.md) | Flutter 模块集成到鸿蒙应用的架构文档 | +| [FlutterChannel通信.md](./如何使用Flutter与OpenHarmony通信%20FlutterChannel.md) | MethodChannel / EventChannel / BasicMessageChannel API 用法 | +| [本地已适配鸿蒙的库.md](./本地已适配鸿蒙的库.md) | 本项目已适配的具体包清单和使用示例 | diff --git a/packages/fluttertoast_ohos/ohos/BuildProfile.ets b/packages/fluttertoast_ohos/ohos/BuildProfile.ets index 3a501e5..6033e79 100644 --- a/packages/fluttertoast_ohos/ohos/BuildProfile.ets +++ b/packages/fluttertoast_ohos/ohos/BuildProfile.ets @@ -2,8 +2,8 @@ * Use these variables when you tailor your ArkTS code. They must be of the const type. */ export const HAR_VERSION = '1.0.0'; -export const BUILD_MODE_NAME = 'debug'; -export const DEBUG = true; +export const BUILD_MODE_NAME = 'release'; +export const DEBUG = false; export const TARGET_NAME = 'default'; /** diff --git a/packages/fluttertoast_ohos/ohos/build/default/intermediates/merge_profile/default/module.json b/packages/fluttertoast_ohos/ohos/build/default/intermediates/merge_profile/default/module.json index d8bdcff..7b2a2cd 100644 --- a/packages/fluttertoast_ohos/ohos/build/default/intermediates/merge_profile/default/module.json +++ b/packages/fluttertoast_ohos/ohos/build/default/intermediates/merge_profile/default/module.json @@ -2,8 +2,8 @@ "app": { "bundleName": "cute.major.kitchen", "debug": true, - "versionCode": 26042402, - "versionName": "1.4.0", + "versionCode": 26042503, + "versionName": "1.4.1", "minAPIVersion": 50005017, "targetAPIVersion": 60100023, "apiReleaseType": "Release", diff --git a/packages/fluttertoast_ohos/ohos/build/release/intermediates/merge_profile/default/module.json b/packages/fluttertoast_ohos/ohos/build/release/intermediates/merge_profile/default/module.json index e3c6db7..c4e247b 100644 --- a/packages/fluttertoast_ohos/ohos/build/release/intermediates/merge_profile/default/module.json +++ b/packages/fluttertoast_ohos/ohos/build/release/intermediates/merge_profile/default/module.json @@ -2,8 +2,8 @@ "app": { "bundleName": "cute.major.kitchen", "debug": false, - "versionCode": 26042402, - "versionName": "1.4.0", + "versionCode": 26042503, + "versionName": "1.4.1", "minAPIVersion": 50001013, "targetAPIVersion": 60100023, "apiReleaseType": "Release", diff --git a/packages/fluttertoast_ohos/ohos/oh-package-lock.json5 b/packages/fluttertoast_ohos/ohos/oh-package-lock.json5 index 8b40a53..85fef06 100644 --- a/packages/fluttertoast_ohos/ohos/oh-package-lock.json5 +++ b/packages/fluttertoast_ohos/ohos/oh-package-lock.json5 @@ -6,20 +6,27 @@ "lockfileVersion": 3, "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { - "@ohos/flutter_ohos@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/flutter_embedding_debug.har": "@ohos/flutter_ohos@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/flutter_embedding_debug.har", - "flutter_native_arm64_v8a@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/arm64_v8a_debug.har": "flutter_native_arm64_v8a@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/arm64_v8a_debug.har" + "@ohos/flutter_ohos@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64-release/flutter_embedding_release.har": "@ohos/flutter_ohos@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64-release/flutter_embedding_release.har", + "flutter_native_arm64_v8a@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64-release/arm64_v8a_release.har": "flutter_native_arm64_v8a@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64-release/arm64_v8a_release.har", + "flutter_native_x86_64@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-x64-release/x86_64_release.har": "flutter_native_x86_64@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-x64-release/x86_64_release.har" }, "packages": { - "@ohos/flutter_ohos@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/flutter_embedding_debug.har": { + "@ohos/flutter_ohos@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64-release/flutter_embedding_release.har": { "name": "@ohos/flutter_ohos", "version": "1.0.0-e34a685f4b", - "resolved": "../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/flutter_embedding_debug.har", + "resolved": "../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64-release/flutter_embedding_release.har", "registryType": "local" }, - "flutter_native_arm64_v8a@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/arm64_v8a_debug.har": { + "flutter_native_arm64_v8a@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64-release/arm64_v8a_release.har": { "name": "flutter_native_arm64_v8a", "version": "1.0.0-e34a685f4b", - "resolved": "../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/arm64_v8a_debug.har", + "resolved": "../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64-release/arm64_v8a_release.har", + "registryType": "local" + }, + "flutter_native_x86_64@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-x64-release/x86_64_release.har": { + "name": "flutter_native_x86_64", + "version": "1.0.0-e34a685f4b", + "resolved": "../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-x64-release/x86_64_release.har", "registryType": "local" } } diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/BuildProfile.ets b/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/BuildProfile.ets index 568cf85..e599d28 100644 --- a/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/BuildProfile.ets +++ b/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/BuildProfile.ets @@ -2,8 +2,8 @@ * Use these variables when you tailor your ArkTS code. They must be of the const type. */ export const HAR_VERSION = '1.0.0-e34a685f4b'; -export const BUILD_MODE_NAME = 'debug'; -export const DEBUG = true; +export const BUILD_MODE_NAME = 'release'; +export const DEBUG = false; export const TARGET_NAME = 'default'; /** diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/obfuscation.txt b/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/obfuscation.txt new file mode 100644 index 0000000..08f59e3 --- /dev/null +++ b/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/obfuscation.txt @@ -0,0 +1,3 @@ +-keep-property-name +flutter +native* diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/oh-package.json5 b/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/oh-package.json5 index 5332e9c..bf91be6 100644 --- a/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/oh-package.json5 +++ b/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/oh-package.json5 @@ -1 +1 @@ -{"license":"Apache-2.0","author":"","name":"@ohos/flutter_ohos","description":"The embedder of flutter in ohos.","main":"index.ets","version":"1.0.0-e34a685f4b","dependencies":{},"devDependencies":{"@types/libflutter.so":"file:./src/main/cpp/types/libflutter"},"metadata":{"workers":["./src/main/ets/embedding/engine/workers/PlatformChannelWorker.ets"],"sourceRoots":["./src/main"],"debug":true},"compatibleSdkVersionStage":"beta1","compatibleSdkVersion":12,"compatibleSdkType":"HarmonyOS","obfuscated":false} +{"license":"Apache-2.0","author":"","name":"@ohos/flutter_ohos","description":"The embedder of flutter in ohos.","main":"index.ets","version":"1.0.0-e34a685f4b","dependencies":{},"devDependencies":{"@types/libflutter.so":"file:./src/main/cpp/types/libflutter"},"metadata":{"workers":["./src/main/ets/embedding/engine/workers/PlatformChannelWorker.ets"],"sourceRoots":["./src/main"],"debug":false},"compatibleSdkVersionStage":"beta1","compatibleSdkVersion":12,"compatibleSdkType":"HarmonyOS","obfuscated":false} diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/src/main/module.json b/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/src/main/module.json index f56b879..a3a1dfa 100644 --- a/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/src/main/module.json +++ b/packages/fluttertoast_ohos/ohos/oh_modules/@ohos/flutter_ohos/src/main/module.json @@ -1,7 +1,7 @@ { "app": { "bundleName": "com.example.config", - "debug": true, + "debug": false, "versionCode": 1000000, "versionName": "1.0.0", "minAPIVersion": 50000012, @@ -13,7 +13,7 @@ "compileSdkType": "HarmonyOS", "appEnvironments": [], "bundleType": "app", - "buildMode": "debug" + "buildMode": "release" }, "module": { "name": "flutter", diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/BuildProfile.ets b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/BuildProfile.ets index 568cf85..e599d28 100644 --- a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/BuildProfile.ets +++ b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/BuildProfile.ets @@ -2,8 +2,8 @@ * Use these variables when you tailor your ArkTS code. They must be of the const type. */ export const HAR_VERSION = '1.0.0-e34a685f4b'; -export const BUILD_MODE_NAME = 'debug'; -export const DEBUG = true; +export const BUILD_MODE_NAME = 'release'; +export const DEBUG = false; export const TARGET_NAME = 'default'; /** diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/libs/arm64-v8a/libflutter.so b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/libs/arm64-v8a/libflutter.so index d89413c..901632c 100644 Binary files a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/libs/arm64-v8a/libflutter.so and b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/libs/arm64-v8a/libflutter.so differ diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/obfuscation.txt b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/obfuscation.txt new file mode 100644 index 0000000..e69de29 diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/oh-package.json5 b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/oh-package.json5 index a97ed01..739d5e1 100644 --- a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/oh-package.json5 +++ b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/oh-package.json5 @@ -1 +1 @@ -{"name":"flutter_native_arm64_v8a","version":"1.0.0-e34a685f4b","description":"Place so files for flutter on ohos.","main":"Index.ets","author":"","license":"Apache-2.0","dependencies":{},"metadata":{"sourceRoots":["./src/main"],"debug":true,"nativeDebugSymbol":false},"compatibleSdkVersionStage":"beta1","compatibleSdkVersion":12,"compatibleSdkType":"HarmonyOS","obfuscated":false} +{"name":"flutter_native_arm64_v8a","version":"1.0.0-e34a685f4b","description":"Place so files for flutter on ohos.","main":"Index.ets","author":"","license":"Apache-2.0","dependencies":{},"metadata":{"sourceRoots":["./src/main"],"debug":false,"nativeDebugSymbol":false},"compatibleSdkVersionStage":"beta1","compatibleSdkVersion":12,"compatibleSdkType":"HarmonyOS","obfuscated":false} diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/src/main/module.json b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/src/main/module.json index 87f6295..c09f8ef 100644 --- a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/src/main/module.json +++ b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_arm64_v8a/src/main/module.json @@ -1,7 +1,7 @@ { "app": { "bundleName": "com.example.config", - "debug": true, + "debug": false, "versionCode": 1000000, "versionName": "1.0.0", "minAPIVersion": 50000012, @@ -13,7 +13,7 @@ "compileSdkType": "HarmonyOS", "appEnvironments": [], "bundleType": "app", - "buildMode": "debug" + "buildMode": "release" }, "module": { "name": "flutter_native", diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/BuildProfile.ets b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/BuildProfile.ets new file mode 100644 index 0000000..e599d28 --- /dev/null +++ b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/BuildProfile.ets @@ -0,0 +1,17 @@ +/** + * Use these variables when you tailor your ArkTS code. They must be of the const type. + */ +export const HAR_VERSION = '1.0.0-e34a685f4b'; +export const BUILD_MODE_NAME = 'release'; +export const DEBUG = false; +export const TARGET_NAME = 'default'; + +/** + * BuildProfile Class is used only for compatibility purposes. + */ +export default class BuildProfile { + static readonly HAR_VERSION = HAR_VERSION; + static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; + static readonly DEBUG = DEBUG; + static readonly TARGET_NAME = TARGET_NAME; +} \ No newline at end of file diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/Index.ets b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/Index.ets new file mode 100644 index 0000000..e69de29 diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/build-profile.json5 b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/build-profile.json5 new file mode 100644 index 0000000..e3fb899 --- /dev/null +++ b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/build-profile.json5 @@ -0,0 +1,34 @@ +{ + "apiType": "stageMode", + "buildOption": { + "nativeLib": { + "debugSymbol": { + "strip": false, + "exclude": [] + } + } + }, + "buildOptionSet": [ + { + "name": "release", + "arkOptions": { + "obfuscation": { + "ruleOptions": { + "enable": false, + "files": [ + "./obfuscation-rules.txt" + ] + }, + "consumerFiles": [ + "./consumer-rules.txt" + ] + } + }, + }, + ], + "targets": [ + { + "name": "default" + } + ] +} diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/consumer-rules.txt b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/consumer-rules.txt new file mode 100644 index 0000000..e69de29 diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/hvigorfile.ts b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/hvigorfile.ts new file mode 100644 index 0000000..4218707 --- /dev/null +++ b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/hvigorfile.ts @@ -0,0 +1,6 @@ +import { harTasks } from '@ohos/hvigor-ohos-plugin'; + +export default { + system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ + plugins:[] /* Custom plugin to extend the functionality of Hvigor. */ +} diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/libs/x86_64/libflutter.so b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/libs/x86_64/libflutter.so new file mode 100644 index 0000000..6a1ed1f Binary files /dev/null and b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/libs/x86_64/libflutter.so differ diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/obfuscation-rules.txt b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/obfuscation-rules.txt new file mode 100644 index 0000000..272efb6 --- /dev/null +++ b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/obfuscation-rules.txt @@ -0,0 +1,23 @@ +# Define project specific obfuscation rules here. +# You can include the obfuscation configuration files in the current module's build-profile.json5. +# +# For more details, see +# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5 + +# Obfuscation options: +# -disable-obfuscation: disable all obfuscations +# -enable-property-obfuscation: obfuscate the property names +# -enable-toplevel-obfuscation: obfuscate the names in the global scope +# -compact: remove unnecessary blank spaces and all line feeds +# -remove-log: remove all console.* statements +# -print-namecache: print the name cache that contains the mapping from the old names to new names +# -apply-namecache: reuse the given cache file + +# Keep options: +# -keep-property-name: specifies property names that you want to keep +# -keep-global-name: specifies names that you want to keep in the global scope + +-enable-property-obfuscation +-enable-toplevel-obfuscation +-enable-filename-obfuscation +-enable-export-obfuscation \ No newline at end of file diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/obfuscation.txt b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/obfuscation.txt new file mode 100644 index 0000000..e69de29 diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/oh-package.json5 b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/oh-package.json5 new file mode 100644 index 0000000..d375780 --- /dev/null +++ b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/oh-package.json5 @@ -0,0 +1 @@ +{"name":"flutter_native_x86_64","version":"1.0.0-e34a685f4b","description":"Place so files for flutter on ohos.","main":"Index.ets","author":"","license":"Apache-2.0","dependencies":{},"metadata":{"sourceRoots":["./src/main"],"debug":false,"nativeDebugSymbol":false},"compatibleSdkVersionStage":"beta1","compatibleSdkVersion":12,"compatibleSdkType":"HarmonyOS","obfuscated":false} diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/src/main/module.json b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/src/main/module.json new file mode 100644 index 0000000..3f478ff --- /dev/null +++ b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/src/main/module.json @@ -0,0 +1,30 @@ +{ + "app": { + "bundleName": "com.example.config", + "debug": false, + "versionCode": 1000000, + "versionName": "1.0.0", + "minAPIVersion": 50000012, + "targetAPIVersion": 60001021, + "apiReleaseType": "Release", + "targetMinorAPIVersion": 0, + "targetPatchAPIVersion": 0, + "compileSdkVersion": "6.0.1.112", + "compileSdkType": "HarmonyOS", + "appEnvironments": [], + "bundleType": "app", + "buildMode": "release" + }, + "module": { + "name": "flutter_native", + "type": "har", + "deviceTypes": [ + "default" + ], + "packageName": "flutter_native_x86_64", + "installationFree": false, + "virtualMachine": "ark", + "compileMode": "esmodule", + "dependencies": [] + } +} diff --git a/packages/本地已适配鸿蒙的库.md b/packages/本地已适配鸿蒙的库.md index d75018e..e369fa2 100644 --- a/packages/本地已适配鸿蒙的库.md +++ b/packages/本地已适配鸿蒙的库.md @@ -1,7 +1,8 @@ # 鸿蒙适配方案 -> 文档创建: 2026-04-09 | 最后更新: 2026-04-22 +> 文档创建: 2026-04-09 | 最后更新: 2026-04-25 > 适配策略: 纯 Dart 包零成本适配 + 原生插件完整适配 +> ⚠️ 重要教训: 纯Dart包禁止添加ohos目录,会导致启动闪退(9001005) --- @@ -12,22 +13,35 @@ | `android/` `ios/` 目录 | ❌ 无 | ✅ 有 | | `flutter.plugin` 声明 | ❌ 无 | ✅ 有 | | MethodChannel / FFI | ❌ 无 | ✅ 有 | -| 适配方式 | 空壳 ohos 目录 | ets 原生实现 + har 包 | -| 工作量 | 5min | 1-3 天 | +| 适配方式 | **仅改版本号,无ohos目录!** | ets 原生实现 + har 包 | +| 工作量 | 1min | 1-3 天 | ### 快速判断流程 ``` 新包是否包含原生代码? -├── 否(纯 Dart)→ ✅ 零适配:改版本号 + 空壳 ohos 目录 +├── 否(纯 Dart)→ ✅ 零适配:仅改版本号即可! +│ ⛔ 禁止创建 ohos 目录!会导致 Invalid relative path (9001005) 启动闪退 +│ Flutter引擎启动时会扫描所有 ohos 模块并 copyResource, +│ 空壳模块引用不存在的 libs/flutter.har → 直接崩溃 +│ └── 是(有原生代码) ├── MethodChannel → 需 ets 实现 + DevEco Studio 打 har └── FFI → 需编译鸿蒙版 .so + CMakeLists ``` +### ⛔ 血泪教训 (2026-04-25) + +2026-04-25 因给9个纯Dart包创建了空壳ohos目录,导致鸿蒙端持续闪退: +- 错误码 `9001005` (Invalid relative path) +- 堆栈 `copyResource → startInitialization → onCreate` +- 根因:空壳 `oh-package.json5` 引用不存在的 `libs/flutter.har` +- **修复**: 删除全部9个纯Dart包的 ohos 目录 +- **结论**: 纯Dart包 = 不需要任何ohos文件! + --- -## 二、纯 Dart 包适配步骤(通用模板) +## 二、纯 Dart 包适配步骤(正确做法) ### 2.1 拉取源码 @@ -40,66 +54,7 @@ git clone --depth 1 --branch `pubspec.yaml` 中 `version: x.x.x` → `x.x.x-ohos.1` -### 2.3 创建空壳 ohos 目录 - -``` -packages//ohos/ -├── Index.ets -├── oh-package.json5 -├── build-profile.json5 -├── libs/ # flutter.har 占位(空目录) -└── src/main/ - ├── module.json5 - ├── resources/base/profile/ # 空目录 - └── ets/components/plugin/ - └── Plugin.ets # 空壳类 -``` - -#### 通用文件模板(所有纯 Dart 包共用) - -**`Index.ets`** -```typescript -import Plugin from './src/main/ets/components/plugin/Plugin'; -export default Plugin; -``` - -**`oh-package.json5`** -```json -{ - "name": "", - "version": "-ohos.1", - "description": " - HarmonyOS adaptation", - "main": "Index.ets", - "author": "", - "license": "", - "dependencies": { - "@ohos/flutter_ohos": "file:libs/flutter.har" - } -} -``` - -**`build-profile.json5`** -```json -{ "apiType": "stageMode", "buildOption": {}, "targets": [{ "name": "default" }] } -``` - -**`module.json5`** -```json -{ - "module": { - "name": "", - "type": "har", - "deviceTypes": ["default", "tablet"] - } -} -``` - -**`Plugin.ets`** -```typescript -export default class Plugin {} -``` - -### 2.4 项目引用 & 验证 +### 2.3 项目引用 & 验证 ```yaml # pubspec.yaml @@ -112,6 +67,11 @@ dependencies: flutter pub get && flutter analyze --no-pub ``` +### ✅ 完成! + +> **就这么简单!** 纯Dart代码跨平台编译,不需要任何原生适配。 +> 不要创建 ohos/ 目录、不要写 Plugin.ets、不要配置 module.json5。 + --- ## 三、原生插件适配流程(参考) @@ -142,28 +102,36 @@ flutter pub get && flutter analyze --no-pub --- -## 五、已适配纯 Dart 包清单 +## 五、已适配包清单 ### 5.1 总览表 -| # | 包名 | 原版本 | 适配版本 | 日期 | 状态 | -|---|------|--------|---------|------|------| -| 1 | fl_chart | 1.2.0 | 1.2.0-ohos.1 | 2026-04-09 | ✅ Android / HarmonyOS | -| 2 | badges | 3.2.0 | 3.2.0-ohos.1 | 2026-04-10 | ✅ analyze 通过 | -| 3 | flutter_staggered_grid_view | 0.7.0 | 0.7.0-ohos.1 | 2026-04-12 | ✅ analyze 通过 | -| 4 | cached_network_image | 3.4.1 | 3.4.1-ohos.1 | 2026-04-12 | ✅ analyze 通过 | -| 5 | flutter_markdown_plus | 1.0.7 | 1.0.7-ohos.1 | 2026-04-14 | ✅ analyze 通过 | -| 6 | flutter_card_swiper | 7.2.0 | 7.2.0-ohos.1 | 2026-04-14 | ✅ analyze 通过 | -| 7 | qr | 3.0.2 | 3.0.2-ohos.1 | 2026-04-19 | ✅ pub get 通过 | -| 8 | mailer | 7.1.0 | 7.1.0-ohos.1 | 2026-04-19 | ✅ pub get 通过 | -| 9 | mobile_scanner | 7.2.0 | 7.2.0+ohos | 2026-04-22 | ✅ 原生插件,含鸿蒙ets实现 | -| 10 | docs_gee | 1.3.2 | 1.3.2-ohos.1 | 2026-04-24 | ✅ 纯Dart,DOCX/PDF生成库 | +| # | 包名 | 类型 | 原版本 | 适配版本 | ohos目录 | 日期 | +|---|------|------|--------|---------|---------|------| +| 1 | fl_chart | 🟢 纯Dart | 1.2.0 | 1.2.0-ohos.1 | ❌ 无需 | 2026-04-09 | +| 2 | badges | 🟢 纯Dart | 3.2.0 | 3.2.0-ohos.1 | ❌ 无需 | 2026-04-10 | +| 3 | flutter_staggered_grid_view | 🟢 纯Dart | 0.7.0 | 0.7.0-ohos.1 | ❌ 无需 | 2026-04-12 | +| 4 | cached_network_image | 🟢 纯Dart | 3.4.1 | 3.4.1-ohos.1 | ❌ 无需 | 2026-04-12 | +| 5 | flutter_markdown_plus | 🟢 纯Dart | 1.0.7 | 1.0.7-ohos.1 | ❌ 无需 | 2026-04-14 | +| 6 | flutter_card_swiper | 🟢 纯Dart | 7.2.0 | 7.2.0-ohos.1 | ❌ 无需 | 2026-04-14 | +| 7 | qr | 🟢 纯Dart | 3.0.2 | 3.0.2-ohos.1 | ❌ 无需 | 2026-04-19 | +| 8 | mailer | 🟢 纯Dart | 7.1.0 | 7.1.0-ohos.1 | ❌ 无需 | 2026-04-19 | +| 9 | docs_gee | 🟢 纯Dart | 1.3.2 | 1.3.2-ohos.1 | ❌ 无需 | 2026-04-24 | +| 10 | **pdf** | 🟢 纯Dart | 3.12.0 | 3.12.0-ohos.1 | ❌ 无需 | 2026-04-25 | +| 11 | mobile_scanner | 🔴 原生插件 | 7.2.0 | 7.2.0+ohos | ✅ 有ets实现 | 2026-04-22 | +| 12 | file_picker | 🔴 原生插件 | - | 1.0.1 | ✅ 有ets实现 | 已适配 | +| 13 | fluttertoast_ohos | 🔴 原生插件 | - | 1.0.0 | ✅ 有ets实现 | 已适配 | + +> **🟢 纯Dart (10个)**: 仅改版本号,无任何ohos文件。Flutter AOT编译直接运行。 +> **🔴 原生插件 (3个)**: 含ets原生代码 + flutter.har依赖,需要DevEco Studio编译。 ### 5.2 各包克隆命令速查 ```bash cd packages +# ====== 🟢 纯Dart包(仅改版本号即可)====== + # 1. fl_chart git clone --depth 1 --branch 1.2.0 https://github.com/imaNNeo/fl_chart.git fl_chart @@ -189,13 +157,19 @@ git clone --depth 1 --branch v3.0.2 https://github.com/kevmoo/qr.dart.git qr # 8. mailer git clone --depth 1 --branch v7.1.0 https://github.com/dart-mailer/mailer.git mailer -# 9. mobile_scanner(官方v7.2.0 + 鸿蒙适配合并) -git clone --depth 1 --branch v7.2.0 https://github.com/juliansteenbakker/mobile_scanner.git mobile_scanner -# 合并鸿蒙适配:ohos/ 目录、ohos_surface_producer_delegate.dart、surface_producer_delegate.dart、rotated_preview.dart 鸿蒙旋转修复 - -# 10. docs_gee(纯Dart,DOCX/PDF文档生成库) +# 9. docs_gee(纯Dart,DOCX/PDF文档生成库) git clone --depth 1 https://github.com/erykkruk/docs_gee.git docs_gee # 实际代码在 docx_generator/ 子目录,引用路径: packages/docs_gee/docx_generator + +# 10. pdf(纯Dart,专业PDF生成库,GitHub 2k+ stars) +git clone --depth 1 https://github.com/DavBfr/dart_pdf.git pdf +# monorepo结构,实际代码在 pdf/ 子目录,引用路径: packages/pdf/pdf + +# ====== 🔴 原生插件(需要ets实现+flutter.har)====== + +# 10. mobile_scanner(官方v7.2.0 + 鸿蒙适配合并) +git clone --depth 1 --branch v7.2.0 https://github.com/juliansteenbakker/mobile_scanner.git mobile_scanner +# 合并鸿蒙适配:ohos/ 目录、CameraUtil.ets、Barcode.ets 等 ``` ### 5.3 各包使用示例 @@ -280,30 +254,61 @@ File('report.docx').writeAsBytesSync(DocxGenerator().generate(doc)); File('report.pdf').writeAsBytesSync(PdfGenerator().generate(doc)); ``` +**pdf(专业PDF生成,推荐用于PDF导出)** +```dart +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; + +final pdf = pw.Document( + theme: pw.ThemeData.withFont( + base: pw.Font.ttf(fontBytes.buffer.asByteData()), + bold: pw.Font.ttf(boldFontBytes.buffer.asByteData()), + ), +); + +pdf.addPage(pw.MultiPage( + pageFormat: PdfPageFormat.a4, + build: (context) => [ + pw.Text('标题', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)), + pw.SizedBox(height: 16), + pw.Text('正文内容'), + pw.TableHelper.fromTextArray( + headers: ['列1', '列2'], + data: [['数据1', '数据2']], + ), + ], +)); + +final bytes = await pdf.save(); +File('report.pdf').writeAsBytesSync(bytes); +``` + --- ## 六、项目依赖兼容性总览 -| 依赖 | 来源 | Web | 鸿蒙 | 备注 | -|------|------|-----|------|------| -| fl_chart | 本地 path | ✅ | ✅ | 纯 Dart | -| badges | 本地 path | ✅ | ✅ | 纯 Dart | -| flutter_staggered_grid_view | 本地 path | ✅ | ✅ | 纯 Dart | -| cached_network_image | 本地 path | ✅ | ✅ | 条件导入,IO 分支 | -| flutter_markdown_plus | 本地 path | ✅ | ✅ | 纯 Dart,全平台支持 | -| flutter_card_swiper | 本地 path | ✅ | ✅ | 纯 Dart | -| qr | 本地 path | ✅ | ✅ | 纯 Dart,QR码生成 | -| mailer | 本地 path | ❌ | ✅ | 纯 Dart,SMTP客户端,Web不支持 | -| mobile_scanner | 本地 path | ✅ | ✅ | 原生插件,扫码/二维码,v7.2.0+鸿蒙适配 | -| docs_gee | 本地 path | ⚠️ | ✅ | 纯Dart,DOCX/PDF生成,Web需处理文件保存 | -| hive_ce | pub.dev | ✅ | ✅ | 纯 Dart | -| get / dio / logger / intl | pub.dev | ✅ | ✅ | 纯 Dart | -| shared_preferences | pub.dev | ✅ | ✅ | localStorage | -| path_provider | git(鸿蒙版) | ⚠️ | ✅ | 需验证 web | -| connectivity_plus | git(鸿蒙版) | ⚠️ | ✅ | 需验证 web | -| share_plus | git(鸿蒙版) | ⚠️ | ✅ | 需验证 web | -| permission_handler | git(鸿蒙版) | ❌ | ✅ | Web 不支持 | -| fluttertoast | 本地 path | ⚠️ | ✅ | 需验证 web | +| 依赖 | 来源 | Web | 鸿蒙 | 类型 | 备注 | +|------|------|-----|------|------|------| +| fl_chart | 本地 path | ✅ | ✅ | 🟢纯Dart | 图表 | +| badges | 本地 path | ✅ | ✅ | 🟢纯Dart | 徽标 | +| flutter_staggered_grid_view | 本地 path | ✅ | ✅ | 🟢纯Dart | 瀑布流 | +| cached_network_image | 本地 path | ✅ | ✅ | 🟢纯Dart | 图片缓存 | +| flutter_markdown_plus | 本地 path | ✅ | ✅ | 🟢纯Dart | Markdown | +| flutter_card_swiper | 本地 path | ✅ | ✅ | 🟢纯Dart | 卡片滑动 | +| qr | 本地 path | ✅ | ✅ | 🟢纯Dart | QR码生成 | +| mailer | 本地 path | ❌ | ✅ | 🟢纯Dart | SMTP发邮件 | +| docs_gee | 本地 path | ⚠️ | ✅ | 🟢纯Dart | DOCX/PDF生成 | +| **pdf** | 本地 path | ✅ | ✅ | 🟢纯Dart | 专业PDF生成(推荐) | +| mobile_scanner | 本地 path | ✅ | ✅ | 🔴原生插件 | 扫码 | +| file_picker | 本地 path | ✅ | ✅ | 🔴原生插件 | 文件选择 | +| fluttertoast_ohos | 本地 path | ⚠️ | ✅ | 🔴原生插件 | Toast提示 | +| hive_ce | pub.dev | ✅ | ✅ | 🟢纯Dart | 数据库 | +| get / dio / logger / intl | pub.dev | ✅ | ✅ | 🟢纯Dart | 工具 | +| shared_preferences | pub.dev | ✅ | ✅ | 🟢纯Dart | 存储 | +| path_provider | git(鸿蒙版) | ⚠️ | ✅ | 🔴原生插件 | 路径 | +| connectivity_plus | git(鸿蒙版) | ⚠️ | ✅ | 🔴原生插件 | 网络 | +| share_plus | git(鸿蒙版) | ⚠️ | ✅ | 🔴原生插件 | 分享 | +| permission_handler | git(鸿蒙版) | ❌ | ✅ | 🔴原生插件 | 权限 | --- @@ -311,6 +316,7 @@ File('report.pdf').writeAsBytesSync(PdfGenerator().generate(doc)); | 文档 | 用途 | |------|------| +| [**Flutter包鸿蒙适配通用指南.md**](./Flutter包鸿蒙适配通用指南.md) | **👈 通用方法论:判断包类型、适配步骤、踩坑记录、检查清单(给其他人看)** | | [ohos平台适配flutter三方库指导.md](./ohos平台适配flutter三方库指导.md) | 原生插件完整适配流程(MethodChannel、打 har 包) | | [开发FFI plugin.md](./开发FFI plugin.md) | FFI 插件开发指南 | | [OpenHarmony应用如何集成Flutter模块.md](./OpenHarmony应用如何集成Flutter模块.md) | Flutter 模块集成到鸿蒙应用 | diff --git a/packages/鸿蒙分层图标配置指南.md b/packages/鸿蒙分层图标配置指南.md new file mode 100644 index 0000000..631ce0a --- /dev/null +++ b/packages/鸿蒙分层图标配置指南.md @@ -0,0 +1,326 @@ +# 鸿蒙(HarmonyOS) 分层图标配置指南 + +> **适用场景**: 华为应用审核要求配置分层图标(前景图 + 后景图),标准尺寸 1024×1024px +> **更新时间**: 2026-04-25 +> **关联脚本**: [gen_hmos_icons.py](../scripts/gen_hmos_icons.py) + +--- + +## 一、为什么需要分层图标? + +华为应用上架审核会检查以下警告: + +``` +为了提供更好的应用启动体验,请使用分层图标。 +应用未配置图标的前景图和后景图,标准要求尺寸1024px*1024px +``` + +**原因**: 鸿蒙系统支持"分层图标"机制,将图标拆分为前景层 + 背景层,系统可动态组合: +- 桌面主题切换时自动替换背景色 +- 深色/浅色模式自适应 +- 不同设备形态统一视觉 + +--- + +## 二、规范要求 + +### 2.1 图片规格 + +| 属性 | 前景图 (foreground) | 背景图 (background) | +|------|---------------------|---------------------| +| 尺寸 | **1024 × 1024 px** | **1024 × 1024 px** | +| 格式 | PNG | PNG | +| 透明度 | ✅ 支持(非核心区域透明) | ❌ **禁止透明像素** | +| 圆角 | ❌ 不要手动加圆角 | ❌ 不要手动加圆角 | +| 内容 | App 图标主体(Logo/图形) | 纯色或简单渐变 | + +### 2.2 设计要点 + +``` +┌─────────────────────┐ +│ │ ← 四角:100% 透明(系统自动裁切圆角) +│ ┌───────────┐ │ +│ │ │ │ ← 前景:保留核心图形 +│ │ APP LOGO │ │ +│ │ │ │ +│ └───────────┘ │ +│ │ +└─────────────────────┘ + +┌─────────────────────┐ +│░░░░░░░░░░░░░░░░░░░░░│ ← 背景:纯色填充,无任何透明区域 +│░░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░░│ (建议与前景主色调一致) +│░░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░░│ +└─────────────────────┘ +``` + +### 2.3 常见错误 + +| 错误 | 说明 | 正确做法 | +|------|------|---------| +| 背景含透明像素 | 审核直接拒绝 | 导出为不透明纯色 PNG | +| 手动加圆角 | 与系统裁切冲突 | 保持正方形,系统自动处理 | +| 尺寸不是 1024 | 不符合规范 | 必须精确 1024×1024 | +| 使用单层图标 | 触发警告 | 改用 foreground + background 双层 | + +--- + +## 三、文件结构 + +### 3.1 需要的文件 + +``` +ohos/ +├── AppScope/ +│ └── resources/ +│ └── base/ +│ └── media/ +│ ├── background_icon.png # 背景层(纯色) +│ ├── foreground_icon.png # 前景层(透明PNG) +│ ├── layered_image.json # ⭐ 分层配置文件 +│ └── app_icon.png # 单层备用图标(startWindowIcon 用) +│ +└── entry/ + └── src/main/resources/base/media/ + ├── background_icon.png # 同上(entry 目录副本) + ├── foreground_icon.png # 同上 + ├── layered_image.json # 同上 + └── icon.png # 启动窗口图标(实际图片,非 JSON) +``` + +### 3.2 关键:`layered_image.json` 格式 + +⚠️ **这是最容易出错的地方!必须包含外层 `"layered-image"` 键** + +```json +{ + "layered-image": { + "foreground": "$media:foreground_icon", + "background": "$media:background_icon" + } +} +``` + +**常见错误格式**(❌ 不识别): + +```json +// ❌ 缺少外层键 — 系统无法解析 +{ + "foreground": "$media:foreground_icon", + "background": "$media:background_icon" +} + +// ❌ 多余字段 — 不符合规范 +{ + "foreground": "...", + "background": "...", + "size": { "width": 1024, "height": 1024 } +} +``` + +--- + +## 四、配置引用 + +### 4.1 app.json5(全局应用图标) + +```json5 +// ohos/AppScope/app.json5 +{ + "app": { + "bundleName": "cute.major.kitchen", + "vendor": "微风暴", + "versionCode": 26042001, + "versionName": "1.1.0", + "icon": "$media:layered_image", // ← 引用分层配置 + "label": "$string:app_name" + } +} +``` + +### 4.2 module.json5(入口 Ability 图标) + +```json5 +// ohos/entry/src/main/module.json5 +{ + "module": { + "abilities": [ + { + "name": "EntryAbility", + "icon": "$media:layered_image", // ← 桌面图标:分层配置 + "label": "$string:EntryAbility_label", + "startWindowIcon": "$media:icon", // ← 启动窗口:实际 PNG 图片! + "startWindowBackground": "$color:start_window_background", + "skills": [...] + } + ] + } +} +``` + +### 4.3 字段区别(重要!) + +| 字段 | 引用类型 | 说明 | +|------|---------|------| +| `icon` | `$media:layered_image` | 桌面/应用列表图标 → **JSON 配置** | +| `startWindowIcon` | `$media:icon` | 启动闪屏图标 → **实际 PNG 图片** | + +⚠️ `startWindowIcon` 不能指向 `layered_image.json`,必须是真实图片文件! + +--- + +## 五、Python 自动生成脚本 + +### 5.1 脚本位置 + +[scripts/gen_hmos_icons.py](../scripts/gen_hmos_icons.py) + +### 5.2 功能说明 + +从源图标 `assets/icons/icon_1024x1024.png` 自动提取: + +1. **背景层** (`background_icon.png`) — 采样源图标四角颜色,生成纯色背景 +2. **前景层** (`foreground_icon.png`) — 抠去背景色,保留图标主体,添加圆角遮罩 +3. **配置文件** (`layered_image.json`) — 生成标准格式的分层配置 + +### 5.3 工作原理 + +``` +源图标 icon_1024x1024.png + │ + ▼ + ┌──────────────┐ + │ 采样背景颜色 │ → 从10个角落/边缘点取平均色 + └──────┬───────┘ + │ + ┌────┴────┐ + ▼ ▼ +┌───────┐ ┌──────────┐ +│背景层 │ │ 前景层 │ +│纯色填充│ │ 去背+圆角 │ +│ 5KB │ │ 1372KB │ +└───────┘ └──────────┘ + │ │ + └────┬────┘ + ▼ + layered_image.json + (标准格式输出) +``` + +### 5.4 运行方式 + +```bash +# 前置依赖 +pip install Pillow + +# 运行脚本 +python scripts/gen_hmos_icons.py +``` + +### 5.5 输出示例 + +``` +源图标: assets/icons/icon_1024x1024.png + 尺寸: (1024, 1024) + 背景色采样: RGB(237, 189, 109) + +--- AppScope/resources/base/media --- + background: .../background_icon.png (5.2KB) + 颜色: RGB(237, 189, 109) + foreground: .../foreground_icon.png (1371.8KB) + layered_image.json: .../layered_image.json + +--- entry/src/main/resources/base/media --- + background: .../background_icon.png (5.2KB) + foreground: .../foreground_icon.png (1371.8KB) + layered_image.json: .../layered_image.json + +完成! +``` + +### 5.6 参数调整 + +如需自定义,修改脚本顶部常量: + +```python +SOURCE_ICON = "assets/icons/icon_1024x1024.png" # 源图标路径 +OUTPUT_SIZE = 1024 # 输出尺寸 +# generate_foreground() 中: +# tolerance=50 # 背景色容差(越小越严格抠图) +# radius=int(OUTPUT_SIZE * 0.22) # 圆角半径(0.22 ≈ 华为默认圆角比例) +``` + +--- + +## 六、手动制作流程(备选方案) + +如果不想用脚本,可以手动在 Figma / Photoshop / Sketch 中操作: + +### 步骤 1: 制作背景图 +1. 新建 1024×1024 画布 +2. 填充纯色(取自原图主色调) +3. 导出为 `background_icon.png`(确保无透明) + +### 步骤 2: 制作前景图 +1. 打开原图 1024×1024 +2. 删除/隐藏背景图层,只保留图形主体 +3. **不要加圆角** +4. 导出为 `foreground_icon.png`(保留透明通道) + +### 步骤 3: 配置 JSON +创建 `layered_image.json`(见第三章 3.2) + +### 步骤 4: 放置文件 +按第三章 3.1 结构放入对应目录 + +--- + +## 七、验证清单 + +部署前逐项检查: + +- [ ] `background_icon.png` 存在于两个 media 目录 +- [ ] `foreground_icon.png` 存在于两个 media 目录 +- [ ] `layered_image.json` 格式正确(有 `"layered-image"` 外层键) +- [ ] `app.json5` 的 `"icon"` 指向 `$media:layered_image` +- [ ] `module.json5` 的 `"icon"` 指向 `$media:layered_image` +- [ ] `module.json5` 的 `"startWindowIcon"` 指向 `$media:icon`(实际图片) +- [ ] 两张图片均为 **1024×1024** PNG +- [ ] 背景图**不含任何透明像素** +- [ ] 前景图**没有手动圆角** + +--- + +## 八、常见问题排查 + +### Q1: 配置后仍有警告? + +**检查优先级**: `module.json5` 的 icon 会覆盖 `app.json5`。如果 module.json5 配了旧的单层图标引用,全局配置不会生效。 + +**解决**: 确保 `module.json5` 中 abilities 的 `icon` 也改为 `$media:layered_image`。 + +### Q2: 启动时图标空白? + +**原因**: `startWindowIcon` 指向了 `layered_image.json`(这是 JSON 不是图片)。 + +**解决**: `startWindowIcon` 必须指向实际 PNG 文件,如 `$media:icon`。 + +### Q3: DevEco Studio 版本要求? + +华为要求 **DevEco Studio ≥ 5.0.5.315** 进行图标处理。低版本升级后可能有残留配置,需清理后重新打包。 + +### Q4: 图标显示模糊/有锯齿? + +- 确保前景图透明区域是 **100% 透明**(alpha=0),不是半透明 +- 使用标准 PNG 格式,避免非标准转换导致失真 +- 源图分辨率至少 1024×1024 + +--- + +## 九、参考链接 + +- [华为官方:配置应用图标和名称](https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/layered-image) +- [OpenHarmony 文档:分层图标](https://github.com/openharmony/docs/blob/master/zh-cn/application-dev/quick-start/layered-image.md) +- [华为开发者社区:分层图标 FAQ](https://developer.huawei.com/consumer/cn/forum/topic/0203201129389706916) diff --git a/pubspec.lock b/pubspec.lock index 53697aa..8cfa346 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,22 @@ packages: relative: true source: path version: "3.2.0-ohos.1" + barcode: + dependency: transitive + description: + name: barcode + sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.9" + bidi: + dependency: transitive + description: + name: bidi + sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.13" boolean_selector: dependency: transitive description: @@ -566,6 +582,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" + image: + dependency: transitive + description: + name: image + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.8.0" image_picker: dependency: transitive description: @@ -837,6 +861,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" path_provider: dependency: "direct main" description: @@ -895,6 +927,13 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.3.0" + pdf: + dependency: "direct main" + description: + path: "packages/pdf/pdf" + relative: true + source: path + version: "3.12.0-ohos.1" permission_handler: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 1a77444..31b4535 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.4.0+26042402 +version: 1.4.1+26042503 environment: sdk: ^3.9.2 @@ -159,6 +159,9 @@ dependencies: docs_gee: path: packages/docs_gee/docx_generator + pdf: + path: packages/pdf/pdf + flutter_markdown_plus: path: packages/flutter_markdown_plus @@ -188,6 +191,8 @@ dependency_overrides: path: packages/fluttertoast mailer: path: packages/mailer + qr: + path: packages/qr # For information on the generic Dart part of this file, see the @@ -209,6 +214,7 @@ flutter: - assets/md/tips/ - assets/md/tips/advanced/ - assets/md/tips/learn/ + - assets/fonts/ # 字体配置:NotoSansSC 子集化版本(GB2312 6763字 + 常用符号),从完整版 32MB 缩减至 6.5MB # 原始字体来源:https://github.com/notofonts/noto-cjk/releases diff --git a/scripts/check_font.py b/scripts/check_font.py new file mode 100644 index 0000000..f010c32 --- /dev/null +++ b/scripts/check_font.py @@ -0,0 +1,22 @@ +from fontTools.ttLib import TTFont +import os + +font_path = os.path.join(os.path.dirname(__file__), "..", "assets", "fonts", "NotoSansSC-Regular.ttf") +font = TTFont(font_path) +print("Tables:", list(font.keys())) +has_glyf = "glyf" in font +has_cff = "CFF " in font +print(f"Has glyf (TrueType): {has_glyf}") +print(f"Has CFF: {has_cff}") +if has_glyf: + print("TRUE TYPE FONT - OK for dart_pdf!") +elif has_cff: + print("CFF FONT - will FAIL with dart_pdf!") + +cmap = font.getBestCmap() +test_chars = ["小", "妈", "厨", "房", "珍", "珠", "虾", "仁"] +for c in test_chars: + cp = ord(c) + found = cp in cmap + print(f" {c} (U+{cp:04X}): {'FOUND' if found else 'MISSING'}") +font.close() diff --git a/scripts/check_ttf_integrity.py b/scripts/check_ttf_integrity.py new file mode 100644 index 0000000..f3cc798 --- /dev/null +++ b/scripts/check_ttf_integrity.py @@ -0,0 +1,151 @@ +from fontTools.ttLib import TTFont +import os + +def check_font_integrity(font_path): + print(f"\n{'='*60}") + print(f"Checking: {os.path.basename(font_path)}") + print(f"{'='*60}") + + try: + font = TTFont(font_path) + + # Basic info + size = os.path.getsize(font_path) + print(f"File size: {size/1024/1024:.1f} MB") + print(f"Glyph count: {len(font.getGlyphOrder())}") + + # Check required tables for TrueType + required_tables = ['cmap', 'glyf', 'head', 'hhea', 'hmtx', 'loca', 'maxp', 'name', 'post'] + missing_tables = [t for t in required_tables if t not in font.keys()] + + if missing_tables: + print(f"❌ MISSING TABLES: {missing_tables}") + return False + + print(f"✅ All required tables present") + + # Check glyf table (glyph outlines) + if 'glyf' in font: + glyf = font['glyf'] + glyph_names = list(glyf.glyphs.keys()) if hasattr(glyf, 'glyphs') else [] + + if not glyph_names and hasattr(glyf, 'keys'): + glyph_names = list(glyf.keys()) + + empty_glyphs = 0 + valid_glyphs = 0 + + for name in font.getGlyphOrder()[:100]: # Check first 100 glyphs + try: + if name in ['.notdef']: + continue + + glyph_set = font.getGlyphSet() + if name in glyph_set: + from fontTools.pens.boundsPen import BoundsPen + pen = BoundsPen(glyph_set) + glyph_set[name].draw(pen) + + if pen.bounds is None: + empty_glyphs += 1 + else: + valid_glyphs += 1 + except Exception as e: + pass + + print(f"Glyph check (first 100): {valid_glyphs} valid, {empty_glyphs} empty") + + if valid_glyphs == 0 and empty_glyphs > 10: + print("⚠️ WARNING: Most glyphs appear to be empty!") + return False + + # Check cmap (character mapping) + if 'cmap' in font: + cmap = font['cmap'] + cmap_tables = cmap.tables + + chinese_chars_found = 0 + test_chars = ['香', '菇', '甲', '鱼', '家', '常', '菜'] + + for table in cmap_tables: + if hasattr(table, 'cmap'): + for char in test_chars: + code = ord(char) + if code in table.cmap: + chinese_chars_found += 1 + + print(f"CJK chars in cmap: {chinese_chars_found}/{len(test_chars)}") + + if chinese_chars_found == 0: + print("❌ CRITICAL: No CJK characters found in cmap!") + return False + + # Try to get a specific CJK glyph + test_char = '香' + glyph_set = font.getGlyphSet() + + if test_char in glyph_set: + print(f"✅ Glyph '{test_char}' accessible") + + # Get bounds to verify it has actual shape data + from fontTools.pens.boundsPen import BoundsPen + pen = BoundsPen(glyph_set) + try: + glyph_set[test_char].draw(pen) + if pen.bounds: + xMin, yMin, xMax, yMax = pen.bounds + width = xMax - xMin + height = yMax - yMin + print(f" Bounds: ({xMin}, {yMin}) - ({xMax}, {yMax})") + print(f" Size: {width} x {height} units") + + if width < 10 or height < 10: + print(" ⚠️ Glyph appears too small (possibly corrupted)") + return False + else: + print(" ⚠️ No bounds (empty glyph?)") + return False + except Exception as e: + print(f" ❌ Error drawing glyph: {e}") + return False + else: + print(f"❌ Glyph '{test_char}' NOT accessible") + return False + + font.close() + return True + + except Exception as e: + print(f"❌ Error opening font: {e}") + import traceback + traceback.print_exc() + return False + +# Check both OTF (original) and TTF (converted) versions +fonts_to_check = [ + ('assets/fonts/NotoSansSC-Regular.otf', 'Original OTF'), + ('assets/fonts/NotoSansSC-Regular.ttf', 'Converted TTF'), + ('assets/fonts/NotoSansSC-Bold.otf', 'Bold Original OTF'), + ('assets/fonts/NotoSansSC-Bold.ttf', 'Bold Converted TTF'), +] + +results = {} +for path, label in fonts_to_check: + if os.path.exists(path): + results[label] = check_font_integrity(path) + else: + print(f"\n⚠️ File not found: {path}") + +print("\n" + "="*60) +print("SUMMARY") +print("="*60) + +for label, passed in results.items(): + status = "✅ PASS" if passed else "❌ FAIL" + print(f"{label}: {status}") + +all_passed = all(results.values()) +if all_passed: + print("\n🎉 All fonts are valid!") +else: + print("\n⚠️ Some fonts have issues - this explains the PDF rendering problem") diff --git a/scripts/gen_hmos_icons.py b/scripts/gen_hmos_icons.py new file mode 100644 index 0000000..7251399 --- /dev/null +++ b/scripts/gen_hmos_icons.py @@ -0,0 +1,139 @@ +""" +鸿蒙(HarmonyOS) 分层图标生成脚本 +从源图标提取 foreground + background,符合华为审核规范 1024x1024 +""" +import os +import json +import math + +from PIL import Image, ImageDraw + +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +ASSETS_ICONS = os.path.join(SCRIPT_DIR, "..", "assets", "icons") +OHOS_MEDIA = os.path.join(SCRIPT_DIR, "..", "ohos", "AppScope", "resources", "base", "media") +OHOS_ENTRY_MEDIA = os.path.join(SCRIPT_DIR, "..", "ohos", "entry", "src", "main", "resources", "base", "media") + +SOURCE_ICON = os.path.join(ASSETS_ICONS, "icon_1024x1024.png") +OUTPUT_SIZE = 1024 + + +def get_dominant_bg_color(img): + samples = [ + (80, 80), (img.width - 81, 80), + (80, img.height - 81), (img.width - 81, img.height - 81), + (img.width // 2, 60), (img.width // 2, img.height - 61), + (60, img.height // 2), (img.width - 61, img.height // 2), + (150, 150), (img.width - 151, img.height - 151), + ] + colors = [] + for pos in samples: + px = img.getpixel(pos) + if len(px) >= 3: + colors.append((px[0], px[1], px[2])) + r_sum = sum(c[0] for c in colors) + g_sum = sum(c[1] for c in colors) + b_sum = sum(c[2] for c in colors) + n = len(colors) + return (r_sum // n, g_sum // n, b_sum // n) + + +def color_distance(c1, c2): + return math.sqrt( + (c1[0] - c2[0]) ** 2 + + (c1[1] - c2[1]) ** 2 + + (c1[2] - c2[2]) ** 2 + ) + + +def create_rounded_mask(size, radius): + mask = Image.new("L", (size, size), 0) + draw = ImageDraw.Draw(mask) + draw.rounded_rectangle([0, 0, size - 1, size - 1], radius=radius, fill=255) + return mask + + +def generate_background(img, output_path, bg_color): + bg = Image.new("RGB", (OUTPUT_SIZE, OUTPUT_SIZE), bg_color) + bg.save(output_path, "PNG") + print(f" background: {output_path} ({os.path.getsize(output_path) / 1024:.1f}KB)") + print(f" 颜色: RGB{bg_color}") + + +def generate_foreground(img, output_path, bg_color, tolerance=50): + from PIL import ImageChops + + fg = img.convert("RGBA").resize((OUTPUT_SIZE, OUTPUT_SIZE), Image.LANCZOS) + + pixels = fg.load() + width, height = fg.size + + for y in range(height): + for x in range(width): + r, g, b, a = pixels[x, y] + dist = color_distance((r, g, b), bg_color) + if dist < tolerance and a > 200: + alpha = int(255 * (dist / tolerance)) + pixels[x, y] = (r, g, b, alpha) + elif dist < tolerance * 0.5: + pixels[x, y] = (r, g, b, 0) + + mask = create_rounded_mask(OUTPUT_SIZE, int(OUTPUT_SIZE * 0.22)) + + fg.putalpha(ImageChops.multiply(fg.split()[3], mask)) + + fg.save(output_path, "PNG") + print(f" foreground: {output_path} ({os.path.getsize(output_path) / 1024:.1f}KB)") + + +def generate_layered_json(output_dir, fg_name="foreground_icon.png", bg_name="background_icon.png"): + config = { + "layered-image": { + "foreground": f"$media:{fg_name.replace('.png', '')}", + "background": f"$media:{bg_name.replace('.png', '')}" + } + } + json_path = os.path.join(output_dir, "layered_image.json") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2, ensure_ascii=False) + print(f" layered_image.json: {json_path}") + return json_path + + +def main(): + if not os.path.exists(SOURCE_ICON): + print(f"错误: 源文件不存在 {SOURCE_ICON}") + return + + print(f"源图标: {SOURCE_ICON}") + img = Image.open(SOURCE_ICON).convert("RGBA") + print(f" 尺寸: {img.size}") + + bg_color = get_dominant_bg_color(img) + print(f" 背景色采样: RGB{bg_color}") + + os.makedirs(OHOS_MEDIA, exist_ok=True) + os.makedirs(OHOS_ENTRY_MEDIA, exist_ok=True) + + print("\n--- AppScope/resources/base/media ---") + + bg_path = os.path.join(OHOS_MEDIA, "background_icon.png") + fg_path = os.path.join(OHOS_MEDIA, "foreground_icon.png") + + generate_background(img, bg_path, bg_color) + generate_foreground(img, fg_path, bg_color) + generate_layered_json(OHOS_MEDIA) + + print("\n--- entry/src/main/resources/base/media ---") + + bg_entry = os.path.join(OHOS_ENTRY_MEDIA, "background_icon.png") + fg_entry = os.path.join(OHOS_ENTRY_MEDIA, "foreground_icon.png") + + generate_background(img, bg_entry, bg_color) + generate_foreground(img, fg_entry, bg_color) + generate_layered_json(OHOS_ENTRY_MEDIA) + + print("\n完成!") + + +if __name__ == "__main__": + main() diff --git a/scripts/test_action_api.dart b/scripts/test_action_api.dart deleted file mode 100644 index 0f50be6..0000000 --- a/scripts/test_action_api.dart +++ /dev/null @@ -1,153 +0,0 @@ -// 2026-04-16 | test_action_api.dart | 动作接口测试 | 验证api_action.php GET/POST请求 -// 运行: dart run scripts/test_action_api.dart - -import 'dart:convert'; -import 'dart:io'; - -const String baseUrl = 'https://eat.wktyl.com/api'; - -Future main() async { - print('=== api_action.php 接口测试 ===\n'); - print('目标: 验证 GET 和 POST 两种请求方式是否都能正常工作\n'); - - const testId = 32892; - - // 1. IP状态查询 (GET) - print('━━━ 1. IP状态查询 (GET) ━━━'); - await testGet('ip_status'); - - // 2. 点赞 - GET方式 - print('\n━━━ 2. 点赞 - GET方式 ━━━'); - await testGet('like', params: {'type': 'recipe', 'id': '$testId', 'action': 'like'}); - - // 3. 点赞 - POST方式 (JSON body) - print('\n━━━ 3. 点赞 - POST方式 (JSON body) ━━━'); - await testPost('like', body: {'type': 'recipe', 'id': testId, 'action': 'like'}); - - // 4. 取消点赞 - GET方式 - print('\n━━━ 4. 取消点赞 - GET方式 ━━━'); - await testGet('like', params: {'type': 'recipe', 'id': '$testId', 'action': 'unlike'}); - - // 5. 评分 - GET方式 - print('\n━━━ 5. 评分 - GET方式 ━━━'); - await testGet('rate', params: {'type': 'recipe', 'id': '$testId', 'score': '4'}); - - // 6. 评分 - POST方式 (JSON body) - print('\n━━━ 6. 评分 - POST方式 (JSON body) ━━━'); - await testPost('rate', body: {'type': 'recipe', 'id': testId, 'score': 5}); - - // 7. 浏览量 - POST方式 - print('\n━━━ 7. 浏览量 - POST方式 ━━━'); - await testPost('view', body: {'type': 'recipe', 'id': testId, 'count': 1}); - - // 8. CORS预检 - OPTIONS - print('\n━━━ 8. CORS预检 (OPTIONS) ━━━'); - await testOptions(); - - print('\n=== 测试完成 ==='); -} - -Future testGet(String act, {Map? params}) async { - final client = HttpClient(); - try { - final queryParams = {'act': act, ...?params}; - final queryString = queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&'); - final url = Uri.parse('$baseUrl/api_action.php?$queryString'); - print(' GET $url'); - - final request = await client.getUrl(url); - request.headers.set('Accept', 'application/json'); - final response = await request.close(); - final body = await response.transform(utf8.decoder).join(); - - _printResult(response.statusCode, body); - } catch (e) { - print(' ❌ 请求失败: $e'); - } finally { - client.close(); - } -} - -Future testPost(String act, {Map? body}) async { - final client = HttpClient(); - try { - final url = Uri.parse('$baseUrl/api_action.php?act=$act'); - print(' POST $url'); - if (body != null) { - print(' Body: ${jsonEncode(body)}'); - } - - final request = await client.postUrl(url); - request.headers.set('Accept', 'application/json'); - request.headers.set('Content-Type', 'application/json'); - if (body != null) { - request.write(jsonEncode(body)); - } - - final response = await request.close(); - final responseBody = await response.transform(utf8.decoder).join(); - - _printResult(response.statusCode, responseBody); - } catch (e) { - print(' ❌ 请求失败: $e'); - } finally { - client.close(); - } -} - -Future testOptions() async { - final client = HttpClient(); - try { - final url = Uri.parse('$baseUrl/api_action.php'); - print(' OPTIONS $url'); - - final request = await client.openUrl('OPTIONS', url); - request.headers.set('Accept', 'application/json'); - request.headers.set('Access-Control-Request-Method', 'POST'); - request.headers.set('Origin', 'http://localhost'); - - final response = await request.close(); - print(' 状态码: ${response.statusCode}'); - print(' CORS头: Access-Control-Allow-Methods = ${response.headers.value('access-control-allow-methods') ?? '未设置'}'); - print(' CORS头: Access-Control-Allow-Origin = ${response.headers.value('access-control-allow-origin') ?? '未设置'}'); - - if (response.statusCode == 204) { - print(' ✅ CORS预检通过'); - } else { - print(' ⚠️ 预期204,实际${response.statusCode}'); - } - } catch (e) { - print(' ❌ 请求失败: $e'); - } finally { - client.close(); - } -} - -void _printResult(int statusCode, String body) { - try { - final json = jsonDecode(body) as Map; - final code = json['code']; - final message = json['message']; - final data = json['data']; - - if (code == 200) { - print(' ✅ HTTP $statusCode | code: $code | $message'); - if (data != null) { - final dataStr = jsonEncode(data); - if (dataStr.length > 200) { - print(' data: ${dataStr.substring(0, 200)}...'); - } else { - print(' data: $dataStr'); - } - } - } else { - print(' ⚠️ HTTP $statusCode | code: $code | $message'); - } - } catch (e) { - if (body.length > 200) { - print(' HTTP $statusCode | ${body.substring(0, 200)}...'); - } else { - print(' HTTP $statusCode | $body'); - } - } -} diff --git a/scripts/test_ascii_qr.dart b/scripts/test_ascii_qr.dart new file mode 100644 index 0000000..e41252c --- /dev/null +++ b/scripts/test_ascii_qr.dart @@ -0,0 +1,41 @@ +import 'dart:io' show stdout; +import 'package:qr/qr.dart'; + +void main() { + final url = 'https://eat.wktyl.com/?id=033793'; + + print('=== ASCII 二维码验证 ===\n'); + print('URL: $url\n'); + + final qrCode = QrCode.fromData( + data: url, + errorCorrectLevel: QrErrorCorrectLevel.M, + ); + final qrImage = QrImage(qrCode); + final moduleCount = qrImage.moduleCount; + + print('模块数量: ${moduleCount}x${moduleCount}\n'); + + // 用双倍宽度字符绘制 + print('╔${'═' * (moduleCount * 2)}╗'); + for (var row = 0; row < moduleCount; row++) { + stdout.write('║'); + for (var col = 0; col < moduleCount; col++) { + stdout.write(qrImage.isDark(row, col) ? '██' : ' '); + } + stdout.writeln('║'); + } + print('╚${'═' * (moduleCount * 2)}╝'); + + print('\n--- 提示 ---'); + print('用微信/支付宝扫描上方二维码可打开链接'); + + // 输出纯文本版本(无边框) + print('\n=== 纯文本版(Word可用) ===\n'); + for (var row = 0; row < moduleCount; row++) { + for (var col = 0; col < moduleCount; col++) { + stdout.write(qrImage.isDark(row, col) ? '██' : ' '); + } + stdout.writeln(); + } +} diff --git a/scripts/test_docs_gee_export.dart b/scripts/test_docs_gee_export.dart deleted file mode 100644 index 2e91c77..0000000 --- a/scripts/test_docs_gee_export.dart +++ /dev/null @@ -1,248 +0,0 @@ -// 2026-04-24 | test_docs_gee_export.dart | docs_gee导出功能验证脚本 | 验证DOCX/PDF导出流程 -// 运行: dart run scripts/test_docs_gee_export.dart - -import 'dart:io'; -import 'package:docs_gee/docs_gee.dart' as gee; - -void main() async { - print('=== docs_gee 导出功能验证 ===\n'); - - await testBasicDocument(); - await testDocxGeneration(); - await testPdfGeneration(); - await testReplaceAllTypeIssue(); - await testFullRecipeExport(); - - print('\n=== 全部测试完成 ==='); -} - -Future testBasicDocument() async { - print('[1] 基础文档构建...'); - try { - final doc = gee.Document(title: '测试菜谱', author: '小妈厨房'); - doc.addParagraph(gee.Paragraph.heading('番茄炒蛋', level: 1)); - doc.addParagraph(gee.Paragraph.text('这是一道经典家常菜')); - doc.addParagraph(gee.Paragraph.heading('食材', level: 2)); - - final rows = [ - gee.TableRow(cells: [gee.TableCell.text('名称'), gee.TableCell.text('用量')]), - gee.TableRow(cells: [gee.TableCell.text('番茄'), gee.TableCell.text('2个')]), - gee.TableRow(cells: [gee.TableCell.text('鸡蛋'), gee.TableCell.text('3个')]), - ]; - doc.addTable(gee.Table(rows: rows)); - - doc.addParagraph(gee.Paragraph.heading('步骤', level: 2)); - doc.addParagraph(gee.Paragraph.numberedItem('打散鸡蛋')); - doc.addParagraph(gee.Paragraph.numberedItem('热油炒蛋')); - doc.addParagraph(gee.Paragraph.numberedItem('加入番茄翻炒')); - - print(' ✅ 文档构建成功'); - } catch (e, s) { - print(' 文档构建失败: $e\n$s'); - } -} - -Future testDocxGeneration() async { - print('\n[2] DOCX 生成...'); - try { - final doc = gee.Document(title: '测试DOCX', author: '验证脚本'); - doc.addParagraph(gee.Paragraph.heading('DOCX测试', level: 1)); - doc.addParagraph(gee.Paragraph.text('验证Word文档生成')); - final rows = [ - gee.TableRow(cells: [gee.TableCell.text('A'), gee.TableCell.text('B')]), - gee.TableRow(cells: [gee.TableCell.text('1'), gee.TableCell.text('2')]), - ]; - doc.addTable(gee.Table(rows: rows)); - - final bytes = gee.DocxGenerator().generate(doc); - final outDir = Directory('test_output'); - if (!await outDir.exists()) await outDir.create(); - final file = File('${outDir.path}/test_docx.docx'); - await file.writeAsBytes(bytes); - print(' ✅ DOCX生成成功: ${file.path} (${bytes.length} bytes)'); - } catch (e, s) { - print(' ❌ DOCX生成失败: $e\n$s'); - } -} - -Future testPdfGeneration() async { - print('\n[3] PDF 生成...'); - try { - final doc = gee.Document(title: '测试PDF', author: '验证脚本'); - doc.addParagraph(gee.Paragraph.heading('PDF测试', level: 1)); - doc.addParagraph(gee.Paragraph.text('验证PDF文档生成')); - final bytes = gee.PdfGenerator().generate(doc); - final outDir = Directory('test_output'); - if (!await outDir.exists()) await outDir.create(); - final file = File('${outDir.path}/test_pdf.pdf'); - await file.writeAsBytes(bytes); - print(' ✅ PDF生成成功: ${file.path} (${bytes.length} bytes)'); - } catch (e, s) { - print(' ❌ PDF生成失败: $e\n$s'); - } -} - -Future testReplaceAllTypeIssue() async { - print('\n[4] replaceAll 类型问题验证...'); - try { - String fileName = '菇笋萝卜豆腐汤<测试>.docx'; - print(' 原始文件名: $fileName'); - - var sanitized = fileName.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_'); - print(' 替换后文件名: $sanitized'); - print(' ✅ replaceAll 正常工作'); - } catch (e, s) { - print(' ❌ replaceAll 失败: $e\n$s'); - print(' 🔍 这是导致报错的根因!'); - } - - print('\n 测试 dynamic 类型的 title:'); - try { - dynamic title = '菇笋萝卜豆腐汤'; - String fileName2 = '$title<特殊>.docx'; - var sanitized2 = fileName2.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_'); - print(' 动态title替换结果: $sanitized2'); - print(' ✅ 动态类型也正常'); - } catch (e, s) { - print(' ❌ 动态类型替换失败: $e\n$s'); - } - - print('\n 测试 recipe.title 为 null:'); - try { - dynamic title = null; - String fileName3 = '${title ?? "未知菜谱"}.docx'; - var sanitized3 = fileName3.replaceAll(RegExp(r'[<>:"/\\|?*]'), '_'); - print(' null title替换结果: $sanitized3'); - print(' ✅ null处理正常'); - } catch (e, s) { - print(' ❌ null处理失败: $e\n$s'); - } -} - -Future testFullRecipeExport() async { - print('\n[5] 完整菜谱模拟导出...'); - - final recipe = _MockRecipe( - title: '菇笋萝卜豆腐汤', - categoryName: '美容养颜食谱', - displayIntro: '清淡营养,适合四季食用', - content: '1. 将所有食材洗净切块\n2. 锅中加水烧开\n3. 依次放入食材煮熟', - ingredients: [ - _MockIngredient(name: '胡萝卜', amount: '100', unit: 'g'), - _MockIngredient(name: '虾仁', amount: '50', unit: 'g'), - _MockIngredient(name: '青豆', amount: '30', unit: 'g'), - ], - tags: [ - _MockTag(name: '汤类'), - _MockTag(name: '养生'), - ], - ); - - try { - ToastService.show = (msg) => print(' 📢 $msg'); - - final doc = gee.Document(title: recipe.title, author: '小妈厨房'); - doc.addParagraph(gee.Paragraph.heading(recipe.title, level: 1)); - - if (recipe.categoryName != null) - doc.addParagraph(gee.Paragraph.text('📂 分类: ${recipe.categoryName}')); - - if (recipe.displayIntro != null && recipe.displayIntro!.isNotEmpty) - doc.addParagraph(gee.Paragraph.quote(recipe.displayIntro!)); - - doc.addParagraph(gee.Paragraph.heading('🥘 食材', level: 2)); - if (recipe.ingredients.isNotEmpty) { - final rows = [ - gee.TableRow( - cells: [gee.TableCell.text('食材名称'), gee.TableCell.text('用量')], - ), - ]; - for (final ing in recipe.ingredients) { - rows.add( - gee.TableRow( - cells: [ - gee.TableCell.text(ing.name), - gee.TableCell.text('${ing.amount ?? ""}${ing.unit ?? ""}'.trim()), - ], - ), - ); - } - doc.addTable(gee.Table(rows: rows)); - } - - if (recipe.content != null && recipe.content!.isNotEmpty) { - doc.addParagraph(gee.Paragraph.heading('👨‍🍳 制作步骤', level: 2)); - final steps = recipe.content! - .split(RegExp(r'\n+')) - .where((s) => s.trim().isNotEmpty) - .toList(); - for (var i = 0; i < steps.length; i++) { - doc.addParagraph(gee.Paragraph.numberedItem(steps[i].trim())); - } - } - - if ((recipe.tags as List).isNotEmpty) { - doc.addParagraph(gee.Paragraph.heading('🏷️ 标签', level: 2)); - final tagNames = recipe.tags.map((t) => t.name).join(' · '); - doc.addParagraph(gee.Paragraph.bulletItem(tagNames)); - } - - final bytes = gee.DocxGenerator().generate(doc); - final outDir = Directory('test_output'); - if (!await outDir.exists()) await outDir.create(); - - final dir = Directory('/storage/emulated/0/Download'); - String filePath; - if (await dir.exists()) { - final sanitized = '${recipe.title}.docx'.replaceAll( - RegExp(r'[<>:"/\\|?*]'), - '_', - ); - filePath = '${dir.path}/$sanitized'; - } else { - filePath = '${outDir.path}/${recipe.title}.docx'; - } - - final file = File(filePath); - await file.writeAsBytes(bytes); - print(' ✅ 完整导出成功: ${file.path} (${bytes.length} bytes)'); - } catch (e, s) { - print(' ❌ 完整导出失败: $e\n$s'); - } -} - -class _MockRecipe { - final String title; - final String? categoryName; - final String? displayIntro; - final String? content; - final List<_MockIngredient> ingredients; - final List<_MockTag> tags; - - _MockRecipe({ - required this.title, - this.categoryName, - this.displayIntro, - this.content, - required this.ingredients, - required this.tags, - }); -} - -class _MockIngredient { - final String name; - final String? amount; - final String? unit; - - _MockIngredient({required this.name, this.amount, this.unit}); -} - -class _MockTag { - final String name; - - _MockTag({required this.name}); -} - -class ToastService { - static void Function(String message)? show; -} diff --git a/scripts/test_export_import.dart b/scripts/test_export_import.dart deleted file mode 100644 index b370c6a..0000000 --- a/scripts/test_export_import.dart +++ /dev/null @@ -1,461 +0,0 @@ -// 2026-04-24 | test_export_import.dart | 数据导出/导入逻辑验证 | 验证JSON格式正确性、导入识别、UTF-8编码、元数据 -// 运行: dart run scripts/test_export_import.dart - -import 'dart:convert'; -import 'dart:io'; - -// ─── 模拟核心逻辑 ─── - -enum DataSource { - favorites('❤️ 收藏', 'favorites'), - shoppingList('🛒 购物清单', 'shopping_list'), - mealRecords('🍽️ 饮食记录', 'meal_records'), - cookingNotes('📝 烹饪笔记', 'cooking_notes'), - weeklyMenu('📅 每周菜单', 'weekly_menu'), - browseHistory('👀 浏览记录', 'browse_history'); - - final String label; - final String fileName; - const DataSource(this.label, this.fileName); -} - -String _exportSingleSourceJson(DataSource source) { - switch (source) { - case DataSource.favorites: - return const JsonEncoder.withIndent(' ').convert([ - { - 'id': 1, - 'title': '红烧肉', - 'intro': '经典家常菜', - 'cover': 'https://example.com/cover.jpg', - 'pic_id': null, - 'categoryName': '家常菜', - 'categoryId': 10, - 'feedType': 'recipe', - 'mdhwScore': 8.5, - 'createdAt': '2026-04-22', - 'favorite_type': 'recipe', - }, - ]); - case DataSource.shoppingList: - return const JsonEncoder.withIndent(' ').convert([ - { - 'name': '鸡蛋', - 'amount': '3', - 'unit': '个', - 'category': '蛋类', - 'isChecked': false, - 'recipeId': null, - 'createdAt': '2026-04-22', - }, - ]); - case DataSource.mealRecords: - return const JsonEncoder.withIndent(' ').convert([ - { - 'date': '2026-04-22', - 'mealType': 'lunch', - 'recipeId': 1, - 'recipeTitle': '红烧肉', - 'calories': 450.0, - 'protein': 25.0, - 'fat': 30.0, - 'carbs': 20.0, - 'fiber': 2.0, - 'note': null, - 'createdAt': '2026-04-22T12:00:00', - }, - ]); - case DataSource.cookingNotes: - return const JsonEncoder.withIndent(' ').convert([ - { - 'id': 'note_1', - 'recipeId': '1', - 'title': '烹饪心得', - 'content': '少放盐更健康', - 'photoPath': null, - 'createdAt': '2026-04-22', - 'tags': ['tips', '健康'], - }, - ]); - case DataSource.weeklyMenu: - return const JsonEncoder.withIndent(' ').convert({ - 'weekStart': '2026-04-21', - 'dailyMenus': { - '2026-04-21': { - 'breakfast': {'recipeId': 1, 'recipeTitle': '豆浆'}, - 'lunch': {'recipeId': 2, 'recipeTitle': '红烧肉'}, - }, - }, - }); - case DataSource.browseHistory: - return const JsonEncoder.withIndent(' ').convert([ - { - 'id': 'hist_1', - 'recipeId': '1', - 'title': '红烧肉', - 'coverImage': null, - 'category': '家常菜', - 'viewedAt': '2026-04-22', - 'viewCount': 3, - }, - ]); - } -} - -String exportAllV2() { - final Map allData = { - '_meta': { - 'app': 'cute_kitchen', - 'version': 2, - 'exportTime': DateTime.now().toIso8601String(), - 'format': 'full', - }, - }; - for (final source in DataSource.values) { - final content = _exportSingleSourceJson(source); - try { - allData[source.fileName] = jsonDecode(content); - } catch (e) { - print(' ⚠️ 解码 ${source.fileName} 失败: $e'); - } - } - return const JsonEncoder.withIndent(' ').convert(allData); -} - -String exportSingleV2(DataSource source) { - final content = _exportSingleSourceJson(source); - final decoded = jsonDecode(content); - final wrapped = { - '_meta': { - 'app': 'cute_kitchen', - 'version': 2, - 'exportTime': DateTime.now().toIso8601String(), - 'format': 'single', - 'source': source.fileName, - }, - source.fileName: decoded, - }; - return const JsonEncoder.withIndent(' ').convert(wrapped); -} - -({Map sourceCounts, String? error}) previewImport( - String jsonContent, -) { - final result = {}; - try { - final decoded = jsonDecode(jsonContent); - if (decoded is Map) { - final hasMeta = decoded.containsKey('_meta'); - if (hasMeta) { - final meta = decoded['_meta']; - if (meta is Map && meta['app'] != 'cute_kitchen') { - return (sourceCounts: result, error: '非本应用导出的数据'); - } - } - for (final source in DataSource.values) { - if (decoded.containsKey(source.fileName)) { - final data = decoded[source.fileName]; - if (data is List) { - if (data.isNotEmpty) result[source] = data.length; - } else if (data is Map && source == DataSource.weeklyMenu) { - if (data.isNotEmpty) result[source] = data.length; - } - } - } - if (result.isEmpty && !hasMeta) { - for (final key in decoded.keys) { - if (key == '_meta') continue; - final data = decoded[key]; - if (data is List && data.isNotEmpty) { - final inferred = data.first is Map - ? _inferDataSource(data.first as Map) - : null; - result[inferred ?? DataSource.favorites] = data.length; - } - } - } - } else if (decoded is List) { - if (decoded.isNotEmpty && decoded.first is Map) { - final inferred = _inferDataSource( - decoded.first as Map, - ); - result[inferred ?? DataSource.favorites] = decoded.length; - } else if (decoded.isNotEmpty) { - result[DataSource.favorites] = decoded.length; - } - } - } catch (e) { - return (sourceCounts: result, error: 'JSON解析失败: $e'); - } - return (sourceCounts: result, error: null); -} - -DataSource? _inferDataSource(Map item) { - if (item.containsKey('favoriteType') || - item.containsKey('favorite_type') || - item.containsKey('favoriteAt')) { - return DataSource.favorites; - } - if (item.containsKey('isChecked') && item.containsKey('amount')) { - return DataSource.shoppingList; - } - if (item.containsKey('mealType') && item.containsKey('date')) { - return DataSource.mealRecords; - } - if (item.containsKey('recipeId') && item.containsKey('content')) { - return DataSource.cookingNotes; - } - if (item.containsKey('dailyMenus') || item.containsKey('weekStart')) { - return DataSource.weeklyMenu; - } - if (item.containsKey('viewCount') && item.containsKey('viewedAt')) { - return DataSource.browseHistory; - } - return null; -} - -// ─── 测试 ─── - -int _passCount = 0; -int _failCount = 0; - -void _check(String name, bool condition, {String? detail}) { - if (condition) { - _passCount++; - print(' ✅ $name'); - } else { - _failCount++; - print(' ❌ $name${detail != null ? ' — $detail' : ''}'); - } -} - -void main() { - print('╔══════════════════════════════════════════════════╗'); - print('║ 📦 数据导出/导入 逻辑验证 v2 ║'); - print('╚══════════════════════════════════════════════════╝\n'); - - testFullExportV2(); - testSingleExportV2(); - testSingleSourceListImport(); - testV1Compatibility(); - testUTF8Encoding(); - testInvalidInputs(); - testMealRecordToJson(); - testFavoriteTypeFieldMatch(); - - print('\n╔══════════════════════════════════════════════════╗'); - print('║ 结果: ✅ $_passCount 通过 ❌ $_failCount 失败 ║'); - print('╚══════════════════════════════════════════════════╝'); - if (_failCount > 0) exit(1); -} - -void testFullExportV2() { - print('━━━ 1. 全量导出JSON格式验证 (V2带_meta) ━━━'); - final json = exportAllV2(); - try { - final decoded = jsonDecode(json) as Map; - _check('JSON可解析', true); - _check('包含_meta元数据', decoded.containsKey('_meta')); - final meta = decoded['_meta'] as Map?; - _check('meta.app = cute_kitchen', meta?['app'] == 'cute_kitchen'); - _check('meta.version = 2', meta?['version'] == 2); - _check('meta.format = full', meta?['format'] == 'full'); - _check( - 'meta.exportTime 非空', - (meta?['exportTime'] as String?)?.isNotEmpty == true, - ); - - final preview = previewImport(json); - _check('previewImport 无错误', preview.error == null); - _check( - '识别 ${preview.sourceCounts.length}/6 个数据源', - preview.sourceCounts.length == 6, - detail: '实际: ${preview.sourceCounts.length}', - ); - } catch (e) { - _check('JSON解析', false, detail: e.toString()); - } - print(''); -} - -void testSingleExportV2() { - print('━━━ 2. 单源导出JSON格式验证 (V2带_meta) ━━━'); - for (final source in DataSource.values) { - final json = exportSingleV2(source); - final preview = previewImport(json); - _check( - '${source.label} 单源导出可识别', - preview.error == null && preview.sourceCounts.isNotEmpty, - ); - if (preview.sourceCounts.isNotEmpty) { - final e = preview.sourceCounts.entries.first; - _check( - '${source.label} 推断类型正确', - e.key == source, - detail: '期望: ${source.fileName}, 实际: ${e.key.fileName}', - ); - } - } - print(''); -} - -void testSingleSourceListImport() { - print('━━━ 3. 单数据源List格式自动推断 ━━━'); - for (final source in DataSource.values) { - final json = _exportSingleSourceJson(source); - final preview = previewImport(json); - if (source == DataSource.weeklyMenu) { - _check( - '${source.label} Map结构无法作为List推断(预期)', - preview.sourceCounts.isEmpty, - detail: '每周菜单是Map结构,单独导入时无法推断', - ); - continue; - } - if (preview.sourceCounts.isNotEmpty) { - final e = preview.sourceCounts.entries.first; - _check( - '${source.label} List推断正确', - e.key == source, - detail: '期望: ${source.fileName}, 实际: ${e.key.fileName}', - ); - } else { - _check('${source.label} List推断', false, detail: '无法识别'); - } - } - print(''); -} - -void testV1Compatibility() { - print('━━━ 4. V1格式兼容性 (无_meta) ━━━'); - final v1Json = const JsonEncoder.withIndent(' ').convert({ - 'favorites': [ - {'id': 1, 'title': '红烧肉', 'favorite_type': 'recipe'}, - ], - 'shopping_list': [ - {'name': '鸡蛋', 'amount': '3', 'isChecked': false}, - ], - }); - final preview = previewImport(v1Json); - _check('V1格式可识别', preview.error == null); - _check( - 'V1识别2个数据源', - preview.sourceCounts.length == 2, - detail: '实际: ${preview.sourceCounts.length}', - ); - print(''); -} - -void testUTF8Encoding() { - print('━━━ 5. UTF-8编码验证 ━━━'); - final testJson = const JsonEncoder.withIndent(' ').convert({ - '_meta': { - 'app': 'cute_kitchen', - 'version': 2, - 'exportTime': '2026-04-24', - 'format': 'full', - }, - 'favorites': [ - {'id': 1, 'title': '红烧肉🥩', 'favorite_type': 'recipe'}, - {'id': 2, 'title': '宫保鸡丁🍗', 'favorite_type': 'recipe'}, - ], - }); - final bytes = utf8.encode(testJson); - final decoded = utf8.decode(bytes); - _check('UTF-8编码/解码一致', decoded == testJson); - final preview = previewImport(decoded); - _check('含中文/emoji的JSON可识别', preview.error == null); - _check( - '中文数据条数正确', - preview.sourceCounts[DataSource.favorites] == 2, - detail: '实际: ${preview.sourceCounts[DataSource.favorites]}', - ); - - final wrongDecode = String.fromCharCodes(bytes); - _check( - 'utf8.decode 与 fromCharCodes 行为可能不同', - true, - detail: - 'utf8.decode: 正确 | fromCharCodes: ${wrongDecode == testJson ? "恰好一致" : "不一致(移动端常见)"}', - ); - _check('推荐使用 utf8.decode 解码文件字节', true); - print(''); -} - -void testInvalidInputs() { - print('━━━ 6. 无效输入验证 ━━━'); - final cases = <(String, String)>[ - ('', '空字符串'), - ('not json at all', '非JSON文本'), - ('{"_meta":{"app":"other_app","version":1}}', '非本应用导出'), - ('[]', '空数组'), - ('{}', '空对象'), - ('{"unknown_key": []}', '未知key(空列表)'), - ]; - for (final (input, desc) in cases) { - final preview = previewImport(input); - _check('无效输入: $desc → 无数据', preview.sourceCounts.isEmpty); - } - print(''); -} - -void testMealRecordToJson() { - print('━━━ 7. MealRecordModel.toJson格式验证 ━━━'); - final record = { - 'date': '2026-04-24', - 'mealType': 'lunch', - 'recipeId': 1, - 'recipeTitle': '红烧肉', - 'calories': 450.0, - 'protein': 25.0, - 'fat': 30.0, - 'carbs': 20.0, - 'fiber': 2.0, - 'note': null, - 'createdAt': '2026-04-24T12:00:00', - }; - final json = const JsonEncoder.withIndent(' ').convert([record]); - final decoded = jsonDecode(json); - _check('MealRecord JSON可序列化', decoded is List); - _check( - 'MealRecord 字段完整', - (decoded as List).isNotEmpty && - (decoded.first as Map).containsKey('mealType'), - ); - _check( - 'MealRecord 含date+mealType可推断为饮食记录', - _inferDataSource(decoded.first as Map) == - DataSource.mealRecords, - ); - print(''); -} - -void testFavoriteTypeFieldMatch() { - print('━━━ 8. favorite_type字段名匹配验证 ━━━'); - final snakeCase = {'id': 1, 'title': '红烧肉', 'favorite_type': 'recipe'}; - final camelCase = {'id': 1, 'title': '红烧肉', 'favoriteType': 'recipe'}; - _check( - 'favorite_type(下划线)可识别', - _inferDataSource(snakeCase) == DataSource.favorites, - ); - _check( - 'favoriteType(驼峰)可识别', - _inferDataSource(camelCase) == DataSource.favorites, - ); - - final exportJson = const JsonEncoder.withIndent(' ').convert({ - '_meta': { - 'app': 'cute_kitchen', - 'version': 2, - 'exportTime': '2026-04-24', - 'format': 'full', - }, - 'favorites': [snakeCase], - }); - final preview = previewImport(exportJson); - _check( - '导出含favorite_type的收藏可识别', - preview.sourceCounts.containsKey(DataSource.favorites), - ); - print(''); -} diff --git a/scripts/test_json_debug_clean.dart b/scripts/test_json_debug_clean.dart new file mode 100644 index 0000000..cebf3a6 --- /dev/null +++ b/scripts/test_json_debug_clean.dart @@ -0,0 +1,157 @@ +// 2026-04-25 | test_json_debug_clean.dart | 详细调试 _cleanJsonContent +import 'dart:io'; +import 'dart:convert'; + +void main() { + final path = r'e:\program files (x86)\wegame\apps\2821981550\filerecv\小妈厨房 - 数据导出(1).json'; + + print('📁 读取文件...\n'); + final bytes = File(path).readAsBytesSync(); + var content = utf8.decode(bytes, allowMalformed: true); + + print('原始内容:'); + print(' 长度: ${content.length}'); + print(' 最后一个字符: "${content[content.length - 1]}" (${content.codeUnitAt(content.length - 1)})'); + print(''); + + // 模拟 _cleanJsonContentPage 的每一步 + print('🧹 执行 _cleanJsonContentPage:\n'); + + // 步骤1: 移除BOM + content = content.replaceFirst(RegExp('^\\uFEFF'), ''); + print('步骤1 - 移除BOM后长度: ${content.length}'); + print(''); + + // 步骤2: 检查最后一个 } + final lastBrace = content.lastIndexOf('}'); + print('步骤2 - 最后一个 } 在位置: $lastBrace (总长度: ${content.length})'); + + if (lastBrace >= 0 && lastBrace < content.length - 1) { + final trailing = content.substring(lastBrace + 1).trim(); + print(' } 之后的内容: "$trailing" (${trailing.length}字符)'); + print(' 内容的hex编码:'); + for (var i = 0; i < (trailing).length; i++) { + print(' [$i] U+${trailing.codeUnitAt(i).toRadixString(16).toUpperCase().padLeft(4, '0')} "${trailing[i]}"'); + } + + if (trailing.isNotEmpty && + !trailing.startsWith(',') && + !trailing.startsWith('}') && + !trailing.startsWith(']')) { + print(' ⚠️ 将截取到位置 ${lastBrace + 1}'); + content = content.substring(0, lastBrace + 1); + print(' 截取后长度: ${content.length}'); + } else { + print(' ✅ 不需要截取'); + } + } else if (lastBrace == content.length - 1) { + print(' ✅ 最后一个字符就是 }'); + } else { + print(' ❌ 没有找到 }'); + } + print(''); + + // 步骤3: 检查最后一个 ] + final lastBracket = content.lastIndexOf(']'); + print('步骤3 - 最后一个 ] 在位置: $lastBracket (当前长度: ${content.length})'); + + if (lastBracket >= 0 && lastBracket < content.length - 1) { + final trailing = content.substring(lastBracket + 1).trim(); + print(' ] 之后的内容: "$trailing" (${trailing.length}字符)'); + + if (trailing.isNotEmpty) { + print(' ⚠️ 将截取到位置 ${lastBracket + 1}'); + content = content.substring(0, lastBracket + 1); + print(' 截取后长度: ${content.length}'); + } else { + print(' ✅ 不需要截取'); + } + } else if (lastBracket == content.length - 1) { + print(' ✅ 最后一个字符就是 ]'); + } else { + print(' ❌ 没有找到 ]'); + } + print(''); + + // 步骤4: 移除控制字符 + final beforeControl = content.length; + content = content.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]'), ''); + if (content.length != beforeControl) { + print('步骤4 - 移除了 ${beforeControl - content.length} 个控制字符'); + } else { + print('步骤4 - 无控制字符需要移除'); + } + print(''); + + // 步骤5: trim + final beforeTrim = content.length; + content = content.trim(); + if (content.length != beforeTrim) { + print('步骤5 - trim移除了 ${beforeTrim - content.length} 个空白字符'); + } else { + print('步骤5 - 无空白字符需要trim'); + } + print(''); + + // 最终结果 + print('✅ 最终结果:'); + print(' 长度: ${content.length}'); + print(' 最后10个字符: ...${content.length > 10 ? content.substring(content.length - 10) : content}'); + print(''); + + // 尝试解析 + print('🔍 尝试 jsonDecode...'); + try { + final decoded = jsonDecode(content); + print('✅ 解析成功!'); + + if (decoded is Map) { + print(' Keys: ${decoded.keys.join(', ')}'); + + // 检查浏览记录 + if (decoded.containsKey('browse_history')) { + final history = decoded['browse_history']; + if (history is List) { + print(' 浏览记录数: ${history.length}'); + } + } + } + } catch (e) { + print('❌ 解析失败: $e'); + print(''); + + // 显示错误位置的上下文 + final lines = content.split('\n'); + print(' 总行数: ${lines.length}'); + + final lineMatch = RegExp(r'line (\d+)').firstMatch(e.toString()); + final charMatch = RegExp(r'character (\d+)').firstMatch(e.toString()); + + if (lineMatch != null) { + final errorLine = int.tryParse(lineMatch.group(1) ?? '0') ?? 0; + final errorChar = int.tryParse(charMatch?.group(1) ?? '0') ?? 0; + + print(' 错误位置: 第${errorLine}行, 第${errorChar}个字符'); + + if (errorLine > 0 && errorLine <= lines.length) { + print(' 该行内容: "${lines[errorLine - 1]}"'); + + // 括号平衡检查 + var braceCount = 0; + var bracketCount = 0; + for (var i = 0; i < errorLine; i++) { + final line = lines[i]; + for (var j = 0; j < line.length; j++) { + final char = line[j]; + if (char == '{') braceCount++; + if (char == '}') braceCount--; + if (char == '[') bracketCount++; + if (char == ']') bracketCount--; + } + } + + print(' 到该行前的括号平衡: {=$braceCount [=$bracketCount'); + } + } + } +} diff --git a/scripts/test_json_import_fix.dart b/scripts/test_json_import_fix.dart deleted file mode 100644 index 6b5740c..0000000 --- a/scripts/test_json_import_fix.dart +++ /dev/null @@ -1,202 +0,0 @@ -// 2026-04-24 | test_json_import_fix.dart | 鸿蒙端JSON导入解析失败诊断脚本 -// 诊断文件: 小妈厨房 - 数据导出.json -// 错误: FormatException: Unexpected character (at line 116, character 2) - -import 'dart:convert'; -import 'dart:io'; -import 'dart:typed_data'; - -void main() async { - final filePath = r'e:\Program Files (x86)\WeGame\apps\2821981550\FileRecv\小妈厨房 - 数据导出.json'; - final file = File(filePath); - - if (!await file.exists()) { - print('❌ 文件不存在: $filePath'); - return; - } - - final bytes = await file.readAsBytes(); - print('📁 文件大小: ${bytes.length} bytes'); - - _checkBom(bytes); - _checkEncoding(bytes); - _checkLine116(bytes); - _tryParseWithFixes(bytes); -} - -void _checkBom(Uint8List bytes) { - print('\n=== BOM 检测 ==='); - if (bytes.length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) { - print('✅ 检测到 UTF-8 BOM (EF BB BF) 在位置 0'); - } else if (bytes.length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) { - print('✅ 检测到 UTF-16 LE BOM (FF FE)'); - } else if (bytes.length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) { - print('✅ 检测到 UTF-16 BE BOM (FE FF)'); - } else { - print('❌ 未检测到 BOM'); - } - - if (bytes.isNotEmpty) { - print('前20字节 hex: ${bytes.take(20).map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}'); - print('前20字节 char: ${bytes.take(20).map((b) => b >= 32 && b < 127 ? String.fromCharCode(b) : '.').join()}'); - } -} - -void _checkEncoding(Uint8List bytes) { - print('\n=== 编码检测 ==='); - - try { - final utf8Str = utf8.decode(bytes, allowMalformed: true); - print('UTF-8 解码成功,长度: ${utf8Str.length}'); - print('前100字符: ${utf8Str.substring(0, utf8Str.length.clamp(0, 100))}'); - - final hasBomChar = utf8Str.startsWith('\uFEFF'); - if (hasBomChar) { - print('⚠️ 字符串以 BOM 字符 \\uFEFF 开头'); - } - - final nullCount = '\x00'.allMatches(utf8Str).length; - if (nullCount > 0) { - print('⚠️ 发现 $nullCount 个 null 字节 (\\x00)'); - } - } catch (e) { - print('❌ UTF-8 解码失败: $e'); - } - - try { - final latin1Str = latin1.decode(bytes); - print('Latin-1 解码成功,长度: ${latin1Str.length}'); - } catch (e) { - print('❌ Latin-1 解码失败: $e'); - } -} - -void _checkLine116(Uint8List bytes) { - print('\n=== 第116行诊断 ==='); - - try { - final content = utf8.decode(bytes, allowMalformed: true); - final lines = content.split('\n'); - - print('总行数: ${lines.length}'); - - if (lines.length >= 116) { - final line115 = lines.length > 115 ? lines[115] : '<不存在>'; - final line116 = lines.length > 116 ? lines[116] : '<不存在>'; - final line117 = lines.length > 117 ? lines[117] : '<不存在>'; - - print('第115行 (${line115.length}字符): ${line115.substring(0, line115.length.clamp(0, 200))}'); - print('第116行 (${line116.length}字符): ${line116.substring(0, line116.length.clamp(0, 200))}'); - print('第117行 (${line117.length}字符): ${line117.substring(0, line117.length.clamp(0, 200))}'); - - if (line116.length >= 2) { - final charAt1 = line116.codeUnitAt(0); - final charAt2 = line116.codeUnitAt(1); - print('第116行字符1(位置0): U+${charAt1.toRadixString(16).padLeft(4, "0")} = ${_charInfo(charAt1)}'); - print('第116行字符2(位置1): U+${charAt2.toRadixString(16).padLeft(4, "0")} = ${_charInfo(charAt2)}'); - - if (charAt2 < 32 || charAt2 > 126) { - print('⚠️ 第116行第2个字符是非ASCII/控制字符!'); - } - } - - print('\n第116行所有字符的码点:'); - for (var i = 0; i < line116.length.clamp(0, 50); i++) { - final c = line116.codeUnitAt(i); - if (c < 32 || c > 126) { - print(' 位置$i: U+${c.toRadixString(16).padLeft(4, "0")} ${_charInfo(c)} ⚠️'); - } - } - } else { - print('⚠️ 文件不足116行'); - } - } catch (e) { - print('❌ 行分析失败: $e'); - } -} - -String _charInfo(int codeUnit) { - if (codeUnit == 0) return 'NULL'; - if (codeUnit == 0xFEFF) return 'BOM (ZERO WIDTH NO-BREAK SPACE)'; - if (codeUnit == 9) return 'TAB'; - if (codeUnit == 10) return 'LF'; - if (codeUnit == 13) return 'CR'; - if (codeUnit < 32) return '控制字符'; - if (codeUnit <= 126) return "'${String.fromCharCode(codeUnit)}'"; - if (codeUnit <= 255) return 'Latin-1扩展'; - return 'Unicode ${String.fromCharCode(codeUnit)}'; -} - -void _tryParseWithFixes(Uint8List bytes) { - print('\n=== 尝试各种修复方案解析 ==='); - - try { - var content = utf8.decode(bytes, allowMalformed: true); - - print('\n方案1: 原始 utf8.decode(allowMalformed: true)'); - try { - jsonDecode(content); - print(' ✅ JSON 解析成功!'); - } catch (e) { - print(' ❌ 失败: $e'); - } - - print('\n方案2: 移除 BOM 字符'); - try { - final trimmed = content.replaceFirst(RegExp('^\\uFEFF'), ''); - jsonDecode(trimmed); - print(' ✅ JSON 解析成功!(移除BOM后)'); - } catch (e) { - print(' ❌ 失败: $e'); - } - - print('\n方案3: 移除所有控制字符(保留\\n\\r\\t)'); - try { - final cleaned = content.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]'), ''); - jsonDecode(cleaned); - print(' ✅ JSON 解析成功!(移除控制字符后)'); - } catch (e) { - print(' ❌ 失败: $e'); - } - - print('\n方案4: trim 后解析'); - try { - jsonDecode(content.trim()); - print(' ✅ JSON 解析成功!(trim后)'); - } catch (e) { - print(' ❌ 失败: $e'); - } - - print('\n方案5: 完整清理流程 (推荐)'); - try { - String cleaned = content; - cleaned = cleaned.replaceFirst(RegExp('^\\uFEFF'), ''); - cleaned = cleaned.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]'), ''); - cleaned = cleaned.trim(); - final result = jsonDecode(cleaned); - print(' ✅ JSON 解析成功!'); - - if (result is Map) { - print(' 📊 顶层 keys: ${result.keys.toList()}'); - if (result.containsKey('_meta')) { - print(' 📋 _meta: ${result["_meta"]}'); - } - for (final key in result.keys) { - if (key == '_meta') continue; - final val = result[key]; - if (val is List) { - print(' 📦 $key: ${val.length} 条数据'); - } else if (val is Map) { - print(' 📦 $key: Map with ${val.length} entries'); - } - } - } else if (result is List) { - print(' 📦 顶层是 List: ${result.length} 条数据'); - } - } catch (e) { - print(' ❌ 失败: $e'); - } - } catch (e) { - print('❌ UTF-8 解码失败: $e'); - } -} diff --git a/scripts/test_pdf_garbled_chars.dart b/scripts/test_pdf_garbled_chars.dart new file mode 100644 index 0000000..bee1868 --- /dev/null +++ b/scripts/test_pdf_garbled_chars.dart @@ -0,0 +1,343 @@ +// 2026-04-25 | test_pdf_garbled_chars.dart | PDF乱码字符诊断脚本 +// 2026-04-25 | 创建: 验证 _cleanPdfText 对各种Unicode字符的过滤效果 + +void main() { + print('╔══════════════════════════════════════════════════════════════╗'); + print('║ PDF 乱码字符诊断工具 v1.0 ║'); + print('║ 测试 _cleanPdfText 过滤效果 ║'); + print('╚══════════════════════════════════════════════════════════════╝\n'); + + // ========== 测试用例 ========== + final testCases = <_TestCase>[ + // 基础测试 - 正常文本应该保留 + _TestCase(name: '✅ 正常中文', input: '这道菜很好吃,营养丰富', expected: '保留'), + _TestCase(name: '✅ 中英文混合', input: 'Hello世界,美味佳肴123', expected: '保留'), + _TestCase(name: '✅ 纯英文', input: 'Delicious food recipe', expected: '保留'), + + // 乱码测试 - 应该被过滤 + _TestCase(name: '❌ 菱形方块 (U+25FF)', input: '▯▯▯▯▯▯▯▯▯▯▯▯', expected: '过滤'), + _TestCase(name: '❌ 交叉形状 (U+2716)', input: '✖✖✖✖✖✖✖✖✖', expected: '过滤'), + _TestCase( + name: '❌ 私用区字符 (U+E000-U+F8FF)', + input: '\uE000\uE001\uE002\uE003\uE004\uE005', + expected: '过滤', + ), + _TestCase( + name: '❌ 变体选择器 (U+FE00-U+FE0F)', + input: 'A\uFE00B\uFE01C\uFE02', + expected: '过滤或部分保留', + ), + _TestCase( + name: '❌ 控制字符 (U+00-U+1F)', + input: '\x00\x01\x02\x03\x04\x05', + expected: '过滤', + ), + _TestCase(name: '❌ Unicode替换字符 (U+FFFD)', input: '�����', expected: '过滤'), + + // 边界情况 + _TestCase(name: '⚠️ 混合内容 (正常+乱码)', input: '很好吃▯▯▯营养▯▯丰富', expected: '部分保留'), + _TestCase( + name: '⚠️ 高比例乱码 (>40%)', + input: '好吃▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯▯', + expected: '过滤(>40%阈值)', + ), + _TestCase( + name: '⚠️ 低比例乱码 (<40%)', + input: '这道菜真的很好吃,营养丰富味道鲜美▯▯', + expected: '保留(<40%阈值)', + ), + + // 特殊Unicode区块 + _TestCase( + name: '🔣 制表符/边框 (U+2500-U+257F)', + input: '┌┐└┘├┤┬┴┼─│', + expected: '过滤', + ), + _TestCase( + name: '🔣 方块元素 (U+25A0-U+25FF)', + input: '■□▢▣▤▥▦▧▨▩', + expected: '过滤', + ), + _TestCase( + name: '🔣 几何形状 (U+25A0-U+25FF)', + input: '▲▼◆◇○●◐◑◒◓', + expected: '过滤', + ), + _TestCase( + name: '🔣 箭头符号 (U+2190-U+21FF)', + input: '→←↑↓↔⇒⇐⇑⇓', + expected: '过滤', + ), + _TestCase( + name: '🔣 数学运算符 (U+2200-U+22FF)', + input: '±×÷≈≠≤≥∞√', + expected: '过滤', + ), + _TestCase( + name: '🔣 装饰符号 (U+2700-U+27BF)', + input: '✓✔✗✘★☆♠♣♥♦', + expected: '过滤', + ), + _TestCase( + name: '🔣 Dingbats (U+2700-U+27BF)', + input: '❤❥❦❧❝❞❟❰❱', + expected: '过滤', + ), + + // 实际场景模拟 + _TestCase( + name: '🎯 场景1: displayIntro含PUA', + input: '美味家常菜\uE000\uE001\uE002\uE003', + expected: '保留"美味家常菜"', + ), + _TestCase( + name: '🎯 场景2: 全是乱码', + input: '\uE000\uE001\uE002\uE003\uE004\uE005\uE006\uE007\uE008\uE009', + expected: '空字符串', + ), + _TestCase( + name: '🎯 场景3: 含控制字符', + input: '好吃的菜\x01\x02\x03\x04\x05', + expected: '保留"好吃的菜"', + ), + _TestCase( + name: '🎯 场景4: CJK扩展区汉字', + input: '\u3400\u3401\u3402\u4E00\u4E01', // CJK扩展A + 统一汉字 + expected: '保留(CJK扩展区)', + ), + ]; + + // ========== 执行测试 ========== + int passed = 0; + int failed = 0; + + for (var i = 0; i < testCases.length; i++) { + final tc = testCases[i]; + print('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); + print("测试 ${i + 1}/${testCases.length}: ${tc.name}"); + print('输入: "${tc.input}"'); + + // 打印每个字符的详细信息 + print('字符分析:'); + for (var j = 0; j < tc.input.length; j++) { + final cp = tc.input.codeUnitAt(j); + final char = tc.input[j]; + final cpHex = 'U+${cp.toRadixString(16).toUpperCase().padLeft(4, '0')}'; + + String category; + if (_isCjk(cp)) { + category = 'CJK汉字'; + } else if (_isAsciiLetter(cp)) { + category = 'ASCII字母'; + } else if (_isDigit(cp)) { + category = '数字'; + } else if (_isSpace(cp)) { + category = '空白'; + } else if (_isPunctuation(cp)) { + category = '标点'; + } else if (_shouldFilterChar(cp, char)) { + category = '❌ 将被过滤'; + } else { + category = '⚠️ 未分类'; + } + + print(' [$j] "$char" $cpHex → $category'); + } + + // 执行清理 + final result = _cleanPdfText(tc.input); + print('输出: "${result ?? "null"}"'); + print('长度: ${result?.length ?? 0}'); + + // 判断是否通过 + bool testPassed = false; + if (tc.expected.contains('保留') && result != null && result.isNotEmpty) { + testPassed = true; + } else if (tc.expected.contains('过滤') && + (result == null || result.isEmpty)) { + testPassed = true; + } else if (tc.expected.contains('空字符串') && + (result == null || result.isEmpty)) { + testPassed = true; + } + + if (testPassed) { + print('结果: ✅ 通过 (预期: ${tc.expected})'); + passed++; + } else { + print('结果: ❌ 失败 (预期: ${tc.expected})'); + failed++; + } + print(''); + } + + // ========== 汇总 ========== + print('╔══════════════════════════════════════════════════════════════╗'); + print('║ 测试汇总 ║'); + print('╠══════════════════════════════════════════════════════════════╣'); + print( + '║ 总测试数: ${testCases.length.toString().padLeft(3)} ║', + ); + print( + '║ ✅ 通过: ${passed.toString().padLeft(3)} ║', + ); + print( + '║ ❌ 失败: ${failed.toString().padLeft(3)} ║', + ); + print( + '║ 通过率: ${(passed / testCases.length * 100).toStringAsFixed(1).padLeft(5)}% ║', + ); + print('╚══════════════════════════════════════════════════════════════╝'); + + if (failed > 0) { + print('\n⚠️ 有 $failed 个测试失败,请检查过滤逻辑!'); + } else { + print('\n🎉 所有测试通过!_cleanPdfText 工作正常。'); + } +} + +// ========== 核心方法 (从 recipe_export_button.dart 复制) ========== + +String _cleanPdfText(String text) { + if (text.isEmpty) return ''; + var cleaned = StringBuffer(); + for (var i = 0; i < text.length; i++) { + final codeUnit = text.codeUnitAt(i); + final char = text[i]; + if (_shouldFilterChar(codeUnit, char)) continue; + cleaned.write(char); + } + var result = cleaned.toString().trim(); + if (result.isEmpty) return ''; + if (_isGarbledText(result)) return ''; + return result; +} + +bool _shouldFilterChar(int codeUnit, String char) { + if (codeUnit < 0x20 && + codeUnit != 0x09 && + codeUnit != 0x0A && + codeUnit != 0x0D) { + return true; + } + if (codeUnit == 0x7F) return true; + if (codeUnit >= 0x80 && codeUnit <= 0x9F) return true; + if ((codeUnit & 0xFFFE) == 0xFFFE || (codeUnit & 0xFFFE) == 0xFFFF) + return true; + if (codeUnit == 0xFFFD) return true; + if (codeUnit >= 0xFDD0 && codeUnit <= 0xFDEF) return true; + if (codeUnit >= 0xE000 && codeUnit <= 0xF8FF) return true; + if (codeUnit >= 0xFFF0 && codeUnit <= 0xFFFB) return true; + if (codeUnit >= 0xFE00 && codeUnit <= 0xFE0F) return true; + if (_isSpecialSymbol(char)) return true; + return false; +} + +bool _isSpecialSymbol(String char) { + const rawSymbols = + '▯□■◯○●◇◆▪▫◻◼◽◾▱░▒▓█▄▌▐▀▸▂▁▃▅▆▇▉▊▋▎▏▕▖▗▘▙▚▛▜▝▞▟╭╮╯╰╱╲╳╴╵╶╷╸╹╺╻╼╽╾╿┌┐└┘├┤┬┴┼─│┈┉┊┋━┃┅┆┇┍┎┏┐┑▒┓└┕┖┗┘┙┚┛├┝┞┟┠┡┢┣┤┥┦┧┨┩┪┫┬┭┮┯┰┱┲┳┴┵┶┷┸┹┺┻┼┽┾┿╀╁╂╃╄╅╆╇╈╉╊╋☐☑☒✓✔✗✘→←↑↓↔⇒⇐⇑⇓⇔⇕⇖⇗⇘⇙♠♣♥♦★☆▲▼◐◑◒◓◔◕◖◗❤❥❦❧❝❞❟❰❱❲❳❴❵❶❷❸❹❺❻❼❽❾❿➔➘➙➚➛➜➝➞➟➠➡➢➣➤➥➦➧➨➩➪➫➬➭➮➯➱➲➳➴➵➶➷➸➹➺➻➼➽➾➿⟦⟧⟨⟩⟪⟫⟬⟭⟮⟯⬅⬆⬇⬈⬉⬊⬋⬌⬍⬎⬏⬐⬑⬒⬓⬘⬙⬚⬛⬜⬝⬞⬟⬠⬡⭢⭣⭤⭥⭦⭧⭨⭩⭪⭫⭬⭭⭮⭯⭐⭕⭘⭙⭚⭛⭜⭝⭞⭟⭠⭡⭢⭣⭤⭥'; + return rawSymbols.contains(char); +} + +bool _isGarbledText(String text) { + if (text.length < 2) return false; + int specialCount = 0; + for (int i = 0; i < text.length; i++) { + final cp = text.codeUnitAt(i); + final isCjk = + (cp >= 0x4E00 && cp <= 0x9FFF) || + (cp >= 0x3400 && cp <= 0x4DBF) || + (cp >= 0x20000 && cp <= 0x2A6DF) || + (cp >= 0x2A700 && cp <= 0x2B73F) || + (cp >= 0x2B740 && cp <= 0x2B81F) || + (cp >= 0x2B820 && cp <= 0x2CEAF) || + (cp >= 0xF900 && cp <= 0xFAFF) || + (cp >= 0x2F800 && cp <= 0x2FA1F); + final isAsciiLetter = + (cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A); + final isDigit = cp >= 0x30 && cp <= 0x39; + final isSpace = cp == 0x20 || cp == 0x09 || cp == 0x0A || cp == 0x0D; + final isPunctuation = + (cp >= 0x2000 && cp <= 0x206F) || + (cp >= 0x3000 && cp <= 0x303F) || + (cp >= 0xFF00 && cp <= 0xFFEF) || + cp == 0x2E || + cp == 0x2C || + cp == 0x3B || + cp == 0x3A || + cp == 0x21 || + cp == 0x3F || + cp == 0x28 || + cp == 0x29 || + cp == 0x5B || + cp == 0x5D || + cp == 0x7B || + cp == 0x7D || + cp == 0x201C || + cp == 0x201D || + cp == 0x2018 || + cp == 0x2019; + if (!isCjk && !isAsciiLetter && !isDigit && !isSpace && !isPunctuation) { + specialCount++; + } + } + final ratio = specialCount / text.length; + return ratio > 0.4; +} + +// ========== 辅助判断方法 ========== + +bool _isCjk(int cp) { + return (cp >= 0x4E00 && cp <= 0x9FFF) || + (cp >= 0x3400 && cp <= 0x4DBF) || + (cp >= 0x20000 && cp <= 0x2A6DF) || + (cp >= 0x2A700 && cp <= 0x2B73F) || + (cp >= 0x2B740 && cp <= 0x2B81F) || + (cp >= 0x2B820 && cp <= 0x2CEAF) || + (cp >= 0xF900 && cp <= 0xFAFF) || + (cp >= 0x2F800 && cp <= 0x2FA1F); +} + +bool _isAsciiLetter(int cp) { + return (cp >= 0x41 && cp <= 0x5A) || (cp >= 0x61 && cp <= 0x7A); +} + +bool _isDigit(int cp) { + return cp >= 0x30 && cp <= 0x39; +} + +bool _isSpace(int cp) { + return cp == 0x20 || cp == 0x09 || cp == 0x0A || cp == 0x0D; +} + +bool _isPunctuation(int cp) { + return (cp >= 0x2000 && cp <= 0x206F) || + (cp >= 0x3000 && cp <= 0x303F) || + (cp >= 0xFF00 && cp <= 0xFFEF) || + cp == 0x2E || + cp == 0x2C || + cp == 0x3B || + cp == 0x3A || + cp == 0x21 || + cp == 0x3F || + cp == 0x28 || + cp == 0x29 || + cp == 0x5B || + cp == 0x5D || + cp == 0x7B || + cp == 0x7D || + cp == 0x201C || + cp == 0x201D || + cp == 0x2018 || + cp == 0x2019; +} + +// ========== 测试用例数据类 ========== + +class _TestCase { + final String name; + final String input; + final String expected; + + _TestCase({required this.name, required this.input, required this.expected}); +}