Compare commits

...

113 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
iven
99db8e5cb0 fix(app): 家长同意验证流程 — PIPL 第28条合规
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 新增 ParentalConsentPage: 显示隐私政策要点 + 双重确认复选框
- 角色选择流程: 学生 → 家长同意确认 → 班级码输入
- Authenticated 状态: 添加 needsParentalConsent/parentalConsentAt/selectedRole
- ParentalConsentAccepted 事件: 记录同意时间戳
- 路由守卫: 注册 /parental-consent 路径和重定向逻辑
- 非学生角色(老师/家长/独立用户)不需要经过同意流程

审计 ID: S-03
2026-06-03 10:25:23 +08:00
iven
a34c9fd176 fix(app): 强制 HTTPS — Android 网络安全配置 + 生产默认 HTTPS
- Android: 添加 network_security_config.xml,默认禁止明文流量
- Android: 仅允许 localhost/127.0.0.1/10.0.2.2 明文(开发调试)
- Android: 更新 AndroidManifest 引用网络安全配置
- ApiClient: 默认 URL 改为 https://api.nuanji.app/api/v1
- AppConfig: fromEnvironment 默认值改为 HTTPS 生产地址
- AppConfig: dev 常量保留 localhost(仅用于本地开发)
- iOS: ATS 默认已强制 HTTPS,无需修改

审计 ID: 6b-C01
2026-06-03 10:13:20 +08:00
iven
45949e3ed0 fix(app): Token 自动刷新拦截器 — 401 时自动刷新 + 重试原请求
- ApiClient: 添加 onRefreshToken 回调,401 时自动调用刷新
- ApiClient: 并发保护(_isRefreshing),防止多个 401 触发多次刷新
- ApiClient: 跳过 /auth/refresh 自身的 401(避免无限循环)
- ApiClient: 刷新成功后自动重试原始请求
- AuthRepository: 注册 _handleAutoRefresh 回调
- 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖

审计 ID: 9a-AUTH-01
2026-06-03 10:07:33 +08:00
iven
c4b2de8294 fix(diary): 家长绑定改为两步验证 — 孩子确认后才生效
- bind_child: 创建 pending 状态绑定(不再自动 verified)
- validate_child_user: 验证目标用户存在且有 student 角色
- confirm_binding: 孩子确认后状态变为 verified,家长获得访问权限
- reject_binding: 孩子拒绝绑定请求
- list_pending_for_child: 孩子查看待确认绑定列表
- 新增 3 个 API 端点: /parent/pending, /bindings/{id}/confirm, /bindings/{id}/reject
- 防止未授权绑定(任何人不验证即可绑定孩子的漏洞)

审计 ID: S-10
2026-06-03 10:03:50 +08:00
iven
cca2d77ea2 fix(diary): 班级码改用字母数字混合 — 16^6 提升到 62^6(568 亿组合)
- 从 UUID hex 后 6 位(0-9a-f,16^6 ≈ 1677 万)改为字母数字混合
- 字符集: 0-9A-Za-z(62 字符,62^6 ≈ 568 亿)
- 使用 UUID v7 后 8 字节随机部分作为熵源,避免同毫秒碰撞
- 符合 CLAUDE.md 设计规格要求

审计 ID: 7b-C01
2026-06-03 09:56:24 +08:00
iven
6d7ac05d0f fix(auth): Token 黑名单改用 SHA-256 替代 SipHash
- access token 黑名单 hash 函数从 std::collections::DefaultHasher (SipHash)
  改为 sha2::Sha256,与 refresh token 存储一致
- SipHash 是非密码学 hash,理论上可被构造碰撞绕过黑名单检查
- SHA-256 提供密码学安全保证,且 sha2 已在 Cargo.toml 依赖中

审计 ID: S-01
2026-06-03 09:51:47 +08:00
iven
11d0971a67 feat(app): pnpm 一键启动 + Flutter Web 编译修复
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
1. 新增 pnpm start:dev / pnpm start:stop 命令
   - scripts/dev.mjs: 跨平台启动脚本(后端+管理端+学生端)
   - scripts/stop.mjs: 端口清理停止脚本
   - 根 package.json 定义 pnpm 脚本

2. 修复 Flutter Web 编译(Isar 3.x + flutter_secure_storage 不兼容)
   - isar_database: 条件导出,Web 用空 stub
   - isar_journal_repository: 条件导出,Web 用空 stub
   - sync_engine: 条件导出,Web 用内存队列(无 Isar 持久化)
   - 移除 flutter_secure_storage(v9 web 插件用 dart:html)
   - 新增 SecureTokenStore 接口 + shared_preferences 实现
   - auth_repository 改用 SecureTokenStore 接口
2026-06-03 09:50:19 +08:00
iven
b81a972245 fix(diary): 为所有 DTO 添加 Validate derive + handler 调用 validate()
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
DTO 验证规则:
- CreateJournalReq: title 1-200, tags ≤20
- UpdateJournalReq: title 1-200, tags ≤20
- CreateClassReq: name 1-50, school_name ≤100
- JoinClassReq: class_code = 6位
- UpdateClassReq: name 1-50, school_name ≤100
- SyncReq: changes ≤100 条
- CreateTopicReq: title 1-200, description ≤2000
- UpdateTopicReq: title 1-200, description ≤2000
- CreateCommentReq: content 1-1000
- CreateStickerPackReq: name 1-50, description ≤500
- UpdateStickerPackReq: name 1-50, description ≤500
- CreateStickerReq: name 1-30, image_url 1-500
- BindChildReq/DeleteChildDataReq: Validate derive (Uuid 已由 serde 验证)

Handler 调用: validate() 放在 require_permission() 之前(先验证输入再检查权限)

审计 ID: 5a-C01, 5a-C02, 5a-C03
2026-06-03 01:14:23 +08:00
iven
af7d3f65fd fix(diary): 修复日记列表 IDOR — 非管理角色只能查看自己的日记
- list_journals: 学生/家长等非管理角色强制 author_id = ctx.user_id
- get_journal: 非管理角色查看他人日记返回 404(避免信息泄露)
- 老师/管理员角色保留查看任意日记的能力(点评/管理需要)
- 家长应通过 parent_service 专用端点查看孩子日记

审计 ID: S-07
2026-06-03 01:08:00 +08:00
iven
9ce300ddb9 fix(app): 修复笔画缓存 use-after-dispose — 移除增量合成时的提前 dispose
- _compositeIncremental 中不再 dispose strokeImage,因为 _cache 持有同一引用
- 提前 dispose 导致 syncStrokes/clear/dispose 时 double-dispose(use-after-free)
- 单笔画 image 生命周期由缓存统一管理:移除/清除/销毁时释放
- 更新 _rebuildComposite 注释,移除过时说明

审计 ID: 8b-R01
2026-06-03 01:06:34 +08:00
iven
e0052ea99b fix(diary): 添加事务 — create_class/join_class/parent 删除原子化
- create_class: 班级创建 + 老师成员插入包裹在 db.transaction() 中
- join_class: 成员插入 + member_count 更新包裹在事务中
- delete_child_data: PIPL 删除权 — 逐条软删除包裹在事务中(避免部分删除)
- DiaryError: 添加 From<TransactionError<DiaryError>> 支持事务闭包

审计 ID: B-07, B-11, 8a-C03
2026-06-03 01:03:57 +08:00
iven
1750f17f41 fix(diary): 修复 class_service unwrap() — 改为从 Model 安全取值
- join_class 中 member_count.unwrap() 和 version.unwrap() 替换为
  在 Model → ActiveModel 转换前直接读取 model 字段值
- 消除潜在的 panic 风险,保持代码可读性

审计 ID: B-01
2026-06-03 00:58:58 +08:00
iven
5f06056d26 fix(server): 添加权限守卫 — 审计日志 + 文件上传 + diary.comment.delete 种子
- audit_log handler: 添加 require_permission("audit.log.list") 守卫
- upload handler: 添加 require_permission("file.upload") 守卫
- 种子数据: 新增 audit.log.list / file.upload / diary.comment.delete 权限定义
- 角色种子: admin 获得 audit.log.list + file.upload + diary.comment.delete 权限
- diary.comment.delete 已在 teacher 列表中(种子定义之前缺失)

审计 ID: 5b-C01, 5b-C02, 4a-C02
2026-06-03 00:57:39 +08:00
iven
935918c9ab fix(server): 修复 RLS 变量名 bug — app.current_tenant → app.current_tenant_id + 空值保护
- 变量名从不存在的 app.current_tenant 修正为 app.current_tenant_id(与中间件一致)
- 添加空值保护:current_setting(...) != '' AND tenant_id = ...(与基座 m000088 严格模式一致)
- 移除 FORCE ROW LEVEL SECURITY,与基座表保持一致(允许迁移/管理操作绕过)
- 添加 DROP POLICY IF EXISTS 幂等保护

审计 ID: 4a-C01, 4b-C01, 4b-C02
2026-06-03 00:55:00 +08:00
iven
d482497e49 fix(app): 修复 smoke test — 改为验证主题构建,避免 Isar 依赖
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-03 00:00:15 +08:00
iven
45530616ee feat(diary): 添加贴纸包 UpdateStickerPackReq DTO + update service/handler — Task 13
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-02 23:54:04 +08:00
iven
d6dd017155 feat(web): 贴纸包 CRUD UI + 主题编辑/停用 — Task 14-15 完成
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
Task 14: StickerPackList 补全 CRUD UI
- stickers.ts: 添加 createPack/deletePack/createSticker API
- StickerPackList: 新建贴纸包按钮 + 创建表单 Modal
- StickerPackList: 卡片添加删除按钮 (Popconfirm)

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

附带修复:
- types.ts: SchoolClass/TopicAssignment 添加 version 字段
- ClassList.tsx: 修复 onUpdate 回调参数签名
- tsconfig.app.json: 排除 src/test 避免缺失模块编译错误
2026-06-02 23:40:46 +08:00
iven
f0741450bc feat(app): 家长端数据导出 — 添加 JSON 文件下载 + 预览
- 新建 file_download.dart 跨平台下载工具(conditional import)
- download_impl_web.dart: Web 平台通过 html.AnchorElement + Blob 下载
- download_impl.dart: 非 Web 平台 stub(Phase 2 扩展 path_provider)
- _ExportDataView: 添加下载按钮 + JSON 折叠预览 + PIPL 提示
- 移除 'Phase 1 预览' 占位文案,替换为完整下载功能
2026-06-02 23:36:35 +08:00
iven
c9a69d0be1 feat(app): 添加评论列表展示组件 — FutureBuilder 轮询模式 2026-06-02 23:26:54 +08:00
iven
9e53ca8555 feat(app): EditorPage 顶栏添加评语入口 — 仅已有日记显示
- 添加评语图标按钮(仅 journalId != null 时显示)
- 实现 _showComments 方法打开 CommentListSheet
- 补充 api_client + comment_list_sheet imports
2026-06-02 23:26:24 +08:00
iven
6c9a38b27b feat(app): 添加 EditorBloc.LoadJournal event — 加载已有日记数据
- LoadJournal event: 原子加载 title/mood/tags/strokes/elements/lastSavedAt
- _onLoadJournal handler: 不触发 auto-save (isDirty=false)
- 单元测试: 验证 LoadJournal 正确还原所有状态字段
- mood_bloc: linter 补充 foundation.dart import
2026-06-02 23:23:17 +08:00
iven
e57c3427a4 fix(app): 18 处 catch(e) 添加 debugPrint 异常日志
- parent_bloc: 6 处 (LoadChildren/BindChild/ViewJournals/ExportData/DeleteData/UnbindChild)
- search_bloc: 3 处 (SearchByMood/SearchByTag/SearchByKeyword)
- achievement_bloc: 1 处 (_fetchAchievements)
- sticker_bloc: 2 处 (_fetchPacks/fetchStickersInPack)
- template_bloc: 1 处 (_fetchTemplates)
- mood_bloc: 1 处 (_loadStats)
- home_bloc: 1 处 (_onLoadData)
- calendar_bloc: 1 处 (_onMonthChanged)
- sync_engine: 1 处 (trySync)
- weekly_page: 已有 debugPrint,无需修改
2026-06-02 23:21:16 +08:00
iven
c92ead60e3 feat(app): EditorPage 加载已有日记 — 替换为 LoadJournal 原子事件
- _loadExistingJournal 改用单一 LoadJournal event 替代多个细粒度事件
- 添加 _titleController 同步,确保 LoadJournal 后标题输入框正确显示
- 不触发 auto-save (isDirty=false),因为这是加载而非用户编辑
2026-06-02 23:16:58 +08:00
iven
ab45f40cc8 docs: 修订实施计划 — 修复 EditorView 目标类/apiClient.data/主题后端已存在 2026-06-02 23:06:11 +08:00
iven
8ea1032c9d feat(diary): Phase 1.3 完善修复 — 贴纸/主题 CRUD + 管理端对接 + HMS 清理
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
H7 贴纸 CRUD:
- POST /diary/sticker-packs — 创建贴纸包
- DELETE /diary/sticker-packs/:id — 软删除贴纸包
- POST /diary/sticker-packs/:id/stickers — 添加贴纸

H8 主题编辑/停用:
- PUT /diary/topics/:id — 编辑主题 (标题/描述/截止日期)
- PATCH /diary/topics/:id/deactivate — 停用主题

管理端前端:
- ClassList.tsx 对接 update/deactivate/reset-code (含 Popconfirm 确认)
- JournalList.tsx 班级筛选改用 classApi.listAll()
- classes.ts 新增 listAll/update/deactivate/resetCode API

M2 HMS 遗留清理:
- 删除 copilot.ts, healthFixtures.ts, healthHandlers.ts
- AuditLogViewer 资源类型 → 日记模块
- auth.test.ts / renderWithProviders health.* → diary.*

M4 编辑器加载:
- EditorPage journalId 非空时从 Isar 恢复笔画/元素/标签/心情/标题

77 tests passed, cargo check , tsc , flutter analyze 
2026-06-02 23:01:13 +08:00
iven
94bfb3297a docs: 添加课堂试点就绪实施计划 — 4 Phase 16 Task 2026-06-02 22:59:16 +08:00
iven
85d6781372 fix: Phase 1.3 完善修复 — 管理端对接 + HMS清理 + 编辑器加载
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- feat(web): ClassList.tsx 对接 update/deactivate/reset-code API
  - 编辑班级: PUT /diary/classes/:id
  - 停用班级: PATCH /diary/classes/:id/deactivate (Popconfirm 确认)
  - 重置班级码: POST /diary/classes/:id/reset-code (Popconfirm 确认)
  - 数据源改用 listAll() 获取所有班级
- fix(web): JournalList.tsx 班级筛选改用 classApi.listAll()
- fix(app): EditorPage 加载已有日记数据 (journalId 非空时)
  - 从 Isar 恢复笔画/元素/标签/心情/标题
  - _EditorView 改为 StatefulWidget + initState 加载
- chore(web): HMS 遗留代码清理
  - 删除 api/copilot.ts, healthFixtures.ts, healthHandlers.ts
  - AuditLogViewer 资源类型替换为日记模块类型
  - auth.test.ts / renderWithProviders 权限码 health.* → diary.*
- docs: 确认 M6 NotificationService 为误报 (已在 3 处调用)
2026-06-02 22:54:09 +08:00
iven
860844a399 docs: 修订课堂试点设计规格 v1.1 — 修正 API 路径/已存在功能/测试标准 2026-06-02 22:49:17 +08:00
iven
4d5ddf35a7 docs: 添加课堂试点就绪设计规格 — 四角色闭环 + 跨角色链路 2026-06-02 22:40:11 +08:00
iven
a83909dd24 fix(server): Phase 1.2 核心功能修复 — C1/C2/H4/H6
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- feat(diary): 新增 list_all_classes 管理端 API (GET /diary/classes/all)
- feat(diary): 新增班级更新 API (PUT /diary/classes/{id}) — 名称/学校名编辑
- feat(diary): 新增班级停用 API (PATCH /diary/classes/{id}/deactivate)
- feat(diary): 新增班级码重置 API (POST /diary/classes/{id}/reset-code)
- fix(db): 补充权限 seed — student 获得 update/delete, teacher 获得 comment.delete
- refactor(diary): 删除 comment_service 中废弃的 contains_sensitive_words 死代码
- test(diary): 77 测试全部通过
2026-06-02 21:33:47 +08:00
iven
49d4aa36a7 fix(app): Phase 1.1 紧急修复 — SyncEngine 接入 + authorId + catch 异常处理
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- feat(sync): SyncEngine 接入 EditorPage, 保存时 enqueue + 网络恢复自动 trySync
- fix(editor): authorId 从 AuthBloc 获取, 替代硬编码 'local'
- fix(bloc): class_bloc/calendar/profile/parent catch(_).全部改为 debugPrint
- feat(editor): 编辑器工具栏拆分 (brush_panel/tag_panel/text_format_bar/dot_grid_painter)
- feat(editor): EditorBloc 扩展 + EditorPage 增强
- feat(search): SearchBloc 扩展搜索功能
- feat(home): HomeBloc/HomePage 增强
- feat(auth): LoginPage 增强
- feat(templates): TemplateGalleryPage 重构
- fix(web): 管理端班级/日记页面修复
- fix(server): comment_service + theme_handler 修复
- docs: 添加全链路审计报告和验证截图
2026-06-02 21:21:43 +08:00
iven
7e928ae1e1 fix(app): 修复 P2~P4 共 10 项前端问题
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
P2 必须修复:
- 教师布置主题 classId 从硬编码改为班级下拉选择器
- 班级日记墙使用服务端 classId 过滤替代前端过滤
- Profile 统计栏接入 JournalRepository 真实数据
- WeeklyPage 从全硬编码改为 JournalRepository 数据驱动

P3 建议改进:
- 提取 mood_utils.dart 公共函数,消除 4 处重复定义
- 贴纸库搜索框连接 StickerBloc 按名称过滤

