Compare commits

..

40 Commits

Author SHA1 Message Date
iven
7af7cd64e6 feat(app): E.1 内测 APK 构建配置 — Android 品牌化 + 签名 + ProGuard
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 更新应用名称为「暖记」(AndroidManifest + strings.xml)
- 添加必要权限: INTERNET, CAMERA, READ_MEDIA_IMAGES, READ_EXTERNAL_STORAGE
- 生成 release 签名密钥 (RSA 2048, 10000 天有效期)
- 配置 ProGuard/R8 代码混淆 + 资源压缩
- 品牌化启动页: 奶油白背景 + 珊瑚色圆形「暖」字 logo
- 品牌化应用图标: 各密度 mipmap (mdpi~xxxhdpi)
- 添加阿里云 Maven 镜像加速依赖下载
- AGP 9.x 兼容: 自动为旧 Flutter 插件注入 namespace
- Gradle 性能优化: 并行编译 + 构建缓存
2026-06-07 20:17:19 +08:00
iven
ec8a04c80a feat(app): D.3 中等优先级 UX 改进 — 保存指示器 + 触摸目标 + 主题持久化
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
D.3.2 三态保存指示器:
- 未保存 (灰色) → 保存中 (琥珀色脉冲点) → 已保存 (绿色点)
- _PulsingDot 动画组件,800ms 呼吸效果
- 点击'完成'时显示保存中状态

D.3.3 工具栏触摸目标:
- BoxConstraints 36x36 → 44x44,符合 WCAG 标准

D.3.4 主题偏好持久化:
- SettingsBloc 接受 SharedPreferences,保存/恢复 themeMode
- NuanjiApp 改为 StatefulWidget,异步初始化 SharedPreferences
- 启动时显示 loading,初始化完成后渲染 app
2026-06-07 13:50:34 +08:00
iven
750605e479 feat(app): 全局离线提示横幅 — 网络不可用时显示黄色警告
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 新增 OfflineBanner widget: 监听 connectivity_plus 自动显示/隐藏
- AnimatedCrossFade 滑入滑出动画 + warmCurve 弹性曲线
- 黄色警告条: wifi_off 图标 + '网络不可用,部分功能受限'
- 嵌入 ResponsiveScaffold 的 body 上方 (手机/平板/桌面三端)
- 只在离线时显示,恢复网络后自动消失
2026-06-07 13:44:40 +08:00
iven
346c751cbb refactor(app): 迁移 4 个页面到共享 EmptyStateWidget + ErrorStateWidget
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
迁移统计:
- discover_page: _buildError → ErrorStateWidget, _buildEmptyHint → EmptyStateWidget
- sticker_library_page: 错误 + 空列表 → 共享组件
- class_page: 错误/班级列表空/日记墙空/话题空 → 共享组件 (4 处)
- calendar_page: CalendarError → ErrorStateWidget

统一体验: 所有页面空状态使用一致的 icon + title + subtitle + CTA 布局
2026-06-07 13:42:56 +08:00
iven
2f96f9a4f4 feat(app): 编辑器未保存确认 + 日历今天按钮
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
D.2.3 编辑器未保存确认:
- _handleBack 检查 state.isDirty,有未保存修改时弹出确认对话框
- 对话框: '放弃编辑?' / '你有未保存的修改' / [继续编辑] [放弃]

D.2.4 日历今天按钮:
- _MonthNavigator 新增 onToday 回调
- 不在当前月时显示 '今天' 文字按钮,点击跳回当月
- _isCurrentMonth 辅助判断
2026-06-07 13:38:34 +08:00
iven
f64355946c feat(app): 共享 UI 组件 + 4 个关键 UX bug 修复
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
Phase 0 — 共享组件:
- EmptyStateWidget: 统一空状态 (icon + title + subtitle + CTA)
- ErrorStateWidget: 统一错误状态 (message + retry)
- SkeletonBox + SkeletonList: 统一骨架屏加载 (shimmer 动画)

Phase 1 — Bug 修复:
- 班级评论按 journalId 过滤,避免显示在错误日记卡片下
- moodCellColors key 修正: love/tired → angry/thinking
- 日历非 CalendarLoaded 状态改为加载指示器 (不再 SizedBox.shrink)
- 贴纸数统计改为 '--' 占位 (之前错误显示日记总数)
2026-06-07 13:36:10 +08:00
iven
1f48a67db5 docs: 更新路线图 — A+B+C 阶段完成,9 条链路 8 通过
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-07 13:15:53 +08:00
iven
225af89e41 fix(app): Token 刷新彻底失败时通知 AuthBloc 派发 AuthExpired
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
审计 9a-AUTH-01 修复:
- ApiClient 新增 onAuthFailed 回调,在 401 刷新失败后触发
- app.dart 注册回调:派发 AuthExpired → GoRouter 重定向到登录页
- 之前刷新失败只清除内存 token,用户停留在死页面
- 现在 401 → 尝试刷新 → 失败 → AuthExpired → 自动跳转登录
2026-06-07 12:59:12 +08:00
iven
dbb74b6545 fix(diary): 系统性修复 DTO 输入验证 — 42 项审计发现中输入验证类全部修复
DTO 字段级验证:
- version 字段全部添加 range(min=0) 防止负数
- 标签内容验证: 单个标签最长 30 字符,不允许空白
- 班级码正则: 仅允许字母数字,拒绝特殊字符
- 贴纸包 price 添加 range(min=0) 防止负价格
- thumbnail_url/image_url 添加 length(max=500) 限制
- 同步请求 data payload 限制 1MB/条

Handler validate() 调用补齐:
- delete_journal: DeleteJournalReq 添加 Validate derive + handler 调用
- bind_child / unbind_child / delete_child_data: 补齐 req.validate() 调用
- join_class: 添加 validate_code() 字母数字检查
- sync_journals: 添加 validate_changes_data() payload 大小检查

审计覆盖: 5a-C01/02/03 + 5a-H02/03/04 + B-03 + 7b-C02
2026-06-07 12:55:50 +08:00
iven
3c3d70c751 fix(app): 日历页切换月份后保留选中日期的日记列表
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-07 10:44:55 +08:00
iven
ed8252d7c8 docs: 更新 Wiki 文档 — 数据层/前端/后端/健康/索引同步至最新 2026-06-07 10:44:26 +08:00
iven
41ef28f20b test(app): CalendarBloc 新增 31 个单元测试 2026-06-07 10:44:15 +08:00
iven
d67eedf7de feat(app): 多页面动态化 — 搜索/资料/教师/贴纸库/模板/日历
- SearchPage: 热搜词从日记标签频率动态生成 + 模板搜索网格
- ProfilePage: 成就徽章从 AchievementBloc 动态加载 + 头像首字母
- TeacherPage: 班级码改为对话框展示 (班级名+码+人数)
- StickerLibraryPage: 分类从 API 动态合并 + 精选包卡片动态化
- TemplateGalleryPage: 适配动态数据
- ClassPage: 微调
- HomePage: 路由适配
- CalendarBloc: 新增测试
- AppRouter: 路由更新
2026-06-07 10:44:04 +08:00
iven
a05374e8d1 feat(app): 编辑器增强 — 查看模式 + 图层排序 + 标签/贴纸动态化
- EditorPage 新增查看模式: 打开已保存日记默认只读,编辑按钮切换
- EditorBloc 新增 ElementLayerChanged 事件,支持置顶/置底图层排序
- DraggableElement 添加图层控制按钮 (置顶/置底/删除)
- TagPanel 标签建议改为从日记历史动态生成 (Top 10 频率)
- StickerPickerSheet 重构,预留 API 扩展点
2026-06-07 10:43:37 +08:00
iven
a5d2b0409f feat(app): 发现页动态化 — DiscoverBloc + API 驱动 + 下拉刷新
- 新增 DiscoverBloc (LoadData/Refresh) + DiscoverModels 4 个数据类
- DiscoverPage 改为 BlocBuilder 驱动: loading/loaded/error/empty 四态
- 替换全部硬编码占位数据为 API 实时数据
- 添加 RefreshIndicator 下拉刷新
- 离线异常时保留已有数据,友好错误提示
2026-06-07 10:43:23 +08:00
iven
3bc2ca7332 feat(diary): 添加发现页 Discover API — 每日灵感/热门标签/精选模板/专家日记
新增 DiscoverService 并发聚合 4 个数据区:
- daily_inspiration: MD5 哈希确定性日更推荐,匿名作者名
- hot_topics: 标签频率统计 Top 8
- featured_templates: 官方模板最多 6 个
- expert_diaries: 评论数热度排序,去重最多 5 位作者

GET /api/v1/diary/discover + utoipa 文档 + diary.journal.read 权限守卫
2026-06-07 10:43:02 +08:00
iven
4cb91f3ac9 fix(app): 修复打开已保存日记时笔迹不可见 — syncStrokes 后缺少 setState
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
根因:_syncCacheAfterBuild 调用 _cache.syncStrokes() 后没有触发
setState,导致 CachedStrokesPainter 不知道缓存已更新,不执行重绘。

对比正常绘画流程(addStroke 后有 setState),加载笔迹流程遗漏了
重绘触发。添加 .then(() => setState) 后,合成图重建完成即刷新画布。
2026-06-04 23:12:51 +08:00
iven
c253c8ddcf chore(scripts): 开发脚本支持 Flutter Windows 桌面端
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- pnpm start:dev 默认在 Windows 上启动桌面端(非 Web)
- 新增 pnpm start:dev:app:win 强制 Windows 桌面
- 新增 pnpm start:dev:app:web 强制 Chrome Web
- SIGINT 清理时同时终止 nuanji_app.exe
- 汇总输出区分桌面/Web 模式
2026-06-04 20:35:39 +08:00
iven
bb388ed8ff fix(app): 日记可见性修复 — 私密日记仅本地 + Web 端 ID 修复 + 分享按钮
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
问题修复:
1. Web端保存的日记看不到:createJournal 返回值未捕获,server ID 丢失导致
   后续元素保存用错 ID。现在使用 saved.id 贯穿全部操作。
2. 管理端看不到新建日记:后端 list_journals 添加 is_private 过滤,admin/teacher
   查看他人日记时排除私密日记。
3. RemoteJournalRepository 添加 onJournalChanged 变更通知流,HomeBloc 可自动刷新。
4. SyncEngine(native + web)enqueue 添加 is_private 防御性检查,私密日记不入队。
5. 编辑器 _persistState 条件入队:仅非私密日记同步到后端。
6. 分享流程改造:首次从私密变为公开时入队 create 操作上传。
7. 日记卡片添加可见性标签(仅自己可见/班级可见/公开),私密日记可点击分享。
8. 首页 _sharePrivateJournal 弹出 ShareBottomSheet 主动分享。
2026-06-04 12:03:24 +08:00
iven
c441aa4e34 fix(app): RemoteJournalRepository 创建日记 date 格式修复 — ISO 8601 → NaiveDate
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
根因: JournalEntry.toJson() 发送 '2026-06-04T12:00:00.123456',
后端 CreateJournalReq.date 是 chrono::NaiveDate,只接受 '2026-06-04'。
反序列化失败导致创建日记被拒绝,前端静默吞掉错误。

修复: createJournal 发送前将 date 截取为 YYYY-MM-DD 格式。
2026-06-04 10:47:14 +08:00
iven
e635557e67 fix(app): 修复 RemoteJournalRepository API 响应解析 — 分页信封嵌套
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
根因: 后端列表接口返回 { success, data: { data: [...], total, ... }, message }
但前端直接 body['data'] as List,类型不匹配导致 TypeError 被静默吞掉。

修复: getJournals 和 getJournalCount 正确解析分页信封
body['data']['data'] 获取列表, body['data']['total'] 获取总数。
2026-06-04 09:35:54 +08:00
iven
138bfa9723 fix(app): Flutter Web 改用 HTML 渲染器,避免 Google Fonts CDN 加载中文字体
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
根因: Flutter Web CanvasKit 渲染器会从 fonts.gstatic.com 按需加载
CJK 字体 woff2 子集,在中国网络环境下完全不可达。

修复: 启动参数添加 --web-renderer html,使用浏览器原生 CSS
font-face 机制加载本地字体文件,无需外网访问。

面向中国国内市场,不依赖任何外部 CDN。
2026-06-04 09:22:48 +08:00
iven
b72009718f fix(app): 日记保存逻辑修复 — EditorPage 改为 StatefulWidget + 更新合并编辑器状态
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
根因分析:
1. EditorPage 是 StatelessWidget,savedJournalId 作为 build() 局部变量
   每次重建都重置为 null,导致每次自动保存都走新建而非更新分支
2. 更新分支直接 repo.updateJournal(existing),没有把编辑器当前
   状态(标题/心情/标签)合并到已有日记中

修复:
- EditorPage 改为 StatefulWidget,_savedJournalId 存储在 State 中
- 更新分支用 existing.copyWith() 合并编辑器当前状态后保存
2026-06-04 00:13:51 +08:00
iven
9fce34f4ef fix(app): 修复 4 个 Flutter 交互问题
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
1. 首页数据不刷新 — JournalRepository 添加 onJournalChanged
   Stream 变更通知,HomeBloc 订阅后自动刷新
2. 画笔再次点击不弹出面板 — 添加 ToolReactivated 事件,
   工具栏检测已激活工具时发出重新激活信号
3. 钢笔铅笔效果一样 — 调整 perfect_freehand 参数
   (pen: size 10/smooth 0.65, pencil: size 3/smooth 0.35)
4. 橡皮擦不生效 — ActiveStrokePainter 橡皮擦模式绘制
   半透明灰色反馈,笔画完成后 setState 触发 Layer 1 重绘
5. 贴纸文字无法缩放 — DraggableElement 用 Scale 手势
   替换 Pan 手势,支持双指缩放和旋转
2026-06-04 00:05:22 +08:00
iven
988ee7335a feat(app): 内容安全词库 + 过滤服务 + 分享前检查 — 28 个测试全覆盖
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
新增文件:
- sensitive_words.dart — 8 分类 ~200 条敏感词 + 谐音/形近/数字变体映射
- content_filter_service.dart — 精确匹配 + 变体匹配 + 文本预处理(去零宽/空格/符号)
- content_filter_service_test.dart — 28 个测试(8分类精确/安全内容/预处理/变体/边界/词库完整性)

修改:
- share_bottom_sheet.dart — 分享到班级前调用 ContentFilterService,
  有敏感词时弹出警告对话框(返回修改/仍然分享),新增 contentText 参数
2026-06-03 19:40:13 +08:00
iven
9c92cba87f test(app): ClassBloc + SearchBloc 单元测试 — 33 个测试全覆盖
ClassBloc (13 tests):
- 班级列表加载(成功/失败/loading 状态)
- 选中班级详情 + 子事件触发
- 成员/日记墙/主题/评语加载
- 创建班级 + 错误处理
- 加入班级 + 列表刷新
- 布置主题
- sharedToClass 过滤逻辑

SearchBloc (20 tests):
- 关键词搜索(标题/摘要/标签匹配、大小写不敏感)
- 心情筛选(null/有值/失败)
- 标签搜索
- 搜索历史(记录/去重)
- 清除搜索 + Tab 切换
- hasActiveFilter 属性
2026-06-03 19:03:29 +08:00
iven
f6d394afb6 test(app): 手写引擎 Canvas 集成测试 — 55 个测试全覆盖
4 个测试文件:
- stroke_model_test.dart (12 tests) — StrokePoint/Stroke 序列化、不可变性、默认值
- stroke_renderer_test.dart (19 tests) — parseHexColor/pointsToOutline/buildStrokePath/createPaintForStroke
- stroke_cache_test.dart (15 tests) — StrokeRasterCache 添加/同步/清除/尺寸变化/边界条件
- handwriting_canvas_test.dart (9 tests) — Widget 渲染结构、手势回调、去抖、预加载、连续笔画
2026-06-03 18:57:41 +08:00
iven
4cd08535d3 chore(app): 管理端品牌替换 — 移除所有 ERP 面向用户文字,统一暖记风格
- index.css 注释头 → 暖记管理后台 Design System
- PluginMarket.tsx 用户提示 → 扩展暖记能力
- package.json → nuanji-admin v0.1.0 + 项目描述
- CSS 注释 Typography → Chinese-first 暖记
2026-06-03 18:48:47 +08:00
iven
271f0c4f29 test(diary): 添加 9 个集成测试 + 修复 mood_stats 表名
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
集成测试 (TestDb + Service 层直接调用):
- test_journal_crud_full_lifecycle: 创建/查询/更新/列表/软删除全流程
- test_journal_version_conflict_on_update: 乐观锁版本冲突检测
- test_journal_tenant_isolation: 多租户数据隔离验证
- test_class_create_and_join: 班级创建+学生加入+成员查询+班级码重置
- test_sync_batch_create_and_fetch: 批量创建 3 篇日记同步
- test_sync_version_conflict_detection: 同步版本冲突检测
- test_mood_stats_aggregation: 心情统计 GROUP BY 聚合
- test_parent_binding_two_step_verification: 家长绑定两步验证
- test_achievement_list: 成就查询

修复:
- mood_stats_service: journal_entry → journal_entries 表名修正

测试: 518/518 全仓库通过 (含 9 新增集成测试)
2026-06-03 18:04:58 +08:00
iven
4cd381295a fix(app): Flutter Web 开发模式默认连接 localhost:3000 API
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- app.dart: kDebugMode 下使用 AppConfig.dev (localhost:3000)
- dev.mjs: flutter run 传入 --dart-define API_BASE_URL/SSE_BASE_URL
2026-06-03 17:50:55 +08:00
iven
8300822232 fix(diary): JournalResp 补充 assigned_topic_id 字段
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- dto.rs: JournalResp 添加 assigned_topic_id: Option<Uuid>
- journal_service model_to_resp: 映射 model.assigned_topic_id
- parent_handler journal_model_to_resp: 同步映射

Flutter 端 JournalEntry 已有 assignedTopicId,无需修改
测试: 84/84 通过
2026-06-03 17:46:50 +08:00
iven
367f21de08 feat(app): 统一同步协议 — SyncModels + ApiClient.sync + SyncEngine.tryBatchSync
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
Flutter ↔ Rust 同步协议对齐:
- 新增 sync_models.dart: SyncReq/SyncResp/SyncChange/ConflictInfo
  与 Rust dto.rs 一一对应 (CreateJournal/UpdateJournal/DeleteJournal)
- ApiClient.sync(): 调用 POST /diary/sync 批量同步端点
- SyncEngine.tryBatchSync(): PendingOperation → SyncChange 批量提交
  成功清空队列,冲突保留待用户处理

保留原有逐个同步 trySync() 作为降级方案
后端 509/509 测试通过, Flutter analyze 0 error
2026-06-03 17:20:51 +08:00
iven
1766cefde9 refactor(diary): Service 层改用 DiaryEvent 枚举替代字符串事件
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- journal_service: 3 处 (JournalCreated/Updated/Deleted)
- class_service: 2 处 (ClassCreated/StudentJoinedClass)
- comment_service: 1 处 (CommentCreated)
- topic_service: 1 处 (TopicAssigned)
- parent_service: 1 处 confirm_binding → ParentBound

保留 DomainEvent::new 的场景:
- class_service deactivate_class (diary.class.deactivated)
- parent_service bind_child (diary.parent.binding_requested)
- parent_service delete_child_data (diary.parent.data_deleted)
以上事件不在 DiaryEvent 枚举中(非核心创建事件)

测试: 509/509 全部通过
2026-06-03 17:15:00 +08:00
iven
38592d61ce refactor(diary): Phase 3 质量提升 — 201 状态码 + OpenAPI 文档 + DiaryEvent 类型安全
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
前端:
- fix(app): Isar native 文件直接导入 isar_database_native.dart,消除 5 个条件导出类型错误
- chore(app): build_runner 重新生成 .g.dart 文件 (102 outputs)
- fix(app): 移除 secure_token_store_factory 未使用的 kIsWeb import

后端:
- refactor(diary): 所有创建端点 POST 返回 201 Created (9 handler, 11 端点)
- feat(diary): DiaryApiDoc OpenApi derive — 42 路径 + 32 Schema 汇总到 Swagger
- feat(diary): DiaryEvent 枚举添加 event_type/payload/to_domain_event 方法 + 4 测试

测试: 84/84 erp-diary 通过, 509/509 全仓库通过, Flutter analyze 0 error
2026-06-03 17:06:03 +08:00
iven
e8df3a9562 fix(app): 修复登录页 Logo 和文字未居中 — Stack alignment + Column mainAxisSize
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-03 16:36:20 +08:00
iven
32a91551c4 perf(app): Phase 2 前端性能优化 5 项 — 8b-D01/D02/D03/M02/N01
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 8b-D01: Isar 添加 authorId+dateEpoch 复合索引和 dateEpoch 单独索引
- 8b-D02: getJournals 分页改为 DB 层 .offset().limit() 替代 Dart 层 sublist
- 8b-D03: home_bloc monthCount 改用日期范围独立查询(不受分页限制)
- 8b-M02: 笔画光栅化改为 BBox 裁剪 — 短笔画不再创建全画布尺寸图像
  - _CacheEntry 增加 offset 字段记录 BBox 偏移
  - _rasterizeStroke 计算包围盒 + 4px padding
  - _compositeIncremental 使用 offset 定位
- 8b-N01: SyncEngine enqueue 合并同一资源的操作
  - create+update → create(最新数据)
  - update+update → update(最新数据)
  - update+delete → delete
  - create+delete → 取消(不发送)
- 注意: Isar .g.dart 需运行 build_runner 重新生成
2026-06-03 16:05:11 +08:00
iven
b6ffc60331 perf(diary): sticker_service 批量 GROUP BY 替代 N+1 贴纸计数 — 8a-C04
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- list_sticker_packs: 单次 SQL GROUP BY pack_id 获取所有计数
- 2 次查询(packs + counts)替代 N+1 次
- 使用 PostgreSQL ANY() 传递 UUID 数组
- 测试 80/80 通过
2026-06-03 15:51:05 +08:00
iven
4e5c1287a6 perf(diary): parent_service 批量软删除替代逐条 UPDATE — 8a-C03
- delete_child_data 改用单条 SQL UPDATE ... WHERE 批量软删除
- 1 次 SQL 替代 N 次逐条 UPDATE(从 O(N) 降到 O(1) 查询)
- 移除不再需要的 TransactionTrait 导入
- 测试 80/80 通过
2026-06-03 15:48:29 +08:00
iven
3258acaa77 perf(diary): sync_service 批量预查询 + 事务化 — 8a-C02
- 按操作类型分组 create/update/delete
- UPDATE/DELETE 目标单次 IN 查询替代逐条 find(消除 N+1)
- 冲突前置检测: 从预取 HashMap 判断版本,再过滤有效操作
- 所有写操作在单个事务内完成(原子化)
- 辅助函数改用 ConnectionTrait 泛型(兼容 DatabaseConnection 和 DatabaseTransaction)
- 测试 80/80 通过
2026-06-03 15:45:36 +08:00
iven
0c9ada242a perf(diary): mood_stats 改用 SQL GROUP BY 替代全量加载 — 8a-C01
- get_mood_stats: SELECT mood, COUNT(*) GROUP BY 替代 all() + Rust 迭代
- calculate_streak: 仅查 date 列 + DISTINCT + 366天窗口裁剪
- 新增 mood_counts_map_aggregation 单元测试
- 测试 78/78 通过
2026-06-03 15:37:09 +08:00
120 changed files with 8201 additions and 1067 deletions

View File

@@ -1,9 +1,19 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// 从 key.properties 加载签名配置
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.nuanji.nuanji_app"
compileSdk = flutter.compileSdkVersion
@@ -15,21 +25,33 @@ android {
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.nuanji.nuanji_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 24 // Android 7.0+ — 支持 Isar 原生库 + CameraX
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}

38
app/android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,38 @@
# 暖记 ProGuard 规则
# 保留 Flutter 引擎
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.** { *; }
-keep class io.flutter.util.** { *; }
-keep class io.flutter.view.** { *; }
-keep class io.flutter.** { *; }
-keep class io.flutter.plugins.** { *; }
# Isar 数据库 保留 native 调用
-keep class com.isor.** { *; }
-keep class isar.** { *; }
-keepclassmembers class ** {
native <methods>;
}
# Dio 网络库
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
# Gson / JSON 序列化
-keepattributes Signature
-keepattributes *Annotation*
-keep class com.google.gson.** { *; }
# freezed 生成的类 保留 JSON 序列化
-keepclassmembers class **.models.** {
*** fromJson(...);
*** toJson();
}
# 保留所有序列化相关类
-keepclassmembers class * {
*** INSTANCE;
*** Companion;
}

View File

@@ -1,9 +1,20 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 网络权限 — API 同步、图片上传 -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<!-- 相机权限 — 日记拍照 -->
<uses-permission android:name="android.permission.CAMERA"/>
<!-- 照片权限 — Android 13+ 细化媒体权限 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<!-- 照片权限 — Android 12 及以下 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<application
android:label="nuanji_app"
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
android:networkSecurityConfig="@xml/network_security_config"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"

View File

@@ -1,12 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<!-- 暖记启动页 — 奶油白背景 + 居中 logo -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item android:drawable="@color/bg_light" />
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/launch_logo" />
</item>
</layer-list>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 暖记启动页 (深色模式) — 深色背景 + 居中 logo -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/bg_dark" />
<item>
<bitmap
android:gravity="center"
android:src="@drawable/launch_logo" />
</item>
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="launch_bg">#1A1614</color>
</resources>

View File

@@ -1,17 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<!-- 深色模式启动主题 — 深色背景 -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowBackground">@drawable/launch_background_dark</item>
<item name="android:statusBarColor">@color/bg_dark</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">暖记</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 暖记设计系统颜色 -->
<color name="bg_light">#FFF8F0</color> <!-- 奶油白背景 -->
<color name="bg_dark">#1A1614</color> <!-- 深色背景 -->
<color name="accent">#E07A5F</color> <!-- 珊瑚色主色 -->
<color name="fg_light">#2D2420</color> <!-- 浅色模式文字 -->
<color name="fg_dark">#F0E8DF</color> <!-- 深色模式文字 -->
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">暖记</string>
</resources>

View File

@@ -1,17 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<!-- 浅色模式启动主题 — 奶油白背景 -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:statusBarColor">@color/bg_light</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>

View File

@@ -1,5 +1,8 @@
allprojects {
repositories {
// 阿里云 Maven 镜像 — 加速中国大陆依赖下载
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
google()
mavenCentral()
}
@@ -15,6 +18,28 @@ subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
// 为缺少 namespace 的旧 Flutter 插件自动注入 namespace
// 解决 AGP 9.x 要求必须指定 namespace 的问题
plugins.withId("com.android.library") {
val android = project.extensions.getByName("android")
if (android is com.android.build.gradle.LibraryExtension && android.namespace == null) {
val manifestFile = project.file("src/main/AndroidManifest.xml")
if (manifestFile.exists()) {
val packageName = manifestFile.readLines()
.firstOrNull { it.contains("package=") }
?.let { line ->
Regex("package=\"([^\"]+)\"").find(line)?.groupValues?.get(1)
}
if (packageName != null) {
android.namespace = packageName
}
}
}
}
}
subprojects {
project.evaluationDependsOn(":app")
}

View File

@@ -4,3 +4,8 @@ android.useAndroidX=true
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
# 构建性能优化
org.gradle.parallel=true
org.gradle.caching=true
# 暂不启用 configuration-cache与 init 脚本冲突)
# org.gradle.configuration-cache=true

View File

