feat(app): 家长端数据导出 — 添加 JSON 文件下载 + 预览

- 新建 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 预览' 占位文案,替换为完整下载功能
This commit is contained in:
iven
2026-06-02 23:36:35 +08:00
parent c9a69d0be1
commit f0741450bc
4 changed files with 178 additions and 4 deletions

View File

@@ -0,0 +1,9 @@
// 文件下载 — 非 Web 平台 stub
//
// 非 Web 平台暂不支持文件下载,返回 false。
// Phase 2 扩展:使用 path_provider + File 实现。
/// 下载文件stub 实现)
Future<bool> downloadFile(String content, String filename, String mimeType) async {
return false;
}

View File

@@ -0,0 +1,21 @@
// 文件下载 — Web 平台实现
//
// 使用 dart:html 的 AnchorElement + Blob 触发浏览器下载。
// 通过 conditional import 自动选择此实现。
import 'dart:html' as html;
/// 下载文件Web 实现)
Future<bool> 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;
}
}

View File

@@ -0,0 +1,23 @@
// 文件下载工具 — 跨平台接口
//
// Web: 通过 html.AnchorElement + Blob 触发浏览器下载
// 非 Web: 返回 falsePhase 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<bool> downloadJsonFile(
Map<String, dynamic> data,
String filename,
) async {
final jsonStr = const JsonEncoder.withIndent(' ').convert(data);
return downloadFile(jsonStr, filename, 'application/json');
}

View File

@@ -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<void> _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<String, dynamic> 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),
),
),
),
),
],
],
),
);
}
}
/// 导出信息行