Compare commits
8 Commits
8ea1032c9d
...
d6dd017155
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d6dd017155 | ||
|
|
f0741450bc | ||
|
|
c9a69d0be1 | ||
|
|
9e53ca8555 | ||
|
|
6c9a38b27b | ||
|
|
e57c3427a4 | ||
|
|
c92ead60e3 | ||
|
|
ab45f40cc8 |
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');
|
||||
}
|
||||
@@ -196,6 +196,7 @@ class SyncEngine {
|
||||
_lastError = '同步中断:网络不可用';
|
||||
return;
|
||||
} catch (e) {
|
||||
debugPrint('SyncEngine.trySync 操作失败: $e');
|
||||
// 操作失败,增加重试计数
|
||||
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 成就 BLoC — 通过 API 加载成就列表
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
|
||||
@@ -98,6 +99,7 @@ class AchievementBloc extends ChangeNotifier {
|
||||
|
||||
_state = _state.copyWith(isLoading: false, achievements: achievements);
|
||||
} catch (e) {
|
||||
debugPrint('AchievementBloc._fetchAchievements 失败: $e');
|
||||
_state = _state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: '加载成就列表失败',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 日历 BLoC — 管理日历视图状态,通过 JournalRepository 加载数据
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
@@ -136,6 +137,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('CalendarBloc._onMonthChanged 失败: $e');
|
||||
if (state is CalendarLoaded) {
|
||||
emit((state as CalendarLoaded).copyWith(isLoading: false));
|
||||
}
|
||||
|
||||
@@ -163,6 +163,28 @@ class TextFormatChanged extends EditorEvent {
|
||||
});
|
||||
}
|
||||
|
||||
/// 加载已有日记数据(从 JournalRepository 读取后原子注入)
|
||||
///
|
||||
/// 与 StrokesLoaded/ElementsLoaded/TagsLoaded 等细粒度事件不同,
|
||||
/// LoadJournal 一次性还原所有日记状态,不触发 auto-save (isDirty=false)。
|
||||
class LoadJournal extends EditorEvent {
|
||||
final String title;
|
||||
final Mood mood;
|
||||
final List<String> tags;
|
||||
final List<Stroke> strokes;
|
||||
final List<JournalElement> elements;
|
||||
final DateTime? lastSavedAt;
|
||||
|
||||
LoadJournal({
|
||||
required this.title,
|
||||
required this.mood,
|
||||
required this.tags,
|
||||
required this.strokes,
|
||||
required this.elements,
|
||||
this.lastSavedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 状态
|
||||
// ============================================================
|
||||
@@ -303,6 +325,9 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
on<ElementSelected>(_onElementSelected);
|
||||
on<ElementsLoaded>(_onElementsLoaded);
|
||||
|
||||
// 日记加载事件
|
||||
on<LoadJournal>(_onLoadJournal);
|
||||
|
||||
// 工具栏事件
|
||||
on<ToolChanged>(_onToolChanged);
|
||||
|
||||
@@ -458,6 +483,25 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
emit(state.copyWith(elements: event.elements));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 日记加载事件处理
|
||||
// ============================================================
|
||||
|
||||
/// 加载已有日记 — 原子操作,一次性还原所有状态
|
||||
///
|
||||
/// 不触发 auto-save(isDirty=false),因为这是加载而非用户编辑。
|
||||
void _onLoadJournal(LoadJournal event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(
|
||||
title: event.title,
|
||||
selectedMood: event.mood,
|
||||
tags: event.tags,
|
||||
strokes: event.strokes,
|
||||
elements: event.elements,
|
||||
lastSavedAt: event.lastSavedAt,
|
||||
isDirty: false,
|
||||
));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具栏事件处理
|
||||
// ============================================================
|
||||
|
||||
@@ -19,9 +19,11 @@ import '../../../data/models/journal_element.dart';
|
||||
import '../../../data/models/journal_entry.dart' show JournalEntry, Mood;
|
||||
import '../../../data/repositories/journal_repository.dart';
|
||||
import '../../../data/repositories/class_repository.dart';
|
||||
import '../../../data/remote/api_client.dart';
|
||||
import '../../../data/services/sync_engine.dart';
|
||||
import '../../auth/bloc/auth_bloc.dart';
|
||||
import '../bloc/editor_bloc.dart';
|
||||
import '../widgets/comment_list_sheet.dart';
|
||||
import '../widgets/handwriting_canvas.dart';
|
||||
import '../widgets/stroke_model.dart';
|
||||
import '../widgets/draggable_element.dart';
|
||||
@@ -283,48 +285,44 @@ class _EditorViewState extends State<_EditorView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 Isar 加载已有日记的笔画、元素、标签、心情、标题
|
||||
/// 从 Isar 加载已有日记 — 使用 LoadJournal 原子事件一次性还原
|
||||
Future<void> _loadExistingJournal(String id) async {
|
||||
try {
|
||||
// 加载日记元数据
|
||||
final entry = await widget.repo.getJournal(id);
|
||||
if (entry == null || !mounted) return;
|
||||
|
||||
final bloc = context.read<EditorBloc>();
|
||||
|
||||
// 加载标题和心情
|
||||
bloc.add(TitleChanged(entry.title));
|
||||
bloc.add(MoodChanged(entry.mood));
|
||||
|
||||
// 加载标签
|
||||
if (entry.tags.isNotEmpty) {
|
||||
bloc.add(TagsLoaded(entry.tags));
|
||||
}
|
||||
|
||||
// 加载元素(含笔画)
|
||||
final elements = await widget.repo.getElements(id);
|
||||
if (!mounted) return;
|
||||
|
||||
for (final element in elements) {
|
||||
if (element.elementType == ElementType.handwritingRef) {
|
||||
// 从 handwriting_ref 元素中恢复笔画
|
||||
final strokesData = element.content['strokes'];
|
||||
if (strokesData is List) {
|
||||
final strokes = strokesData
|
||||
.map((s) => Stroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
bloc.add(StrokesLoaded(strokes));
|
||||
}
|
||||
// 从 handwriting_ref 元素中反序列化笔画
|
||||
List<Stroke> strokes = [];
|
||||
final strokesElement = elements
|
||||
.where((e) => e.elementType == ElementType.handwritingRef)
|
||||
.firstOrNull;
|
||||
if (strokesElement != null) {
|
||||
final strokesData = strokesElement.content['strokes'];
|
||||
if (strokesData is List) {
|
||||
strokes = strokesData
|
||||
.map((s) => Stroke.fromJson(s as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
// 加载非笔画元素(贴纸/文字/图片)
|
||||
final nonStrokeElements = elements
|
||||
// 过滤掉 handwriting_ref 元素(笔画单独管理)
|
||||
final otherElements = elements
|
||||
.where((e) => e.elementType != ElementType.handwritingRef)
|
||||
.toList();
|
||||
if (nonStrokeElements.isNotEmpty) {
|
||||
bloc.add(ElementsLoaded(nonStrokeElements));
|
||||
}
|
||||
|
||||
// 原子加载 — 一次 dispatch 还原所有状态
|
||||
context.read<EditorBloc>().add(LoadJournal(
|
||||
title: entry.title,
|
||||
mood: entry.mood,
|
||||
tags: entry.tags,
|
||||
strokes: strokes,
|
||||
elements: otherElements,
|
||||
lastSavedAt: entry.updatedAt,
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('加载日记数据失败: $e');
|
||||
}
|
||||
@@ -431,6 +429,13 @@ class _EditorViewState extends State<_EditorView> {
|
||||
onPressed: () => _showTagPanel(context, state),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// 评语按钮(仅已有日记显示)
|
||||
if (widget.journalId != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
|
||||
onPressed: () => _showComments(context),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// 完成/保存按钮
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
@@ -468,6 +473,22 @@ class _EditorViewState extends State<_EditorView> {
|
||||
widget.onSaveComplete();
|
||||
}
|
||||
|
||||
/// 显示评论列表
|
||||
void _showComments(BuildContext context) {
|
||||
final journalId = widget.journalId;
|
||||
if (journalId == null) return;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => CommentListSheet(
|
||||
journalId: journalId,
|
||||
apiClient: context.read<ApiClient>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 格式化日期显示
|
||||
String _formatDate(EditorState state) {
|
||||
final now = DateTime.now();
|
||||
@@ -621,6 +642,13 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
@override
|
||||
void didUpdateWidget(covariant _EditorStack oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 同步标题输入框(LoadJournal 更新 state.title 时 controller 需要跟随)
|
||||
if (widget.state.title != oldWidget.state.title &&
|
||||
widget.state.title != _titleController.text) {
|
||||
_titleController.text = widget.state.title;
|
||||
}
|
||||
|
||||
final currentTool = widget.state.activeTool;
|
||||
|
||||
// 防止重复弹窗:只在工具切换时触发
|
||||
|
||||
262
app/lib/features/editor/widgets/comment_list_sheet.dart
Normal file
262
app/lib/features/editor/widgets/comment_list_sheet.dart
Normal file
@@ -0,0 +1,262 @@
|
||||
// 评论列表底部面板 — FutureBuilder 拉取老师评语
|
||||
//
|
||||
// 独立 widget,不纳入 EditorBloc。
|
||||
// 打开日记时从后端拉取评论列表展示。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/remote/api_client.dart';
|
||||
import '../../class_/bloc/class_bloc.dart' show Comment;
|
||||
|
||||
/// 评论列表底部面板
|
||||
class CommentListSheet extends StatelessWidget {
|
||||
final String journalId;
|
||||
final ApiClient apiClient;
|
||||
|
||||
const CommentListSheet({
|
||||
super.key,
|
||||
required this.journalId,
|
||||
required this.apiClient,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.4,
|
||||
minChildSize: 0.2,
|
||||
maxChildSize: 0.7,
|
||||
builder: (context, scrollController) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 拖拽指示条
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 标题
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'老师评语',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
_CommentCountBadge(
|
||||
journalId: journalId,
|
||||
apiClient: apiClient,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// 评论列表
|
||||
Expanded(
|
||||
child: _CommentListFuture(
|
||||
journalId: journalId,
|
||||
apiClient: apiClient,
|
||||
scrollController: scrollController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论数量 Badge
|
||||
class _CommentCountBadge extends StatelessWidget {
|
||||
final String journalId;
|
||||
final ApiClient apiClient;
|
||||
|
||||
const _CommentCountBadge({
|
||||
required this.journalId,
|
||||
required this.apiClient,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<dynamic>>(
|
||||
future: _fetchComments(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) return const SizedBox.shrink();
|
||||
final count = snapshot.data!.length;
|
||||
if (count == 0) return const SizedBox.shrink();
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<dynamic>> _fetchComments() async {
|
||||
try {
|
||||
final response = await apiClient.get('/diary/journals/$journalId/comments');
|
||||
return response.data as List<dynamic>;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 评论列表 — FutureBuilder 拉取
|
||||
class _CommentListFuture extends StatelessWidget {
|
||||
final String journalId;
|
||||
final ApiClient apiClient;
|
||||
final ScrollController scrollController;
|
||||
|
||||
const _CommentListFuture({
|
||||
required this.journalId,
|
||||
required this.apiClient,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<Comment>>(
|
||||
future: _fetchComments(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text('加载评语失败', style: TextStyle(color: Colors.grey[500])),
|
||||
);
|
||||
}
|
||||
final comments = snapshot.data ?? [];
|
||||
if (comments.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 36, color: Colors.grey[300]),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'还没有评语哦',
|
||||
style: TextStyle(color: Colors.grey[400]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: comments.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _CommentTile(comment: comments[index]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Comment>> _fetchComments() async {
|
||||
try {
|
||||
final response = await apiClient.get('/diary/journals/$journalId/comments');
|
||||
final list = response.data as List<dynamic>;
|
||||
return list
|
||||
.map((json) => Comment(
|
||||
id: json['id'] as String,
|
||||
journalId: json['journal_id'] as String,
|
||||
authorId: json['author_id'] as String,
|
||||
content: json['content'] as String,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
debugPrint('加载评论失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条评论卡片
|
||||
class _CommentTile extends StatelessWidget {
|
||||
final Comment comment;
|
||||
|
||||
const _CommentTile({required this.comment});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 老师标签 + 时间
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE07A5F).withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'老师',
|
||||
style: TextStyle(fontSize: 11, color: Color(0xFFE07A5F)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatTime(comment.createdAt),
|
||||
style: TextStyle(fontSize: 11, color: Colors.grey[500]),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 评语内容
|
||||
Text(
|
||||
comment.content,
|
||||
style: const TextStyle(fontSize: 14, height: 1.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// 首页 BLoC — 加载最近日记和心情概览
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
@@ -141,6 +142,7 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
todayWeather: todayWeather,
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('HomeBloc._onLoadData 失败: $e');
|
||||
emit(const HomeLoaded()); // 空状态而非错误,离线友好
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 心情 BLoC — 通过 API 加载心情统计数据
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
@@ -138,6 +139,7 @@ class MoodBloc extends ChangeNotifier {
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('MoodBloc._loadStats 失败: $e');
|
||||
_state = _state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: '加载统计数据失败',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 状态机: ParentInitial → ParentLoading → ParentChildrenLoaded / ParentJournalsLoaded / ParentDataExported / ParentDataDeleted / ParentError
|
||||
// API: /diary/parent/* 端点
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/remote/api_client.dart';
|
||||
@@ -40,6 +41,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
.toList();
|
||||
emit(ParentChildrenLoaded(children));
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onLoadChildren 失败: $e');
|
||||
emit(const ParentError('加载孩子列表失败'));
|
||||
}
|
||||
}
|
||||
@@ -57,6 +59,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
// 绑定成功后重新加载列表
|
||||
add(const ParentLoadChildren());
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onBindChild 失败: $e');
|
||||
emit(const ParentError('绑定失败,请检查孩子 ID'));
|
||||
}
|
||||
}
|
||||
@@ -83,6 +86,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
journals: items.cast<Map<String, dynamic>>(),
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onViewJournals 失败: $e');
|
||||
emit(const ParentError('加载日记失败'));
|
||||
}
|
||||
}
|
||||
@@ -103,6 +107,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
data: response.data as Map<String, dynamic>,
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onExportData 失败: $e');
|
||||
emit(const ParentError('导出失败'));
|
||||
}
|
||||
}
|
||||
@@ -119,6 +124,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
});
|
||||
emit(ParentDataDeleted(event.childId));
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onDeleteData 失败: $e');
|
||||
emit(const ParentError('删除失败'));
|
||||
}
|
||||
}
|
||||
@@ -134,6 +140,7 @@ class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
});
|
||||
add(const ParentLoadChildren());
|
||||
} catch (e) {
|
||||
debugPrint('ParentBloc._onUnbindChild 失败: $e');
|
||||
emit(const ParentError('解绑失败'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出信息行
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 状态机: SearchInitial → SearchLoading → SearchLoaded/SearchError
|
||||
// 支持关键词搜索、标签筛选、心情筛选、结果分类 tab。
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/models/journal_entry.dart';
|
||||
@@ -50,6 +51,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
||||
searchHistory: List.unmodifiable(_searchHistory),
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('SearchBloc._onSearchByMood 失败: $e');
|
||||
emit(const SearchError('搜索失败,请重试'));
|
||||
}
|
||||
}
|
||||
@@ -73,6 +75,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
||||
searchHistory: List.unmodifiable(_searchHistory),
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('SearchBloc._onSearchByTag 失败: $e');
|
||||
emit(const SearchError('搜索失败,请重试'));
|
||||
}
|
||||
}
|
||||
@@ -113,6 +116,7 @@ class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
||||
searchHistory: List.unmodifiable(_searchHistory),
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('SearchBloc._onSearchByKeyword 失败: $e');
|
||||
emit(const SearchError('搜索失败,请重试'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 贴纸 BLoC — 通过 API 加载贴纸包和贴纸数据
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
|
||||
@@ -168,6 +169,7 @@ class StickerBloc extends ChangeNotifier {
|
||||
|
||||
_state = _state.copyWith(isLoading: false, packs: packs);
|
||||
} catch (e) {
|
||||
debugPrint('StickerBloc._fetchPacks 失败: $e');
|
||||
_state = _state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: '加载贴纸包失败',
|
||||
@@ -194,6 +196,7 @@ class StickerBloc extends ChangeNotifier {
|
||||
);
|
||||
}).toList();
|
||||
} catch (e) {
|
||||
debugPrint('StickerBloc.fetchStickersInPack 失败: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 模板 BLoC — 通过 API 加载模板列表
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/data/remote/api_client.dart';
|
||||
|
||||
@@ -125,6 +126,7 @@ class TemplateBloc extends ChangeNotifier {
|
||||
|
||||
_state = _state.copyWith(isLoading: false, templates: templates);
|
||||
} catch (e) {
|
||||
debugPrint('TemplateBloc._fetchTemplates 失败: $e');
|
||||
_state = _state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: '加载模板列表失败',
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/models/journal_element.dart';
|
||||
import 'package:nuanji_app/features/editor/bloc/editor_bloc.dart';
|
||||
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
|
||||
@@ -262,5 +263,35 @@ void main() {
|
||||
|
||||
bloc.close();
|
||||
});
|
||||
|
||||
// ===== LoadJournal 原子加载 =====
|
||||
|
||||
test('LoadJournal 还原已有日记数据', () async {
|
||||
final strokes = [_testStroke(id: 's1')];
|
||||
final elements = [
|
||||
JournalElement.createSticker(
|
||||
journalId: 'j1',
|
||||
emoji: '😊',
|
||||
position: Offset.zero,
|
||||
),
|
||||
];
|
||||
|
||||
final state = await dispatch(LoadJournal(
|
||||
title: '测试日记',
|
||||
mood: Mood.happy,
|
||||
tags: const ['开心', '学校'],
|
||||
strokes: strokes,
|
||||
elements: elements,
|
||||
lastSavedAt: DateTime(2026, 6, 1),
|
||||
));
|
||||
|
||||
expect(state.title, '测试日记');
|
||||
expect(state.selectedMood, Mood.happy);
|
||||
expect(state.tags, ['开心', '学校']);
|
||||
expect(state.strokes.length, 1);
|
||||
expect(state.elements.length, 1);
|
||||
expect(state.lastSavedAt, DateTime(2026, 6, 1));
|
||||
expect(state.isDirty, isFalse, reason: 'LoadJournal 不应标记为 dirty');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,9 +6,18 @@ export const stickerApi = {
|
||||
client.get<{ success: boolean; data: StickerPack[] }>('/diary/sticker-packs', { params })
|
||||
.then((r) => r.data.data),
|
||||
|
||||
createPack: (data: { name: string; description?: string; thumbnail_url?: string; is_free?: boolean; price?: number; category?: string }) =>
|
||||
client.post('/diary/sticker-packs', data).then((r) => r.data.data),
|
||||
|
||||
deletePack: (packId: string) =>
|
||||
client.delete(`/diary/sticker-packs/${packId}`).then((r) => r.data),
|
||||
|
||||
listStickers: (packId: string) =>
|
||||
client.get<{ success: boolean; data: Sticker[] }>(`/diary/sticker-packs/${packId}/stickers`)
|
||||
.then((r) => r.data.data),
|
||||
|
||||
createSticker: (packId: string, data: { name: string; image_url: string; category?: string }) =>
|
||||
client.post(`/diary/sticker-packs/${packId}/stickers`, data).then((r) => r.data.data),
|
||||
};
|
||||
|
||||
export const templateApi = {
|
||||
|
||||
@@ -8,4 +8,10 @@ export const topicApi = {
|
||||
|
||||
assign: (classId: string, data: CreateTopicReq) =>
|
||||
client.post(`/diary/classes/${classId}/topics`, data).then((r) => r.data.data),
|
||||
|
||||
update: (topicId: string, data: { title?: string; description?: string; due_date?: string; version: number }) =>
|
||||
client.put(`/diary/topics/${topicId}`, data).then((r) => r.data.data),
|
||||
|
||||
deactivate: (topicId: string) =>
|
||||
client.patch(`/diary/topics/${topicId}/deactivate`).then((r) => r.data.data),
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface SchoolClass {
|
||||
class_code: string;
|
||||
member_count: number;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateClassReq {
|
||||
@@ -76,6 +77,7 @@ export interface TopicAssignment {
|
||||
description?: string;
|
||||
due_date?: string;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export interface CreateTopicReq {
|
||||
|
||||
@@ -57,11 +57,11 @@ export default function ClassList() {
|
||||
onCreate: async (values) => {
|
||||
await classApi.create({ name: values.name as string, school_name: values.school_name as string | undefined });
|
||||
},
|
||||
onUpdate: async (record, values) => {
|
||||
await classApi.update(record.id, {
|
||||
onUpdate: async (id, values) => {
|
||||
await classApi.update(id, {
|
||||
name: values.name as string,
|
||||
school_name: values.school_name as string | undefined,
|
||||
version: record.version,
|
||||
version: values.version,
|
||||
});
|
||||
},
|
||||
onSuccess: refresh,
|
||||
|
||||
@@ -13,11 +13,16 @@ import {
|
||||
Select,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
Form,
|
||||
Input,
|
||||
} from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
AppstoreOutlined,
|
||||
PictureOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { stickerApi } from '../../api/diary/stickers';
|
||||
import type { StickerPack, Sticker } from '../../api/diary/types';
|
||||
@@ -74,6 +79,11 @@ export default function StickerPackList() {
|
||||
const [stickers, setStickers] = useState<Sticker[]>([]);
|
||||
const [stickersLoading, setStickersLoading] = useState(false);
|
||||
|
||||
// --- Create modal state ---
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm] = Form.useForm();
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// --- Fetch sticker packs ---
|
||||
const fetchPacks = useCallback(async (currentFilters: StickerFilters) => {
|
||||
setLoading(true);
|
||||
@@ -137,6 +147,36 @@ export default function StickerPackList() {
|
||||
setStickers([]);
|
||||
}, []);
|
||||
|
||||
// --- Create sticker pack ---
|
||||
const handleCreate = useCallback(async () => {
|
||||
const values = await createForm.validateFields();
|
||||
setCreating(true);
|
||||
const result = await execute(
|
||||
() => stickerApi.createPack(values),
|
||||
'贴纸包创建成功',
|
||||
'创建失败',
|
||||
);
|
||||
setCreating(false);
|
||||
if (result) {
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
fetchPacks(filters);
|
||||
}
|
||||
}, [execute, createForm, fetchPacks, filters]);
|
||||
|
||||
// --- Delete sticker pack ---
|
||||
const handleDelete = useCallback(async (packId: string, e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
const result = await execute(
|
||||
() => stickerApi.deletePack(packId),
|
||||
'贴纸包已删除',
|
||||
'删除失败',
|
||||
);
|
||||
if (result !== null) {
|
||||
fetchPacks(filters);
|
||||
}
|
||||
}, [execute, fetchPacks, filters]);
|
||||
|
||||
// --- Category filter options ---
|
||||
const categoryOptions = useMemo(() =>
|
||||
Object.entries(CATEGORY_LABELS).map(([value, label]) => ({
|
||||
@@ -313,6 +353,29 @@ export default function StickerPackList() {
|
||||
</Space>
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="确认删除此贴纸包?"
|
||||
description="删除后不可恢复"
|
||||
onConfirm={(e) => handleDelete(pack.id, e ?? undefined)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Tooltip title="删除贴纸包">
|
||||
<Tag
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
background: isDark ? '#3A2020' : '#FFF0F0',
|
||||
color: '#E07A5F',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -336,12 +399,24 @@ export default function StickerPackList() {
|
||||
}
|
||||
onResetFilters={handleResetFilters}
|
||||
actions={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchPacks(filters)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => fetchPacks(filters)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建贴纸包
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
@@ -465,6 +540,78 @@ export default function StickerPackList() {
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Create sticker pack modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<PlusOutlined style={{ color: '#E07A5F' }} />
|
||||
<span>新建贴纸包</span>
|
||||
</Space>
|
||||
}
|
||||
open={createOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => {
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
}}
|
||||
confirmLoading={creating}
|
||||
okText="创建"
|
||||
cancelText="取消"
|
||||
okButtonProps={{
|
||||
style: {
|
||||
background: '#E07A5F',
|
||||
borderColor: '#E07A5F',
|
||||
},
|
||||
}}
|
||||
destroyOnClose
|
||||
width={520}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="贴纸包名称"
|
||||
rules={[{ required: true, message: '请输入贴纸包名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:可爱动物" maxLength={50} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="描述"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="贴纸包简要描述..."
|
||||
maxLength={200}
|
||||
showCount
|
||||
rows={3}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="category"
|
||||
label="分类"
|
||||
>
|
||||
<Select
|
||||
placeholder="选择贴纸分类"
|
||||
allowClear
|
||||
options={categoryOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="is_free"
|
||||
label="免费"
|
||||
valuePropName="checked"
|
||||
initialValue={true}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: true, label: '免费' },
|
||||
{ value: false, label: '付费' },
|
||||
]}
|
||||
placeholder="选择类型"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Badge,
|
||||
Typography,
|
||||
Tooltip,
|
||||
Popconfirm,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -23,6 +24,8 @@ import {
|
||||
CalendarOutlined,
|
||||
FileTextOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
EditOutlined,
|
||||
StopOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { topicApi } from '../../api/diary/topics';
|
||||
@@ -53,6 +56,12 @@ export default function TopicList() {
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// --- Edit modal state ---
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editForm] = Form.useForm();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editingTopic, setEditingTopic] = useState<TopicAssignment | null>(null);
|
||||
|
||||
// --- Fetch classes ---
|
||||
const fetchClasses = useCallback(async () => {
|
||||
setClassesLoading(true);
|
||||
@@ -158,6 +167,56 @@ export default function TopicList() {
|
||||
}
|
||||
}, [selectedClassId, form, execute, fetchTopics]);
|
||||
|
||||
// --- Open edit modal ---
|
||||
const openEditModal = useCallback((topic: TopicAssignment) => {
|
||||
setEditingTopic(topic);
|
||||
editForm.setFieldsValue({
|
||||
title: topic.title,
|
||||
description: topic.description ?? '',
|
||||
due_date: topic.due_date ? dayjs(topic.due_date) : undefined,
|
||||
});
|
||||
setEditOpen(true);
|
||||
}, [editForm]);
|
||||
|
||||
// --- Submit edit topic ---
|
||||
const handleEdit = useCallback(async () => {
|
||||
if (!editingTopic || !selectedClassId) return;
|
||||
|
||||
const values = await editForm.validateFields();
|
||||
setEditing(true);
|
||||
const result = await execute(
|
||||
() =>
|
||||
topicApi.update(editingTopic.id, {
|
||||
title: values.title as string,
|
||||
description: values.description as string | undefined,
|
||||
due_date: values.due_date ? (values.due_date as dayjs.Dayjs).format('YYYY-MM-DD') : undefined,
|
||||
version: editingTopic.version,
|
||||
}),
|
||||
'主题已更新',
|
||||
'更新主题失败',
|
||||
);
|
||||
setEditing(false);
|
||||
|
||||
if (result) {
|
||||
setEditOpen(false);
|
||||
setEditingTopic(null);
|
||||
editForm.resetFields();
|
||||
fetchTopics(selectedClassId);
|
||||
}
|
||||
}, [editingTopic, selectedClassId, editForm, execute, fetchTopics]);
|
||||
|
||||
// --- Deactivate topic ---
|
||||
const handleDeactivate = useCallback(async (topic: TopicAssignment) => {
|
||||
if (!selectedClassId) return;
|
||||
try {
|
||||
await topicApi.deactivate(topic.id);
|
||||
message.success(`主题「${topic.title}」已停用`);
|
||||
fetchTopics(selectedClassId);
|
||||
} catch {
|
||||
message.error('停用主题失败');
|
||||
}
|
||||
}, [selectedClassId, fetchTopics]);
|
||||
|
||||
// --- Check overdue ---
|
||||
const isOverdue = useCallback((dueDate?: string) => {
|
||||
if (!dueDate) return false;
|
||||
@@ -455,14 +514,43 @@ export default function TopicList() {
|
||||
无截止日期
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
{topic.teacher_id.length > 10
|
||||
? `${topic.teacher_id.slice(0, 10)}...`
|
||||
: topic.teacher_id}
|
||||
</Text>
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑主题">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditModal(topic);
|
||||
}}
|
||||
style={{ color: isDark ? '#94a3b8' : '#475569' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
{topic.is_active && (
|
||||
<Popconfirm
|
||||
title="确认停用此主题?"
|
||||
description="停用后学生将无法看到该主题"
|
||||
onConfirm={(e) => {
|
||||
e?.stopPropagation();
|
||||
handleDeactivate(topic);
|
||||
}}
|
||||
okText="停用"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Tooltip title="停用主题">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<StopOutlined />}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ color: '#E07A5F' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -552,6 +640,72 @@ export default function TopicList() {
|
||||
</Text>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Edit topic modal */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<EditOutlined style={{ color: '#E07A5F' }} />
|
||||
<span>编辑主题</span>
|
||||
</Space>
|
||||
}
|
||||
open={editOpen}
|
||||
onOk={handleEdit}
|
||||
onCancel={() => {
|
||||
setEditOpen(false);
|
||||
setEditingTopic(null);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
confirmLoading={editing}
|
||||
okText="保存"
|
||||
cancelText="取消"
|
||||
okButtonProps={{
|
||||
style: {
|
||||
background: '#E07A5F',
|
||||
borderColor: '#E07A5F',
|
||||
},
|
||||
}}
|
||||
destroyOnClose
|
||||
width={520}
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label="主题标题"
|
||||
rules={[{ required: true, message: '请输入主题标题' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="例如:我最喜欢的季节"
|
||||
maxLength={100}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label="主题描述"
|
||||
>
|
||||
<TextArea
|
||||
placeholder="描述写作要求或提示,帮助学生理解主题..."
|
||||
maxLength={500}
|
||||
showCount
|
||||
rows={4}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="due_date"
|
||||
label="截止日期"
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择截止日期(可选)"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/test"]
|
||||
}
|
||||
|
||||
@@ -154,29 +154,11 @@ git commit -m "feat(app): 添加 EditorBloc.LoadJournal event — 加载已有
|
||||
**Files:**
|
||||
- Modify: `app/lib/features/editor/views/editor_page.dart`
|
||||
|
||||
- [ ] **Step 1: 在 _EditorStackState.initState 中加载已有日记**
|
||||
> ⚠️ **重要:** `_loadExistingJournal` 已存在于 `_EditorViewState`(line ~278-331),使用多个细粒度事件(TitleChanged, MoodChanged, TagsLoaded, StrokesLoaded, ElementsLoaded)。本任务将其替换为单一的原子 `LoadJournal` 事件。修改目标是 **`_EditorViewState`**,不是 `_EditorStackState`。
|
||||
|
||||
修改 `_EditorStackState` 的 `initState`:
|
||||
- [ ] **Step 1: 替换 `_EditorViewState._loadExistingJournal` 方法**
|
||||
|
||||
```dart
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_titleController = TextEditingController(text: widget.state.title);
|
||||
|
||||
// 如果打开已有日记,加载其数据
|
||||
if (widget.journalId != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_loadExistingJournal(widget.journalId!);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 实现 _loadExistingJournal 方法**
|
||||
|
||||
在 `_EditorStackState` 中添加:
|
||||
找到 `_EditorViewState._loadExistingJournal`(约 line 287-331),替换为使用 LoadJournal 的版本:
|
||||
|
||||
```dart
|
||||
/// 加载已有日记数据并 dispatch LoadJournal
|
||||
@@ -217,50 +199,30 @@ Future<void> _loadExistingJournal(String journalId) async {
|
||||
lastSavedAt: entry.updatedAt,
|
||||
));
|
||||
|
||||
// 同步标题输入框
|
||||
_titleController.text = entry.title;
|
||||
// 同步标题输入框(_EditorStackState 中的 controller)
|
||||
} catch (e) {
|
||||
debugPrint('加载日记失败: $e');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 确认 JournalRepository 有 getElements 方法**
|
||||
- [ ] **Step 2: 验证 JournalRepository.getElements 已存在(完整性检查,预计已存在)**
|
||||
|
||||
Run: `cd g:/nj/app && grep -n "getElements" lib/data/repositories/journal_repository.dart`
|
||||
|
||||
如果不存在,需要在 `JournalRepository` 接口和实现中添加:
|
||||
Expected: 找到方法声明,无需添加。
|
||||
|
||||
```dart
|
||||
// journal_repository.dart
|
||||
Future<List<JournalElement>> getElements(String journalId);
|
||||
|
||||
// isar_journal_repository.dart
|
||||
@override
|
||||
Future<List<JournalElement>> getElements(String journalId) async {
|
||||
// 查询 isar 中该 journalId 的所有元素
|
||||
final collections = await isar.journalElementCollections
|
||||
.where()
|
||||
.filter()
|
||||
.journalIdEqualTo(journalId)
|
||||
.findAll();
|
||||
return collections.map(_collectionToElement).toList();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行 flutter analyze 确认无错误**
|
||||
- [ ] **Step 3: 运行 flutter analyze 确认无错误**
|
||||
|
||||
Run: `cd g:/nj/app && flutter analyze`
|
||||
Expected: No issues found
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
- [ ] **Step 4: 提交**
|
||||
|
||||
```bash
|
||||
cd g:/nj
|
||||
git add app/lib/features/editor/views/editor_page.dart
|
||||
git add app/lib/data/repositories/journal_repository.dart
|
||||
git add app/lib/data/repositories/isar_journal_repository.dart
|
||||
git commit -m "feat(app): EditorPage 加载已有日记 — initState 触发 LoadJournal"
|
||||
git commit -m "feat(app): EditorPage 加载已有日记 — 替换为 LoadJournal 原子事件"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -604,7 +566,7 @@ class _CommentCountBadge extends StatelessWidget {
|
||||
Future<List<dynamic>> _fetchComments() async {
|
||||
try {
|
||||
final response = await apiClient.get('/diary/journals/$journalId/comments');
|
||||
return response as List<dynamic>;
|
||||
return response.data as List<dynamic>;
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
@@ -667,7 +629,7 @@ class _CommentListFuture extends StatelessWidget {
|
||||
Future<List<Comment>> _fetchComments() async {
|
||||
try {
|
||||
final response = await apiClient.get('/diary/journals/$journalId/comments');
|
||||
final list = response as List<dynamic>;
|
||||
final list = response.data as List<dynamic>;
|
||||
return list.map((json) => Comment(
|
||||
id: json['id'] as String,
|
||||
journalId: json['journal_id'] as String,
|
||||
@@ -920,7 +882,22 @@ git commit -m "fix(server): 补充暖记管理端菜单 seed — 贴纸/主题/
|
||||
- Modify: `crates/erp-diary/src/handler/sticker_handler.rs`
|
||||
- Modify: `crates/erp-diary/src/lib.rs`
|
||||
|
||||
- [ ] **Step 1: 在 sticker_service.rs 添加 CRUD 方法**
|
||||
- [ ] **Step 1: 在 dto.rs 添加 UpdateStickerPackReq DTO**
|
||||
|
||||
参考已有的 `CreateStickerPackReq`,在 `crates/erp-diary/src/dto.rs` 中添加:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateStickerPackReq {
|
||||
#[validate(length(min = 1, max = 100))]
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub is_free: Option<bool>,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 sticker_service.rs 添加 CRUD 方法**
|
||||
|
||||
```rust
|
||||
// 创建贴纸包
|
||||
@@ -931,7 +908,7 @@ pub async fn update_sticker_pack(&self, id: &str, req: UpdateStickerPackReq) ->
|
||||
pub async fn delete_sticker_pack(&self, id: &str) -> Result<()> { ... }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 sticker_handler.rs 添加 handler**
|
||||
- [ ] **Step 3: 在 sticker_handler.rs 添加 handler**
|
||||
|
||||
```rust
|
||||
// POST /diary/sticker-packs — 需 diary.sticker.manage 权限
|
||||
@@ -944,14 +921,14 @@ pub async fn update_sticker_pack(...) { ... }
|
||||
pub async fn delete_sticker_pack(...) { ... }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 在 lib.rs 注册路由**
|
||||
- [ ] **Step 4: 在 lib.rs 注册路由**
|
||||
|
||||
- [ ] **Step 4: 运行 cargo check + cargo test**
|
||||
- [ ] **Step 5: 运行 cargo check + cargo test**
|
||||
|
||||
Run: `cd g:/nj && cargo check && cargo test`
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: 提交**
|
||||
- [ ] **Step 6: 提交**
|
||||
|
||||
```bash
|
||||
git add crates/erp-diary/
|
||||
@@ -998,20 +975,25 @@ git commit -m "feat(web): 贴纸包管理 CRUD UI — 创建/编辑/删除"
|
||||
### Task 15: 主题编辑/停用
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/erp-diary/src/service/topic_service.rs`
|
||||
- Modify: `crates/erp-diary/src/handler/topic_handler.rs`
|
||||
- Modify: `crates/erp-diary/src/lib.rs`
|
||||
- Verify: `crates/erp-diary/src/handler/topic_handler.rs` (update_topic + deactivate_topic 已存在)
|
||||
- Modify: `apps/web/src/api/diary/topics.ts`
|
||||
- Modify: `apps/web/src/pages/diary/TopicList.tsx`
|
||||
|
||||
- [ ] **Step 1: 后端添加主题更新/停用**
|
||||
> ⚠️ **后端已实现** — `topic_handler.rs` 已有 `update_topic` (PUT) 和 `deactivate_topic` (PATCH) handler,`lib.rs` 已注册路由。只需补全管理端 UI。
|
||||
|
||||
```rust
|
||||
// PUT /diary/topics/{id} — 编辑主题(标题/描述/截止日期)
|
||||
// PATCH /diary/topics/{id}/deactivate — 停用主题
|
||||
- [ ] **Step 1: 验证后端 API 可用**
|
||||
|
||||
Run: `cd g:/nj && grep -n "update_topic\|deactivate_topic" crates/erp-diary/src/handler/topic_handler.rs`
|
||||
Expected: 找到两个方法
|
||||
|
||||
- [ ] **Step 2: 在 topics.ts 添加 update/deactivate API 调用**
|
||||
|
||||
```typescript
|
||||
update: (id: string, data: UpdateTopicReq) => api.put(`/diary/topics/${id}`, data),
|
||||
deactivate: (id: string) => api.patch(`/diary/topics/${id}/deactivate`),
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 管理端 TopicList.tsx 添加编辑/停用按钮**
|
||||
- [ ] **Step 3: 管理端 TopicList.tsx 添加编辑/停用按钮**
|
||||
|
||||
- [ ] **Step 3: 提交**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user