@@ -11,6 +11,10 @@ pluginManagement {
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
// 阿里云 Maven 镜像 — 加速中国大陆依赖下载
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
maven { url = uri("https://maven.aliyun.com/repository/gradle-plugin") }
google()
mavenCentral()
gradlePluginPortal()

View File

@@ -10,11 +10,12 @@
// └─ BlocProvider<AuthBloc>
// └─ MaterialApp.router
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart' show ListenableProvider;
import 'package:shared_preferences/shared_preferences.dart';
import 'config/app_config.dart';
import 'core/theme/app_theme.dart';
@@ -31,64 +32,99 @@ import 'features/auth/bloc/auth_bloc.dart';
import 'features/profile/bloc/settings_bloc.dart';
/// 暖记 App — 根组件
class NuanjiApp extends StatelessWidget {
class NuanjiApp extends StatefulWidget {
const NuanjiApp({super.key});
@override
Widget build(BuildContext context) {
// 创建全局依赖App 生命周期内单例)
final config = AppConfig.fromEnvironment();
final apiClient = ApiClient(baseUrl: config.apiBaseUrl);
State<NuanjiApp> createState() => _NuanjiAppState();
}
class _NuanjiAppState extends State<NuanjiApp> {
late final ApiClient _apiClient;
late final AuthRepository _authRepository;
late final JournalRepository _journalRepository;
late final RemoteJournalRepository _remoteJournalRepository;
late final SyncEngine _syncEngine;
late final ClassRepository _classRepository;
late final SettingsBloc _settingsBloc;
late final AuthBloc _authBloc;
bool _initialized = false;
@override
void initState() {
super.initState();
_initApp();
}
Future<void> _initApp() async {
final config = kDebugMode ? AppConfig.dev : AppConfig.fromEnvironment();
_apiClient = ApiClient(baseUrl: config.apiBaseUrl);
final tokenStore = createSecureTokenStore();
final authRepository = AuthRepository(apiClient: apiClient, tokenStore: tokenStore);
// 离线优先Isar 为主要本地仓库Remote 供 SyncEngine 推送
// Web 平台Isar 3.x 不支持 Web直接使用远程仓库
final journalRepository = kIsWeb
? RemoteJournalRepository(api: apiClient)
_authRepository = AuthRepository(apiClient: _apiClient, tokenStore: tokenStore);
_journalRepository = kIsWeb
? RemoteJournalRepository(api: _apiClient)
: IsarJournalRepository();
final remoteJournalRepository = RemoteJournalRepository(api: apiClient);
final syncEngine = SyncEngine(apiClient: apiClient);
final classRepository = ClassRepository(api: apiClient);
final settingsBloc = SettingsBloc();
final authBloc = AuthBloc(
authRepository: authRepository,
classRepository: classRepository,
_remoteJournalRepository = RemoteJournalRepository(api: _apiClient);
_syncEngine = SyncEngine(apiClient: _apiClient);
_classRepository = ClassRepository(api: _apiClient);
_settingsBloc = SettingsBloc(
prefs: await SharedPreferences.getInstance(),
);
_authBloc = AuthBloc(
authRepository: _authRepository,
classRepository: _classRepository,
);
// 启动时检查认证状态
authBloc.add(const AppStarted());
_authBloc.add(const AppStarted());
// 异步恢复 SyncEngine 持久化队列fire-and-forget不阻塞 UI
syncEngine.restorePendingQueue();
// 启动网络监听 — 网络恢复时自动触发 trySync()
syncEngine.startAutoSync();
// 异步恢复 SyncEngine 持久化队列
_syncEngine.restorePendingQueue();
_syncEngine.startAutoSync();
// 认证状态监听:登出时清除 token
// 注意:登录时 token 由 AuthRepository.login() 直接注入 ApiClient
authBloc.stream.listen((state) {
_authBloc.stream.listen((state) {
if (state is! Authenticated) {
apiClient.clearToken();
_apiClient.clearToken();
}
});
// Token 刷新彻底失败时 → 派发 AuthExpired
_apiClient.onAuthFailed = () {
_authBloc.add(const AuthExpired());
};
setState(() => _initialized = true);
}
@override
Widget build(BuildContext context) {
if (!_initialized) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: Scaffold(body: Center(child: CircularProgressIndicator())),
);
}
return MultiRepositoryProvider(
providers: [
RepositoryProvider<ApiClient>.value(value: apiClient),
RepositoryProvider<AuthRepository>.value(value: authRepository),
RepositoryProvider<JournalRepository>.value(value: journalRepository),
RepositoryProvider<RemoteJournalRepository>.value(value: remoteJournalRepository),
RepositoryProvider<SyncEngine>.value(value: syncEngine),
RepositoryProvider<ClassRepository>.value(value: classRepository),
RepositoryProvider<ApiClient>.value(value: _apiClient),
RepositoryProvider<AuthRepository>.value(value: _authRepository),
RepositoryProvider<JournalRepository>.value(value: _journalRepository),
RepositoryProvider<RemoteJournalRepository>.value(value: _remoteJournalRepository),
RepositoryProvider<SyncEngine>.value(value: _syncEngine),
RepositoryProvider<ClassRepository>.value(value: _classRepository),
],
child: ListenableProvider<SettingsBloc>.value(
value: settingsBloc,
value: _settingsBloc,
child: Builder(
builder: (context) {
final settings = context.watch<SettingsBloc>();
return BlocProvider<AuthBloc>.value(
value: authBloc,
value: _authBloc,
child: _AppView(
router: createAppRouter(authBloc),
router: createAppRouter(_authBloc),
themeMode: settings.state.themeMode,
),
);

View File

@@ -42,6 +42,7 @@ import '../../features/templates/views/template_gallery_page.dart';
import '../../features/settings/views/settings_page.dart';
import '../../features/auth/bloc/auth_bloc.dart';
import '../../features/search/bloc/search_bloc.dart';
import '../../features/discover/bloc/discover_bloc.dart';
import '../../data/repositories/journal_repository.dart';
import '../../data/remote/api_client.dart';
@@ -168,7 +169,13 @@ GoRouter createAppRouter(AuthBloc authBloc) {
GoRoute(
path: '/discover',
name: 'discover',
builder: (context, state) => const DiscoverPage(),
builder: (context, state) {
return BlocProvider(
create: (_) => DiscoverBloc(api: context.read<ApiClient>())
..add(const DiscoverLoadData()),
child: const DiscoverPage(),
);
},
),
// 个人中心
GoRoute(

View File

@@ -177,12 +177,13 @@ class AppColors {
};
/// 心情 → 日历单元格背景色
/// key 必须与 Mood 枚举值一致: happy/calm/sad/angry/thinking
static const Map<String, Color> moodCellColors = {
'happy': secondarySoftLight, // #D4E8DC
'love': roseSoftLight, // #F0DADA
'calm': tertiarySoftLight, // #FBE8C8
'sad': Color(0xFFD4DDE8), // 灰蓝
'tired': Color(0xFFE8E4E0), // 灰棕
'happy': secondarySoftLight, // 😊 开心 — 鼠尾草绿 #D4E8DC
'calm': tertiarySoftLight, // 😌 平静 — 暖金 #FBE8C8
'sad': Color(0xFFD4DDE8), // 😢 难过 — 灰蓝
'angry': Color(0xFFFFE0D6), // 😠 生气 — 暖珊瑚 (与 primaryContainer 一致)
'thinking': Color(0xFFE8E4E0), // 🤔 思考 — 灰棕
};
// ===== 浅色主题色彩方案 =====

View File

@@ -16,7 +16,7 @@ extension GetJournalElementCollectionCollection on Isar {
const JournalElementCollectionSchema = CollectionSchema(
name: r'JournalElementCollection',
id: -1002,
id: -3625932583395690305,
properties: {
r'contentJson': PropertySchema(
id: 0,
@@ -96,7 +96,7 @@ const JournalElementCollectionSchema = CollectionSchema(
idName: r'isarId',
indexes: {
r'id': IndexSchema(
id: -2002,
id: -3268401673993471357,
name: r'id',
unique: false,
replace: false,
@@ -109,7 +109,7 @@ const JournalElementCollectionSchema = CollectionSchema(
],
),
r'journalId': IndexSchema(
id: 3001,
id: 1745640946427815323,
name: r'journalId',
unique: false,
replace: false,

View File

@@ -16,7 +16,8 @@ class JournalEntryCollection {
@Index()
String id = '';
/// 作者 ID
/// 作者 ID(索引 + 组合索引 authorId+dateEpoch覆盖按作者查询并按日期排序的场景
@Index(composite: [CompositeIndex('dateEpoch')])
String authorId = '';
/// 班级 ID可选
@@ -25,7 +26,8 @@ class JournalEntryCollection {
/// 日记标题
String title = '';
/// 日记日期epoch milliseconds
/// 日记日期epoch milliseconds— 单独索引支持日期范围查询
@Index()
int dateEpoch = 0;
/// 心情enum → string

View File

@@ -16,7 +16,7 @@ extension GetJournalEntryCollectionCollection on Isar {
const JournalEntryCollectionSchema = CollectionSchema(
name: r'JournalEntryCollection',
id: -1001,
id: -6325316395299921961,
properties: {
r'assignedTopicId': PropertySchema(
id: 0,
@@ -106,7 +106,7 @@ const JournalEntryCollectionSchema = CollectionSchema(
idName: r'isarId',
indexes: {
r'id': IndexSchema(
id: -2001,
id: -3268401673993471357,
name: r'id',
unique: false,
replace: false,
@@ -117,6 +117,37 @@ const JournalEntryCollectionSchema = CollectionSchema(
caseSensitive: true,
)
],
),
r'authorId_dateEpoch': IndexSchema(
id: -4869847655132214108,
name: r'authorId_dateEpoch',
unique: false,
replace: false,
properties: [
IndexPropertySchema(
name: r'authorId',
type: IndexType.hash,
caseSensitive: true,
),
IndexPropertySchema(
name: r'dateEpoch',
type: IndexType.value,
caseSensitive: false,
)
],
),
r'dateEpoch': IndexSchema(
id: 359017825055613028,
name: r'dateEpoch',
unique: false,
replace: false,
properties: [
IndexPropertySchema(
name: r'dateEpoch',
type: IndexType.value,
caseSensitive: false,
)
],
)
},
links: {},
@@ -277,6 +308,15 @@ extension JournalEntryCollectionQueryWhereSort
return query.addWhereClause(const IdWhereClause.any());
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterWhere>
anyDateEpoch() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
const IndexWhereClause.any(indexName: r'dateEpoch'),
);
});
}
}
extension JournalEntryCollectionQueryWhere on QueryBuilder<
@@ -393,6 +433,242 @@ extension JournalEntryCollectionQueryWhere on QueryBuilder<
}
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause> authorIdEqualToAnyDateEpoch(String authorId) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'authorId_dateEpoch',
value: [authorId],
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause> authorIdNotEqualToAnyDateEpoch(String authorId) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [],
upper: [authorId],
includeUpper: false,
))
.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [authorId],
includeLower: false,
upper: [],
));
} else {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [authorId],
includeLower: false,
upper: [],
))
.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [],
upper: [authorId],
includeUpper: false,
));
}
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause>
authorIdDateEpochEqualTo(String authorId, int dateEpoch) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'authorId_dateEpoch',
value: [authorId, dateEpoch],
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause>
authorIdEqualToDateEpochNotEqualTo(String authorId, int dateEpoch) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [authorId],
upper: [authorId, dateEpoch],
includeUpper: false,
))
.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [authorId, dateEpoch],
includeLower: false,
upper: [authorId],
));
} else {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [authorId, dateEpoch],
includeLower: false,
upper: [authorId],
))
.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [authorId],
upper: [authorId, dateEpoch],
includeUpper: false,
));
}
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause> authorIdEqualToDateEpochGreaterThan(
String authorId,
int dateEpoch, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [authorId, dateEpoch],
includeLower: include,
upper: [authorId],
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause> authorIdEqualToDateEpochLessThan(
String authorId,
int dateEpoch, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [authorId],
upper: [authorId, dateEpoch],
includeUpper: include,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause> authorIdEqualToDateEpochBetween(
String authorId,
int lowerDateEpoch,
int upperDateEpoch, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'authorId_dateEpoch',
lower: [authorId, lowerDateEpoch],
includeLower: includeLower,
upper: [authorId, upperDateEpoch],
includeUpper: includeUpper,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause> dateEpochEqualTo(int dateEpoch) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'dateEpoch',
value: [dateEpoch],
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause> dateEpochNotEqualTo(int dateEpoch) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'dateEpoch',
lower: [],
upper: [dateEpoch],
includeUpper: false,
))
.addWhereClause(IndexWhereClause.between(
indexName: r'dateEpoch',
lower: [dateEpoch],
includeLower: false,
upper: [],
));
} else {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'dateEpoch',
lower: [dateEpoch],
includeLower: false,
upper: [],
))
.addWhereClause(IndexWhereClause.between(
indexName: r'dateEpoch',
lower: [],
upper: [dateEpoch],
includeUpper: false,
));
}
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause> dateEpochGreaterThan(
int dateEpoch, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'dateEpoch',
lower: [dateEpoch],
includeLower: include,
upper: [],
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause> dateEpochLessThan(
int dateEpoch, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'dateEpoch',
lower: [],
upper: [dateEpoch],
includeUpper: include,
));
});
}
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
QAfterWhereClause> dateEpochBetween(
int lowerDateEpoch,
int upperDateEpoch, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.between(
indexName: r'dateEpoch',
lower: [lowerDateEpoch],
includeLower: includeLower,
upper: [upperDateEpoch],
includeUpper: includeUpper,
));
});
}
}
extension JournalEntryCollectionQueryFilter on QueryBuilder<

View File

@@ -16,7 +16,7 @@ extension GetPendingOperationCollectionCollection on Isar {
const PendingOperationCollectionSchema = CollectionSchema(
name: r'PendingOperationCollection',
id: -1003,
id: -6885010264946527864,
properties: {
r'createdAtEpoch': PropertySchema(
id: 0,
@@ -61,7 +61,7 @@ const PendingOperationCollectionSchema = CollectionSchema(
idName: r'isarId',
indexes: {
r'id': IndexSchema(
id: -2003,
id: -3268401673993471357,
name: r'id',
unique: false,
replace: false,

View File

@@ -3,8 +3,6 @@
// 根据平台创建对应的 SecureTokenStore 实现。
// 运行时判断 kIsWeb避免 Web 编译时加载 flutter_secure_storage。
import 'package:flutter/foundation.dart' show kIsWeb;
import 'secure_token_store.dart';
import 'secure_token_store_web.dart';

View File

@@ -0,0 +1,176 @@
// 同步协议模型 — 与 Rust 端 SyncReq/SyncResp/SyncChange/ConflictInfo 一一对应
//
// 端点: POST /api/v1/diary/sync
// Rust DTO: crates/erp-diary/src/dto.rs (SyncReq, SyncResp, SyncChange, ConflictInfo)
/// 同步请求 — 与 Rust SyncReq 对应
///
/// ```rust
/// pub struct SyncReq {
/// pub last_sync_time: Option<DateTime<Utc>>,
/// pub changes: Vec<SyncChange>,
/// }
/// ```
class SyncReq {
final DateTime? lastSyncTime;
final List<SyncChange> changes;
const SyncReq({this.lastSyncTime, this.changes = const []});
Map<String, dynamic> toJson() => {
if (lastSyncTime != null)
'last_sync_time': lastSyncTime!.toUtc().toIso8601String(),
'changes': changes.map((c) => c.toJson()).toList(),
};
}
/// 同步变更条目 — 与 Rust SyncChange 枚举对应
///
/// ```rust
/// pub enum SyncChange {
/// CreateJournal { data: serde_json::Value },
/// UpdateJournal { id: Uuid, version: i32, data: serde_json::Value },
/// DeleteJournal { id: Uuid, version: i32 },
/// }
/// ```
sealed class SyncChange {
const SyncChange();
Map<String, dynamic> toJson();
/// 从 JSON 反序列化
factory SyncChange.fromJson(Map<String, dynamic> json) {
if (json.containsKey('CreateJournal')) {
return SyncChangeCreateJournal(
data: json['CreateJournal']['data'] as Map<String, dynamic>,
);
}
if (json.containsKey('UpdateJournal')) {
final inner = json['UpdateJournal'] as Map<String, dynamic>;
return SyncChangeUpdateJournal(
id: inner['id'] as String,
version: inner['version'] as int,
data: inner['data'] as Map<String, dynamic>,
);
}
if (json.containsKey('DeleteJournal')) {
final inner = json['DeleteJournal'] as Map<String, dynamic>;
return SyncChangeDeleteJournal(
id: inner['id'] as String,
version: inner['version'] as int,
);
}
throw FormatException('Unknown SyncChange variant: $json');
}
}
/// 创建日记变更
class SyncChangeCreateJournal extends SyncChange {
final Map<String, dynamic> data;
const SyncChangeCreateJournal({required this.data});
@override
Map<String, dynamic> toJson() => {
'CreateJournal': {'data': data},
};
}
/// 更新日记变更
class SyncChangeUpdateJournal extends SyncChange {
final String id;
final int version;
final Map<String, dynamic> data;
const SyncChangeUpdateJournal({
required this.id,
required this.version,
required this.data,
});
@override
Map<String, dynamic> toJson() => {
'UpdateJournal': {
'id': id,
'version': version,
'data': data,
},
};
}
/// 删除日记变更
class SyncChangeDeleteJournal extends SyncChange {
final String id;
final int version;
const SyncChangeDeleteJournal({
required this.id,
required this.version,
});
@override
Map<String, dynamic> toJson() => {
'DeleteJournal': {
'id': id,
'version': version,
},
};
}
/// 同步响应 — 与 Rust SyncResp 对应
///
/// ```rust
/// pub struct SyncResp {
/// pub server_changes: Vec<serde_json::Value>,
/// pub conflicts: Vec<ConflictInfo>,
/// pub sync_time: DateTime<Utc>,
/// }
/// ```
class SyncResp {
final List<Map<String, dynamic>> serverChanges;
final List<ConflictInfo> conflicts;
final DateTime syncTime;
const SyncResp({
required this.serverChanges,
required this.conflicts,
required this.syncTime,
});
factory SyncResp.fromJson(Map<String, dynamic> json) => SyncResp(
serverChanges: (json['server_changes'] as List)
.map((e) => Map<String, dynamic>.from(e as Map))
.toList(),
conflicts: (json['conflicts'] as List)
.map((e) => ConflictInfo.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(),
syncTime: DateTime.parse(json['sync_time'] as String),
);
}
/// 冲突信息 — 与 Rust ConflictInfo 对应
///
/// ```rust
/// pub struct ConflictInfo {
/// pub journal_id: Uuid,
/// pub local_version: i32,
/// pub server_version: i32,
/// }
/// ```
class ConflictInfo {
final String journalId;
final int localVersion;
final int serverVersion;
const ConflictInfo({
required this.journalId,
required this.localVersion,
required this.serverVersion,
});
factory ConflictInfo.fromJson(Map<String, dynamic> json) => ConflictInfo(
journalId: json['journal_id'] as String,
localVersion: json['local_version'] as int,
serverVersion: json['server_version'] as int,
);
}

View File

@@ -10,6 +10,8 @@
import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import '../models/sync_models.dart';
/// 网络离线异常 — 网络不可用时由 ApiClient 抛出
class OfflineException implements Exception {
final String message;
@@ -33,6 +35,12 @@ class ApiClient {
/// 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖。
Future<String?> Function()? onRefreshToken;
/// 认证彻底失败回调 — 刷新 token 失败后由 app.dart 注册
///
/// 通知 AuthBloc 派发 AuthExpired 事件,触发路由重定向到登录页。
/// 解决审计 9a-AUTH-01刷新失败时用户不会被留在死页面。
void Function()? onAuthFailed;
/// 是否正在刷新 token防止并发 401 触发多次刷新)
bool _isRefreshing = false;
@@ -93,8 +101,9 @@ class ApiClient {
_isRefreshing = false;
}
// 刷新失败或无刷新回调 → 清除 token
// 刷新失败或无刷新回调 → 清除 token,通知全局认证失效
_token = null;
onAuthFailed?.call();
}
handler.next(error);
},
@@ -187,4 +196,19 @@ class ApiClient {
});
return _dio.post<T>(path, data: formData);
}
// ===== 同步 API =====
/// 批量同步 — POST /diary/sync
///
/// 将客户端变更批量提交到服务端,返回服务端变更和冲突信息。
/// 对应 Rust sync_handler::sync_journals 端点。
Future<SyncResp> sync(SyncReq req) async {
await _ensureOnline();
final response = await _dio.post<Map<String, dynamic>>(
'/diary/sync',
data: req.toJson(),
);
return SyncResp.fromJson(response.data!);
}
}

View File

@@ -7,11 +7,12 @@
// - JournalEntry ↔ JournalEntryCollection通过 toCollection/fromCollection
// - JournalElement ↔ JournalElementCollection通过 toCollection/fromCollection
import 'dart:async';
import 'dart:convert';
import 'package:isar/isar.dart';
import '../local/isar_database.dart';
import '../local/isar_database_native.dart';
import '../local/collections/journal_entry_collection.dart';
import '../local/collections/journal_element_collection.dart';
import '../models/journal_entry.dart';
@@ -20,7 +21,12 @@ import 'journal_repository.dart';
/// Isar 本地日记仓库 — JournalRepository 的 Isar 实现
class IsarJournalRepository implements JournalRepository {
Isar get _isar => IsarDatabase.instance!;
Isar get _isar => IsarDatabase.instance;
final StreamController<void> _changeController = StreamController<void>.broadcast();
@override
Stream<void> get onJournalChanged => _changeController.stream;
// ============================================================
// 日记 CRUD
@@ -64,19 +70,18 @@ class IsarJournalRepository implements JournalRepository {
query = query.and().classIdEqualTo(classId);
}
// 按日期降序排列
var results = await query
.sortByDateEpochDesc()
.findAll();
// 分页
// 按日期降序排列 + DB 层分页(替代全量加载后 Dart 层 sublist
if (page != null && pageSize != null) {
final start = (page - 1) * pageSize;
if (start >= results.length) return [];
final end = (start + pageSize).clamp(0, results.length);
results = results.sublist(start, end);
final offset = (page - 1) * pageSize;
final results = await query
.sortByDateEpochDesc()
.offset(offset)
.limit(pageSize)
.findAll();
return results.map(_fromCollection).toList();
}
final results = await query.sortByDateEpochDesc().findAll();
return results.map(_fromCollection).toList();
}
@@ -108,6 +113,7 @@ class IsarJournalRepository implements JournalRepository {
await _isar.writeTxn(() async {
await _isar.journalEntryCollections.put(col);
});
_changeController.add(null);
return entry;
}
@@ -144,6 +150,7 @@ class IsarJournalRepository implements JournalRepository {
await _isar.journalEntryCollections.put(col);
});
_changeController.add(null);
return updated;
}
@@ -177,6 +184,7 @@ class IsarJournalRepository implements JournalRepository {
await _isar.journalElementCollections.put(el);
}
});
_changeController.add(null);
}
// ============================================================

View File