P4 细节打磨:
- 家长页多孩子时显示 DropdownButton 选择器
- 搜索结果日记卡片点击跳转 /editor?id=
- MonthlyPage 照片数量从 JournalElement 统计
- calendar_page/mood_page/search_page 统一使用 moodToEmoji/moodToLabel
2026-06-02 20:21:51 +08:00
iven
75db6a7eb7 fix(server): 修复菜单种子迁移 — 使用动态 tenant 查询替代 nil UUID
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
2026-06-02 14:13:32 +08:00
iven
74551d48e6 feat(server): 添加暖记日记管理菜单种子数据 + 图标注册
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 新增迁移 m20260602_000301_diary_menu_seed
- 插入'日记管理'目录菜单 (BookOutlined, sort=50)
- 子菜单: 班级管理/日记审核/主题管理/贴纸管理
- 关联 admin + teacher 角色 (menu_roles)
- 图标注册: BookOutlined, ScheduleOutlined, SmileOutlined
2026-06-02 12:24:29 +08:00
iven
78018a9a64 feat(app): 管理端 Web 基座→暖记品牌迁移 + 日记管理页面
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
Phase 1 — 品牌替换:
- BRAND_DEFAULTS 回退值改为暖记品牌 (themes.ts)
- 登录页/侧边栏/底部回退文字 → 暖记 (Login, MainLayout)
- index.html title/meta/favicon → 暖记
- localStorage key → nuanji-theme, 默认主题 → warm
- 4 套主题色适配暖记设计系统 (珊瑚 #E07A5F / 蓝 / 深色 / 鼠尾草绿)
- 品牌信息通过系统设置配置,不硬编码

Phase 2 — 清理 HMS 模块:
- 删除 health/ai 页面 (~55+2)、API (~30+9)、组件、stores、hooks
- 重写 Home.tsx 为暖记 Dashboard
- 重写 NotificationPanel/MediaPicker 移除 health 依赖
- 清理 routeConfig 移除所有 health/ai 路由权限

Phase 3 — 暖记管理页面:
- API 层: api/diary/{types,journals,classes,topics,comments,stickers}.ts
- 班级管理: 班级列表+创建+成员查看+班级码复制 (ClassList)
- 日记审核: 日记列表+筛选+详情+老师点评 (JournalList)
- 主题管理: 班级选择+主题卡片+创建+过期标记 (TopicList)
- 贴纸管理: 贴纸包卡片+贴纸详情网格 (StickerPackList)
- 路由注册: /diary/classes, /diary/journals, /diary/topics, /diary/stickers

验证: tsc 0 error, vite build ✓, vitest 226/226 pass
2026-06-02 12:16:44 +08:00
iven
0a9e5b1cb3 docs: 更新 wiki + CLAUDE.md — 三端架构和管理端集成
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- wiki/index.md: 更新关键数字、三端架构图、启动命令、症状导航
- wiki/architecture.md: 补充三端架构、来源追溯、活跃问题更新
- wiki/admin-web.md: 新建管理端文档 — 结构/API代理/功能映射/品牌定制清单
- CLAUDE.md: 补充 apps/web 目录、管理端场景化指令、三端启动命令
2026-06-02 10:11:03 +08:00
iven
8111471e93 feat: 添加管理端前端 (HMS 基座 React 管理面板)
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
- 从 HMS 基座复制 apps/web/ (React + Ant Design + Vite + TypeScript)
- 管理端自动代理 API 到 localhost:3000 (vite.config.ts)
- 更新 scripts/dev.sh 支持三端启动: backend/admin/app
- 登录验证通过, 用户管理/角色权限/审计日志等页面正常
- 添加 .gitignore 排除 node_modules/dist
2026-06-02 10:03:13 +08:00
iven
181bfb1f3e fix(app): 对齐 Open Design spec — 字体/Token/首页/Tab栏/路由/Discover页
针对 docs/opendesign/warm-notes-design-spec.md 全面审查的修复:

## 🔴 阻断级修复(商用合规)
- 下载真实 Quicksand/Nunito 字体文件(原 0 字节)
- 添加 OFL.txt 许可证文件,履行 SIL Open Font License 分发义务

## 🟠 设计 Token 偏差
- AppRadius: 删除非规范的 xs=8px,所有引用迁移至 sm=10px
- AppColors.moodColors: 对齐 spec §3.6
  - happy #FFD93D → secondary #81B29A
  - calm #81B29A → tertiary #F2CC8F
  - sad #7B9CC4 → #5B7DB1
  - thinking #B8A9C9(淡紫,spec 无)→ #8B7E74
- AppShadows: blurRadius/alpha 精确对齐 spec §1 (12/20/32 + 0.06/0.08/0.12)
- DesignTokens: 补 spacing40 + 新增 safe-top/safe-bottom/tab-height/touch-min 常量

## 🟠 首页 §3.4 完全重构
- 新增问候语头部(xx好,小暖 + accent 色高亮名字)
- 新增 streak-badge pill 徽章(tertiary-soft + #B8860B 暖金)
- 心情选择器卡片背景从 primaryContainer 改为 surface(spec 规定 #FFFFFF)
- 心情卡片圆角 lg(22) → md(16) 对齐 spec
- 新增 today-card 渐变卡片 + 浮动右下圆形写按钮
- 新增 quick-stats 三栏统计(本月日记/连续天数/总日记数)
- 移除 AppBar 多余的贴纸/模板按钮,搜索按钮改路由到 /search
- HomeBloc 扩展 monthCount/totalCount 字段
- 日记卡片:72×72 预览图 + 标签摘要 + 心情圆点

## 🟠 路由 §3.12 + §3.13 拆分
- 新建 DiscoverPage (features/discover/views/discover_page.dart)
  - 搜索框(跳转 /search)
  - 每日推荐渐变卡片
  - 热门话题横向 chips(前 3 个 accent 高亮)
  - 精选模板 2 列网格
  - 达人日记列表
- /discover 路由从指向 SearchPage 改为 DiscoverPage
- 新增 /search 路由(全屏无 Tab)指向 SearchPage

## 🟠 Tab 栏 §2.2 重构
- 高度从 64px 改为 56+bottomPadding(含 safe-bottom,约 90px)
- 中心按钮从 CircularNotchedRectangle 凹槽改为 margin-top:-16px 凸起
- FAB 尺寸从默认改为 48×48 spec 规格
- FAB 图标从 edit_rounded 改为 add_rounded(spec §2.2)
- 删除未使用的 _navItems 旧常量

## 🟡 登录页圆角统一
- 移除 3 处 InputBorder 显式 mdBorder(16px) 覆盖
- 全局主题 smBorder(10px) 生效,对齐 spec
- 提交按钮圆角改为 pill(spec §2.6 Primary 按钮)

## 验证
- flutter analyze: 0 errors (剩余 40 个 warning/info 全为预存)
- flutter test: 84/85 通过(widget smoke test 预存失败,与本次无关)
2026-06-02 09:11:46 +08:00
iven
b320641d9c fix(app): 全链路验证修复 — 编译错误/CORS/迁移/启动脚本
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
前端修复:
- calendar_page: 移除不存在的 JournalEntry.content getter
- responsive_scaffold: 移除不存在的 notchThickness 参数
- splash_page: SingleTickerProvider → TickerProvider (多 AnimationController)
- profile_page: UserRoleType.name → .code (修复运行时崩溃)
- 导入缺失的 user.dart

后端修复:
- class_service: generate_class_code 取 UUID 后6位(随机部分)避免碰撞
- diary_role_seed: 移除不存在的 id 列,使用复合主键 ON CONFLICT

基础设施:
- config/default.toml: CORS 改为通配符(开发模式)
- scripts/dev.sh: 统一启动脚本(自动清理端口)
- docs/opendesign/: Open Design 设计规格 HTML 原型稿

验证结果: flutter analyze 0 error, cargo test 77/77 通过, 17个页面全部渲染正常
2026-06-02 01:03:58 +08:00
iven
749ef55b89 feat: Week 4 收尾 + 架构治理 — 搜索/家长中心/Feature Flag/Docker/环境配置
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
架构治理:
- Feature Flag 落地: Cargo.toml [features] default=["diary"] + main.rs cfg 条件编译
- 环境配置统一: AppConfig 类 + --dart-define 注入 + SSE 端口 8080→3000 修复

搜索替代方案 (无 FTS):
- SearchBloc + 标签/心情筛选接入后端 API
- JournalRepository 扩展 mood/tag 筛选参数
- 搜索页 UI 接入实际数据(替换占位文本)

家长中心最小集 (PIPL 合规):
- 后端: parent_service (绑定/查看/导出/删除/解绑) + parent_handler (6 个 API 端点)
- 前端: ParentBloc + ParentPage 功能完整实现
- 绑定孩子、只读查看日记、导出数据、删除数据、解绑

Docker 部署:
- verify.sh 健康检查脚本 (Axum/PG/Redis/OpenAPI 四项检查)

测试修复:
- home_bloc_test / calendar_bloc_test 适配 JournalRepository 新参数

验证: flutter test 84/84 pass, cargo test 76/76 pass, cargo check pass
2026-06-01 23:53:34 +08:00
iven
ffde0c9e77 feat(test): Week 3 质量保障体系 — 55 新增测试 + CI/CD 流水线
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
前端第二批测试 (42 用例):
- AuthBloc: 16 用例 (启动恢复/登录/注册/角色选择/班级码/登出)
- HomeBloc: 8 用例 (数据加载/今日检测/心情统计/连续天数/离线容错)
- CalendarBloc: 10 用例 (月份切换/日期选择/视图模式/状态保持)
- MoodBloc: 8 用例 (统计加载/周期切换/API解析/错误处理)

后端 P0 单元测试 (13 用例):
- journal_service: 5 用例 (model_to_resp 转换/mood回退/weather回退/tags解析)
- sync_service: 8 用例 (冲突收集/DTO构造/序列化roundtrip/非冲突排除)

CI/CD:
- pr-check.yml: PR 触发 cargo fmt+check+clippy+test + flutter analyze+test
- main-merge.yml: main push 触发完整检查 + cargo audit 安全审计

测试统计: 前端 84 通过, 后端 73 通过 (全部通过)
2026-06-01 23:20:18 +08:00
iven
f0921d554c fix(app): 修复 ShareBottomSheet nullable 检查 2026-06-01 23:06:39 +08:00
iven
4c743e150e test(app): 前端第一批测试 — EditorBloc 19用例 + JournalElement 11用例 + InMemoryRepo 12用例
添加 mocktail 测试依赖 + 字体文件占位
总计 42 测试通过,覆盖工具/笔画/元素/撤销/重做/序列化/乐观锁/CRUD
2026-06-01 23:02:14 +08:00
iven
3eaf83c79a feat(app): 老师点评功能 — CommentCreate事件 + CommentBottomSheet + 日记墙点评按钮 2026-06-01 22:49:56 +08:00
iven
973bb56af6 feat(app): 编辑器完成按钮接入分享面板 — ShareBottomSheet + sharedToClass更新 2026-06-01 22:45:56 +08:00
iven
55285b57a7 feat(app): 班级码验证前后端联调 — AuthBloc接入API + 错误计数锁定UI 2026-06-01 22:42:33 +08:00
iven
b3fc066aac feat(db): 添加 student/teacher/parent 角色种子 — 日记权限分配 2026-06-01 22:37:52 +08:00
iven
6cb288b4f2 feat(diary): 班级码验证添加5次错误锁定 — Redis计数 + 30分钟冷却 2026-06-01 22:34:02 +08:00
iven
0c6a33d96b chore(app): linter 格式化 + image_picker 自动生成插件注册 2026-06-01 21:44:07 +08:00
iven
6378da055f feat(app): EditorPage 读取 template 参数 — 模板选择框架 2026-06-01 21:41:53 +08:00
iven
db881c25a0 feat(app): 集成贴纸选择到编辑器 — 底部面板 + 自动放置 2026-06-01 21:38:26 +08:00
iven
57b45f7cbf feat(app): 创建贴纸选择底部面板 — 6 类 60 个 emoji 贴纸 2026-06-01 21:36:51 +08:00
iven
89c1cefb11 feat(app): 集成图片上传到编辑器 — 拍照/相册 + 压缩 + 拖拽定位 2026-06-01 21:35:43 +08:00
iven
cd86156590 feat(app): 创建图片选择+压缩处理器 — ImagePickerHandler 2026-06-01 21:32:55 +08:00
iven
f8a20d673e feat(app): 添加 image_picker 依赖 2026-06-01 21:31:28 +08:00
iven
9785370922 feat(app): 增强文字元素渲染 — 多行+字号+颜色 2026-06-01 21:28:15 +08:00
iven
fef2d629e5 feat(app): 集成文字输入到编辑器 — TextInputOverlay + 工具栏选项行 2026-06-01 21:27:15 +08:00
iven
d392515f4a feat(app): 创建文字输入覆盖层组件 — TextInputOverlay 2026-06-01 21:23:30 +08:00
iven
417dcb08e4 feat(app): 添加 JournalElement 类型便利工厂方法 — text/image/sticker 2026-06-01 21:22:27 +08:00
iven
92a70ca2ed docs(plans): Week 1 编辑器体验完善实施计划
5 Chunk / 12 Task 详细实施计划:
- Chunk 1: 文字输入 (便利工厂 + TextInputOverlay + 集成 + 渲染)
- Chunk 2: 图片上传 (依赖 + ImagePickerHandler + 集成)
- Chunk 3: 贴纸接入 (StickerPickerSheet + 集成)
- Chunk 4: 模板数据传递
- Chunk 5: 全链路集成验证

TDD 风格,每个 Step 包含完整代码、验证命令、Commit
2026-06-01 21:03:32 +08:00
iven
075536660a docs(specs): 30 天行动计划 — 质量+体验+架构三位一体
多专家组头脑风暴综合报告:
- 质量保障: 前端测试 4 批次 ~120 用例 + CI/CD + Docker 验证
- 核心体验: 编辑器文字/图片/贴纸 + 班级闭环 + 家长 PIPL
- 架构演进: Feature Flag + 状态管理统一 + 环境配置
- 30 天时间线: Week1 编辑器 → Week2 班级 → Week3 测试 → Week4 收尾
2026-06-01 20:57:28 +08:00
iven
ab58186ab3 docs(wiki): 全量项目健康度评估 + 技术债全景更新
- 新增 project-health.md — 项目评分/技术债全景/风险矩阵/改进路线图
- 更新 index.md — 代码量分布表/新发现技术债统计/新增症状条目
- 更新 architecture.md — Feature Flag 未实现状态/超大文件发现
- 更新 frontend.md — 状态管理不统一/SSE 端口/测试缺失等 11 项问题
- 更新 erp-diary.md — 代码量分布参考/班级码硬编码问题

基于 4 代理并行深度分析: 后端 Rust 51,459 行 + 前端 Flutter 18,398 行
2026-06-01 18:33:38 +08:00
iven
c2a95798bd fix(app): 修复日历页日期查询参数格式 — 去除毫秒匹配后端 NaiveDateTime 2026-06-01 18:12:51 +08:00
iven
8e3e232278 fix: 全链路问题修复 — 编辑器返回/Tab导航/数据库编码/Token注入
修复内容:
- 编辑器返回按钮: 所有 context.go('/editor') 改为 context.push(),pop() 加安全守卫 fallback 到 /home
- Tab 导航: Web 平台强制使用移动端底部 TabBar 布局 (kIsWeb 守卫)
- 数据库编码: db.rs 自动追加 client_encoding=utf8 参数,修复中文 display_name 乱码
- AuthBloc token: 清理冗余 TODO,token 注入已在 AuthRepository 中正常工作
- 影响 9 个文件的编辑器导航调用点统一修改
2026-06-01 18:08:09 +08:00
iven
33dc5e19e4 fix(app): Web 平台兼容性修复 + 字体资源 + API base URL
- 添加 Web/Windows 平台支持 (flutter create --platforms)
- 下载字体资源 (NotoSansSC/Caveat Regular+Bold)
- Isar 3.x Web 不兼容:添加 kIsWeb 守卫,Web 上跳过 Isar 初始化
- IsarJournalRepository: instance 返回 nullable,Web 上使用 RemoteJournalRepository
- SyncEngine: persistPendingQueue/restorePendingQueue Web 安全
- SettingsBloc: 从 RepositoryProvider 改为 ListenableProvider
- ApiClient base URL: 8080 → 3000 匹配后端端口
- Isar .g.dart: 64 位 ID 替换为 JS 安全范围值
2026-06-01 17:30:27 +08:00
iven
d1a07229e2 docs: 项目 Wiki 知识库 — 7 文件覆盖架构/手写/数据/前端/后端/技术债
新增 wiki/ 知识库 (遵循 HMS wiki-methodology.md 5 节结构):
- index.md (84 行) — 症状导航 13 条 + 模块索引 + 系统数据流
- architecture.md (120 行) — 基座剥离 7 耦合点 + Feature Flag + PIPL 合规
- handwriting-engine.md (124 行) — 双层 Canvas + O(1) 点缓冲 + 光栅化缓存
- data-layer.md (127 行) — Isar + SyncEngine 离线同步 + 踩坑记录
- frontend.md (118 行) — 16 模块地图 + BLoC 注入链 + 设计系统
- erp-diary.md (101 行) — 15 Entity / 10 Service / 8 Handler + API 端点

新增 docs/:
- tech-debt-board.md (110 行) — 10 条技术债 + 偿还优先级排名

其他更新:
- .gitignore: 添加 .understand-anything/ (待初始化)
- CLAUDE.md §9: 添加 wiki 参考文档链接
2026-06-01 15:08:21 +08:00
iven
2481c8fce6 feat(app): Isar 本地数据库集成 — Collection + Repository + 编辑器持久化 + SyncEngine 队列
新增文件:
- data/local/collections/ 3 个 Isar Collection 定义 + 生成 Schema
- data/repositories/isar_journal_repository.dart 完整 CRUD + 乐观锁

修改文件:
- app.dart: IsarJournalRepository 注册为主 JournalRepository + SyncEngine 注入
- editor_page.dart: onSave 接入 JournalRepository,笔画/元素自动保存到 Isar
- sync_engine.dart: 新增 persistPendingQueue/restorePendingQueue Isar 持久化
- isar_database.dart: 注册 3 个 Collection Schema
- main.dart: 启动时初始化 Isar

架构: 离线优先 — Isar 为本地主仓库,Remote 供 SyncEngine 推送
2026-06-01 14:41:40 +08:00
iven
e07da7addb perf(app): 手写引擎性能优化 — 双层架构 + 光栅化缓存 + O(1) 点缓冲
性能优化:
- 新建 StrokeRasterCache: 已完成笔画光栅化为 ui.Image 合成位图
- 新建 CachedStrokesPainter: 每帧仅 drawImage,O(1) 开销
- 新建 ActiveStrokePainter: 仅渲染当前笔画,isComplete: false
- _currentPoints 改为可变缓冲区 + ValueNotifier 驱动,消除 O(N²) 列表拷贝
- 双层 Stack 架构: 已缓存层(不随指针移动重绘) + 实时层(仅当前笔画)

Bug 修复:
- 橡皮擦 saveLayer 合成: BlendMode.dstOut 在离屏缓冲区中正确工作
- pointsToOutline 新增 isComplete 参数: 实时绘制传 false,完成笔画传 true
- 模式切换不再销毁 HandwritingCanvas: IgnorePointer 替代 if/else 分支

架构改进:
- 提取 createPaintForStroke() 为顶层函数,供缓存和 Painter 共用
- 移除旧 StrokePainter 类,由双层 Painter 替代
- LayoutBuilder 跟踪画布尺寸,尺寸变化时缓存自动失效

文件变更:
- 新建 stroke_cache.dart (~210 行)
- 新建 cached_strokes_painter.dart (~35 行)
- 新建 active_stroke_painter.dart (~70 行)
- 重写 handwriting_canvas.dart (~300 行)
- 重构 stroke_renderer.dart (~185 行, 移除旧 Painter)
- 修改 editor_page.dart (IgnorePointer 模式切换)

验证: flutter analyze 0 error
2026-06-01 13:18:36 +08:00
iven
8331db63ba feat(app): 设置页 UI + Mood/成就/贴纸 BLoC 接入 API + B7 测试扩展
前端改动:
- 新建设置页面 (主题切换/关于/隐私政策/用户协议/儿童隐私保护)
- SettingsBloc 注册到 MultiRepositoryProvider 全局可访问
- MoodBloc 修复编译错误 + 接入 /diary/stats/mood API
- MoodPage 添加错误状态展示和重试按钮
- AchievementBloc + 页面改造接入 /diary/achievements API
- StickerBloc + 页面改造接入 /diary/sticker-packs API
- TemplateBloc + 页面改造接入 /diary/templates API
- ProfilePage 设置入口改为跳转 /settings
- 添加 /settings 路由

后端改动:
- 扩展 mood_stats_service 测试 (连续天数算法/心情计数/边界场景)
- 新增 class_service 测试 (班级码生成/唯一性/错误映射)
- 新增 achievement_service 测试 (DTO 结构/序列化/map 构建)
- 新增 sticker_service 测试 (DTO 序列化/错误处理)
- 扩展 dto.rs 测试 (achievement/mood_stats/sticker/template/notification)
- 清理 2 个 unused import warning

验证:
- cargo check 0 error 0 warning
- flutter analyze 0 error
2026-06-01 11:19:43 +08:00
iven
860e9e5d22 feat(app): BLoC 集成 Repository + SettingsBloc 主题切换
全局依赖注入:
- app.dart 注入 JournalRepository + ClassRepository + SettingsBloc
- ApiClient token 自动注入(监听 AuthBloc 状态)

BLoC 重构 (占位数据 → Repository):
- CalendarBloc: 通过 JournalRepository 加载月度日记
- ClassBloc: 通过 ClassRepository + JournalRepository 加载班级数据
- 新增 ClassJoin 事件支持班级码加入
- HomeBloc: 加载最近日记 + 心情概览 + 连续天数 + 今日是否已写

设置系统:
- SettingsBloc: ThemeMode 切换 (system/light/dark)
- app.dart 通过 ListenableBuilder 响应主题变化
- HomeBloc 支持下拉刷新

首页增强:
- 连续天数徽章 + 今日已写标记 + 最常用心情高亮
- RefreshIndicator 下拉刷新
- 日记列表卡片显示日期

验证: flutter analyze 0 error
2026-06-01 10:32:20 +08:00
iven
263ddf31a6 feat(app): 数据层集成 — RemoteJournalRepository + ClassRepository + SSE 通知
数据层新增:
- RemoteJournalRepository: 日记 CRUD + 元素管理,通过 ApiClient 连接后端
- ClassRepository: 班级/主题/评语 API 操作(getMyClasses/joinClass/assignTopic/createComment)
- SseNotificationService: SSE 实时通知监听 + 自动重连 + 事件流
- ApiException: 统一 API 错误封装
- DTO: ClassMemberDto + TopicDto + CommentDto

设计:
- Repository 模式: 抽象接口 + 远程实现 + 内存实现
- SSE: Dio stream + SSE 协议解析 + 3秒自动重连
- 所有 Repository 通过 ApiClient 注入,依赖现有 JWT 拦截器

验证: flutter analyze 0 error
2026-06-01 10:11:47 +08:00
iven
05317d50d5 fix(diary): B7 测试套件 + F11 深色模式修复
B7 API 打磨:
- DTO 序列化/反序列化测试 12 个 (Mood/Weather/SyncChange/NotificationType等)
- 测试总数 17 → 29,全部通过
- SyncChange 添加 Serialize derive (测试发现遗漏)

F11 深色模式:
- 修复 mood_page.dart 唯一硬编码颜色 Colors.white → colorScheme.onPrimary
- 全面审计确认所有页面均使用 AppColors/colorScheme,无其他硬编码

验证: cargo test 29/29 ✓ flutter analyze 0 error ✓
2026-06-01 10:07:44 +08:00
iven
c4a317c90f feat(app): F8 班级系统 + F9/F10 占位页面
F8 班级系统:
- ClassBloc 状态管理 (班级列表/日记墙/成员/主题/评语)
- 班级主页: 日记墙 + 主题布置 Tab + 成员列表
- 老师管理页: 创建班级 + 布置主题 + 点评入口
- 班级码展示 + 评语卡片

F9 家长功能 (占位):
- 家长中心页面框架: 日记查看/心情统计/使用时间/数据管理
- PIPL 合规提示卡片

F10 搜索/设置 (部分):
- 个人中心: 用户信息 + 角色展示 + 功能入口 + 退出登录
- 搜索页: 标签筛选 + 心情过滤 + 搜索框

验证: flutter analyze 0 error
2026-06-01 09:43:54 +08:00
475 changed files with 99757 additions and 2611 deletions

54
.github/workflows/main-merge.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: Main Merge
on:
push:
branches: [main]
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: crates → target
- name: cargo fmt
run: cargo fmt --all -- --check
- name: cargo check
run: cargo check --all-targets
- name: cargo clippy
run: cargo clippy --all-targets -- -D warnings
- name: cargo test
run: cargo test --all
- name: cargo audit # 安全审计(可选,允许失败)
run: |
cargo install cargo-audit 2>/dev/null || true
cargo audit || true
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.x'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
working-directory: app
- name: flutter analyze
run: flutter analyze --no-fatal-infos
working-directory: app
- name: flutter test
run: flutter test
working-directory: app

56
.github/workflows/pr-check.yml vendored Normal file
View File

@@ -0,0 +1,56 @@
name: PR Check
on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
jobs:
# 后端检查
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: crates → target
- name: cargo fmt
run: cargo fmt --all -- --check
working-directory: .
- name: cargo check
run: cargo check --all-targets
working-directory: .
- name: cargo clippy
run: cargo clippy --all-targets -- -D warnings
working-directory: .
- name: cargo test
run: cargo test --all
working-directory: .
# 前端检查
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.x'
channel: 'stable'
- name: Install dependencies
run: flutter pub get
working-directory: app
- name: flutter analyze
run: flutter analyze --no-fatal-infos
working-directory: app
- name: flutter test
run: flutter test
working-directory: app

3
.gitignore vendored
View File

@@ -98,6 +98,9 @@ trace-*.json
# Graphify knowledge graph (regenerated locally)
graphify-out/
# Understand-Anything knowledge graph (local dev tool)
.understand-anything/
# Native miniprogram (separate project)
apps/mp-native/

View File

@@ -36,8 +36,13 @@ nj/ (一个仓库)
│ ├── src/service/ # ~12 Service
│ ├── src/handler/ # ~10 Handler
│ └── src/{dto,error,event,state}.rs
├── app/ # Flutter 前端
├── config/ # 服务器配置
├── apps/ # 🆕 管理端前端 (从 HMS 基座复用)
│ └── web/ # React + Ant Design + Vite (:5174)
│ ├── src/pages/ # 管理页面 (用户/角色/权限/审计...)
│ └── vite.config.ts # API 代理 → localhost:3000
├── app/ # Flutter 学生端 (:8080)
├── scripts/dev.sh # 🆕 统一启动脚本 (自动清理端口)
├── config/ # 服务器配置 (CORS=*)
├── docker/ # Docker Compose (PG + Redis)
├── docs/ # 产品文档
│ └── superpowers/specs/ # 设计规格 v1.2
@@ -62,6 +67,7 @@ nj/ (一个仓库)
| BPMN 工作流 | erp-workflow 继承 | 零开发 |
| SeaORM 迁移框架 | erp-server 继承 | 零开发 |
| OpenAPI 文档 | utoipa 继承 | 零开发 |
| 管理端 Web 前端 | HMS apps/web/ 复用 | 零开发 (品牌替换待做) |
| student/teacher/parent 角色 | erp-auth 扩展 | 🆕 ~200 行 |
| 班级码认证 | erp-auth 扩展 | 🆕 ~500 行 |
| 日记 CRUD + 同步 | erp-diary 新增 | 🆕 ~2000 行 |
@@ -321,9 +327,12 @@ chore(docker): 添加 PostgreSQL 16 + Redis 7 开发环境
- 当遇到**新增数据表** → 创建 SeaORM migration + Entity包含所有标准字段
- 当遇到**跨模块通信** → 定义事件类型到 erp-diary/src/event.rs通过 EventBus 发布
- 当遇到**新增 Flutter 功能** → 创建 features/{name}/ 目录bloc/views/widgets 分层
- 当遇到**管理端修改** → 在 apps/web/ 中修改 React 组件,`pnpm dev` 启动开发服务器
- 当遇到**管理端新增页面** → 在 apps/web/src/pages/ 添加,更新 routeConfig.ts + 侧边栏菜单
- 当遇到**手写性能问题** → 检查 shouldRepaint 守卫 + 笔画光栅化缓存 + Listener 替代 GestureDetector
- 当遇到**同步冲突** → 版本号比对Phase 1 使用"本地优先"简单策略
- 当遇到**儿童数据** → 确认 PIPL 合规检查清单(家长授权/最小数据/加密/注销机制)
- 当遇到**启动端口占用** → `./scripts/dev.sh stop` 清理所有旧进程
---
@@ -414,6 +423,14 @@ chore(docker): 添加 PostgreSQL 16 + Redis 7 开发环境
| 文档 | 位置 |
|------|------|
| **知识库首页** | `wiki/index.md` — 症状导航 + 模块索引 |
| 架构决策 | `wiki/architecture.md` — 基座剥离 + Feature Flag + 多租户 |
| 手写引擎 | `wiki/handwriting-engine.md` — 双层 Canvas + 光栅化缓存 |
| 数据层 | `wiki/data-layer.md` — Isar + SyncEngine 离线同步 |
| Flutter 前端 | `wiki/frontend.md` — 16 模块 + BLoC + 设计系统 |
| 管理端前端 | `wiki/admin-web.md` — React + Ant Design + 品牌定制清单 |
| 后端模块 | `wiki/erp-diary.md` — Entity/Service/Handler 清单 |
| 技术债看板 | `docs/tech-debt-board.md` — 10 条待偿还债务 |
| 产品设计规格 v1.2 | `docs/superpowers/specs/2026-05-31-nuanji-warm-notes-design.md` |
| 实施规划 v2.1 | `plans/hazy-petting-lampson.md` |
| 头脑风暴文档 (8 份) | `.superpowers/brainstorm/734-1780218658/` |
@@ -424,6 +441,35 @@ chore(docker): 添加 PostgreSQL 16 + Redis 7 开发环境
## 10. 开发环境
### 三端启动
```bash
# 一键启动全部 (后端 + 管理端 + 学生端)
./scripts/dev.sh
# 单独启动
./scripts/dev.sh backend # Rust Axum → :3000
./scripts/dev.sh admin # React Vite → :5174
./scripts/dev.sh app # Flutter Web → :8080
# 停止所有 (自动清理端口)
./scripts/dev.sh stop
```
管理端默认账号: `admin / admin123`
### 环境依赖
| 服务 | 地址 | 说明 |
|------|------|------|
| PostgreSQL 16 | localhost:5432 | 数据库 `nuanji` |
| Redis 7 | localhost:6379 | 缓存/速率限制 |
| Flutter SDK | D:\flutter\bin\flutter.bat | 学生端 |
| Node.js + pnpm | - | 管理端 |
| Rust toolchain | stable | 后端 |
### 参考文档
| 文档 | 位置 |
|------|------|
| 产品设计规格 v1.2 | `docs/superpowers/specs/2026-05-31-nuanji-warm-notes-design.md` |

1
Cargo.lock generated
View File

@@ -1457,6 +1457,7 @@ dependencies = [
"chrono",
"erp-auth",
"erp-core",
"redis",
"sea-orm",
"serde",
"serde_json",

View File

@@ -15,10 +15,7 @@ migration:
- platform: root
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: android
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
- platform: ios
- platform: windows
create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42

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,8 +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:icon="@mipmap/ic_launcher"
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

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 网络安全配置 — 强制 HTTPS仅允许 localhost 明文(开发用)
审计 ID: 6b-C01 — Flutter 默认 HTTP 明文传输修复
-->
<network-security-config>
<!-- 生产配置:强制 HTTPS -->
<base-config cleartextTrafficPermitted="false">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
<!-- 开发配置:允许 localhost/10.0.2.2 明文(模拟器/本地调试)
生产构建时应移除此段 -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">localhost</domain>
<domain includeSubdomains="false">10.0.2.2</domain>
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>

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()

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

93
app/assets/fonts/OFL.txt Normal file
View File

@@ -0,0 +1,93 @@
Copyright 2011 The Quicksand Project Authors (https://github.com/andrew-paglinawan/QuicksandFamily), with Reserved Font Name “Quicksand”.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,42 +1,135 @@
// 暖记 App 根组件 — MaterialApp + BLoC Provider 注入
//
// 依赖注入结构:
// RepositoryProvider<AuthRepository> — 认证仓库(全局唯一)
// BlocProvider<AuthBloc> — 认证 BLoC全局唯一
// └─ MaterialApp.router — 路由(使用 auth 状态守卫)
// MultiRepositoryProvider
// ApiClient
// ├─ AuthRepository
// ├─ JournalRepository (IsarJournalRepository — 离线优先)
// ├─ RemoteJournalRepository (供 SyncEngine 使用)
// └─ ClassRepository
// └─ BlocProvider<AuthBloc>
// └─ MaterialApp.router
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';
import 'core/routing/app_router.dart';
import 'data/local/secure_token_store_factory.dart';
import 'data/remote/api_client.dart';
import 'data/repositories/auth_repository.dart';
import 'data/repositories/journal_repository.dart';
import 'data/repositories/isar_journal_repository.dart';
import 'data/repositories/remote_journal_repository.dart';
import 'data/repositories/class_repository.dart';
import 'data/services/sync_engine.dart';
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 apiClient = ApiClient();
final authRepository = AuthRepository(apiClient: apiClient);
final authBloc = AuthBloc(authRepository: authRepository);
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();
_authRepository = AuthRepository(apiClient: _apiClient, tokenStore: tokenStore);
_journalRepository = kIsWeb
? RemoteJournalRepository(api: _apiClient)
: IsarJournalRepository();
_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 持久化队列
_syncEngine.restorePendingQueue();
_syncEngine.startAutoSync();
// 认证状态监听:登出时清除 token
_authBloc.stream.listen((state) {
if (state is! Authenticated) {
_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<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: BlocProvider<AuthBloc>.value(
value: authBloc,
child: _AppView(router: createAppRouter(authBloc)),
child: ListenableProvider<SettingsBloc>.value(
value: _settingsBloc,
child: Builder(
builder: (context) {
final settings = context.watch<SettingsBloc>();
return BlocProvider<AuthBloc>.value(
value: _authBloc,
child: _AppView(
router: createAppRouter(_authBloc),
themeMode: settings.state.themeMode,
),
);
},
),
),
);
}
@@ -45,8 +138,9 @@ class NuanjiApp extends StatelessWidget {
/// App 视图 — MaterialApp.router 包装
class _AppView extends StatelessWidget {
final GoRouter router;
final ThemeMode themeMode;
const _AppView({required this.router});
const _AppView({required this.router, this.themeMode = ThemeMode.system});
@override
Widget build(BuildContext context) {
@@ -55,7 +149,7 @@ class _AppView extends StatelessWidget {
debugShowCheckedModeBanner: false,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
themeMode: ThemeMode.system,
themeMode: themeMode,
routerConfig: router,
);
}

View File

@@ -0,0 +1,53 @@
// 应用环境配置 — 通过 --dart-define 注入
//
// 使用方式:
// flutter run # 开发模式localhost
// flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1 # 生产模式
//
// 安全说明:
// - 生产环境强制 HTTPSAndroid network_security_config 禁止明文流量)
// - 开发模式使用 localhostAndroid 网络安全配置已允许 localhost 明文)
/// 应用环境配置 — 集中管理所有外部服务地址
class AppConfig {
/// API 基础 URL后端 Axum 服务地址)
final String apiBaseUrl;
/// SSE 推送服务 URL通常与 API 同一地址)
final String sseBaseUrl;
const AppConfig({
required this.apiBaseUrl,
required this.sseBaseUrl,
});
/// 从编译时环境变量构建配置
///
/// 使用 `--dart-define` 注入,未设置时使用生产 HTTPS 默认值。
/// 开发环境使用 [dev] 常量或通过 --dart-define 覆盖。
factory AppConfig.fromEnvironment({
String defaultApiBaseUrl = 'https://api.nuanji.app/api/v1',
String defaultSseBaseUrl = 'https://api.nuanji.app/api/v1',
}) {
// const String.fromEnvironment 在编译时求值
const apiBaseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'https://api.nuanji.app/api/v1',
);
const sseBaseUrl = String.fromEnvironment(
'SSE_BASE_URL',
defaultValue: 'https://api.nuanji.app/api/v1',
);
return AppConfig(
apiBaseUrl: apiBaseUrl,
sseBaseUrl: sseBaseUrl,
);
}
/// 开发环境默认配置localhost 明文 — 仅用于本地调试)
static const dev = AppConfig(
apiBaseUrl: 'http://localhost:3000/api/v1',
sseBaseUrl: 'http://localhost:3000/api/v1',
);
}

View File

@@ -5,7 +5,7 @@ import 'package:flutter/animation.dart';
class DesignTokens {
DesignTokens._();
// ===== 间距 =====
// ===== 间距4px 基准9 级)=====
static const double spacing4 = 4;
static const double spacing8 = 8;
static const double spacing12 = 12;
@@ -13,8 +13,16 @@ class DesignTokens {
static const double spacing20 = 20;
static const double spacing24 = 24;
static const double spacing32 = 32;
static const double spacing40 = 40;
static const double spacing48 = 48;
// ===== 安全区 & 布局常量(对齐 spec §1=====
static const double safeTop = 54; // iPhone Dynamic Island
static const double safeBottom = 34; // Home Indicator
static const double tabHeight = 56; // 底部 Tab 栏
static const double touchMin = 44; // WCAG 最小触控目标
static const double containerMax = 390; // 移动端容器宽度
// ===== 动画时长 =====
static const Duration animFast = Duration(milliseconds: 150);
static const Duration animNormal = Duration(milliseconds: 300);

View File

@@ -1,4 +1,5 @@
// 暖记路由表 — go_router 20 页面 + 认证守卫
// 暖记路由表 — go_router + 认证守卫
// 对齐 Open Design: TabBar = 首页/日历/发现/我的,中心 FAB = 写日记
//
// 路由守卫逻辑:
// - 未认证用户访问受保护路由 → 重定向到 /login
@@ -11,6 +12,7 @@ export '../../widgets/responsive_scaffold.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../widgets/responsive_scaffold.dart';
@@ -19,31 +21,43 @@ import '../../features/home/views/home_page.dart';
import '../../features/calendar/views/calendar_page.dart';
import '../../features/mood/views/mood_page.dart';
import '../../features/search/views/search_page.dart';
import '../../features/discover/views/discover_page.dart';
import '../../features/calendar/views/weekly_page.dart';
import '../../features/calendar/views/monthly_page.dart';
import '../../features/profile/views/profile_page.dart';
import '../../features/editor/views/editor_page.dart';
import '../../features/auth/views/login_page.dart';
import '../../features/auth/views/role_selection_page.dart';
import '../../features/auth/views/parental_consent_page.dart';
import '../../features/auth/views/class_code_join_page.dart';
import '../../features/onboarding/views/splash_page.dart';
import '../../features/onboarding/views/onboarding_page.dart';
import '../../features/class_/views/class_page.dart';
import '../../features/teacher/views/teacher_page.dart';
import '../../features/parent/views/parent_page.dart';
import '../../features/parent/bloc/parent_bloc.dart';
import '../../features/achievement/views/achievement_page.dart';
import '../../features/stickers/views/sticker_library_page.dart';
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';
// Shell 分支键
final _rootNavigatorKey = GlobalKey<NavigatorState>();
final _shellNavigatorKey = GlobalKey<NavigatorState>();
/// 不需要认证的白名单路径
const _publicPaths = ['/login', '/role-selection', '/class-code'];
const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/parental-consent', '/class-code'];
/// 创建路由配置 — 需要注入 AuthBloc
GoRouter createAppRouter(AuthBloc authBloc) {
return GoRouter(
navigatorKey: _rootNavigatorKey,
initialLocation: '/home',
initialLocation: '/splash',
debugLogDiagnostics: true,
// ===== 认证路由守卫 =====
@@ -62,6 +76,7 @@ GoRouter createAppRouter(AuthBloc authBloc) {
// 已认证 + 访问公开页面 → 根据状态重定向
if (isAuthenticated && isPublicPath) {
if (authState.needsRoleSelection) return '/role-selection';
if (authState.needsParentalConsent) return '/parental-consent';
if (authState.needsClassCode) return '/class-code';
return '/home';
}
@@ -71,9 +86,14 @@ GoRouter createAppRouter(AuthBloc authBloc) {
if (authState.needsRoleSelection && currentPath != '/role-selection') {
return '/role-selection';
}
if (authState.needsParentalConsent &&
currentPath != '/parental-consent') {
return '/parental-consent';
}
if (authState.needsClassCode &&
currentPath != '/class-code' &&
currentPath != '/role-selection') {
currentPath != '/role-selection' &&
currentPath != '/parental-consent') {
return '/class-code';
}
return null;
@@ -90,6 +110,18 @@ GoRouter createAppRouter(AuthBloc authBloc) {
refreshListenable: _AuthListenable(authBloc),
routes: [
// 启动页 & 引导页(无 Shell无需认证
GoRoute(
path: '/splash',
name: 'splash',
builder: (context, state) => const SplashPage(),
),
GoRoute(
path: '/onboarding',
name: 'onboarding',
builder: (context, state) => const OnboardingPage(),
),
// 认证路由(无 Shell
GoRoute(
path: '/login',
@@ -101,13 +133,18 @@ GoRouter createAppRouter(AuthBloc authBloc) {
name: 'roleSelection',
builder: (context, state) => const RoleSelectionPage(),
),
GoRoute(
path: '/parental-consent',
name: 'parentalConsent',
builder: (context, state) => const ParentalConsentPage(),
),
GoRoute(
path: '/class-code',
name: 'classCode',
builder: (context, state) => const ClassCodeJoinPage(),
),
// 主 Shell 路由(底部导航 + 侧边导航
// 主 Shell 路由(底部导航: 首页/日历/发现/我的
ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) {
@@ -128,16 +165,19 @@ GoRouter createAppRouter(AuthBloc authBloc) {
name: 'calendar',
builder: (context, state) => const CalendarPage(),
),
// 发现页 — 灵感、话题、达人日记spec §3.12
GoRoute(
path: '/mood',
name: 'mood',
builder: (context, state) => const MoodPage(),
),
GoRoute(
path: '/search',
name: 'search',
builder: (context, state) => const SearchPage(),
path: '/discover',
name: 'discover',
builder: (context, state) {
return BlocProvider(
create: (_) => DiscoverBloc(api: context.read<ApiClient>())
..add(const DiscoverLoadData()),
child: const DiscoverPage(),
);
},
),
// 个人中心
GoRoute(
path: '/profile',
name: 'profile',
@@ -146,6 +186,20 @@ GoRouter createAppRouter(AuthBloc authBloc) {
],
),
// 搜索页 — 全屏无 Tabspec §3.13
GoRoute(
path: '/search',
name: 'search',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
final journalRepo = context.read<JournalRepository>();
return BlocProvider(
create: (_) => SearchBloc(journalRepository: journalRepo),
child: const SearchPage(),
);
},
),
// 全屏页面(无底部导航)
GoRoute(
path: '/editor',
@@ -153,9 +207,31 @@ GoRouter createAppRouter(AuthBloc authBloc) {
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) {
final journalId = state.uri.queryParameters['id'];
return EditorPage(journalId: journalId);
final templateId = state.uri.queryParameters['template'];
return EditorPage(journalId: journalId, templateId: templateId);
},
),
// 心情追踪(全屏,从首页心情卡片进入)
GoRoute(
path: '/mood',
name: 'mood',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const MoodPage(),
),
// 周概览(全屏,从日历页进入)
GoRoute(
path: '/weekly',
name: 'weekly',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const WeeklyPage(),
),
// 月度概览(全屏,从日历页进入)
GoRoute(
path: '/monthly',
name: 'monthly',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const MonthlyPage(),
),
GoRoute(
path: '/class',
name: 'class',
@@ -172,7 +248,12 @@ GoRouter createAppRouter(AuthBloc authBloc) {
path: '/parent',
name: 'parent',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const ParentPage(),
builder: (context, state) {
return BlocProvider(
create: (_) => ParentBloc(api: context.read<ApiClient>()),
child: const ParentPage(),
);
},
),
GoRoute(
path: '/achievements',
@@ -192,17 +273,22 @@ GoRouter createAppRouter(AuthBloc authBloc) {
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const TemplateGalleryPage(),
),
GoRoute(
path: '/settings',
name: 'settings',
parentNavigatorKey: _rootNavigatorKey,
builder: (context, state) => const SettingsPage(),
),
],
);
}
/// 路径 → Tab index 映射
/// 路径 → Tab index 映射4 项: 首页=0, 日历=1, 发现=2, 我的=3
int _selectedIndexFromLocation(String location) {
if (location.startsWith('/calendar')) return 1;
if (location.startsWith('/mood')) return 2;
if (location.startsWith('/search')) return 3;
if (location.startsWith('/profile')) return 4;
return 0;
if (location.startsWith('/discover')) return 2;
if (location.startsWith('/profile')) return 3;
return 0; // /home 或未知路径
}
/// AuthBloc 变化监听器 — 驱动 GoRouter refreshListenable
@@ -223,6 +309,8 @@ class _AuthListenable extends ChangeNotifier {
}
/// App Shell — 包裹 ResponsiveScaffold
/// TabBar: 首页(0) / 日历(1) / 发现(2) / 我的(3)
/// 中心 FAB: 写日记
class _AppShell extends StatelessWidget {
const _AppShell({
required this.selectedIndex,
@@ -243,20 +331,13 @@ class _AppShell extends StatelessWidget {
case 1:
context.go('/calendar');
case 2:
context.go('/mood');
context.go('/discover');
case 3:
context.go('/search');
case 4:
context.go('/profile');
}
},
body: child,
floatingActionButton: selectedIndex == 0
? FloatingActionButton(
onPressed: () => context.go('/editor'),
child: const Icon(Icons.edit_rounded),
)
: null,
onCenterButtonPressed: () => context.push('/editor'),
);
}
}

View File

@@ -1,5 +1,6 @@
// 暖记色彩系统 — 7 色 × 浅色/深色模式
// 设计规格 v1.2
// 暖记色彩系统 — 完整设计 Token
// 对齐 Open Design 原型稿 tokens.css
// 浅色(暖阳) + 深色 + 松风主题色值
import 'package:flutter/material.dart';
@@ -7,7 +8,7 @@ import 'package:flutter/material.dart';
class AppColors {
AppColors._();
// ===== 浅色模式 =====
// ===== 核心七色 · 浅色模式(暖阳 Warm Sun=====
/// 奶油白背景 #FFF8F0
static const Color bgLight = Color(0xFFFFF8F0);
@@ -30,7 +31,45 @@ class AppColors {
/// 玫瑰粉 #D4A5A5
static const Color rose = Color(0xFFD4A5A5);
// ===== 色模式 =====
// ===== 中间色 · 浅色模式 =====
/// 次要文字 #5C4F47
static const Color fg2Light = Color(0xFF5C4F47);
/// 柔和/禁用文字 #7A6D63
static const Color mutedLight = Color(0xFF7A6D63);
/// 辅助说明文字 #8B7E74
static const Color metaLight = Color(0xFF8B7E74);
/// 边框 #E8DDD4
static const Color borderLight = Color(0xFFE8DDD4);
/// 柔和边框 #F0E8DF
static const Color borderSoftLight = Color(0xFFF0E8DF);
/// 主色悬停 #D06A4F
static const Color accentHover = Color(0xFFD06A4F);
/// 主色按下 #C05A3F
static const Color accentActive = Color(0xFFC05A3F);
/// 主色辉光 rgba(224,122,95,0.25)
static const Color accentGlow = Color(0x40E07A5F);
/// 温暖表面 #FFF3E6
static const Color surfaceWarmLight = Color(0xFFFFF3E6);
/// 鼠尾草绿柔和 #D4E8DC
static const Color secondarySoftLight = Color(0xFFD4E8DC);
/// 暖金柔和 #FBE8C8
static const Color tertiarySoftLight = Color(0xFFFBE8C8);
/// 玫瑰柔和 #F0DADA
static const Color roseSoftLight = Color(0xFFF0DADA);
// ===== 核心七色 · 深色模式 =====
/// 深色背景 #1A1614
static const Color bgDark = Color(0xFF1A1614);
@@ -53,29 +92,98 @@ class AppColors {
/// 深色玫瑰 #C4A0A0
static const Color roseDark = Color(0xFFC4A0A0);
// ===== 功能色 =====
// ===== 中间色 · 深色模式 =====
/// 错误红
static const Color error = Color(0xFFD32F2F);
/// 深色次要文字 #C4B8AA
static const Color fg2Dark = Color(0xFFC4B8AA);
/// 成功绿
static const Color success = Color(0xFF4CAF50);
/// 深色柔和文字 #9B8E82
static const Color mutedDark = Color(0xFF9B8E82);
/// 警告黄
static const Color warning = Color(0xFFFFA726);
/// 深色辅助文字 #7A6D63
static const Color metaDark = Color(0xFF7A6D63);
/// 信息蓝
/// 深色边框 #3A3530
static const Color borderDark = Color(0xFF3A3530);
/// 深色柔和边框 #302B26
static const Color borderSoftDark = Color(0xFF302B26);
/// 深色主色悬停 #D07A64
static const Color accentHoverDark = Color(0xFFD07A64);
/// 深色主色按下 #C06A54
static const Color accentActiveDark = Color(0xFFC06A54);
/// 深色主色辉光
static const Color accentGlowDark = Color(0x40E8907A);
/// 深色温暖表面 #332D28
static const Color surfaceWarmDark = Color(0xFF332D28);
/// 深色鼠尾草绿柔和 #2A3A2E
static const Color secondarySoftDark = Color(0xFF2A3A2E);
/// 深色暖金柔和 #302A1E
static const Color tertiarySoftDark = Color(0xFF302A1E);
/// 深色玫瑰柔和 #3A2A2A
static const Color roseSoftDark = Color(0xFF3A2A2A);
// ===== 功能色(对齐设计稿)=====
/// 错误/危险 #C93D3D
static const Color error = Color(0xFFC93D3D);
/// 成功 #5A9E7E
static const Color success = Color(0xFF5A9E7E);
/// 警告 #D4A843
static const Color warning = Color(0xFFD4A843);
/// 信息 #42A5F5
static const Color info = Color(0xFF42A5F5);
// ===== 心情颜色映射 =====
// ===== 功能色 · 深色模式 =====
/// 心情 → 颜色
/// 深色错误 #D94A4A
static const Color errorDark = Color(0xFFD94A4A);
/// 深色成功 #6AAF8E
static const Color successDark = Color(0xFF6AAF8E);
/// 深色警告 #C4A843
static const Color warningDark = Color(0xFFC4A843);
// ===== 阴影色调 =====
/// 浅色阴影 rgba(45,36,32,...)
static const Color shadowLight = Color(0xFF2D2420);
/// 深色阴影 rgba(0,0,0,...)
static const Color shadowDark = Color(0xFF000000);
// ===== 心情颜色映射 =====
// 对齐 spec §2.8 mood-selector: happy/calm/sad/angry/thinking
// 对齐 spec §3.6 calendar mood-dot 颜色(开心=secondary, 平静=tertiary, 难过=#5B7DB1
/// 心情 → 颜色(主色,用于心情选择器圆圈/标签)
static const Map<String, Color> moodColors = {
'happy': Color(0xFFFFD93D), // 😊 开心 — 暖黄
'calm': Color(0xFF81B29A), // 😌 平静 — 鼠尾草绿
'sad': Color(0xFF7B9CC4), // 😢 难过 — 灰蓝
'angry': Color(0xFFE07A5F), // 😠 生气 — 珊瑚
'thinking': Color(0xFFB8A9C9),// 🤔 思考 — 淡紫
'happy': secondary, // 😊 开心 — 鼠尾草绿 #81B29A
'calm': tertiary, // 😌 平静 — 暖金 #F2CC8F
'sad': Color(0xFF5B7DB1), // 😢 难过 — 灰蓝
'angry': accent, // 😠 生气 — 珊瑚 #E07A5F
'thinking': metaLight, // 🤔 思考 — 灰棕 #8B7E74替代原先的淡紫spec 无淡紫)
};
/// 心情 → 日历单元格背景色
/// key 必须与 Mood 枚举值一致: happy/calm/sad/angry/thinking
static const Map<String, Color> moodCellColors = {
'happy': secondarySoftLight, // 😊 开心 — 鼠尾草绿 #D4E8DC
'calm': tertiarySoftLight, // 😌 平静 — 暖金 #FBE8C8
'sad': Color(0xFFD4DDE8), // 😢 难过 — 灰蓝
'angry': Color(0xFFFFE0D6), // 😠 生气 — 暖珊瑚 (与 primaryContainer 一致)
'thinking': Color(0xFFE8E4E0), // 🤔 思考 — 灰棕
};
// ===== 浅色主题色彩方案 =====
@@ -83,12 +191,12 @@ class AppColors {
static const _light = ColorScheme(
brightness: Brightness.light,
primary: accent,
onPrimary: Colors.white,
onPrimary: Color(0xFFFFF8F0), // accent-on
primaryContainer: Color(0xFFFFE0D6),
onPrimaryContainer: fgLight,
secondary: secondary,
onSecondary: Colors.white,
secondaryContainer: Color(0xFFD4E8DC),
secondaryContainer: secondarySoftLight,
onSecondaryContainer: fgLight,
tertiary: tertiary,
onTertiary: fgLight,
@@ -96,6 +204,9 @@ class AppColors {
onError: Colors.white,
surface: surfaceLight,
onSurface: fgLight,
onSurfaceVariant: mutedLight, // 次要文字
outline: borderLight, // 边框
outlineVariant: borderSoftLight, // 柔和边框
surfaceContainerHighest: bgLight,
);
@@ -104,19 +215,22 @@ class AppColors {
static const _dark = ColorScheme(
brightness: Brightness.dark,
primary: accentDark,
onPrimary: fgDark,
onPrimary: Color(0xFF1A1614), // accent-on dark
primaryContainer: Color(0xFF5A2E22),
onPrimaryContainer: Color(0xFFFFD6CC),
secondary: secondaryDark,
onSecondary: fgDark,
secondaryContainer: Color(0xFF2A4A38),
secondaryContainer: secondarySoftDark,
onSecondaryContainer: Color(0xFFD4E8DC),
tertiary: tertiaryDark,
onTertiary: fgDark,
error: Color(0xFFEF5350),
error: errorDark,
onError: fgDark,
surface: surfaceDark,
onSurface: fgDark,
onSurfaceVariant: mutedDark,
outline: borderDark,
outlineVariant: borderSoftDark,
surfaceContainerHighest: bgDark,
);

View File

@@ -1,12 +1,12 @@
// 暖记圆角系统
// 设计规格: 10 / 16 / 22 / 28 / pill
// 对齐 Open Design 原型稿 tokens.css: sm(10) / md(16) / lg(22) / xl(28) / pill
import 'package:flutter/material.dart';
class AppRadius {
AppRadius._();
/// 小圆角 10px — 按钮、输入框
/// 小圆角 10px — 按钮、输入框、小元素
static const double sm = 10;
static BorderRadius get smBorder => BorderRadius.circular(sm);

View File

@@ -1,4 +1,8 @@
// 暖记阴影系统 — soft / medium / float
// 对齐 spec §1 阴影 token:
// --elev-soft: 0 2px 12px rgba(45,36,32,0.06)
// --elev-medium: 0 4px 20px rgba(45,36,32,0.08)
// --elev-float: 0 8px 32px rgba(45,36,32,0.12)
import 'package:flutter/material.dart';
@@ -12,9 +16,9 @@ class AppShadows {
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: const Color(0xFF2D2420).withValues(alpha: 0.08),
: const Color(0xFF2D2420).withValues(alpha: 0.06),
offset: const Offset(0, 2),
blurRadius: 8,
blurRadius: 12,
),
];
}
@@ -26,9 +30,9 @@ class AppShadows {
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.4)
: const Color(0xFF2D2420).withValues(alpha: 0.12),
: const Color(0xFF2D2420).withValues(alpha: 0.08),
offset: const Offset(0, 4),
blurRadius: 16,
blurRadius: 20,
),
];
}
@@ -40,9 +44,9 @@ class AppShadows {
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.5)
: const Color(0xFF2D2420).withValues(alpha: 0.16),
: const Color(0xFF2D2420).withValues(alpha: 0.12),
offset: const Offset(0, 8),
blurRadius: 24,
blurRadius: 32,
),
];
}

View File

@@ -1,4 +1,5 @@
// 暖记主题入口 — 浅色/深色 ThemeData
// 对齐 Open Design 原型稿设计系统
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';
@@ -44,7 +45,7 @@ class AppTheme {
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
color: colorScheme.outlineVariant,
),
),
color: colorScheme.surface,
@@ -102,7 +103,7 @@ class AppTheme {
bottomNavigationBarTheme: BottomNavigationBarThemeData(
type: BottomNavigationBarType.fixed,
selectedItemColor: colorScheme.primary,
unselectedItemColor: colorScheme.onSurface.withValues(alpha: 0.5),
unselectedItemColor: colorScheme.onSurfaceVariant,
backgroundColor: colorScheme.surface,
elevation: 8,
),
@@ -115,15 +116,15 @@ class AppTheme {
side: BorderSide.none,
),
// FloatingActionButton
// FloatingActionButton — 珊瑚色圆形凸起
floatingActionButtonTheme: FloatingActionButtonThemeData(
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
),
backgroundColor: isLight ? AppColors.accent : AppColors.accentDark,
foregroundColor: isLight ? const Color(0xFFFFF8F0) : const Color(0xFF1A1614),
shape: const CircleBorder(),
elevation: 4,
),
// Page transitions — 弹性曲线
// Page transitions — 弹性曲线 cubic-bezier(0.34, 1.56, 0.64, 1)
pageTransitionsTheme: PageTransitionsTheme(
builders: {
TargetPlatform.android: _WarmCurveBuilder(),

View File

@@ -1,4 +1,6 @@
// 暖记字体系统 — Noto Sans SC + Caveat
// 暖记字体系统 — Quicksand(标题) + Nunito(正文) + Caveat(手写装饰)
// 对齐 Open Design 原型稿 tokens.css
// 字体文件待下载,当前系统字体回退
import 'package:flutter/material.dart';
@@ -6,99 +8,102 @@ class AppTypography {
AppTypography._();
/// 字体族
static const String displayFont = 'Caveat'; // 手写风格(标题装饰
static const String bodyFont = 'NotoSansSC'; // 正文(中文优先
static const String monoFont = 'JetBrains Mono'; // 等宽(暂用系统回退
static const String displayFont = 'Quicksand'; // 标题显示(待下载,系统回退 sans-serif
static const String bodyFont = 'Nunito'; // 正文(待下载,系统回退 sans-serif
static const String handwrittenFont = 'Caveat'; // 手写装饰(已有字体文件
static const String cjkFont = 'NotoSansSC'; // CJK 回退(已有字体文件)
static const String monoFont = 'JetBrains Mono'; // 等宽(暂用系统回退)
/// 浅色主题文字主题
/// 字号对齐设计稿: xs=11, sm=13, base=15, md=17, lg=20, xl=24, 2xl=30, 3xl=38, 4xl=48
static TextTheme lightTextTheme() => TextTheme(
// 大标题 — 手写风格
// Display — Quicksand 标题(对应 text-3xl / text-4xl
displayLarge: TextStyle(
fontFamily: displayFont,
fontSize: 57,
fontSize: 48,
height: 1.12,
fontWeight: FontWeight.w700,
),
displayMedium: TextStyle(
fontFamily: displayFont,
fontSize: 45,
fontSize: 38,
height: 1.16,
fontWeight: FontWeight.w700,
),
displaySmall: TextStyle(
fontFamily: displayFont,
fontSize: 36,
height: 1.22,
fontSize: 30,
height: 1.2,
fontWeight: FontWeight.w700,
),
// 标题 — 正文衬线
// Headline — Nunito 标题(对应 text-xl / text-2xl
headlineLarge: TextStyle(
fontFamily: bodyFont,
fontSize: 32,
fontSize: 30,
height: 1.25,
fontWeight: FontWeight.w700,
),
headlineMedium: TextStyle(
fontFamily: bodyFont,
fontSize: 28,
fontSize: 24,
height: 1.29,
fontWeight: FontWeight.w600,
),
headlineSmall: TextStyle(
fontFamily: bodyFont,
fontSize: 24,
height: 1.33,
fontSize: 20,
height: 1.3,
fontWeight: FontWeight.w600,
),
// 副标题
// Title — Nunito 副标题(对应 text-md / text-base
titleLarge: TextStyle(
fontFamily: bodyFont,
fontSize: 22,
height: 1.27,
fontSize: 17,
height: 1.3,
fontWeight: FontWeight.w600,
),
titleMedium: TextStyle(
fontFamily: bodyFont,
fontSize: 16,
height: 1.5,
fontSize: 15,
height: 1.6,
fontWeight: FontWeight.w600,
),
titleSmall: TextStyle(
fontFamily: bodyFont,
fontSize: 14,
height: 1.43,
fontSize: 13,
height: 1.4,
fontWeight: FontWeight.w600,
),
// 正文
// Body — Nunito 正文(对应 text-base / text-sm
bodyLarge: TextStyle(
fontFamily: bodyFont,
fontSize: 16,
height: 1.5,
fontSize: 15,
height: 1.6,
fontWeight: FontWeight.w400,
),
bodyMedium: TextStyle(
fontFamily: bodyFont,
fontSize: 14,
height: 1.43,
fontSize: 15,
height: 1.6,
fontWeight: FontWeight.w400,
),
bodySmall: TextStyle(
fontFamily: bodyFont,
fontSize: 12,
height: 1.33,
fontSize: 13,
height: 1.6,
fontWeight: FontWeight.w400,
),
// 标签
// Label — Nunito 标签(对应 text-base / text-sm / text-xs
labelLarge: TextStyle(
fontFamily: bodyFont,
fontSize: 14,
height: 1.43,
fontSize: 15,
height: 1.6,
fontWeight: FontWeight.w500,
),
labelMedium: TextStyle(
fontFamily: bodyFont,
fontSize: 12,
height: 1.33,
fontSize: 13,
height: 1.6,
fontWeight: FontWeight.w500,
),
labelSmall: TextStyle(
@@ -109,6 +114,6 @@ class AppTypography {
),
);
/// 深色主题文字主题
/// 深色主题文字主题(与浅色共享字号,颜色由 ColorScheme 控制)
static TextTheme darkTextTheme() => lightTextTheme();
}

View File

@@ -0,0 +1,9 @@
// 文件下载 — 非 Web 平台 stub
//
// 非 Web 平台暂不支持文件下载,返回 false。
// Phase 2 扩展:使用 path_provider + File 实现。
/// 下载文件stub 实现)
Future<bool> downloadFile(String content, String filename, String mimeType) async {
return false;
}

View File

@@ -0,0 +1,21 @@
// 文件下载 — Web 平台实现
//
// 使用 dart:html 的 AnchorElement + Blob 触发浏览器下载。
// 通过 conditional import 自动选择此实现。
import 'dart:html' as html;
/// 下载文件Web 实现)
Future<bool> downloadFile(String content, String filename, String mimeType) async {
try {
final blob = html.Blob([content], mimeType);
final url = html.Url.createObjectUrlFromBlob(blob);
html.AnchorElement(href: url)
..setAttribute('download', filename)
..click();
html.Url.revokeObjectUrl(url);
return true;
} catch (e) {
return false;
}
}

View File

@@ -0,0 +1,23 @@
// 文件下载工具 — 跨平台接口
//
// Web: 通过 html.AnchorElement + Blob 触发浏览器下载
// 非 Web: 返回 falsePhase 2 扩展 path_provider
import 'dart:convert';
import 'download_impl.dart'
if (dart.library.html) 'download_impl_web.dart';
/// 下载 JSON 数据为文件
///
/// [data] — 要导出的 JSON 数据
/// [filename] — 下载文件名(如 "export_2026-06-02.json"
///
/// 返回 true 表示下载成功。
Future<bool> downloadJsonFile(
Map<String, dynamic> data,
String filename,
) async {
final jsonStr = const JsonEncoder.withIndent(' ').convert(data);
return downloadFile(jsonStr, filename, 'application/json');
}

View File

@@ -0,0 +1,22 @@
// 心情公共工具 — 统一 Mood 枚举的 emoji/标签映射
// 消除 calendar_page / mood_page / search_page / monthly_page 中的重复定义
import 'package:nuanji_app/data/models/journal_entry.dart';
/// 心情 → emoji
String moodToEmoji(Mood mood) => switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
/// 心情 → 中文标签
String moodToLabel(Mood mood) => switch (mood) {
Mood.happy => '开心',
Mood.calm => '平静',
Mood.sad => '难过',
Mood.angry => '生气',
Mood.thinking => '思考',
};

View File

@@ -0,0 +1,63 @@
// 日记元素 Isar Collection — 本地持久化存储
//
// 与纯 Dart 模型 JournalElement 分离,通过转换函数桥接。
// journalId 索引支持按日记查询所有元素。
import 'package:isar/isar.dart';
part 'journal_element_collection.g.dart';
@collection
class JournalElementCollection {
/// Isar 自增主键
Id isarId = Isar.autoIncrement;
/// 业务 UUID索引
@Index()
String id = '';
/// 所属日记 ID索引用于外键查询
@Index()
String journalId = '';
/// 元素类型enum → string: text/image/sticker/handwriting_ref/tape
String elementType = 'text';
/// X 坐标
double positionX = 0;
/// Y 坐标
double positionY = 0;
/// 宽度
double width = 100;
/// 高度
double height = 100;
/// 旋转角度
double rotation = 0;
/// 层级
int zIndex = 0;
/// 结构化内容JSON String
/// text: {'text':'...','fontSize':16.0}
/// image: {'filePath':'...'}
/// sticker: {'stickerPackId':'...','stickerId':'...'}
/// handwriting_ref: {'strokesJson':'...','strokeCount':42}
/// tape: {'tapeStyle':'washi_dots'}
String contentJson = '{}';
/// 版本号(乐观锁)
int version = 1;
/// 创建时间epoch milliseconds
int createdAtEpoch = 0;
/// 更新时间epoch milliseconds
int updatedAtEpoch = 0;
/// 软删除标记
bool isDeleted = false;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
// 日记条目 Isar Collection — 本地持久化存储
//
// 与纯 Dart 模型 JournalEntry 分离,通过转换函数桥接。
// 业务 ID (String UUID) 作为索引字段Isar 主键用 autoIncrement。
import 'package:isar/isar.dart';
part 'journal_entry_collection.g.dart';
@collection
class JournalEntryCollection {
/// Isar 自增主键
Id isarId = Isar.autoIncrement;
/// 业务 UUID索引用于查找
@Index()
String id = '';
/// 作者 ID索引 + 组合索引 authorId+dateEpoch覆盖按作者查询并按日期排序的场景
@Index(composite: [CompositeIndex('dateEpoch')])
String authorId = '';
/// 班级 ID可选
String? classId;
/// 日记标题
String title = '';
/// 日记日期epoch milliseconds— 单独索引支持日期范围查询
@Index()
int dateEpoch = 0;
/// 心情enum → string
String mood = 'calm';
/// 天气enum → string
String weather = 'sunny';
/// 标签列表JSON String
String tagsJson = '[]';
/// 是否私密
bool isPrivate = true;
/// 是否分享到班级
bool sharedToClass = false;
/// 关联主题 ID可选
String? assignedTopicId;
/// 内容摘要(自动从文本元素提取)
String? contentExcerpt;
/// 版本号(乐观锁)
int version = 1;
/// 创建时间epoch milliseconds
int createdAtEpoch = 0;
/// 更新时间epoch milliseconds
int updatedAtEpoch = 0;
/// 软删除标记
bool isDeleted = false;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
// 待同步操作 Isar Collection — SyncEngine 队列持久化
//
// 应用退出时将内存队列写入 Isar下次启动时恢复。
// 保证离线操作不会因进程终止而丢失。
import 'package:isar/isar.dart';
part 'pending_operation_collection.g.dart';
@collection
class PendingOperationCollection {
/// Isar 自增主键
Id isarId = Isar.autoIncrement;
/// 业务 UUID索引
@Index()
String id = '';
/// 操作类型create / update / delete
String operationType = 'create';
/// API 端点(如 '/diary/journals'
String endpoint = '';
/// 请求负载JSON String
String dataJson = '{}';
/// 资源版本号(乐观锁)
int version = 1;
/// 创建时间epoch milliseconds
int createdAtEpoch = 0;
/// 重试次数(最大 5 次)
int retryCount = 0;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,82 +1,14 @@
// Isar 数据库初始化 — 本地持久化存储
// Isar 数据库条件导出
//
// Isar 3.x 要求 open() 时传入 List<CollectionSchema> 位置参数。
// 由于我们使用手写不可变类而非 isar_generator 代码生成,
// 需要在调用 [init] 时传入 schema 列表。
// 当前阶段使用 [ensureInitialized] 占位,待后续添加 Isar Collection 后正式注册。
// 根据平台自动选择实现:
// - 原生平台 (Android/iOS/Desktop) → isar_database_native.dart
// - Web 平台 → isar_database_web.dart (空 stub)
//
// 条件导出逻辑:
// dart.library.io 存在 → 原生平台,使用 native 实现
// 否则Web→ 使用 web stub
//
// 使用方式不变import 'isar_database.dart';
// 用 IsarDatabase.isAvailable 判断平台可用性。
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
/// Isar 数据库单例管理
///
/// 使用方式Phase 1 — 无 schema 时):
/// ```dart
/// // 直接使用,不初始化 Isar内存仓库模式
/// ```
///
/// 使用方式Phase 2 — 有 schema 后):
/// ```dart
/// final isar = await IsarDatabase.init(schemas: [JournalEntrySchema]);
/// ```
class IsarDatabase {
IsarDatabase._();
static Isar? _instance;
static bool _initialized = false;
/// 是否已初始化
static bool get isInitialized => _initialized;
/// 初始化数据库(需在 app 启动时调用,传入所有 CollectionSchema
///
/// - [schemas]: Isar Collection Schema 列表(由 isar_generator 生成)
/// - 在应用文档目录下创建 isar 数据库文件
/// - 开发模式开启 inspectorflutter pub global run isar_inspector
static Future<Isar> init({
required List<CollectionSchema<dynamic>> schemas,
}) async {
if (_instance != null && _instance!.isOpen) return _instance!;
final dir = await getApplicationDocumentsDirectory();
_instance = await Isar.open(
schemas,
directory: dir.path,
inspector: true, // 开发模式,发布时关闭
);
_initialized = true;
return _instance!;
}
/// 获取 Isar 实例(必须先调用 [init]
///
/// 如果未初始化会抛出 [StateError]。
static Isar get instance {
if (_instance == null || !_instance!.isOpen) {
throw StateError(
'IsarDatabase 未初始化,请先调用 IsarDatabase.init(schemas: [...])',
);
}
return _instance!;
}
/// 关闭数据库连接
///
/// 通常只在应用退出时调用。
static Future<void> close() async {
if (_instance != null && _instance!.isOpen) {
await _instance!.close();
_instance = null;
_initialized = false;
}
}
/// 清空所有数据(仅用于测试)
static Future<void> clearAll() async {
if (_instance == null || !_instance!.isOpen) return;
await _instance!.writeTxn(() async {
// TODO: 清空所有 collection
});
}
}
export 'isar_database_web.dart' if (dart.library.io) 'isar_database_native.dart';

View File

@@ -0,0 +1,70 @@
// Isar 数据库初始化 — 原生平台实现 (Android/iOS/Desktop)
//
// 在原生平台上使用 Isar 3.x 本地数据库。
// Web 平台使用 isar_database_web.dart stub。
import 'package:isar/isar.dart';
import 'package:path_provider/path_provider.dart';
import 'collections/journal_entry_collection.dart';
import 'collections/journal_element_collection.dart';
import 'collections/pending_operation_collection.dart';
/// Isar 数据库单例管理(原生平台实现)
class IsarDatabase {
IsarDatabase._();
static Isar? _instance;
static bool _initialized = false;
/// 所有 Collection Schema由 build_runner 生成)
static final List<CollectionSchema<dynamic>> schemas = [
JournalEntryCollectionSchema,
JournalElementCollectionSchema,
PendingOperationCollectionSchema,
];
/// 是否已初始化
static bool get isInitialized => _initialized;
/// 原生平台 Isar 可用
static bool get isAvailable => true;
/// 初始化数据库
static Future<void> init() async {
if (_initialized) return;
final dir = await getApplicationDocumentsDirectory();
_instance = await Isar.open(
schemas,
directory: dir.path,
inspector: true, // 开发模式,发布时关闭
);
_initialized = true;
}
/// 获取 Isar 实例(必须先调用 [init]
static Isar get instance {
if (_instance == null || !_instance!.isOpen) {
throw StateError('IsarDatabase 未初始化,请先调用 IsarDatabase.init()');
}
return _instance!;
}
/// 关闭数据库连接
static Future<void> close() async {
if (_instance != null && _instance!.isOpen) {
await _instance!.close();
_instance = null;
_initialized = false;
}
}
/// 清空所有数据(仅用于测试)
static Future<void> clearAll() async {
if (_instance == null || !_instance!.isOpen) return;
await _instance!.writeTxn(() async {
await _instance!.clear();
});
}
}

View File

@@ -0,0 +1,31 @@
// Isar 数据库初始化 — Web 平台 stub
//
// Isar 3.x 不支持 Web此文件提供空实现。
// 原生平台使用 isar_database_native.dart。
/// Isar 数据库单例管理Web 平台空实现)
class IsarDatabase {
IsarDatabase._();
static bool _initialized = false;
/// 是否已初始化
static bool get isInitialized => _initialized;
/// Web 平台 Isar 不可用
static bool get isAvailable => false;
/// Web 平台:跳过初始化
static Future<void> init() async {
_initialized = true;
}
/// Web 平台:返回 null
static Type? get instance => null;
/// Web 平台:无操作
static Future<void> close() async {}
/// Web 平台:无操作
static Future<void> clearAll() async {}
}

View File

@@ -0,0 +1,18 @@
// 安全令牌存储接口 — 平台条件导出
//
// 原生平台使用 flutter_secure_storage加密存储PIPL 合规)
// Web 平台使用 shared_preferences浏览器本地存储
//
// 统一接口read / write / delete
/// 安全令牌存储接口
abstract class SecureTokenStore {
/// 读取值
Future<String?> read(String key);
/// 写入值
Future<void> write(String key, String value);
/// 删除值
Future<void> delete(String key);
}

View File

@@ -0,0 +1,17 @@
// 安全令牌存储 — 工厂函数
//
// 根据平台创建对应的 SecureTokenStore 实现。
// 运行时判断 kIsWeb避免 Web 编译时加载 flutter_secure_storage。
import 'secure_token_store.dart';
import 'secure_token_store_web.dart';
/// 创建平台对应的 SecureTokenStore 实例
///
/// Web 平台 → WebSecureTokenStore (shared_preferences)
/// 原生平台 → WebSecureTokenStore (shared_preferences临时方案)
///
/// TODO: flutter_secure_storage 升级到 v10+ 后恢复 NativeSecureTokenStore
SecureTokenStore createSecureTokenStore() {
return WebSecureTokenStore();
}

View File

@@ -0,0 +1,37 @@
// 安全令牌存储 — 原生平台实现shared_preferences
//
// 临时使用 shared_preferences 替代 flutter_secure_storage。
// flutter_secure_storage v9 的 web 插件不兼容 Flutter 3.44
// 待其升级到 v10+ 后恢复加密存储。
// TODO: 恢复 flutter_secure_storage 加密存储
import 'package:shared_preferences/shared_preferences.dart';
import 'secure_token_store.dart';
/// 原生平台安全令牌存储(临时使用 shared_preferences
class NativeSecureTokenStore implements SecureTokenStore {
SharedPreferences? _prefs;
Future<SharedPreferences> get _instance async {
return _prefs ??= await SharedPreferences.getInstance();
}
@override
Future<String?> read(String key) async {
final prefs = await _instance;
return prefs.getString(key);
}
@override
Future<void> write(String key, String value) async {
final prefs = await _instance;
await prefs.setString(key, value);
}
@override
Future<void> delete(String key) async {
final prefs = await _instance;
await prefs.remove(key);
}
}

View File

@@ -0,0 +1,36 @@
// 安全令牌存储 — Web 平台实现shared_preferences
//
// Web 平台上 flutter_secure_storage 不可用dart:html 已弃用),
// 使用 shared_preferences 作为替代。
// 注意Web 端存储不加密,但浏览器本身提供 HTTPS 传输安全。
import 'package:shared_preferences/shared_preferences.dart';
import 'secure_token_store.dart';
/// Web 平台安全令牌存储shared_preferences
class WebSecureTokenStore implements SecureTokenStore {
SharedPreferences? _prefs;
Future<SharedPreferences> get _instance async {
return _prefs ??= await SharedPreferences.getInstance();
}
@override
Future<String?> read(String key) async {
final prefs = await _instance;
return prefs.getString(key);
}
@override
Future<void> write(String key, String value) async {
final prefs = await _instance;
await prefs.setString(key, value);
}
@override
Future<void> delete(String key) async {
final prefs = await _instance;
await prefs.remove(key);
}
}

View File

@@ -1,6 +1,7 @@
// 日记元素数据模型 — 手写不可变类(避免 build_runner 依赖)
import 'dart:collection';
import 'dart:ui';
import 'package:uuid/uuid.dart';
@@ -153,4 +154,65 @@ class JournalElement {
updatedAt: now,
);
}
/// 文字元素便利工厂
factory JournalElement.createText({
required String journalId,
required String text,
required Offset position,
double fontSize = 18.0,
String fontColor = '#2D2420',
}) {
return JournalElement.create(
journalId: journalId,
elementType: ElementType.text,
positionX: position.dx,
positionY: position.dy,
content: {
'text': text,
'fontSize': fontSize,
'fontColor': fontColor,
},
);
}
/// 图片元素便利工厂
factory JournalElement.createImage({
required String journalId,
required String filePath,
required Offset position,
String? thumbnailPath,
}) {
return JournalElement.create(
journalId: journalId,
elementType: ElementType.image,
positionX: position.dx,
positionY: position.dy,
content: {
'filePath': filePath,
if (thumbnailPath != null) 'thumbnailPath': thumbnailPath,
},
);
}
/// 贴纸元素便利工厂
factory JournalElement.createSticker({
required String journalId,
required String emoji,
required Offset position,
String? stickerPackId,
String? stickerId,
}) {
return JournalElement.create(
journalId: journalId,
elementType: ElementType.sticker,
positionX: position.dx,
positionY: position.dy,
content: {
'emoji': emoji,
if (stickerPackId != null) 'stickerPackId': stickerPackId,
if (stickerId != null) 'stickerId': stickerId,
},
);
}
}

View File

@@ -42,6 +42,10 @@ class JournalEntry {
final bool isPrivate;
final bool sharedToClass;
final String? assignedTopicId;
/// 内容摘要 — 自动从文本元素提取,用于列表预览
final String? contentExcerpt;
final int version;
final DateTime createdAt;
final DateTime updatedAt;
@@ -58,6 +62,7 @@ class JournalEntry {
this.isPrivate = true,
this.sharedToClass = false,
this.assignedTopicId,
this.contentExcerpt,
this.version = 1,
required this.createdAt,
required this.updatedAt,
@@ -77,6 +82,8 @@ class JournalEntry {
bool? sharedToClass,
String? assignedTopicId,
bool clearAssignedTopicId = false,
String? contentExcerpt,
bool clearContentExcerpt = false,
int? version,
DateTime? createdAt,
DateTime? updatedAt,
@@ -94,6 +101,9 @@ class JournalEntry {
sharedToClass: sharedToClass ?? this.sharedToClass,
assignedTopicId:
clearAssignedTopicId ? null : (assignedTopicId ?? this.assignedTopicId),
contentExcerpt: clearContentExcerpt
? null
: (contentExcerpt ?? this.contentExcerpt),
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
@@ -111,6 +121,7 @@ class JournalEntry {
'is_private': isPrivate,
'shared_to_class': sharedToClass,
'assigned_topic_id': assignedTopicId,
'content_excerpt': contentExcerpt,
'version': version,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
@@ -134,6 +145,7 @@ class JournalEntry {
isPrivate: (json['is_private'] as bool?) ?? true,
sharedToClass: (json['shared_to_class'] as bool?) ?? false,
assignedTopicId: json['assigned_topic_id'] as String?,
contentExcerpt: json['content_excerpt'] as String?,
version: (json['version'] as int?) ?? 1,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),

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

@@ -1,14 +1,17 @@
// API 客户端 — Dio 封装 + JWT 注入 + 离线感知
// API 客户端 — Dio 封装 + JWT 注入 + Token 自动刷新 + 离线感知
//
// 核心职责:
// - 封装 Dio HTTP 客户端,统一配置超时和头信息
// - JWT token 自动注入(请求拦截器)
// - 401 自动刷新 token + 重试原请求(审计 9a-AUTH-01
// - 离线状态感知(网络不可用时抛出明确异常)
// - 为 SyncEngine 提供远程操作能力
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;
@@ -26,7 +29,27 @@ class ApiClient {
/// 基础 URL默认指向本地开发服务器
final String baseUrl;
ApiClient({this.baseUrl = 'http://localhost:8080/api/v1'}) {
/// Token 刷新回调 — 由 AuthRepository 在构造后注册
///
/// 返回新的 access token失败返回 null。
/// 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖。
Future<String?> Function()? onRefreshToken;
/// 认证彻底失败回调 — 刷新 token 失败后由 app.dart 注册
///
/// 通知 AuthBloc 派发 AuthExpired 事件,触发路由重定向到登录页。
/// 解决审计 9a-AUTH-01刷新失败时用户不会被留在死页面。
void Function()? onAuthFailed;
/// 是否正在刷新 token防止并发 401 触发多次刷新)
bool _isRefreshing = false;
/// 创建 API 客户端
///
/// [baseUrl] 默认使用 HTTPS 生产地址。
/// 开发环境可通过构造参数覆盖为 http://localhost:3000/api/v1
/// Android 网络安全配置已允许 localhost 明文)。
ApiClient({this.baseUrl = 'https://api.nuanji.app/api/v1'}) {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
@@ -48,12 +71,39 @@ class ApiClient {
},
));
// 响应拦截器:统一错误处理
// 响应拦截器:401 自动刷新 token + 重试
_dio.interceptors.add(InterceptorsWrapper(
onError: (error, handler) {
// 401 时自动清除 token需要重新登录
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
// 不对刷新端点本身重试(避免无限循环)
final isRefreshRequest =
error.requestOptions.path.endsWith('/auth/refresh');
if (!isRefreshRequest &&
onRefreshToken != null &&
!_isRefreshing) {
_isRefreshing = true;
try {
final newToken = await onRefreshToken!();
if (newToken != null) {
_token = newToken;
// 用新 token 重试原始请求
error.requestOptions.headers['Authorization'] =
'Bearer $newToken';
_isRefreshing = false;
return handler.resolve(
await _dio.fetch(error.requestOptions),
);
}
} catch (_) {
// 刷新失败,继续走 401 逻辑
}
_isRefreshing = false;
}
// 刷新失败或无刷新回调 → 清除 token通知全局认证失效
_token = null;
onAuthFailed?.call();
}
handler.next(error);
},
@@ -146,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

@@ -2,14 +2,14 @@
//
// 职责:
// - 封装后端认证 API 调用(登录/注册/刷新令牌/登出)
// - 通过 flutter_secure_storage 安全持久化 JWT 令牌PIPL 合规)
// - 通过 SecureTokenStore 安全持久化 JWT 令牌PIPL 合规)
// - 为 AuthBloc 提供干净的认证数据访问接口
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:logger/logger.dart';
import '../local/secure_token_store.dart';
import '../models/auth_token.dart';
import '../models/user.dart';
import '../remote/api_client.dart';
@@ -33,11 +33,11 @@ class AuthException implements Exception {
/// 认证仓库 — 管理用户登录状态和令牌
///
/// 使用 [ApiClient] 与后端通信,使用 [FlutterSecureStorage] 持久化令牌。
/// 所有令牌操作都是加密存储,满足儿童数据 PIPL 合规要求
/// 使用 [ApiClient] 与后端通信,使用 [SecureTokenStore] 持久化令牌。
/// 原生平台使用加密存储Web 平台使用 shared_preferences
class AuthRepository {
final ApiClient _apiClient;
final FlutterSecureStorage _secureStorage;
final SecureTokenStore _tokenStore;
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
AuthToken? _currentToken;
@@ -45,13 +45,12 @@ class AuthRepository {
AuthRepository({
required ApiClient apiClient,
FlutterSecureStorage? secureStorage,
required SecureTokenStore tokenStore,
}) : _apiClient = apiClient,
_secureStorage = secureStorage ??
const FlutterSecureStorage(
aOptions: AndroidOptions(encryptedSharedPreferences: true),
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
_tokenStore = tokenStore {
// 注册 token 自动刷新回调 — 401 时 ApiClient 自动调用
_apiClient.onRefreshToken = _handleAutoRefresh;
}
/// 当前用户(可能为 null
User? get currentUser => _currentUser;
@@ -167,10 +166,10 @@ class AuthRepository {
_logger.d('恢复认证状态');
try {
final accessToken = await _secureStorage.read(key: _keyAccessToken);
final refreshTokenStr = await _secureStorage.read(key: _keyRefreshToken);
final expiresAtStr = await _secureStorage.read(key: _keyExpiresAt);
final userJsonStr = await _secureStorage.read(key: _keyUserJson);
final accessToken = await _tokenStore.read(_keyAccessToken);
final refreshTokenStr = await _tokenStore.read(_keyRefreshToken);
final expiresAtStr = await _tokenStore.read(_keyExpiresAt);
final userJsonStr = await _tokenStore.read(_keyUserJson);
if (accessToken == null || refreshTokenStr == null || userJsonStr == null) {
_logger.d('无存储的认证信息');
@@ -219,6 +218,33 @@ class AuthRepository {
}
}
// ===== Token 自动刷新 =====
/// ApiClient 401 拦截器调用的自动刷新处理
///
/// 使用 refresh_token 获取新 access_token更新 ApiClient 的 token
/// 返回新 access_token失败返回 null
Future<String?> _handleAutoRefresh() async {
if (_currentToken == null) return null;
_logger.d('自动刷新令牌401 触发)');
try {
final response = await _apiClient.post('/auth/refresh', data: {
'refresh_token': _currentToken!.refreshToken,
});
final data = _extractData(response.data);
final token = AuthToken.fromJson(data);
await _saveToken(token);
_logger.i('自动刷新令牌成功');
return token.accessToken;
} catch (e) {
_logger.w('自动刷新令牌失败: $e');
return null;
}
}
// ===== 私有方法 =====
/// 从 API 响应中提取 data 字段
@@ -238,27 +264,27 @@ class AuthRepository {
_currentToken = token;
_currentUser = user;
await _saveToken(token);
await _secureStorage.write(
key: _keyUserJson,
value: jsonEncode(user.toJson()),
await _tokenStore.write(
_keyUserJson,
jsonEncode(user.toJson()),
);
}
/// 仅保存令牌到安全存储
Future<void> _saveToken(AuthToken token) async {
_currentToken = token;
await _secureStorage.write(key: _keyAccessToken, value: token.accessToken);
await _secureStorage.write(key: _keyRefreshToken, value: token.refreshToken);
await _secureStorage.write(key: _keyExpiresAt, value: token.expiresAt.toIso8601String());
await _tokenStore.write(_keyAccessToken, token.accessToken);
await _tokenStore.write(_keyRefreshToken, token.refreshToken);
await _tokenStore.write(_keyExpiresAt, token.expiresAt.toIso8601String());
}
/// 清除所有认证数据
Future<void> _clearAuth() async {
_currentToken = null;
_currentUser = null;
await _secureStorage.delete(key: _keyAccessToken);
await _secureStorage.delete(key: _keyRefreshToken);
await _secureStorage.delete(key: _keyExpiresAt);
await _secureStorage.delete(key: _keyUserJson);
await _tokenStore.delete(_keyAccessToken);
await _tokenStore.delete(_keyRefreshToken);
await _tokenStore.delete(_keyExpiresAt);
await _tokenStore.delete(_keyUserJson);
}
}

View File

@@ -0,0 +1,201 @@
// 班级仓库 — 通过 API 客户端管理班级、主题、评语
import '../models/school_class.dart';
import '../remote/api_client.dart';
/// 班级成员数据
class ClassMemberDto {
final String userId;
final String role;
final String? nickname;
final DateTime joinedAt;
const ClassMemberDto({
required this.userId,
required this.role,
this.nickname,
required this.joinedAt,
});
factory ClassMemberDto.fromJson(Map<String, dynamic> json) => ClassMemberDto(
userId: json['user_id'] as String,
role: json['role'] as String,
nickname: json['nickname'] as String?,
joinedAt: DateTime.parse(json['joined_at'] as String),
);
}
/// 主题布置数据
class TopicDto {
final String id;
final String classId;
final String teacherId;
final String title;
final String? description;
final DateTime? dueDate;
final bool isActive;
const TopicDto({
required this.id,
required this.classId,
required this.teacherId,
required this.title,
this.description,
this.dueDate,
this.isActive = true,
});
factory TopicDto.fromJson(Map<String, dynamic> json) => TopicDto(
id: json['id'] as String,
classId: json['class_id'] as String,
teacherId: json['teacher_id'] as String,
title: json['title'] as String,
description: json['description'] as String?,
dueDate: json['due_date'] != null
? DateTime.parse(json['due_date'] as String)
: null,
isActive: (json['is_active'] as bool?) ?? true,
);
}
/// 评语数据
class CommentDto {
final String id;
final String journalId;
final String authorId;
final String content;
final DateTime createdAt;
const CommentDto({
required this.id,
required this.journalId,
required this.authorId,
required this.content,
required this.createdAt,
});
factory CommentDto.fromJson(Map<String, dynamic> json) => CommentDto(
id: json['id'] as String,
journalId: json['journal_id'] as String,
authorId: json['author_id'] as String,
content: json['content'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
/// 班级仓库 — 班级/主题/评语的 API 操作
class ClassRepository {
final ApiClient _api;
ClassRepository({required ApiClient api}) : _api = api;
// ===== 班级 =====
/// 获取我加入的班级列表
Future<List<SchoolClass>> getMyClasses() async {
final response = await _api.get('/diary/classes');
final body = response.data as Map<String, dynamic>;
final items = body['data'] as List? ?? [];
return items
.map((json) => SchoolClass.fromJson(json as Map<String, dynamic>))
.toList();
}
/// 创建班级(老师)
Future<SchoolClass> createClass({
required String name,
String? schoolName,
}) async {
final response = await _api.post('/diary/classes', data: {
'name': name,
if (schoolName != null) 'school_name': schoolName,
});
final body = response.data as Map<String, dynamic>;
return SchoolClass.fromJson(body['data'] as Map<String, dynamic>);
}
/// 加入班级(通过班级码)
Future<SchoolClass> joinClass(String classCode, {String? nickname}) async {
final response = await _api.post('/diary/classes/join', data: {
'class_code': classCode,
if (nickname != null) 'nickname': nickname,
});
final body = response.data as Map<String, dynamic>;
return SchoolClass.fromJson(body['data'] as Map<String, dynamic>);
}
/// 获取班级详情
Future<SchoolClass> getClass(String classId) async {
final response = await _api.get('/diary/classes/$classId');
final body = response.data as Map<String, dynamic>;
return SchoolClass.fromJson(body['data'] as Map<String, dynamic>);
}
/// 获取班级成员列表
Future<List<ClassMemberDto>> getMembers(String classId) async {
final response = await _api.get('/diary/classes/$classId/members');
final body = response.data as Map<String, dynamic>;
final items = body['data'] as List? ?? [];
return items
.map((json) => ClassMemberDto.fromJson(json as Map<String, dynamic>))
.toList();
}
// ===== 主题 =====
/// 获取班级主题列表
Future<List<TopicDto>> getTopics(String classId) async {
final response = await _api.get('/diary/classes/$classId/topics');
final body = response.data as Map<String, dynamic>;
final items = body['data'] as List? ?? [];
return items
.map((json) => TopicDto.fromJson(json as Map<String, dynamic>))
.toList();
}
/// 布置主题(老师)
Future<TopicDto> assignTopic({
required String classId,
required String title,
String? description,
DateTime? dueDate,
}) async {
final response = await _api.post('/diary/classes/$classId/topics', data: {
'title': title,
if (description != null) 'description': description,
if (dueDate != null) 'due_date': dueDate.toIso8601String(),
});
final body = response.data as Map<String, dynamic>;
return TopicDto.fromJson(body['data'] as Map<String, dynamic>);
}
// ===== 评语 =====
/// 获取日记评语列表
Future<List<CommentDto>> getComments(String journalId) async {
final response = await _api.get('/diary/journals/$journalId/comments');
final body = response.data as Map<String, dynamic>;
final items = body['data'] as List? ?? [];
return items
.map((json) => CommentDto.fromJson(json as Map<String, dynamic>))
.toList();
}
/// 添加评语(老师点评学生日记)
Future<CommentDto> createComment({
required String journalId,
required String content,
}) async {
final response = await _api.post(
'/diary/journals/$journalId/comments',
data: {'content': content},
);
final body = response.data as Map<String, dynamic>;
return CommentDto.fromJson(body['data'] as Map<String, dynamic>);
}
/// 删除评语
Future<void> deleteComment(String commentId) async {
await _api.delete('/diary/comments/$commentId');
}
}

View File

@@ -0,0 +1,7 @@
// Isar 本地日记仓库 — 条件导出
//
// 根据平台选择实现:
// - 原生平台 → isar_journal_repository_native.dartIsar 本地数据库)
// - Web 平台 → isar_journal_repository_web.dart空 stub应使用 RemoteJournalRepository
export 'isar_journal_repository_web.dart' if (dart.library.io) 'isar_journal_repository_native.dart';

View File

@@ -0,0 +1,370 @@
// Isar 本地日记仓库 — 本地优先数据存储
//
// 实现 JournalRepository 抽象接口,所有数据存储在 Isar 本地数据库。
// 核心逻辑参考 InMemoryJournalRepository替换内存 Map 为 Isar 查询。
//
// 转换层:
// - JournalEntry ↔ JournalEntryCollection通过 toCollection/fromCollection
// - JournalElement ↔ JournalElementCollection通过 toCollection/fromCollection
import 'dart:async';
import 'dart:convert';
import 'package:isar/isar.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';
import '../models/journal_element.dart';
import 'journal_repository.dart';
/// Isar 本地日记仓库 — JournalRepository 的 Isar 实现
class IsarJournalRepository implements JournalRepository {
Isar get _isar => IsarDatabase.instance;
final StreamController<void> _changeController = StreamController<void>.broadcast();
@override
Stream<void> get onJournalChanged => _changeController.stream;
// ============================================================
// 日记 CRUD
// ============================================================
@override
Future<List<JournalEntry>> getJournals({
DateTime? dateFrom,
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
String? classId,
}) async {
var query = _isar.journalEntryCollections
.where()
.filter()
.isDeletedEqualTo(false);
// 日期范围过滤
if (dateFrom != null) {
query = query.and().dateEpochGreaterThan(dateFrom.millisecondsSinceEpoch);
}
if (dateTo != null) {
query = query.and().dateEpochLessThan(dateTo.millisecondsSinceEpoch);
}
// 心情过滤
if (mood != null) {
query = query.and().moodEqualTo(mood);
}
// 标签过滤Isar tagsJson 字段存储 JSON 数组,用 contains 匹配
if (tag != null) {
query = query.and().tagsJsonContains(tag);
}
// 班级过滤
if (classId != null) {
query = query.and().classIdEqualTo(classId);
}
// 按日期降序排列 + DB 层分页(替代全量加载后 Dart 层 sublist
if (page != null && pageSize != null) {
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();
}
@override
Future<int> getJournalCount() async {
return _isar.journalEntryCollections
.where()
.filter()
.isDeletedEqualTo(false)
.count();
}
@override
Future<JournalEntry?> getJournal(String id) async {
final col = await _isar.journalEntryCollections
.where()
.filter()
.idEqualTo(id)
.and()
.isDeletedEqualTo(false)
.findFirst();
if (col == null) return null;
return _fromCollection(col);
}
@override
Future<JournalEntry> createJournal(JournalEntry entry) async {
final col = _toEntryCollection(entry);
await _isar.writeTxn(() async {
await _isar.journalEntryCollections.put(col);
});
_changeController.add(null);
return entry;
}
@override
Future<JournalEntry> updateJournal(JournalEntry entry) async {
final existing = await _isar.journalEntryCollections
.where()
.filter()
.idEqualTo(entry.id)
.and()
.isDeletedEqualTo(false)
.findFirst();
if (existing == null) {
throw StateError('日记不存在: ${entry.id}');
}
// 乐观锁冲突检测
if (existing.version != entry.version) {
throw StateError(
'版本冲突: 本地版本 ${entry.version}, 存储版本 ${existing.version}',
);
}
final updated = entry.copyWith(
version: entry.version + 1,
updatedAt: DateTime.now(),
);
final col = _toEntryCollection(updated);
col.isarId = existing.isarId; // 保留 Isar 主键
await _isar.writeTxn(() async {
await _isar.journalEntryCollections.put(col);
});
_changeController.add(null);
return updated;
}
@override
Future<void> deleteJournal(String id) async {
final existing = await _isar.journalEntryCollections
.where()
.filter()
.idEqualTo(id)
.findFirst();
if (existing == null) return;
// 软删除日记
existing.isDeleted = true;
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
// 软删除关联元素
final elements = await _isar.journalElementCollections
.where()
.filter()
.journalIdEqualTo(id)
.and()
.isDeletedEqualTo(false)
.findAll();
await _isar.writeTxn(() async {
await _isar.journalEntryCollections.put(existing);
for (final el in elements) {
el.isDeleted = true;
el.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
await _isar.journalElementCollections.put(el);
}
});
_changeController.add(null);
}
// ============================================================
// 元素 CRUD
// ============================================================
@override
Future<List<JournalElement>> getElements(String journalId) async {
final results = await _isar.journalElementCollections
.where()
.filter()
.journalIdEqualTo(journalId)
.and()
.isDeletedEqualTo(false)
.sortByZIndex()
.findAll();
return results.map(_fromElementCollection).toList();
}
@override
Future<JournalElement> addElement(JournalElement element) async {
final col = _toElementCollection(element);
await _isar.writeTxn(() async {
await _isar.journalElementCollections.put(col);
});
return element;
}
@override
Future<JournalElement> updateElement(JournalElement element) async {
final existing = await _isar.journalElementCollections
.where()
.filter()
.idEqualTo(element.id)
.and()
.isDeletedEqualTo(false)
.findFirst();
if (existing == null) {
throw StateError('元素不存在: ${element.id}');
}
// 乐观锁冲突检测
if (existing.version != element.version) {
throw StateError(
'版本冲突: 本地版本 ${element.version}, 存储版本 ${existing.version}',
);
}
final updated = element.copyWith(
version: element.version + 1,
updatedAt: DateTime.now(),
);
final col = _toElementCollection(updated);
col.isarId = existing.isarId;
await _isar.writeTxn(() async {
await _isar.journalElementCollections.put(col);
});
return updated;
}
@override
Future<void> removeElement(String elementId) async {
final existing = await _isar.journalElementCollections
.where()
.filter()
.idEqualTo(elementId)
.findFirst();
if (existing == null) return;
// 软删除
existing.isDeleted = true;
existing.updatedAtEpoch = DateTime.now().millisecondsSinceEpoch;
await _isar.writeTxn(() async {
await _isar.journalElementCollections.put(existing);
});
}
// ============================================================
// 转换函数JournalEntry ↔ JournalEntryCollection
// ============================================================
/// JournalEntry → JournalEntryCollection
JournalEntryCollection _toEntryCollection(JournalEntry entry) {
return JournalEntryCollection()
..id = entry.id
..authorId = entry.authorId
..classId = entry.classId
..title = entry.title
..dateEpoch = entry.date.millisecondsSinceEpoch
..mood = entry.mood.value
..weather = entry.weather.value
..tagsJson = jsonEncode(entry.tags)
..isPrivate = entry.isPrivate
..sharedToClass = entry.sharedToClass
..assignedTopicId = entry.assignedTopicId
..contentExcerpt = entry.contentExcerpt
..version = entry.version
..createdAtEpoch = entry.createdAt.millisecondsSinceEpoch
..updatedAtEpoch = entry.updatedAt.millisecondsSinceEpoch
..isDeleted = false;
}
/// JournalEntryCollection → JournalEntry
JournalEntry _fromCollection(JournalEntryCollection col) {
return JournalEntry(
id: col.id,
authorId: col.authorId,
classId: col.classId,
title: col.title,
date: DateTime.fromMillisecondsSinceEpoch(col.dateEpoch),
mood: Mood.values.firstWhere(
(m) => m.value == col.mood,
orElse: () => Mood.calm,
),
weather: Weather.values.firstWhere(
(w) => w.value == col.weather,
orElse: () => Weather.sunny,
),
tags: List<String>.from(
jsonDecode(col.tagsJson) as List? ?? [],
),
isPrivate: col.isPrivate,
sharedToClass: col.sharedToClass,
assignedTopicId: col.assignedTopicId,
contentExcerpt: col.contentExcerpt,
version: col.version,
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
);
}
// ============================================================
// 转换函数JournalElement ↔ JournalElementCollection
// ============================================================
/// JournalElement → JournalElementCollection
JournalElementCollection _toElementCollection(JournalElement element) {
return JournalElementCollection()
..id = element.id
..journalId = element.journalId
..elementType = element.elementType.value
..positionX = element.positionX
..positionY = element.positionY
..width = element.width
..height = element.height
..rotation = element.rotation
..zIndex = element.zIndex
..contentJson = jsonEncode(element.content)
..version = element.version
..createdAtEpoch = element.createdAt.millisecondsSinceEpoch
..updatedAtEpoch = element.updatedAt.millisecondsSinceEpoch
..isDeleted = false;
}
/// JournalElementCollection → JournalElement
JournalElement _fromElementCollection(JournalElementCollection col) {
return JournalElement(
id: col.id,
journalId: col.journalId,
elementType: ElementType.values.firstWhere(
(e) => e.value == col.elementType,
orElse: () => ElementType.text,
),
positionX: col.positionX,
positionY: col.positionY,
width: col.width,
height: col.height,
rotation: col.rotation,
zIndex: col.zIndex,
content: Map<String, dynamic>.from(
jsonDecode(col.contentJson) as Map? ?? {},
),
version: col.version,
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
updatedAt: DateTime.fromMillisecondsSinceEpoch(col.updatedAtEpoch),
);
}
}

View File

@@ -0,0 +1,65 @@
// Isar 本地日记仓库 — Web 平台 stub不可用
//
// Isar 3.x 不支持 Web此文件提供空实现。
// Web 平台应使用 RemoteJournalRepository。
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
Future<List<JournalEntry>> getJournals({
DateTime? dateFrom,
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
String? classId,
}) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<int> getJournalCount() =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<JournalEntry?> getJournal(String id) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<JournalEntry> createJournal(JournalEntry entry) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<JournalEntry> updateJournal(JournalEntry entry) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<void> deleteJournal(String id) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<List<JournalElement>> getElements(String journalId) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<JournalElement> addElement(JournalElement element) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@override
Future<JournalElement> updateElement(JournalElement element) =>
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
@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';
@@ -15,14 +17,20 @@ import '../models/journal_element.dart';
/// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间)
/// - [page]/[pageSize]: 分页参数,从 1 开始
abstract class JournalRepository {
/// 获取日记列表(支持日期范围过滤和分页)
/// 获取日记列表(支持日期范围、心情、标签、班级过滤和分页)
Future<List<JournalEntry>> getJournals({
DateTime? dateFrom,
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
String? classId,
});
/// 获取日记总数
Future<int> getJournalCount();
/// 获取单篇日记(返回 null 表示不存在)
Future<JournalEntry?> getJournal(String id);
@@ -46,6 +54,9 @@ abstract class JournalRepository {
/// 从日记中移除元素
Future<void> removeElement(String elementId);
/// 日记变更通知流 — create/update/delete 时发出信号
Stream<void> get onJournalChanged;
}
/// 内存实现 — 用于开发阶段快速迭代和单元测试
@@ -55,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({
@@ -62,6 +77,9 @@ class InMemoryJournalRepository implements JournalRepository {
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
String? classId,
}) async {
var results = _journals.values.toList();
@@ -73,6 +91,21 @@ class InMemoryJournalRepository implements JournalRepository {
results = results.where((j) => j.date.isBefore(dateTo)).toList();
}
// 心情过滤
if (mood != null) {
results = results.where((j) => j.mood.value == mood).toList();
}
// 标签过滤(日记 tags 列表包含指定标签)
if (tag != null) {
results = results.where((j) => j.tags.contains(tag)).toList();
}
// 班级过滤
if (classId != null) {
results = results.where((j) => j.classId == classId).toList();
}
// 按日期降序排列(最新在前)
results.sort((a, b) => b.date.compareTo(a.date));
@@ -87,6 +120,9 @@ class InMemoryJournalRepository implements JournalRepository {
return results;
}
@override
Future<int> getJournalCount() async => _journals.length;
@override
Future<JournalEntry?> getJournal(String id) async {
return _journals[id];
@@ -95,6 +131,7 @@ class InMemoryJournalRepository implements JournalRepository {
@override
Future<JournalEntry> createJournal(JournalEntry entry) async {
_journals[entry.id] = entry;
_changeController.add(null);
return entry;
}
@@ -118,6 +155,7 @@ class InMemoryJournalRepository implements JournalRepository {
updatedAt: DateTime.now(),
);
_journals[entry.id] = updated;
_changeController.add(null);
return updated;
}
@@ -127,6 +165,7 @@ class InMemoryJournalRepository implements JournalRepository {
_journals.remove(id);
// 同时移除关联元素
_elements.removeWhere((_, e) => e.journalId == id);
_changeController.add(null);
}
@override

View File

@@ -0,0 +1,171 @@
// 远程日记仓库 — 通过 API 客户端连接后端
import 'dart:async';
import '../models/journal_element.dart';
import '../models/journal_entry.dart';
import '../remote/api_client.dart';
import 'journal_repository.dart';
/// 远程日记仓库 — 通过 HTTP API 操作后端数据
///
/// 所有操作需要网络连接。离线场景由 SyncEngine 协调 Isar 本地仓库处理。
class RemoteJournalRepository implements JournalRepository {
final ApiClient _api;
/// 变更通知流控制器 — 写操作成功后通知 UI 刷新
final StreamController<void> _changeController =
StreamController<void>.broadcast();
RemoteJournalRepository({required ApiClient api}) : _api = api;
@override
Future<List<JournalEntry>> getJournals({
DateTime? dateFrom,
DateTime? dateTo,
int? page,
int? pageSize,
String? mood,
String? tag,
String? classId,
}) async {
final queryParams = <String, dynamic>{};
// 后端 NaiveDateTime 格式: "2026-06-01T00:00:00"(不带毫秒)
if (dateFrom != null) {
queryParams['date_from'] = dateFrom.toIso8601String().replaceFirst(RegExp(r'\.\d+'), '');
}
if (dateTo != null) {
queryParams['date_to'] = dateTo.toIso8601String().replaceFirst(RegExp(r'\.\d+'), '');
}
if (page != null) queryParams['page'] = page;
if (pageSize != null) queryParams['page_size'] = pageSize;
if (mood != null) queryParams['mood'] = mood;
if (tag != null) queryParams['tag'] = tag;
if (classId != null) queryParams['class_id'] = classId;
final response = await _api.get('/diary/journals', queryParams: queryParams);
final body = response.data as Map<String, dynamic>;
// 后端信封格式: { 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();
}
@override
Future<int> getJournalCount() async {
final response = await _api.get('/diary/journals', queryParams: {
'page': 1,
'page_size': 1,
});
final body = response.data as Map<String, dynamic>;
// 后端信封格式: { success, data: { data: [...], total, ... }, message }
final envelope = body['data'] as Map<String, dynamic>? ?? {};
return (envelope['total'] as int?) ?? 0;
}
@override
Future<JournalEntry?> getJournal(String id) async {
try {
final response = await _api.get('/diary/journals/$id');
final body = response.data as Map<String, dynamic>;
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
} on ApiException catch (e) {
if (e.statusCode == 404) return null;
rethrow;
}
}
@override
Future<JournalEntry> createJournal(JournalEntry entry) async {
// 后端 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>;
final created = JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
_changeController.add(null); // 通知 UI 刷新列表
return created;
}
@override
Future<JournalEntry> updateJournal(JournalEntry entry) async {
final response = await _api.put(
'/diary/journals/${entry.id}',
data: {
'title': entry.title,
'mood': entry.mood.value,
'weather': entry.weather.value,
'tags': entry.tags,
'is_private': entry.isPrivate,
'shared_to_class': entry.sharedToClass,
'version': entry.version,
},
);
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
Future<List<JournalElement>> getElements(String journalId) async {
final response = await _api.get('/diary/journals/$journalId/elements');
final body = response.data as Map<String, dynamic>;
final items = body['data'] as List? ?? [];
return items
.map((json) => JournalElement.fromJson(json as Map<String, dynamic>))
.toList();
}
@override
Future<JournalElement> addElement(JournalElement element) async {
final response = await _api.post(
'/diary/journals/${element.journalId}/elements',
data: element.toJson(),
);
final body = response.data as Map<String, dynamic>;
return JournalElement.fromJson(body['data'] as Map<String, dynamic>);
}
@override
Future<JournalElement> updateElement(JournalElement element) async {
final response = await _api.put(
'/diary/journals/${element.journalId}/elements/${element.id}',
data: element.toJson(),
);
final body = response.data as Map<String, dynamic>;
return JournalElement.fromJson(body['data'] as Map<String, dynamic>);
}
@override
Future<void> removeElement(String elementId) async {
await _api.delete('/diary/elements/$elementId');
}
/// 变更通知流 — 写操作后广播,供 HomeBloc 自动刷新
@override
Stream<void> get onJournalChanged => _changeController.stream;
}
/// API 异常封装 — 后端返回非 2xx 状态码时抛出
class ApiException implements Exception {
final String message;
final int statusCode;
final dynamic responseBody;
const ApiException({
required this.message,
required this.statusCode,
this.responseBody,
});
@override
String toString() => 'ApiException($statusCode): $message';
}

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

@@ -0,0 +1,146 @@
// SSE 通知服务 — 监听服务端推送事件
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
/// SSE 通知事件
class NotificationEvent {
final String type;
final Map<String, dynamic> payload;
final DateTime receivedAt;
const NotificationEvent({
required this.type,
required this.payload,
required this.receivedAt,
});
}
/// SSE 通知服务 — 监听后端 Server-Sent Events 推送
///
/// 使用方式:
/// ```dart
/// final service = SseNotificationService(token: 'jwt-token');
/// service.events.listen((event) {
/// // 处理通知
/// });
/// await service.connect();
/// ```
class SseNotificationService {
final String _baseUrl;
final String? _token;
Dio? _dio;
Response<ResponseBody>? _response;
StreamController<NotificationEvent>? _controller;
bool _disposed = false;
SseNotificationService({
required String token,
required String baseUrl,
}) : _token = token,
_baseUrl = baseUrl;
/// 通知事件流
Stream<NotificationEvent> get events {
_controller ??= StreamController<NotificationEvent>.broadcast();
return _controller!.stream;
}
/// 连接到 SSE 端点
Future<void> connect() async {
if (_disposed) return;
_dio = Dio(BaseOptions(
baseUrl: _baseUrl,
headers: {
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache',
if (_token != null) 'Authorization': 'Bearer $_token',
},
responseType: ResponseType.stream,
));
try {
_response = await _dio!.get<ResponseBody>('/message/stream');
if (_response?.data == null) return;
_response!.data!.stream.listen(
(data) {
_parseSseData(data);
},
onError: (error) {
if (!_disposed) {
// 自动重连逻辑3秒延迟
Future.delayed(const Duration(seconds: 3), () {
if (!_disposed) connect();
});
}
},
onDone: () {
if (!_disposed) {
Future.delayed(const Duration(seconds: 3), () {
if (!_disposed) connect();
});
}
},
);
} catch (e) {
// 连接失败,延迟重连
if (!_disposed) {
Future.delayed(const Duration(seconds: 5), () {
if (!_disposed) connect();
});
}
}
}
/// 解析 SSE 数据帧
void _parseSseData(List<int> data) {
final text = utf8.decode(data);
final lines = text.split('\n');
String? eventType;
String? eventData;
for (final line in lines) {
if (line.startsWith('event:')) {
eventType = line.substring(6).trim();
} else if (line.startsWith('data:')) {
eventData = line.substring(5).trim();
} else if (line.isEmpty && eventType != null && eventData != null) {
// 空行 = 事件结束
_emitEvent(eventType, eventData);
eventType = null;
eventData = null;
}
}
}
/// 发射通知事件到流
void _emitEvent(String type, String data) {
if (_disposed || _controller == null) return;
try {
final payload = jsonDecode(data) as Map<String, dynamic>;
_controller!.add(NotificationEvent(
type: type,
payload: payload,
receivedAt: DateTime.now(),
));
} catch (_) {
// 忽略解析错误
}
}
/// 断开连接并释放资源
void dispose() {
_disposed = true;
_response?.data?.stream.listen((_) {});
_controller?.close();
_dio?.close();
}
}

View File

@@ -1,232 +1,7 @@
// 同步引擎 — WiFi 增量同步 + 操作队列
// 同步引擎 — 条件导出
//
// 设计思路
// - 所有本地修改先入队 [PendingOperation]
// - 网络恢复时自动批量同步
// - 版本号冲突检测Phase 1 使用"本地优先"策略
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
//
// Phase 1 策略:本地优先
// - 离线时正常使用,操作入队等待
// - 联网后自动推送待同步操作
// - 版本冲突时本地版本覆盖远端(简单策略)
// 根据平台选择实现
// - 原生平台 → sync_engine_native.dartIsar 持久化队列)
// - Web 平台 → sync_engine_web.dart纯内存队列
import 'dart:collection';
import 'package:connectivity_plus/connectivity_plus.dart';
import '../remote/api_client.dart';
/// 同步操作类型
enum SyncOperationType {
create('POST'),
update('PUT'),
delete('DELETE');
const SyncOperationType(this.httpMethod);
final String httpMethod;
}
/// 同步状态
enum SyncStatus {
idle, // 空闲,无待同步操作
syncing, // 正在同步
paused, // 暂停(网络不可用)
error, // 出错,需要重试
}
/// 待同步操作 — 记录一次本地修改
class PendingOperation {
final String id;
final SyncOperationType type;
final String endpoint;
final Map<String, dynamic> data;
final int version;
final DateTime createdAt;
final int retryCount;
/// 最大重试次数
static const int maxRetryCount = 5;
const PendingOperation({
required this.id,
required this.type,
required this.endpoint,
required this.data,
required this.version,
required this.createdAt,
this.retryCount = 0,
});
PendingOperation copyWith({
String? id,
SyncOperationType? type,
String? endpoint,
Map<String, dynamic>? data,
int? version,
DateTime? createdAt,
int? retryCount,
}) =>
PendingOperation(
id: id ?? this.id,
type: type ?? this.type,
endpoint: endpoint ?? this.endpoint,
data: data ?? this.data,
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
retryCount: retryCount ?? this.retryCount,
);
/// 是否已超过最大重试次数
bool get isExhausted => retryCount >= maxRetryCount;
}
/// 同步引擎 — 管理 WiFi 增量同步和操作队列
///
/// 使用方式:
/// ```dart
/// final engine = SyncEngine(apiClient: apiClient);
///
/// // 本地修改后入队
/// engine.enqueue(PendingOperation(
/// id: 'op-1',
/// type: SyncOperationType.create,
/// endpoint: '/diary/entries',
/// data: entry.toJson(),
/// version: 1,
/// createdAt: DateTime.now(),
/// ));
///
/// // 网络恢复时触发同步
/// await engine.trySync();
/// ```
class SyncEngine {
final ApiClient _apiClient;
final Queue<PendingOperation> _pendingQueue = Queue();
SyncStatus _status = SyncStatus.idle;
String? _lastError;
SyncEngine({required this._apiClient});
/// 当前同步状态
SyncStatus get status => _status;
/// 最近一次错误信息
String? get lastError => _lastError;
/// 待同步操作数量
int get pendingCount => _pendingQueue.length;
/// 是否有操作正在同步
bool get isSyncing => _status == SyncStatus.syncing;
/// 添加待同步操作到队列尾部
void enqueue(PendingOperation operation) {
_pendingQueue.add(operation);
if (_status == SyncStatus.idle) {
_status = SyncStatus.paused;
}
}
/// 批量添加待同步操作
void enqueueAll(List<PendingOperation> operations) {
for (final op in operations) {
_pendingQueue.add(op);
}
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
_status = SyncStatus.paused;
}
}
/// 检查网络状态并尝试同步全部待处理操作
///
/// 同步策略:
/// 1. 检查网络是否可用
/// 2. 按先进先出顺序处理队列
/// 3. 每个操作最多重试 [PendingOperation.maxRetryCount] 次
/// 4. 超过重试次数的操作标记为冲突,移出队列
/// 5. 网络中断时暂停同步,保留剩余操作
Future<void> trySync() async {
if (_status == SyncStatus.syncing) return; // 防止重入
if (_pendingQueue.isEmpty) {
_status = SyncStatus.idle;
return;
}
// 检查网络
final connectivity = Connectivity();
final result = await connectivity.checkConnectivity();
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (!isOnline) {
_status = SyncStatus.paused;
_lastError = '网络不可用';
return;
}
// WiFi 优先策略:仅在 WiFi 下自动同步Phase 1 简化)
// TODO: 添加用户设置允许蜂窝数据同步
_status = SyncStatus.syncing;
_lastError = null;
while (_pendingQueue.isNotEmpty) {
final operation = _pendingQueue.removeFirst();
try {
await _executeOperation(operation);
} on OfflineException {
// 网络中断,操作放回队列头部
_pendingQueue.addFirst(operation);
_status = SyncStatus.paused;
_lastError = '同步中断:网络不可用';
return;
} catch (e) {
// 操作失败,增加重试计数
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
if (retried.isExhausted) {
// 超过最大重试次数标记为冲突Phase 1 简化:丢弃)
// TODO: Phase 2 将冲突操作持久化,提供 UI 让用户手动解决
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
continue;
}
// 放回队列头部,下次重试
_pendingQueue.addFirst(retried);
_status = SyncStatus.error;
_lastError = '同步失败: $e';
return;
}
}
// 全部同步完成
_status = SyncStatus.idle;
_lastError = null;
}
/// 执行单个同步操作
Future<void> _executeOperation(PendingOperation operation) async {
switch (operation.type) {
case SyncOperationType.create:
await _apiClient.post(operation.endpoint, data: operation.data);
case SyncOperationType.update:
await _apiClient.put(operation.endpoint, data: operation.data);
case SyncOperationType.delete:
await _apiClient.delete(operation.endpoint);
}
}
/// 清空队列(数据已全部同步完成或需要强制清空时调用)
void clear() {
_pendingQueue.clear();
_status = SyncStatus.idle;
_lastError = null;
}
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
///
/// 应用退出时调用此方法,将待同步操作保存到 Isar
/// 下次启动时通过 [enqueueAll] 恢复。
List<PendingOperation> get snapshot => _pendingQueue.toList();
}
export 'sync_engine_web.dart' if (dart.library.io) 'sync_engine_native.dart';

View File

@@ -0,0 +1,504 @@
// 同步引擎 — WiFi 增量同步 + 操作队列 + Isar 持久化
//
// 设计思路:
// - 所有本地修改先入队 [PendingOperation]
// - 网络恢复时自动批量同步
// - 版本号冲突检测Phase 1 使用"本地优先"策略
// - 最大重试次数限制,超过后标记为冲突供用户手动解决
// - 队列持久化到 Isar应用退出后不丢失
//
// Phase 1 策略:本地优先
// - 离线时正常使用,操作入队等待
// - 联网后自动推送待同步操作
// - 版本冲突时本地版本覆盖远端(简单策略)
import 'dart:async';
import 'dart:convert';
import 'dart:collection';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:isar/isar.dart';
import '../local/isar_database_native.dart';
import '../local/collections/pending_operation_collection.dart';
import '../models/sync_models.dart';
import '../remote/api_client.dart';
/// 同步操作类型
enum SyncOperationType {
create('POST'),
update('PUT'),
delete('DELETE');
const SyncOperationType(this.httpMethod);
final String httpMethod;
}
/// 同步状态
enum SyncStatus {
idle, // 空闲,无待同步操作
syncing, // 正在同步
paused, // 暂停(网络不可用)
error, // 出错,需要重试
}
/// 待同步操作 — 记录一次本地修改
class PendingOperation {
final String id;
final SyncOperationType type;
final String endpoint;
final Map<String, dynamic> data;
final int version;
final DateTime createdAt;
final int retryCount;
/// 最大重试次数
static const int maxRetryCount = 5;
const PendingOperation({
required this.id,
required this.type,
required this.endpoint,
required this.data,
required this.version,
required this.createdAt,
this.retryCount = 0,
});
PendingOperation copyWith({
String? id,
SyncOperationType? type,
String? endpoint,
Map<String, dynamic>? data,
int? version,
DateTime? createdAt,
int? retryCount,
}) =>
PendingOperation(
id: id ?? this.id,
type: type ?? this.type,
endpoint: endpoint ?? this.endpoint,
data: data ?? this.data,
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
retryCount: retryCount ?? this.retryCount,
);
/// 是否已超过最大重试次数
bool get isExhausted => retryCount >= maxRetryCount;
}
/// 同步引擎 — 管理 WiFi 增量同步和操作队列
///
/// 使用方式:
/// ```dart
/// final engine = SyncEngine(apiClient: apiClient);
///
/// // 启动时恢复持久化队列
/// await engine.restorePendingQueue();
///
/// // 本地修改后入队
/// engine.enqueue(PendingOperation(
/// id: 'op-1',
/// type: SyncOperationType.create,
/// endpoint: '/diary/entries',
/// data: entry.toJson(),
/// version: 1,
/// createdAt: DateTime.now(),
/// ));
///
/// // 网络恢复时触发同步
/// await engine.trySync();
///
/// // 应用退出时持久化
/// await engine.persistPendingQueue();
/// ```
class SyncEngine {
final ApiClient _apiClient;
final Queue<PendingOperation> _pendingQueue = Queue();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
SyncStatus _status = SyncStatus.idle;
String? _lastError;
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
/// 当前同步状态
SyncStatus get status => _status;
/// 最近一次错误信息
String? get lastError => _lastError;
/// 待同步操作数量
int get pendingCount => _pendingQueue.length;
/// 是否有操作正在同步
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) {
// 防御性检查:私密日记不入队
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) {
enqueue(op);
}
}
/// 合并同一资源的两个操作
///
/// 返回合并后的操作,或 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;
}
/// 检查网络状态并尝试同步全部待处理操作
///
/// 同步策略:
/// 1. 检查网络是否可用
/// 2. 按先进先出顺序处理队列
/// 3. 每个操作最多重试 [PendingOperation.maxRetryCount] 次
/// 4. 超过重试次数的操作标记为冲突,移出队列
/// 5. 网络中断时暂停同步,保留剩余操作
Future<void> trySync() async {
if (_status == SyncStatus.syncing) return; // 防止重入
if (_pendingQueue.isEmpty) {
_status = SyncStatus.idle;
return;
}
// 检查网络
final connectivity = Connectivity();
final result = await connectivity.checkConnectivity();
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (!isOnline) {
_status = SyncStatus.paused;
_lastError = '网络不可用';
return;
}
// WiFi 优先策略:仅在 WiFi 下自动同步Phase 1 简化)
// TODO: 添加用户设置允许蜂窝数据同步
_status = SyncStatus.syncing;
_lastError = null;
while (_pendingQueue.isNotEmpty) {
final operation = _pendingQueue.removeFirst();
try {
await _executeOperation(operation);
} on OfflineException {
// 网络中断,操作放回队列头部
_pendingQueue.addFirst(operation);
_status = SyncStatus.paused;
_lastError = '同步中断:网络不可用';
return;
} catch (e) {
debugPrint('SyncEngine.trySync 操作失败: $e');
// 操作失败,增加重试计数
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
if (retried.isExhausted) {
// 超过最大重试次数标记为冲突Phase 1 简化:丢弃)
// TODO: Phase 2 将冲突操作持久化,提供 UI 让用户手动解决
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
continue;
}
// 放回队列头部,下次重试
_pendingQueue.addFirst(retried);
_status = SyncStatus.error;
_lastError = '同步失败: $e';
return;
}
}
// 全部同步完成,更新持久化
_status = SyncStatus.idle;
_lastError = null;
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) {
case SyncOperationType.create:
await _apiClient.post(operation.endpoint, data: operation.data);
case SyncOperationType.update:
await _apiClient.put(operation.endpoint, data: operation.data);
case SyncOperationType.delete:
await _apiClient.delete(operation.endpoint);
}
}
/// 清空队列(数据已全部同步完成或需要强制清空时调用)
void clear() {
_pendingQueue.clear();
_status = SyncStatus.idle;
_lastError = null;
}
/// 获取当前队列中所有操作的快照(用于持久化到本地存储)
///
/// 应用退出时调用此方法,将待同步操作保存到 Isar
/// 下次启动时通过 [restorePendingQueue] 恢复。
List<PendingOperation> get snapshot => _pendingQueue.toList();
// ============================================================
// Isar 持久化
// ============================================================
/// 将当前内存队列持久化到 Isar
///
/// 替换策略:先清空旧的持久化数据,再写入当前队列。
/// 在 app 退出、isolate 暂停、或同步完成后调用。
Future<void> persistPendingQueue() async {
if (!IsarDatabase.isAvailable) return;
final isar = IsarDatabase.instance;
final ops = snapshot;
await isar.writeTxn(() async {
// 清空旧数据
await isar.pendingOperationCollections.clear();
// 写入当前队列
for (final op in ops) {
final col = _operationToCollection(op);
await isar.pendingOperationCollections.put(col);
}
});
}
/// 从 Isar 恢复持久化队列到内存
///
/// 在 app 启动时调用,恢复上次退出时未同步的操作。
/// Web 平台上 Isar 不可用,跳过恢复。
Future<void> restorePendingQueue() async {
if (!IsarDatabase.isAvailable) return;
final isar = IsarDatabase.instance;
final persisted = await isar.pendingOperationCollections
.where()
.anyIsarId()
.findAll();
for (final col in persisted) {
final op = _collectionToOperation(col);
_pendingQueue.add(op);
}
if (_pendingQueue.isNotEmpty && _status == SyncStatus.idle) {
_status = SyncStatus.paused;
}
}
/// 启动网络监听 — 网络恢复时自动触发同步
///
/// 在 app.dart 中创建 SyncEngine 后调用一次。
/// 调用 [dispose] 停止监听。
void startAutoSync() {
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
trySync();
}
});
}
/// 停止网络监听并清理资源
void dispose() {
_connectivitySub?.cancel();
_connectivitySub = null;
}
// ============================================================
// 转换函数
// ============================================================
/// PendingOperation → PendingOperationCollection
PendingOperationCollection _operationToCollection(PendingOperation op) {
return PendingOperationCollection()
..id = op.id
..operationType = op.type.httpMethod
..endpoint = op.endpoint
..dataJson = _encodeJson(op.data)
..version = op.version
..createdAtEpoch = op.createdAt.millisecondsSinceEpoch
..retryCount = op.retryCount;
}
/// PendingOperationCollection → PendingOperation
PendingOperation _collectionToOperation(PendingOperationCollection col) {
return PendingOperation(
id: col.id,
type: SyncOperationType.values.firstWhere(
(t) => t.httpMethod == col.operationType,
orElse: () => SyncOperationType.create,
),
endpoint: col.endpoint,
data: _decodeJson(col.dataJson),
version: col.version,
createdAt: DateTime.fromMillisecondsSinceEpoch(col.createdAtEpoch),
retryCount: col.retryCount,
);
}
/// 安全编码 JSON
String _encodeJson(Map<String, dynamic> data) {
try {
return jsonEncode(data);
} catch (_) {
return '{}';
}
}
/// 安全解码 JSON
Map<String, dynamic> _decodeJson(String json) {
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
}

View File

@@ -0,0 +1,221 @@
// 同步引擎 — Web 平台实现(无 Isar 持久化)
//
// Web 平台上 Isar 不可用,操作队列仅保存在内存中。
// 核心同步逻辑与原生版一致,仅持久化部分为空实现。
import 'dart:async';
import 'dart:convert';
import 'dart:collection';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import '../remote/api_client.dart';
/// 同步操作类型
enum SyncOperationType {
create('POST'),
update('PUT'),
delete('DELETE');
const SyncOperationType(this.httpMethod);
final String httpMethod;
}
/// 同步状态
enum SyncStatus {
idle,
syncing,
paused,
error,
}
/// 待同步操作
class PendingOperation {
final String id;
final SyncOperationType type;
final String endpoint;
final Map<String, dynamic> data;
final int version;
final DateTime createdAt;
final int retryCount;
static const int maxRetryCount = 5;
const PendingOperation({
required this.id,
required this.type,
required this.endpoint,
required this.data,
required this.version,
required this.createdAt,
this.retryCount = 0,
});
PendingOperation copyWith({
String? id,
SyncOperationType? type,
String? endpoint,
Map<String, dynamic>? data,
int? version,
DateTime? createdAt,
int? retryCount,
}) =>
PendingOperation(
id: id ?? this.id,
type: type ?? this.type,
endpoint: endpoint ?? this.endpoint,
data: data ?? this.data,
version: version ?? this.version,
createdAt: createdAt ?? this.createdAt,
retryCount: retryCount ?? this.retryCount,
);
bool get isExhausted => retryCount >= maxRetryCount;
}
/// 同步引擎 — Web 版(内存队列,无持久化)
class SyncEngine {
final ApiClient _apiClient;
final Queue<PendingOperation> _pendingQueue = Queue();
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
SyncStatus _status = SyncStatus.idle;
String? _lastError;
SyncEngine({required ApiClient apiClient}) : _apiClient = apiClient;
SyncStatus get status => _status;
String? get lastError => _lastError;
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;
}
}
void enqueueAll(List<PendingOperation> operations) {
for (final op in operations) {
_pendingQueue.add(op);
}
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
_status = SyncStatus.paused;
}
}
Future<void> trySync() async {
if (_status == SyncStatus.syncing) return;
if (_pendingQueue.isEmpty) {
_status = SyncStatus.idle;
return;
}
final connectivity = Connectivity();
final result = await connectivity.checkConnectivity();
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (!isOnline) {
_status = SyncStatus.paused;
_lastError = '网络不可用';
return;
}
_status = SyncStatus.syncing;
_lastError = null;
while (_pendingQueue.isNotEmpty) {
final operation = _pendingQueue.removeFirst();
try {
await _executeOperation(operation);
} on OfflineException {
_pendingQueue.addFirst(operation);
_status = SyncStatus.paused;
_lastError = '同步中断:网络不可用';
return;
} catch (e) {
debugPrint('SyncEngine.trySync 操作失败: $e');
final retried = operation.copyWith(retryCount: operation.retryCount + 1);
if (retried.isExhausted) {
_lastError = '操作同步失败(已耗尽重试次数): ${operation.endpoint}';
continue;
}
_pendingQueue.addFirst(retried);
_status = SyncStatus.error;
_lastError = '同步失败: $e';
return;
}
}
_status = SyncStatus.idle;
_lastError = null;
}
Future<void> _executeOperation(PendingOperation operation) async {
switch (operation.type) {
case SyncOperationType.create:
await _apiClient.post(operation.endpoint, data: operation.data);
case SyncOperationType.update:
await _apiClient.put(operation.endpoint, data: operation.data);
case SyncOperationType.delete:
await _apiClient.delete(operation.endpoint);
}
}
void clear() {
_pendingQueue.clear();
_status = SyncStatus.idle;
_lastError = null;
}
List<PendingOperation> get snapshot => _pendingQueue.toList();
/// Web 平台:持久化为空操作(队列仅保存在内存中)
Future<void> persistPendingQueue() async {}
/// Web 平台:恢复队列为空操作(无持久化数据)
Future<void> restorePendingQueue() async {}
void startAutoSync() {
_connectivitySub = Connectivity().onConnectivityChanged.listen((result) {
final isOnline = result.any((r) => r != ConnectivityResult.none);
if (isOnline && _pendingQueue.isNotEmpty && _status != SyncStatus.syncing) {
debugPrint('SyncEngine: 网络恢复,开始同步 ${_pendingQueue.length} 个操作');
trySync();
}
});
}
void dispose() {
_connectivitySub?.cancel();
_connectivitySub = null;
}
String _encodeJson(Map<String, dynamic> data) {
try {
return jsonEncode(data);
} catch (_) {
return '{}';
}
}
Map<String, dynamic> _decodeJson(String json) {
try {
return jsonDecode(json) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
}

View File

@@ -0,0 +1,110 @@
// 成就 BLoC — 通过 API 加载成就列表
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:nuanji_app/data/remote/api_client.dart';
// ===== 模型 =====
/// 成就数据
class Achievement {
final String id;
final String code;
final String name;
final String? description;
final String? icon;
final String category;
final bool isUnlocked;
final DateTime? unlockedAt;
const Achievement({
required this.id,
required this.code,
required this.name,
this.description,
this.icon,
required this.category,
this.isUnlocked = false,
this.unlockedAt,
});
}
// ===== State =====
/// 成就页面状态
class AchievementState {
final List<Achievement> achievements;
final bool isLoading;
final String? errorMessage;
const AchievementState({
this.achievements = const [],
this.isLoading = false,
this.errorMessage,
});
int get unlockedCount =>
achievements.where((a) => a.isUnlocked).length;
AchievementState copyWith({
List<Achievement>? achievements,
bool? isLoading,
String? errorMessage,
}) =>
AchievementState(
achievements: achievements ?? this.achievements,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage,
);
}
// ===== BLoC =====
/// 成就 BLoC — ChangeNotifier 模式
class AchievementBloc extends ChangeNotifier {
final ApiClient _api;
AchievementState _state = const AchievementState();
AchievementState get state => _state;
AchievementBloc({required ApiClient api}) : _api = api;
/// 加载成就列表
void load() {
_state = _state.copyWith(isLoading: true);
notifyListeners();
_fetchAchievements();
}
Future<void> _fetchAchievements() async {
try {
final response = await _api.get('/diary/achievements');
final body = response.data as Map<String, dynamic>;
final list = body['data'] as List? ?? [];
final achievements = list.map((item) {
final m = item as Map<String, dynamic>;
return Achievement(
id: m['id'] as String,
code: m['code'] as String,
name: m['name'] as String,
description: m['description'] as String?,
icon: m['icon'] as String?,
category: m['category'] as String,
isUnlocked: m['is_unlocked'] as bool? ?? false,
unlockedAt: m['unlocked_at'] != null
? DateTime.tryParse(m['unlocked_at'] as String)
: null,
);
}).toList();
_state = _state.copyWith(isLoading: false, achievements: achievements);
} catch (e) {
debugPrint('AchievementBloc._fetchAchievements 失败: $e');
_state = _state.copyWith(
isLoading: false,
errorMessage: '加载成就列表失败',
);
}
notifyListeners();
}
}

View File

@@ -1,92 +1,110 @@
// 成就页面 — 徽章收集展示
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
/// 成就数据模型
class Achievement {
final String id;
final String code;
final String name;
final String? description;
final String icon;
final String category;
final bool isUnlocked;
const Achievement({
required this.id,
required this.code,
required this.name,
this.description,
required this.icon,
required this.category,
this.isUnlocked = false,
});
}
import 'package:nuanji_app/core/theme/app_radius.dart';
import 'package:nuanji_app/data/remote/api_client.dart';
import '../bloc/achievement_bloc.dart';
/// 成就页面 — 徽章收集和展示
class AchievementPage extends StatelessWidget {
class AchievementPage extends StatefulWidget {
const AchievementPage({super.key});
static const _achievements = [
Achievement(id: '1', code: 'first_diary', name: '初次落笔', description: '写下第一篇日记', icon: '✏️', category: 'writing', isUnlocked: true),
Achievement(id: '2', code: 'streak_7', name: '坚持一周', description: '连续写日记 7 天', icon: '🔥', category: 'writing'),
Achievement(id: '3', code: 'streak_30', name: '月度达人', description: '连续写日记 30 天', icon: '💪', category: 'writing'),
Achievement(id: '4', code: 'sticker_collector', name: '贴纸收藏家', description: '收集 10 张贴纸', icon: '🎨', category: 'collection'),
Achievement(id: '5', code: 'social_butterfly', name: '分享之星', description: '分享 5 篇日记到班级', icon: '🌟', category: 'social'),
Achievement(id: '6', code: 'mood_tracker', name: '心情记录员', description: '连续记录心情 14 天', icon: '🌈', category: 'writing'),
Achievement(id: '7', code: 'early_bird', name: '早起日记', description: '在早上 7 点前写日记', icon: '🌅', category: 'special'),
Achievement(id: '8', code: 'artist', name: '小画家', description: '在日记中画 10 幅涂鸦', icon: '🖌️', category: 'collection'),
];
@override
State<AchievementPage> createState() => _AchievementPageState();
}
class _AchievementPageState extends State<AchievementPage> {
late final AchievementBloc _bloc;
@override
void initState() {
super.initState();
_bloc = AchievementBloc(api: context.read<ApiClient>());
_bloc.load();
}
@override
void dispose() {
_bloc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final unlocked = _achievements.where((a) => a.isUnlocked).length;
return Scaffold(
appBar: AppBar(
title: const Text('成就'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 进度概览
_AchievementProgressCard(
unlocked: unlocked,
total: _achievements.length,
colorScheme: colorScheme,
),
const SizedBox(height: 24),
Text(
'全部成就',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
appBar: AppBar(title: const Text('成就')),
body: ListenableBuilder(
listenable: _bloc,
builder: (context, _) {
final state = _bloc.state;
if (state.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state.errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 12),
Text(state.errorMessage!, style: theme.textTheme.bodyMedium),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: _bloc.load,
child: const Text('重试'),
),
],
),
),
const SizedBox(height: 12),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: _achievements.length,
itemBuilder: (context, index) {
return _AchievementCard(
achievement: _achievements[index],
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 进度概览
_AchievementProgressCard(
unlocked: state.unlockedCount,
total: state.achievements.length,
colorScheme: colorScheme,
);
},
),
const SizedBox(height: 24),
Text(
'全部成就',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.75,
),
itemCount: state.achievements.length,
itemBuilder: (context, index) {
return _AchievementCard(
achievement: state.achievements[index],
colorScheme: colorScheme,
);
},
),
],
),
],
),
);
},
),
);
}
@@ -112,7 +130,7 @@ class _AchievementProgressCard extends StatelessWidget {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
borderRadius: AppRadius.lgBorder,
),
color: colorScheme.primaryContainer,
child: Padding(
@@ -139,7 +157,7 @@ class _AchievementProgressCard extends StatelessWidget {
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
borderRadius: AppRadius.smBorder,
child: LinearProgressIndicator(
value: progress,
minHeight: 10,
@@ -171,7 +189,7 @@ class _AchievementCard extends StatelessWidget {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.mdBorder,
side: BorderSide(
color: achievement.isUnlocked
? AppColors.accent.withValues(alpha: 0.4)
@@ -194,7 +212,10 @@ class _AchievementCard extends StatelessWidget {
),
alignment: Alignment.center,
child: achievement.isUnlocked
? Text(achievement.icon, style: const TextStyle(fontSize: 28))
? Text(
achievement.icon ?? '🏆',
style: const TextStyle(fontSize: 28),
)
: Icon(
Icons.lock_outline,
color: colorScheme.onSurface.withValues(alpha: 0.3),

View File

@@ -4,6 +4,7 @@
// ↕
// Authenticating → Authenticated/AuthError
import 'package:dio/dio.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:logger/logger.dart';
@@ -11,6 +12,7 @@ import '../../../data/models/auth_token.dart';
import '../../../data/models/user.dart';
import '../../../data/remote/api_client.dart';
import '../../../data/repositories/auth_repository.dart';
import '../../../data/repositories/class_repository.dart';
part 'auth_event.dart';
part 'auth_state.dart';
@@ -18,16 +20,21 @@ part 'auth_state.dart';
/// 认证 BLoC — 处理所有认证相关的状态转换
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository _authRepository;
final ClassRepository? _classRepository;
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
AuthBloc({required AuthRepository authRepository})
: _authRepository = authRepository,
AuthBloc({
required AuthRepository authRepository,
ClassRepository? classRepository,
}) : _authRepository = authRepository,
_classRepository = classRepository,
super(const AuthInitial()) {
// 注册事件处理器
on<AppStarted>(_onAppStarted);
on<LoginRequested>(_onLoginRequested);
on<RegisterRequested>(_onRegisterRequested);
on<RoleSelected>(_onRoleSelected);
on<ParentalConsentAccepted>(_onParentalConsentAccepted);
on<ClassCodeSubmitted>(_onClassCodeSubmitted);
on<LogoutRequested>(_onLogoutRequested);
on<TokenRefreshed>(_onTokenRefreshed);
@@ -118,16 +125,38 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final currentState = state;
if (currentState is! Authenticated) return;
// 学生角色需要先经过家长同意确认PIPL 第28条
final needsParentalConsent = event.role == UserRoleType.student;
// 根据角色决定下一步
final needsClassCode =
event.role == UserRoleType.student || event.role == UserRoleType.parent;
emit(currentState.copyWith(
needsRoleSelection: false,
needsClassCode: needsClassCode,
needsParentalConsent: needsParentalConsent,
needsClassCode: needsClassCode && !needsParentalConsent,
selectedRole: event.role,
));
_logger.i('角色选择: ${event.role.name}, 需要班级码: $needsClassCode');
_logger.i('角色选择: ${event.role.name}, 需要家长同意: $needsParentalConsent');
}
/// 家长/监护人同意信息收集PIPL 合规)
Future<void> _onParentalConsentAccepted(
ParentalConsentAccepted event,
Emitter<AuthState> emit,
) async {
final currentState = state;
if (currentState is! Authenticated) return;
_logger.i('家长同意已确认: ${event.consentAt}');
emit(currentState.copyWith(
needsParentalConsent: false,
needsClassCode: true,
parentalConsentAt: event.consentAt,
));
}
/// 班级码加入
@@ -138,13 +167,64 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final currentState = state;
if (currentState is! Authenticated) return;
// TODO: 调用后端 API 验证班级码并加入班级
// 当前先标记为已完成,班级码验证在 F8 阶段完善
emit(currentState.copyWith(
needsClassCode: false,
));
// 如果没有 ClassRepository离线模式直接跳过
final classRepo = _classRepository;
if (classRepo == null) {
_logger.w('ClassRepository 不可用,跳过班级码验证');
emit(currentState.copyWith(needsClassCode: false));
return;
}
_logger.i('班级码加入: ${event.classCode}');
emit(currentState.copyWith(isLoading: true));
try {
// 调用后端 API 验证班级码并加入班级
await classRepo.joinClass(
event.classCode,
nickname: currentState.user.displayName,
);
// 成功 — 清除班级码需求
emit(currentState.copyWith(
needsClassCode: false,
isLoading: false,
));
_logger.i('班级码加入成功: ${event.classCode}');
} on DioException catch (e) {
final statusCode = e.response?.statusCode;
String errorMessage = '加入班级失败,请重试';
if (statusCode == 400) {
// 班级码无效或已过期
final body = e.response?.data;
if (body is Map && body['message'] is String) {
errorMessage = body['message'] as String;
} else {
errorMessage = '班级码无效,请检查后重新输入';
}
} else if (statusCode == 429) {
// 尝试次数过多 — 锁定
errorMessage = '尝试次数过多,请等待 30 分钟后再试';
}
_logger.w('班级码验证失败 ($statusCode): $errorMessage');
emit(currentState.copyWith(
isLoading: false,
classCodeError: errorMessage,
));
} on OfflineException {
emit(currentState.copyWith(
isLoading: false,
classCodeError: '网络不可用,请检查网络后重试',
));
} catch (e) {
_logger.e('班级码验证异常: $e');
emit(currentState.copyWith(
isLoading: false,
classCodeError: '加入班级失败,请稍后重试',
));
}
}
/// 用户登出

View File

@@ -43,6 +43,12 @@ final class RoleSelected extends AuthEvent {
const RoleSelected(this.role);
}
/// 家长/监护人同意 PIPL 信息收集(审计 S-03
final class ParentalConsentAccepted extends AuthEvent {
final DateTime consentAt;
const ParentalConsentAccepted(this.consentAt);
}
/// 班级码加入(学生/家长加入班级)
final class ClassCodeSubmitted extends AuthEvent {
final String classCode;

View File

@@ -37,24 +37,55 @@ final class Authenticated extends AuthState {
/// 是否需要角色选择(新注册用户还没有角色)
final bool needsRoleSelection;
/// 是否需要家长/监护人同意PIPL 第28条 — 学生角色)
final bool needsParentalConsent;
/// 是否需要班级码加入(学生/家长角色)
final bool needsClassCode;
/// 是否正在加载(班级码验证中)
final bool isLoading;
/// 班级码验证错误信息
final String? classCodeError;
/// 已选择的角色(角色选择后暂存)
final UserRoleType? selectedRole;
/// 家长同意时间戳
final DateTime? parentalConsentAt;
const Authenticated({
required this.user,
this.needsRoleSelection = false,
this.needsParentalConsent = false,
this.needsClassCode = false,
this.isLoading = false,
this.classCodeError,
this.selectedRole,
this.parentalConsentAt,
});
Authenticated copyWith({
User? user,
bool? needsRoleSelection,
bool? needsParentalConsent,
bool? needsClassCode,
bool? isLoading,
String? classCodeError,
UserRoleType? selectedRole,
DateTime? parentalConsentAt,
}) =>
Authenticated(
user: user ?? this.user,
needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection,
needsParentalConsent:
needsParentalConsent ?? this.needsParentalConsent,
needsClassCode: needsClassCode ?? this.needsClassCode,
isLoading: isLoading ?? this.isLoading,
classCodeError: classCodeError,
selectedRole: selectedRole ?? this.selectedRole,
parentalConsentAt: parentalConsentAt ?? this.parentalConsentAt,
);
}

View File

@@ -31,6 +31,19 @@ class _ClassCodeJoinPageState extends State<ClassCodeJoinPage> {
(_) => FocusNode(),
);
int _failedAttempts = 0;
DateTime? _lockoutEndTime;
/// 当前是否被锁定
bool get _isCurrentlyLocked {
if (_lockoutEndTime == null) return false;
if (DateTime.now().isAfter(_lockoutEndTime!)) {
_lockoutEndTime = null;
return false;
}
return true;
}
@override
void initState() {
super.initState();
@@ -58,6 +71,14 @@ class _ClassCodeJoinPageState extends State<ClassCodeJoinPage> {
bool get _isComplete =>
_controllers.every((c) => c.text.isNotEmpty);
/// 清空所有输入框
void _clearInputs() {
for (final c in _controllers) {
c.clear();
}
_focusNodes[0].requestFocus();
}
void _onChanged(int index, String value) {
if (value.isEmpty && index > 0) {
// 退格清空 → 跳到前一位
@@ -74,13 +95,48 @@ class _ClassCodeJoinPageState extends State<ClassCodeJoinPage> {
}
void _submit() {
if (!_isComplete) return;
if (!_isComplete || _isCurrentlyLocked) return;
context.read<AuthBloc>().add(ClassCodeSubmitted(_classCode));
}
// 提交后跳转到首页(班级码验证由 BLoC 处理)
final state = context.read<AuthBloc>().state;
if (state is Authenticated && !state.needsClassCode) {
/// 处理 AuthBloc 状态变化
void _handleAuthState(BuildContext context, AuthState state) {
if (state is! Authenticated) return;
// 成功加入班级
if (!state.needsClassCode) {
context.go('/home');
return;
}
// 班级码验证错误
final error = state.classCodeError;
if (error != null && error.isNotEmpty) {
_failedAttempts++;
// 检查是否为锁定错误
if (error.contains('30 分钟') || error.contains('尝试次数过多')) {
setState(() {
_lockoutEndTime = DateTime.now().add(
Duration(minutes: DesignTokens.classCodeLockoutMinutes),
);
});
}
// 清空输入
_clearInputs();
// 显示错误提示
if (mounted) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(error),
backgroundColor: Theme.of(context).colorScheme.error,
duration: const Duration(seconds: 3),
),
);
}
}
}
@@ -88,75 +144,136 @@ class _ClassCodeJoinPageState extends State<ClassCodeJoinPage> {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.spacing24,
),
child: Column(
children: [
const Spacer(flex: 2),
return BlocListener<AuthBloc, AuthState>(
listener: _handleAuthState,
child: Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.spacing24,
),
child: Column(
children: [
const Spacer(flex: 2),
// 标题
Icon(
Icons.groups_rounded,
size: 64,
color: colorScheme.primary,
),
const SizedBox(height: DesignTokens.spacing24),
Text(
'加入班级',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: DesignTokens.spacing8),
Text(
'输入老师提供的 6 位班级码',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: DesignTokens.spacing48),
// 6 位输入框
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
DesignTokens.classCodeLength,
(index) => _buildCodeInput(context, index, colorScheme),
// 标题
Icon(
Icons.groups_rounded,
size: 64,
color: colorScheme.primary,
),
),
const SizedBox(height: DesignTokens.spacing24),
const SizedBox(height: DesignTokens.spacing24),
Text(
'加入班级',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: DesignTokens.spacing8),
Text(
'输入老师提供的 6 位班级码',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: DesignTokens.spacing48),
// 跳过按钮
TextButton(
onPressed: () => context.go('/home'),
child: Text(
'稍后再加入',
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.5),
// 锁定状态 or 输入框
if (_isCurrentlyLocked)
_buildLockoutView(context, colorScheme)
else
_buildCodeInputs(context, colorScheme),
const SizedBox(height: DesignTokens.spacing24),
// 跳过按钮
TextButton(
onPressed: () => context.go('/home'),
child: Text(
'稍后再加入',
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
),
),
const Spacer(flex: 3),
],
// 剩余尝试次数提示
if (!_isCurrentlyLocked && _failedAttempts > 0)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'剩余 ${DesignTokens.classCodeMaxAttempts - _failedAttempts} 次尝试机会',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: _failedAttempts >= 4
? colorScheme.error
: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
),
const Spacer(flex: 3),
],
),
),
),
),
);
}
/// 锁定视图 — 超过最大尝试次数后显示
Widget _buildLockoutView(BuildContext context, ColorScheme colorScheme) {
return Column(
children: [
Icon(Icons.lock_outline, size: 48, color: colorScheme.error),
const SizedBox(height: 16),
Text(
'尝试次数过多',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'请等待 ${DesignTokens.classCodeLockoutMinutes} 分钟后再试',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
);
}
/// 6 位班级码输入框
Widget _buildCodeInputs(BuildContext context, ColorScheme colorScheme) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final isLoading = state is Authenticated && state.isLoading;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
DesignTokens.classCodeLength,
(index) => _buildCodeInput(context, index, colorScheme, isLoading),
),
);
},
);
}
/// 单个班级码输入框
Widget _buildCodeInput(BuildContext context, int index, ColorScheme colorScheme) {
Widget _buildCodeInput(
BuildContext context,
int index,
ColorScheme colorScheme,
bool isLoading,
) {
return SizedBox(
width: 48,
height: 56,
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
enabled: !isLoading,
textAlign: TextAlign.center,
textAlignVertical: TextAlignVertical.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(

View File

@@ -11,6 +11,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/design_tokens.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_radius.dart';
import '../bloc/auth_bloc.dart';
/// 登录/注册页面
@@ -29,6 +31,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
bool _isRegister = false;
bool _obscurePassword = true;
bool _agreedToTerms = false;
late final AnimationController _animController;
late final Animation<double> _fadeAnim;
@@ -59,6 +62,13 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
void _submit() {
if (!_formKey.currentState!.validate()) return;
if (_isRegister && !_agreedToTerms) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请先阅读并同意用户协议和隐私政策')),
);
return;
}
if (_isRegister) {
context.read<AuthBloc>().add(RegisterRequested(
username: _usernameController.text.trim(),
@@ -105,13 +115,20 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildHeader(context, colorScheme),
const SizedBox(height: DesignTokens.spacing48),
const SizedBox(height: DesignTokens.spacing32),
_buildForm(context, theme, colorScheme),
const SizedBox(height: DesignTokens.spacing24),
_buildSubmitButton(context, colorScheme),
const SizedBox(height: DesignTokens.spacing16),
_buildModeToggle(context, colorScheme),
const SizedBox(height: DesignTokens.spacing32),
const SizedBox(height: DesignTokens.spacing24),
// 协议复选框(注册模式下显示)
if (_isRegister) ...[
_buildAgreementRow(context, colorScheme),
const SizedBox(height: DesignTokens.spacing16),
],
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthError) {
@@ -120,6 +137,13 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
return const SizedBox.shrink();
},
),
// 社交登录分割线
const SizedBox(height: DesignTokens.spacing24),
_buildSocialLoginDivider(context, colorScheme),
const SizedBox(height: DesignTokens.spacing16),
_buildSocialLoginButtons(context, colorScheme),
const SizedBox(height: DesignTokens.spacing32),
],
),
),
@@ -131,37 +155,85 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
}
Widget _buildHeader(BuildContext context, ColorScheme colorScheme) {
return Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(22),
),
child: Icon(
Icons.edit_note_rounded,
size: 44,
color: colorScheme.primary,
),
final isDark = Theme.of(context).brightness == Brightness.dark;
final bgColor = isDark ? const Color(0xFF1A1614) : const Color(0xFFFFF8F0);
final tertiarySoft = isDark ? AppColors.tertiarySoftDark : AppColors.tertiarySoftLight;
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 40),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [bgColor, tertiarySoft],
),
const SizedBox(height: DesignTokens.spacing16),
Text(
'暖记',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.primary,
),
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
// 装饰圆圈
Positioned(left: 20, top: -10, child: _decorCircle(60, AppColors.accent, 0.15)),
Positioned(right: 30, top: 20, child: _decorCircle(40, AppColors.secondary, 0.12)),
Positioned(left: 80, bottom: -20, child: _decorCircle(30, AppColors.tertiary, 0.18)),
Positioned(right: 60, bottom: 10, child: _decorCircle(20, AppColors.accent, 0.10)),
Positioned(left: -10, top: 50, child: _decorCircle(25, AppColors.secondary, 0.13)),
Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo — 自定义笔记本图标
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
border: Border.all(color: AppColors.accent, width: 3),
borderRadius: AppRadius.lgBorder,
color: colorScheme.surface.withValues(alpha: 0.5),
),
child: const Icon(
Icons.edit_note_rounded,
size: 44,
color: AppColors.accent,
),
),
),
const SizedBox(height: DesignTokens.spacing4),
Text(
'记录温暖,书写成长',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
const SizedBox(height: DesignTokens.spacing16),
// 品牌名
Text(
'暖记',
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.accent,
),
),
),
],
const SizedBox(height: DesignTokens.spacing4),
// 标语 — Caveat 手写风格
Text(
'记录温暖,书写成长',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: AppColors.accent,
fontFamily: 'Caveat',
),
),
],
),
],
),
);
}
/// 装饰圆圈
Widget _decorCircle(double size, Color color, double opacity) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: color.withValues(alpha: opacity),
shape: BoxShape.circle,
),
);
}
@@ -181,13 +253,10 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
padding: const EdgeInsets.only(bottom: DesignTokens.spacing16),
child: TextFormField(
controller: _displayNameController,
decoration: InputDecoration(
decoration: const InputDecoration(
labelText: '昵称',
hintText: '你想被叫什么名字?',
prefixIcon: const Icon(Icons.face_rounded),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
),
prefixIcon: Icon(Icons.face_rounded),
),
textInputAction: TextInputAction.next,
),
@@ -201,9 +270,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
labelText: '账号',
hintText: _isRegister ? '设置一个账号名' : '输入你的账号',
prefixIcon: const Icon(Icons.person_rounded),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
),
),
textInputAction: TextInputAction.next,
validator: (value) {
@@ -236,9 +302,6 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
});
},
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
),
),
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
@@ -268,7 +331,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
onPressed: isLoading ? null : _submit,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.pillBorder,
),
),
child: isLoading
@@ -327,4 +390,160 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
),
);
}
/// 社交登录分割线
Widget _buildSocialLoginDivider(BuildContext context, ColorScheme colorScheme) {
final dividerColor = colorScheme.onSurface.withValues(alpha: 0.15);
return Row(
children: [
Expanded(child: Divider(color: dividerColor)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text('其他登录方式', style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
)),
),
Expanded(child: Divider(color: dividerColor)),
],
);
}
/// 社交登录按钮行
Widget _buildSocialLoginButtons(BuildContext context, ColorScheme colorScheme) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 微信
_SocialButton(
bgColor: const Color(0xFF07C160),
icon: Icons.chat_bubble,
semanticLabel: '微信登录',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('微信登录即将支持')),
);
},
),
const SizedBox(width: 24),
// Apple
_SocialButton(
bgColor: const Color(0xFF1D1D1F),
icon: Icons.apple,
semanticLabel: 'Apple 登录',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Apple 登录即将支持')),
);
},
),
const SizedBox(width: 24),
// Google
_SocialButton(
bgColor: colorScheme.surface,
borderColor: colorScheme.outlineVariant,
child: const Text('G', style: TextStyle(
fontSize: 24, fontWeight: FontWeight.w700, color: Color(0xFF4285F4),
)),
semanticLabel: 'Google 登录',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Google 登录即将支持')),
);
},
),
],
);
}
/// 协议复选框行
Widget _buildAgreementRow(BuildContext context, ColorScheme colorScheme) {
return Row(
children: [
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: _agreedToTerms,
onChanged: (v) => setState(() => _agreedToTerms = v ?? false),
activeColor: AppColors.accent,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
const SizedBox(width: 8),
Expanded(
child: Wrap(
children: [
Text('我已阅读并同意', style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
)),
GestureDetector(
onTap: () {
// TODO: 打开用户协议
},
child: Text('《用户协议》', style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.accent, fontWeight: FontWeight.w500,
)),
),
Text('', style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.6),
)),
GestureDetector(
onTap: () {
// TODO: 打开隐私政策
},
child: Text('《隐私政策》', style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: AppColors.accent, fontWeight: FontWeight.w500,
)),
),
],
),
),
],
);
}
}
/// 社交登录圆形按钮
class _SocialButton extends StatelessWidget {
const _SocialButton({
required this.bgColor,
required this.semanticLabel,
required this.onTap,
this.icon,
this.child,
this.borderColor,
});
final Color bgColor;
final Color? borderColor;
final IconData? icon;
final Widget? child;
final String semanticLabel;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 56,
height: 56,
child: Material(
color: bgColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
side: borderColor != null
? BorderSide(color: borderColor!, width: 1.5)
: BorderSide.none,
),
child: InkWell(
onTap: onTap,
customBorder: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
child: Center(
child: child ??
Icon(icon, size: 28, color: Colors.white),
),
),
),
);
}
}

