feat: 更新鸿蒙应用配置与功能优化
- 添加鸿蒙分层图标配置和生成脚本 - 修复数据导出JSON解析问题 - 优化关于页面和团队信息展示 - 更新应用版本至1.4.1 - 清理代码警告和冗余文件 - 添加字体和二维码测试脚本 - 完善鸿蒙适配文档和指南
1184
CHANGELOG.md
@@ -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 包缓存失效问题)
|
||||
|
||||
BIN
assets/fonts/NotoSansSC-Bold.ttf
Normal file
BIN
assets/fonts/NotoSansSC-Regular.ttf
Normal file
BIN
dist/小妈厨房_Setup_1.4.0.exe
vendored
Normal 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()),
|
||||
),
|
||||
|
||||
@@ -281,7 +281,7 @@ class _StatsDashboardPageState extends State<StatsDashboardPage> {
|
||||
_formatNumber(nutritionRecords),
|
||||
DesignTokens.teal,
|
||||
isDark,
|
||||
subtitle: '$nutritionTypes 项日志',
|
||||
subtitle: '$nutritionTypes 项日志(归档)',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space2),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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导出的备份数据,在小妈厨房 android,Harmony,Web,Windows 可直接导入了',
|
||||
'现已支持将菜品导出为PDF,Word (实验)',
|
||||
],
|
||||
isDark,
|
||||
primaryColor,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.space3),
|
||||
_buildUpdateItem(
|
||||
'版本 0.99.6',
|
||||
'2026-04-18',
|
||||
['工具中心', '工具下拉页面使用new UI ,旧版ui停止维护'],
|
||||
'版本 1.0.1',
|
||||
'2026-04-20',
|
||||
['优化工具中心下拉动画', '工具中心暂时归档,不再增加功能'],
|
||||
isDark,
|
||||
primaryColor,
|
||||
),
|
||||
@@ -1232,6 +1232,107 @@ class _AppInfoPageState extends State<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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
290
lib/src/pages/profile/known_issues_sheet.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"vendor": "微风暴",
|
||||
"versionCode": 26042001,
|
||||
"versionName": "1.1.0",
|
||||
"icon": "$media:app_icon",
|
||||
"icon": "$media:layered_image",
|
||||
"label": "$string:app_name"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.1 MiB |
BIN
ohos/AppScope/resources/base/media/background_icon.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
ohos/AppScope/resources/base/media/foreground_icon.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
1
ohos/AppScope/resources/base/media/layered_image.json
Normal file
@@ -0,0 +1 @@
|
||||
{"layered-image": {"background": "$media:background_icon", "foreground": "$media:foreground_icon"}}
|
||||
@@ -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",
|
||||
|
||||
BIN
ohos/entry/src/main/resources/base/media/background_icon.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
ohos/entry/src/main/resources/base/media/foreground_icon.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1 @@
|
||||
{"layered-image": {"background": "$media:background_icon", "foreground": "$media:foreground_icon"}}
|
||||
239
packages/Flutter包鸿蒙适配通用指南.md
Normal 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) | 本项目已适配的具体包清单和使用示例 |
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-keep-property-name
|
||||
flutter
|
||||
native*
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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. */
|
||||
}
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
@@ -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 | ✅ 纯Dart,DOCX/PDF生成库 |
|
||||
| # | 包名 | 类型 | 原版本 | 适配版本 | ohos目录 | 日期 |
|
||||
|---|------|------|--------|---------|---------|------|
|
||||
| 1 | fl_chart | 🟢 纯Dart | 1.2.0 | 1.2.0-ohos.1 | ❌ 无需 | 2026-04-09 |
|
||||
| 2 | badges | 🟢 纯Dart | 3.2.0 | 3.2.0-ohos.1 | ❌ 无需 | 2026-04-10 |
|
||||
| 3 | flutter_staggered_grid_view | 🟢 纯Dart | 0.7.0 | 0.7.0-ohos.1 | ❌ 无需 | 2026-04-12 |
|
||||
| 4 | cached_network_image | 🟢 纯Dart | 3.4.1 | 3.4.1-ohos.1 | ❌ 无需 | 2026-04-12 |
|
||||
| 5 | flutter_markdown_plus | 🟢 纯Dart | 1.0.7 | 1.0.7-ohos.1 | ❌ 无需 | 2026-04-14 |
|
||||
| 6 | flutter_card_swiper | 🟢 纯Dart | 7.2.0 | 7.2.0-ohos.1 | ❌ 无需 | 2026-04-14 |
|
||||
| 7 | qr | 🟢 纯Dart | 3.0.2 | 3.0.2-ohos.1 | ❌ 无需 | 2026-04-19 |
|
||||
| 8 | mailer | 🟢 纯Dart | 7.1.0 | 7.1.0-ohos.1 | ❌ 无需 | 2026-04-19 |
|
||||
| 9 | docs_gee | 🟢 纯Dart | 1.3.2 | 1.3.2-ohos.1 | ❌ 无需 | 2026-04-24 |
|
||||
| 10 | **pdf** | 🟢 纯Dart | 3.12.0 | 3.12.0-ohos.1 | ❌ 无需 | 2026-04-25 |
|
||||
| 11 | mobile_scanner | 🔴 原生插件 | 7.2.0 | 7.2.0+ohos | ✅ 有ets实现 | 2026-04-22 |
|
||||
| 12 | file_picker | 🔴 原生插件 | - | 1.0.1 | ✅ 有ets实现 | 已适配 |
|
||||
| 13 | fluttertoast_ohos | 🔴 原生插件 | - | 1.0.0 | ✅ 有ets实现 | 已适配 |
|
||||
|
||||
> **🟢 纯Dart (10个)**: 仅改版本号,无任何ohos文件。Flutter AOT编译直接运行。
|
||||
> **🔴 原生插件 (3个)**: 含ets原生代码 + flutter.har依赖,需要DevEco Studio编译。
|
||||
|
||||
### 5.2 各包克隆命令速查
|
||||
|
||||
```bash
|
||||
cd packages
|
||||
|
||||
# ====== 🟢 纯Dart包(仅改版本号即可)======
|
||||
|
||||
# 1. fl_chart
|
||||
git clone --depth 1 --branch 1.2.0 https://github.com/imaNNeo/fl_chart.git fl_chart
|
||||
|
||||
@@ -189,13 +157,19 @@ git clone --depth 1 --branch v3.0.2 https://github.com/kevmoo/qr.dart.git qr
|
||||
# 8. mailer
|
||||
git clone --depth 1 --branch v7.1.0 https://github.com/dart-mailer/mailer.git mailer
|
||||
|
||||
# 9. mobile_scanner(官方v7.2.0 + 鸿蒙适配合并)
|
||||
git clone --depth 1 --branch v7.2.0 https://github.com/juliansteenbakker/mobile_scanner.git mobile_scanner
|
||||
# 合并鸿蒙适配:ohos/ 目录、ohos_surface_producer_delegate.dart、surface_producer_delegate.dart、rotated_preview.dart 鸿蒙旋转修复
|
||||
|
||||
# 10. docs_gee(纯Dart,DOCX/PDF文档生成库)
|
||||
# 9. docs_gee(纯Dart,DOCX/PDF文档生成库)
|
||||
git clone --depth 1 https://github.com/erykkruk/docs_gee.git docs_gee
|
||||
# 实际代码在 docx_generator/ 子目录,引用路径: packages/docs_gee/docx_generator
|
||||
|
||||
# 10. pdf(纯Dart,专业PDF生成库,GitHub 2k+ stars)
|
||||
git clone --depth 1 https://github.com/DavBfr/dart_pdf.git pdf
|
||||
# monorepo结构,实际代码在 pdf/ 子目录,引用路径: packages/pdf/pdf
|
||||
|
||||
# ====== 🔴 原生插件(需要ets实现+flutter.har)======
|
||||
|
||||
# 10. mobile_scanner(官方v7.2.0 + 鸿蒙适配合并)
|
||||
git clone --depth 1 --branch v7.2.0 https://github.com/juliansteenbakker/mobile_scanner.git mobile_scanner
|
||||
# 合并鸿蒙适配:ohos/ 目录、CameraUtil.ets、Barcode.ets 等
|
||||
```
|
||||
|
||||
### 5.3 各包使用示例
|
||||
@@ -280,30 +254,61 @@ File('report.docx').writeAsBytesSync(DocxGenerator().generate(doc));
|
||||
File('report.pdf').writeAsBytesSync(PdfGenerator().generate(doc));
|
||||
```
|
||||
|
||||
**pdf(专业PDF生成,推荐用于PDF导出)**
|
||||
```dart
|
||||
import 'package:pdf/pdf.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
|
||||
final pdf = pw.Document(
|
||||
theme: pw.ThemeData.withFont(
|
||||
base: pw.Font.ttf(fontBytes.buffer.asByteData()),
|
||||
bold: pw.Font.ttf(boldFontBytes.buffer.asByteData()),
|
||||
),
|
||||
);
|
||||
|
||||
pdf.addPage(pw.MultiPage(
|
||||
pageFormat: PdfPageFormat.a4,
|
||||
build: (context) => [
|
||||
pw.Text('标题', style: pw.TextStyle(fontSize: 24, fontWeight: pw.FontWeight.bold)),
|
||||
pw.SizedBox(height: 16),
|
||||
pw.Text('正文内容'),
|
||||
pw.TableHelper.fromTextArray(
|
||||
headers: ['列1', '列2'],
|
||||
data: [['数据1', '数据2']],
|
||||
),
|
||||
],
|
||||
));
|
||||
|
||||
final bytes = await pdf.save();
|
||||
File('report.pdf').writeAsBytesSync(bytes);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、项目依赖兼容性总览
|
||||
|
||||
| 依赖 | 来源 | Web | 鸿蒙 | 备注 |
|
||||
|------|------|-----|------|------|
|
||||
| fl_chart | 本地 path | ✅ | ✅ | 纯 Dart |
|
||||
| badges | 本地 path | ✅ | ✅ | 纯 Dart |
|
||||
| flutter_staggered_grid_view | 本地 path | ✅ | ✅ | 纯 Dart |
|
||||
| cached_network_image | 本地 path | ✅ | ✅ | 条件导入,IO 分支 |
|
||||
| flutter_markdown_plus | 本地 path | ✅ | ✅ | 纯 Dart,全平台支持 |
|
||||
| flutter_card_swiper | 本地 path | ✅ | ✅ | 纯 Dart |
|
||||
| qr | 本地 path | ✅ | ✅ | 纯 Dart,QR码生成 |
|
||||
| mailer | 本地 path | ❌ | ✅ | 纯 Dart,SMTP客户端,Web不支持 |
|
||||
| mobile_scanner | 本地 path | ✅ | ✅ | 原生插件,扫码/二维码,v7.2.0+鸿蒙适配 |
|
||||
| docs_gee | 本地 path | ⚠️ | ✅ | 纯Dart,DOCX/PDF生成,Web需处理文件保存 |
|
||||
| hive_ce | pub.dev | ✅ | ✅ | 纯 Dart |
|
||||
| get / dio / logger / intl | pub.dev | ✅ | ✅ | 纯 Dart |
|
||||
| shared_preferences | pub.dev | ✅ | ✅ | localStorage |
|
||||
| path_provider | git(鸿蒙版) | ⚠️ | ✅ | 需验证 web |
|
||||
| connectivity_plus | git(鸿蒙版) | ⚠️ | ✅ | 需验证 web |
|
||||
| share_plus | git(鸿蒙版) | ⚠️ | ✅ | 需验证 web |
|
||||
| permission_handler | git(鸿蒙版) | ❌ | ✅ | Web 不支持 |
|
||||
| fluttertoast | 本地 path | ⚠️ | ✅ | 需验证 web |
|
||||
| 依赖 | 来源 | Web | 鸿蒙 | 类型 | 备注 |
|
||||
|------|------|-----|------|------|------|
|
||||
| fl_chart | 本地 path | ✅ | ✅ | 🟢纯Dart | 图表 |
|
||||
| badges | 本地 path | ✅ | ✅ | 🟢纯Dart | 徽标 |
|
||||
| flutter_staggered_grid_view | 本地 path | ✅ | ✅ | 🟢纯Dart | 瀑布流 |
|
||||
| cached_network_image | 本地 path | ✅ | ✅ | 🟢纯Dart | 图片缓存 |
|
||||
| flutter_markdown_plus | 本地 path | ✅ | ✅ | 🟢纯Dart | Markdown |
|
||||
| flutter_card_swiper | 本地 path | ✅ | ✅ | 🟢纯Dart | 卡片滑动 |
|
||||
| qr | 本地 path | ✅ | ✅ | 🟢纯Dart | QR码生成 |
|
||||
| mailer | 本地 path | ❌ | ✅ | 🟢纯Dart | SMTP发邮件 |
|
||||
| docs_gee | 本地 path | ⚠️ | ✅ | 🟢纯Dart | DOCX/PDF生成 |
|
||||
| **pdf** | 本地 path | ✅ | ✅ | 🟢纯Dart | 专业PDF生成(推荐) |
|
||||
| mobile_scanner | 本地 path | ✅ | ✅ | 🔴原生插件 | 扫码 |
|
||||
| file_picker | 本地 path | ✅ | ✅ | 🔴原生插件 | 文件选择 |
|
||||
| fluttertoast_ohos | 本地 path | ⚠️ | ✅ | 🔴原生插件 | Toast提示 |
|
||||
| hive_ce | pub.dev | ✅ | ✅ | 🟢纯Dart | 数据库 |
|
||||
| get / dio / logger / intl | pub.dev | ✅ | ✅ | 🟢纯Dart | 工具 |
|
||||
| shared_preferences | pub.dev | ✅ | ✅ | 🟢纯Dart | 存储 |
|
||||
| path_provider | git(鸿蒙版) | ⚠️ | ✅ | 🔴原生插件 | 路径 |
|
||||
| connectivity_plus | git(鸿蒙版) | ⚠️ | ✅ | 🔴原生插件 | 网络 |
|
||||
| share_plus | git(鸿蒙版) | ⚠️ | ✅ | 🔴原生插件 | 分享 |
|
||||
| permission_handler | git(鸿蒙版) | ❌ | ✅ | 🔴原生插件 | 权限 |
|
||||
|
||||
---
|
||||
|
||||
@@ -311,6 +316,7 @@ File('report.pdf').writeAsBytesSync(PdfGenerator().generate(doc));
|
||||
|
||||
| 文档 | 用途 |
|
||||
|------|------|
|
||||
| [**Flutter包鸿蒙适配通用指南.md**](./Flutter包鸿蒙适配通用指南.md) | **👈 通用方法论:判断包类型、适配步骤、踩坑记录、检查清单(给其他人看)** |
|
||||
| [ohos平台适配flutter三方库指导.md](./ohos平台适配flutter三方库指导.md) | 原生插件完整适配流程(MethodChannel、打 har 包) |
|
||||
| [开发FFI plugin.md](./开发FFI plugin.md) | FFI 插件开发指南 |
|
||||
| [OpenHarmony应用如何集成Flutter模块.md](./OpenHarmony应用如何集成Flutter模块.md) | Flutter 模块集成到鸿蒙应用 |
|
||||
|
||||
326
packages/鸿蒙分层图标配置指南.md
Normal 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)
|
||||
39
pubspec.lock
@@ -57,6 +57,22 @@ packages:
|
||||
relative: true
|
||||
source: path
|
||||
version: "3.2.0-ohos.1"
|
||||
barcode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: barcode
|
||||
sha256: "7b6729c37e3b7f34233e2318d866e8c48ddb46c1f7ad01ff7bb2a8de1da2b9f4"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.2.9"
|
||||
bidi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bidi
|
||||
sha256: "77f475165e94b261745cf1032c751e2032b8ed92ccb2bf5716036db79320637d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.0.13"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -566,6 +582,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "4.8.0"
|
||||
image_picker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -837,6 +861,14 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_parsing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_parsing
|
||||
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -895,6 +927,13 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
pdf:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "packages/pdf/pdf"
|
||||
relative: true
|
||||
source: path
|
||||
version: "3.12.0-ohos.1"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
151
scripts/check_ttf_integrity.py
Normal 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
@@ -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()
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
41
scripts/test_ascii_qr.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('');
|
||||
}
|
||||
157
scripts/test_json_debug_clean.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
343
scripts/test_pdf_garbled_chars.dart
Normal 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});
|
||||
}
|
||||