@@ -7,6 +7,9 @@ import '../models/journal_entry.dart';
import '../models/journal_element.dart';
import 'journal_repository.dart';
/// 空的变更通知流 — Web 平台 stub
const _emptyStream = Stream<void>.empty();
/// Isar 本地日记仓库 — Web 空实现(抛出 UnsupportedError
class IsarJournalRepository implements JournalRepository {
@override
@@ -56,4 +59,7 @@ class IsarJournalRepository implements JournalRepository {
@override
Future<void> removeElement(String elementId) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Stream<void> get onJournalChanged => _emptyStream;
}

View File

@@ -6,6 +6,8 @@
// - SyncEngine 负责协调本地和远程仓库之间的数据同步
// - 内存实现 [InMemoryJournalRepository] 用于开发阶段快速迭代
import 'dart:async';
import '../models/journal_entry.dart';
import '../models/journal_element.dart';
@@ -52,6 +54,9 @@ abstract class JournalRepository {
/// 从日记中移除元素
Future<void> removeElement(String elementId);
/// 日记变更通知流 — create/update/delete 时发出信号
Stream<void> get onJournalChanged;
}
/// 内存实现 — 用于开发阶段快速迭代和单元测试
@@ -61,6 +66,10 @@ abstract class JournalRepository {
class InMemoryJournalRepository implements JournalRepository {
final Map<String, JournalEntry> _journals = {};
final Map<String, JournalElement> _elements = {};
final StreamController<void> _changeController = StreamController<void>.broadcast();
@override
Stream<void> get onJournalChanged => _changeController.stream;
@override
Future<List<JournalEntry>> getJournals({
@@ -122,6 +131,7 @@ class InMemoryJournalRepository implements JournalRepository {
@override
Future<JournalEntry> createJournal(JournalEntry entry) async {
_journals[entry.id] = entry;
_changeController.add(null);
return entry;
}
@@ -145,6 +155,7 @@ class InMemoryJournalRepository implements JournalRepository {
updatedAt: DateTime.now(),
);
_journals[entry.id] = updated;
_changeController.add(null);
return updated;
}
@@ -154,6 +165,7 @@ class InMemoryJournalRepository implements JournalRepository {
_journals.remove(id);
// 同时移除关联元素
_elements.removeWhere((_, e) => e.journalId == id);
_changeController.add(null);
}
@override

View File

@@ -1,5 +1,7 @@
// 远程日记仓库 — 通过 API 客户端连接后端
import 'dart:async';
import '../models/journal_element.dart';
import '../models/journal_entry.dart';
import '../remote/api_client.dart';
@@ -11,6 +13,10 @@ import 'journal_repository.dart';
class RemoteJournalRepository implements JournalRepository {
final ApiClient _api;
/// 变更通知流控制器 — 写操作成功后通知 UI 刷新
final StreamController<void> _changeController =
StreamController<void>.broadcast();
RemoteJournalRepository({required ApiClient api}) : _api = api;
@override
@@ -39,7 +45,9 @@ class RemoteJournalRepository implements JournalRepository {
final response = await _api.get('/diary/journals', queryParams: queryParams);
final body = response.data as Map<String, dynamic>;
final items = body['data'] as List? ?? [];
// 后端信封格式: { success, data: { data: [...], total, page, ... }, message }
final envelope = body['data'] as Map<String, dynamic>? ?? {};
final items = envelope['data'] as List? ?? [];
return items
.map((json) => JournalEntry.fromJson(json as Map<String, dynamic>))
.toList();
@@ -52,7 +60,9 @@ class RemoteJournalRepository implements JournalRepository {
'page_size': 1,
});
final body = response.data as Map<String, dynamic>;
return (body['total'] as int?) ?? 0;
// 后端信封格式: { success, data: { data: [...], total, ... }, message }
final envelope = body['data'] as Map<String, dynamic>? ?? {};
return (envelope['total'] as int?) ?? 0;
}
@override
@@ -69,9 +79,14 @@ class RemoteJournalRepository implements JournalRepository {
@override
Future<JournalEntry> createJournal(JournalEntry entry) async {
final response = await _api.post('/diary/journals', data: entry.toJson());
// 后端 CreateJournalReq.date 是 NaiveDate只有日期需转换格式
final json = entry.toJson();
json['date'] = entry.date.toIso8601String().substring(0, 10);
final response = await _api.post('/diary/journals', data: json);
final body = response.data as Map<String, dynamic>;
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
final created = JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
_changeController.add(null); // 通知 UI 刷新列表
return created;
}
@override
@@ -89,12 +104,14 @@ class RemoteJournalRepository implements JournalRepository {
},
);
final body = response.data as Map<String, dynamic>;
_changeController.add(null); // 通知 UI 刷新列表
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
}
@override
Future<void> deleteJournal(String id) async {
await _api.delete('/diary/journals/$id');
_changeController.add(null); // 通知 UI 刷新列表
}
@override
@@ -131,6 +148,10 @@ class RemoteJournalRepository implements JournalRepository {
Future<void> removeElement(String elementId) async {
await _api.delete('/diary/elements/$elementId');
}
/// 变更通知流 — 写操作后广播,供 HomeBloc 自动刷新
@override
Stream<void> get onJournalChanged => _changeController.stream;
}
/// API 异常封装 — 后端返回非 2xx 状态码时抛出

View File

@@ -0,0 +1,184 @@
// 内容安全过滤服务 — 本地敏感词检测
//
// 提供 checkText() 纯函数,用于在分享日记前检查文本内容是否包含敏感词。
// 检测策略:精确匹配 + 谐音/形近/数字变体匹配。
// 返回匹配列表,空列表表示内容安全。不自动屏蔽,由 UI 层决定提示方式。
import 'sensitive_words.dart';
/// 敏感词匹配结果
class SensitiveWordMatch {
/// 匹配到的敏感词原文
final String word;
/// 所属分类
final SensitiveCategory category;
/// 在预处理后文本中的起始位置
final int position;
const SensitiveWordMatch({
required this.word,
required this.category,
required this.position,
});
@override
String toString() => 'SensitiveWordMatch("$word", ${category.label}, @$position)';
}
/// 内容安全过滤服务
///
/// 纯静态方法,无状态,可安全在任何地方调用。
/// 性能:~200 条词 × contains() 检查,<1ms 完成。
class ContentFilterService {
ContentFilterService._();
/// 检查文本内容,返回所有匹配到的敏感词。
///
/// 对输入文本进行预处理(去空格/特殊符号/零宽字符/小写化),
/// 然后遍历全量词库做精确匹配和谐音变体匹配。
/// 返回空列表表示内容安全。
static List<SensitiveWordMatch> checkText(String text) {
if (text.isEmpty) return const [];
final normalized = _normalize(text);
if (normalized.isEmpty) return const [];
final matches = <SensitiveWordMatch>[];
final seen = <String>{}; // 去重:同一词不重复报告
for (final entry in kSensitiveWords.entries) {
final category = entry.key;
for (final word in entry.value) {
// 精确匹配
final pos = normalized.indexOf(word);
if (pos >= 0 && seen.add(word)) {
matches.add(SensitiveWordMatch(
word: word,
category: category,
position: pos,
));
}
// 谐音/变体匹配 — 将词中每个有变体映射的字替换为变体,检查是否命中
if (_matchesWithVariants(normalized, word)) {
if (seen.add('variant:$word')) {
matches.add(SensitiveWordMatch(
word: word,
category: category,
position: -1, // 变体匹配无法精确定位
));
}
}
}
}
// 整词变体匹配 — kHomophoneVariants 中的多字 key如 "卧槽"
for (final variantEntry in kHomophoneVariants.entries) {
final originalKey = variantEntry.key;
if (originalKey.length <= 1) continue; // 单字已在上面处理
// 找到这个变体 key 对应的分类
SensitiveCategory? foundCategory;
for (final entry in kSensitiveWords.entries) {
if (entry.value.contains(originalKey)) {
foundCategory = entry.key;
break;
}
}
if (foundCategory == null) continue;
for (final variant in variantEntry.value) {
if (variant.isEmpty) continue;
final vPos = normalized.indexOf(variant.toLowerCase());
if (vPos >= 0) {
final key = 'wvariant:$originalKey:$variant';
if (seen.add(key)) {
matches.add(SensitiveWordMatch(
word: originalKey,
category: foundCategory,
position: vPos,
));
}
}
}
}
return matches;
}
/// 检查文本是否包含敏感词(快捷方法)
static bool hasSensitiveContent(String text) => checkText(text).isNotEmpty;
/// 获取匹配到的分类标签集合(用于 UI 展示)
static Set<String> getMatchedCategories(List<SensitiveWordMatch> matches) {
return matches.map((m) => m.category.label).toSet();
}
/// 变体匹配:检查文本中是否出现了词的谐音/形近/数字变体版本
///
/// 将敏感词中每个有变体映射的字符逐一替换为变体,检查替换后的
/// 字符串是否出现在文本中。例如 "去死" → 检查 "去4" 是否在文本中。
static bool _matchesWithVariants(String normalizedText, String word) {
final chars = word.split('');
final variantChars = <List<String>>[];
for (final char in chars) {
final variants = kHomophoneVariants[char];
if (variants != null && variants.isNotEmpty) {
// 原字符 + 所有变体
variantChars.add([char, ...variants]);
} else {
variantChars.add([char]);
}
}
// 生成所有变体组合并检查
return _checkCombinations(normalizedText, variantChars, 0, '');
}
/// 递归生成变体组合并检查文本
static bool _checkCombinations(
String text,
List<List<String>> variantChars,
int index,
String current,
) {
if (index == variantChars.length) {
return text.contains(current);
}
for (final char in variantChars[index]) {
if (_checkCombinations(text, variantChars, index + 1, current + char)) {
return true;
}
}
return false;
}
/// 文本预处理:去除干扰字符,统一为小写
///
/// 1. 去除零宽字符U+200B~U+200F, U+FEFF
/// 2. 去除空格、制表符、换行
/// 3. 去除常见特殊符号(用于绕过的 @#$%^&* 等)
/// 4. 转小写(对英文词有效)
static String _normalize(String text) {
final buffer = StringBuffer();
for (final rune in text.runes) {
// 跳过零宽字符
if (rune >= 0x200B && rune <= 0x200F) continue;
if (rune == 0xFEFF) continue;
// 跳过空白
if (rune == 0x20 || rune == 0x09 || rune == 0x0A || rune == 0x0D) continue;
// 跳过常见绕过符号
if (rune == 0x2E || rune == 0x2C || rune == 0x2D || rune == 0x5F) continue; // . , - _
if (rune == 0x21 || rune == 0x40 || rune == 0x23 || rune == 0x24) continue; // ! @ # $
if (rune == 0x25 || rune == 0x5E || rune == 0x26 || rune == 0x2A) continue; // % ^ & *
if (rune == 0x7E || rune == 0x60) continue; // ~ `
buffer.writeCharCode(rune);
}
return buffer.toString().toLowerCase();
}
}

View File

@@ -0,0 +1,141 @@
// 敏感词库 — 本地静态词库常量,面向小学生场景
//
// 分类:暴力、色情、欺凌、毒品、赌博、政治、诈骗、粗口
// 每个分类包含基础词 + 谐音/形近/数字变体
// 词库为 const 编译期常量,零运行时开销
//
// 注意:本词库仅为 Phase 1 基础覆盖Phase 2 将接入服务端 AI + 可更新词库。
/// 敏感词分类
enum SensitiveCategory {
violence('暴力'),
sexual('色情'),
bullying('欺凌'),
drugs('毒品'),
gambling('赌博'),
politics('政治敏感'),
fraud('诈骗'),
profanity('粗口');
const SensitiveCategory(this.label);
final String label;
}
/// ============================================================
/// 各分类敏感词
/// ============================================================
/// 暴力类
const _violenceWords = [
// 直接暴力
'杀人', '砍人', '捅人', '打死', '打死你', '弄死', '弄死你',
'揍你', '揍死', '打死他', '砍死', '捅死',
'杀了他', '打死他', '砍了他', '捅了他',
'去死', '你去死', '怎么不去死',
'割腕', '割脖子', '跳楼', '上吊',
// 武器
'炸弹', '手枪', '步枪', '子弹', '刀杀',
// 自残/伤害暗示
'自杀', '自残', '不想活',
];
/// 色情类
const _sexualWords = [
'色情', '裸体', '裸照', '黄色', '黄片',
'做爱', '性行为', '性交', '强奸', '强暴',
'猥亵', '性骚扰', '偷拍',
'发情', '骚货', '贱人',
];
/// 欺凌类
const _bullyingWords = [
'废物', '垃圾', '蠢货', '白痴', '弱智',
'傻子', '笨蛋', '猪头', '丑八怪',
'滚开', '滚蛋', '闭嘴', '别烦我',
'讨厌鬼', '没人要', '没朋友',
'不和你玩', '不要和你玩',
'大家不要理', '孤立',
'偷东西', '小偷',
];
/// 毒品类
const _drugsWords = [
'毒品', '吸毒', '贩毒', '大麻', '海洛因',
'冰毒', '摇头丸', '可卡因', '吗啡',
'鸦片', 'K粉', '安非他命',
'上瘾', '毒瘾',
];
/// 赌博类
const _gamblingWords = [
'赌博', '赌钱', '下注', '押注', '赌场',
'买彩票', '时时彩', '六合彩',
'百家乐', '老虎机', '扑克赌',
'赌债', '借钱赌',
];
/// 政治敏感类
const _politicsWords = [
'反动', '颠覆', '分裂', '暴动', '造反',
'推翻', '政变', '游行示威',
];
/// 诈骗类
const _fraudWords = [
'诈骗', '骗钱', '骗密码', '骗账号',
'中奖了', '恭喜中奖', '免费领取',
'点击链接领奖', '转账给我',
'刷单', '兼职刷单', '高薪兼职',
'传销', '拉人头',
];
/// 粗口类
const _profanityWords = [
'操你', '妈的', '他妈', '去你的', '狗屎',
'', '', '放屁', '扯淡', '王八蛋',
'混蛋', '', '我去', '卧槽',
'我靠', '我擦',
];
/// 全量词库:分类 → 词列表
const Map<SensitiveCategory, List<String>> kSensitiveWords = {
SensitiveCategory.violence: _violenceWords,
SensitiveCategory.sexual: _sexualWords,
SensitiveCategory.bullying: _bullyingWords,
SensitiveCategory.drugs: _drugsWords,
SensitiveCategory.gambling: _gamblingWords,
SensitiveCategory.politics: _politicsWords,
SensitiveCategory.fraud: _fraudWords,
SensitiveCategory.profanity: _profanityWords,
};
/// ============================================================
/// 谐音/形近/数字变体映射
/// ============================================================
/// 原词 → 变体列表
///
/// 变体检测在预处理后的文本上运行,可以捕获常见的绕过手法:
/// - 数字谐音: "死" → "4"
/// - 形近替换: "傻" → "纱"
/// - 拼音缩写: "牛逼" → "nb"
const Map<String, List<String>> kHomophoneVariants = {
// 暴力相关
'': ['4', '', '', ''],
'': ['', '', ''],
'': ['砍人'],
'': ['捅人'],
// 欺凌相关
'': ['', '', ''],
'': [], // 无实际变体
'': [''],
'废物': ['费物', '废无'],
'垃圾': ['拉吉', '垃 圾'],
// 粗口相关
'': ['', '', ''],
'卧槽': ['我槽', '我草', 'wc', 'WC', 'Wc'],
'我靠': ['我 k', '我K'],
// 欺凌
'': [''],
'': [''],
};

View File

@@ -20,8 +20,9 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:isar/isar.dart';
import '../local/isar_database.dart';
import '../local/isar_database_native.dart';
import '../local/collections/pending_operation_collection.dart';
import '../models/sync_models.dart';
import '../remote/api_client.dart';
/// 同步操作类型
@@ -136,21 +137,86 @@ class SyncEngine {
bool get isSyncing => _status == SyncStatus.syncing;
/// 添加待同步操作到队列尾部
///
/// 合并策略8b-N01同一资源endpoint 相同)的连续操作只保留最新一条。
/// create+update → create使用最新数据
/// update+update → update使用最新数据
/// update+delete → delete资源最终被删除
/// create+delete → 取消(资源从未存在)
///
/// 私密日记is_private=true不入队 — 仅保存在本地,不上传后端。
void enqueue(PendingOperation operation) {
_pendingQueue.add(operation);
// 防御性检查:私密日记不入队
final isPrivate = operation.data['is_private'] as bool? ?? false;
if (isPrivate) {
debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}');
return;
}
// 查找队列中同一资源的最后一个操作
PendingOperation? existing;
for (final op in _pendingQueue) {
if (op.endpoint == operation.endpoint) {
existing = op;
}
}
if (existing != null) {
final merged = _mergeOperations(existing, operation);
_pendingQueue.remove(existing);
if (merged != null) {
_pendingQueue.add(merged);
}
// merged == null → create+delete 取消,不添加
} else {
_pendingQueue.add(operation);
}
if (_status == SyncStatus.idle) {
_status = SyncStatus.paused;
}
}
/// 批量添加待同步操作
/// 批量添加待同步操作(每个操作独立走合并逻辑)
void enqueueAll(List<PendingOperation> operations) {
for (final op in operations) {
_pendingQueue.add(op);
enqueue(op);
}
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
_status = SyncStatus.paused;
}
/// 合并同一资源的两个操作
///
/// 返回合并后的操作,或 null 表示应取消create+delete
PendingOperation? _mergeOperations(
PendingOperation existing,
PendingOperation incoming,
) {
// create + delete → 取消(资源从未同步到服务端)
if (existing.type == SyncOperationType.create &&
incoming.type == SyncOperationType.delete) {
return null;
}
// create + update → create使用最新数据
if (existing.type == SyncOperationType.create &&
incoming.type == SyncOperationType.update) {
return existing.copyWith(data: incoming.data, version: incoming.version);
}
// update + update → update使用最新数据
if (existing.type == SyncOperationType.update &&
incoming.type == SyncOperationType.update) {
return incoming;
}
// update + delete → delete
if (existing.type == SyncOperationType.update &&
incoming.type == SyncOperationType.delete) {
return incoming;
}
// 其他组合delete+create, create+create 等)不合并
return incoming;
}
/// 检查网络状态并尝试同步全部待处理操作
@@ -221,6 +287,79 @@ class SyncEngine {
await persistPendingQueue();
}
/// 批量同步 — 使用 POST /diary/sync 端点一次性提交所有变更
///
/// 将队列中的 PendingOperation 转换为 SyncChange 列表,
/// 调用 Rust sync_handler 批量处理,获取服务端变更和冲突。
/// 成功后清空队列;失败时保留队列供重试。
Future<SyncResp?> tryBatchSync({DateTime? lastSyncTime}) async {
if (_status == SyncStatus.syncing) return null;
if (_pendingQueue.isEmpty) {
_status = SyncStatus.idle;
return null;
}
_status = SyncStatus.syncing;
_lastError = null;
try {
// 转换: PendingOperation → SyncChange
final changes = _pendingQueue.map(_operationToSyncChange).toList();
final req = SyncReq(
lastSyncTime: lastSyncTime,
changes: changes,
);
final resp = await _apiClient.sync(req);
// 处理冲突 — 将冲突的操作保留在队列中
if (resp.conflicts.isNotEmpty) {
final conflictIds = resp.conflicts.map((c) => c.journalId).toSet();
// 移除已成功同步的非冲突操作,保留冲突操作
_pendingQueue.removeWhere(
(op) => !conflictIds.contains(op.id),
);
_lastError = '${resp.conflicts.length} 个操作存在版本冲突';
} else {
// 全部成功,清空队列
_pendingQueue.clear();
}
_status = _pendingQueue.isEmpty ? SyncStatus.idle : SyncStatus.paused;
await persistPendingQueue();
return resp;
} on OfflineException {
_status = SyncStatus.paused;
_lastError = '同步中断:网络不可用';
return null;
} catch (e) {
_status = SyncStatus.error;
_lastError = '批量同步失败: $e';
return null;
}
}
/// PendingOperation → SyncChange 转换
SyncChange _operationToSyncChange(PendingOperation op) {
switch (op.type) {
case SyncOperationType.create:
return SyncChangeCreateJournal(data: op.data);
case SyncOperationType.update:
return SyncChangeUpdateJournal(
id: op.id,
version: op.version,
data: op.data,
);
case SyncOperationType.delete:
return SyncChangeDeleteJournal(
id: op.id,
version: op.version,
);
}
}
/// 执行单个同步操作
Future<void> _executeOperation(PendingOperation operation) async {
switch (operation.type) {
@@ -256,7 +395,7 @@ class SyncEngine {
/// 在 app 退出、isolate 暂停、或同步完成后调用。
Future<void> persistPendingQueue() async {
if (!IsarDatabase.isAvailable) return;
final isar = IsarDatabase.instance!;
final isar = IsarDatabase.instance;
final ops = snapshot;
await isar.writeTxn(() async {
@@ -277,7 +416,7 @@ class SyncEngine {
/// Web 平台上 Isar 不可用,跳过恢复。
Future<void> restorePendingQueue() async {
if (!IsarDatabase.isAvailable) return;
final isar = IsarDatabase.instance!;
final isar = IsarDatabase.instance;
final persisted = await isar.pendingOperationCollections
.where()
.anyIsarId()

View File

@@ -90,7 +90,15 @@ class SyncEngine {
int get pendingCount => _pendingQueue.length;
bool get isSyncing => _status == SyncStatus.syncing;
/// 入队待同步操作 — 私密日记is_private=true不入队
void enqueue(PendingOperation operation) {
// 防御性检查:私密日记仅保存在本地,不上传后端
final isPrivate = operation.data['is_private'] as bool? ?? false;
if (isPrivate) {
debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}');
return;
}
_pendingQueue.add(operation);
if (_status == SyncStatus.idle) {
_status = SyncStatus.paused;

View File

@@ -171,6 +171,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
// 装饰圆圈
Positioned(left: 20, top: -10, child: _decorCircle(60, AppColors.accent, 0.15)),
@@ -180,6 +181,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
Positioned(left: -10, top: 50, child: _decorCircle(25, AppColors.secondary, 0.13)),
Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo — 自定义笔记本图标
Container(

View File

@@ -131,8 +131,16 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
if (state is CalendarLoaded) {
final current = state as CalendarLoaded;
// 根据当前选中日期查找日记,避免进入页面时空白
final dayKey = DateTime(
current.selectedDay.year,
current.selectedDay.month,
current.selectedDay.day,
);
final selectedJournals = byDate[dayKey] ?? [];
emit(current.copyWith(
journalsByDate: byDate,
selectedDayJournals: selectedJournals,
isLoading: false,
));
}

View File

@@ -9,6 +9,7 @@ import 'package:nuanji_app/core/theme/app_radius.dart';
import 'package:nuanji_app/core/utils/mood_utils.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import '../../../widgets/error_state_widget.dart';
import '../bloc/calendar_bloc.dart';
/// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
@@ -41,25 +42,17 @@ class _CalendarView extends StatelessWidget {
}
if (state is CalendarError) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(state.message, style: theme.textTheme.bodyLarge),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: () => context.read<CalendarBloc>()
.add(CalendarMonthChanged(DateTime.now())),
child: const Text('重试'),
),
],
),
return ErrorStateWidget(
message: state.message,
onRetry: () => context.read<CalendarBloc>()
.add(CalendarMonthChanged(DateTime.now())),
icon: Icons.error_outline,
);
}
if (state is! CalendarLoaded) return const SizedBox.shrink();
if (state is! CalendarLoaded) {
return const Center(child: CircularProgressIndicator());
}
final loaded = state;
return Column(
@@ -81,6 +74,9 @@ class _CalendarView extends StatelessWidget {
);
context.read<CalendarBloc>().add(CalendarMonthChanged(next));
},
onToday: () {
context.read<CalendarBloc>().add(CalendarMonthChanged(DateTime.now()));
},
),
// 视图模式切换
@@ -541,11 +537,13 @@ class _MonthNavigator extends StatelessWidget {
required this.month,
required this.onPrevious,
required this.onNext,
this.onToday,
});
final DateTime month;
final VoidCallback onPrevious;
final VoidCallback onNext;
final VoidCallback? onToday;
@override
Widget build(BuildContext context) {
@@ -578,6 +576,22 @@ class _MonthNavigator extends StatelessWidget {
fontWeight: FontWeight.bold,
),
),
// "今天" 按钮 — 不在当前月时显示
if (onToday != null && !_isCurrentMonth(month))
Padding(
padding: const EdgeInsets.only(left: 8),
child: TextButton(
onPressed: onToday,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('今天', style: TextStyle(fontSize: 13)),
),
)
else
const SizedBox(width: 8),
SizedBox(
width: 44,
height: 44,
@@ -605,6 +619,11 @@ class _MonthNavigator extends StatelessWidget {
];
return '${date.year}${months[date.month - 1]}';
}
bool _isCurrentMonth(DateTime date) {
final now = DateTime.now();
return date.year == now.year && date.month == now.month;
}
}
// ===== 星期标题行 =====

View File

@@ -9,6 +9,8 @@ import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/school_class.dart';
import 'package:nuanji_app/data/repositories/class_repository.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import '../../../widgets/empty_state_widget.dart';
import '../../../widgets/error_state_widget.dart';
import '../../auth/bloc/auth_bloc.dart';
import '../bloc/class_bloc.dart';
import '../widgets/comment_bottom_sheet.dart';
@@ -46,7 +48,10 @@ class _ClassView extends StatelessWidget {
if (state is ClassError) {
return Scaffold(
appBar: AppBar(title: const Text('班级')),
body: Center(child: Text(state.message)),
body: ErrorStateWidget(
message: state.message,
onRetry: () => context.read<ClassBloc>().add(const ClassLoadMyClasses()),
),
);
}
@@ -93,22 +98,11 @@ class _ClassListView extends StatelessWidget {
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.groups_outlined, size: 64, color: colorScheme.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 16),
Text('还没有加入任何班级', style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
)),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: () => context.go('/class-code'),
child: const Text('输入班级码加入'),
),
],
),
return EmptyStateWidget(
icon: Icons.group_add_rounded,
title: '还没有加入班级',
actionLabel: '通过班级码加入',
onAction: () => context.go('/class-code'),
);
}
}
@@ -250,21 +244,11 @@ class _DiaryWallTab extends StatelessWidget {
}
if (state.diaryWall.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_stories_outlined, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text('日记墙还是空的', style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
)),
const SizedBox(height: 8),
Text('分享你的日记到班级吧!', style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.3),
)),
],
),
return const EmptyStateWidget(
icon: Icons.auto_stories_rounded,
title: '日记墙还是空的',
subtitle: '分享你的日记到这里吧',
iconSize: 48,
);
}
@@ -313,7 +297,7 @@ class _DiaryWallCard extends StatelessWidget {
radius: 16,
backgroundColor: AppColors.rose.withValues(alpha: 0.2),
child: Text(
'',
journal.title.isNotEmpty ? journal.title[0] : '',
style: theme.textTheme.labelMedium?.copyWith(color: AppColors.rose),
),
),
@@ -344,31 +328,35 @@ class _DiaryWallCard extends StatelessWidget {
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
),
// 评语
if (comments.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: AppRadius.smBorder,
),
child: Row(
children: [
const Icon(Icons.rate_review_rounded, size: 14),
const SizedBox(width: 4),
Expanded(
child: Text(
comments.first.content,
style: theme.textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
// 评语(按 journalId 过滤,避免显示在错误卡片下)
...(() {
final journalComments = comments.where((c) => c.journalId == journal.id).toList();
if (journalComments.isEmpty) return <Widget>[];
return [
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
borderRadius: AppRadius.smBorder,
),
child: Row(
children: [
const Icon(Icons.rate_review_rounded, size: 14),
const SizedBox(width: 4),
Expanded(
child: Text(
journalComments.first.content,
style: theme.textTheme.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
],
],
),
),
),
],
];
})(),
// 写评语按钮(仅老师可见)
if (_isTeacher(context)) ...[
const SizedBox(height: 8),
@@ -437,10 +425,9 @@ class _TopicsTab extends StatelessWidget {
final colorScheme = theme.colorScheme;
if (topics.isEmpty) {
return Center(
child: Text('暂无主题布置', style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
)),
return const EmptyStateWidget(
icon: Icons.assignment_outlined,
title: '暂无主题布置',
);
}

View File

@@ -0,0 +1,78 @@
// 发现页 BLoC — 管理发现页数据加载状态
//
// 职责:调用 /diary/discover API解析响应管理加载/成功/失败状态。
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/remote/api_client.dart';
import '../models/discover_models.dart';
part 'discover_event.dart';
part 'discover_state.dart';
class DiscoverBloc extends Bloc<DiscoverEvent, DiscoverState> {
final ApiClient _api;
DiscoverBloc({required ApiClient api})
: _api = api,
super(const DiscoverInitial()) {
on<DiscoverLoadData>(_onLoadData);
on<DiscoverRefresh>(_onRefresh);
}
/// 首次加载 — 显示 loading 状态
Future<void> _onLoadData(
DiscoverLoadData event,
Emitter<DiscoverState> emit,
) async {
emit(const DiscoverLoading());
await _fetchData(emit);
}
/// 刷新 — 不显示 loading静默更新
Future<void> _onRefresh(
DiscoverRefresh event,
Emitter<DiscoverState> emit,
) async {
await _fetchData(emit);
}
/// 通用数据获取逻辑
Future<void> _fetchData(Emitter<DiscoverState> emit) async {
try {
final response = await _api.get('/diary/discover');
final body = response.data as Map<String, dynamic>;
// 后端信封格式: { success, data: { ... }, message }
final dataJson = body['data'] as Map<String, dynamic>? ?? {};
final discoverData = DiscoverData.fromJson(dataJson);
emit(DiscoverLoaded(discoverData));
} on OfflineException {
// 离线时,如果有已加载的数据,保留它
if (state is DiscoverLoaded) return;
emit(const DiscoverError('网络不可用,请检查网络连接'));
} catch (e) {
if (state is DiscoverLoaded) return;
emit(DiscoverError('加载失败:${_friendlyError(e)}'));
}
}
/// 将异常转换为用户友好的错误消息
String _friendlyError(Object error) {
final msg = error.toString();
if (msg.contains('SocketException') || msg.contains('Connection refused')) {
return '无法连接服务器';
}
if (msg.contains('401')) {
return '登录已过期,请重新登录';
}
if (msg.contains('403')) {
return '没有访问权限';
}
if (msg.contains('500')) {
return '服务器错误,请稍后重试';
}
return '请稍后重试';
}
}

View File

@@ -0,0 +1,16 @@
part of 'discover_bloc.dart';
/// 发现页事件
sealed class DiscoverEvent {
const DiscoverEvent();
}
/// 加载发现页数据(首次进入或重新进入页面)
final class DiscoverLoadData extends DiscoverEvent {
const DiscoverLoadData();
}
/// 下拉刷新(不显示全屏 loading避免闪烁
final class DiscoverRefresh extends DiscoverEvent {
const DiscoverRefresh();
}

View File

@@ -0,0 +1,30 @@
part of 'discover_bloc.dart';
/// 发现页状态
sealed class DiscoverState {
const DiscoverState();
}
/// 初始状态
final class DiscoverInitial extends DiscoverState {
const DiscoverInitial();
}
/// 加载中
final class DiscoverLoading extends DiscoverState {
const DiscoverLoading();
}
/// 加载成功
final class DiscoverLoaded extends DiscoverState {
final DiscoverData data;
const DiscoverLoaded(this.data);
}
/// 加载失败
final class DiscoverError extends DiscoverState {
final String message;
const DiscoverError(this.message);
}

View File

@@ -0,0 +1,160 @@
// 发现页数据模型 — 手写不可变类(避免 build_runner 依赖)
/// 发现页聚合响应 — 一次 API 返回全部板块数据
class DiscoverData {
final InspirationItem? dailyInspiration;
final List<TagCount> hotTopics;
final List<DiscoverTemplateItem> featuredTemplates;
final List<ExpertDiaryItem> expertDiaries;
const DiscoverData({
this.dailyInspiration,
this.hotTopics = const [],
this.featuredTemplates = const [],
this.expertDiaries = const [],
});
factory DiscoverData.fromJson(Map<String, dynamic> json) => DiscoverData(
dailyInspiration: json['daily_inspiration'] != null
? InspirationItem.fromJson(
json['daily_inspiration'] as Map<String, dynamic>)
: null,
hotTopics: (json['hot_topics'] as List? ?? [])
.map((e) => TagCount.fromJson(e as Map<String, dynamic>))
.toList(),
featuredTemplates: (json['featured_templates'] as List? ?? [])
.map(
(e) => DiscoverTemplateItem.fromJson(e as Map<String, dynamic>))
.toList(),
expertDiaries: (json['expert_diaries'] as List? ?? [])
.map((e) => ExpertDiaryItem.fromJson(e as Map<String, dynamic>))
.toList(),
);
/// 心情 → emoji 映射
static String moodToEmoji(String mood) => switch (mood) {
'happy' => '😊',
'calm' => '😌',
'sad' => '😢',
'angry' => '😤',
'thinking' => '🤔',
_ => '📝',
};
}
/// 每日推荐条目
class InspirationItem {
final String journalId;
final String title;
final String authorName;
final String mood;
final DateTime date;
const InspirationItem({
required this.journalId,
required this.title,
required this.authorName,
required this.mood,
required this.date,
});
factory InspirationItem.fromJson(Map<String, dynamic> json) =>
InspirationItem(
journalId: json['journal_id'] as String,
title: json['title'] as String,
authorName: json['author_name'] as String,
mood: json['mood'] as String,
date: DateTime.parse(json['date'] as String),
);
}
/// 热门话题
class TagCount {
final String tag;
final int count;
const TagCount({required this.tag, required this.count});
factory TagCount.fromJson(Map<String, dynamic> json) => TagCount(
tag: json['tag'] as String,
count: json['count'] as int,
);
}
/// 精选模板条目(轻量版,不含 layout_data
class DiscoverTemplateItem {
final String id;
final String name;
final String? previewUrl;
final String? category;
final bool isFree;
const DiscoverTemplateItem({
required this.id,
required this.name,
this.previewUrl,
this.category,
this.isFree = true,
});
factory DiscoverTemplateItem.fromJson(Map<String, dynamic> json) =>
DiscoverTemplateItem(
id: json['id'] as String,
name: json['name'] as String,
previewUrl: json['preview_url'] as String?,
category: json['category'] as String?,
isFree: json['is_free'] as bool? ?? true,
);
/// 分类 → emoji 映射
String get emoji => switch (category) {
'日常' => '📖',
'旅行' => '✈️',
'校园' => '🎓',
'节日' => '🎄',
'创意' => '',
'心情' => '🌿',
_ => '📝',
};
/// 使用人数展示文本
String get usageText => isFree ? '免费模板' : '精品模板';
}
/// 达人日记条目
class ExpertDiaryItem {
final String journalId;
final String title;
final String authorId;
final String authorName;
final String authorEmoji;
final String contentPreview;
final int likeCount;
final DateTime createdAt;
const ExpertDiaryItem({
required this.journalId,
required this.title,
required this.authorId,
required this.authorName,
required this.authorEmoji,
required this.contentPreview,
required this.likeCount,
required this.createdAt,
});
factory ExpertDiaryItem.fromJson(Map<String, dynamic> json) =>
ExpertDiaryItem(
journalId: json['journal_id'] as String,
title: json['title'] as String,
authorId: json['author_id'] as String,
authorName: json['author_name'] as String,
authorEmoji: json['author_emoji'] as String,
contentPreview: json['content_preview'] as String? ?? '',
likeCount: json['like_count'] as int? ?? 0,
createdAt: DateTime.parse(json['created_at'] as String),
);
/// 点赞数展示文本
String get likeText => '$likeCount';
}

View File

@@ -8,8 +8,10 @@
// 5. 达人日记 expert-diaries (纵向列表)
//
// 注意:本页是发现/灵感浏览,区别于 /search主动搜索
// 数据来源GET /diary/discover → DiscoverBloc
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/design_tokens.dart';
@@ -17,51 +19,156 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/theme/app_shadows.dart';
import '../../../core/theme/app_typography.dart';
import '../../../widgets/empty_state_widget.dart';
import '../../../widgets/error_state_widget.dart';
import '../bloc/discover_bloc.dart';
import '../models/discover_models.dart';
class DiscoverPage extends StatelessWidget {
const DiscoverPage({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final bg = isDark ? AppColors.bgDark : AppColors.bgLight;
return Scaffold(
backgroundColor: bg,
backgroundColor: _bgColor(context),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: DesignTokens.spacing12),
_SearchBar(onTap: () => context.push('/search')),
const SizedBox(height: DesignTokens.spacing20),
const _InspirationCard(
title: '今日推荐:图书馆的午后时光',
author: '小暖 · 5月31日',
emoji: '📚',
),
const SizedBox(height: DesignTokens.spacing24),
_SectionTitle(title: '热门话题'),
const SizedBox(height: DesignTokens.spacing12),
const _HotTopicsChips(),
const SizedBox(height: DesignTokens.spacing24),
_SectionTitle(title: '精选模板'),
const SizedBox(height: DesignTokens.spacing12),
const _FeaturedTemplatesGrid(),
const SizedBox(height: DesignTokens.spacing24),
_SectionTitle(title: '达人日记'),
const SizedBox(height: DesignTokens.spacing12),
const _ExpertDiariesList(),
const SizedBox(height: DesignTokens.spacing24),
],
child: RefreshIndicator(
onRefresh: () async {
context.read<DiscoverBloc>().add(const DiscoverRefresh());
// 等待状态变化完成
await context.read<DiscoverBloc>().stream.firstWhere(
(s) => s is DiscoverLoaded || s is DiscoverError,
orElse: () => const DiscoverLoaded(DiscoverData()),
);
},
child: BlocBuilder<DiscoverBloc, DiscoverState>(
builder: (context, state) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.spacing20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: DesignTokens.spacing12),
_SearchBar(onTap: () => context.push('/search')),
const SizedBox(height: DesignTokens.spacing20),
_buildContent(context, state),
const SizedBox(height: DesignTokens.spacing24),
],
),
);
},
),
),
),
);
}
/// 根据状态构建主要内容
Widget _buildContent(BuildContext context, DiscoverState state) {
return switch (state) {
DiscoverInitial() => _buildLoading(context),
DiscoverLoading() => _buildLoading(context),
DiscoverLoaded(:final data) => _buildLoaded(context, data),
DiscoverError(:final message) => _buildError(context, message),
};
}
/// 加载中状态 — 骨架占位
Widget _buildLoading(BuildContext context) {
return const Column(
children: [
_LoadingSkeleton(height: 140),
SizedBox(height: DesignTokens.spacing24),
_LoadingSkeleton(height: 44),
SizedBox(height: DesignTokens.spacing24),
_LoadingSkeleton(height: 200),
],
);
}
/// 加载成功 — 渲染真实数据
Widget _buildLoaded(BuildContext context, DiscoverData data) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 每日推荐
_InspirationCard(item: data.dailyInspiration),
// 热门话题
if (data.hotTopics.isNotEmpty) ...[
const SizedBox(height: DesignTokens.spacing24),
const _SectionTitle(title: '热门话题'),
const SizedBox(height: DesignTokens.spacing12),
_HotTopicsChips(topics: data.hotTopics),
],
// 精选模板
if (data.featuredTemplates.isNotEmpty) ...[
const SizedBox(height: DesignTokens.spacing24),
const _SectionTitle(title: '精选模板'),
const SizedBox(height: DesignTokens.spacing12),
_FeaturedTemplatesGrid(templates: data.featuredTemplates),
],
// 达人日记
if (data.expertDiaries.isNotEmpty) ...[
const SizedBox(height: DesignTokens.spacing24),
const _SectionTitle(title: '达人日记'),
const SizedBox(height: DesignTokens.spacing12),
_ExpertDiariesList(diaries: data.expertDiaries),
],
// 全部为空时的占位提示
if (data.dailyInspiration == null &&
data.hotTopics.isEmpty &&
data.featuredTemplates.isEmpty &&
data.expertDiaries.isEmpty)
_buildEmptyHint(context),
],
);
}
/// 错误状态
Widget _buildError(BuildContext context, String message) {
return ErrorStateWidget(
message: message,
onRetry: () =>
context.read<DiscoverBloc>().add(const DiscoverLoadData()),
);
}
/// 空数据提示
Widget _buildEmptyHint(BuildContext context) {
return const EmptyStateWidget(
icon: Icons.explore_rounded,
title: '还没有发现内容',
subtitle: '试试写一篇日记分享给大家吧',
);
}
Color _bgColor(BuildContext context) {
final theme = Theme.of(context);
return theme.brightness == Brightness.dark
? AppColors.bgDark
: AppColors.bgLight;
}
}
/// 加载骨架占位
class _LoadingSkeleton extends StatelessWidget {
const _LoadingSkeleton({required this.height});
final double height;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
height: height,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: 0.3),
borderRadius: AppRadius.lgBorder,
),
);
}
}
/// 1. 搜索框(点击跳转 /search
@@ -77,7 +184,8 @@ class _SearchBar extends StatelessWidget {
borderRadius: AppRadius.pillBorder,
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
padding:
const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: AppRadius.pillBorder,
@@ -85,7 +193,8 @@ class _SearchBar extends StatelessWidget {
),
child: Row(
children: [
Icon(Icons.search_rounded, size: 20, color: theme.colorScheme.onSurfaceVariant),
Icon(Icons.search_rounded,
size: 20, color: theme.colorScheme.onSurfaceVariant),
const SizedBox(width: DesignTokens.spacing12),
Text(
'搜索日记、模板、话题...',
@@ -103,18 +212,49 @@ class _SearchBar extends StatelessWidget {
/// 2. 每日推荐卡片(渐变背景)
class _InspirationCard extends StatelessWidget {
const _InspirationCard({
required this.title,
required this.author,
required this.emoji,
});
final String title;
final String author;
final String emoji;
const _InspirationCard({required this.item});
final InspirationItem? item;
@override
Widget build(BuildContext context) {
if (item == null) {
// 无推荐日记时的占位卡片
return Container(
width: double.infinity,
padding: const EdgeInsets.all(DesignTokens.spacing20),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.accent, AppColors.tertiary],
),
borderRadius: AppRadius.lgBorder,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('今日推荐',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white.withValues(alpha: 0.85),
letterSpacing: 0.5,
)),
const SizedBox(height: DesignTokens.spacing12),
const Text('今天还没有推荐日记',
style: TextStyle(fontSize: 16, color: Colors.white)),
const SizedBox(height: 4),
Text('写下你的日记,可能出现在这里哦 ✨',
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.7))),
],
),
);
}
final emoji = DiscoverData.moodToEmoji(item!.mood);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(DesignTokens.spacing20),
@@ -191,17 +331,19 @@ class _InspirationCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
item!.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: Colors.white,
height: 1.25,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Text(
author,
'${item!.authorName} · ${_formatDate(item!.date)}',
style: TextStyle(
fontSize: 12,
color: Colors.white.withValues(alpha: 0.75),
@@ -218,6 +360,10 @@ class _InspirationCard extends StatelessWidget {
),
);
}
String _formatDate(DateTime date) {
return '${date.month}${date.day}';
}
}
class _SectionTitle extends StatelessWidget {
@@ -241,12 +387,8 @@ class _SectionTitle extends StatelessWidget {
/// 3. 热门话题(横向滚动 chips
class _HotTopicsChips extends StatelessWidget {
const _HotTopicsChips();
static const _topics = [
'#期末备考', '#读书笔记', '#旅行手账', '#美食日记',
'#校园生活', '#自我成长', '#心情日记', '#手写摘抄',
];
const _HotTopicsChips({required this.topics});
final List<TagCount> topics;
@override
Widget build(BuildContext context) {
@@ -255,24 +397,31 @@ class _HotTopicsChips extends StatelessWidget {
height: 44,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _topics.length,
separatorBuilder: (_, __) => const SizedBox(width: DesignTokens.spacing8),
itemCount: topics.length,
separatorBuilder: (_, __) =>
const SizedBox(width: DesignTokens.spacing8),
itemBuilder: (context, index) {
final isHot = index < 3;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
padding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: isHot ? theme.colorScheme.primary : theme.colorScheme.surface,
color:
isHot ? theme.colorScheme.primary : theme.colorScheme.surface,
borderRadius: AppRadius.pillBorder,
border: isHot ? null : Border.all(color: theme.colorScheme.outlineVariant),
border: isHot
? null
: Border.all(color: theme.colorScheme.outlineVariant),
),
alignment: Alignment.center,
child: Text(
_topics[index],
'#${topics[index].tag}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isHot ? theme.colorScheme.onPrimary : theme.colorScheme.onSurface,
color: isHot
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface,
),
),
);
@@ -284,14 +433,8 @@ class _HotTopicsChips extends StatelessWidget {
/// 4. 精选模板2 列网格)
class _FeaturedTemplatesGrid extends StatelessWidget {
const _FeaturedTemplatesGrid();
static const _templates = [
('📖', '每日心情日记', '2.3k 人使用', AppColors.secondarySoftLight),
('🎓', '期末复习计划', '1.8k 人使用', AppColors.tertiarySoftLight),
('🌿', '植物观察日记', '956 人使用', AppColors.roseSoftLight),
('✈️', '旅行手账本', '742 人使用', AppColors.secondarySoftLight),
];
const _FeaturedTemplatesGrid({required this.templates});
final List<DiscoverTemplateItem> templates;
@override
Widget build(BuildContext context) {
@@ -304,13 +447,28 @@ class _FeaturedTemplatesGrid extends StatelessWidget {
crossAxisSpacing: DesignTokens.spacing12,
childAspectRatio: 0.85,
),
itemCount: _templates.length,
itemCount: templates.length,
itemBuilder: (context, index) {
final t = _templates[index];
return _TemplateCard(emoji: t.$1, name: t.$2, usage: t.$3, bg: t.$4);
final t = templates[index];
return _TemplateCard(
emoji: t.emoji,
name: t.name,
usage: t.usageText,
bg: _categoryColor(t.category),
);
},
);
}
Color _categoryColor(String? category) {
return switch (category) {
'日常' => AppColors.secondarySoftLight,
'校园' => AppColors.tertiarySoftLight,
'心情' => AppColors.roseSoftLight,
'旅行' => AppColors.secondarySoftLight,
_ => AppColors.secondarySoftLight,
};
}
}
class _TemplateCard extends StatelessWidget {
@@ -368,7 +526,9 @@ class _TemplateCard extends StatelessWidget {
const SizedBox(height: 2),
Text(
usage,
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant),
),
],
),
@@ -380,19 +540,14 @@ class _TemplateCard extends StatelessWidget {
/// 5. 达人日记(纵向列表)
class _ExpertDiariesList extends StatelessWidget {
const _ExpertDiariesList();
static const _experts = [
('🌸', '小桃子', '春日漫步手账', '记录春天的每一朵花开...', '342 赞'),
('', '咖啡少年', '咖啡馆日记', '今天尝试了一家新店...', '218 赞'),
('📝', '学习达人', '考研倒计时30天', '坚持就是胜利...', '556 赞'),
];
const _ExpertDiariesList({required this.diaries});
final List<ExpertDiaryItem> diaries;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
children: _experts.map((e) {
children: diaries.map((diary) {
return Container(
margin: const EdgeInsets.only(bottom: DesignTokens.spacing12),
padding: const EdgeInsets.all(DesignTokens.spacing16),
@@ -412,7 +567,8 @@ class _ExpertDiariesList extends StatelessWidget {
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(e.$1, style: const TextStyle(fontSize: 20)),
child: Text(diary.authorEmoji,
style: const TextStyle(fontSize: 20)),
),
const SizedBox(width: DesignTokens.spacing12),
Expanded(
@@ -422,7 +578,7 @@ class _ExpertDiariesList extends StatelessWidget {
Row(
children: [
Text(
e.$2,
diary.authorName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
@@ -432,12 +588,13 @@ class _ExpertDiariesList extends StatelessWidget {
const SizedBox(width: DesignTokens.spacing8),
Text(
'·',
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
style: TextStyle(
color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(width: DesignTokens.spacing8),
Expanded(
child: Text(
e.$3,
diary.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@@ -452,7 +609,9 @@ class _ExpertDiariesList extends StatelessWidget {
),
const SizedBox(height: 4),
Text(
e.$4,
diary.contentPreview.isNotEmpty
? diary.contentPreview
: '...',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
@@ -468,11 +627,14 @@ class _ExpertDiariesList extends StatelessWidget {
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.favorite_rounded, size: 14, color: AppColors.rose),
Icon(Icons.favorite_rounded,
size: 14, color: AppColors.rose),
const SizedBox(width: 4),
Text(
e.$5,
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
diary.likeText,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant),
),
],
),

View File

@@ -99,6 +99,16 @@ class ElementSelected extends EditorEvent {
ElementSelected(this.elementId);
}
/// 图层顺序调整方向
enum LayerChange { bringToFront, sendToBack }
/// 调整元素图层顺序
class ElementLayerChanged extends EditorEvent {
final String elementId;
final LayerChange change;
ElementLayerChanged({required this.elementId, required this.change});
}
// --- 工具栏事件 ---
/// 切换活动工具
@@ -107,6 +117,12 @@ class ToolChanged extends EditorEvent {
ToolChanged(this.tool);
}
/// 再次点击已激活的工具 — 重新弹出设置面板
class ToolReactivated extends EditorEvent {
final EditorTool tool;
ToolReactivated(this.tool);
}
/// 加载已有元素
class ElementsLoaded extends EditorEvent {
final List<JournalElement> elements;
@@ -227,6 +243,9 @@ class EditorState {
final bool isDirty;
final DateTime? lastSavedAt;
// 工具重新激活时间戳(用于驱动面板重新弹出)
final int toolReactivatedAt;
const EditorState({
this.strokes = const [],
this.redoStack = const [],
@@ -243,6 +262,7 @@ class EditorState {
this.title = '',
this.isDirty = false,
this.lastSavedAt,
this.toolReactivatedAt = 0,
});
EditorState copyWith({
@@ -261,6 +281,7 @@ class EditorState {
String? title,
bool? isDirty,
DateTime? lastSavedAt,
int? toolReactivatedAt,
}) =>
EditorState(
strokes: strokes ?? this.strokes,
@@ -279,6 +300,7 @@ class EditorState {
title: title ?? this.title,
isDirty: isDirty ?? this.isDirty,
lastSavedAt: lastSavedAt ?? this.lastSavedAt,
toolReactivatedAt: toolReactivatedAt ?? this.toolReactivatedAt,
);
/// 是否处于手写模式
@@ -323,6 +345,7 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
on<ElementResized>(_onElementResized);
on<ElementRotated>(_onElementRotated);
on<ElementSelected>(_onElementSelected);
on<ElementLayerChanged>(_onElementLayerChanged);
on<ElementsLoaded>(_onElementsLoaded);
// 日记加载事件
@@ -330,6 +353,7 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
// 工具栏事件
on<ToolChanged>(_onToolChanged);
on<ToolReactivated>(_onToolReactivated);
// 标签/心情/标题事件
on<TagAdded>(_onTagAdded);
@@ -479,6 +503,36 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
));
}
/// 调整元素图层顺序 — 置顶或置底
void _onElementLayerChanged(
ElementLayerChanged event,
Emitter<EditorState> emit,
) {
final elements = List<JournalElement>.from(state.elements);
final index = elements.indexWhere((e) => e.id == event.elementId);
if (index == -1) return;
switch (event.change) {
case LayerChange.bringToFront:
// 设为最大 zIndex + 1
final maxZ = elements.fold<int>(
0,
(max, e) => e.zIndex > max ? e.zIndex : max,
);
elements[index] = elements[index].copyWith(zIndex: maxZ + 1);
case LayerChange.sendToBack:
// 设为最小 zIndex - 1
final minZ = elements.fold<int>(
0,
(min, e) => e.zIndex < min ? e.zIndex : min,
);
elements[index] = elements[index].copyWith(zIndex: minZ - 1);
}
emit(state.copyWith(elements: elements, isDirty: true));
_scheduleAutoSave();
}
void _onElementsLoaded(ElementsLoaded event, Emitter<EditorState> emit) {
emit(state.copyWith(elements: event.elements));
}
@@ -514,6 +568,13 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
));
}
void _onToolReactivated(ToolReactivated event, Emitter<EditorState> emit) {
// 不改变 activeTool仅递增时间戳驱动 UI 层重新弹出面板
emit(state.copyWith(
toolReactivatedAt: DateTime.now().millisecondsSinceEpoch,
));
}
// ============================================================
// 标签/心情/标题事件处理
// ============================================================

View File

@@ -37,12 +37,26 @@ import '../widgets/brush_panel.dart';
import '../widgets/dot_grid_painter.dart';
/// 手账编辑器页面
class EditorPage extends StatelessWidget {
class EditorPage extends StatefulWidget {
final String? journalId;
final String? templateId;
const EditorPage({super.key, this.journalId, this.templateId});
@override
State<EditorPage> createState() => _EditorPageState();
}
class _EditorPageState extends State<EditorPage> {
/// 跟踪已保存的日记 ID — 新建日记首次保存后赋值
String? _savedJournalId;
@override
void initState() {
super.initState();
_savedJournalId = widget.journalId;
}
@override
Widget build(BuildContext context) {
// 从 Provider 树获取 JournalRepositoryIsarJournalRepository
@@ -50,10 +64,6 @@ class EditorPage extends StatelessWidget {
// 从 Provider 树获取 SyncEngine同步到后端
final syncEngine = context.read<SyncEngine>();
// 可变闭包变量:跟踪已保存的日记 ID
// 新建日记首次保存后赋值,后续自动更新使用此 ID
String? savedJournalId = journalId;
return BlocProvider(
create: (_) => EditorBloc(
onSave: (state) async {
@@ -66,7 +76,7 @@ class EditorPage extends StatelessWidget {
}
await _persistState(
repo, state, (id) => savedJournalId = id, savedJournalId,
repo, state, (id) => _savedJournalId = id, _savedJournalId,
syncEngine: syncEngine,
authorId: authorId,
);
@@ -76,12 +86,12 @@ class EditorPage extends StatelessWidget {
},
),
child: _EditorView(
journalId: journalId,
templateId: templateId,
savedJournalId: savedJournalId,
journalId: widget.journalId,
templateId: widget.templateId,
savedJournalId: _savedJournalId,
repo: repo,
onSaveComplete: () {
_showShareSheetAndNavigate(context, repo, savedJournalId);
_showShareSheetAndNavigate(context, repo, _savedJournalId);
},
),
);
@@ -111,43 +121,59 @@ class EditorPage extends StatelessWidget {
title: '${now.month}${now.day}日的日记',
date: now,
);
await repo.createJournal(entry);
setId(entry.id);
// 保存笔画
// 保存到仓库Web=远程API原生=Isar本地
// 远程仓库返回服务端生成的 ID必须使用返回值
final saved = await repo.createJournal(entry);
final journalId = saved.id;
setId(journalId);
// 保存笔画 — 使用 saved.id与仓库一致
if (state.strokes.isNotEmpty) {
await _saveStrokesAsElement(repo, entry.id, state.strokes);
await _saveStrokesAsElement(repo, journalId, state.strokes);
}
// 保存其他元素
for (final element in state.elements) {
await repo.addElement(element.copyWith(journalId: entry.id));
await repo.addElement(element.copyWith(journalId: journalId));
}
// 入队 SyncEngine 等待同步到后端
syncEngine.enqueue(PendingOperation(
id: entry.id,
type: SyncOperationType.create,
endpoint: '/diary/journals',
data: entry.toJson(),
version: entry.version,
createdAt: now,
));
// 仅非私密日记入队 SyncEngine 等待同步到后端
// 私密日记is_private=true仅保存在本地不上传
if (!saved.isPrivate) {
syncEngine.enqueue(PendingOperation(
id: journalId,
type: SyncOperationType.create,
endpoint: '/diary/journals',
data: saved.toJson(),
version: saved.version,
createdAt: now,
));
}
} else {
// --- 更新已有日记 ---
final existing = await repo.getJournal(savedJournalId);
if (existing != null) {
await repo.updateJournal(existing);
// 将编辑器当前状态合并到已有日记中
final updated = existing.copyWith(
title: state.title.isNotEmpty ? state.title : existing.title,
mood: state.selectedMood,
tags: state.tags.isNotEmpty ? state.tags : existing.tags,
updatedAt: now,
);
await repo.updateJournal(updated);
// 入队 SyncEngine 等待同步到后端
syncEngine.enqueue(PendingOperation(
id: existing.id,
type: SyncOperationType.update,
endpoint: '/diary/journals/${existing.id}',
data: existing.toJson(),
version: existing.version,
createdAt: now,
));
// 仅非私密日记入队 SyncEngine
if (!updated.isPrivate) {
syncEngine.enqueue(PendingOperation(
id: updated.id,
type: SyncOperationType.update,
endpoint: '/diary/journals/${updated.id}',
data: updated.toJson(),
version: updated.version,
createdAt: now,
));
}
}
// 更新笔画
@@ -198,6 +224,11 @@ class EditorPage extends StatelessWidget {
}
/// 显示分享面板并在用户选择后导航
///
/// 分享行为:
/// - 分享到班级/所有人 → is_private=false + shared_to_class=对应值
/// - 仅自己可见 → is_private=true不上传到后端
/// - 首次将私密日记变为公开时,入队 SyncEngine create 操作上传
static Future<void> _showShareSheetAndNavigate(
BuildContext context,
JournalRepository repo,
@@ -229,14 +260,41 @@ class EditorPage extends StatelessWidget {
classId: userClassId,
className: userClassName,
onDecision: (shareToClass) async {
// 更新日记的 sharedToClass 状态
if (savedJournalId != null) {
try {
final entry = await repo.getJournal(savedJournalId);
if (entry != null) {
await repo.updateJournal(
entry.copyWith(sharedToClass: shareToClass),
final wasPrivate = entry.isPrivate;
// 分享到班级/所有人 → 取消私密标记
final updated = entry.copyWith(
isPrivate: false,
sharedToClass: shareToClass,
);
await repo.updateJournal(updated);
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
if (wasPrivate && !updated.isPrivate) {
final syncEngine = context.read<SyncEngine>();
syncEngine.enqueue(PendingOperation(
id: updated.id,
type: SyncOperationType.create,
endpoint: '/diary/journals',
data: updated.toJson(),
version: updated.version,
createdAt: DateTime.now(),
));
} else if (!updated.isPrivate) {
// 已公开日记的分享状态更新
final syncEngine = context.read<SyncEngine>();
syncEngine.enqueue(PendingOperation(
id: updated.id,
type: SyncOperationType.update,
endpoint: '/diary/journals/${updated.id}',
data: updated.toJson(),
version: updated.version,
createdAt: DateTime.now(),
));
}
}
} catch (e) {
debugPrint('更新分享状态失败: $e');
@@ -276,15 +334,29 @@ class _EditorView extends StatefulWidget {
}
class _EditorViewState extends State<_EditorView> {
/// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式
bool _isViewMode = false;
/// 保存中状态 — 用于显示"保存中..."指示器
bool _isSaving = false;
@override
void initState() {
super.initState();
// 当 journalId 非空时,从 Isar 加载已有日记数据
// 当 journalId 非空时,进入查看模式
_isViewMode = widget.journalId != null;
if (widget.journalId != null) {
_loadExistingJournal(widget.journalId!);
}
}
/// 从查看模式切换到编辑模式
void _enterEditMode() {
setState(() => _isViewMode = false);
// 切换到画笔工具,进入编辑状态
context.read<EditorBloc>().add(ToolChanged(EditorTool.brush));
}
/// 从 Isar 加载已有日记 — 使用 LoadJournal 原子事件一次性还原
Future<void> _loadExistingJournal(String id) async {
try {
@@ -323,6 +395,11 @@ class _EditorViewState extends State<_EditorView> {
elements: otherElements,
lastSavedAt: entry.updatedAt,
));
// 查看模式下使用 select 工具,避免自动弹出画笔面板
if (_isViewMode) {
context.read<EditorBloc>().add(ToolChanged(EditorTool.select));
}
} catch (e) {
debugPrint('加载日记数据失败: $e');
}
@@ -347,26 +424,31 @@ class _EditorViewState extends State<_EditorView> {
Expanded(
child: BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return _EditorStack(state: state, journalId: widget.journalId);
return _EditorStack(
state: state,
journalId: widget.journalId,
isViewMode: _isViewMode,
);
},
),
),
// 底部工具栏(自带底部安全区)
BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return EditorToolbar(
state: state,
onEvent: (event) => context.read<EditorBloc>().add(event),
);
},
),
// 底部工具栏 — 仅编辑模式显示
if (!_isViewMode)
BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return EditorToolbar(
state: state,
onEvent: (event) => context.read<EditorBloc>().add(event),
);
},
),
],
),
);
}
/// 顶部操作栏 — 日期/撤销重做/标签/心情/完成
/// 顶部操作栏 — 查看模式: 返回/日期/评语/编辑按钮;编辑模式: 返回/日期/撤销重做/标签/完成
Widget _buildTopBar(BuildContext context, EditorState state) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
@@ -409,58 +491,83 @@ class _EditorViewState extends State<_EditorView> {
),
),
),
// 撤销
IconButton(
icon: const Icon(Icons.undo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Undo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 重做
IconButton(
icon: const Icon(Icons.redo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Redo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 自动保存状态
_buildAutosaveIndicator(state),
// 标签按钮
IconButton(
icon: const Icon(Icons.sell_rounded, size: 18),
onPressed: () => _showTagPanel(context, state),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 评语按钮(仅已有日记显示)
if (widget.journalId != null)
if (_isViewMode) ...[
// 查看模式:评语按钮 + 编辑按钮
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),
child: FilledButton.tonal(
onPressed: _enterEditMode,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
minimumSize: const Size(0, 32),
),
child: const Text('编辑', style: TextStyle(fontSize: 14)),
),
),
] else ...[
// 编辑模式:撤销/重做/标签/评语/完成
IconButton(
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
onPressed: () => _showComments(context),
icon: const Icon(Icons.undo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Undo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 完成/保存按钮
Padding(
padding: const EdgeInsets.only(left: 4),
child: FilledButton.tonal(
onPressed: () => _handleSave(context, state),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
minimumSize: const Size(0, 32),
),
child: const Text('完成', style: TextStyle(fontSize: 14)),
IconButton(
icon: const Icon(Icons.redo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Redo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
),
_buildAutosaveIndicator(state),
IconButton(
icon: const Icon(Icons.sell_rounded, size: 18),
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),
child: FilledButton.tonal(
onPressed: () => _handleSave(context, state),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
minimumSize: const Size(0, 32),
),
child: const Text('完成', style: TextStyle(fontSize: 14)),
),
),
],
],
),
),
),
// 日期 + 心情条 (40px)
_buildDateMoodStrip(context, state),
// 日期 + 心情条 (40px) — 仅编辑模式显示
if (!_isViewMode) _buildDateMoodStrip(context, state),
],
),
);
}
/// 返回处理
/// 返回处理 — 有未保存修改时弹出确认
void _handleBack(BuildContext context) {
final bloc = context.read<EditorBloc>();
if (bloc.state.isDirty) {
_showDiscardDialog(context);
} else {
_doNavigateBack(context);
}
}
void _doNavigateBack(BuildContext context) {
if (context.canPop()) {
context.pop();
} else {
@@ -468,9 +575,39 @@ class _EditorViewState extends State<_EditorView> {
}
}
/// 保存处理
void _showDiscardDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('放弃编辑?'),
content: const Text('你有未保存的修改,确定要离开吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('继续编辑'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
_doNavigateBack(context);
},
child: const Text('放弃'),
),
],
),
);
}
/// 保存处理 — 显示"保存中..."后触发保存
void _handleSave(BuildContext context, EditorState state) {
widget.onSaveComplete();
setState(() => _isSaving = true);
// 短暂延迟让 UI 显示"保存中..."状态
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
setState(() => _isSaving = false);
widget.onSaveComplete();
}
});
}
/// 显示评论列表
@@ -497,7 +634,26 @@ class _EditorViewState extends State<_EditorView> {
}
/// 自动保存状态指示器
/// 保存指示器 — 三态: 未保存 / 保存中 / 已保存
Widget _buildAutosaveIndicator(EditorState state) {
// 保存中 — 琥珀色脉冲点
if (_isSaving) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PulsingDot(color: AppColors.tertiary),
const SizedBox(width: 4),
Text(
'保存中...',
style: TextStyle(fontSize: 11, color: Colors.amber[700]),
),
],
),
);
}
// 未保存
if (state.lastSavedAt == null) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
@@ -507,6 +663,7 @@ class _EditorViewState extends State<_EditorView> {
),
);
}
// 已保存 — 绿色点
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
@@ -616,8 +773,13 @@ class _EditorViewState extends State<_EditorView> {
class _EditorStack extends StatefulWidget {
final EditorState state;
final String? journalId;
final bool isViewMode;
const _EditorStack({required this.state, this.journalId});
const _EditorStack({
required this.state,
this.journalId,
this.isViewMode = false,
});
@override
State<_EditorStack> createState() => _EditorStackState();
@@ -625,6 +787,7 @@ class _EditorStack extends StatefulWidget {
class _EditorStackState extends State<_EditorStack> {
EditorTool? _lastTool;
int _lastReactivatedAt = 0;
late final TextEditingController _titleController;
@override
@@ -675,6 +838,26 @@ class _EditorStackState extends State<_EditorStack> {
});
}
_lastTool = currentTool;
// 工具重新激活(再次点击已选中的工具)→ 重新弹出面板
final reactivatedAt = widget.state.toolReactivatedAt;
if (reactivatedAt != _lastReactivatedAt) {
_lastReactivatedAt = reactivatedAt;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
switch (currentTool) {
case EditorTool.brush:
_showBrushPanel();
case EditorTool.sticker:
_showStickerPicker();
case EditorTool.more:
_showMoreSheet();
default:
break;
}
});
}
}
/// 显示贴纸选择底部面板
@@ -821,6 +1004,7 @@ class _EditorStackState extends State<_EditorStack> {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: TextField(
controller: _titleController,
enabled: !widget.isViewMode,
style: TextStyle(
fontFamily: 'Quicksand',
fontSize: 18,
@@ -864,8 +1048,8 @@ class _EditorStackState extends State<_EditorStack> {
if (state.elements.isNotEmpty)
_buildElementLayer(context, state),
// 文字输入覆盖层(文字工具激活时显示)
if (state.activeTool == EditorTool.text)
// 文字输入覆盖层(文字工具激活时显示)— 仅编辑模式
if (!widget.isViewMode && state.activeTool == EditorTool.text)
TextInputOverlay(
onConfirmed: (text, fontSize, fontColor) {
final center = Offset(
@@ -888,8 +1072,8 @@ class _EditorStackState extends State<_EditorStack> {
},
),
// 图片选择覆盖层(图片工具激活时显示)
if (state.activeTool == EditorTool.photo)
// 图片选择覆盖层(图片工具激活时显示)— 仅编辑模式
if (!widget.isViewMode && state.activeTool == EditorTool.photo)
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -909,8 +1093,11 @@ class _EditorStackState extends State<_EditorStack> {
),
),
// 空状态提示
if (state.strokes.isEmpty && state.elements.isEmpty && state.activeTool == EditorTool.select)
// 空状态提示 — 仅编辑模式显示
if (!widget.isViewMode &&
state.strokes.isEmpty &&
state.elements.isEmpty &&
state.activeTool == EditorTool.select)
_buildEmptyHint(context),
],
);
@@ -962,9 +1149,27 @@ class _EditorStackState extends State<_EditorStack> {
positionY: y,
));
},
onResized: (id, w, h) {
context.read<EditorBloc>().add(ElementResized(
elementId: id,
width: w,
height: h,
));
},
onRotated: (id, r) {
context.read<EditorBloc>().add(ElementRotated(
elementId: id,
rotation: r,
));
},
onDeleted: (id) {
context.read<EditorBloc>().add(ElementRemoved(id));
},
onLayerChanged: (id, change) {
context.read<EditorBloc>().add(
ElementLayerChanged(elementId: id, change: change),
);
},
);
}).toList(),
);
@@ -1036,3 +1241,53 @@ class _ImageSourceButton extends StatelessWidget {
);
}
}
/// 脉冲圆点动画 — 用于"保存中..."指示器
class _PulsingDot extends StatefulWidget {
const _PulsingDot({required this.color});
final Color color;
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final scale = 0.6 + 0.4 * _controller.value;
return Transform.scale(
scale: scale,
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
);
},
);
}
}