View File

@@ -0,0 +1,254 @@
// 家长同意确认页面 — PIPL 第28条合规
//
// 未满 14 岁用户选择"学生"角色后,必须经过家长/监护人确认。
// 页面展示隐私政策要点,要求家长勾选同意并确认。
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';
import '../../../core/theme/app_radius.dart';
import '../bloc/auth_bloc.dart';
/// 家长同意确认页面
class ParentalConsentPage extends StatefulWidget {
const ParentalConsentPage({super.key});
@override
State<ParentalConsentPage> createState() => _ParentalConsentPageState();
}
class _ParentalConsentPageState extends State<ParentalConsentPage> {
bool _consentGiven = false;
bool _privacyPolicyAccepted = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final canProceed = _consentGiven && _privacyPolicyAccepted;
return Scaffold(
backgroundColor: theme.colorScheme.surface,
appBar: AppBar(
title: const Text('家长/监护人确认'),
backgroundColor: theme.colorScheme.surface,
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(DesignTokens.spacing16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题
Icon(
Icons.shield_rounded,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: DesignTokens.spacing12),
Text(
'儿童个人信息保护',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: DesignTokens.spacing8),
Text(
'根据《中华人民共和国个人信息保护法》第28条'
'未满14周岁未成年人的个人信息处理需要取得父母或监护人的同意。',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: DesignTokens.spacing24),
// 信息收集说明卡片
_buildInfoCard(
context,
icon: Icons.info_outline_rounded,
title: '我们会收集哪些信息',
items: const [
'昵称和年级(不收集真实姓名和身份证号)',
'日记内容和手写笔画',
'心情标签和照片',
],
),
const SizedBox(height: DesignTokens.spacing12),
// 用途说明卡片
_buildInfoCard(
context,
icon: Icons.security_rounded,
title: '信息如何保护',
items: const [
'所有数据加密存储和传输',
'仅用于日记记录和班级互动',
'不会用于商业广告或分享给第三方',
'您可以随时查阅、更正或删除孩子数据',
],
),
const SizedBox(height: DesignTokens.spacing24),
// 同意复选框
_buildCheckbox(
value: _privacyPolicyAccepted,
onChanged: (v) =>
setState(() => _privacyPolicyAccepted = v ?? false),
text: '我已阅读并同意《暖记隐私政策》和《儿童个人信息保护规则》',
),
const SizedBox(height: DesignTokens.spacing4),
_buildCheckbox(
value: _consentGiven,
onChanged: (v) => setState(() => _consentGiven = v ?? false),
text: '我是该用户的家长/监护人,同意暖记收集和处理上述信息',
),
const SizedBox(height: DesignTokens.spacing32),
// 确认按钮
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: canProceed ? _onConfirm : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: DesignTokens.spacing12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.pill),
),
),
child: const Text('确认同意,继续'),
),
),
const SizedBox(height: DesignTokens.spacing8),
// 拒绝按钮
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => context.pop(),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: DesignTokens.spacing12,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.pill),
),
),
child: const Text('不同意,返回'),
),
),
],
),
),
),
);
}
Widget _buildInfoCard(
BuildContext context, {
required IconData icon,
required String title,
required List<String> items,
}) {
final theme = Theme.of(context);
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.lg),
),
child: Padding(
padding: const EdgeInsets.all(DesignTokens.spacing12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: theme.colorScheme.primary),
const SizedBox(width: DesignTokens.spacing8),
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: DesignTokens.spacing8),
...items.map(
(item) => Padding(
padding: const EdgeInsets.only(
bottom: DesignTokens.spacing4,
left: DesignTokens.spacing12,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
),
),
Expanded(
child: Text(
item,
style: theme.textTheme.bodySmall,
),
),
],
),
),
),
],
),
),
);
}
Widget _buildCheckbox({
required bool value,
required ValueChanged<bool?> onChanged,
required String text,
}) {
final theme = Theme.of(context);
return InkWell(
onTap: () => onChanged(!value),
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: DesignTokens.spacing4,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: value,
onChanged: onChanged,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
const SizedBox(width: DesignTokens.spacing4),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(
text,
style: theme.textTheme.bodySmall,
),
),
),
],
),
),
);
}
/// 确认同意 — 发出事件继续注册流程
void _onConfirm() {
final consentAt = DateTime.now();
context.read<AuthBloc>().add(ParentalConsentAccepted(consentAt));
}
}

