Files
nj/docs/superpowers/plans/2026-06-02-classroom-pilot-readiness.md

1045 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 暖记课堂试点就绪 — 实施计划
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 四角色闭环 + 跨角色链路全部打通,达到真实课堂试点就绪
**Architecture:** 基于现有 BLoC 模式扩展LoadJournal 为 EditorBloc 新增 event评论展示用独立 FutureBuilder widget家长 PIPL 在现有 parent_page 上验证补全。管理端 CRUD 扩展后端 handler + 前端 DrawerForm。
**Tech Stack:** Flutter 3.x / flutter_bloc / Isar / Rust Axum / SeaORM / React + Ant Design
**Spec:** `docs/superpowers/specs/2026-06-02-classroom-pilot-readiness-design.md` v1.1
---
## Chunk 1: Phase 1 — 孩子核心闭环
### 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| 修改 | `app/lib/features/editor/bloc/editor_bloc.dart` | 添加 `LoadJournal` event + handler |
| 修改 | `app/lib/features/editor/views/editor_page.dart` | initState 触发 LoadJournal |
| 修改 | `test/features/editor/bloc/editor_bloc_test.dart` | LoadJournal 单元测试 |
| 修改 | 8+ 文件 | catch(e) 添加 debugPrint |
---
### Task 1: LoadJournal Event + Handler
**Files:**
- Modify: `app/lib/features/editor/bloc/editor_bloc.dart` (events 区域 + BLoC handlers)
- Test: `test/features/editor/bloc/editor_bloc_test.dart`
- [ ] **Step 1: 写 LoadJournal event 的失败测试**
`test/features/editor/bloc/editor_bloc_test.dart` 末尾添加:
```dart
test('LoadJournal 还原已有日记数据', () async {
// 准备测试数据
final strokes = [
Stroke(id: 's1', points: [
const StrokePoint(x: 10, y: 20),
]),
];
final elements = [
JournalElement.createSticker(
journalId: 'j1',
emoji: '😊',
position: Offset.zero,
),
];
// dispatch LoadJournal
bloc.add(LoadJournal(
title: '测试日记',
mood: Mood.happy,
tags: const ['开心', '学校'],
strokes: strokes,
elements: elements,
lastSavedAt: DateTime(2026, 6, 1),
));
// 等待状态更新
await Future.delayed(const Duration(milliseconds: 50));
// 验证状态
expect(bloc.state.title, '测试日记');
expect(bloc.state.selectedMood, Mood.happy);
expect(bloc.state.tags, ['开心', '学校']);
expect(bloc.state.strokes.length, 1);
expect(bloc.state.elements.length, 1);
expect(bloc.state.lastSavedAt, DateTime(2026, 6, 1));
expect(bloc.state.isDirty, false);
});
```
- [ ] **Step 2: 运行测试确认失败**
Run: `cd g:/nj/app && flutter test test/features/editor/bloc/editor_bloc_test.dart`
Expected: FAIL — `LoadJournal` 类不存在
- [ ] **Step 3: 添加 LoadJournal event 类**
`app/lib/features/editor/bloc/editor_bloc.dart` 的 events 区域(`TextFormatChanged` 之后)添加:
```dart
/// 加载已有日记数据(从 JournalRepository 读取后传入)
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;
const LoadJournal({
required this.title,
required this.mood,
required this.tags,
required this.strokes,
required this.elements,
this.lastSavedAt,
});
}
```
- [ ] **Step 4: 添加 _onLoadJournal handler**
在 EditorBloc 构造函数中注册:
```dart
on<LoadJournal>(_onLoadJournal);
```
实现 handler
```dart
Future<void> _onLoadJournal(
LoadJournal event,
Emitter<EditorState> emit,
) async {
emit(state.copyWith(
title: event.title,
selectedMood: event.mood,
tags: event.tags,
strokes: event.strokes,
elements: event.elements,
lastSavedAt: event.lastSavedAt,
isDirty: false,
));
}
```
> 注意LoadJournal 不触发 auto-saveisDirty 设为 false因为这是加载已有数据不是用户编辑。
- [ ] **Step 5: 运行测试确认通过**
Run: `cd g:/nj/app && flutter test test/features/editor/bloc/editor_bloc_test.dart`
Expected: ALL PASS
- [ ] **Step 6: 提交**
```bash
cd g:/nj
git add app/lib/features/editor/bloc/editor_bloc.dart test/features/editor/bloc/editor_bloc_test.dart
git commit -m "feat(app): 添加 EditorBloc.LoadJournal event — 加载已有日记数据"
```
---
### Task 2: EditorPage 触发 LoadJournal
**Files:**
- Modify: `app/lib/features/editor/views/editor_page.dart`
> ⚠️ **重要:** `_loadExistingJournal` 已存在于 `_EditorViewState`line ~278-331使用多个细粒度事件TitleChanged, MoodChanged, TagsLoaded, StrokesLoaded, ElementsLoaded。本任务将其替换为单一的原子 `LoadJournal` 事件。修改目标是 **`_EditorViewState`**,不是 `_EditorStackState`。
- [ ] **Step 1: 替换 `_EditorViewState._loadExistingJournal` 方法**
找到 `_EditorViewState._loadExistingJournal`(约 line 287-331替换为使用 LoadJournal 的版本:
```dart
/// 加载已有日记数据并 dispatch LoadJournal
Future<void> _loadExistingJournal(String journalId) async {
try {
final repo = context.read<JournalRepository>();
final entry = await repo.getJournal(journalId);
if (entry == null || !mounted) return;
// 加载元素
final elements = await repo.getElements(journalId);
// 从 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();
}
}
// 过滤掉 handwriting_ref 元素(笔画单独管理)
final otherElements = elements
.where((e) => e.elementType != ElementType.handwritingRef)
.toList();
context.read<EditorBloc>().add(LoadJournal(
title: entry.title,
mood: entry.mood,
tags: entry.tags,
strokes: strokes,
elements: otherElements,
lastSavedAt: entry.updatedAt,
));
// 同步标题输入框_EditorStackState 中的 controller
} catch (e) {
debugPrint('加载日记失败: $e');
}
}
```
- [ ] **Step 2: 验证 JournalRepository.getElements 已存在(完整性检查,预计已存在)**
Run: `cd g:/nj/app && grep -n "getElements" lib/data/repositories/journal_repository.dart`
Expected: 找到方法声明,无需添加。
- [ ] **Step 3: 运行 flutter analyze 确认无错误**
Run: `cd g:/nj/app && flutter analyze`
Expected: No issues found
- [ ] **Step 4: 提交**
```bash
cd g:/nj
git add app/lib/features/editor/views/editor_page.dart
git commit -m "feat(app): EditorPage 加载已有日记 — 替换为 LoadJournal 原子事件"
```
---
### Task 3: catch(e) 异常处理修复18 处添加 debugPrint
**Files:**
- Modify: `app/lib/features/parent/bloc/parent_bloc.dart` (6 处)
- Modify: `app/lib/features/search/bloc/search_bloc.dart` (3 处)
- Modify: `app/lib/features/achievement/bloc/achievement_bloc.dart` (1 处)
- Modify: `app/lib/features/stickers/bloc/sticker_bloc.dart` (2 处)
- Modify: `app/lib/features/templates/bloc/template_bloc.dart` (1 处)
- Modify: `app/lib/features/mood/bloc/mood_bloc.dart` (1 处)
- Modify: `app/lib/features/home/bloc/home_bloc.dart` (1 处)
- Modify: `app/lib/features/calendar/bloc/calendar_bloc.dart` (1 处)
- Modify: `app/lib/features/calendar/views/weekly_page.dart` (1 处)
- Modify: `app/lib/data/services/sync_engine.dart` (1 处, line 198)
**修复模式(所有文件统一):**
```dart
// Before:
} catch (e) {
emit(SomeError('操作失败'));
}
// After:
} catch (e) {
debugPrint('ParentBloc._onLoadChildren 失败: $e');
emit(SomeError('操作失败'));
}
```
- [ ] **Step 1: 修复 parent_bloc.dart (6 处)**
`_onLoadChildren`, `_onBindChild`, `_onViewJournals`, `_onExportData`, `_onDeleteData`, `_onUnbindChild` 的 catch 块中添加 `debugPrint('ParentBloc._onXxx 失败: $e');`。确保文件头部有 `import 'package:flutter/foundation.dart';`
- [ ] **Step 2: 修复 search_bloc.dart (3 处)**
同上模式。
- [ ] **Step 3: 修复其余 ChangeNotifier BLoC (5 处)**
achievement_bloc.dart, sticker_bloc.dart, template_bloc.dart, mood_bloc.dart — 这些使用 `_state = _state.copyWith(...)` 而非 emit但同样需要 debugPrint。
- [ ] **Step 4: 修复 flutter_bloc 文件 (3 处)**
home_bloc.dart, calendar_bloc.dart, weekly_page.dart。
- [ ] **Step 5: 修复 sync_engine.dart (1 处)**
line 198 的 catch 块添加 `debugPrint('SyncEngine._trySync 操作失败: $e');`
- [ ] **Step 6: 运行 flutter analyze 确认无错误**
Run: `cd g:/nj/app && flutter analyze`
Expected: No issues found
- [ ] **Step 7: 提交**
```bash
cd g:/nj
git add app/lib/features/parent/bloc/parent_bloc.dart \
app/lib/features/search/bloc/search_bloc.dart \
app/lib/features/achievement/bloc/achievement_bloc.dart \
app/lib/features/stickers/bloc/sticker_bloc.dart \
app/lib/features/templates/bloc/template_bloc.dart \
app/lib/features/mood/bloc/mood_bloc.dart \
app/lib/features/home/bloc/home_bloc.dart \
app/lib/features/calendar/bloc/calendar_bloc.dart \
app/lib/features/calendar/views/weekly_page.dart \
app/lib/data/services/sync_engine.dart
git commit -m "fix(app): 18 处 catch(e) 添加 debugPrint 异常日志"
```
---
### Task 4: Phase 1 端到端验证
- [ ] **Step 1: 运行全部 Flutter 测试**
Run: `cd g:/nj/app && flutter test`
Expected: ALL PASS
- [ ] **Step 2: 运行 flutter analyze**
Run: `cd g:/nj/app && flutter analyze`
Expected: 0 errors
- [ ] **Step 3: 手动验证 — 创建并重新编辑日记**
1. `./scripts/dev.sh app` 启动 Flutter Web
2. 创建日记:手写 + 贴纸 + 选择心情 → 点击"完成"
3. 返回首页 → 点击刚创建的日记
4. **验证:编辑器正确加载标题、笔画、贴纸、心情**
5. 继续编辑 → 添加新内容 → 再次保存 → 验证更新成功
---
## Chunk 2: Phase 2 — 家长合规闭环
### 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| 验证 | `app/lib/features/parent/bloc/parent_bloc.dart` | API 调用正确性 |
| 验证+修改 | `app/lib/features/parent/views/parent_page.dart` | PIPL 数据权利 UI |
| 修改 | `app/lib/features/home/views/home_page.dart` | 添加家长入口(如缺失) |
---
### Task 5: 家长端数据流端到端验证
**Files:**
- Verify: `app/lib/features/parent/bloc/parent_bloc.dart`
- Verify: `app/lib/features/parent/views/parent_page.dart`
- [ ] **Step 1: 验证后端 API 可用**
Run: `curl -s -H "Authorization: Bearer $TOKEN" http://localhost:3000/api/v1/diary/parent/children`
Expected: 200 + JSON array可能为空因为未绑定孩子
- [ ] **Step 2: 手动验证家长端完整流程**
1. 启动后端 + Flutter: `./scripts/dev.sh`
2. 家长账号登录 → 进入家长中心
3. 绑定孩子(输入孩子 ID
4. 查看孩子日记列表 → **验证列表非空,内容正确**
5. 点击"数据导出" → **验证导出结果显示JSON 内容)**
6. 点击"数据删除" → **验证确认对话框弹出,包含 PIPL 法律文本**
7. 取消删除(不实际执行) → 验证数据未丢失
- [ ] **Step 3: 如果发现数据流问题,修复后提交**
常见问题可能包括API 返回格式不匹配、状态转换不正确等。根据具体问题修复。
```bash
git add app/lib/features/parent/
git commit -m "fix(app): 修复家长端数据流 — [具体问题描述]"
```
---
### Task 6: 导出功能补全(文件下载)
**Files:**
- Modify: `app/lib/features/parent/views/parent_page.dart` (导出结果区域)
- [ ] **Step 1: 验证当前导出行为**
检查 `parent_page.dart``ParentDataExported` state 的处理 — 是否只显示 JSON 预览,还是有文件下载功能。
- [ ] **Step 2: 如果缺少文件下载,添加下载按钮**
`_ExportDataView` widget 中添加下载操作。Flutter Web 可使用 `html.AnchorElement` 触发下载:
```dart
import 'dart:convert';
// 仅 Web 平台
import 'package:flutter/foundation.dart' show kIsWeb;
void _downloadJson(Map<String, dynamic> data, String filename) {
if (kIsWeb) {
// Web: 使用 AnchorElement 下载
final blob = html.Blob([jsonEncode(data)]);
final url = html.Url.createObjectUrlFromBlob(blob);
html.AnchorElement(href: url)
..setAttribute('download', filename)
..click();
html.Url.revokeObjectUrl(url);
}
// 非 Web 平台可用 path_provider + File
}
```
> 注意Flutter Web 下载需要 `dart:html`,通过 conditional import 处理。
- [ ] **Step 3: 运行 flutter analyze**
Run: `cd g:/nj/app && flutter analyze`
Expected: No issues found
- [ ] **Step 4: 提交**
```bash
git add app/lib/features/parent/views/parent_page.dart
git commit -m "feat(app): 家长端数据导出 — 添加 JSON 文件下载"
```
---
### Task 7: Phase 2 端到端验证
- [ ] **Step 1: 完整家长流程**
1. 家长登录 → 绑定孩子 → 查看日记列表
2. 导出数据 → **验证 JSON 文件可下载、内容正确**
3. 删除单篇日记 → 确认对话框 → 取消(不实际删除)
4. PIPL 法律说明 → **验证文本完整显示**
---
## Chunk 3: Phase 3 — 师生互动闭环
### 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| 新建 | `app/lib/features/editor/widgets/comment_list_sheet.dart` | 评论列表 FutureBuilder |
| 修改 | `app/lib/features/editor/views/editor_page.dart` | 顶栏添加评语图标 |
| 验证 | `app/lib/features/class_/bloc/class_bloc.dart` | _onCommentCreate 数据流 |
---
### Task 8: 评论列表展示 Widget
**Files:**
- Create: `app/lib/features/editor/widgets/comment_list_sheet.dart`
- [ ] **Step 1: 创建 comment_list_sheet.dart**
```dart
// 评论列表底部面板 — 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(),
// 评论数量 badge
_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')}';
}
}
```
> 注意Comment 类定义在 class_bloc.dart 中。如果引用路径有问题,可将 Comment 提取为独立 model 文件。
- [ ] **Step 2: 运行 flutter analyze**
Run: `cd g:/nj/app && flutter analyze`
Expected: No issues found
- [ ] **Step 3: 提交**
```bash
git add app/lib/features/editor/widgets/comment_list_sheet.dart
git commit -m "feat(app): 添加评论列表展示组件 — FutureBuilder 轮询模式"
```
---
### Task 9: EditorPage 顶栏添加评语入口
**Files:**
- Modify: `app/lib/features/editor/views/editor_page.dart`
- [ ] **Step 1: 在 _buildTopBar 的按钮行中添加评语图标**
在"标签按钮"`Icons.sell_rounded`)之后、"完成"按钮之前添加:
```dart
// 评语按钮(仅已有日记显示)
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),
),
```
- [ ] **Step 2: 实现 _showComments 方法**
`_EditorView` 中添加:
```dart
/// 显示评论列表
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>(),
),
);
}
```
- [ ] **Step 3: 确认 import 完整**
在 editor_page.dart 头部确认有:
```dart
import '../../../data/remote/api_client.dart';
import '../widgets/comment_list_sheet.dart';
```
- [ ] **Step 4: 运行 flutter analyze**
Run: `cd g:/nj/app && flutter analyze`
Expected: No issues found
- [ ] **Step 5: 提交**
```bash
git add app/lib/features/editor/views/editor_page.dart
git commit -m "feat(app): EditorPage 顶栏添加评语入口 — 仅已有日记显示"
```
---
### Task 10: 班级日记墙评语按钮验证
**Files:**
- Verify: `app/lib/features/class_/bloc/class_bloc.dart`
- Verify: `app/lib/features/class_/views/` 日记墙相关页面
- [ ] **Step 1: 验证 ClassBloc._onCommentCreate 流程**
检查 `_onCommentCreate` (line 406-424)
1. Guard: `state is ClassDetailLoaded`
2. 调用 `_classRepo.createComment()`
3. 成功后 `add(ClassLoadComments())` 刷新 ✅
4. 失败时 `debugPrint` + `emit error`
- [ ] **Step 2: 验证日记墙 UI 有评语入口**
在班级详情/日记墙页面中,确认:
- 学生日记卡片上有评语按钮或评语输入框
- 如果缺失,需要在日记墙页面添加评语按钮 + 底部输入框
> 具体修改取决于当前日记墙的实现。如果 ClassBloc 的 CommentCreate 事件在 UI 中有对应触发点(评语输入框),则无需修改。
- [ ] **Step 3: 手动验证 — 老师写评语**
1. 老师账号 → 进入班级 → 查看日记墙
2. 找到学生日记 → 点击评语按钮 → 输入评语 → 提交
3. **验证:评语提交成功,日记墙上显示评语数量更新**
如有问题,修复后提交:
```bash
git add app/lib/features/class_/
git commit -m "fix(app): 修复班级日记墙评语 UI — [具体问题]"
```
---
### Task 11: Phase 3 端到端验证
- [ ] **Step 1: 完整师生互动流程**
1. **老师**:创建班级 → 布置主题
2. **学生**:看到主题 → 写日记 → 分享到班级
3. **老师**:查看日记墙 → 写评语 → **验证提交成功**
4. **学生**:打开该日记 → 点击评语图标 → **验证看到老师评语**
---
## Chunk 4: Phase 4 — 管理端补全 + 全系统验证
### 文件结构
| 操作 | 文件 | 职责 |
|------|------|------|
| 修改 | `crates/erp-diary/src/handler/sticker_handler.rs` | 贴纸 CRUD handler |
| 修改 | `crates/erp-diary/src/handler/topic_handler.rs` | 主题编辑/停用 |
| 修改 | `crates/erp-diary/src/service/sticker_service.rs` | 贴纸 CRUD service |
| 修改 | `crates/erp-diary/src/service/topic_service.rs` | 主题编辑/停用 service |
| 修改 | `crates/erp-diary/src/lib.rs` | 注册新路由 |
| 修改 | `apps/web/src/api/diary/stickers.ts` | 贴纸 CRUD API 调用 |
| 修改 | `apps/web/src/pages/diary/StickerPackList.tsx` | 贴纸 CRUD UI |
| 修改 | `apps/web/src/pages/diary/TopicList.tsx` | 主题编辑/停用 UI |
| 验证 | 后端菜单 seed 迁移 | 确认所有暖记菜单已注册 |
---
### Task 12: 验证管理端菜单完整性
**Files:**
- Verify: `crates/erp-server/migration/src/` 暖记菜单 seed
- [ ] **Step 1: 查询数据库确认菜单**
```bash
psql -U postgres -d nuanji -c "SELECT id, name, path, parent_id FROM sys_menu WHERE path LIKE '/diary%' OR name LIKE '%暖记%' OR name LIKE '%日记%' ORDER BY sort_order;"
```
Expected: 至少包含以下菜单:
- 暖记管理(目录)
- 班级管理 `/diary/classes`
- 日记审阅 `/diary/journals`
- 主题管理 `/diary/topics`
- 贴纸管理 `/diary/stickers`
- [ ] **Step 2: 如果菜单缺失,添加 seed 迁移**
`crates/erp-server/migration/src/` 中修改最新的 diary seed 迁移,补充缺失菜单。
```bash
git commit -m "fix(server): 补充暖记管理端菜单 seed — 贴纸/主题/统计入口"
```
---
### Task 13: 贴纸 CRUD 后端
**Files:**
- Modify: `crates/erp-diary/src/service/sticker_service.rs`
- Modify: `crates/erp-diary/src/handler/sticker_handler.rs`
- Modify: `crates/erp-diary/src/lib.rs`
- [ ] **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
// 创建贴纸包
pub async fn create_sticker_pack(&self, req: CreateStickerPackReq) -> Result<StickerPack> { ... }
// 更新贴纸包
pub async fn update_sticker_pack(&self, id: &str, req: UpdateStickerPackReq) -> Result<StickerPack> { ... }
// 删除贴纸包(软删除)
pub async fn delete_sticker_pack(&self, id: &str) -> Result<()> { ... }
```
- [ ] **Step 3: 在 sticker_handler.rs 添加 handler**
```rust
// POST /diary/sticker-packs — 需 diary.sticker.manage 权限
pub async fn create_sticker_pack(...) { ... }
// PUT /diary/sticker-packs/{id} — 需 diary.sticker.manage 权限
pub async fn update_sticker_pack(...) { ... }
// DELETE /diary/sticker-packs/{id} — 需 diary.sticker.manage 权限
pub async fn delete_sticker_pack(...) { ... }
```
- [ ] **Step 4: 在 lib.rs 注册路由**
- [ ] **Step 5: 运行 cargo check + cargo test**
Run: `cd g:/nj && cargo check && cargo test`
Expected: PASS
- [ ] **Step 6: 提交**
```bash
git add crates/erp-diary/
git commit -m "feat(diary): 贴纸包 CRUD API — 创建/更新/删除"
```
---
### Task 14: 贴纸管理端 UI
**Files:**
- Modify: `apps/web/src/api/diary/stickers.ts`
- Modify: `apps/web/src/pages/diary/StickerPackList.tsx`
- [ ] **Step 1: 在 stickers.ts 添加 CRUD API 调用**
```typescript
createPack: (data: CreatePackReq) => api.post('/diary/sticker-packs', data),
updatePack: (id: string, data: UpdatePackReq) => api.put(`/diary/sticker-packs/${id}`, data),
deletePack: (id: string) => api.delete(`/diary/sticker-packs/${id}`),
```
- [ ] **Step 2: 在 StickerPackList.tsx 添加 CRUD 操作**
参考 `ClassList.tsx``DrawerForm` 模式:
- 添加"新建贴纸包"按钮
- DrawerForm 创建/编辑表单
- 删除确认对话框
- [ ] **Step 3: 验证 pnpm build**
Run: `cd g:/nj/apps/web && pnpm build`
Expected: 无错误
- [ ] **Step 4: 提交**
```bash
git add apps/web/src/
git commit -m "feat(web): 贴纸包管理 CRUD UI — 创建/编辑/删除"
```
---
### Task 15: 主题编辑/停用
**Files:**
- 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`
> ⚠️ **后端已实现** — `topic_handler.rs` 已有 `update_topic` (PUT) 和 `deactivate_topic` (PATCH) handler`lib.rs` 已注册路由。只需补全管理端 UI。
- [ ] **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 3: 管理端 TopicList.tsx 添加编辑/停用按钮**
- [ ] **Step 3: 提交**
```bash
git add crates/erp-diary/ apps/web/src/
git commit -m "feat(diary): 主题编辑/停用 — 后端 API + 管理端 UI"
```
---
### Task 16: 全系统端到端验证
- [ ] **Step 1: 编译全部三端**
```bash
cd g:/nj && cargo check && cargo test # 后端
cd g:/nj/app && flutter analyze && flutter test # Flutter
cd g:/nj/apps/web && pnpm build # 管理端
```
Expected: 全部通过
- [ ] **Step 2: 四角色 × 跨角色 完整场景**
| # | 角色 | 场景 | 预期 |
|---|------|------|------|
| 1 | 学生 | 注册 → 写日记(手写+贴纸+心情)→ 保存 → 返回 → 重新打开编辑 | 笔画+贴纸+心情正确还原 |
| 2 | 学生 | 搜索历史日记 | 搜索结果正确 |
| 3 | 学生 | 加入班级 → 分享日记到班级 | 分享成功 |
| 4 | 老师 | 创建班级 → 布置主题 | 主题出现在学生端 |
| 5 | 老师 | 查看日记墙 → 写评语 | 评语提交成功 |
| 6 | 学生 | 打开已分享日记 → 查看老师评语 | 评语正确显示 |
| 7 | 家长 | 绑定孩子 → 查看日记 → 导出数据 | JSON 文件可下载 |
| 8 | 家长 | PIPL 法律说明 | 文本完整显示 |
| 9 | 管理员 | 班级列表(全部班级) | 显示所有班级 |
| 10 | 管理员 | 贴纸管理 CRUD | 创建/编辑/删除正常 |
| 11 | 管理员 | 主题管理 → 停用 | 学生端不再看到已停用主题 |
- [ ] **Step 3: 最终提交(如有修复)**
```bash
git add -A
git commit -m "fix: 全系统端到端验证修复 — [具体问题列表]"
```
---
*计划版本 1.0 | 2026-06-02 | 基于 spec v1.1 (commit 860844a)*