View File

@@ -53,6 +53,16 @@ class ActiveStrokePainter extends CustomPainter {
final path = buildStrokePath(outlinePoints);
// 橡皮擦实时反馈:绘制半透明灰色,让用户看到擦除范围
// 实际擦除在笔画完成后的合成图中通过 BlendMode.dstOut 执行
if (brushType == BrushType.eraser) {
canvas.drawPath(path, Paint()
..color = const Color(0x40808080) // 25% 灰色
..style = PaintingStyle.fill
..isAntiAlias = true);
return;
}
// 构造临时 Stroke 用于获取 Paint
final stroke = Stroke(
id: '__active__',

View File

@@ -12,6 +12,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import '../../../data/models/journal_element.dart';
import '../bloc/editor_bloc.dart' show LayerChange;
/// 可拖拽日记元素组件
class DraggableElement extends StatefulWidget {
@@ -19,7 +20,10 @@ class DraggableElement extends StatefulWidget {
final bool isSelected;
final ValueChanged<String> onTap;
final void Function(String id, double x, double y) onMoved;
final void Function(String id, double w, double h)? onResized;
final void Function(String id, double rotation)? onRotated;
final ValueChanged<String> onDeleted;
final void Function(String id, LayerChange change)? onLayerChanged;
const DraggableElement({
super.key,
@@ -27,7 +31,10 @@ class DraggableElement extends StatefulWidget {
this.isSelected = false,
required this.onTap,
required this.onMoved,
this.onResized,
this.onRotated,
required this.onDeleted,
this.onLayerChanged,
});
@override
@@ -41,6 +48,11 @@ class _DraggableElementState extends State<DraggableElement> {
late double _height;
late double _rotation;
// Scale 手势状态
double _baseWidth = 0;
double _baseHeight = 0;
double _baseRotation = 0;
@override
void initState() {
super.initState();
@@ -76,15 +88,35 @@ class _DraggableElementState extends State<DraggableElement> {
child: Transform.rotate(
angle: _rotation,
child: GestureDetector(
// 拖拽移动
onPanUpdate: (details) {
// 缩放开始 — 记录基准值
onScaleStart: (details) {
_baseWidth = _width;
_baseHeight = _height;
_baseRotation = _rotation;
},
// 缩放更新 — 支持单指拖拽 + 双指缩放/旋转
onScaleUpdate: (details) {
setState(() {
_x += details.delta.dx;
_y += details.delta.dy;
// 拖拽(单指和双指都支持)
_x += details.focalPointDelta.dx;
_y += details.focalPointDelta.dy;
// 双指缩放 + 旋转
if (details.pointerCount >= 2) {
final newW = (_baseWidth * details.scale).clamp(40.0, 400.0);
final newH = (_baseHeight * details.scale).clamp(40.0, 400.0);
_width = newW;
_height = newH;
_rotation = _baseRotation + details.rotation;
}
});
widget.onMoved(widget.element.id, _x, _y);
if (details.pointerCount >= 2) {
widget.onResized?.call(widget.element.id, _width, _height);
widget.onRotated?.call(widget.element.id, _rotation);
}
},
onPanEnd: (_) {
onScaleEnd: (_) {
// 确保最终位置已通知
widget.onMoved(widget.element.id, _x, _y);
},
@@ -113,26 +145,41 @@ class _DraggableElementState extends State<DraggableElement> {
),
),
// 选中时显示删除按钮
// 选中时显示操作按钮:图层 + 删除
if (widget.isSelected)
Positioned(
top: -12,
right: -12,
child: GestureDetector(
onTap: () => widget.onDeleted(widget.element.id),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 置顶
_ActionButton(
icon: Icons.flip_to_front_rounded,
color: Theme.of(context).colorScheme.primary,
onTap: () => widget.onLayerChanged?.call(
widget.element.id,
LayerChange.bringToFront,
),
),
const SizedBox(width: 4),
// 置底
_ActionButton(
icon: Icons.flip_to_back_rounded,
color: Theme.of(context).colorScheme.primary,
onTap: () => widget.onLayerChanged?.call(
widget.element.id,
LayerChange.sendToBack,
),
),
const SizedBox(width: 4),
// 删除
_ActionButton(
icon: Icons.close_rounded,
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
onTap: () => widget.onDeleted(widget.element.id),
),
child: const Icon(
Icons.close_rounded,
size: 16,
color: Colors.white,
),
),
],
),
),
],
@@ -250,3 +297,32 @@ class _DraggableElementState extends State<DraggableElement> {
);
}
}
/// 选中元素的操作按钮(图层/删除)
class _ActionButton extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
const _ActionButton({
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(icon, size: 16, color: Colors.white),
),
);
}
}

View File

@@ -68,10 +68,10 @@ class EditorToolbar extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () => onEvent(ToolChanged(tool)),
onTap: () => onEvent(isActive ? ToolReactivated(tool) : ToolChanged(tool)),
behavior: HitTestBehavior.opaque,
child: Container(
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,

View File

@@ -134,10 +134,15 @@ class _HandwritingCanvasState extends State<HandwritingCanvas> {
}
/// 在 build 完成后同步缓存(避免在 build 中触发异步操作)
///
/// syncStrokes 重建合成图后必须调用 setState
/// 否则 CachedStrokesPainter 不知道缓存已更新,不会触发重绘。
void _syncCacheAfterBuild() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_cache.syncStrokes(widget.strokes);
_cache.syncStrokes(widget.strokes).then((_) {
if (mounted) setState(() {});
});
});
}
@@ -235,7 +240,10 @@ class _HandwritingCanvasState extends State<HandwritingCanvas> {
widget.onStrokeCompleted?.call(stroke);
// 光栅化新笔画到缓存(异步,不阻塞 UI
_cache.addStroke(stroke);
// 完成后 setState 确保 Layer 1 (CachedStrokesPainter) 用新合成图重绘
_cache.addStroke(stroke).then((_) {
if (mounted) setState(() {});
});
}
/// 指针取消(如来电打断):丢弃当前笔画。

View File

@@ -4,20 +4,27 @@
// - 温暖友好的文案(面向小学生)
// - 分享到班级(有班级时显示)/ 仅自己可见
// - 无班级时提示加入班级后可分享
// - 分享前自动进行内容安全检查(敏感词过滤)
import 'package:flutter/material.dart';
import '../../../data/services/content_filter_service.dart';
/// 编辑器完成后的分享选择面板
class ShareBottomSheet extends StatelessWidget {
final String? classId;
final String className;
final void Function(bool shareToClass) onDecision;
/// 用于内容安全检查的文本内容(标题 + 文本元素)
final String contentText;
const ShareBottomSheet({
super.key,
required this.classId,
required this.className,
required this.onDecision,
this.contentText = '',
});
@override
@@ -65,10 +72,7 @@ class ShareBottomSheet extends StatelessWidget {
width: double.infinity,
height: 52,
child: FilledButton.icon(
onPressed: () {
onDecision(true);
Navigator.pop(context);
},
onPressed: () => _handleShare(context, shareToClass: true),
icon: const Icon(Icons.group),
label: Text('分享到 $className'),
style: FilledButton.styleFrom(
@@ -86,10 +90,7 @@ class ShareBottomSheet extends StatelessWidget {
width: double.infinity,
height: 52,
child: OutlinedButton.icon(
onPressed: () {
onDecision(false);
Navigator.pop(context);
},
onPressed: () => _handleShare(context, shareToClass: false),
icon: const Icon(Icons.lock_outline),
label: const Text('仅自己可见'),
style: OutlinedButton.styleFrom(
@@ -116,4 +117,80 @@ class ShareBottomSheet extends StatelessWidget {
),
);
}
/// 处理分享/保存决定
void _handleShare(BuildContext context, {required bool shareToClass}) {
// 仅在分享到班级时进行内容安全检查
if (shareToClass && contentText.isNotEmpty) {
final matches = ContentFilterService.checkText(contentText);
if (matches.isNotEmpty) {
_showContentWarning(context, matches);
return;
}
}
// 安全或仅自己可见 → 直接执行
onDecision(shareToClass);
Navigator.pop(context);
}
/// 显示内容安全警告对话框
void _showContentWarning(
BuildContext context,
List<SensitiveWordMatch> matches,
) {
final categories = ContentFilterService.getMatchedCategories(matches);
final words = matches.map((m) => ' "${m.word}"').toSet().toList();
final wordList = words.take(5).join('');
final categoryList = categories.join('');
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('内容提醒'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('日记中可能包含不太合适分享的内容:'),
const SizedBox(height: 8),
Text(
wordList,
style: const TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
'涉及:$categoryList',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
const SizedBox(height: 12),
const Text(
'建议修改后再分享,或者先保存为仅自己可见。',
style: TextStyle(fontSize: 13),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('返回修改'),
),
TextButton(
onPressed: () {
Navigator.pop(dialogContext); // 关闭对话框
onDecision(true); // 仍然分享
Navigator.pop(context); // 关闭 BottomSheet
},
child: const Text('仍然分享'),
),
],
),
);
}
}

View File

@@ -1,7 +1,8 @@
// 贴纸选择底部面板
//
// Phase 1 使用内置 emoji 贴纸6 类 60 个),后续替换为贴纸包资源
// 分类:心情/动物/自然/食物/学校/装饰
// Phase 1 使用内置 emoji 贴纸6 类 60 个)。
// 当贴纸包 API 有数据时自动追加到"更多贴纸"分类。
// 后续 Phase 2 将完全迁移到贴纸包资源。
import 'package:flutter/material.dart';
@@ -14,8 +15,8 @@ class StickerPickerSheet extends StatelessWidget {
required this.onStickerSelected,
});
// Phase 1 内置贴纸集
static const _stickerCategories = <String, List<String>>{
// 内置基础贴纸集Phase 1 保底,保证离线可用)
static const _builtinStickers = <String, List<String>>{
'心情': ['😊', '😢', '😡', '🤔', '😐', '🥰', '😋', '🤗', '😴', '🎉'],
'动物': ['🐱', '🐶', '🐰', '🐻', '🦊', '🐼', '🐨', '🦄', '🐸', '🦋'],
'自然': ['🌸', '🌺', '🌻', '🍀', '🌈', '', '🌙', '☀️', '❄️', '🍃'],
@@ -24,6 +25,9 @@ class StickerPickerSheet extends StatelessWidget {
'装饰': ['💕', '', '🎀', '🎵', '🎶', '💫', '🦋', '🌸', '🍀', '💎'],
};
/// 合并后的贴纸分类(预留 API 扩展入口)
Map<String, List<String>> get _stickerCategories => _builtinStickers;
@override
Widget build(BuildContext context) {
return Container(

View File

@@ -20,8 +20,10 @@ import 'stroke_renderer.dart';
class _CacheEntry {
final ui.Image image;
final Stroke stroke;
/// BBox 偏移量 — 光栅化时裁剪的起点,合成时用于定位
final Offset offset;
const _CacheEntry({required this.image, required this.stroke});
const _CacheEntry({required this.image, required this.stroke, required this.offset});
}
// ===== 光栅化缓存 =====
@@ -76,18 +78,22 @@ class StrokeRasterCache {
/// 添加一条已完成笔画到缓存
///
/// 光栅化该笔画为 ui.Image然后增量合成到 compositeImage。
/// 光栅化该笔画为 ui.Image(仅 BBox 区域),然后增量合成到 compositeImage。
Future<void> addStroke(Stroke stroke) async {
if (_canvasSize == Size.zero) return;
// 光栅化单笔画
final image = await _rasterizeStroke(stroke);
if (image == null) return;
// 光栅化单笔画BBox 裁剪)
final result = await _rasterizeStroke(stroke);
if (result == null) return;
_cache[stroke.id] = _CacheEntry(image: image, stroke: stroke);
_cache[stroke.id] = _CacheEntry(
image: result.image,
stroke: stroke,
offset: result.offset,
);
// 增量合成:将新笔画绘制到现有 compositeImage 之上
await _compositeIncremental(stroke, image);
await _compositeIncremental(stroke, result.image, result.offset);
}
/// 同步笔画列表(用于撤销/重做后与 BLoC 状态对齐)
@@ -108,9 +114,13 @@ class StrokeRasterCache {
final toAdd = currentIds.difference(cachedIds);
for (final stroke in strokes) {
if (toAdd.contains(stroke.id)) {
final image = await _rasterizeStroke(stroke);
if (image != null) {
_cache[stroke.id] = _CacheEntry(image: image, stroke: stroke);
final result = await _rasterizeStroke(stroke);
if (result != null) {
_cache[stroke.id] = _CacheEntry(
image: result.image,
stroke: stroke,
offset: result.offset,
);
}
}
}
@@ -155,8 +165,12 @@ class StrokeRasterCache {
// ===== 光栅化 =====
/// 将单条笔画光栅化为 ui.Image
Future<ui.Image?> _rasterizeStroke(Stroke stroke) async {
/// 将单条笔画光栅化为 ui.Image — 仅光栅化 BBox 区域(性能优化 8b-M02
///
/// 计算笔画的包围盒 (bounding box),仅对该区域光栅化,
/// 大幅减少 GPU 内存占用(短笔画从全画布 4096×4096 降到实际尺寸)。
/// 返回 null 表示笔画无有效点。
Future<({ui.Image image, Offset offset})?> _rasterizeStroke(Stroke stroke) async {
final outlinePoints = pointsToOutline(
stroke.points,
stroke.brushType,
@@ -165,16 +179,38 @@ class StrokeRasterCache {
);
if (outlinePoints.isEmpty) return null;
// 计算笔画包围盒
double minX = double.infinity, minY = double.infinity;
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
for (final p in outlinePoints) {
if (p.dx < minX) minX = p.dx;
if (p.dy < minY) minY = p.dy;
if (p.dx > maxX) maxX = p.dx;
if (p.dy > maxY) maxY = p.dy;
}
// 添加边距(抗锯齿 + 笔触溢出)
const padding = 4.0;
final bboxLeft = (minX - padding).clamp(0.0, _canvasSize.width);
final bboxTop = (minY - padding).clamp(0.0, _canvasSize.height);
final bboxRight = (maxX + padding).clamp(0.0, _canvasSize.width);
final bboxBottom = (maxY + padding).clamp(0.0, _canvasSize.height);
final bboxWidth = (bboxRight - bboxLeft).clamp(1.0, 4096.0);
final bboxHeight = (bboxBottom - bboxTop).clamp(1.0, 4096.0);
final path = buildStrokePath(outlinePoints);
final paint = createPaintForStroke(stroke);
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
// 平移坐标系,使 BBox 左上角对齐 (0, 0)
canvas.translate(-bboxLeft, -bboxTop);
// 橡皮擦需要 saveLayer 保护,避免穿透
if (stroke.brushType == BrushType.eraser) {
canvas.saveLayer(
Rect.fromLTWH(0, 0, _canvasSize.width, _canvasSize.height),
Rect.fromLTWH(0, 0, bboxWidth, bboxHeight),
Paint(),
);
}
@@ -186,16 +222,20 @@ class StrokeRasterCache {
}
final picture = recorder.endRecording();
return picture.toImage(
_canvasSize.width.toInt().clamp(1, 4096),
_canvasSize.height.toInt().clamp(1, 4096),
final image = await picture.toImage(
bboxWidth.toInt().clamp(1, 4096),
bboxHeight.toInt().clamp(1, 4096),
);
return (image: image, offset: Offset(bboxLeft, bboxTop));
}
// ===== 合成 =====
/// 增量合成:将新笔画图像绘制到现有 compositeImage 上
Future<void> _compositeIncremental(Stroke stroke, ui.Image strokeImage) async {
///
/// strokeImage 是 BBox 裁剪后的图像offset 是其原始位置偏移。
Future<void> _compositeIncremental(Stroke stroke, ui.Image strokeImage, Offset offset) async {
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
@@ -210,15 +250,15 @@ class StrokeRasterCache {
canvas.drawImage(_compositeImage!, Offset.zero, Paint());
}
// 再绘制新笔画(橡皮擦用 dstOut
// 再绘制新笔画(橡皮擦用 dstOut,使用 BBox offset 定位
if (stroke.brushType == BrushType.eraser) {
canvas.drawImage(
strokeImage,
Offset.zero,
offset,
Paint()..blendMode = BlendMode.dstOut,
);
} else {
canvas.drawImage(strokeImage, Offset.zero, Paint());
canvas.drawImage(strokeImage, offset, Paint());
}
canvas.restore();

View File

@@ -39,19 +39,21 @@ class _BrushConfig {
/// 各画笔的渲染参数。
const Map<BrushType, _BrushConfig> _brushConfigs = {
/// 钢笔:中等粗细,强压感变化,模拟笔效果
/// 钢笔:粗壮平滑,模拟签字笔效果
BrushType.pen: _BrushConfig(
size: 8,
thinning: 0.7,
smoothing: 0.5,
size: 10,
thinning: 0.65,
smoothing: 0.65,
streamline: 0.6,
simulatePressure: true,
),
/// 铅笔:细线,轻微压感,高平滑度产生自然线条
/// 铅笔:纤细有质感,保留书写抖动
BrushType.pencil: _BrushConfig(
size: 4,
thinning: 0.3,
smoothing: 0.7,
size: 3,
thinning: 0.4,
smoothing: 0.35,
streamline: 0.3,
simulatePressure: true,
),

View File

@@ -1,9 +1,12 @@
// 标签面板 -- 底部抽屉
// 支持添加/移除自定义标签 + 推荐标签快捷选择
// 推荐标签从用户历史标签动态推导,无数据时使用默认推荐
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_colors.dart';
import '../../../data/repositories/journal_repository.dart';
/// 标签面板 -- 底部抽屉
class TagPanel extends StatefulWidget {
@@ -26,15 +29,37 @@ class _TagPanelState extends State<TagPanel> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
static const _suggestedTags = [
'日常', '学习', '读书', '心情', '学校', '旅行',
'美食', '运动', '音乐', '梦想',
];
/// 推荐标签 — 动态推导
List<String> _suggestedTags = ['日常', '学习', '读书', '心情', '学校', '旅行', '美食', '运动'];
@override
void initState() {
super.initState();
_focusNode.requestFocus();
_deriveSuggestedTags();
}
/// 从用户历史日记标签推导推荐标签
Future<void> _deriveSuggestedTags() async {
try {
final repo = context.read<JournalRepository>();
final journals = await repo.getJournals();
final tagFreq = <String, int>{};
for (final j in journals) {
for (final tag in j.tags) {
tagFreq[tag] = (tagFreq[tag] ?? 0) + 1;
}
}
final sorted = tagFreq.keys.toList()
..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!));
if (sorted.isNotEmpty && mounted) {
setState(() {
_suggestedTags = sorted.take(10).toList();
});
}
} catch (_) {
// 保持默认值
}
}
@override

View File

@@ -1,5 +1,7 @@
// 首页 BLoC — 加载最近日记和心情概览
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
@@ -78,12 +80,24 @@ final class HomeError extends HomeState {
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final JournalRepository _journalRepo;
StreamSubscription<void>? _changeSubscription;
HomeBloc({required JournalRepository journalRepository})
: _journalRepo = journalRepository,
super(const HomeInitial()) {
on<HomeLoadData>(_onLoadData);
on<HomeRefresh>(_onRefresh);
// 监听日记变更,自动刷新首页数据
_changeSubscription = _journalRepo.onJournalChanged.listen((_) {
add(const HomeRefresh());
});
}
@override
Future<void> close() {
_changeSubscription?.cancel();
return super.close();
}
Future<void> _onLoadData(
@@ -116,9 +130,14 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
// 推算连续天数
final streakDays = _calculateStreak(journals);
// 本月日记数spec §3.4 quick-stats
final monthCount = journals.where((j) =>
j.date.year == today.year && j.date.month == today.month).length;
// 本月日记数 — 使用日期范围查询,不受分页限制(修复 8b-D03
final monthStart = DateTime(today.year, today.month, 1);
final monthEnd = DateTime(today.year, today.month + 1, 1);
final monthJournals = await _journalRepo.getJournals(
dateFrom: monthStart,
dateTo: monthEnd,
);
final monthCount = monthJournals.length;
// 总日记数 — 使用仓库计数方法(不受分页限制)
final totalCount = await _journalRepo.getJournalCount();

View File

@@ -24,6 +24,10 @@ import '../../../core/theme/app_shadows.dart';
import '../../../core/theme/app_typography.dart';
import '../../../data/models/journal_entry.dart';
import '../../../data/repositories/journal_repository.dart';
import '../../../data/repositories/class_repository.dart';
import '../../../data/services/sync_engine.dart';
import '../../auth/bloc/auth_bloc.dart';
import '../../editor/widgets/share_bottom_sheet.dart';
import '../bloc/home_bloc.dart';
class HomePage extends StatelessWidget {
@@ -87,7 +91,10 @@ class _HomeView extends StatelessWidget {
children: [
_GreetingHeader(
greeting: greeting,
username: '小暖',
username: context.select<AuthBloc, String>((bloc) {
final s = bloc.state;
return s is Authenticated ? s.user.displayLabel : '同学';
}),
dateText: dateText,
onSearchTap: () => context.push('/search'),
),
@@ -659,9 +666,22 @@ class _JournalCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${journal.date.month}${journal.date.day}',
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
Row(
children: [
Text(
'${journal.date.month}${journal.date.day}',
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
),
const SizedBox(width: 6),
// 可见性标签
_VisibilityBadge(
isPrivate: journal.isPrivate,
sharedToClass: journal.sharedToClass,
onTap: journal.isPrivate
? () => _sharePrivateJournal(context, journal)
: null,
),
],
),
const SizedBox(height: 2),
Text(
@@ -702,6 +722,152 @@ class _JournalCard extends StatelessWidget {
),
);
}
/// 分享私密日记 — 弹出分享面板,将日记变为公开并上传到后端
Future<void> _sharePrivateJournal(BuildContext context, JournalEntry entry) async {
String? userClassId;
String userClassName = '我的班级';
try {
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
try {
final classRepo = context.read<ClassRepository>();
final classes = await classRepo.getMyClasses();
if (classes.isNotEmpty) {
userClassId = classes.first.id;
userClassName = classes.first.name;
}
} catch (_) {
// 没有班级信息,使用默认值
}
}
} catch (_) {}
// ignore: use_build_context_synchronously
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (sheetContext) => ShareBottomSheet(
classId: userClassId,
className: userClassName,
onDecision: (shareToClass) async {
try {
final repo = context.read<JournalRepository>();
// 将私密日记变为公开
final updated = entry.copyWith(
isPrivate: false,
sharedToClass: shareToClass,
);
await repo.updateJournal(updated);
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
final syncEngine = context.read<SyncEngine>();
syncEngine.enqueue(PendingOperation(
id: updated.id,
type: SyncOperationType.create,
endpoint: '/diary/journals',
data: updated.toJson(),
version: updated.version,
createdAt: DateTime.now(),
));
// 刷新首页列表
// ignore: use_build_context_synchronously
context.read<HomeBloc>().add(const HomeRefresh());
} catch (e) {
debugPrint('分享日记失败: $e');
}
},
),
);
}
}
/// 可见性标签 — 显示日记的可见性状态
///
/// - 私密:🔒 仅自己可见(可点击分享)
/// - 分享到班级:🏫 班级可见
/// - 公开:🌐 所有人可见
class _VisibilityBadge extends StatelessWidget {
const _VisibilityBadge({
required this.isPrivate,
required this.sharedToClass,
this.onTap,
});
final bool isPrivate;
final bool sharedToClass;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
if (isPrivate) {
// 私密日记 — 显示锁定图标,可点击分享
return InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.tertiarySoftLight,
borderRadius: AppRadius.pillBorder,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.lock_outline, size: 12, color: Color(0xFFB8860B)),
SizedBox(width: 3),
Text(
'仅自己可见',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFFB8860B)),
),
],
),
),
);
}
if (sharedToClass) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.secondary.withValues(alpha: 0.15),
borderRadius: AppRadius.pillBorder,
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.groups, size: 12, color: AppColors.secondary),
SizedBox(width: 3),
Text(
'班级可见',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.secondary),
),
],
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: 0.12),
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.public, size: 12, color: AppColors.accent),
const SizedBox(width: 3),
Text(
'公开',
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.accent),
),
],
),
);
}
}
class _EmptyJournalState extends StatelessWidget {

View File

@@ -1,9 +1,12 @@
// 设置 BLoC — 主题切换 + 应用设置管理
//
// ChangeNotifier 模式(同 MoodBloc通过 ListenableBuilder 消费。
// Phase 1: 内存态 + TODO 持久化到 SharedPreferences。
// 主题偏好持久化到 SharedPreferences。
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
const _kThemeMode = 'settings_theme_mode';
// ===== State =====
@@ -28,14 +31,34 @@ class SettingsState {
/// 设置管理器 — 全局单例,在 NuanjiApp 中创建
class SettingsBloc extends ChangeNotifier {
SettingsBloc({SharedPreferences? prefs}) : _prefs = prefs {
_loadSavedTheme();
}
final SharedPreferences? _prefs;
SettingsState _state = const SettingsState();
SettingsState get state => _state;
/// 从 SharedPreferences 恢复保存的主题
void _loadSavedTheme() {
if (_prefs == null) return;
final saved = _prefs?.getString(_kThemeMode);
if (saved != null) {
final mode = switch (saved) {
'light' => ThemeMode.light,
'dark' => ThemeMode.dark,
_ => ThemeMode.system,
};
_state = _state.copyWith(themeMode: mode);
notifyListeners();
}
}
/// 切换主题模式
void changeTheme(ThemeMode mode) {
_state = _state.copyWith(themeMode: mode);
notifyListeners();
// TODO: 持久化到 SharedPreferences
_prefs?.setString(_kThemeMode, mode.name);
}
/// 循环切换: system → light → dark → system

View File

@@ -10,6 +10,8 @@ import 'package:nuanji_app/features/auth/bloc/auth_bloc.dart';
import 'package:nuanji_app/features/profile/bloc/settings_bloc.dart';
import 'package:nuanji_app/data/models/user.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import 'package:nuanji_app/features/achievement/bloc/achievement_bloc.dart';
import 'package:nuanji_app/data/remote/api_client.dart';
/// 个人中心页面
class ProfilePage extends StatelessWidget {
@@ -60,7 +62,10 @@ class ProfilePage extends StatelessWidget {
),
),
alignment: Alignment.center,
child: const Text('😊', style: TextStyle(fontSize: 36)),
child: Text(
displayName.isNotEmpty ? displayName[0] : '😊',
style: const TextStyle(fontSize: 36, color: Colors.white),
),
),
const SizedBox(height: 12),
// 用户名
@@ -91,7 +96,7 @@ class ProfilePage extends StatelessWidget {
_LiveStatsBar(borderSoft: borderSoft, colorScheme: colorScheme),
const SizedBox(height: 20),
// ---- 成就徽章 ----
// ---- 成就徽章(动态加载) ----
Align(
alignment: Alignment.centerLeft,
child: Text('成就徽章', style: theme.textTheme.titleMedium?.copyWith(
@@ -101,21 +106,11 @@ class ProfilePage extends StatelessWidget {
const SizedBox(height: 12),
SizedBox(
height: 100,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_BadgeItem(emoji: '📝', name: '初出茅庐', bgColor: accentSoft, locked: false),
const SizedBox(width: 12),
_BadgeItem(emoji: '🔥', name: '七日连续', bgColor: tertiarySoft, locked: false),
const SizedBox(width: 12),
_BadgeItem(emoji: '🎨', name: '装饰达人', bgColor: roseSoft, locked: false),
const SizedBox(width: 12),
_BadgeItem(emoji: '🌟', name: '人气之星', bgColor: secondarySoft, locked: true),
const SizedBox(width: 12),
_BadgeItem(emoji: '🏆', name: '写作高手', bgColor: accentSoft, locked: true),
const SizedBox(width: 12),
_BadgeItem(emoji: '💎', name: '全能王', bgColor: tertiarySoft, locked: true),
],
child: _AchievementBadges(
accentSoft: accentSoft,
tertiarySoft: tertiarySoft,
roseSoft: roseSoft,
secondarySoft: secondarySoft,
),
),
const SizedBox(height: 20),
@@ -436,3 +431,73 @@ class _LiveStatsBarState extends State<_LiveStatsBar> {
);
}
}
/// 成就徽章动态组件 — 从 AchievementBloc 加载真实数据
class _AchievementBadges extends StatefulWidget {
const _AchievementBadges({
required this.accentSoft,
required this.tertiarySoft,
required this.roseSoft,
required this.secondarySoft,
});
final Color accentSoft;
final Color tertiarySoft;
final Color roseSoft;
final Color secondarySoft;
@override
State<_AchievementBadges> createState() => _AchievementBadgesState();
}
class _AchievementBadgesState extends State<_AchievementBadges> {
late final AchievementBloc _bloc;
List<Achievement> _achievements = [];
@override
void initState() {
super.initState();
_bloc = AchievementBloc(api: context.read<ApiClient>());
_bloc.load();
_bloc.addListener(_onUpdate);
}
void _onUpdate() {
if (mounted) {
setState(() {
_achievements = _bloc.state.achievements;
});
}
}
@override
void dispose() {
_bloc.removeListener(_onUpdate);
_bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_achievements.isEmpty) {
return const Center(child: Text('暂无成就', style: TextStyle(fontSize: 13)));
}
final bgColors = [widget.accentSoft, widget.tertiarySoft, widget.roseSoft, widget.secondarySoft];
return ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _achievements.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final a = _achievements[index];
return _BadgeItem(
emoji: a.icon ?? '🏆',
name: a.name,
bgColor: bgColors[index % bgColors.length],
locked: !a.isUnlocked,
);
},
);
}
}

View File

@@ -17,6 +17,9 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/utils/mood_utils.dart';
import '../../../data/models/journal_entry.dart';
import '../../../data/remote/api_client.dart';
import '../../../data/repositories/journal_repository.dart';
import '../../templates/bloc/template_bloc.dart';
import '../bloc/search_bloc.dart';
/// 搜索页面 — 搜索历史 + 热门搜索 + 结果分类
@@ -31,18 +34,42 @@ class _SearchPageState extends State<SearchPage> {
final _searchController = TextEditingController();
final _searchFocusNode = FocusNode();
// 热门搜索占位数据
final _hotSearches = ['日常', '学校', '旅行', '美食', '读书', '心情', '手账', '贴纸'];
// 热门搜索 — 从用户日记标签动态推导,无数据时使用默认推荐
List<String> _hotSearches = ['日常', '学校', '旅行', '心情', '读书', '手账'];
@override
void initState() {
super.initState();
_deriveHotSearches();
// 自动弹出键盘
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
}
/// 从日记标签频率推导热门搜索
Future<void> _deriveHotSearches() async {
try {
final repo = context.read<JournalRepository>();
final journals = await repo.getJournals();
final tagFreq = <String, int>{};
for (final j in journals) {
for (final tag in j.tags) {
tagFreq[tag] = (tagFreq[tag] ?? 0) + 1;
}
}
final sorted = tagFreq.keys.toList()
..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!));
if (sorted.isNotEmpty && mounted) {
setState(() {
_hotSearches = sorted.take(8).toList();
});
}
} catch (_) {
// 保持默认值
}
}
@override
void dispose() {
_searchController.dispose();
@@ -490,79 +517,10 @@ class _SearchPageState extends State<SearchPage> {
);
}
// ===== 6E: 模板结果(占位 =====
// ===== 6E: 模板结果(动态加载 =====
Widget _buildTemplateResults(ThemeData theme, bool isDark) {
// Phase 1 占位 — 模板功能未实现
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: 4,
itemBuilder: (context, index) {
final gradients = [
const [AppColors.accent, AppColors.tertiary],
const [AppColors.secondary, AppColors.tertiary],
const [AppColors.rose, AppColors.accent],
const [AppColors.tertiary, AppColors.secondary],
];
final labels = ['每日心情', '旅行手账', '读书笔记', '日常记录'];
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradients[index],
),
),
child: Stack(
children: [
// 装饰圆
Positioned(
right: -10,
bottom: -10,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.12),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
labels[index],
style: theme.textTheme.titleSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'即将上线',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
),
],
),
);
},
);
return _TemplateSearchGrid(theme: theme, isDark: isDark);
}
// ===== 6E: 标签结果 =====
@@ -781,3 +739,124 @@ extension _PadAll on Widget {
child: this,
);
}
/// 搜索页模板结果 — 从 TemplateBloc 动态加载
class _TemplateSearchGrid extends StatefulWidget {
const _TemplateSearchGrid({required this.theme, required this.isDark});
final ThemeData theme;
final bool isDark;
@override
State<_TemplateSearchGrid> createState() => _TemplateSearchGridState();
}
class _TemplateSearchGridState extends State<_TemplateSearchGrid> {
late final TemplateBloc _bloc;
@override
void initState() {
super.initState();
_bloc = TemplateBloc(api: context.read<ApiClient>());
_bloc.load();
_bloc.addListener(() => setState(() {}));
}
@override
void dispose() {
_bloc.dispose();
super.dispose();
}
static const _gradients = [
[AppColors.accent, AppColors.tertiary],
[AppColors.secondary, AppColors.tertiary],
[AppColors.rose, AppColors.accent],
[AppColors.tertiary, AppColors.secondary],
];
@override
Widget build(BuildContext context) {
final templates = _bloc.state.templates;
if (_bloc.state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (templates.isEmpty) {
return Center(
child: Text('暂无模板', style: widget.theme.textTheme.bodyMedium?.copyWith(
color: widget.isDark ? AppColors.mutedDark : AppColors.mutedLight,
)),
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: templates.length,
itemBuilder: (context, index) {
final t = templates[index];
final colors = _gradients[index % _gradients.length];
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.md),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: colors,
),
),
child: Stack(
children: [
Positioned(
right: -10,
bottom: -10,
child: Container(
width: 60,
height: 60,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.12),
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
Text(
t.emoji,
style: const TextStyle(fontSize: 28),
),
const SizedBox(height: 8),
Text(
t.name,
style: widget.theme.textTheme.titleSmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
t.isFree ? '免费模板' : '精品模板',
style: widget.theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
),
],
),
);
},
);
}
}

