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

32 KiB
Raw Blame History

暖记课堂试点就绪 — 实施计划

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 末尾添加:

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 之后)添加:

/// 加载已有日记数据(从 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 构造函数中注册:

on<LoadJournal>(_onLoadJournal);

实现 handler

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: 提交
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 已存在于 _EditorViewStateline ~278-331使用多个细粒度事件TitleChanged, MoodChanged, TagsLoaded, StrokesLoaded, ElementsLoaded。本任务将其替换为单一的原子 LoadJournal 事件。修改目标是 _EditorViewState,不是 _EditorStackState

  • Step 1: 替换 _EditorViewState._loadExistingJournal 方法

找到 _EditorViewState._loadExistingJournal(约 line 287-331替换为使用 LoadJournal 的版本:

/// 加载已有日记数据并 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: 提交
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)

修复模式(所有文件统一):

// 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: 提交
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 返回格式不匹配、状态转换不正确等。根据具体问题修复。

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.dartParentDataExported state 的处理 — 是否只显示 JSON 预览,还是有文件下载功能。

  • Step 2: 如果缺少文件下载,添加下载按钮

_ExportDataView widget 中添加下载操作。Flutter Web 可使用 html.AnchorElement 触发下载:

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: 提交
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

// 评论列表底部面板 — 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: 提交
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)之后、"完成"按钮之前添加:

// 评语按钮(仅已有日记显示)
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 中添加:

/// 显示评论列表
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 头部确认有:

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: 提交
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. 验证:评语提交成功,日记墙上显示评语数量更新

如有问题,修复后提交:

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: 查询数据库确认菜单

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 迁移,补充缺失菜单。

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 中添加:

#[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 方法
// 创建贴纸包
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
// 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: 提交
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 调用

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.tsxDrawerForm 模式:

  • 添加"新建贴纸包"按钮

  • DrawerForm 创建/编辑表单

  • 删除确认对话框

  • Step 3: 验证 pnpm build

Run: cd g:/nj/apps/web && pnpm build Expected: 无错误

  • Step 4: 提交
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) handlerlib.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 调用
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: 提交

git add crates/erp-diary/ apps/web/src/
git commit -m "feat(diary): 主题编辑/停用 — 后端 API + 管理端 UI"

Task 16: 全系统端到端验证

  • Step 1: 编译全部三端
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: 最终提交(如有修复)
git add -A
git commit -m "fix: 全系统端到端验证修复 — [具体问题列表]"

计划版本 1.0 | 2026-06-02 | 基于 spec v1.1 (commit 860844a)