View File

@@ -11,6 +11,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/design_tokens.dart';
import '../../../core/theme/app_radius.dart';
import '../../../data/models/user.dart';
import '../bloc/auth_bloc.dart';
@@ -146,11 +147,11 @@ class _RoleCardWidget extends StatelessWidget {
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(22),
borderRadius: AppRadius.lgBorder,
child: Ink(
decoration: BoxDecoration(
color: role.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(22),
borderRadius: AppRadius.lgBorder,
border: Border.all(
color: role.color.withValues(alpha: 0.3),
width: 2,

View File

@@ -1,7 +1,9 @@
// 日历 BLoC — 管理日历视图状态和日记列表
// 日历 BLoC — 管理日历视图状态,通过 JournalRepository 加载数据
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
// ===== Events =====
@@ -21,18 +23,12 @@ final class CalendarDaySelected extends CalendarEvent {
const CalendarDaySelected(this.day);
}
/// 切换视图模式(月/周/时间轴)
/// 切换视图模式
final class CalendarViewModeChanged extends CalendarEvent {
final CalendarViewMode mode;
const CalendarViewModeChanged(this.mode);
}
/// 加载某月的日记列表
final class CalendarLoadJournals extends CalendarEvent {
final DateTime month;
const CalendarLoadJournals(this.month);
}
// ===== State =====
/// 日历视图模式
@@ -43,29 +39,17 @@ sealed class CalendarState {
const CalendarState();
}
/// 初始加载中
final class CalendarInitial extends CalendarState {
const CalendarInitial();
}
/// 日历已加载 — 包含当前月份、选中日期、日记列表
/// 日历已加载
final class CalendarLoaded extends CalendarState {
/// 当前显示的月份
final DateTime focusedMonth;
/// 选中的日期
final DateTime selectedDay;
/// 当前月份所有日记(按日期索引)
final Map<DateTime, List<JournalEntry>> journalsByDate;
/// 当前选中日期的日记列表
final List<JournalEntry> selectedDayJournals;
/// 视图模式
final CalendarViewMode viewMode;
/// 是否正在加载
final bool isLoading;
const CalendarLoaded({
@@ -95,7 +79,6 @@ final class CalendarLoaded extends CalendarState {
);
}
/// 加载失败
final class CalendarError extends CalendarState {
final String message;
const CalendarError(this.message);
@@ -104,17 +87,20 @@ final class CalendarError extends CalendarState {
// ===== BLoC =====
class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
CalendarBloc() : super(const CalendarInitial()) {
final JournalRepository _journalRepo;
CalendarBloc({required JournalRepository journalRepository})
: _journalRepo = journalRepository,
super(const CalendarInitial()) {
on<CalendarMonthChanged>(_onMonthChanged);
on<CalendarDaySelected>(_onDaySelected);
on<CalendarViewModeChanged>(_onViewModeChanged);
on<CalendarLoadJournals>(_onLoadJournals);
}
void _onMonthChanged(
Future<void> _onMonthChanged(
CalendarMonthChanged event,
Emitter<CalendarState> emit,
) {
) async {
final currentState = state is CalendarLoaded ? state as CalendarLoaded : null;
emit(CalendarLoaded(
@@ -123,9 +109,47 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
journalsByDate: currentState?.journalsByDate ?? {},
selectedDayJournals: [],
viewMode: currentState?.viewMode ?? CalendarViewMode.month,
isLoading: true,
));
add(CalendarLoadJournals(event.month));
try {
// 加载当月日记
final startOfMonth = DateTime(event.month.year, event.month.month, 1);
final endOfMonth = DateTime(event.month.year, event.month.month + 1, 0);
final journals = await _journalRepo.getJournals(
dateFrom: startOfMonth,
dateTo: endOfMonth,
);
// 按日期索引
final byDate = <DateTime, List<JournalEntry>>{};
for (final journal in journals) {
final key = DateTime(journal.date.year, journal.date.month, journal.date.day);
byDate.putIfAbsent(key, () => []).add(journal);
}
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,
));
}
} catch (e) {
debugPrint('CalendarBloc._onMonthChanged 失败: $e');
if (state is CalendarLoaded) {
emit((state as CalendarLoaded).copyWith(isLoading: false));
}
}
}
void _onDaySelected(
@@ -135,7 +159,6 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
if (state is! CalendarLoaded) return;
final current = state as CalendarLoaded;
// 查找选中日期的日记
final dayKey = DateTime(event.day.year, event.day.month, event.day.day);
final dayJournals = current.journalsByDate[dayKey] ?? [];
@@ -150,26 +173,6 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
Emitter<CalendarState> emit,
) {
if (state is! CalendarLoaded) return;
final current = state as CalendarLoaded;
emit(current.copyWith(viewMode: event.mode));
}
Future<void> _onLoadJournals(
CalendarLoadJournals event,
Emitter<CalendarState> emit,
) async {
if (state is! CalendarLoaded) return;
final current = state as CalendarLoaded;
emit(current.copyWith(isLoading: true));
// Phase 1: 使用空数据占位,待 Repository 集成后替换
// 实际将从 JournalRepository.loadByMonth(event.month) 获取
await Future.delayed(const Duration(milliseconds: 300));
emit(current.copyWith(
isLoading: false,
journalsByDate: current.journalsByDate,
));
emit((state as CalendarLoaded).copyWith(viewMode: event.mode));
}
}

View File

@@ -1,21 +1,27 @@
// 日历页面 — 月视图 + 日记列表
// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
// 对齐 Open Design 原型稿: 日历格子用心情颜色填充背景 + 月/周/时间轴切换
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
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';
/// 日历页面 — 月视图 + 选中日期的日记列表
/// 日历页面 — 月视图(心情色彩) + 周视图 + 时间轴
class CalendarPage extends StatelessWidget {
const CalendarPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CalendarBloc()
..add(CalendarMonthChanged(DateTime.now())),
create: (context) => CalendarBloc(
journalRepository: context.read<JournalRepository>(),
)..add(CalendarMonthChanged(DateTime.now())),
child: const _CalendarView(),
);
}
@@ -36,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(
@@ -76,29 +74,38 @@ class _CalendarView extends StatelessWidget {
);
context.read<CalendarBloc>().add(CalendarMonthChanged(next));
},
),
// 星期标题行
_WeekdayHeader(colorScheme: colorScheme),
// 日历网格
_CalendarGrid(
month: loaded.focusedMonth,
selectedDay: loaded.selectedDay,
journalsByDate: loaded.journalsByDate,
onDaySelected: (day) {
context.read<CalendarBloc>().add(CalendarDaySelected(day));
onToday: () {
context.read<CalendarBloc>().add(CalendarMonthChanged(DateTime.now()));
},
),
const Divider(height: 1),
// 选中日期的日记列表
Expanded(
child: loaded.selectedDayJournals.isEmpty
? _EmptyDayView(selectedDay: loaded.selectedDay)
: _DayJournalList(journals: loaded.selectedDayJournals),
// 视图模式切换
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: SegmentedButton<CalendarViewMode>(
segments: const [
ButtonSegment(value: CalendarViewMode.month, label: Text('')),
ButtonSegment(value: CalendarViewMode.week, label: Text('')),
ButtonSegment(value: CalendarViewMode.timeline, label: Text('时间轴')),
],
selected: {loaded.viewMode},
onSelectionChanged: (modes) {
context.read<CalendarBloc>()
.add(CalendarViewModeChanged(modes.first));
},
style: ButtonStyle(
visualDensity: VisualDensity.compact,
textStyle: WidgetStatePropertyAll(theme.textTheme.labelSmall),
),
),
),
// 根据视图模式切换内容
switch (loaded.viewMode) {
CalendarViewMode.month => _MonthView(loaded: loaded),
CalendarViewMode.week => _WeekView(loaded: loaded),
CalendarViewMode.timeline => _TimelineView(loaded: loaded),
},
],
);
},
@@ -106,17 +113,437 @@ class _CalendarView extends StatelessWidget {
}
}
/// 月份导航栏
// ===== 月视图 =====
class _MonthView extends StatelessWidget {
const _MonthView({required this.loaded});
final CalendarLoaded loaded;
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
children: [
// 本月心情概览柱状图
_MoodSummaryChart(journalsByDate: loaded.journalsByDate),
const SizedBox(height: 8),
// 星期标题行
_WeekdayHeader(colorScheme: Theme.of(context).colorScheme),
// 日历网格
_CalendarGrid(
month: loaded.focusedMonth,
selectedDay: loaded.selectedDay,
journalsByDate: loaded.journalsByDate,
onDaySelected: (day) {
context.read<CalendarBloc>().add(CalendarDaySelected(day));
},
),
const Divider(height: 1),
// 选中日期的日记列表
Expanded(
child: loaded.selectedDayJournals.isEmpty
? _EmptyDayView(selectedDay: loaded.selectedDay)
: _DayJournalList(journals: loaded.selectedDayJournals),
),
],
),
);
}
}
/// 本月心情概览 — 5 柱状图
class _MoodSummaryChart extends StatelessWidget {
const _MoodSummaryChart({required this.journalsByDate});
final Map<DateTime, List<JournalEntry>> journalsByDate;
static const _moodConfig = [
(Mood.happy, '开心', AppColors.secondary),
(Mood.calm, '平静', AppColors.tertiary),
(Mood.sad, '难过', Color(0xFF5B7DB1)),
(Mood.angry, '生气', AppColors.accent),
(Mood.thinking, '思考', Color(0xFF8B7E74)),
];
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// 统计每种心情的数量
final counts = <Mood, int>{};
for (final entry in journalsByDate.entries) {
for (final journal in entry.value) {
counts[journal.mood] = (counts[journal.mood] ?? 0) + 1;
}
}
final maxCount = counts.values.fold(0, (a, b) => a > b ? a : b).toDouble();
final barMaxHeight = 60.0;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.colorScheme.shadow.withValues(alpha: 0.05),
offset: const Offset(0, 2),
blurRadius: 8,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本月心情概览',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: _moodConfig.map((config) {
final count = counts[config.$1] ?? 0;
final barHeight = maxCount > 0 && count > 0
? (count / maxCount * barMaxHeight).clamp(4.0, barMaxHeight)
: 4.0;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: barHeight,
decoration: BoxDecoration(
color: config.$3,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(6),
),
),
),
const SizedBox(height: 6),
Text(
config.$2,
style: TextStyle(
fontSize: 11,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}).toList(),
),
],
),
);
}
}
// ===== 周视图 =====
class _WeekView extends StatelessWidget {
const _WeekView({required this.loaded});
final CalendarLoaded loaded;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// 计算选中日期所在周
final selected = loaded.selectedDay;
final startOfWeek = selected.subtract(Duration(days: selected.weekday - 1));
return Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// 7 天条目
...List.generate(7, (i) {
final day = startOfWeek.add(Duration(days: i));
final dayKey = DateTime(day.year, day.month, day.day);
final journals = loaded.journalsByDate[dayKey] ?? [];
final isToday = _isToday(day);
final isSelected = _isSameDay(day, loaded.selectedDay);
final hasEntry = journals.isNotEmpty;
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: InkWell(
onTap: () {
context.read<CalendarBloc>().add(CalendarDaySelected(day));
},
borderRadius: AppRadius.mdBorder,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primaryContainer
: hasEntry
? _getMoodBgColor(journals.first.mood.value)
: colorScheme.surface,
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: isToday
? colorScheme.primary
: colorScheme.outlineVariant,
width: isToday ? 2 : 1,
),
),
child: Row(
children: [
// 日期
SizedBox(
width: 48,
child: Column(
children: [
Text(
_weekdayName(day.weekday),
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
Text(
'${day.day}',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: isToday ? FontWeight.bold : FontWeight.normal,
color: isToday ? colorScheme.primary : null,
),
),
],
),
),
const SizedBox(width: 16),
// 内容
Expanded(
child: hasEntry
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
journals.first.title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (journals.length > 1)
Text(
'还有 ${journals.length - 1} 篇日记',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
)
: Text(
'无日记',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
),
),
// 心情 emoji
if (hasEntry)
Text(
moodToEmoji(journals.first.mood),
style: const TextStyle(fontSize: 24),
),
],
),
),
),
);
}),
],
),
);
}
}
// ===== 时间轴视图 =====
class _TimelineView extends StatelessWidget {
const _TimelineView({required this.loaded});
final CalendarLoaded loaded;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// 获取当月所有日记,按日期排序
final allJournals = loaded.journalsByDate.entries
.expand((e) => e.value.map((j) => MapEntry(e.key, j)))
.toList()
..sort((a, b) => b.key.compareTo(a.key));
if (allJournals.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.timeline_outlined, size: 48,
color: colorScheme.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 16),
Text('本月还没有日记', style: theme.textTheme.bodyLarge),
],
),
);
}
return Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: allJournals.length,
itemBuilder: (context, index) {
final entry = allJournals[index];
final date = entry.key;
final journal = entry.value;
final isLast = index == allJournals.length - 1;
// 时间格式化 HH:mm
final timeStr =
'${journal.createdAt.hour.toString().padLeft(2, '0')}:${journal.createdAt.minute.toString().padLeft(2, '0')}';
// 摘要文本
final excerpt = journal.contentExcerpt ?? '';
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 时间轴线
SizedBox(
width: 60,
child: Column(
children: [
// 圆点 + emoji
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _getMoodBgColor(journal.mood.value),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(moodToEmoji(journal.mood), style: const TextStyle(fontSize: 20)),
),
// 竖线
if (!isLast)
Expanded(
child: Container(
width: 2,
color: colorScheme.outlineVariant,
),
),
],
),
),
// 内容卡片
Expanded(
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () => context.push('/editor?id=${journal.id}'),
borderRadius: AppRadius.mdBorder,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'${date.month}${date.day}',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 8),
Text(
timeStr,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
),
),
],
),
const SizedBox(height: 4),
Text(
journal.title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (excerpt.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
excerpt,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
],
),
),
),
),
),
),
],
),
);
},
),
);
}
}
// ===== 月份导航栏 =====
class _MonthNavigator extends StatelessWidget {
const _MonthNavigator({
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) {
@@ -125,13 +552,23 @@ class _MonthNavigator extends StatelessWidget {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: onPrevious,
icon: const Icon(Icons.chevron_left),
tooltip: '上个月',
SizedBox(
width: 44,
height: 44,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
shape: const CircleBorder(),
padding: EdgeInsets.zero,
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
onPressed: onPrevious,
child: const Icon(Icons.chevron_left, size: 20),
),
),
Text(
monthName,
@@ -139,10 +576,36 @@ class _MonthNavigator extends StatelessWidget {
fontWeight: FontWeight.bold,
),
),
IconButton(
onPressed: onNext,
icon: const Icon(Icons.chevron_right),
tooltip: '下个月',
// "今天" 按钮 — 不在当前月时显示
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,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
shape: const CircleBorder(),
padding: EdgeInsets.zero,
side: BorderSide(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
onPressed: onNext,
child: const Icon(Icons.chevron_right, size: 20),
),
),
],
),
@@ -156,9 +619,15 @@ 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;
}
}
/// 星期标题行
// ===== 星期标题行 =====
class _WeekdayHeader extends StatelessWidget {
const _WeekdayHeader({required this.colorScheme});
@@ -189,7 +658,8 @@ class _WeekdayHeader extends StatelessWidget {
}
}
/// 日历网格 — 6行7列
// ===== 日历网格 — 6行7列(带心情色彩背景)=====
class _CalendarGrid extends StatelessWidget {
const _CalendarGrid({
required this.month,
@@ -205,8 +675,6 @@ class _CalendarGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final today = DateTime.now();
final days = _generateDays(month);
return Padding(
@@ -215,19 +683,18 @@ class _CalendarGrid extends StatelessWidget {
children: days.map((week) {
return Row(
children: week.map((dayInfo) {
final dayKey = DateTime(
dayInfo.date.year, dayInfo.date.month, dayInfo.date.day);
final journals = journalsByDate[dayKey] ?? [];
final moodValue = journals.isNotEmpty ? journals.first.mood.value : null;
return Expanded(
child: _DayCell(
dayInfo: dayInfo,
isToday: dayInfo.date.year == today.year &&
dayInfo.date.month == today.month &&
dayInfo.date.day == today.day,
isSelected: dayInfo.date.year == selectedDay.year &&
dayInfo.date.month == selectedDay.month &&
dayInfo.date.day == selectedDay.day,
hasJournals: journalsByDate.containsKey(
DateTime(dayInfo.date.year, dayInfo.date.month, dayInfo.date.day),
),
colorScheme: colorScheme,
isToday: _isToday(dayInfo.date),
isSelected: _isSameDay(dayInfo.date, selectedDay),
hasJournals: journals.isNotEmpty,
moodValue: moodValue,
onTap: () => onDaySelected(dayInfo.date),
),
);
@@ -238,35 +705,26 @@ class _CalendarGrid extends StatelessWidget {
);
}
/// 生成当月日历数据(包含前后补齐)
List<List<_DayInfo>> _generateDays(DateTime month) {
final firstDay = DateTime(month.year, month.month, 1);
// 周一为第一天weekday 1=Mon...7=Sun
final startOffset = firstDay.weekday - 1;
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
final allDays = <_DayInfo>[];
// 前面的空白
for (var i = 0; i < startOffset; i++) {
allDays.add(_DayInfo(date: firstDay.subtract(Duration(days: startOffset - i)), isCurrentMonth: false));
}
// 当月日期
for (var d = 1; d <= daysInMonth; d++) {
allDays.add(_DayInfo(
date: DateTime(month.year, month.month, d),
isCurrentMonth: true,
));
allDays.add(_DayInfo(date: DateTime(month.year, month.month, d), isCurrentMonth: true));
}
// 后面的补齐到完整行
while (allDays.length % 7 != 0) {
final last = allDays.last.date;
allDays.add(_DayInfo(date: last.add(const Duration(days: 1)), isCurrentMonth: false));
}
// 分周
return List.generate(allDays.length ~/ 7, (i) => allDays.sublist(i * 7, (i + 1) * 7));
}
}
@@ -277,14 +735,15 @@ class _DayInfo {
final bool isCurrentMonth;
}
/// 单日格子
// ===== 单日格子(带心情色彩背景)=====
class _DayCell extends StatelessWidget {
const _DayCell({
required this.dayInfo,
required this.isToday,
required this.isSelected,
required this.hasJournals,
required this.colorScheme,
required this.moodValue,
required this.onTap,
});
@@ -292,56 +751,84 @@ class _DayCell extends StatelessWidget {
final bool isToday;
final bool isSelected;
final bool hasJournals;
final ColorScheme colorScheme;
final String? moodValue;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// 心情色彩背景
final moodBg = hasJournals && moodValue != null
? _getMoodBgColor(moodValue!)
: Colors.transparent;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
height: 44,
height: 48,
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isSelected
? colorScheme.primary
: moodBg != Colors.transparent
? moodBg
: isToday
? colorScheme.primaryContainer
: null,
borderRadius: AppRadius.smBorder,
border: isToday && !isSelected
? Border.all(color: colorScheme.primary, width: 2)
: null,
),
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? colorScheme.primary
: isToday
? colorScheme.primaryContainer
: null,
),
alignment: Alignment.center,
child: Text(
'${dayInfo.date.day}',
style: TextStyle(
fontSize: 14,
fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal,
color: !dayInfo.isCurrentMonth
? colorScheme.onSurface.withValues(alpha: 0.3)
: isSelected
? colorScheme.onPrimary
: colorScheme.onSurface,
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${dayInfo.date.day}',
style: TextStyle(
fontSize: 14,
fontWeight: isSelected || isToday ? FontWeight.bold : FontWeight.normal,
color: !dayInfo.isCurrentMonth
? colorScheme.onSurface.withValues(alpha: 0.3)
: isSelected
? colorScheme.onPrimary
: colorScheme.onSurface,
),
),
),
// 心情小圆点(仅在有日记但无背景色时显示)
if (hasJournals && moodBg == Colors.transparent)
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(top: 1),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected ? colorScheme.onPrimary : AppColors.accent,
),
),
],
),
// 日记指示点
// 日记条目指示点
if (hasJournals)
Container(
width: 4,
height: 4,
margin: const EdgeInsets.only(top: 2),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? colorScheme.onPrimary
: AppColors.accent,
Positioned(
top: 3,
right: 5,
child: Container(
width: 5,
height: 5,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isSelected
? colorScheme.onPrimary
: AppColors.accent,
),
),
),
],
@@ -351,7 +838,8 @@ class _DayCell extends StatelessWidget {
}
}
/// 无日记的空状态
// ===== 无日记的空状态 =====
class _EmptyDayView extends StatelessWidget {
const _EmptyDayView({required this.selectedDay});
@@ -383,7 +871,7 @@ class _EmptyDayView extends StatelessWidget {
),
const SizedBox(height: 24),
FilledButton.tonal(
onPressed: () => context.go('/editor'),
onPressed: () => context.push('/editor'),
child: const Text('写一篇'),
),
],
@@ -392,7 +880,8 @@ class _EmptyDayView extends StatelessWidget {
}
}
/// 日记列表
// ===== 日记列表 =====
class _DayJournalList extends StatelessWidget {
const _DayJournalList({required this.journals});
@@ -414,17 +903,16 @@ class _DayJournalList extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () => context.go('/editor?id=${journal.id}'),
borderRadius: BorderRadius.circular(16),
onTap: () => context.push('/editor?id=${journal.id}'),
borderRadius: AppRadius.mdBorder,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
// 心情图标
Container(
width: 40,
height: 40,
@@ -434,12 +922,11 @@ class _DayJournalList extends StatelessWidget {
),
alignment: Alignment.center,
child: Text(
_moodEmoji(journal.mood),
moodToEmoji(journal.mood),
style: const TextStyle(fontSize: 20),
),
),
const SizedBox(width: 12),
// 标题和标签
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -476,14 +963,36 @@ class _DayJournalList extends StatelessWidget {
},
);
}
String _moodEmoji(Mood mood) {
return switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
}
}
// ===== 辅助函数 =====
/// 获取心情对应的日历格子背景色
Color _getMoodBgColor(String mood) {
return AppColors.moodCellColors[mood] ?? AppColors.secondarySoftLight;
}
/// 是否是今天
bool _isToday(DateTime date) {
final now = DateTime.now();
return date.year == now.year && date.month == now.month && date.day == now.day;
}
/// 是否同一天
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
/// 周几名称
String _weekdayName(int weekday) {
return switch (weekday) {
1 => '周一',
2 => '周二',
3 => '周三',
4 => '周四',
5 => '周五',
6 => '周六',
7 => '周日',
_ => '',
};
}