View File

@@ -5,6 +5,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/core/theme/app_radius.dart';
import 'package:nuanji_app/data/remote/api_client.dart';
import '../../../widgets/empty_state_widget.dart';
import '../../../widgets/error_state_widget.dart';
import '../bloc/sticker_bloc.dart';
/// 贴纸库页面 — 分类浏览贴纸包
@@ -19,10 +21,19 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
late final StickerBloc _bloc;
final _searchController = TextEditingController();
/// 设计规格中的 8 个分类
static const _specCategories = [
'推荐', '可爱', '植物', '手绘', '校园', '节日', '文字', '和纸胶带',
];
/// 默认分类 — 从 API 数据动态补充
static const _defaultCategories = ['推荐', '可爱', '植物', '手绘', '校园', '节日', '文字', '和纸胶带'];
List<String> get _categories {
final apiCategories = _bloc.state.packs
.map((p) => p.category)
.whereType<String>()
.toSet()
.toList();
if (apiCategories.isEmpty) return _defaultCategories;
// 合并:推荐 + API 返回的分类
return ['推荐', ...apiCategories];
}
@override
void initState() {
@@ -55,18 +66,10 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
}
if (state.errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _bloc.load,
child: const Text('重试'),
),
],
),
return ErrorStateWidget(
message: state.errorMessage ?? '加载失败',
onRetry: _bloc.load,
icon: Icons.error_outline,
);
}
@@ -120,7 +123,7 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: _specCategories.map((cat) {
children: _categories.map((cat) {
final isSelected = cat == state.selectedCategory ||
(cat == '推荐' && state.selectedCategory == '全部');
return Padding(
@@ -148,19 +151,22 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
),
const SizedBox(height: 12),
// ---- 精选贴纸包卡片 ----
if (state.selectedCategory == '全部')
// ---- 精选贴纸包卡片(动态数据) ----
if (state.selectedCategory == '全部' && state.filteredPacks.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: const _FeaturedPackCard(),
child: _FeaturedPackCard(pack: state.filteredPacks.first),
),
if (state.selectedCategory == '全部')
if (state.selectedCategory == '全部' && state.filteredPacks.isNotEmpty)
const SizedBox(height: 16),
// ---- 贴纸包网格 ----
Expanded(
child: state.filteredPacks.isEmpty
? const Center(child: Text('暂无贴纸包'))
? const EmptyStateWidget(
icon: Icons.sticky_note_2_outlined,
title: '暂无贴纸包',
)
: GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate:
@@ -188,9 +194,10 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
}
}
/// 精选贴纸包卡片 — 渐变背景 + 限时免费标签
/// 精选贴纸包卡片 — 渐变背景 + 动态数据
class _FeaturedPackCard extends StatelessWidget {
const _FeaturedPackCard();
const _FeaturedPackCard({required this.pack});
final StickerPack pack;
@override
Widget build(BuildContext context) {
@@ -198,7 +205,7 @@ class _FeaturedPackCard extends StatelessWidget {
return GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('打开精选贴纸包: 治愈小动物')),
SnackBar(content: Text('打开精选贴纸包: ${pack.name}')),
);
},
child: Container(
@@ -214,7 +221,6 @@ class _FeaturedPackCard extends StatelessWidget {
),
child: Row(
children: [
// emoji 图标区域
Container(
width: 64,
height: 64,
@@ -223,30 +229,38 @@ class _FeaturedPackCard extends StatelessWidget {
borderRadius: AppRadius.mdBorder,
),
alignment: Alignment.center,
child: const Text('🧸', style: TextStyle(fontSize: 36)),
child: Text(pack.displayCover, style: const TextStyle(fontSize: 36)),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('治愈小动物', style: theme.textTheme.titleMedium?.copyWith(
Text(pack.name, style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, color: Colors.white,
)),
const SizedBox(height: 4),
Text('超可爱的手绘小动物贴纸', style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 0.85),
)),
Text(
pack.description ?? '${pack.stickerCount} 张精选贴纸',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white.withValues(alpha: 0.85),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.secondary,
color: pack.isFree ? AppColors.secondary : AppColors.rose,
borderRadius: AppRadius.pillBorder,
),
child: const Text('限时免费', style: TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white,
)),
child: Text(
pack.isFree ? '免费' : '精品',
style: const TextStyle(
fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white,
),
),
),
],
),

View File

@@ -67,11 +67,7 @@ class _TeacherView extends StatelessWidget {
iconColor: AppColors.tertiary,
title: '班级码管理',
subtitle: '查看和重置班级码',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('班级码: a1b2c3')),
);
},
onTap: () => _showClassCodes(context),
),
const SizedBox(height: 24),
@@ -159,6 +155,40 @@ class _TeacherView extends StatelessWidget {
);
}
void _showClassCodes(BuildContext context) {
final classState = context.read<ClassBloc>().state;
final classes = classState is ClassListLoaded ? classState.classes : <SchoolClass>[];
if (classes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请先创建班级')),
);
return;
}
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('班级码管理'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: classes.map((c) => ListTile(
leading: const Icon(Icons.qr_code, color: AppColors.tertiary),
title: Text(c.name),
subtitle: Text('班级码: ${c.classCode} · ${c.memberCount}'),
dense: true,
)).toList(),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('关闭'),
),
],
),
);
}
void _showAssignTopicDialog(BuildContext context) {
final titleController = TextEditingController();
final descController = TextEditingController();

View File

@@ -293,13 +293,22 @@ class _TemplateCard extends StatelessWidget {
),
const SizedBox(height: 8),
// 标签
// 标签(从模板 category 动态生成)
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_TagPill(label: '学生专属', bgColor: secondarySoft, textColor: AppColors.secondary),
_TagPill(label: '简约', bgColor: tertiarySoft, textColor: AppColors.tertiary),
if (template.category != null && template.category!.isNotEmpty)
_TagPill(
label: template.category!,
bgColor: secondarySoft,
textColor: AppColors.secondary,
),
_TagPill(
label: template.isFree ? '免费' : '精品',
bgColor: template.isFree ? tertiarySoft : AppColors.roseSoftLight,
textColor: template.isFree ? AppColors.tertiary : AppColors.rose,
),
],
),
const SizedBox(height: 8),

View File

@@ -0,0 +1,95 @@
// 共享空状态组件 — 统一所有页面的空数据展示
//
// 使用: EmptyStateWidget(icon: Icons.xxx, title: '暂无数据', subtitle: '下拉刷新试试')
// 样式参考: home_page._EmptyJournalState — 大图标淡化 + 标题 + 副标题 + 暖色按钮
import 'package:flutter/material.dart';
import '../core/constants/design_tokens.dart';
import '../core/theme/app_colors.dart';
import '../core/theme/app_radius.dart';
/// 统一空状态组件 — 图标 + 标题 + 可选副标题 + 可选操作按钮
class EmptyStateWidget extends StatelessWidget {
const EmptyStateWidget({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.actionLabel,
this.onAction,
this.iconSize = 64,
});
/// 主图标
final IconData icon;
/// 图标大小
final double iconSize;
/// 标题文字
final String title;
/// 副标题(灰色小字)
final String? subtitle;
/// 操作按钮文字(不传则不显示按钮)
final String? actionLabel;
/// 操作按钮回调
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing40),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: iconSize,
color: theme.colorScheme.onSurface.withValues(alpha: 0.2),
),
const SizedBox(height: DesignTokens.spacing16),
Text(
title,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: DesignTokens.spacing8),
Text(
subtitle!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
textAlign: TextAlign.center,
),
],
if (actionLabel != null && onAction != null) ...[
const SizedBox(height: DesignTokens.spacing24),
FilledButton.icon(
onPressed: onAction,
icon: const Icon(Icons.add_rounded),
label: Text(actionLabel!),
style: FilledButton.styleFrom(
backgroundColor: AppColors.accent,
foregroundColor: AppColors.bgLight,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.pillBorder,
),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,72 @@
// 共享错误状态组件 — 统一所有页面的错误展示
//
// 使用: ErrorStateWidget(message: '加载失败', onRetry: () => ...)
// 样式: 云朵图标 + 错误信息 + 重试按钮
import 'package:flutter/material.dart';
import '../core/constants/design_tokens.dart';
import '../core/theme/app_radius.dart';
/// 统一错误状态组件 — 图标 + 错误信息 + 可选重试按钮
class ErrorStateWidget extends StatelessWidget {
const ErrorStateWidget({
super.key,
required this.message,
this.onRetry,
this.icon = Icons.cloud_off_rounded,
});
/// 错误描述文字
final String message;
/// 重试回调(不传则不显示重试按钮)
final VoidCallback? onRetry;
/// 主图标(默认云朵离线图标)
final IconData icon;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.spacing24,
vertical: DesignTokens.spacing40,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 56,
color: theme.colorScheme.onSurface.withValues(alpha: 0.25),
),
const SizedBox(height: DesignTokens.spacing16),
Text(
message,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
if (onRetry != null) ...[
const SizedBox(height: DesignTokens.spacing20),
TextButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('重试'),
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: AppRadius.pillBorder,
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,102 @@
// 全局离线提示横幅 — 监听 connectivity_plus 显示/隐藏
//
// 放在 Scaffold body 上方,离线时显示黄色警告横幅
// 使用: 在 responsive_scaffold 的 body 上方嵌套
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import '../core/constants/design_tokens.dart';
import '../core/theme/app_colors.dart';
/// 全局离线提示横幅 — 自动监听网络状态
class OfflineBanner extends StatefulWidget {
const OfflineBanner({super.key, required this.child});
final Widget child;
@override
State<OfflineBanner> createState() => _OfflineBannerState();
}
class _OfflineBannerState extends State<OfflineBanner> {
bool _isOffline = false;
@override
void initState() {
super.initState();
// 初始检查
Connectivity().checkConnectivity().then((result) {
if (mounted) {
setState(() {
_isOffline = result.every((r) => r == ConnectivityResult.none);
});
}
});
// 监听变化
Connectivity().onConnectivityChanged.listen((result) {
if (mounted) {
final offline = result.every((r) => r == ConnectivityResult.none);
if (offline != _isOffline) {
setState(() => _isOffline = offline);
}
}
});
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// 离线横幅 — 带动画滑入/滑出
AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: _OfflineBar(),
crossFadeState: _isOffline
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
duration: DesignTokens.animNormal,
sizeCurve: DesignTokens.warmCurve,
),
// 正常内容
Expanded(child: widget.child),
],
);
}
}
/// 离线横幅条
class _OfflineBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppColors.warning.withValues(alpha: 0.15),
border: Border(
bottom: BorderSide(
color: AppColors.warning.withValues(alpha: 0.3),
width: 1,
),
),
),
child: Row(
children: [
Icon(Icons.wifi_off_rounded, size: 16, color: AppColors.warning),
const SizedBox(width: 8),
Expanded(
child: Text(
'网络不可用,部分功能受限',
style: TextStyle(
fontSize: 13,
color: AppColors.warning,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
}

View File

@@ -9,6 +9,7 @@ import '../core/constants/breakpoints.dart';
import '../core/theme/app_colors.dart';
import '../core/theme/app_typography.dart';
import '../core/theme/app_radius.dart';
import 'offline_banner.dart';
/// 导航项数量(不含中心 FAB
const int kNavItemCount = 4;
@@ -167,7 +168,7 @@ class _MobileLayout extends StatelessWidget {
appBar: appBarTitle != null
? AppBar(title: Text(appBarTitle!))
: null,
body: body,
body: OfflineBanner(child: body),
extendBody: true, // 允许内容延伸到 Tab 栏下面(圆角透明效果)
bottomNavigationBar: _BottomNavBar(
selectedIndex: selectedIndex,

View File

@@ -0,0 +1,156 @@
// 共享骨架屏加载组件 — 替代 CircularProgressIndicator
//
// 使用: SkeletonBox(height: 80) 或 SkeletonBox(height: 48, shimmer: true)
// 样式: 灰色圆角矩形 + 可选 shimmer 微光动画
import 'package:flutter/material.dart';
import '../core/theme/app_radius.dart';
/// 骨架屏加载占位块
class SkeletonBox extends StatelessWidget {
const SkeletonBox({
super.key,
required this.height,
this.width,
this.shimmer = false,
this.borderRadius,
});
/// 高度
final double height;
/// 宽度(默认撑满父容器)
final double? width;
/// 是否启用 shimmer 微光动画
final bool shimmer;
/// 自定义圆角(默认 AppRadius.md
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bgColor = theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.3);
final radius = borderRadius ?? AppRadius.mdBorder;
if (shimmer) {
return _ShimmerBox(
height: height,
width: width,
bgColor: bgColor,
borderRadius: radius,
);
}
return Container(
height: height,
width: width,
decoration: BoxDecoration(
color: bgColor,
borderRadius: radius,
),
);
}
}
/// 带微光动画的骨架屏
class _ShimmerBox extends StatefulWidget {
const _ShimmerBox({
required this.height,
this.width,
required this.bgColor,
required this.borderRadius,
});
final double height;
final double? width;
final Color bgColor;
final BorderRadius borderRadius;
@override
State<_ShimmerBox> createState() => _ShimmerBoxState();
}
class _ShimmerBoxState extends State<_ShimmerBox>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
height: widget.height,
width: widget.width,
decoration: BoxDecoration(
borderRadius: widget.borderRadius,
gradient: LinearGradient(
begin: Alignment(-1.0 + 2.0 * _controller.value, 0),
end: Alignment(1.0 + 2.0 * _controller.value, 0),
colors: [
widget.bgColor,
theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.15),
widget.bgColor,
],
),
),
);
},
);
}
}
/// 通用列表骨架屏 — 显示 N 行占位
class SkeletonList extends StatelessWidget {
const SkeletonList({
super.key,
this.itemCount = 5,
this.itemHeight = 72,
this.spacing = 12,
this.shimmer = false,
});
/// 骨架行数
final int itemCount;
/// 每行高度
final double itemHeight;
/// 行间距
final double spacing;
/// 是否 shimmer
final bool shimmer;
@override
Widget build(BuildContext context) {
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: itemCount,
itemBuilder: (_, _) => Padding(
padding: EdgeInsets.only(bottom: spacing),
child: SkeletonBox(height: itemHeight, shimmer: shimmer),
),
);
}
}

