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:
9
app/lib/core/utils/download_impl.dart
Normal file
9
app/lib/core/utils/download_impl.dart
Normal 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;
|
||||||
|
}
|
||||||
21
app/lib/core/utils/download_impl_web.dart
Normal file
21
app/lib/core/utils/download_impl_web.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/lib/core/utils/file_download.dart
Normal file
23
app/lib/core/utils/file_download.dart
Normal file
@@ -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<bool> downloadJsonFile(
|
||||||
|
Map<String, dynamic> data,
|
||||||
|
String filename,
|
||||||
|
) async {
|
||||||
|
final jsonStr = const JsonEncoder.withIndent(' ').convert(data);
|
||||||
|
return downloadFile(jsonStr, filename, 'application/json');
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
// - 数据删除 → 确认对话框 → ParentDeleteData
|
// - 数据删除 → 确认对话框 → ParentDeleteData
|
||||||
// 保留 PIPL 合规提示。
|
// 保留 PIPL 合规提示。
|
||||||
|
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.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_colors.dart';
|
||||||
import '../../../core/theme/app_radius.dart';
|
import '../../../core/theme/app_radius.dart';
|
||||||
|
import '../../../core/utils/file_download.dart';
|
||||||
import '../bloc/parent_bloc.dart';
|
import '../bloc/parent_bloc.dart';
|
||||||
|
|
||||||
/// 家长中心页面 — 家长查看孩子日记和统计数据
|
/// 家长中心页面 — 家长查看孩子日记和统计数据
|
||||||
@@ -929,7 +932,7 @@ class _JournalCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导出数据视图 — 展示导出结果
|
/// 导出数据视图 — 展示导出结果 + 下载按钮
|
||||||
class _ExportDataView extends StatelessWidget {
|
class _ExportDataView extends StatelessWidget {
|
||||||
const _ExportDataView({
|
const _ExportDataView({
|
||||||
required this.childId,
|
required this.childId,
|
||||||
@@ -1037,7 +1040,27 @@ class _ExportDataView extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
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(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -1048,14 +1071,15 @@ class _ExportDataView extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.info_outline,
|
Icons.shield_outlined,
|
||||||
size: 18,
|
size: 18,
|
||||||
color: AppColors.tertiary,
|
color: AppColors.tertiary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'数据已生成。Phase 1 展示 JSON 预览,后续版本将支持文件下载。',
|
'根据《个人信息保护法》,您有权导出孩子的全部个人数据。'
|
||||||
|
'导出数据仅供个人查阅,请妥善保管。',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: AppColors.fg2Light,
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 导出信息行
|
/// 导出信息行
|
||||||
|
|||||||
Reference in New Issue
Block a user