View File

@@ -0,0 +1,690 @@
// 月度概览页面 — 心情色彩月历 + 月度统计 + 精选日记
// 对齐 Open Design 原型稿 screens/monthly.html
import 'package:flutter/material.dart';
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/core/theme/app_shadows.dart';
import 'package:nuanji_app/core/theme/app_typography.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/journal_element.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
/// 月度概览页面
class MonthlyPage extends StatefulWidget {
final JournalRepository? journalRepository;
const MonthlyPage({super.key, this.journalRepository});
@override
State<MonthlyPage> createState() => _MonthlyPageState();
}
class _MonthlyPageState extends State<MonthlyPage> {
late DateTime _focusedMonth;
List<JournalEntry> _journals = [];
int _photoCount = 0;
@override
void initState() {
super.initState();
_focusedMonth = DateTime.now();
_loadJournals();
}
JournalRepository get _repo =>
widget.journalRepository ?? context.read<JournalRepository>();
Future<void> _loadJournals() async {
final firstDay = DateTime(_focusedMonth.year, _focusedMonth.month, 1);
// 下月 1 号作为上界(开区间),所以用 month+1
final nextMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1, 1);
final journals = await _repo.getJournals(
dateFrom: firstDay,
dateTo: nextMonth,
);
// 统计照片元素数量
var photoCount = 0;
for (final journal in journals) {
try {
final elements = await _repo.getElements(journal.id);
photoCount += elements.where((e) => e.elementType == ElementType.image).length;
} catch (e) {
debugPrint('MonthlyPage: 加载日记 ${journal.id} 元素失败: $e');
// 单个日记加载元素失败不影响整体统计
}
}
if (mounted) {
setState(() {
_journals = journals;
_photoCount = photoCount;
});
}
}
void _goToPreviousMonth() {
setState(() {
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1);
});
_loadJournals();
}
void _goToNextMonth() {
setState(() {
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1);
});
_loadJournals();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 月头部导航
_MonthHeader(
month: _focusedMonth,
onPrevious: _goToPreviousMonth,
onNext: _goToNextMonth,
),
// 可滚动内容区
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
const SizedBox(height: 16),
// 心情色彩月历
_MoodCalendar(month: _focusedMonth, journals: _journals),
const SizedBox(height: 20),
// 月度统计 2x2
_MonthSummary(journals: _journals, photoCount: _photoCount),
const SizedBox(height: 20),
// 精选日记
_Highlights(journals: _journals),
const SizedBox(height: 32),
],
),
),
],
),
),
);
}
}
// ===== 月头部导航 =====
class _MonthHeader extends StatelessWidget {
const _MonthHeader({
required this.month,
required this.onPrevious,
required this.onNext,
});
final DateTime month;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final title = '${month.year}${month.month}';
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 12, 0),
child: Row(
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontFamily: AppTypography.displayFont,
fontWeight: FontWeight.w700,
),
),
),
_NavButton(
icon: Icons.chevron_left_rounded,
onTap: onPrevious,
borderColor: colorScheme.outline,
foregroundColor: colorScheme.onSurface,
),
const SizedBox(width: 8),
_NavButton(
icon: Icons.chevron_right_rounded,
onTap: onNext,
borderColor: colorScheme.outline,
foregroundColor: colorScheme.onSurface,
),
],
),
);
}
}
/// 圆形导航按钮 (44px 触摸目标)
class _NavButton extends StatelessWidget {
const _NavButton({
required this.icon,
required this.onTap,
required this.borderColor,
required this.foregroundColor,
});
final IconData icon;
final VoidCallback onTap;
final Color borderColor;
final Color foregroundColor;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 44,
height: 44,
child: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
shape: const CircleBorder(),
side: BorderSide(color: borderColor, width: 1.5),
foregroundColor: foregroundColor,
backgroundColor: Theme.of(context).colorScheme.surface,
),
child: Icon(icon, size: 18),
),
);
}
}
// ===== 心情色彩月历 =====
class _MoodCalendar extends StatelessWidget {
const _MoodCalendar({required this.month, required this.journals});
final DateTime month;
final List<JournalEntry> journals;
// 心情 → emoji对齐 Mood 枚举: happy/calm/sad/angry/thinking
static const _moodEmojis = <Mood, String>{
Mood.happy: '😊',
Mood.calm: '😌',
Mood.sad: '😢',
Mood.angry: '😡',
Mood.thinking: '🤔',
};
// 心情 → 背景色
static const _moodBgColors = <Mood, Color>{
Mood.happy: AppColors.secondarySoftLight,
Mood.angry: AppColors.roseSoftLight,
Mood.calm: AppColors.tertiarySoftLight,
Mood.sad: Color(0xFFD4DDE8),
Mood.thinking: Color(0xFFE8E4E0),
};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final now = DateTime.now();
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: AppRadius.mdBorder,
boxShadow: AppShadows.soft(context),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
children: [
// 星期标题行
_WeekdayRow(colorScheme: colorScheme),
const SizedBox(height: 8),
// 7列网格
_buildGrid(context, now),
],
),
);
}
Widget _buildGrid(BuildContext context, DateTime now) {
final firstDay = DateTime(month.year, month.month, 1);
// 周一=0 → 偏移量; weekday 返回 1(周一)..7(周日)
final startOffset = firstDay.weekday - 1; // 周一开头
final daysInMonth = DateTime(month.year, month.month + 1, 0).day;
// 按日期建索引day → JournalEntry
final journalByDay = <int, JournalEntry>{};
for (final j in journals) {
if (j.date.year == month.year && j.date.month == month.month) {
journalByDay[j.date.day] = j;
}
}
final cells = <Widget>[];
// 空白填充
for (var i = 0; i < startOffset; i++) {
cells.add(const SizedBox.shrink());
}
for (var d = 1; d <= daysInMonth; d++) {
final isToday = now.year == month.year &&
now.month == month.month &&
now.day == d;
final entry = journalByDay[d];
final mood = entry?.mood;
final bgColor =
mood != null ? (_moodBgColors[mood] ?? Colors.transparent) : Colors.transparent;
final emoji = mood != null ? (_moodEmojis[mood] ?? '') : '';
cells.add(
_MoodCell(
day: d,
emoji: emoji,
bgColor: bgColor,
isToday: isToday,
),
);
}
return GridView.count(
crossAxisCount: 7,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 3,
crossAxisSpacing: 3,
childAspectRatio: 1,
children: cells,
);
}
}
/// 星期标题行
class _WeekdayRow extends StatelessWidget {
const _WeekdayRow({required this.colorScheme});
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
const weekdays = ['', '', '', '', '', '', ''];
return Row(
children: weekdays.map((day) {
return Expanded(
child: Center(
child: Text(
day,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: colorScheme.onSurfaceVariant,
),
),
),
);
}).toList(),
);
}
}
/// 单个心情格子
class _MoodCell extends StatelessWidget {
const _MoodCell({
required this.day,
required this.emoji,
required this.bgColor,
required this.isToday,
});
final int day;
final String emoji;
final Color bgColor;
final bool isToday;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: () {
// TODO: 选择日期,跳转详情
},
child: Container(
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(6),
border: isToday
? Border.all(color: AppColors.accent, width: 2)
: null,
),
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$day',
style: TextStyle(
fontSize: 12,
color: colorScheme.onSurface,
fontWeight: isToday ? FontWeight.w700 : FontWeight.w400,
),
),
const SizedBox(height: 1),
Text(
emoji,
style: const TextStyle(fontSize: 10),
),
],
),
),
);
}
}
// ===== 月度统计 2x2 =====
class _MonthSummary extends StatelessWidget {
const _MonthSummary({required this.journals, required this.photoCount});
final List<JournalEntry> journals;
final int photoCount;
/// 计算最长连续写日记天数
int _calcLongestStreak() {
if (journals.isEmpty) return 0;
final days = journals.map((j) => j.date.day).toSet().toList()..sort();
int longest = 1;
int current = 1;
for (var i = 1; i < days.length; i++) {
if (days[i] == days[i - 1] + 1) {
current++;
if (current > longest) longest = current;
} else {
current = 1;
}
}
return longest;
}
/// 计算"好心情"happy/calm占比
String _calcGoodMoodPercent() {
if (journals.isEmpty) return '0%';
final good = journals.where(
(j) => j.mood == Mood.happy || j.mood == Mood.calm,
).length;
return '${((good / journals.length) * 100).round()}%';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本月总结',
style: theme.textTheme.headlineSmall?.copyWith(
fontFamily: AppTypography.displayFont,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.2,
children: [
_StatCard(
icon: '📝',
value: '${journals.length}',
label: '日记篇数',
bgColor: AppColors.tertiarySoftLight,
valueColor: const Color(0xFFB8860B),
),
_StatCard(
icon: '🔥',
value: '${_calcLongestStreak()}',
label: '最长连续',
bgColor: AppColors.secondarySoftLight,
valueColor: const Color(0xFF2D7D46),
),
_StatCard(
icon: '😊',
value: _calcGoodMoodPercent(),
label: '好心情占比',
bgColor: AppColors.roseSoftLight,
valueColor: const Color(0xFF9B4D4D),
),
_StatCard(
icon: '📸',
value: '$photoCount',
label: '照片数量',
bgColor: const Color(0xFFD4DDE8),
valueColor: const Color(0xFF4A6B8A),
),
],
),
],
);
}
}
/// 单张统计小卡片
class _StatCard extends StatelessWidget {
const _StatCard({
required this.icon,
required this.value,
required this.label,
required this.bgColor,
required this.valueColor,
});
final String icon;
final String value;
final String label;
final Color bgColor;
final Color valueColor;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: bgColor,
borderRadius: AppRadius.mdBorder,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(icon, style: const TextStyle(fontSize: 24)),
const SizedBox(height: 8),
Text(
value,
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 24,
fontWeight: FontWeight.w700,
color: valueColor,
),
),
const SizedBox(height: 2),
Text(
label,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
// ===== 精选日记 =====
class _Highlights extends StatelessWidget {
const _Highlights({required this.journals});
final List<JournalEntry> journals;
static const _badgeConfig = <Mood, ({String badge, Color bg, Color fg})>{
Mood.happy: (badge: '最佳心情', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)),
Mood.calm: (badge: '平静时光', bg: AppColors.tertiarySoftLight, fg: Color(0xFFB8860B)),
Mood.sad: (badge: '真实记录', bg: Color(0xFFD4DDE8), fg: Color(0xFF4A6B8A)),
Mood.angry: (badge: '真情流露', bg: AppColors.roseSoftLight, fg: Color(0xFF9B4D4D)),
Mood.thinking: (badge: '深度思考', bg: AppColors.secondarySoftLight, fg: Color(0xFF2D7D46)),
};
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// 按日期降序取前 3 篇
final top = List<JournalEntry>.from(journals)
..sort((a, b) => b.date.compareTo(a.date));
final highlights = top.take(3).toList();
if (highlights.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本月精选',
style: theme.textTheme.headlineSmall?.copyWith(
fontFamily: AppTypography.displayFont,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 16),
...highlights.map((entry) {
final mood = entry.mood;
final emoji = _MoodCalendar._moodEmojis[mood] ?? '📝';
final emojiBg = _MoodCalendar._moodBgColors[mood] ?? AppColors.tertiarySoftLight;
final cfg = _badgeConfig[mood] ??
(badge: '日记', bg: AppColors.tertiarySoftLight, fg: const Color(0xFFB8860B));
final dateStr = '${entry.date.month}${entry.date.day}';
return _HighlightCard(
emoji: emoji,
emojiBg: emojiBg,
date: dateStr,
title: entry.title,
badge: cfg.badge,
badgeBg: cfg.bg,
badgeFg: cfg.fg,
);
}),
],
);
}
}
/// 单张精选日记卡片
class _HighlightCard extends StatelessWidget {
const _HighlightCard({
required this.emoji,
required this.emojiBg,
required this.date,
required this.title,
required this.badge,
required this.badgeBg,
required this.badgeFg,
});
final String emoji;
final Color emojiBg;
final String date;
final String title;
final String badge;
final Color badgeBg;
final Color badgeFg;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: AppRadius.mdBorder,
boxShadow: AppShadows.soft(context),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Row(
children: [
// 48x48 emoji 圆圈
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: emojiBg,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(emoji, style: const TextStyle(fontSize: 24)),
),
const SizedBox(width: 12),
// 标题 + 日期
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
date,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontFamily: AppTypography.displayFont,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// badge pill
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: badgeBg,
borderRadius: AppRadius.pillBorder,
),
child: Text(
badge,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: badgeFg,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,680 @@
// 周概览页面 — 7天条目 + 统计卡片 + 每日日记卡片
// 对齐 Open Design 原型稿 screens/weekly.html
// 接入 JournalRepository 加载真实数据
import 'package:flutter/material.dart';
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/core/theme/app_shadows.dart';
import 'package:nuanji_app/core/theme/app_typography.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';
/// 周概览页面
class WeeklyPage extends StatefulWidget {
const WeeklyPage({super.key});
@override
State<WeeklyPage> createState() => _WeeklyPageState();
}
class _WeeklyPageState extends State<WeeklyPage> {
late DateTime _focusedWeekStart;
List<JournalEntry> _journals = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
final now = DateTime.now();
_focusedWeekStart = _startOfWeek(now);
_loadWeekData();
}
JournalRepository get _repo => context.read<JournalRepository>();
/// 获取某天的周一日期
DateTime _startOfWeek(DateTime date) {
return date.subtract(Duration(days: date.weekday - 1));
}
Future<void> _loadWeekData() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
final weekEnd = _focusedWeekStart.add(const Duration(days: 7));
final journals = await _repo.getJournals(
dateFrom: _focusedWeekStart,
dateTo: weekEnd,
);
if (mounted) {
setState(() {
_journals = journals;
_isLoading = false;
});
}
} catch (e) {
debugPrint('WeeklyPage._loadWeekData 失败: $e');
if (mounted) setState(() => _isLoading = false);
}
}
void _goToPreviousWeek() {
setState(() {
_focusedWeekStart = _focusedWeekStart.subtract(const Duration(days: 7));
});
_loadWeekData();
}
void _goToNextWeek() {
setState(() {
_focusedWeekStart = _focusedWeekStart.add(const Duration(days: 7));
});
_loadWeekData();
}
/// 按日期索引日记: day-of-week (1=周一..7=周日) → JournalEntry 列表
Map<int, List<JournalEntry>> get _journalsByWeekday {
final map = <int, List<JournalEntry>>{};
for (final j in _journals) {
// 判断日记日期是否在本周范围内
final dayKey = j.date.difference(_focusedWeekStart).inDays;
if (dayKey >= 0 && dayKey < 7) {
final weekday = dayKey + 1; // 1=周一, 7=周日
(map[weekday] ??= []).add(j);
}
}
return map;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Scaffold(
body: SafeArea(
child: Column(
children: [
// 周头部导航
_WeekHeader(
weekStart: _focusedWeekStart,
onPrevious: _goToPreviousWeek,
onNext: _goToNextWeek,
),
// 可滚动内容区
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.symmetric(horizontal: 20),
children: [
const SizedBox(height: 16),
// 7天条目真实数据
_WeekStrip(
weekStart: _focusedWeekStart,
journalsByWeekday: _journalsByWeekday,
),
const SizedBox(height: 20),
// 本周总结(真实数据)
_WeekSummary(journals: _journals),
const SizedBox(height: 20),
// 每日日记卡片(真实数据)
..._buildDayCards(theme, colorScheme),
const SizedBox(height: 32),
],
),
),
],
),
),
);
}
List<Widget> _buildDayCards(ThemeData theme, ColorScheme colorScheme) {
final byWeekday = _journalsByWeekday;
final cards = <Widget>[];
final weekNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
// 按日期倒序生成卡片(最新的在上面)
for (var i = 6; i >= 0; i--) {
final weekday = i + 1;
final dayJournals = byWeekday[weekday];
if (dayJournals == null || dayJournals.isEmpty) continue;
final day = _focusedWeekStart.add(Duration(days: i));
final first = dayJournals.first;
cards.add(_DayCard(
weekday: weekNames[i],
date: '${day.month}${day.day}',
moodEmoji: moodToEmoji(first.mood),
weatherEmoji: _weatherEmoji(first.weather),
body: first.contentExcerpt ?? first.title,
tags: first.tags.take(2).map((tag) {
// 根据标签内容选择颜色
return (tag, AppColors.secondarySoftLight, const Color(0xFF2D7D46));
}).toList(),
photoEmoji: dayJournals.any((j) => j.contentExcerpt != null && j.contentExcerpt!.contains('📷'))
? '📷'
: null,
));
}
// 无日记时显示空状态
if (cards.isEmpty) {
return [
SizedBox(
height: 200,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.edit_note_rounded, 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.4),
)),
],
),
),
),
];
}
return cards;
}
String _weatherEmoji(Weather weather) => switch (weather) {
Weather.sunny => '☀️',
Weather.cloudy => '',
Weather.rainy => '🌧️',
Weather.snowy => '❄️',
Weather.windy => '💨',
};
}
// ===== 周头部导航 =====
class _WeekHeader extends StatelessWidget {
const _WeekHeader({
required this.weekStart,
required this.onPrevious,
required this.onNext,
});
final DateTime weekStart;
final VoidCallback onPrevious;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// 格式化: "2026年6月 第1周"
final monthNames = [
'', '1月', '2月', '3月', '4月', '5月', '6月',
'7月', '8月', '9月', '10月', '11月', '12月',
];
final title =
'${weekStart.year}${monthNames[weekStart.month]}${_weekOfMonth(weekStart)}';
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 12, 0),
child: Row(
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontFamily: AppTypography.displayFont,
fontWeight: FontWeight.w700,
),
),
),
// 左右箭头导航按钮
_NavButton(
icon: Icons.chevron_left_rounded,
onTap: onPrevious,
borderColor: colorScheme.outline,
foregroundColor: colorScheme.onSurface,
),
const SizedBox(width: 8),
_NavButton(
icon: Icons.chevron_right_rounded,
onTap: onNext,
borderColor: colorScheme.outline,
foregroundColor: colorScheme.onSurface,
),
],
),
);
}
/// 计算是当月第几周
int _weekOfMonth(DateTime date) {
final firstDay = DateTime(date.year, date.month, 1);
final offset = firstDay.weekday - 1;
return ((date.day + offset) / 7).ceil();
}
}
/// 圆形导航按钮 (44px 触摸目标)
class _NavButton extends StatelessWidget {
const _NavButton({
required this.icon,
required this.onTap,
required this.borderColor,
required this.foregroundColor,
});
final IconData icon;
final VoidCallback onTap;
final Color borderColor;
final Color foregroundColor;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 44,
height: 44,
child: OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
shape: const CircleBorder(),
side: BorderSide(color: borderColor, width: 1.5),
foregroundColor: foregroundColor,
backgroundColor: Theme.of(context).colorScheme.surface,
),
child: Icon(icon, size: 18),
),
);
}
}
// ===== 7天条目真实数据=====
class _WeekStrip extends StatelessWidget {
const _WeekStrip({
required this.weekStart,
required this.journalsByWeekday,
});
final DateTime weekStart;
final Map<int, List<JournalEntry>> journalsByWeekday;
static const _weekNames = ['', '', '', '', '', '', ''];
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final now = DateTime.now();
return Row(
children: List.generate(7, (i) {
final day = weekStart.add(Duration(days: i));
final weekday = i + 1;
final isToday = day.year == now.year &&
day.month == now.month &&
day.day == now.day;
final dayJournals = journalsByWeekday[weekday] ?? [];
final hasEntry = dayJournals.isNotEmpty;
final moodEmoji = hasEntry ? moodToEmoji(dayJournals.first.mood) : '·';
return Expanded(
child: GestureDetector(
onTap: () {
// TODO: 选择某天后刷新下方日记卡片
},
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: isToday ? AppColors.accent : null,
borderRadius: AppRadius.mdBorder,
),
child: Column(
children: [
// 周名
Text(
_weekNames[i],
style: TextStyle(
fontSize: 11,
color: isToday
? const Color(0xFFFFF8F0).withValues(alpha: 0.85)
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
// 日期数字
Text(
'${day.day}',
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 20,
fontWeight: FontWeight.w700,
color: isToday
? const Color(0xFFFFF8F0) // accent-on
: colorScheme.onSurface.withValues(alpha: 0.7),
),
),
const SizedBox(height: 4),
// 心情 emoji
Text(moodEmoji, style: TextStyle(
fontSize: hasEntry ? 16 : 14,
color: hasEntry ? null : colorScheme.onSurface.withValues(alpha: 0.2),
)),
// 有日记: 日期下方4px小圆点
if (hasEntry && !isToday)
Container(
width: 4,
height: 4,
margin: const EdgeInsets.only(top: 4),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.accent,
),
),
if (hasEntry && isToday)
Container(
width: 4,
height: 4,
margin: const EdgeInsets.only(top: 4),
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFFFF8F0),
),
),
],
),
),
),
);
}),
);
}
}
// ===== 本周总结卡片(真实数据)=====
class _WeekSummary extends StatelessWidget {
const _WeekSummary({required this.journals});
final List<JournalEntry> journals;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
// 统计真实数据
final recordDays = journals.map((j) => j.date.day).toSet().length;
final journalCount = journals.length;
// 统计贴纸元素 — 从日记标签中估算Phase 1 简化)
final stickerCount = journals.fold<int>(
0, (sum, j) => sum + j.tags.length,
);
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: AppRadius.mdBorder,
boxShadow: AppShadows.soft(context),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本周总结',
style: theme.textTheme.titleMedium?.copyWith(
fontFamily: AppTypography.displayFont,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
// 3个统计数字
Row(
children: [
_SummaryItem(
value: '$recordDays',
label: '记录天数',
valueColor: AppColors.accent,
),
_SummaryItem(
value: '$journalCount',
label: '日记篇数',
valueColor: AppColors.secondary,
),
_SummaryItem(
value: '$stickerCount',
label: '使用标签',
valueColor: AppColors.tertiary,
),
],
),
const SizedBox(height: 16),
// 心情分布条
_MoodDistributionBar(journals: journals),
],
),
);
}
}
/// 心情分布条 — 从日记数据计算各心情占比
class _MoodDistributionBar extends StatelessWidget {
const _MoodDistributionBar({required this.journals});
final List<JournalEntry> journals;
static const _moodConfig = [
(Mood.happy, AppColors.secondary),
(Mood.calm, AppColors.tertiary),
(Mood.sad, Color(0xFF5B7DB1)),
(Mood.angry, AppColors.accent),
(Mood.thinking, Color(0xFF8B7E74)),
];
@override
Widget build(BuildContext context) {
if (journals.isEmpty) {
return Container(
height: 8,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
);
}
// 统计各心情数量
final counts = <Mood, int>{};
for (final j in journals) {
counts[j.mood] = (counts[j.mood] ?? 0) + 1;
}
return Row(
children: _moodConfig.where((c) => counts[c.$1] != null).map((config) {
final count = counts[config.$1]!;
return Expanded(
flex: count,
child: Container(
height: 8,
margin: const EdgeInsets.only(right: 2),
decoration: BoxDecoration(
color: config.$2,
borderRadius: BorderRadius.circular(4),
),
),
);
}).toList(),
);
}
}
/// 单个统计项
class _SummaryItem extends StatelessWidget {
const _SummaryItem({
required this.value,
required this.label,
required this.valueColor,
});
final String value;
final String label;
final Color valueColor;
@override
Widget build(BuildContext context) {
return Expanded(
child: Column(
children: [
Text(
value,
style: TextStyle(
fontFamily: AppTypography.displayFont,
fontSize: 24,
fontWeight: FontWeight.w700,
color: valueColor,
),
),
const SizedBox(height: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
// ===== 每日日记卡片 =====
class _DayCard extends StatelessWidget {
const _DayCard({
required this.weekday,
required this.date,
required this.moodEmoji,
required this.weatherEmoji,
required this.body,
required this.tags,
this.photoEmoji,
});
final String weekday;
final String date;
final String moodEmoji;
final String weatherEmoji;
final String body;
final List<(String, Color, Color)> tags; // (label, bg, fg)
final String? photoEmoji;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: AppRadius.mdBorder,
boxShadow: AppShadows.soft(context),
border: Border.all(color: colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部: 日期 + 心情/weather emoji
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'$weekday · $date',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.onSurface.withValues(alpha: 0.7),
),
),
Text(
'$moodEmoji $weatherEmoji',
style: const TextStyle(fontSize: 13),
),
],
),
const SizedBox(height: 12),
// 正文预览 (3行截断)
Text(
body,
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
height: 1.6,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
// 标签 pills
if (tags.isNotEmpty) ...[
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 4,
children: tags.map((tag) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 3,
),
decoration: BoxDecoration(
color: tag.$2,
borderRadius: AppRadius.pillBorder,
),
child: Text(
tag.$1,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: tag.$3,
),
),
);
}).toList(),
),
],
// 照片占位
if (photoEmoji != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
height: 80,
decoration: BoxDecoration(
borderRadius: AppRadius.smBorder,
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.surfaceContainerHighest
.withValues(alpha: 0.6),
colorScheme.outlineVariant.withValues(alpha: 0.3),
],
),
),
alignment: Alignment.center,
child: Text(photoEmoji!, style: const TextStyle(fontSize: 24)),
),
],
],
),
);
}
}