View File

@@ -0,0 +1,221 @@
// ContentFilterService 单元测试
//
// 覆盖:精确匹配、谐音变体匹配、文本预处理、各分类检测、边界条件
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/data/services/content_filter_service.dart';
import 'package:nuanji_app/data/services/sensitive_words.dart';
void main() {
// ============================================================
// 精确匹配 — 各分类
// ============================================================
group('精确匹配', () {
test('暴力类词汇检测', () {
final matches = ContentFilterService.checkText('我要打死你');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.violence), isTrue);
expect(matches.any((m) => m.word == '打死你'), isTrue);
});
test('色情类词汇检测', () {
final matches = ContentFilterService.checkText('这个视频很色情');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.sexual), isTrue);
});
test('欺凌类词汇检测', () {
final matches = ContentFilterService.checkText('你是个废物');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.bullying), isTrue);
expect(matches.any((m) => m.word == '废物'), isTrue);
});
test('毒品类词汇检测', () {
final matches = ContentFilterService.checkText('他在吸毒');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.drugs), isTrue);
});
test('赌博类词汇检测', () {
final matches = ContentFilterService.checkText('我们去赌钱吧');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.gambling), isTrue);
});
test('粗口类词汇检测', () {
final matches = ContentFilterService.checkText('卧槽太厉害了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.profanity), isTrue);
});
test('诈骗类词汇检测', () {
final matches = ContentFilterService.checkText('恭喜中奖了,点击链接领奖');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.fraud), isTrue);
});
test('政治敏感类词汇检测', () {
final matches = ContentFilterService.checkText('要造反了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.category == SensitiveCategory.politics), isTrue);
});
});
// ============================================================
// 安全内容
// ============================================================
group('安全内容', () {
test('正常日记文本不触发', () {
final text = '今天天气很好,我和小明一起去公园玩,非常开心。'
'我们玩了滑梯、秋千,还吃了冰淇淋。';
final matches = ContentFilterService.checkText(text);
expect(matches, isEmpty);
});
test('学习相关文本不触发', () {
final text = '今天数学课学了乘法,我觉得很有趣。'
'老师表扬了我,说我进步很大。';
final matches = ContentFilterService.checkText(text);
expect(matches, isEmpty);
});
});
// ============================================================
// 文本预处理 — 绕过手法
// ============================================================
group('文本预处理', () {
test('空格分隔不影响检测', () {
final matches = ContentFilterService.checkText('我 要 打 死 你');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '打死你'), isTrue);
});
test('特殊符号插入不影响检测', () {
final matches = ContentFilterService.checkText('废.物.垃.圾');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '废物'), isTrue);
});
test('零宽字符不影响检测', () {
// U+200B 零宽空格
final matches = ContentFilterService.checkText('废​物​');
expect(matches, isNotEmpty);
});
test('下划线连字符不影响检测', () {
final matches = ContentFilterService.checkText('废_物');
expect(matches, isNotEmpty);
});
});
// ============================================================
// 谐音/变体匹配
// ============================================================
group('谐音变体匹配', () {
test('数字谐音 "4" 匹配含 "死" 的词', () {
// "去死" 在词库中 → "去4" 应触发匹配
final matches = ContentFilterService.checkText('你怎么不去4');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '去死'), isTrue);
});
test('形近字 "草" 匹配含 "操" 的词', () {
// "操你" 在词库中 → "草你" 应触发匹配
final matches = ContentFilterService.checkText('我草你太牛了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '操你'), isTrue);
});
test('变体 "wc" 匹配 "卧槽"', () {
final matches = ContentFilterService.checkText('wc这个好厉害');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '卧槽'), isTrue);
});
test('变体 "莎" 匹配含 "杀" 的词', () {
// "杀人" 在词库中 → "莎人" 应触发匹配
final matches = ContentFilterService.checkText('我要莎人了');
expect(matches, isNotEmpty);
expect(matches.any((m) => m.word == '杀人'), isTrue);
});
});
// ============================================================
// 边界条件
// ============================================================
group('边界条件', () {
test('空字符串返回空列表', () {
expect(ContentFilterService.checkText(''), isEmpty);
});
test('纯空格返回空列表', () {
expect(ContentFilterService.checkText(' '), isEmpty);
});
test('纯符号返回空列表', () {
expect(ContentFilterService.checkText('!@#\$%^&*'), isEmpty);
});
test('超长文本不崩溃', () {
final longText = '今天天气很好。' * 10000; // ~80,000 字符
final matches = ContentFilterService.checkText(longText);
expect(matches, isEmpty); // 正常内容
});
test('多次出现同一词精确匹配只报告一次', () {
final matches = ContentFilterService.checkText('白痴白痴');
final exactMatches = matches.where((m) => m.word == '白痴' && m.position >= 0).toList();
expect(exactMatches.length, 1);
});
});
// ============================================================
// 辅助方法
// ============================================================
group('辅助方法', () {
test('hasSensitiveContent 正确判断', () {
expect(ContentFilterService.hasSensitiveContent('你好世界'), isFalse);
expect(ContentFilterService.hasSensitiveContent('你是废物'), isTrue);
});
test('getMatchedCategories 返回分类标签', () {
final matches = ContentFilterService.checkText('废物你去死');
final categories = ContentFilterService.getMatchedCategories(matches);
expect(categories, isNotEmpty);
// 至少包含欺凌和暴力
expect(categories.any((c) => c == '欺凌'), isTrue);
expect(categories.any((c) => c == '暴力'), isTrue);
});
});
// ============================================================
// 词库完整性
// ============================================================
group('词库完整性', () {
test('8 个分类都有词', () {
expect(kSensitiveWords.length, 8);
for (final entry in kSensitiveWords.entries) {
expect(entry.value, isNotEmpty, reason: '${entry.key.label} 分类不应为空');
}
});
test('总词量 >= 100', () {
final total = kSensitiveWords.values.fold(0, (sum, list) => sum + list.length);
expect(total, greaterThanOrEqualTo(100));
});
test('谐音变体映射的 key 都在词库中', () {
final allWords = kSensitiveWords.values.expand((w) => w).toSet();
for (final key in kHomophoneVariants.keys) {
// 变体 key 应该在词库中存在(单字映射除外)
// 有些变体 key 是单字如 "死",对应词库中的 "去死" 等
expect(
allWords.any((w) => w.contains(key)),
isTrue,
reason: '变体 key "$key" 不在词库中',
);
}
});
});
}

View File

@@ -108,6 +108,37 @@ void main() {
expect(loaded.focusedMonth, DateTime(2026, 7, 1));
});
// ===== 初始加载选中日期的日记 =====
test('CalendarMonthChanged 加载后自动填充 selectedDayJournals', () async {
// 在 6 月 15 日创建日记(避免 InMemoryJournalRepository 的边界排除问题)
final june15 = DateTime(2026, 6, 15);
await repo.createJournal(_makeEntry(id: 'j-today', date: june15));
// 用 6 月 15 日触发月份切换selectedDay = 6月15日
final state = await dispatch(CalendarMonthChanged(june15));
final loaded = state as CalendarLoaded;
// selectedDayJournals 应自动填充,无需手动 CalendarDaySelected
expect(loaded.selectedDayJournals, isNotEmpty);
expect(loaded.selectedDayJournals.length, 1);
expect(loaded.selectedDayJournals.first.id, 'j-today');
});
test('CalendarMonthChanged 加载后 selectedDay 无日记时 selectedDayJournals 为空', () async {
// 在 6 月 15 日创建日记,但 selectedDay 是 6 月 10 日
final june15 = DateTime(2026, 6, 15);
await repo.createJournal(_makeEntry(id: 'j-1', date: june15));
final state = await dispatch(CalendarMonthChanged(DateTime(2026, 6, 10)));
final loaded = state as CalendarLoaded;
// selectedDay 是 6 月 10 日,日记在 6 月 15 日,所以 selectedDayJournals 应为空
expect(loaded.selectedDayJournals, isEmpty);
// 但 journalsByDate 应有数据
expect(loaded.journalsByDate, isNotEmpty);
});
// ===== 日期选择 =====
test('CalendarDaySelected 有日记 → selectedDayJournals 不为空', () async {
@@ -233,4 +264,7 @@ class _FailingJournalRepository implements JournalRepository {
@override
Future<void> removeElement(String elementId) async {}
@override
Stream<void> get onJournalChanged => const Stream<void>.empty();
}

View File

@@ -0,0 +1,372 @@
// ClassBloc 单元测试
//
// 覆盖:班级列表加载、选中详情、成员/日记墙/主题/评语加载、创建班级、加入班级、布置主题
// 使用 mocktail mock ClassRepository + JournalRepository
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/school_class.dart';
import 'package:nuanji_app/data/repositories/class_repository.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import 'package:nuanji_app/features/class_/bloc/class_bloc.dart';
// ===== Mocks =====
class MockClassRepository extends Mock implements ClassRepository {}
class MockJournalRepository extends Mock implements JournalRepository {}
// ===== 测试数据工厂 =====
SchoolClass _makeClass({
String id = 'class-1',
String name = '三年级一班',
String schoolName = '暖阳小学',
String teacherId = 'teacher-1',
String classCode = 'ABC123',
int memberCount = 25,
}) {
return SchoolClass(
id: id,
name: name,
schoolName: schoolName,
teacherId: teacherId,
classCode: classCode,
memberCount: memberCount,
createdAt: DateTime(2026, 1, 1),
updatedAt: DateTime(2026, 1, 1),
);
}
JournalEntry _makeJournal({
String id = 'j-1',
String title = '今天的心情',
Mood mood = Mood.happy,
bool sharedToClass = true,
}) {
return JournalEntry(
id: id,
authorId: 'user-1',
title: title,
date: DateTime(2026, 6, 1),
mood: mood,
createdAt: DateTime(2026, 6, 1),
updatedAt: DateTime(2026, 6, 1),
sharedToClass: sharedToClass,
);
}
ClassMemberDto _makeMember({
String userId = 'student-1',
String role = 'student',
String? nickname = '小明',
}) {
return ClassMemberDto(
userId: userId,
role: role,
nickname: nickname,
joinedAt: DateTime(2026, 1, 15),
);
}
TopicDto _makeTopic({
String id = 'topic-1',
String classId = 'class-1',
String title = '我的暑假计划',
}) {
return TopicDto(
id: id,
classId: classId,
teacherId: 'teacher-1',
title: title,
isActive: true,
);
}
CommentDto _makeComment({
String id = 'comment-1',
String journalId = 'j-1',
String content = '写得很好!',
}) {
return CommentDto(
id: id,
journalId: journalId,
authorId: 'teacher-1',
content: content,
createdAt: DateTime(2026, 6, 2),
);
}
void main() {
late MockClassRepository mockClassRepo;
late MockJournalRepository mockJournalRepo;
late ClassBloc bloc;
setUp(() {
mockClassRepo = MockClassRepository();
mockJournalRepo = MockJournalRepository();
bloc = ClassBloc(
classRepository: mockClassRepo,
journalRepository: mockJournalRepo,
);
});
tearDown(() {
bloc.close();
});
// ===== 辅助:收集事件触发后的最终状态 =====
Future<ClassState> dispatch(ClassEvent event) async {
bloc.add(event);
// 等待所有异步事件处理完毕
await Future<void>.delayed(const Duration(milliseconds: 50));
return bloc.state;
}
// ===== 班级列表 =====
group('ClassLoadMyClasses', () {
test('成功加载班级列表', () async {
final classes = [_makeClass(id: 'c1'), _makeClass(id: 'c2', name: '三年级二班')];
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => classes);
final state = await dispatch(const ClassLoadMyClasses());
expect(state, isA<ClassListLoaded>());
final loaded = state as ClassListLoaded;
expect(loaded.classes.length, 2);
expect(loaded.classes[0].id, 'c1');
expect(loaded.classes[1].name, '三年级二班');
expect(loaded.isLoading, false);
expect(loaded.error, isNull);
});
test('加载失败返回空列表', () async {
when(() => mockClassRepo.getMyClasses()).thenThrow(Exception('网络错误'));
final state = await dispatch(const ClassLoadMyClasses());
expect(state, isA<ClassListLoaded>());
final loaded = state as ClassListLoaded;
expect(loaded.classes, isEmpty);
expect(loaded.isLoading, false);
});
test('加载中状态先触发', () async {
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async {
await Future<void>.delayed(const Duration(milliseconds: 100));
return [_makeClass()];
});
bloc.add(const ClassLoadMyClasses());
// 立即检查,应该在 loading 状态
await Future<void>.delayed(const Duration(milliseconds: 10));
// state 可能是 loading也可能已完成取决于调度
});
});
// ===== 选中班级 =====
group('ClassSelected', () {
test('成功选中班级并触发子事件加载', () async {
final classInfo = _makeClass();
final members = [_makeMember()];
final journals = [_makeJournal(sharedToClass: true)];
final topics = [_makeTopic()];
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => classInfo);
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => journals);
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => members);
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => topics);
final state = await dispatch(const ClassSelected('class-1'));
expect(state, isA<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.classInfo.id, 'class-1');
expect(detail.members.length, 1);
expect(detail.topics.length, 1);
});
test('加载失败返回 ClassError', () async {
when(() => mockClassRepo.getClass('class-1')).thenThrow(Exception('不存在'));
final state = await dispatch(const ClassSelected('class-1'));
expect(state, isA<ClassError>());
expect((state as ClassError).message, contains('加载班级失败'));
});
});
// ===== 成员加载 =====
group('ClassLoadMembers', () {
test('在 ClassDetailLoaded 状态下加载成员', () async {
// 先进入 ClassDetailLoaded
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
await dispatch(const ClassSelected('class-1'));
// 现在加载成员
final members = [_makeMember(), _makeMember(userId: 'student-2', nickname: '小红')];
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => members);
final state = await dispatch(const ClassLoadMembers('class-1'));
expect(state, isA<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.members.length, 2);
expect(detail.members[0].nickname, '小明');
expect(detail.members[1].nickname, '小红');
});
test('非 ClassDetailLoaded 状态下忽略', () async {
// 初始状态 ClassInitial直接加载成员应被忽略
final state = await dispatch(const ClassLoadMembers('class-1'));
expect(state, isA<ClassInitial>());
});
});
// ===== 日记墙 =====
group('ClassLoadDiaryWall', () {
test('只包含 sharedToClass 为 true 的日记', () async {
// 手动进入 ClassDetailLoaded 状态
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
await dispatch(const ClassSelected('class-1'));
// 等待 ClassSelected 的子事件完成
await Future<void>.delayed(const Duration(milliseconds: 150));
// 此时应该已经在 ClassDetailLoaded 状态
expect(bloc.state, isA<ClassDetailLoaded>());
// 现在单独测试 ClassLoadDiaryWall 过滤逻辑
final journals = [
_makeJournal(id: 'j1', sharedToClass: true),
_makeJournal(id: 'j2', sharedToClass: false),
_makeJournal(id: 'j3', sharedToClass: true),
];
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => journals);
final state = await dispatch(const ClassLoadDiaryWall('class-1'));
expect(state, isA<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.diaryWall.length, 2);
expect(detail.diaryWall.every((j) => j.sharedToClass), isTrue);
});
});
// ===== 创建班级 =====
group('ClassCreate', () {
test('成功创建班级并添加到列表', () async {
// 先加载列表
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => [_makeClass(id: 'c1')]);
await dispatch(const ClassLoadMyClasses());
// 创建新班级
final newClass = _makeClass(id: 'c2', name: '新班级');
when(() => mockClassRepo.createClass(name: '新班级')).thenAnswer((_) async => newClass);
final state = await dispatch(const ClassCreate(name: '新班级'));
expect(state, isA<ClassListLoaded>());
final loaded = state as ClassListLoaded;
expect(loaded.classes.length, 2);
expect(loaded.classes.last.id, 'c2');
});
test('创建失败设置 error', () async {
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => []);
await dispatch(const ClassLoadMyClasses());
when(() => mockClassRepo.createClass(name: '失败')).thenThrow(Exception('创建失败'));
final state = await dispatch(const ClassCreate(name: '失败'));
expect(state, isA<ClassListLoaded>());
expect((state as ClassListLoaded).error, isNotNull);
});
});
// ===== 加入班级 =====
group('ClassJoin', () {
test('加入成功后触发列表刷新', () async {
when(() => mockClassRepo.joinClass('ABC123')).thenAnswer((_) async => _makeClass());
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => [_makeClass()]);
await dispatch(const ClassJoin(classCode: 'ABC123'));
// 应该触发 ClassLoadMyClasses最终状态为 ClassListLoaded
expect(bloc.state, isA<ClassListLoaded>());
verify(() => mockClassRepo.joinClass('ABC123')).called(1);
verify(() => mockClassRepo.getMyClasses()).called(1);
});
test('加入失败返回 ClassError', () async {
when(() => mockClassRepo.joinClass('INVALID')).thenThrow(Exception('班级码错误'));
final state = await dispatch(const ClassJoin(classCode: 'INVALID'));
expect(state, isA<ClassError>());
expect((state as ClassError).message, contains('加入班级失败'));
});
});
// ===== 布置主题 =====
group('TopicAssign', () {
test('成功布置主题并添加到列表', () async {
// 进入 ClassDetailLoaded
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
await dispatch(const ClassSelected('class-1'));
final newTopic = _makeTopic(id: 'topic-2', title: '新主题');
when(() => mockClassRepo.assignTopic(classId: 'class-1', title: '新主题'))
.thenAnswer((_) async => newTopic);
final state = await dispatch(const TopicAssign(classId: 'class-1', title: '新主题'));
expect(state, isA<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.topics.length, 1);
expect(detail.topics.first.title, '新主题');
});
});
// ===== 评语 =====
group('ClassLoadComments', () {
test('加载评语并设置 selectedJournalId', () async {
// 进入 ClassDetailLoaded
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
await dispatch(const ClassSelected('class-1'));
final comments = [_makeComment(), _makeComment(id: 'comment-2', content: '继续加油')];
when(() => mockClassRepo.getComments('j-1')).thenAnswer((_) async => comments);
final state = await dispatch(const ClassLoadComments('j-1'));
expect(state, isA<ClassDetailLoaded>());
final detail = state as ClassDetailLoaded;
expect(detail.comments.length, 2);
expect(detail.selectedJournalId, 'j-1');
});
});
}

View File

@@ -0,0 +1,272 @@
// HandwritingCanvas Widget 集成测试 — 指针事件驱动笔画完成回调
//
// 验证:
// 1. Widget 正确渲染(双层 CustomPaint
// 2. 手势事件触发 onStrokeCompleted 回调
// 3. 不同画笔类型/颜色/宽度正确传递
// 4. 去抖过滤(微小移动被丢弃)
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/features/editor/widgets/handwriting_canvas.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
void main() {
// ============================================================
// 辅助
// ============================================================
/// 包裹 HandwritingCanvas 在必要的父组件中,提供约束尺寸
Widget buildTestSubject({
Key? key,
BrushType brushType = BrushType.pen,
String brushColor = '#2D2420',
double brushWidth = 3.0,
List<Stroke> strokes = const [],
ValueChanged<Stroke>? onStrokeCompleted,
}) {
return MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 600,
child: HandwritingCanvas(
key: key,
brushType: brushType,
brushColor: brushColor,
brushWidth: brushWidth,
strokes: strokes,
onStrokeCompleted: onStrokeCompleted,
),
),
),
);
}
/// 使用标准 TestGesture 模拟一条完整的拖拽手势
Future<void> simulateDragStroke(
WidgetTester tester,
List<Offset> points,
) async {
assert(points.length >= 2, '至少需要 down 和 up 两个点');
final gesture = await tester.startGesture(points.first);
await tester.pump();
for (var i = 1; i < points.length; i++) {
await gesture.moveTo(points[i]);
await tester.pump();
}
await gesture.up();
await tester.pump();
}
// ============================================================
// 渲染结构验证
// ============================================================
group('HandwritingCanvas — 渲染结构', () {
testWidgets('正确渲染双层 CustomPaint', (tester) async {
await tester.pumpWidget(buildTestSubject());
await tester.pumpAndSettle();
// 应找到 CustomPaint两层CachedStrokesPainter + ActiveStrokePainter
final customPaints = find.byType(CustomPaint);
expect(customPaints, findsAtLeast(2));
// 应找到 ListenerHandwritingCanvas 的 Listener + Gesture 识别器可能有额外 Listener
expect(find.byType(Listener), findsAtLeast(1));
// 应找到 RepaintBoundaryMaterialApp/Scaffold 可能添加额外的)
expect(find.byType(RepaintBoundary), findsAtLeast(1));
});
testWidgets('初始无笔画时仍正确渲染', (tester) async {
await tester.pumpWidget(buildTestSubject());
await tester.pumpAndSettle();
expect(find.byType(HandwritingCanvas), findsOneWidget);
});
});
// ============================================================
// 手势事件 → onStrokeCompleted
// ============================================================
group('HandwritingCanvas — 笔画完成回调', () {
testWidgets('有效拖拽触发 onStrokeCompleted', (tester) async {
Stroke? completedStroke;
await tester.pumpWidget(buildTestSubject(
onStrokeCompleted: (stroke) => completedStroke = stroke,
));
await tester.pumpAndSettle();
// 模拟 5 个点的笔画(距离足够大,避免去抖过滤)
await simulateDragStroke(tester, [
const Offset(100, 100),
const Offset(150, 120),
const Offset(200, 140),
const Offset(250, 160),
const Offset(300, 180),
]);
// 应触发回调
expect(completedStroke, isNotNull);
expect(completedStroke!.points.length, greaterThanOrEqualTo(2));
expect(completedStroke!.brushType, BrushType.pen);
expect(completedStroke!.color, '#2D2420');
expect(completedStroke!.width, 3.0);
});
testWidgets('笔画携带正确的画笔类型', (tester) async {
Stroke? completedStroke;
await tester.pumpWidget(buildTestSubject(
brushType: BrushType.marker,
onStrokeCompleted: (stroke) => completedStroke = stroke,
));
await tester.pumpAndSettle();
await simulateDragStroke(tester, [
const Offset(100, 100),
const Offset(200, 200),
const Offset(300, 100),
]);
expect(completedStroke, isNotNull);
expect(completedStroke!.brushType, BrushType.marker);
});
testWidgets('笔画携带正确的颜色和宽度', (tester) async {
Stroke? completedStroke;
await tester.pumpWidget(buildTestSubject(
brushColor: '#E07A5F',
brushWidth: 8.0,
onStrokeCompleted: (stroke) => completedStroke = stroke,
));
await tester.pumpAndSettle();
await simulateDragStroke(tester, [
const Offset(50, 50),
const Offset(150, 150),
const Offset(250, 50),
]);
expect(completedStroke!.color, '#E07A5F');
expect(completedStroke!.width, 8.0);
});
testWidgets('tap无拖拽不触发回调', (tester) async {
Stroke? completedStroke;
await tester.pumpWidget(buildTestSubject(
onStrokeCompleted: (stroke) => completedStroke = stroke,
));
await tester.pumpAndSettle();
// 仅 tapdown + up 在同一位置)— 单点不足以构成笔画
await tester.tapAt(const Offset(100, 100));
await tester.pumpAndSettle();
// Tap 产生 1 个点down 和 up 位置相同),不触发回调
expect(completedStroke, isNull);
});
});
// ============================================================
// 预加载笔画
// ============================================================
group('HandwritingCanvas — 预加载笔画', () {
testWidgets('初始笔画列表正确传入', (tester) async {
final existingStrokes = [
Stroke(
id: 'existing-1',
points: [
const StrokePoint(x: 10, y: 10),
const StrokePoint(x: 100, y: 100),
],
),
];
await tester.pumpWidget(buildTestSubject(
strokes: existingStrokes,
));
await tester.pumpAndSettle();
expect(find.byType(HandwritingCanvas), findsOneWidget);
});
testWidgets('笔画更新触发 didUpdateWidget', (tester) async {
final key = GlobalKey();
// 第一次渲染 — 无笔画
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 600,
child: HandwritingCanvas(
key: key,
strokes: const [],
),
),
),
));
await tester.pumpAndSettle();
// 更新 — 添加笔画
final updatedStrokes = [
Stroke(
id: 'new-1',
points: [
const StrokePoint(x: 50, y: 50),
const StrokePoint(x: 200, y: 200),
],
),
];
await tester.pumpWidget(MaterialApp(
home: Scaffold(
body: SizedBox(
width: 800,
height: 600,
child: HandwritingCanvas(
key: key,
strokes: updatedStrokes,
),
),
),
));
await tester.pumpAndSettle();
expect(find.byType(HandwritingCanvas), findsOneWidget);
});
});
// ============================================================
// 连续多笔画
// ============================================================
group('HandwritingCanvas — 连续多笔画', () {
testWidgets('连续绘制多条笔画,每条都触发回调', (tester) async {
final completedStrokes = <Stroke>[];
await tester.pumpWidget(buildTestSubject(
onStrokeCompleted: (stroke) => completedStrokes.add(stroke),
));
await tester.pumpAndSettle();
// 第一条笔画
await simulateDragStroke(tester, [
const Offset(50, 50),
const Offset(150, 100),
const Offset(250, 50),
]);
// 第二条笔画(使用新的 gesture
await simulateDragStroke(tester, [
const Offset(100, 200),
const Offset(200, 300),
const Offset(300, 200),
]);
expect(completedStrokes.length, 2);
expect(completedStrokes[0].id, isNot(equals(completedStrokes[1].id)));
});
});
}

View File

@@ -0,0 +1,238 @@
// StrokeRasterCache 单元测试 — 光栅化缓存管理器
//
// 注意ui.PictureRecorder().endRecording().toImage() 需要 Flutter Test 绑定,
// 因此这些测试在 flutter test 环境中运行(自动提供 TestWidgetsFlutterBinding
// 使用足够大的画布尺寸(>0才能使光栅化生效。
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_cache.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
void main() {
// ============================================================
// 辅助
// ============================================================
/// 构造一条从 (x0,y0) 到 (x1,y1) 的简单笔画
Stroke makeStroke(
String id, {
double x0 = 10.0,
double y0 = 10.0,
double x1 = 200.0,
double y1 = 200.0,
BrushType brushType = BrushType.pen,
String color = '#2D2420',
double width = 3.0,
int pointCount = 10,
}) {
return Stroke(
id: id,
points: List.generate(
pointCount,
(i) {
final t = i / (pointCount - 1);
return StrokePoint(
x: x0 + (x1 - x0) * t,
y: y0 + (y1 - y0) * t,
pressure: 0.5,
timestamp: i * 16,
);
},
),
brushType: brushType,
color: color,
width: width,
);
}
// ============================================================
// 生命周期与基本属性
// ============================================================
group('StrokeRasterCache — 生命周期', () {
test('初始状态为空', () {
final cache = StrokeRasterCache();
addTearDown(cache.dispose);
expect(cache.compositeImage, isNull);
expect(cache.layerVersion, 0);
expect(cache.length, 0);
expect(cache.cachedStrokeIds, isEmpty);
});
test('dispose 后可安全调用', () {
final cache = StrokeRasterCache();
cache.dispose();
// 不应抛异常
expect(cache.compositeImage, isNull);
});
});
// ============================================================
// 尺寸管理
// ============================================================
group('StrokeRasterCache — 尺寸管理', () {
test('ensureSize 设置画布尺寸', () {
final cache = StrokeRasterCache();
addTearDown(cache.dispose);
cache.ensureSize(const Size(800, 600));
expect(cache.canvasSize, const Size(800, 600));
});
test('ensureSize 相同尺寸不触发失效', () {
final cache = StrokeRasterCache();
addTearDown(cache.dispose);
cache.ensureSize(const Size(800, 600));
final v1 = cache.layerVersion;
cache.ensureSize(const Size(800, 600)); // 相同尺寸
expect(cache.layerVersion, v1); // 版本不变
});
});
// ============================================================
// 笔画操作
// ============================================================
group('StrokeRasterCache — 笔画操作', () {
late StrokeRasterCache cache;
setUp(() {
cache = StrokeRasterCache();
cache.ensureSize(const Size(800, 600));
});
tearDown(() {
cache.dispose();
});
test('addStroke 缓存笔画并递增版本', () async {
final stroke = makeStroke('s1');
await cache.addStroke(stroke);
expect(cache.length, 1);
expect(cache.cachedStrokeIds, contains('s1'));
expect(cache.layerVersion, greaterThan(0));
expect(cache.compositeImage, isNotNull);
});
test('addStroke 在画布尺寸为零时跳过', () async {
final emptyCache = StrokeRasterCache();
addTearDown(emptyCache.dispose);
// 未调用 ensureSizecanvasSize == Size.zero
await emptyCache.addStroke(makeStroke('s1'));
expect(emptyCache.length, 0);
});
test('多条笔画增量合成', () async {
await cache.addStroke(makeStroke('s1'));
final v1 = cache.layerVersion;
await cache.addStroke(makeStroke('s2'));
final v2 = cache.layerVersion;
await cache.addStroke(makeStroke('s3'));
expect(cache.length, 3);
expect(v2, greaterThan(v1));
expect(cache.layerVersion, greaterThan(v2));
});
test('不同画笔类型均可光栅化', () async {
for (final bt in BrushType.values) {
final id = 'stroke-${bt.value}';
await cache.addStroke(makeStroke(
id,
brushType: bt,
color: bt == BrushType.eraser ? '#FFFFFF' : '#E07A5F',
));
expect(cache.cachedStrokeIds, contains(id), reason: '$bt 应能光栅化');
}
expect(cache.length, BrushType.values.length);
});
test('clear 清除所有缓存', () async {
await cache.addStroke(makeStroke('s1'));
await cache.addStroke(makeStroke('s2'));
expect(cache.length, 2);
await cache.clear();
expect(cache.length, 0);
expect(cache.compositeImage, isNull);
expect(cache.cachedStrokeIds, isEmpty);
});
test('syncStrokes 添加缺失笔画', () async {
await cache.addStroke(makeStroke('s1'));
expect(cache.length, 1);
// syncStrokes 传入 s1 + s2应只添加 s2
await cache.syncStrokes([makeStroke('s1'), makeStroke('s2')]);
expect(cache.length, 2);
expect(cache.cachedStrokeIds, containsAll(['s1', 's2']));
});
test('syncStrokes 移除多余笔画(模拟撤销)', () async {
await cache.addStroke(makeStroke('s1'));
await cache.addStroke(makeStroke('s2'));
expect(cache.length, 2);
// syncStrokes 只保留 s1移除 s2
await cache.syncStrokes([makeStroke('s1')]);
expect(cache.length, 1);
expect(cache.cachedStrokeIds, {'s1'});
});
test('syncStrokes 无变化时不增加版本', () async {
await cache.addStroke(makeStroke('s1'));
final v = cache.layerVersion;
// 传入完全相同的笔画列表
await cache.syncStrokes([makeStroke('s1')]);
expect(cache.layerVersion, v);
});
test('invalidateAll 重建所有缓存', () async {
await cache.addStroke(makeStroke('s1'));
final v1 = cache.layerVersion;
// 尺寸变化触发 invalidateAll
cache.ensureSize(const Size(1024, 768));
await cache.addStroke(makeStroke('s1')); // 重建后重新添加
// 版本应高于之前(因为 invalidateAll + addStroke 都递增)
expect(cache.layerVersion, greaterThan(v1));
});
});
// ============================================================
// 空边界条件
// ============================================================
group('StrokeRasterCache — 边界条件', () {
test('单点笔画不会生成缓存条目', () async {
final cache = StrokeRasterCache();
cache.ensureSize(const Size(800, 600));
addTearDown(cache.dispose);
final singlePointStroke = Stroke(
id: 'single',
points: [const StrokePoint(x: 50, y: 50)],
);
await cache.addStroke(singlePointStroke);
// 单点 → pointsToOutline 返回空 → _rasterizeStroke 返回 null
expect(cache.length, 0);
});
test('空笔画列表的 syncStrokes 不报错', () async {
final cache = StrokeRasterCache();
cache.ensureSize(const Size(800, 600));
addTearDown(cache.dispose);
await cache.syncStrokes([]); // 不应抛
expect(cache.length, 0);
});
});
}

