From f0741450bcebe0c593d206674b107e8d08a95b0f Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 2 Jun 2026 23:36:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E5=AE=B6=E9=95=BF=E7=AB=AF?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=87=BA=20=E2=80=94=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20JSON=20=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD=20+=20?= =?UTF-8?q?=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新建 file_download.dart 跨平台下载工具(conditional import) - download_impl_web.dart: Web 平台通过 html.AnchorElement + Blob 下载 - download_impl.dart: 非 Web 平台 stub(Phase 2 扩展 path_provider) - _ExportDataView: 添加下载按钮 + JSON 折叠预览 + PIPL 提示 - 移除 'Phase 1 预览' 占位文案,替换为完整下载功能 --- app/lib/core/utils/download_impl.dart | 9 ++ app/lib/core/utils/download_impl_web.dart | 21 +++ app/lib/core/utils/file_download.dart | 23 ++++ .../features/parent/views/parent_page.dart | 129 +++++++++++++++++- 4 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 app/lib/core/utils/download_impl.dart create mode 100644 app/lib/core/utils/download_impl_web.dart create mode 100644 app/lib/core/utils/file_download.dart diff --git a/app/lib/core/utils/download_impl.dart b/app/lib/core/utils/download_impl.dart new file mode 100644 index 0000000..6b48c2c --- /dev/null +++ b/app/lib/core/utils/download_impl.dart @@ -0,0 +1,9 @@ +// 文件下载 — 非 Web 平台 stub +// +// 非 Web 平台暂不支持文件下载,返回 false。 +// Phase 2 扩展:使用 path_provider + File 实现。 + +/// 下载文件(stub 实现) +Future downloadFile(String content, String filename, String mimeType) async { + return false; +} diff --git a/app/lib/core/utils/download_impl_web.dart b/app/lib/core/utils/download_impl_web.dart new file mode 100644 index 0000000..8157ddf --- /dev/null +++ b/app/lib/core/utils/download_impl_web.dart @@ -0,0 +1,21 @@ +// 文件下载 — Web 平台实现 +// +// 使用 dart:html 的 AnchorElement + Blob 触发浏览器下载。 +// 通过 conditional import 自动选择此实现。 + +import 'dart:html' as html; + +/// 下载文件(Web 实现) +Future downloadFile(String content, String filename, String mimeType) async { + try { + final blob = html.Blob([content], mimeType); + final url = html.Url.createObjectUrlFromBlob(blob); + html.AnchorElement(href: url) + ..setAttribute('download', filename) + ..click(); + html.Url.revokeObjectUrl(url); + return true; + } catch (e) { + return false; + } +} diff --git a/app/lib/core/utils/file_download.dart b/app/lib/core/utils/file_download.dart new file mode 100644 index 0000000..516f9eb --- /dev/null +++ b/app/lib/core/utils/file_download.dart @@ -0,0 +1,23 @@ +// 文件下载工具 — 跨平台接口 +// +// Web: 通过 html.AnchorElement + Blob 触发浏览器下载 +// 非 Web: 返回 false(Phase 2 扩展 path_provider) + +import 'dart:convert'; + +import 'download_impl.dart' + if (dart.library.html) 'download_impl_web.dart'; + +/// 下载 JSON 数据为文件 +/// +/// [data] — 要导出的 JSON 数据 +/// [filename] — 下载文件名(如 "export_2026-06-02.json") +/// +/// 返回 true 表示下载成功。 +Future downloadJsonFile( + Map data, + String filename, +) async { + final jsonStr = const JsonEncoder.withIndent(' ').convert(data); + return downloadFile(jsonStr, filename, 'application/json'); +} diff --git a/app/lib/features/parent/views/parent_page.dart b/app/lib/features/parent/views/parent_page.dart index ce09df5..d7aaa2e 100644 --- a/app/lib/features/parent/views/parent_page.dart +++ b/app/lib/features/parent/views/parent_page.dart @@ -9,6 +9,8 @@ // - 数据删除 → 确认对话框 → ParentDeleteData // 保留 PIPL 合规提示。 +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -16,6 +18,7 @@ import 'package:intl/intl.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_radius.dart'; +import '../../../core/utils/file_download.dart'; import '../bloc/parent_bloc.dart'; /// 家长中心页面 — 家长查看孩子日记和统计数据 @@ -929,7 +932,7 @@ class _JournalCard extends StatelessWidget { } } -/// 导出数据视图 — 展示导出结果 +/// 导出数据视图 — 展示导出结果 + 下载按钮 class _ExportDataView extends StatelessWidget { const _ExportDataView({ required this.childId, @@ -1037,7 +1040,27 @@ class _ExportDataView extends StatelessWidget { ), ), const SizedBox(height: 20), - // 提示 + // 下载按钮 + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => _handleDownload(context), + icon: const Icon(Icons.download_rounded, size: 20), + label: const Text('下载 JSON 文件'), + style: FilledButton.styleFrom( + backgroundColor: AppColors.secondary, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.smBorder, + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ), + const SizedBox(height: 20), + // JSON 预览(折叠面板) + _JsonPreviewCard(data: data), + const SizedBox(height: 16), + // PIPL 提示 Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( @@ -1048,14 +1071,15 @@ class _ExportDataView extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon( - Icons.info_outline, + Icons.shield_outlined, size: 18, color: AppColors.tertiary, ), const SizedBox(width: 8), Expanded( child: Text( - '数据已生成。Phase 1 展示 JSON 预览,后续版本将支持文件下载。', + '根据《个人信息保护法》,您有权导出孩子的全部个人数据。' + '导出数据仅供个人查阅,请妥善保管。', style: theme.textTheme.bodySmall?.copyWith( color: AppColors.fg2Light, ), @@ -1071,6 +1095,103 @@ class _ExportDataView extends StatelessWidget { ], ); } + + /// 触发文件下载 + Future _handleDownload(BuildContext context) async { + final filename = '暖记_数据导出_${DateFormat('yyyy-MM-dd').format(DateTime.now())}.json'; + final success = await downloadJsonFile(data, filename); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(success ? '文件已开始下载' : '下载失败,请重试'), + backgroundColor: success ? AppColors.success : AppColors.error, + ), + ); + } + } +} + +/// JSON 预览折叠卡片 +class _JsonPreviewCard extends StatefulWidget { + const _JsonPreviewCard({required this.data}); + + final Map data; + + @override + State<_JsonPreviewCard> createState() => _JsonPreviewCardState(); +} + +class _JsonPreviewCardState extends State<_JsonPreviewCard> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: AppRadius.mdBorder, + side: BorderSide(color: colorScheme.outlineVariant), + ), + child: Column( + children: [ + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + borderRadius: BorderRadius.vertical( + top: const Radius.circular(12), + bottom: _expanded ? Radius.zero : const Radius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon( + Icons.data_object, + size: 20, + color: colorScheme.onSurface.withValues(alpha: 0.5), + ), + const SizedBox(width: 12), + Text( + 'JSON 数据预览', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Icon( + _expanded ? Icons.expand_less : Icons.expand_more, + size: 20, + color: colorScheme.onSurface.withValues(alpha: 0.4), + ), + ], + ), + ), + ), + if (_expanded) ...[ + const Divider(height: 1), + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 300), + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Text( + const JsonEncoder.withIndent(' ').convert(widget.data), + style: TextStyle( + fontFamily: 'JetBrains Mono', + fontSize: 12, + height: 1.5, + color: colorScheme.onSurface.withValues(alpha: 0.7), + ), + ), + ), + ), + ], + ], + ), + ); + } } /// 导出信息行