View File

@@ -0,0 +1,425 @@
// 班级 BLoC — 通过 ClassRepository 管理班级数据
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nuanji_app/data/models/journal_entry.dart';
import 'package:nuanji_app/data/models/school_class.dart';
import 'package:nuanji_app/data/repositories/class_repository.dart';
import 'package:nuanji_app/data/repositories/journal_repository.dart';
// ===== Events =====
sealed class ClassEvent {
const ClassEvent();
}
final class ClassLoadMyClasses extends ClassEvent {
const ClassLoadMyClasses();
}
final class ClassSelected extends ClassEvent {
final String classId;
const ClassSelected(this.classId);
}
final class ClassLoadMembers extends ClassEvent {
final String classId;
const ClassLoadMembers(this.classId);
}
final class ClassLoadDiaryWall extends ClassEvent {
final String classId;
const ClassLoadDiaryWall(this.classId);
}
final class ClassLoadTopics extends ClassEvent {
final String classId;
const ClassLoadTopics(this.classId);
}
final class ClassLoadComments extends ClassEvent {
final String journalId;
const ClassLoadComments(this.journalId);
}
final class ClassCreate extends ClassEvent {
final String name;
final String? schoolName;
const ClassCreate({required this.name, this.schoolName});
}
final class TopicAssign extends ClassEvent {
final String classId;
final String title;
final String? description;
final DateTime? dueDate;
const TopicAssign({
required this.classId,
required this.title,
this.description,
this.dueDate,
});
}
final class ClassJoin extends ClassEvent {
final String classCode;
final String? nickname;
const ClassJoin({required this.classCode, this.nickname});
}
final class CommentCreate extends ClassEvent {
final String journalId;
final String content;
const CommentCreate({required this.journalId, required this.content});
}
// ===== State =====
class ClassMember {
final String userId;
final String role;
final String? nickname;
final DateTime joinedAt;
const ClassMember({required this.userId, required this.role, this.nickname, required this.joinedAt});
}
class TopicAssignment {
final String id;
final String classId;
final String teacherId;
final String title;
final String? description;
final DateTime? dueDate;
final bool isActive;
const TopicAssignment({required this.id, required this.classId, required this.teacherId, required this.title, this.description, this.dueDate, this.isActive = true});
}
class Comment {
final String id;
final String journalId;
final String authorId;
final String content;
final DateTime createdAt;
const Comment({required this.id, required this.journalId, required this.authorId, required this.content, required this.createdAt});
}
sealed class ClassState {
const ClassState();
}
final class ClassInitial extends ClassState {
const ClassInitial();
}
final class ClassLoading extends ClassState {
const ClassLoading();
}
final class ClassListLoaded extends ClassState {
final List<SchoolClass> classes;
final bool isLoading;
final String? error;
const ClassListLoaded({this.classes = const [], this.isLoading = false, this.error});
ClassListLoaded copyWith({List<SchoolClass>? classes, bool? isLoading, String? error, bool clearError = false}) =>
ClassListLoaded(
classes: classes ?? this.classes,
isLoading: isLoading ?? this.isLoading,
error: clearError ? null : (error ?? this.error),
);
}
final class ClassDetailLoaded extends ClassState {
final SchoolClass classInfo;
final List<ClassMember> members;
final List<JournalEntry> diaryWall;
final List<TopicAssignment> topics;
final List<Comment> comments;
final bool isLoadingWall;
final bool isLoadingMembers;
final String? selectedJournalId;
final String? error;
const ClassDetailLoaded({
required this.classInfo,
this.members = const [],
this.diaryWall = const [],
this.topics = const [],
this.comments = const [],
this.isLoadingWall = false,
this.isLoadingMembers = false,
this.selectedJournalId,
this.error,
});
ClassDetailLoaded copyWith({
SchoolClass? classInfo,
List<ClassMember>? members,
List<JournalEntry>? diaryWall,
List<TopicAssignment>? topics,
List<Comment>? comments,
bool? isLoadingWall,
bool? isLoadingMembers,
String? selectedJournalId,
bool clearSelectedJournal = false,
String? error,
bool clearError = false,
}) =>
ClassDetailLoaded(
classInfo: classInfo ?? this.classInfo,
members: members ?? this.members,
diaryWall: diaryWall ?? this.diaryWall,
topics: topics ?? this.topics,
comments: comments ?? this.comments,
isLoadingWall: isLoadingWall ?? this.isLoadingWall,
isLoadingMembers: isLoadingMembers ?? this.isLoadingMembers,
selectedJournalId: clearSelectedJournal ? null : (selectedJournalId ?? this.selectedJournalId),
error: clearError ? null : (error ?? this.error),
);
}
final class ClassError extends ClassState {
final String message;
const ClassError(this.message);
}
// ===== BLoC =====
class ClassBloc extends Bloc<ClassEvent, ClassState> {
final ClassRepository _classRepo;
final JournalRepository _journalRepo;
ClassBloc({
required ClassRepository classRepository,
required JournalRepository journalRepository,
}) : _classRepo = classRepository,
_journalRepo = journalRepository,
super(const ClassInitial()) {
on<ClassLoadMyClasses>(_onLoadMyClasses);
on<ClassSelected>(_onClassSelected);
on<ClassLoadMembers>(_onLoadMembers);
on<ClassLoadDiaryWall>(_onLoadDiaryWall);
on<ClassLoadTopics>(_onLoadTopics);
on<ClassLoadComments>(_onLoadComments);
on<ClassCreate>(_onCreateClass);
on<TopicAssign>(_onTopicAssign);
on<ClassJoin>(_onJoinClass);
on<CommentCreate>(_onCommentCreate);
}
Future<void> _onLoadMyClasses(
ClassLoadMyClasses event,
Emitter<ClassState> emit,
) async {
emit(const ClassListLoaded(isLoading: true));
try {
final classes = await _classRepo.getMyClasses();
emit(ClassListLoaded(classes: classes));
} catch (e) {
debugPrint('ClassBloc._onLoadMyClasses 失败: $e');
emit(const ClassListLoaded());
}
}
Future<void> _onClassSelected(
ClassSelected event,
Emitter<ClassState> emit,
) async {
try {
final classInfo = await _classRepo.getClass(event.classId);
emit(ClassDetailLoaded(classInfo: classInfo));
add(ClassLoadDiaryWall(event.classId));
add(ClassLoadMembers(event.classId));
add(ClassLoadTopics(event.classId));
} catch (e) {
debugPrint('ClassBloc._onClassSelected 失败: $e');
emit(const ClassError('加载班级失败,请重试'));
}
}
Future<void> _onLoadMembers(
ClassLoadMembers event,
Emitter<ClassState> emit,
) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
emit(current.copyWith(isLoadingMembers: true));
try {
final dtos = await _classRepo.getMembers(event.classId);
final members = dtos
.map((d) => ClassMember(
userId: d.userId,
role: d.role,
nickname: d.nickname,
joinedAt: d.joinedAt,
))
.toList();
emit(current.copyWith(members: members, isLoadingMembers: false));
} catch (e) {
debugPrint('ClassBloc._onLoadMembers 失败: $e');
emit(current.copyWith(isLoadingMembers: false));
}
}
Future<void> _onLoadDiaryWall(
ClassLoadDiaryWall event,
Emitter<ClassState> emit,
) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
emit(current.copyWith(isLoadingWall: true));
try {
// 服务端过滤:按 classId 查询班级公开日记(后端 API 已支持 ?class_id= 参数)
final journals = await _journalRepo.getJournals(classId: event.classId);
final classJournals = journals.where((j) => j.sharedToClass).toList();
emit(current.copyWith(diaryWall: classJournals, isLoadingWall: false));
} catch (e) {
debugPrint('ClassBloc._onLoadDiaryWall 失败: $e');
emit(current.copyWith(isLoadingWall: false));
}
}
Future<void> _onLoadTopics(
ClassLoadTopics event,
Emitter<ClassState> emit,
) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
try {
final dtos = await _classRepo.getTopics(event.classId);
final topics = dtos
.map((d) => TopicAssignment(
id: d.id,
classId: d.classId,
teacherId: d.teacherId,
title: d.title,
description: d.description,
dueDate: d.dueDate,
isActive: d.isActive,
))
.toList();
emit(current.copyWith(topics: topics));
} catch (e) {
debugPrint('ClassBloc._onLoadTopics 失败: $e');
// 保留空列表
}
}
Future<void> _onLoadComments(
ClassLoadComments event,
Emitter<ClassState> emit,
) async {
if (state is! ClassDetailLoaded) return;
final current = state as ClassDetailLoaded;
try {
final dtos = await _classRepo.getComments(event.journalId);
final comments = dtos
.map((d) => Comment(
id: d.id,
journalId: d.journalId,
authorId: d.authorId,
content: d.content,
createdAt: d.createdAt,
))
.toList();
emit(current.copyWith(comments: comments, selectedJournalId: event.journalId));
} catch (e) {
debugPrint('ClassBloc._onLoadComments 失败: $e');
emit(current.copyWith(selectedJournalId: event.journalId));
}
}
Future<void> _onCreateClass(
ClassCreate event,
Emitter<ClassState> emit,
) async {
try {
final newClass = await _classRepo.createClass(
name: event.name,
schoolName: event.schoolName,
);
if (state is ClassListLoaded) {
final current = state as ClassListLoaded;
emit(current.copyWith(classes: [...current.classes, newClass]));
}
} catch (e) {
debugPrint('ClassBloc._onCreateClass 失败: $e');
// 创建失败不改变状态,但通知 UI
if (state is ClassListLoaded) {
emit((state as ClassListLoaded).copyWith(error: '创建班级失败,请重试'));
}
}
}
Future<void> _onTopicAssign(
TopicAssign event,
Emitter<ClassState> emit,
) async {
try {
final dto = await _classRepo.assignTopic(
classId: event.classId,
title: event.title,
description: event.description,
dueDate: event.dueDate,
);
// 更新本地 topics 列表(仅在班级详情视图中)
if (state is ClassDetailLoaded) {
final current = state as ClassDetailLoaded;
final newTopic = TopicAssignment(
id: dto.id,
classId: dto.classId,
teacherId: dto.teacherId,
title: dto.title,
description: dto.description,
dueDate: dto.dueDate,
);
emit(current.copyWith(topics: [newTopic, ...current.topics]));
}
} catch (e) {
debugPrint('ClassBloc._onTopicAssign 失败: $e');
// 通知 UI 布置失败
if (state is ClassDetailLoaded) {
emit((state as ClassDetailLoaded).copyWith(error: '话题布置失败,请重试'));
}
}
}
Future<void> _onJoinClass(
ClassJoin event,
Emitter<ClassState> emit,
) async {
try {
await _classRepo.joinClass(event.classCode, nickname: event.nickname);
// 加入成功后刷新列表
add(const ClassLoadMyClasses());
} catch (e) {
debugPrint('ClassBloc._onJoinClass 失败: $e');
emit(const ClassError('加入班级失败,请检查班级码'));
}
}
Future<void> _onCommentCreate(
CommentCreate event,
Emitter<ClassState> emit,
) async {
final currentState = state;
if (currentState is! ClassDetailLoaded) return;
try {
await _classRepo.createComment(
journalId: event.journalId,
content: event.content,
);
// 创建成功后重新加载评语列表
add(ClassLoadComments(event.journalId));
} catch (e) {
debugPrint('ClassBloc._onCommentCreate 失败: $e');
emit(currentState.copyWith(error: '评语发布失败'));
}
}
}