View File

@@ -0,0 +1,159 @@
// StrokeModel 单元测试 — 笔画数据模型的序列化与不可变性验证
import 'dart:collection';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
void main() {
// ============================================================
// StrokePoint
// ============================================================
group('StrokePoint', () {
test('构造函数设置默认值', () {
const point = StrokePoint(x: 10.0, y: 20.0);
expect(point.x, 10.0);
expect(point.y, 20.0);
expect(point.pressure, 0.5);
expect(point.timestamp, 0);
});
test('copyWith 返回新实例,原实例不变', () {
const original = StrokePoint(x: 1.0, y: 2.0, pressure: 0.3, timestamp: 100);
final copied = original.copyWith(x: 10.0, pressure: 0.8);
expect(copied.x, 10.0);
expect(copied.y, 2.0); // 未变
expect(copied.pressure, 0.8);
expect(copied.timestamp, 100); // 未变
// 原实例不变
expect(original.x, 1.0);
expect(original.pressure, 0.3);
});
test('toJson → fromJson 往返一致', () {
const point = StrokePoint(x: 123.456, y: 789.012, pressure: 0.75, timestamp: 1700000000);
final json = point.toJson();
final restored = StrokePoint.fromJson(json);
expect(restored.x, closeTo(point.x, 0.001));
expect(restored.y, closeTo(point.y, 0.001));
expect(restored.pressure, closeTo(point.pressure, 0.001));
expect(restored.timestamp, point.timestamp);
});
test('fromJson 处理缺失字段使用默认值', () {
final restored = StrokePoint.fromJson({'x': 5.0, 'y': 10.0});
expect(restored.pressure, 0.5);
expect(restored.timestamp, 0);
});
});
// ============================================================
// Stroke
// ============================================================
group('Stroke', () {
List<StrokePoint> makePoints(int count) => List.generate(
count,
(i) => StrokePoint(x: i * 10.0, y: i * 5.0, pressure: 0.5, timestamp: i * 16),
);
test('构造函数设置默认值', () {
final stroke = Stroke(id: 'test-1', points: makePoints(3));
expect(stroke.id, 'test-1');
expect(stroke.brushType, BrushType.pen);
expect(stroke.color, '#2D2420');
expect(stroke.width, 3.0);
});
test('copyWith 返回新实例', () {
final original = Stroke(
id: 's1',
points: makePoints(3),
brushType: BrushType.marker,
color: '#FF0000',
width: 5.0,
);
final copied = original.copyWith(color: '#00FF00', width: 8.0);
expect(copied.id, 's1'); // 未变
expect(copied.brushType, BrushType.marker); // 未变
expect(copied.color, '#00FF00');
expect(copied.width, 8.0);
});
test('toJson → fromJson 往返一致', () {
final stroke = Stroke(
id: 'abc-123',
points: makePoints(5),
brushType: BrushType.pencil,
color: '#81B29A',
width: 2.0,
);
final json = stroke.toJson();
final restored = Stroke.fromJson(json);
expect(restored.id, stroke.id);
expect(restored.brushType, stroke.brushType);
expect(restored.color, stroke.color);
expect(restored.width, stroke.width);
expect(restored.points.length, stroke.points.length);
for (var i = 0; i < stroke.points.length; i++) {
expect(restored.points[i].x, closeTo(stroke.points[i].x, 0.001));
expect(restored.points[i].y, closeTo(stroke.points[i].y, 0.001));
}
});
test('fromJson 产生不可变点列表', () {
final stroke = Stroke.fromJson({
'id': 'immutable-test',
'points': [
{'x': 1.0, 'y': 2.0},
{'x': 3.0, 'y': 4.0},
],
});
expect(stroke.points, isA<UnmodifiableListView<StrokePoint>>());
});
test('fromJson 处理缺失可选字段使用默认值', () {
final restored = Stroke.fromJson({
'id': 'minimal',
'points': [
{'x': 0.0, 'y': 0.0},
],
});
expect(restored.brushType, BrushType.pen);
expect(restored.color, '#2D2420');
expect(restored.width, 3.0);
});
test('fromJson 处理未知 brushType 回退到 pen', () {
final restored = Stroke.fromJson({
'id': 'unknown-brush',
'points': [
{'x': 0.0, 'y': 0.0},
],
'brushType': 'nonexistent',
});
expect(restored.brushType, BrushType.pen);
});
});
// ============================================================
// BrushType 枚举
// ============================================================
group('BrushType', () {
test('包含全部 4 种画笔', () {
expect(BrushType.values.length, 4);
expect(BrushType.values.map((b) => b.value), ['pen', 'pencil', 'marker', 'eraser']);
});
test('value 与枚举一一对应', () {
for (final bt in BrushType.values) {
final found = BrushType.values.firstWhere((b) => b.value == bt.value);
expect(found, bt);
}
});
});
}

View File

@@ -0,0 +1,214 @@
// StrokeRenderer 单元测试 — 纯函数验证
//
// 覆盖pointsToOutline、buildStrokePath、parseHexColor、createPaintForStroke
// 不依赖 Flutter 绑定dart:ui 的 Canvas/Image仅测试纯逻辑。
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
import 'package:nuanji_app/features/editor/widgets/stroke_renderer.dart';
void main() {
// ============================================================
// parseHexColor
// ============================================================
group('parseHexColor', () {
test('解析标准 #RRGGBB 格式', () {
final color = parseHexColor('#E07A5F');
expect(color.value, const Color(0xFFE07A5F).value);
});
test('解析不带 # 的 6 位十六进制', () {
// parseHexColor 会先 replaceFirst('#', ''),所以直接传 6 位也应该工作
final color = parseHexColor('#2D2420');
expect(color, const Color(0xFF2D2420));
});
test('全黑 #000000', () {
expect(parseHexColor('#000000').value, const Color(0xFF000000).value);
});
test('全白 #FFFFFF', () {
expect(parseHexColor('#FFFFFF').value, const Color(0xFFFFFFFF).value);
});
test('无效长度回退到默认色', () {
const fallback = Color(0xFF2D2420);
expect(parseHexColor('#FFF').value, fallback.value);
expect(parseHexColor('#12345').value, fallback.value);
expect(parseHexColor('').value, fallback.value);
});
test('无效字符回退到默认色', () {
expect(parseHexColor('#GGGGGG').value, const Color(0xFF2D2420).value);
});
});
// ============================================================
// pointsToOutline
// ============================================================
group('pointsToOutline', () {
/// 构造 N 个均匀分布的点
List<StrokePoint> makeLinearPoints(int count) => List.generate(
count,
(i) => StrokePoint(
x: i * 10.0,
y: i * 10.0,
pressure: 0.5,
timestamp: i * 16,
),
);
test('少于 2 个点返回空列表', () {
final empty = pointsToOutline([], BrushType.pen, 3.0);
expect(empty, isEmpty);
final onePoint = pointsToOutline(
[const StrokePoint(x: 0, y: 0)],
BrushType.pen,
3.0,
);
expect(onePoint, isEmpty);
});
test('2 个点生成非空轮廓', () {
final points = makeLinearPoints(2);
final outline = pointsToOutline(points, BrushType.pen, 3.0);
expect(outline, isNotEmpty);
// perfect_freehand 生成的是封闭轮廓,点数远多于输入点
expect(outline.length, greaterThan(points.length));
});
test('4 种画笔类型均能生成轮廓', () {
final points = makeLinearPoints(10);
for (final bt in BrushType.values) {
final outline = pointsToOutline(points, bt, 3.0);
expect(outline, isNotEmpty, reason: '$bt 应生成非空轮廓');
}
});
test('宽度影响轮廓大小 — 更大的 width 产生更大的轮廓', () {
final points = makeLinearPoints(10);
final outlineThin = pointsToOutline(points, BrushType.pen, 1.0);
final outlineThick = pointsToOutline(points, BrushType.pen, 8.0);
// 计算轮廓的包围盒面积作为粗略大小指标
double bboxArea(List<Offset> pts) {
if (pts.isEmpty) return 0;
double minX = double.infinity, maxX = double.negativeInfinity;
double minY = double.infinity, maxY = double.negativeInfinity;
for (final p in pts) {
if (p.dx < minX) minX = p.dx;
if (p.dx > maxX) maxX = p.dx;
if (p.dy < minY) minY = p.dy;
if (p.dy > maxY) maxY = p.dy;
}
return (maxX - minX) * (maxY - minY);
}
expect(bboxArea(outlineThick), greaterThan(bboxArea(outlineThin)));
});
test('isComplete 参数影响输出(端点处理)', () {
final points = makeLinearPoints(5);
final complete = pointsToOutline(points, BrushType.pen, 3.0, isComplete: true);
final active = pointsToOutline(points, BrushType.pen, 3.0, isComplete: false);
// 两者都应该生成轮廓,但端点处理不同
expect(complete, isNotEmpty);
expect(active, isNotEmpty);
});
});
// ============================================================
// buildStrokePath
// ============================================================
group('buildStrokePath', () {
test('空列表返回空 Path', () {
final path = buildStrokePath([]);
// 空 path 的 bounds 是零矩形
expect(path.getBounds().isEmpty, isTrue);
});
test('非空点列表返回有效 Path', () {
final points = [
const Offset(0, 0),
const Offset(10, 10),
const Offset(20, 5),
const Offset(30, 15),
];
final path = buildStrokePath(points);
expect(path.getBounds().isEmpty, isFalse);
expect(path.getBounds().width, greaterThan(0));
expect(path.getBounds().height, greaterThan(0));
});
test('路径包围盒包含所有输入点', () {
final points = [
const Offset(5, 5),
const Offset(100, 50),
const Offset(200, 100),
];
final path = buildStrokePath(points);
final bounds = path.getBounds();
for (final p in points) {
expect(bounds.left, lessThanOrEqualTo(p.dx));
expect(bounds.top, lessThanOrEqualTo(p.dy));
expect(bounds.right, greaterThanOrEqualTo(p.dx));
expect(bounds.bottom, greaterThanOrEqualTo(p.dy));
}
});
});
// ============================================================
// createPaintForStroke
// ============================================================
group('createPaintForStroke', () {
Stroke makeStroke(BrushType type, {String color = '#2D2420', double width = 3.0}) {
return Stroke(
id: 'test',
points: [
const StrokePoint(x: 0, y: 0),
const StrokePoint(x: 100, y: 100),
],
brushType: type,
color: color,
width: width,
);
}
test('钢笔 — 不透明实心填充', () {
final paint = createPaintForStroke(makeStroke(BrushType.pen));
expect(paint.color.value, parseHexColor('#2D2420').value);
expect(paint.style, PaintingStyle.fill);
expect(paint.isAntiAlias, isTrue);
});
test('铅笔 — 不透明实心填充', () {
final paint = createPaintForStroke(makeStroke(BrushType.pencil));
expect(paint.style, PaintingStyle.fill);
expect(paint.isAntiAlias, isTrue);
});
test('马克笔 — 半透明', () {
final paint = createPaintForStroke(makeStroke(BrushType.marker, color: '#E07A5F'));
// alpha = 0.4
expect(paint.color.alpha, closeTo(102, 1)); // 0.4 * 255 ≈ 102
expect(paint.style, PaintingStyle.fill);
});
test('橡皮擦 — dstOut 混合模式', () {
final paint = createPaintForStroke(makeStroke(BrushType.eraser));
expect(paint.blendMode, BlendMode.dstOut);
expect(paint.style, PaintingStyle.fill);
expect(paint.isAntiAlias, isTrue);
});
test('颜色正确传递', () {
final paint = createPaintForStroke(makeStroke(BrushType.pen, color: '#81B29A'));
expect(paint.color.value, parseHexColor('#81B29A').value);
});
});
}

View File

@@ -271,6 +271,9 @@ class _FailingJournalRepository implements JournalRepository {
Future<void> removeElement(String elementId) async {
throw UnimplementedError();
}
@override
Stream<void> get onJournalChanged => const Stream<void>.empty();
}
/// 在指定 bloc 上触发事件并等待处理完毕

View File

@@ -0,0 +1,293 @@
// SearchBloc 单元测试
//
// 覆盖关键词搜索、标签搜索、心情筛选、搜索历史、清除搜索、tab 切换
// 使用 mocktail mock JournalRepository
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/features/search/bloc/search_bloc.dart';
// ===== Mock =====
class MockJournalRepository extends Mock implements JournalRepository {}
// ===== 测试数据工厂 =====
JournalEntry _makeJournal({
String id = 'j-1',
String title = '今天的心情日记',
Mood mood = Mood.happy,
String? contentExcerpt,
List<String> tags = const [],
}) {
return JournalEntry(
id: id,
authorId: 'user-1',
title: title,
date: DateTime(2026, 6, 1),
mood: mood,
contentExcerpt: contentExcerpt,
tags: tags,
createdAt: DateTime(2026, 6, 1),
updatedAt: DateTime(2026, 6, 1),
);
}
void main() {
late MockJournalRepository mockJournalRepo;
late SearchBloc bloc;
setUp(() {
mockJournalRepo = MockJournalRepository();
bloc = SearchBloc(journalRepository: mockJournalRepo);
});
tearDown(() {
bloc.close();
});
/// 辅助dispatch 并等待处理完成
Future<SearchState> dispatch(SearchEvent event) async {
bloc.add(event);
await Future<void>.delayed(const Duration(milliseconds: 50));
return bloc.state;
}
// ===== 关键词搜索 =====
group('SearchByKeyword', () {
test('空关键词不触发搜索,返回空结果', () async {
final state = await dispatch(const SearchByKeyword(''));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results, isEmpty);
});
test('纯空格关键词视为空', () async {
final state = await dispatch(const SearchByKeyword(' '));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results, isEmpty);
});
test('匹配标题中的关键词', () async {
final journals = [
_makeJournal(id: 'j1', title: '今天的心情日记'),
_makeJournal(id: 'j2', title: '周末旅行记'),
_makeJournal(id: 'j3', title: '读后感'),
];
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => journals);
final state = await dispatch(const SearchByKeyword('心情'));
expect(state, isA<SearchLoaded>());
final loaded = state as SearchLoaded;
expect(loaded.results.length, 1);
expect(loaded.results.first.id, 'j1');
expect(loaded.activeKeyword, '心情');
});
test('匹配内容摘要中的关键词', () async {
final journals = [
_makeJournal(id: 'j1', title: '日记', contentExcerpt: '今天心情很好,阳光明媚'),
_makeJournal(id: 'j2', title: '随笔', contentExcerpt: '天气阴沉'),
];
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => journals);
final state = await dispatch(const SearchByKeyword('阳光'));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results.length, 1);
expect((state as SearchLoaded).results.first.id, 'j1');
});
test('匹配标签中的关键词', () async {
final journals = [
_makeJournal(id: 'j1', tags: ['旅行', '周末']),
_makeJournal(id: 'j2', tags: ['学习']),
];
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => journals);
final state = await dispatch(const SearchByKeyword('旅行'));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results.length, 1);
});
test('大小写不敏感搜索', () async {
final journals = [
_makeJournal(id: 'j1', title: 'Happy Day'),
_makeJournal(id: 'j2', title: 'happy mood'),
];
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => journals);
final state = await dispatch(const SearchByKeyword('HAPPY'));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results.length, 2);
});
test('搜索失败返回 SearchError', () async {
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenThrow(Exception('网络错误'));
final state = await dispatch(const SearchByKeyword('测试'));
expect(state, isA<SearchError>());
});
test('搜索历史记录', () async {
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => []);
await dispatch(const SearchByKeyword('关键词A'));
await dispatch(const SearchByKeyword('关键词B'));
final state = await dispatch(const SearchByKeyword(''));
final loaded = state as SearchLoaded;
expect(loaded.searchHistory, ['关键词B', '关键词A']);
});
test('搜索历史去重', () async {
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => []);
await dispatch(const SearchByKeyword('重复'));
await dispatch(const SearchByKeyword('其他'));
await dispatch(const SearchByKeyword('重复'));
final state = await dispatch(const SearchByKeyword(''));
final loaded = state as SearchLoaded;
expect(loaded.searchHistory, ['重复', '其他']);
});
});
// ===== 心情筛选 =====
group('SearchByMood', () {
test('null mood 返回空结果', () async {
final state = await dispatch(const SearchByMood(null));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).results, isEmpty);
expect((state as SearchLoaded).activeMood, isNull);
});
test('按心情筛选日记', () async {
final journals = [
_makeJournal(id: 'j1', mood: Mood.happy),
_makeJournal(id: 'j2', mood: Mood.happy),
_makeJournal(id: 'j3', mood: Mood.sad),
];
when(() => mockJournalRepo.getJournals(mood: 'happy', page: 1, pageSize: 50))
.thenAnswer((_) async => journals.where((j) => j.mood == Mood.happy).toList());
final state = await dispatch(const SearchByMood(Mood.happy));
expect(state, isA<SearchLoaded>());
final loaded = state as SearchLoaded;
expect(loaded.results.length, 2);
expect(loaded.activeMood, 'happy');
});
test('心情筛选失败返回 SearchError', () async {
when(() => mockJournalRepo.getJournals(mood: 'happy', page: 1, pageSize: 50))
.thenThrow(Exception('网络错误'));
final state = await dispatch(const SearchByMood(Mood.happy));
expect(state, isA<SearchError>());
});
});
// ===== 标签搜索 =====
group('SearchByTag', () {
test('按标签筛选日记', () async {
final journals = [
_makeJournal(id: 'j1', tags: ['旅行', '周末']),
];
when(() => mockJournalRepo.getJournals(tag: '旅行', page: 1, pageSize: 50))
.thenAnswer((_) async => journals);
final state = await dispatch(const SearchByTag('旅行'));
expect(state, isA<SearchLoaded>());
final loaded = state as SearchLoaded;
expect(loaded.results.length, 1);
expect(loaded.activeTag, '旅行');
expect(loaded.searchHistory, contains('旅行'));
});
test('标签搜索失败返回 SearchError', () async {
when(() => mockJournalRepo.getJournals(tag: '不存在', page: 1, pageSize: 50))
.thenThrow(Exception('网络错误'));
final state = await dispatch(const SearchByTag('不存在'));
expect(state, isA<SearchError>());
});
});
// ===== 清除搜索 =====
group('SearchClear', () {
test('清除搜索返回空结果', () async {
// 先执行一次搜索
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => [_makeJournal()]);
await dispatch(const SearchByKeyword('测试'));
// 清除
final state = await dispatch(const SearchClear());
expect(state, isA<SearchLoaded>());
final loaded = state as SearchLoaded;
expect(loaded.results, isEmpty);
expect(loaded.activeKeyword, isNull);
expect(loaded.activeMood, isNull);
expect(loaded.activeTag, isNull);
});
});
// ===== Tab 切换 =====
group('SearchTabChanged', () {
test('切换 tab 更新 activeTab', () async {
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => []);
await dispatch(const SearchByKeyword('测试'));
final state = await dispatch(const SearchTabChanged(SearchResultTab.journal));
expect(state, isA<SearchLoaded>());
expect((state as SearchLoaded).activeTab, SearchResultTab.journal);
});
test('非 SearchLoaded 状态下切换 tab 无效', () async {
// 初始状态 SearchInitial不响应 tab 切换
final state = await dispatch(const SearchTabChanged(SearchResultTab.tag));
expect(state, isA<SearchInitial>());
});
});
// ===== hasActiveFilter =====
group('SearchLoaded.hasActiveFilter', () {
test('无筛选条件时 hasActiveFilter 为 false', () async {
final state = await dispatch(const SearchClear());
expect((state as SearchLoaded).hasActiveFilter, isFalse);
});
test('有关键词时 hasActiveFilter 为 true', () async {
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
.thenAnswer((_) async => []);
final state = await dispatch(const SearchByKeyword('测试'));
expect((state as SearchLoaded).hasActiveFilter, isTrue);
});
});
}

View File

