Compare commits

...

8 Commits

Author SHA1 Message Date
iven
d6dd017155 feat(web): 贴纸包 CRUD UI + 主题编辑/停用 — Task 14-15 完成
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
Task 14: StickerPackList 补全 CRUD UI
- stickers.ts: 添加 createPack/deletePack/createSticker API
- StickerPackList: 新建贴纸包按钮 + 创建表单 Modal
- StickerPackList: 卡片添加删除按钮 (Popconfirm)

Task 15: TopicList 补全编辑/停用
- topics.ts: 添加 update/deactivate API
- TopicList: 编辑 Modal (标题/描述/截止日期)
- TopicList: 卡片添加编辑+停用按钮

附带修复:
- types.ts: SchoolClass/TopicAssignment 添加 version 字段
- ClassList.tsx: 修复 onUpdate 回调参数签名
- tsconfig.app.json: 排除 src/test 避免缺失模块编译错误
2026-06-02 23:40:46 +08:00
iven
f0741450bc 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 预览' 占位文案,替换为完整下载功能
2026-06-02 23:36:35 +08:00
iven
c9a69d0be1 feat(app): 添加评论列表展示组件 — FutureBuilder 轮询模式 2026-06-02 23:26:54 +08:00
iven
9e53ca8555 feat(app): EditorPage 顶栏添加评语入口 — 仅已有日记显示
- 添加评语图标按钮(仅 journalId != null 时显示)
- 实现 _showComments 方法打开 CommentListSheet
- 补充 api_client + comment_list_sheet imports
2026-06-02 23:26:24 +08:00
iven
6c9a38b27b feat(app): 添加 EditorBloc.LoadJournal event — 加载已有日记数据
- LoadJournal event: 原子加载 title/mood/tags/strokes/elements/lastSavedAt
- _onLoadJournal handler: 不触发 auto-save (isDirty=false)
- 单元测试: 验证 LoadJournal 正确还原所有状态字段
- mood_bloc: linter 补充 foundation.dart import
2026-06-02 23:23:17 +08:00
iven
e57c3427a4 fix(app): 18 处 catch(e) 添加 debugPrint 异常日志
- parent_bloc: 6 处 (LoadChildren/BindChild/ViewJournals/ExportData/DeleteData/UnbindChild)
- search_bloc: 3 处 (SearchByMood/SearchByTag/SearchByKeyword)
- achievement_bloc: 1 处 (_fetchAchievements)
- sticker_bloc: 2 处 (_fetchPacks/fetchStickersInPack)
- template_bloc: 1 处 (_fetchTemplates)
- mood_bloc: 1 处 (_loadStats)
- home_bloc: 1 处 (_onLoadData)
- calendar_bloc: 1 处 (_onMonthChanged)
- sync_engine: 1 处 (trySync)
- weekly_page: 已有 debugPrint,无需修改
2026-06-02 23:21:16 +08:00
iven
c92ead60e3 feat(app): EditorPage 加载已有日记 — 替换为 LoadJournal 原子事件
- _loadExistingJournal 改用单一 LoadJournal event 替代多个细粒度事件
- 添加 _titleController 同步,确保 LoadJournal 后标题输入框正确显示
- 不触发 auto-save (isDirty=false),因为这是加载而非用户编辑
2026-06-02 23:16:58 +08:00
iven
ab45f40cc8 docs: 修订实施计划 — 修复 EditorView 目标类/apiClient.data/主题后端已存在 2026-06-02 23:06:11 +08:00
25 changed files with 977 additions and 112 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

@@ -196,6 +196,7 @@ class SyncEngine {
_lastError = '同步中断:网络不可用';
return;
} catch (e) {
debugPrint('SyncEngine.trySync 操作失败: $e');
// 操作失败,增加重试计数
final retried = operation.copyWith(retryCount: operation.retryCount + 1);

View File

@@ -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: '加载成就列表失败',

View File

@@ -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));
}

View File

@@ -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-saveisDirty=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,
));
}
// ============================================================
// 工具栏事件处理
// ============================================================

View File

@@ -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;
// 防止重复弹窗:只在工具切换时触发

View 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')}';
}
}

View File

@@ -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()); // 空状态而非错误,离线友好
}
}

View File

@@ -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: '加载统计数据失败',

View File

@@ -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('解绑失败'));
}
}

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),
),
),
),
),
],
],
),
);
}
}
/// 导出信息行

View File

@@ -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('搜索失败,请重试'));
}
}

View File

@@ -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 [];
}
}

View File

@@ -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: '加载模板列表失败',

View File

@@ -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');
});
});
}

View File

@@ -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 = {

View File

@@ -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),
};

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -21,5 +21,6 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/test"]
}

View File

@@ -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: 确认 JournalRepositorygetElements 方法**
- [ ] **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: 提交**