View File

@@ -1,14 +1,550 @@
import 'package:flutter/material.dart';
// 班级主页 — 日记墙 + 班级信息 + 成员 + 主题
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:nuanji_app/core/theme/app_colors.dart';
import 'package:nuanji_app/core/theme/app_radius.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 '../../../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';
/// 班级主页 — 日记墙 + 班级信息
class ClassPage extends StatelessWidget {
const ClassPage({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Center(
child: Text('班级 - 占位页面'),
return BlocProvider(
create: (context) => ClassBloc(
classRepository: context.read<ClassRepository>(),
journalRepository: context.read<JournalRepository>(),
)..add(const ClassLoadMyClasses()),
child: const _ClassView(),
);
}
}
class _ClassView extends StatelessWidget {
const _ClassView();
@override
Widget build(BuildContext context) {
return BlocBuilder<ClassBloc, ClassState>(
builder: (context, state) {
if (state is ClassLoading || state is ClassInitial) {
return Scaffold(
appBar: AppBar(title: const Text('班级')),
body: Center(child: CircularProgressIndicator()),
);
}
if (state is ClassError) {
return Scaffold(
appBar: AppBar(title: const Text('班级')),
body: ErrorStateWidget(
message: state.message,
onRetry: () => context.read<ClassBloc>().add(const ClassLoadMyClasses()),
),
);
}
if (state is ClassListLoaded) {
return _ClassListView(classes: state.classes, colorScheme: Theme.of(context).colorScheme);
}
if (state is ClassDetailLoaded) {
return _ClassDetailView(state: state);
}
return const SizedBox.shrink();
},
);
}
}
// ===== 班级列表视图 =====
class _ClassListView extends StatelessWidget {
const _ClassListView({required this.classes, required this.colorScheme});
final List<SchoolClass> classes;
final ColorScheme colorScheme;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('我的班级')),
body: classes.isEmpty
? _buildEmptyState(context, colorScheme)
: ListView(
padding: const EdgeInsets.all(16),
children: classes.map((cls) {
return _ClassListCard(
cls: cls,
colorScheme: colorScheme,
onTap: () =>
context.read<ClassBloc>().add(ClassSelected(cls.id)),
);
}).toList(),
),
);
}
Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme) {
return EmptyStateWidget(
icon: Icons.group_add_rounded,
title: '还没有加入班级',
actionLabel: '通过班级码加入',
onAction: () => context.go('/class-code'),
);
}
}
class _ClassListCard extends StatelessWidget {
const _ClassListCard({
required this.cls,
required this.colorScheme,
required this.onTap,
});
final SchoolClass cls;
final ColorScheme colorScheme;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.mdBorder,
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.secondary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.center,
child: const Icon(Icons.school_rounded, color: AppColors.secondary),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cls.name, style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(
'${cls.schoolName} · ${cls.memberCount}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
);
}
}
// ===== 班级详情视图(日记墙)=====
class _ClassDetailView extends StatelessWidget {
const _ClassDetailView({required this.state});
final ClassDetailLoaded state;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final classInfo = state.classInfo;
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.read<ClassBloc>().add(const ClassLoadMyClasses()),
icon: const Icon(Icons.arrow_back),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(classInfo.name),
Text(
'${classInfo.schoolName} · 班级码: ${classInfo.classCode}',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
bottom: const TabBar(
tabs: [
Tab(text: '日记墙'),
Tab(text: '主题'),
Tab(text: '成员'),
],
),
),
body: TabBarView(
children: [
// 日记墙
_DiaryWallTab(state: state),
// 主题布置
_TopicsTab(topics: state.topics),
// 成员列表
_MembersTab(state: state),
],
),
),
);
}
}
// ===== 日记墙 Tab =====
class _DiaryWallTab extends StatelessWidget {
const _DiaryWallTab({required this.state});
final ClassDetailLoaded state;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (state.isLoadingWall) {
return const Center(child: CircularProgressIndicator());
}
if (state.diaryWall.isEmpty) {
return const EmptyStateWidget(
icon: Icons.auto_stories_rounded,
title: '日记墙还是空的',
subtitle: '分享你的日记到这里吧',
iconSize: 48,
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.diaryWall.length,
itemBuilder: (context, index) {
final journal = state.diaryWall[index];
return _DiaryWallCard(journal: journal, comments: state.comments);
},
);
}
}
class _DiaryWallCard extends StatelessWidget {
const _DiaryWallCard({required this.journal, required this.comments});
final JournalEntry journal;
final List<Comment> comments;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final moodColor = AppColors.moodColors[journal.mood.value] ?? colorScheme.primary;
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () => context.push('/editor?id=${journal.id}'),
borderRadius: AppRadius.mdBorder,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部:作者 + 心情
Row(
children: [
CircleAvatar(
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),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
journal.title,
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
),
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: moodColor.withValues(alpha: 0.15),
),
alignment: Alignment.center,
child: Text(_moodEmoji(journal.mood), style: const TextStyle(fontSize: 14)),
),
],
),
const SizedBox(height: 8),
// 日期
Text(
'${journal.date.month}${journal.date.day}',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.4),
),
),
// 评语(按 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),
TextButton.icon(
onPressed: () {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) => CommentBottomSheet(
journalId: journal.id,
studentName: journal.authorId, // TODO: 替换为真实昵称
onSubmit: (content) {
context.read<ClassBloc>().add(
CommentCreate(
journalId: journal.id,
content: content,
),
);
},
),
);
},
icon: const Icon(Icons.rate_review_outlined, size: 16),
label: const Text('写评语'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
),
),
],
],
),
),
),
);
}
/// 判断当前用户是否是老师
bool _isTeacher(BuildContext context) {
final authState = context.read<AuthBloc>().state;
if (authState is Authenticated) {
return authState.user.isTeacher;
}
return false;
}
String _moodEmoji(Mood mood) => switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
}
// ===== 主题 Tab =====
class _TopicsTab extends StatelessWidget {
const _TopicsTab({required this.topics});
final List<TopicAssignment> topics;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
if (topics.isEmpty) {
return const EmptyStateWidget(
icon: Icons.assignment_outlined,
title: '暂无主题布置',
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: topics.length,
itemBuilder: (context, index) {
final topic = topics[index];
final isOverdue = topic.dueDate != null && topic.dueDate!.isBefore(DateTime.now());
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
side: BorderSide(color: colorScheme.outlineVariant),
),
child: InkWell(
onTap: () => context.push('/editor?topic=${topic.id}'),
borderRadius: AppRadius.mdBorder,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.assignment_outlined, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
topic.title,
style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
),
if (topic.isActive && !isOverdue)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.secondary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text('进行中', style: theme.textTheme.labelSmall?.copyWith(color: AppColors.secondary)),
),
],
),
if (topic.description != null) ...[
const SizedBox(height: 8),
Text(topic.description!, style: theme.textTheme.bodyMedium),
],
if (topic.dueDate != null) ...[
const SizedBox(height: 8),
Text(
'截止: ${topic.dueDate!.month}${topic.dueDate!.day}',
style: theme.textTheme.bodySmall?.copyWith(
color: isOverdue ? colorScheme.error : colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
],
),
),
),
);
},
);
}
}
// ===== 成员 Tab =====
class _MembersTab extends StatelessWidget {
const _MembersTab({required this.state});
final ClassDetailLoaded state;
@override
Widget build(BuildContext context) {
if (state.isLoadingMembers) {
return const Center(child: CircularProgressIndicator());
}
final theme = Theme.of(context);
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.members.length,
itemBuilder: (context, index) {
final member = state.members[index];
final isTeacher = member.role == 'teacher';
return ListTile(
leading: CircleAvatar(
backgroundColor: isTeacher
? AppColors.accent.withValues(alpha: 0.15)
: AppColors.secondary.withValues(alpha: 0.15),
child: Text(
(member.nickname ?? '?').characters.first,
style: TextStyle(
color: isTeacher ? AppColors.accent : AppColors.secondary,
fontWeight: FontWeight.bold,
),
),
),
title: Text(member.nickname ?? '未知'),
trailing: isTeacher
? Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.accent.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text('老师', style: theme.textTheme.labelSmall?.copyWith(color: AppColors.accent)),
)
: null,
);
},
);
}
}

