1045 lines
32 KiB
Markdown
1045 lines
32 KiB
Markdown
# 暖记课堂试点就绪 — 实施计划
|
||
|
||
> **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-save(isDirty 设为 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)*
|