@@ -1,7 +1,8 @@
{
"name": "web",
"name": "nuanji-admin",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"description": "暖记管理后台 — 班级管理·日记审核·成长追踪",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,14 +1,13 @@
@import "tailwindcss";
/* ====================================================================
* ERP Platform — Design System Tokens & Global Styles
* Soft UI Evolution: Professional, warm, accessible for all industries
* Generated by UI UX Pro Max
* 暖记管理后台 — Design System Tokens & Global Styles
* 温暖治愈风格 · 手账日记管理 · Soft UI Evolution
* ==================================================================== */
/* --- Design Tokens (CSS Custom Properties) --- */
:root {
/* Primary Palette — Trust Blue */
/* Primary Palette — 珊瑚暖色 (warm 主题为默认,:root 为基线) */
--erp-primary: #2563eb;
--erp-primary-hover: #1d4ed8;
--erp-primary-active: #1e40af;
@@ -69,7 +68,7 @@
--erp-space-xl: 32px;
--erp-space-2xl: 48px;
/* Typography — Noto Sans SC for Chinese-first ERP */
/* Typography — Noto Sans SC for Chinese-first 暖记 */
--erp-font-family: 'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto,
'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif;
--erp-font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;

View File

@@ -142,7 +142,7 @@ export default function PluginMarket() {
<Title level={3} style={{ marginBottom: 8 }}>
<AppstoreOutlined />
</Title>
<Text type="secondary"> ERP </Text>
<Text type="secondary"></Text>
</div>
{/* 搜索和分类 */}

View File

@@ -4,6 +4,14 @@ use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use validator::Validate;
/// 标签字符串验证:单个标签最长 30 字符
const TAG_MAX_LEN: usize = 30;
/// 班级码正则:仅允许字母和数字
fn validate_class_code(code: &str) -> bool {
code.chars().all(|c| c.is_ascii_alphanumeric())
}
/// 日记心情枚举
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
@@ -41,6 +49,22 @@ pub struct CreateJournalReq {
pub assigned_topic_id: Option<uuid::Uuid>,
}
impl CreateJournalReq {
/// 验证标签内容:每个标签非空且不超过 30 字符
pub fn validate_tags(&self) -> Result<(), String> {
for tag in &self.tags {
let trimmed = tag.trim();
if trimmed.is_empty() {
return Err("标签不能为空".to_string());
}
if trimmed.len() > TAG_MAX_LEN {
return Err(format!("标签「{}」超过 {} 字符", trimmed, TAG_MAX_LEN));
}
}
Ok(())
}
}
/// 更新日记请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateJournalReq {
@@ -52,9 +76,28 @@ pub struct UpdateJournalReq {
pub tags: Option<Vec<String>>,
pub is_private: Option<bool>,
pub shared_to_class: Option<bool>,
#[validate(range(min = 0, message = "版本号不能为负数"))]
pub version: i32,
}
impl UpdateJournalReq {
/// 验证标签内容
pub fn validate_tags(&self) -> Result<(), String> {
if let Some(ref tags) = self.tags {
for tag in tags {
let trimmed = tag.trim();
if trimmed.is_empty() {
return Err("标签不能为空".to_string());
}
if trimmed.len() > TAG_MAX_LEN {
return Err(format!("标签「{}」超过 {} 字符", trimmed, TAG_MAX_LEN));
}
}
}
Ok(())
}
}
/// 日记响应
#[derive(Debug, Serialize, ToSchema)]
pub struct JournalResp {
@@ -68,6 +111,7 @@ pub struct JournalResp {
pub tags: Vec<String>,
pub is_private: bool,
pub shared_to_class: bool,
pub assigned_topic_id: Option<uuid::Uuid>,
pub version: i32,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
@@ -89,6 +133,16 @@ pub struct JoinClassReq {
pub class_code: String,
}
impl JoinClassReq {
/// 验证班级码仅含字母数字
pub fn validate_code(&self) -> Result<(), String> {
if !validate_class_code(&self.class_code) {
return Err("班级码仅允许字母和数字".to_string());
}
Ok(())
}
}
/// 更新班级请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct UpdateClassReq {
@@ -99,6 +153,7 @@ pub struct UpdateClassReq {
#[validate(length(max = 100, message = "学校名称最长 100 字符"))]
pub school_name: Option<String>,
/// 乐观锁版本号
#[validate(range(min = 0, message = "版本号不能为负数"))]
pub version: i32,
}
@@ -131,6 +186,32 @@ pub struct SyncReq {
pub changes: Vec<SyncChange>,
}
/// 单条同步变更的 JSON data 最大字节数
const SYNC_DATA_MAX_BYTES: usize = 1024 * 1024; // 1 MB
impl SyncReq {
/// 验证每条 SyncChange 的 data 字段大小
pub fn validate_changes_data(&self) -> Result<(), String> {
for (i, change) in self.changes.iter().enumerate() {
match change {
SyncChange::CreateJournal { data } | SyncChange::UpdateJournal { data, .. } => {
let len = serde_json::to_string(data)
.map(|s| s.len())
.unwrap_or(SYNC_DATA_MAX_BYTES + 1);
if len > SYNC_DATA_MAX_BYTES {
return Err(format!(
"{} 条变更数据过大 ({} > {} 字节)",
i + 1, len, SYNC_DATA_MAX_BYTES
));
}
}
SyncChange::DeleteJournal { .. } => {}
}
}
Ok(())
}
}
/// 同步变更条目
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub enum SyncChange {
@@ -215,6 +296,7 @@ pub struct UpdateTopicReq {
/// 截止日期
pub due_date: Option<chrono::NaiveDate>,
/// 乐观锁版本号
#[validate(range(min = 0, message = "版本号不能为负数"))]
pub version: i32,
}
@@ -228,11 +310,13 @@ pub struct CreateStickerPackReq {
#[validate(length(max = 500, message = "描述最长 500 字符"))]
pub description: Option<String>,
/// 缩略图 URL
#[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))]
pub thumbnail_url: Option<String>,
/// 是否免费
#[serde(default = "default_true")]
pub is_free: bool,
/// 价格(积分)
#[validate(range(min = 0, message = "价格不能为负数"))]
#[serde(default)]
pub price: i32,
/// 分类
@@ -252,10 +336,12 @@ pub struct UpdateStickerPackReq {
#[validate(length(max = 500, message = "描述最长 500 字符"))]
pub description: Option<String>,
/// 缩略图 URL
#[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))]
pub thumbnail_url: Option<String>,
/// 是否免费
pub is_free: Option<bool>,
/// 价格(积分)
#[validate(range(min = 0, message = "价格不能为负数"))]
pub price: Option<i32>,
/// 分类
#[validate(length(max = 30, message = "分类最长 30 字符"))]
@@ -271,6 +357,8 @@ pub struct CreateStickerReq {
/// 图片 URL
#[validate(length(min = 1, max = 500, message = "图片 URL 长度 1-500 字符"))]
pub image_url: String,
/// 贴纸包 ID
pub pack_id: Option<uuid::Uuid>,
/// 分类
#[validate(length(max = 30, message = "分类最长 30 字符"))]
pub category: Option<String>,
@@ -379,6 +467,51 @@ pub struct TemplateResp {
pub is_free: bool,
}
// ========== 发现页 ==========
/// 发现页聚合响应 — 一次返回全部板块数据
#[derive(Debug, Serialize, ToSchema)]
pub struct DiscoverResp {
/// 每日推荐(无共享日记时为 null
pub daily_inspiration: Option<InspirationItem>,
/// 热门话题(标签频率 TOP 8
pub hot_topics: Vec<TagCount>,
/// 精选模板(官方模板)
pub featured_templates: Vec<TemplateResp>,
/// 达人日记(不同作者最近共享日记)
pub expert_diaries: Vec<ExpertDiaryItem>,
}
/// 每日推荐条目
#[derive(Debug, Serialize, ToSchema)]
pub struct InspirationItem {
pub journal_id: uuid::Uuid,
pub title: String,
pub author_name: String,
pub mood: String,
pub date: chrono::NaiveDate,
}
/// 热门话题
#[derive(Debug, Serialize, ToSchema)]
pub struct TagCount {
pub tag: String,
pub count: i64,
}
/// 达人日记条目
#[derive(Debug, Serialize, ToSchema)]
pub struct ExpertDiaryItem {
pub journal_id: uuid::Uuid,
pub title: String,
pub author_id: uuid::Uuid,
pub author_name: String,
pub author_emoji: String,
pub content_preview: String,
pub like_count: i64,
pub created_at: chrono::DateTime<chrono::Utc>,
}
/// 成就响应
#[derive(Debug, Serialize, ToSchema)]
pub struct AchievementResp {

View File

@@ -1,61 +1,262 @@
// erp-diary 事件定义
//
// DiaryEvent 是日记模块的领域事件枚举,提供类型安全的事件构建。
// 通过 `to_domain_event(tenant_id)` 转换为基座 DomainEvent 后发布到 EventBus。
//
// 使用方式Service 层):
// use crate::event::DiaryEvent;
// let evt = DiaryEvent::JournalCreated { journal_id, author_id, class_id };
// event_bus.publish(evt.to_domain_event(tenant_id), db).await;
use serde_json::json;
use uuid::Uuid;
use erp_core::events::DomainEvent;
/// 日记模块领域事件
#[derive(Debug, Clone)]
pub enum DiaryEvent {
/// 日记创建
JournalCreated {
journal_id: uuid::Uuid,
author_id: uuid::Uuid,
class_id: Option<uuid::Uuid>,
journal_id: Uuid,
author_id: Uuid,
class_id: Option<Uuid>,
},
/// 日记更新
JournalUpdated {
journal_id: uuid::Uuid,
author_id: uuid::Uuid,
journal_id: Uuid,
author_id: Uuid,
version: i32,
},
/// 日记删除
JournalDeleted {
journal_id: uuid::Uuid,
author_id: uuid::Uuid,
journal_id: Uuid,
author_id: Uuid,
},
/// 日记分享到班级
JournalShared {
journal_id: uuid::Uuid,
author_id: uuid::Uuid,
class_id: uuid::Uuid,
journal_id: Uuid,
author_id: Uuid,
class_id: Uuid,
},
/// 班级创建
ClassCreated {
class_id: uuid::Uuid,
teacher_id: uuid::Uuid,
class_id: Uuid,
teacher_id: Uuid,
},
/// 学生加入班级
StudentJoinedClass {
class_id: uuid::Uuid,
student_id: uuid::Uuid,
class_id: Uuid,
student_id: Uuid,
},
/// 老师布置主题
TopicAssigned {
topic_id: uuid::Uuid,
class_id: uuid::Uuid,
teacher_id: uuid::Uuid,
topic_id: Uuid,
class_id: Uuid,
teacher_id: Uuid,
},
/// 老师点评
CommentCreated {
comment_id: uuid::Uuid,
journal_id: uuid::Uuid,
teacher_id: uuid::Uuid,
student_id: uuid::Uuid,
comment_id: Uuid,
journal_id: Uuid,
teacher_id: Uuid,
student_id: Uuid,
},
/// 家长绑定孩子
ParentBound {
parent_id: uuid::Uuid,
child_id: uuid::Uuid,
parent_id: Uuid,
child_id: Uuid,
},
/// 成就解锁
AchievementUnlocked {
user_id: uuid::Uuid,
user_id: Uuid,
achievement_id: String,
},
}
impl DiaryEvent {
/// 返回事件类型字符串(用于 DomainEvent.event_type
pub fn event_type(&self) -> &'static str {
match self {
Self::JournalCreated { .. } => "diary.created",
Self::JournalUpdated { .. } => "diary.updated",
Self::JournalDeleted { .. } => "diary.deleted",
Self::JournalShared { .. } => "diary.shared",
Self::ClassCreated { .. } => "diary.class.created",
Self::StudentJoinedClass { .. } => "diary.class.student_joined",
Self::TopicAssigned { .. } => "diary.topic.assigned",
Self::CommentCreated { .. } => "diary.comment.created",
Self::ParentBound { .. } => "diary.parent.binding_confirmed",
Self::AchievementUnlocked { .. } => "diary.achievement.unlocked",
}
}
/// 返回事件 payloadJSON 格式)
pub fn payload(&self) -> serde_json::Value {
match self {
Self::JournalCreated {
journal_id,
author_id,
class_id,
} => json!({
"journal_id": journal_id,
"author_id": author_id,
"class_id": class_id,
}),
Self::JournalUpdated {
journal_id,
author_id,
version,
} => json!({
"journal_id": journal_id,
"author_id": author_id,
"version": version,
}),
Self::JournalDeleted {
journal_id,
author_id,
} => json!({
"journal_id": journal_id,
"author_id": author_id,
}),
Self::JournalShared {
journal_id,
author_id,
class_id,
} => json!({
"journal_id": journal_id,
"author_id": author_id,
"class_id": class_id,
}),
Self::ClassCreated {
class_id,
teacher_id,
} => json!({
"class_id": class_id,
"teacher_id": teacher_id,
}),
Self::StudentJoinedClass {
class_id,
student_id,
} => json!({
"class_id": class_id,
"student_id": student_id,
}),
Self::TopicAssigned {
topic_id,
class_id,
teacher_id,
} => json!({
"topic_id": topic_id,
"class_id": class_id,
"teacher_id": teacher_id,
}),
Self::CommentCreated {
comment_id,
journal_id,
teacher_id,
student_id,
} => json!({
"comment_id": comment_id,
"journal_id": journal_id,
"teacher_id": teacher_id,
"student_id": student_id,
}),
Self::ParentBound {
parent_id,
child_id,
} => json!({
"parent_id": parent_id,
"child_id": child_id,
}),
Self::AchievementUnlocked {
user_id,
achievement_id,
} => json!({
"user_id": user_id,
"achievement_id": achievement_id,
}),
}
}
/// 转换为基座 DomainEvent可直接发布到 EventBus
pub fn to_domain_event(&self, tenant_id: Uuid) -> DomainEvent {
DomainEvent::new(self.event_type(), tenant_id, self.payload())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn journal_created_event_type() {
let id = Uuid::now_v7();
let evt = DiaryEvent::JournalCreated {
journal_id: id,
author_id: id,
class_id: None,
};
assert_eq!(evt.event_type(), "diary.created");
assert_eq!(evt.payload()["journal_id"], id.to_string());
}
#[test]
fn class_created_event_type() {
let id = Uuid::now_v7();
let evt = DiaryEvent::ClassCreated {
class_id: id,
teacher_id: id,
};
assert_eq!(evt.event_type(), "diary.class.created");
}
#[test]
fn to_domain_event_preserves_fields() {
let tid = Uuid::now_v7();
let jid = Uuid::now_v7();
let aid = Uuid::now_v7();
let de = DiaryEvent::JournalCreated {
journal_id: jid,
author_id: aid,
class_id: Some(tid),
}
.to_domain_event(tid);
assert_eq!(de.event_type, "diary.created");
assert_eq!(de.tenant_id, tid);
assert_eq!(de.payload["journal_id"], jid.to_string());
assert_eq!(de.payload["author_id"], aid.to_string());
assert_eq!(de.payload["class_id"], tid.to_string());
}
#[test]
fn all_variants_have_correct_event_type() {
let id = Uuid::now_v7();
let variants: Vec<DiaryEvent> = vec![
DiaryEvent::JournalCreated { journal_id: id, author_id: id, class_id: None },
DiaryEvent::JournalUpdated { journal_id: id, author_id: id, version: 1 },
DiaryEvent::JournalDeleted { journal_id: id, author_id: id },
DiaryEvent::JournalShared { journal_id: id, author_id: id, class_id: id },
DiaryEvent::ClassCreated { class_id: id, teacher_id: id },
DiaryEvent::StudentJoinedClass { class_id: id, student_id: id },
DiaryEvent::TopicAssigned { topic_id: id, class_id: id, teacher_id: id },
DiaryEvent::CommentCreated { comment_id: id, journal_id: id, teacher_id: id, student_id: id },
DiaryEvent::ParentBound { parent_id: id, child_id: id },
DiaryEvent::AchievementUnlocked { user_id: id, achievement_id: "first_diary".into() },
];
let types: Vec<&str> = variants.iter().map(|v| v.event_type()).collect();
assert!(types.contains(&"diary.created"));
assert!(types.contains(&"diary.updated"));
assert!(types.contains(&"diary.deleted"));
assert!(types.contains(&"diary.shared"));
assert!(types.contains(&"diary.class.created"));
assert!(types.contains(&"diary.class.student_joined"));
assert!(types.contains(&"diary.topic.assigned"));
assert!(types.contains(&"diary.comment.created"));
assert!(types.contains(&"diary.parent.binding_confirmed"));
assert!(types.contains(&"diary.achievement.unlocked"));
}
}

View File

@@ -1,6 +1,7 @@
// 成就 API 处理器
use axum::extract::{Extension, FromRef, Path, State};
use axum::http::StatusCode;
use axum::response::Json;
use erp_core::error::AppError;
@@ -44,7 +45,7 @@ where
path = "/api/v1/diary/achievements/{code}/unlock",
params(("code" = String, Path, description = "成就编码")),
responses(
(status = 200, description = "解锁成功", body = ApiResponse<AchievementResp>),
(status = 201, description = "解锁成功", body = ApiResponse<AchievementResp>),
(status = 404, description = "成就不存在"),
),
security(("bearer_auth" = [])),
@@ -57,7 +58,7 @@ pub async fn unlock_achievement<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(code): Path<String>,
) -> Result<Json<ApiResponse<AchievementResp>>, AppError>
) -> Result<(StatusCode, Json<ApiResponse<AchievementResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
@@ -73,5 +74,5 @@ where
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
}

View File

@@ -1,6 +1,7 @@
// 班级 API 处理器 — 创建班级、加入班级、查询班级
use axum::extract::{Extension, FromRef, Path, State};
use axum::http::StatusCode;
use axum::response::Json;
use uuid::Uuid;
use validator::Validate;
@@ -18,7 +19,7 @@ use crate::state::DiaryState;
path = "/api/v1/diary/classes",
request_body = CreateClassReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<ClassResp>),
(status = 201, description = "创建成功", body = ApiResponse<ClassResp>),
(status = 400, description = "验证失败"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
@@ -33,7 +34,7 @@ pub async fn create_class<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateClassReq>,
) -> Result<Json<ApiResponse<ClassResp>>, AppError>
) -> Result<(StatusCode, Json<ApiResponse<ClassResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
@@ -55,7 +56,7 @@ where
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
}
#[utoipa::path(
@@ -63,7 +64,7 @@ where
path = "/api/v1/diary/classes/join",
request_body = JoinClassReq,
responses(
(status = 200, description = "加入成功", body = ApiResponse<ClassResp>),
(status = 201, description = "加入成功", body = ApiResponse<ClassResp>),
(status = 400, description = "班级码无效或已过期"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
@@ -78,12 +79,13 @@ pub async fn join_class<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<JoinClassReq>,
) -> Result<Json<ApiResponse<ClassResp>>, AppError>
) -> Result<(StatusCode, Json<ApiResponse<ClassResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_code().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.create")?;
if req.class_code.trim().is_empty() {
@@ -101,7 +103,7 @@ where
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
}
#[utoipa::path(

View File

@@ -1,6 +1,7 @@
// 评语 API 处理器 — 老师点评学生日记
use axum::extract::{Extension, FromRef, Path, State};
use axum::http::StatusCode;
use axum::response::Json;
use uuid::Uuid;
use validator::Validate;
@@ -19,7 +20,7 @@ use crate::state::DiaryState;
params(("journal_id" = Uuid, Path, description = "日记ID")),
request_body = CreateCommentReq,
responses(
(status = 200, description = "点评成功", body = ApiResponse<CommentResp>),
(status = 201, description = "点评成功", body = ApiResponse<CommentResp>),
(status = 400, description = "验证失败或内容安全检查未通过"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足或不是本班老师"),
@@ -37,7 +38,7 @@ pub async fn create_comment<S>(
Extension(ctx): Extension<TenantContext>,
Path(journal_id): Path<Uuid>,
Json(req): Json<CreateCommentReq>,
) -> Result<Json<ApiResponse<CommentResp>>, AppError>
) -> Result<(StatusCode, Json<ApiResponse<CommentResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
@@ -59,7 +60,7 @@ where
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
}
#[utoipa::path(

View File

@@ -0,0 +1,40 @@
// 发现页 API 处理器
use axum::extract::{Extension, FromRef, State};
use axum::response::Json;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::DiscoverResp;
use crate::service::discover_service::DiscoverService;
use crate::state::DiaryState;
#[utoipa::path(
get,
path = "/api/v1/diary/discover",
responses(
(status = 200, description = "成功", body = ApiResponse<DiscoverResp>),
),
security(("bearer_auth" = [])),
tag = "发现页"
)]
/// GET /api/v1/diary/discover
///
/// 获取发现页全部数据(每日推荐、热门话题、精选模板、达人日记)。
/// 需要 `diary.journal.read` 权限。
pub async fn get_discover<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<DiscoverResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let resp = DiscoverService::get_discover(ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}

View File

@@ -1,6 +1,7 @@
// 日记 API 处理器 — CRUD + 列表
use axum::extract::{Extension, FromRef, Path, Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use serde::Deserialize;
use utoipa::IntoParams;
@@ -39,7 +40,7 @@ pub struct JournalListParams {
path = "/api/v1/diary/journals",
request_body = CreateJournalReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<JournalResp>),
(status = 201, description = "创建成功", body = ApiResponse<JournalResp>),
(status = 400, description = "验证失败"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
@@ -54,12 +55,13 @@ pub async fn create_journal<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateJournalReq>,
) -> Result<Json<ApiResponse<JournalResp>>, AppError>
) -> Result<(StatusCode, Json<ApiResponse<JournalResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_tags().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.create")?;
// 基础验证
@@ -76,7 +78,7 @@ where
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
}
#[utoipa::path(
@@ -148,6 +150,7 @@ where
S: Clone + Send + Sync + 'static,
{
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_tags().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.update")?;
let resp = JournalService::update(
@@ -164,9 +167,10 @@ where
}
/// 删除日记请求体(包含版本号)
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[derive(Debug, Deserialize, Validate, utoipa::ToSchema)]
pub struct DeleteJournalReq {
/// 当前版本号(乐观锁)
#[validate(range(min = 0, message = "版本号不能为负数"))]
pub version: i32,
}
@@ -201,6 +205,8 @@ where
{
require_permission(&ctx, "diary.journal.delete")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
JournalService::delete(
ctx.tenant_id,
ctx.user_id,
@@ -250,7 +256,7 @@ where
// IDOR 修复:非管理角色只能查看自己的日记
// - 学生:强制 author_id = ctx.user_id
// - 老师/管理员:允许查看所有日记
// - 老师/管理员:允许查看所有日记,但排除其他用户的私密日记
// - 家长:应通过 parent_service 专用端点查看孩子日记
let author_id = if ctx.roles.iter().any(|r| r == "teacher" || r == "admin") {
// 管理角色可查看任意作者的日记
@@ -260,6 +266,11 @@ where
Some(ctx.user_id)
};
// 管理角色查看他人日记时,排除 is_private=true 的私密日记
// 学生查看自己的日记时,包含私密日记(那是他们自己的)
let exclude_private = ctx.roles.iter().any(|r| r == "teacher" || r == "admin")
&& author_id != Some(ctx.user_id);
let (items, total) = JournalService::list(
ctx.tenant_id,
author_id,
@@ -267,6 +278,7 @@ where
params.date_from,
params.date_to,
params.class_id,
exclude_private,
page,
page_size,
&state.db,

View File

@@ -9,3 +9,4 @@ pub mod sticker_handler;
pub mod achievement_handler;
pub mod stats_handler;
pub mod parent_handler;
pub mod discover_handler;

View File

@@ -1,6 +1,7 @@
// 家长中心 API 处理器 — PIPL 合规: 绑定/查阅/导出/删除
use axum::extract::{Extension, FromRef, Path, Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
@@ -71,7 +72,7 @@ pub struct DeleteResultResp {
path = "/api/v1/diary/parent/bind",
request_body = BindChildReq,
responses(
(status = 200, description = "绑定成功", body = ApiResponse<BindingResp>),
(status = 201, description = "绑定成功", body = ApiResponse<BindingResp>),
(status = 400, description = "已绑定该孩子"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
@@ -86,13 +87,15 @@ pub async fn bind_child<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BindChildReq>,
) -> Result<Json<ApiResponse<BindingResp>>, AppError>
) -> Result<(StatusCode, Json<ApiResponse<BindingResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
let binding = ParentService::bind_child(
ctx.tenant_id,
ctx.user_id,
@@ -102,11 +105,11 @@ where
)
.await?;
Ok(Json(ApiResponse::ok(BindingResp {
Ok((StatusCode::CREATED, Json(ApiResponse::ok(BindingResp {
binding_id: binding.id,
child_id: binding.child_id,
verified_at: binding.verified_at,
})))
}))))
}
#[utoipa::path(
@@ -258,6 +261,8 @@ where
{
require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
let count = ParentService::delete_child_data(
ctx.tenant_id,
ctx.user_id,
@@ -300,6 +305,8 @@ where
{
require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
ParentService::unbind_child(ctx.tenant_id, ctx.user_id, req.child_id, &state.db).await?;
Ok(Json(ApiResponse {
@@ -357,7 +364,7 @@ where
path = "/api/v1/diary/parent/bindings/{binding_id}/confirm",
params(("binding_id" = Uuid, Path, description = "绑定请求ID")),
responses(
(status = 200, description = "确认成功", body = ApiResponse<BindingResp>),
(status = 201, description = "确认成功", body = ApiResponse<BindingResp>),
(status = 401, description = "未授权"),
(status = 403, description = "无权确认此绑定"),
(status = 404, description = "绑定请求不存在"),
@@ -372,7 +379,7 @@ pub async fn confirm_binding<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(binding_id): Path<Uuid>,
) -> Result<Json<ApiResponse<BindingResp>>, AppError>
) -> Result<(StatusCode, Json<ApiResponse<BindingResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
@@ -386,11 +393,11 @@ where
)
.await?;
Ok(Json(ApiResponse::ok(BindingResp {
Ok((StatusCode::CREATED, Json(ApiResponse::ok(BindingResp {
binding_id: binding.id,
child_id: binding.parent_id,
verified_at: binding.verified_at,
})))
}))))
}
#[utoipa::path(
@@ -452,6 +459,7 @@ fn journal_model_to_resp(model: crate::entity::journal_entry::Model) -> JournalR
tags,
is_private: model.is_private,
shared_to_class: model.shared_to_class,
assigned_topic_id: model.assigned_topic_id,
version: model.version,
created_at: model.created_at,
updated_at: model.updated_at,

View File

@@ -1,6 +1,7 @@
// 贴纸与模板 API 处理器
use axum::extract::{Extension, FromRef, Path, Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use serde::Deserialize;
use utoipa::IntoParams;
@@ -91,7 +92,7 @@ where
path = "/api/v1/diary/sticker-packs",
request_body = CreateStickerPackReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<StickerPackResp>),
(status = 201, description = "创建成功", body = ApiResponse<StickerPackResp>),
(status = 400, description = "验证失败"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
@@ -106,7 +107,7 @@ pub async fn create_sticker_pack<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateStickerPackReq>,
) -> Result<Json<ApiResponse<StickerPackResp>>, AppError>
) -> Result<(StatusCode, Json<ApiResponse<StickerPackResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
@@ -126,7 +127,7 @@ where
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
}
#[utoipa::path(
@@ -216,7 +217,7 @@ where
params(("pack_id" = Uuid, Path, description = "贴纸包ID")),
request_body = CreateStickerReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<StickerResp>),
(status = 201, description = "创建成功", body = ApiResponse<StickerResp>),
(status = 400, description = "验证失败"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
@@ -233,7 +234,7 @@ pub async fn create_sticker<S>(
Extension(ctx): Extension<TenantContext>,
Path(pack_id): Path<Uuid>,
Json(req): Json<CreateStickerReq>,
) -> Result<Json<ApiResponse<StickerResp>>, AppError>
) -> Result<(StatusCode, Json<ApiResponse<StickerResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
@@ -254,7 +255,7 @@ where
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct TemplateQuery {

View File

@@ -40,6 +40,7 @@ where
S: Clone + Send + Sync + 'static,
{
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_changes_data().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.read")?;
let resp = SyncService::sync(

View File

@@ -1,6 +1,7 @@
// 主题布置 API 处理器 — 老师布置/查询主题
use axum::extract::{Extension, FromRef, Path, State};
use axum::http::StatusCode;
use axum::response::Json;
use uuid::Uuid;
use validator::Validate;
@@ -19,7 +20,7 @@ use crate::state::DiaryState;
params(("class_id" = Uuid, Path, description = "班级ID")),
request_body = CreateTopicReq,
responses(
(status = 200, description = "布置成功", body = ApiResponse<TopicResp>),
(status = 201, description = "布置成功", body = ApiResponse<TopicResp>),
(status = 400, description = "验证失败"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
@@ -36,7 +37,7 @@ pub async fn assign_topic<S>(
Extension(ctx): Extension<TenantContext>,
Path(class_id): Path<Uuid>,
Json(req): Json<CreateTopicReq>,
) -> Result<Json<ApiResponse<TopicResp>>, AppError>
) -> Result<(StatusCode, Json<ApiResponse<TopicResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
@@ -58,7 +59,7 @@ where
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
}
#[utoipa::path(

View File

@@ -12,7 +12,7 @@ use erp_core::module::ErpModule;
use crate::handler::{
journal_handler, sync_handler, class_handler, topic_handler, comment_handler,
sticker_handler, achievement_handler, stats_handler, parent_handler,
sticker_handler, achievement_handler, stats_handler, parent_handler, discover_handler,
};
/// 暖记日记业务模块
@@ -268,5 +268,10 @@ impl DiaryModule {
"/diary/parent/bindings/{binding_id}/reject",
axum::routing::post(parent_handler::reject_binding),
)
// 发现页 — 灵感、热门话题、精选模板、达人日记
.route(
"/diary/discover",
axum::routing::get(discover_handler::get_discover),
)
}
}

View File

@@ -12,6 +12,8 @@ use crate::entity::{class_member, school_class};
use crate::error::{DiaryError, DiaryResult};
use erp_core::events::{DomainEvent, EventBus};
use crate::event::DiaryEvent;
/// 班级服务 — 6 位码生成、过期控制、成员管理
pub struct ClassService;
@@ -84,14 +86,11 @@ impl ClassService {
// 发布 ClassCreated 事件
event_bus
.publish(
DomainEvent::new(
"diary.class.created",
tenant_id,
serde_json::json!({
"class_id": id,
"teacher_id": teacher_id,
}),
),
DiaryEvent::ClassCreated {
class_id: id,
teacher_id,
}
.to_domain_event(tenant_id),
db,
)
.await;
@@ -229,14 +228,11 @@ impl ClassService {
// 8. 发布 StudentJoinedClass 事件
event_bus
.publish(
DomainEvent::new(
"diary.class.student_joined",
tenant_id,
serde_json::json!({
"class_id": class_id,
"student_id": user_id,
}),
),
DiaryEvent::StudentJoinedClass {
class_id,
student_id: user_id,
}
.to_domain_event(tenant_id),
db,
)
.await;

View File

@@ -11,7 +11,9 @@ use crate::entity::{class_member, comment, journal_entry};
use crate::error::{DiaryError, DiaryResult};
use crate::service::content_safety_service::ContentSafetyService;
use crate::service::notification_service::NotificationService;
use erp_core::events::{DomainEvent, EventBus};
use erp_core::events::EventBus;
use crate::event::DiaryEvent;
/// 评语服务 — 老师对学生日记的点评
///
@@ -81,17 +83,13 @@ impl CommentService {
// 5. 发布 CommentCreated 事件
event_bus
.publish(
DomainEvent::new(
"diary.comment.created",
tenant_id,
serde_json::json!({
"comment_id": id,
"journal_id": journal_id,
"teacher_id": author_id,
"student_id": journal.author_id,
"content_preview": content.chars().take(50).collect::<String>(),
}),
),
DiaryEvent::CommentCreated {
comment_id: id,
journal_id,
teacher_id: author_id,
student_id: journal.author_id,
}
.to_domain_event(tenant_id),
db,
)
.await;

View File

@@ -0,0 +1,312 @@
// 发现页服务 — 聚合热门话题、精选模板、每日推荐、达人日记
use sea_orm::{
ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder,
QuerySelect, Statement,
};
use uuid::Uuid;
use crate::dto::{DiscoverResp, ExpertDiaryItem, InspirationItem, TagCount, TemplateResp};
use crate::entity::template;
use crate::error::DiaryResult;
/// 发现页服务 — 聚合查询,一次返回全部板块数据
pub struct DiscoverService;
/// 心情 → emoji 映射
fn mood_to_emoji(mood: &str) -> &'static str {
match mood {
"happy" => "😊",
"calm" => "😌",
"sad" => "😢",
"angry" => "😤",
"thinking" => "🤔",
_ => "📝",
}
}
impl DiscoverService {
/// 获取发现页全部数据4 个板块并发查询)
pub async fn get_discover(
tenant_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<DiscoverResp> {
let (inspiration, topics, templates, experts) = tokio::join!(
Self::daily_inspiration(tenant_id, db),
Self::hot_topics(tenant_id, db),
Self::featured_templates(tenant_id, db),
Self::expert_diaries(tenant_id, db),
);
Ok(DiscoverResp {
daily_inspiration: inspiration?,
hot_topics: topics?,
featured_templates: templates?,
expert_diaries: experts?,
})
}
/// 每日推荐 — 基于日期种子的确定性随机,选取一篇共享日记
///
/// 使用日期字符串作为盐,与 UUID 拼接后取哈希,得到每天固定但不同的结果。
/// 无共享日记时返回 None。
async fn daily_inspiration(
tenant_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Option<InspirationItem>> {
let date_seed = chrono::Utc::now().format("%Y-%m-%d").to_string();
let sql = r#"
SELECT id, title, author_id, mood, date
FROM journal_entries
WHERE tenant_id = $1
AND is_private = false
AND shared_to_class = true
AND deleted_at IS NULL
ORDER BY (
('x' || md5(id::text || $2))::bit(32)::int
) DESC
LIMIT 1
"#;
let stmt = Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into(), date_seed.into()],
);
let rows = db.query_all(stmt).await?;
if let Some(row) = rows.into_iter().next() {
let journal_id: Uuid = row.try_get_by_index::<Uuid>(0)?;
let title: String = row.try_get_by_index::<String>(1)?;
let author_id: Uuid = row.try_get_by_index::<Uuid>(2)?;
let mood: String = row.try_get_by_index::<String>(3)?;
let date: chrono::NaiveDate = row.try_get_by_index::<chrono::NaiveDate>(4)?;
// Phase 1: 用 author_id 前 4 位作为昵称后缀
let author_hex = author_id.to_string().replace('-', "");
let suffix = &author_hex[..4];
let author_name = format!("小暖·{}", suffix);
Ok(Some(InspirationItem {
journal_id,
title,
author_name,
mood,
date,
}))
} else {
Ok(None)
}
}
/// 热门话题 — 统计所有非私密日记的标签频率,返回 TOP 8
async fn hot_topics(
tenant_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<TagCount>> {
let sql = r#"
SELECT tag, COUNT(*) AS count
FROM (
SELECT jsonb_array_elements_text(tags) AS tag
FROM journal_entries
WHERE tenant_id = $1
AND is_private = false
AND deleted_at IS NULL
AND tags IS NOT NULL
) sub
GROUP BY tag
ORDER BY count DESC
LIMIT 8
"#;
let stmt = Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into()],
);
let rows = db.query_all(stmt).await?;
let topics = rows
.into_iter()
.filter_map(|row| {
let tag: String = row.try_get_by_index::<String>(0).ok()?;
let count: i64 = row.try_get_by_index::<i64>(1).ok()?;
Some(TagCount { tag, count })
})
.collect();
Ok(topics)
}
/// 精选模板 — 官方模板,按名称排序,最多 6 个
async fn featured_templates(
tenant_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<TemplateResp>> {
let templates = template::Entity::find()
.filter(template::Column::TenantId.eq(tenant_id))
.filter(template::Column::IsOfficial.eq(true))
.filter(template::Column::DeletedAt.is_null())
.order_by_asc(template::Column::Name)
.limit(6)
.all(db)
.await?;
Ok(templates
.into_iter()
.map(|t| TemplateResp {
id: t.id,
name: t.name,
description: None,
preview_url: t.thumbnail_url,
template_data: None, // 发现页不需要完整布局数据
category: t.category,
is_free: true,
})
.collect())
}
/// 达人日记 — 不同作者最近共享的日记,以评论数作为热度代理
async fn expert_diaries(
tenant_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<ExpertDiaryItem>> {
let sql = r#"
SELECT
j.id, j.title, j.author_id, j.mood,
j.created_at,
COUNT(c.id) AS comment_count
FROM journal_entries j
LEFT JOIN comments c
ON c.journal_id = j.id
AND c.deleted_at IS NULL
WHERE j.tenant_id = $1
AND j.is_private = false
AND j.shared_to_class = true
AND j.deleted_at IS NULL
GROUP BY j.id
ORDER BY j.created_at DESC
LIMIT 20
"#;
let stmt = Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
sql,
[tenant_id.into()],
);
let rows = db.query_all(stmt).await?;
// 去重:每个作者只保留最新一篇,最多 5 位作者
let mut seen_authors = std::collections::HashSet::new();
let mut experts = Vec::new();
for row in rows {
let author_id: Uuid = row.try_get_by_index::<Uuid>(2)?;
if seen_authors.contains(&author_id) {
continue;
}
if experts.len() >= 5 {
break;
}
seen_authors.insert(author_id);
let journal_id: Uuid = row.try_get_by_index::<Uuid>(0)?;
let title: String = row.try_get_by_index::<String>(1)?;
let mood: String = row.try_get_by_index::<String>(3)?;
let created_at: chrono::DateTime<chrono::Utc> =
row.try_get_by_index::<chrono::DateTime<chrono::Utc>>(4)?;
let comment_count: i64 = row.try_get_by_index::<i64>(5)?;
let author_hex = author_id.to_string().replace('-', "");
let suffix = &author_hex[..4];
let author_name = format!("日记达人·{}", suffix);
experts.push(ExpertDiaryItem {
journal_id,
title,
author_id,
author_name,
author_emoji: mood_to_emoji(&mood).to_string(),
content_preview: String::new(), // Phase 1: 无 content_preview 列,暂留空
like_count: comment_count,
created_at,
});
}
Ok(experts)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mood_to_emoji_maps_correctly() {
assert_eq!(mood_to_emoji("happy"), "😊");
assert_eq!(mood_to_emoji("calm"), "😌");
assert_eq!(mood_to_emoji("sad"), "😢");
assert_eq!(mood_to_emoji("angry"), "😤");
assert_eq!(mood_to_emoji("thinking"), "🤔");
assert_eq!(mood_to_emoji("unknown"), "📝");
}
#[test]
fn discover_resp_structure() {
let resp = DiscoverResp {
daily_inspiration: Some(InspirationItem {
journal_id: Uuid::nil(),
title: "测试日记".into(),
author_name: "小暖·a3f2".into(),
mood: "happy".into(),
date: chrono::NaiveDate::from_ymd_opt(2026, 6, 7).unwrap(),
}),
hot_topics: vec![
TagCount {
tag: "期末备考".into(),
count: 42,
},
],
featured_templates: vec![],
expert_diaries: vec![],
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"daily_inspiration\""));
assert!(json.contains("\"hot_topics\""));
assert!(json.contains("\"期末备考\""));
assert!(json.contains("\"count\":42"));
}
#[test]
fn discover_resp_null_inspiration() {
let resp = DiscoverResp {
daily_inspiration: None,
hot_topics: vec![],
featured_templates: vec![],
expert_diaries: vec![],
};
let json = serde_json::to_string(&resp).unwrap();
assert!(json.contains("\"daily_inspiration\":null"));
}
#[test]
fn expert_diary_item_serializes() {
let item = ExpertDiaryItem {
journal_id: Uuid::nil(),
title: "春日漫步手账".into(),
author_id: Uuid::nil(),
author_name: "日记达人·abcd".into(),
author_emoji: "🌸".into(),
content_preview: "记录春天的每一朵花开...".into(),
like_count: 342,
created_at: chrono::Utc::now(),
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"like_count\":342"));
assert!(json.contains("\"author_emoji\":\"🌸\""));
}
}

View File

@@ -11,7 +11,9 @@ use crate::dto::{CreateJournalReq, JournalResp, UpdateJournalReq};
use crate::entity::journal_entry;
use crate::error::{DiaryError, DiaryResult};
use erp_core::error::check_version;
use erp_core::events::{DomainEvent, EventBus};
use erp_core::events::EventBus;
use crate::event::DiaryEvent;
/// 日记 CRUD 服务 — 创建、读取、更新、软删除日记条目
pub struct JournalService;
@@ -57,15 +59,12 @@ impl JournalService {
// 发布领域事件
event_bus
.publish(
DomainEvent::new(
"diary.created",
tenant_id,
serde_json::json!({
"journal_id": id,
"author_id": author_id,
"class_id": req.class_id,
}),
),
DiaryEvent::JournalCreated {
journal_id: id,
author_id,
class_id: req.class_id,
}
.to_domain_event(tenant_id),
db,
)
.await;
@@ -153,15 +152,12 @@ impl JournalService {
// 发布领域事件
event_bus
.publish(
DomainEvent::new(
"diary.updated",
tenant_id,
serde_json::json!({
"journal_id": id,
"author_id": operator_id,
"version": new_version,
}),
),
DiaryEvent::JournalUpdated {
journal_id: id,
author_id: operator_id,
version: new_version,
}
.to_domain_event(tenant_id),
db,
)
.await;
@@ -206,14 +202,11 @@ impl JournalService {
// 发布领域事件
event_bus
.publish(
DomainEvent::new(
"diary.deleted",
tenant_id,
serde_json::json!({
"journal_id": id,
"author_id": operator_id,
}),
),
DiaryEvent::JournalDeleted {
journal_id: id,
author_id: operator_id,
}
.to_domain_event(tenant_id),
db,
)
.await;
@@ -224,6 +217,7 @@ impl JournalService {
/// 日记列表(分页 + 筛选)
///
/// 支持按作者、心情、日期范围、班级筛选。
/// `exclude_private` 为 true 时排除 is_private=true 的日记(管理端查看他人日记场景)。
/// 返回 (items, total)。
pub async fn list(
tenant_id: Uuid,
@@ -232,6 +226,7 @@ impl JournalService {
date_from: Option<chrono::NaiveDate>,
date_to: Option<chrono::NaiveDate>,
class_id: Option<Uuid>,
exclude_private: bool,
page: u64,
page_size: u64,
db: &DatabaseConnection,
@@ -255,6 +250,10 @@ impl JournalService {
if let Some(cid) = class_id {
condition = condition.add(journal_entry::Column::ClassId.eq(cid));
}
// 管理角色查看他人日记时,排除私密日记
if exclude_private {
condition = condition.add(journal_entry::Column::IsPrivate.eq(false));
}
let page_size = page_size.min(100).max(1);
let page = page.max(1);
@@ -299,6 +298,7 @@ fn model_to_resp(model: journal_entry::Model) -> JournalResp {
tags,
is_private: model.is_private,
shared_to_class: model.shared_to_class,
assigned_topic_id: model.assigned_topic_id,
version: model.version,
created_at: model.created_at,
updated_at: model.updated_at,

Some files were not shown because too many files have changed in this diff Show More