View File

@@ -0,0 +1,191 @@
// 评语输入 BottomSheet — 老师点评学生日记
//
// 设计要点:
// - 7 个快捷评语模板,一键选择
// - 自由文字输入,支持多行
// - 温暖鼓励的语气(面向小学生日记)
// - 触摸目标 ≥ 44px
import 'package:flutter/material.dart';
/// 老师点评输入面板
class CommentBottomSheet extends StatefulWidget {
final String journalId;
final String studentName;
final void Function(String content) onSubmit;
const CommentBottomSheet({
super.key,
required this.journalId,
required this.studentName,
required this.onSubmit,
});
@override
State<CommentBottomSheet> createState() => _CommentBottomSheetState();
}
class _CommentBottomSheetState extends State<CommentBottomSheet> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
bool _isSubmitting = false;
// 快捷评语模板 — 温暖鼓励风格
static const _quickComments = [
'🌟 写得真好!继续加油!',
'📖 故事很精彩,想象力很丰富!',
'💪 字写得很工整,继续保持!',
'🎨 画得很漂亮,很有创意!',
'🌈 内容很丰富,观察很仔细!',
'✍️ 可以再多写一点自己的感受哦',
'📸 照片拍得很好,记录很用心!',
];
@override
void initState() {
super.initState();
Future.microtask(() => _focusNode.requestFocus());
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
void _submit() {
final content = _controller.text.trim();
if (content.isEmpty) return;
setState(() => _isSubmitting = true);
widget.onSubmit(content);
Navigator.pop(context);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 拖拽条
Center(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
// 标题
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'点评 ${widget.studentName} 的日记',
style: theme.textTheme.titleSmall,
),
IconButton(
icon: const Icon(Icons.close, size: 20),
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
const SizedBox(height: 8),
// 快捷评语
SizedBox(
height: 80,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: _quickComments.map((comment) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
label: Text(comment, style: const TextStyle(fontSize: 12)),
onPressed: () {
_controller.text = comment;
_focusNode.requestFocus();
},
),
);
}).toList(),
),
),
const SizedBox(height: 8),
// 输入框
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
controller: _controller,
focusNode: _focusNode,
maxLines: 3,
minLines: 1,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
decoration: InputDecoration(
hintText: '写下你的评语...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
),
),
),
const SizedBox(height: 12),
// 提交按钮
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
height: 44,
child: FilledButton(
onPressed: _isSubmitting ? null : _submit,
child: _isSubmitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('发布评语'),
),
),
),
const SizedBox(height: 16),
],
),
);
}
}

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

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