feat: 更新鸿蒙应用配置与功能优化

- 添加鸿蒙分层图标配置和生成脚本
- 修复数据导出JSON解析问题
- 优化关于页面和团队信息展示
- 更新应用版本至1.4.1
- 清理代码警告和冗余文件
- 添加字体和二维码测试脚本
- 完善鸿蒙适配文档和指南
This commit is contained in:
Developer
2026-04-25 09:52:06 +08:00
parent 3c90407bb5
commit 4ec348b28e
63 changed files with 4538 additions and 1818 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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 包缓存失效问题)

Binary file not shown.

Binary file not shown.

BIN
dist/小妈厨房_Setup_1.4.0.exe vendored Normal file

Binary file not shown.

View File

@@ -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<MiniCardPage> {
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<MiniCardPage> {
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<MiniCardPage> {
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<MiniCardPage> {
),
),
),
progressIndicatorBuilder: (_, __, _) => Container(
progressIndicatorBuilder: (_, _, _) => Container(
color: DesignTokens.background,
child: const Center(child: CupertinoActivityIndicator()),
),

View File

@@ -281,7 +281,7 @@ class _StatsDashboardPageState extends State<StatsDashboardPage> {
_formatNumber(nutritionRecords),
DesignTokens.teal,
isDark,
subtitle: '$nutritionTypes 项日志',
subtitle: '$nutritionTypes 项日志(归档)',
),
),
const SizedBox(width: DesignTokens.space2),

View File

@@ -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(

View File

@@ -391,7 +391,8 @@ class _AppInfoPageState extends State<AppInfoPage> {
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<AppInfoPage> {
_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<AppInfoPage> {
children: [
_buildUpdateItem(
'版本 ${AppConfig.appVersion}',
'2026-04-23',
['升级鸿蒙api 23', '优化关于页面布局与交互体验'],
'2026-04-25',
[
'修复鸿蒙端数据导入问题',
'现在你从APP导出的备份数据在小妈厨房 androidHarmonyWebWindows 可直接导入了',
'现已支持将菜品导出为PDFWord (实验)',
],
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<AppInfoPage> {
);
}
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,

View File

@@ -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(

View File

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

View File

@@ -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 }

View File

@@ -388,7 +388,10 @@ class _DataExportPageState extends State<DataExportPage> {
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);
}

View File

@@ -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',
);

View File

@@ -4,7 +4,7 @@
"vendor": "微风暴",
"versionCode": 26042001,
"versionName": "1.1.0",
"icon": "$media:app_icon",
"icon": "$media:layered_image",
"label": "$string:app_name"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1 @@
{"layered-image": {"background": "$media:background_icon", "foreground": "$media:foreground_icon"}}

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -0,0 +1 @@
{"layered-image": {"background": "$media:background_icon", "foreground": "$media:foreground_icon"}}

View File

@@ -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 <版本号> <github地址> <包名>
```
### 步骤 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 <plugin>_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 <device> 验证功能正常
```
### 4.2 关键文件清单
```
<plugin>/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/
└── <PluginName>Plugin.ets # 原生实现MethodChannel Handler
```
### 4.3 oh-package.json5 必要配置
```json
{
"name": "<plugin_name>",
"version": "1.0.0",
"description": "HarmonyOS implementation of <plugin>",
"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) | 本项目已适配的具体包清单和使用示例 |

View File

@@ -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';
/**

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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';
/**

View File

@@ -0,0 +1,3 @@
-keep-property-name
flutter
native*

View File

@@ -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}

View File

@@ -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",

View File

@@ -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';
/**

View File

@@ -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}

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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"
}
]
}

View File

@@ -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. */
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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": []
}
}

View File

@@ -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 <version> <github-url> <package-name>
`pubspec.yaml``version: x.x.x``x.x.x-ohos.1`
### 2.3 创建空壳 ohos 目录
```
packages/<name>/ohos/
├── Index.ets
├── oh-package.json5
├── build-profile.json5
├── libs/ # flutter.har 占位(空目录)
└── src/main/
├── module.json5
├── resources/base/profile/ # 空目录
└── ets/components/plugin/
└── <Name>Plugin.ets # 空壳类
```
#### 通用文件模板(所有纯 Dart 包共用)
**`Index.ets`**
```typescript
import <Name>Plugin from './src/main/ets/components/plugin/<Name>Plugin';
export default <Name>Plugin;
```
**`oh-package.json5`**
```json
{
"name": "<package_name>",
"version": "<version>-ohos.1",
"description": "<desc> - HarmonyOS adaptation",
"main": "Index.ets",
"author": "",
"license": "<license>",
"dependencies": {
"@ohos/flutter_ohos": "file:libs/flutter.har"
}
}
```
**`build-profile.json5`**
```json
{ "apiType": "stageMode", "buildOption": {}, "targets": [{ "name": "default" }] }
```
**`module.json5`**
```json
{
"module": {
"name": "<package_name>",
"type": "har",
"deviceTypes": ["default", "tablet"]
}
}
```
**`<Name>Plugin.ets`**
```typescript
export default class <Name>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 | ✅ 纯DartDOCX/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纯DartDOCX/PDF文档生成库
# 9. docs_gee纯DartDOCX/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 | ✅ | ✅ | 纯 DartQR码生成 |
| mailer | 本地 path | ❌ | ✅ | 纯 DartSMTP客户端Web不支持 |
| mobile_scanner | 本地 path | ✅ | ✅ | 原生插件,扫码/二维码v7.2.0+鸿蒙适配 |
| docs_gee | 本地 path | ⚠️ | ✅ | 纯DartDOCX/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 模块集成到鸿蒙应用 |

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

22
scripts/check_font.py Normal file
View File

@@ -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()

View File

@@ -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")

139
scripts/gen_hmos_icons.py Normal file
View File

@@ -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()

View File

@@ -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<void> 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<void> testGet(String act, {Map<String, String>? 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<void> testPost(String act, {Map<String, dynamic>? 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<void> 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<String, dynamic>;
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');
}
}
}

View File

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

View File

@@ -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<void> 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<void> 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<void> 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<void> 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<void> 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;
}

View File

@@ -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<String, dynamic> 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<DataSource, int> sourceCounts, String? error}) previewImport(
String jsonContent,
) {
final result = <DataSource, int>{};
try {
final decoded = jsonDecode(jsonContent);
if (decoded is Map<String, dynamic>) {
final hasMeta = decoded.containsKey('_meta');
if (hasMeta) {
final meta = decoded['_meta'];
if (meta is Map<String, dynamic> && 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<String, dynamic>
? _inferDataSource(data.first as Map<String, dynamic>)
: null;
result[inferred ?? DataSource.favorites] = data.length;
}
}
}
} else if (decoded is List) {
if (decoded.isNotEmpty && decoded.first is Map<String, dynamic>) {
final inferred = _inferDataSource(
decoded.first as Map<String, dynamic>,
);
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<String, dynamic> 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<String, dynamic>;
_check('JSON可解析', true);
_check('包含_meta元数据', decoded.containsKey('_meta'));
final meta = decoded['_meta'] as Map<String, dynamic>?;
_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<String, dynamic>) ==
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('');
}

View File

@@ -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<String, dynamic>) {
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');
}
}
}
}

View File

@@ -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<String, dynamic>) {
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');
}
}

View File

@@ -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: '<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>', 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});
}