Compare commits
4 Commits
5e6c6fdd62
...
7e3597dc77
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e3597dc77 | ||
|
|
482eb244d5 | ||
|
|
0fe3bc705c | ||
|
|
232a53dbed |
127
CLAUDE.md
127
CLAUDE.md
@@ -36,7 +36,7 @@ nj/ (一个仓库)
|
||||
│ ├── src/service/ # ~12 Service
|
||||
│ ├── src/handler/ # ~10 Handler
|
||||
│ └── src/{dto,error,event,state}.rs
|
||||
├── app/ # Flutter 前端 (待创建)
|
||||
├── app/ # Flutter 前端
|
||||
├── config/ # 服务器配置
|
||||
├── docker/ # Docker Compose (PG + Redis)
|
||||
├── docs/ # 产品文档
|
||||
@@ -327,7 +327,90 @@ chore(docker): 添加 PostgreSQL 16 + Redis 7 开发环境
|
||||
|
||||
---
|
||||
|
||||
## 8. 参考文档
|
||||
## 8. 上下文管理与会话交接
|
||||
|
||||
> **核心问题:** 长时间开发会话会触发 "API Error: The model has reached its context window limit.",导致工作中断。
|
||||
> **解决方案:** 在上下文即将耗尽前,主动执行会话交接,确保新会话能无缝续接。
|
||||
|
||||
### 8.1 触发条件
|
||||
|
||||
当出现以下**任一**信号时,立即执行 §8.2 交接流程:
|
||||
|
||||
- 感觉对话已经很长,处理速度明显变慢
|
||||
- 已经完成了 2 个以上独立的功能开发步骤
|
||||
- 单次会话中修改了 10+ 个文件
|
||||
- 用户提醒上下文快满了
|
||||
|
||||
### 8.2 会话交接流程
|
||||
|
||||
**不要等到报错才交接!** 主动在合适的节点(完成一个 Phase、通过测试后)执行:
|
||||
|
||||
**步骤 1 — 收尾当前工作:**
|
||||
- 确保当前代码改动已提交(`git add` + `git commit`)
|
||||
- 确认 `cargo check` 和 `cargo test`(涉及前端时 `flutter analyze`)通过
|
||||
- 未完成的工作不要提交到 main — 用 stash 或临时分支暂存
|
||||
|
||||
**步骤 2 — 更新项目记忆文件:**
|
||||
|
||||
更新 `~/.claude/projects/g--nj/memory/project-status.md`,包含:
|
||||
|
||||
```markdown
|
||||
## 已完成 (截至 YYYY-MM-DD)
|
||||
- [x] Phase X: 具体完成内容 (commit hash)
|
||||
- [x] Phase Y: 具体完成内容 (commit hash)
|
||||
|
||||
## 当前进行中 / 未完成
|
||||
- 正在做的事情,描述到什么程度了,还剩什么
|
||||
- 例如:"F2 认证模块 — Auth BLoC 已完成,角色选择页未开始"
|
||||
|
||||
## 下一步工作 (按优先级)
|
||||
1. 具体的下一个任务,写清楚要做什么、在哪个文件
|
||||
2. 第二个任务
|
||||
3. 第三个任务
|
||||
|
||||
## 本会话的关键决策/发现
|
||||
- 记录影响后续工作的决策、踩过的坑、发现的注意事项
|
||||
```
|
||||
|
||||
**步骤 3 — 向用户输出交接摘要:**
|
||||
|
||||
用以下格式明确告诉用户,方便在新会话中说"按计划继续":
|
||||
|
||||
```
|
||||
## 交接摘要
|
||||
|
||||
### 本会话完成
|
||||
- ...
|
||||
|
||||
### 下一步 (新会话直接做)
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
### 注意事项
|
||||
- ...
|
||||
```
|
||||
|
||||
### 8.3 新会话启动指南
|
||||
|
||||
新会话启动时,AI 应:
|
||||
|
||||
1. **读取记忆文件** — `~/.claude/projects/g--nj/memory/project-status.md`
|
||||
2. **确认代码状态** — `git log --oneline -5` + `cargo check`
|
||||
3. **读取规划文档** — `plans/hazy-petting-lampson.md` 中对应阶段
|
||||
4. **直接开始工作** — 不要重复已完成的分析,按记忆中的"下一步工作"推进
|
||||
|
||||
### 8.4 交接质量标准
|
||||
|
||||
一份合格的交接必须让新会话**无需猜测**就能继续工作:
|
||||
|
||||
- ✅ 下一步是具体的文件和功能,不是模糊的方向
|
||||
- ✅ 未完成的工作状态清晰(做了多少、还差什么)
|
||||
- ✅ 关键决策已记录(为什么这样做、有哪些备选方案被否决)
|
||||
- ✅ 所有代码已提交,工作区干净
|
||||
|
||||
---
|
||||
|
||||
## 9. 参考文档
|
||||
|
||||
| 文档 | 位置 |
|
||||
|------|------|
|
||||
@@ -339,37 +422,13 @@ chore(docker): 添加 PostgreSQL 16 + Redis 7 开发环境
|
||||
|
||||
---
|
||||
|
||||
## 9. 开发环境
|
||||
## 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/` |
|
||||
| 基座仓库 | https://git.stableeasy.com/iven/base.git |
|
||||
| HMS 源码 (只读参考) | G:\hms |
|
||||
|
||||
```bash
|
||||
# 编译检查
|
||||
cargo check
|
||||
|
||||
# 运行测试
|
||||
cargo test
|
||||
|
||||
# 启动后端 (需要 PG + Redis)
|
||||
cd crates/erp-server && cargo run
|
||||
|
||||
# Docker 环境
|
||||
cd docker && docker compose up -d
|
||||
```
|
||||
|
||||
### 前端 (待创建)
|
||||
|
||||
```bash
|
||||
cd app
|
||||
flutter pub get
|
||||
flutter run
|
||||
flutter test
|
||||
```
|
||||
|
||||
### 配置
|
||||
|
||||
配置文件:`config/default.toml`,环境变量覆盖前缀 `ERP__`,分隔符 `__`
|
||||
|
||||
数据库:PostgreSQL 16 (端口 5432)
|
||||
Redis:7-alpine (端口 6379)
|
||||
API:`http://localhost:8080/api/v1/`
|
||||
|
||||
@@ -1,10 +1,53 @@
|
||||
// 暖记 App 根组件 — MaterialApp + BLoC Provider 注入
|
||||
//
|
||||
// 依赖注入结构:
|
||||
// RepositoryProvider<AuthRepository> — 认证仓库(全局唯一)
|
||||
// └─ BlocProvider<AuthBloc> — 认证 BLoC(全局唯一)
|
||||
// └─ MaterialApp.router — 路由(使用 auth 状态守卫)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/routing/app_router.dart';
|
||||
import 'data/remote/api_client.dart';
|
||||
import 'data/repositories/auth_repository.dart';
|
||||
import 'features/auth/bloc/auth_bloc.dart';
|
||||
|
||||
/// 暖记 App — 根组件
|
||||
class NuanjiApp extends StatelessWidget {
|
||||
const NuanjiApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 创建全局依赖(App 生命周期内单例)
|
||||
final apiClient = ApiClient();
|
||||
final authRepository = AuthRepository(apiClient: apiClient);
|
||||
final authBloc = AuthBloc(authRepository: authRepository);
|
||||
|
||||
// 启动时检查认证状态
|
||||
authBloc.add(const AppStarted());
|
||||
|
||||
return MultiRepositoryProvider(
|
||||
providers: [
|
||||
RepositoryProvider<ApiClient>.value(value: apiClient),
|
||||
RepositoryProvider<AuthRepository>.value(value: authRepository),
|
||||
],
|
||||
child: BlocProvider<AuthBloc>.value(
|
||||
value: authBloc,
|
||||
child: _AppView(router: createAppRouter(authBloc)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// App 视图 — MaterialApp.router 包装
|
||||
class _AppView extends StatelessWidget {
|
||||
final GoRouter router;
|
||||
|
||||
const _AppView({required this.router});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp.router(
|
||||
@@ -13,7 +56,7 @@ class NuanjiApp extends StatelessWidget {
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
themeMode: ThemeMode.system,
|
||||
routerConfig: appRouter,
|
||||
routerConfig: router,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// 暖记路由表 — go_router 20 页面
|
||||
// 暖记路由表 — go_router 20 页面 + 认证守卫
|
||||
//
|
||||
// 路由守卫逻辑:
|
||||
// - 未认证用户访问受保护路由 → 重定向到 /login
|
||||
// - 已认证用户访问 /login → 重定向到 /home
|
||||
// - 需要角色选择 → 重定向到 /role-selection
|
||||
// - 需要班级码 → 重定向到 /class-code
|
||||
|
||||
export '../../widgets/responsive_scaffold.dart';
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -14,123 +22,179 @@ import '../../features/search/views/search_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/class_code_join_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/achievement/views/achievement_page.dart';
|
||||
import '../../features/stickers/views/sticker_library_page.dart';
|
||||
import '../../features/templates/views/template_gallery_page.dart';
|
||||
import '../../features/auth/bloc/auth_bloc.dart';
|
||||
|
||||
// Shell 分支键
|
||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
/// 暖记路由配置
|
||||
final appRouter = GoRouter(
|
||||
navigatorKey: _rootNavigatorKey,
|
||||
initialLocation: '/home',
|
||||
debugLogDiagnostics: true,
|
||||
routes: [
|
||||
// 认证路由(无 Shell)
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
/// 不需要认证的白名单路径
|
||||
const _publicPaths = ['/login', '/role-selection', '/class-code'];
|
||||
|
||||
// 主 Shell 路由(底部导航 + 侧边导航)
|
||||
ShellRoute(
|
||||
navigatorKey: _shellNavigatorKey,
|
||||
builder: (context, state, child) {
|
||||
// 根据当前路径计算选中的 tab index
|
||||
final index = _selectedIndexFromLocation(state.uri.path);
|
||||
return _AppShell(
|
||||
selectedIndex: index,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
routes: [
|
||||
// Tab 0: 首页日记流
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
name: 'home',
|
||||
builder: (context, state) => const HomePage(),
|
||||
),
|
||||
// Tab 1: 日历
|
||||
GoRoute(
|
||||
path: '/calendar',
|
||||
name: 'calendar',
|
||||
builder: (context, state) => const CalendarPage(),
|
||||
),
|
||||
// Tab 2: 心情
|
||||
GoRoute(
|
||||
path: '/mood',
|
||||
name: 'mood',
|
||||
builder: (context, state) => const MoodPage(),
|
||||
),
|
||||
// Tab 3: 搜索
|
||||
GoRoute(
|
||||
path: '/search',
|
||||
name: 'search',
|
||||
builder: (context, state) => const SearchPage(),
|
||||
),
|
||||
// Tab 4: 个人中心
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
builder: (context, state) => const ProfilePage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
/// 创建路由配置 — 需要注入 AuthBloc
|
||||
GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
return GoRouter(
|
||||
navigatorKey: _rootNavigatorKey,
|
||||
initialLocation: '/home',
|
||||
debugLogDiagnostics: true,
|
||||
|
||||
// 全屏页面(无底部导航)
|
||||
GoRoute(
|
||||
path: '/editor',
|
||||
name: 'editor',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) {
|
||||
final journalId = state.uri.queryParameters['id'];
|
||||
return EditorPage(journalId: journalId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/class',
|
||||
name: 'class',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const ClassPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/teacher',
|
||||
name: 'teacher',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const TeacherPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/parent',
|
||||
name: 'parent',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const ParentPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/achievements',
|
||||
name: 'achievements',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const AchievementPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/stickers',
|
||||
name: 'stickers',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const StickerLibraryPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/templates',
|
||||
name: 'templates',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const TemplateGalleryPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
// ===== 认证路由守卫 =====
|
||||
redirect: (context, state) {
|
||||
final authState = authBloc.state;
|
||||
final currentPath = state.uri.path;
|
||||
|
||||
// 加载中 → 不做重定向
|
||||
if (authState is AuthInitial || authState is AuthLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final isAuthenticated = authState is Authenticated;
|
||||
final isPublicPath = _publicPaths.contains(currentPath);
|
||||
|
||||
// 已认证 + 访问公开页面 → 根据状态重定向
|
||||
if (isAuthenticated && isPublicPath) {
|
||||
if (authState.needsRoleSelection) return '/role-selection';
|
||||
if (authState.needsClassCode) return '/class-code';
|
||||
return '/home';
|
||||
}
|
||||
|
||||
// 已认证 + 访问受保护页面 → 检查是否需要额外步骤
|
||||
if (isAuthenticated) {
|
||||
if (authState.needsRoleSelection && currentPath != '/role-selection') {
|
||||
return '/role-selection';
|
||||
}
|
||||
if (authState.needsClassCode &&
|
||||
currentPath != '/class-code' &&
|
||||
currentPath != '/role-selection') {
|
||||
return '/class-code';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 未认证 + 访问公开页面 → 放行
|
||||
if (isPublicPath) return null;
|
||||
|
||||
// 未认证 + 访问受保护页面 → 重定向到登录
|
||||
return '/login';
|
||||
},
|
||||
|
||||
// 监听认证状态变化,自动触发重定向
|
||||
refreshListenable: _AuthListenable(authBloc),
|
||||
|
||||
routes: [
|
||||
// 认证路由(无 Shell)
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
builder: (context, state) => const LoginPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/role-selection',
|
||||
name: 'roleSelection',
|
||||
builder: (context, state) => const RoleSelectionPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/class-code',
|
||||
name: 'classCode',
|
||||
builder: (context, state) => const ClassCodeJoinPage(),
|
||||
),
|
||||
|
||||
// 主 Shell 路由(底部导航 + 侧边导航)
|
||||
ShellRoute(
|
||||
navigatorKey: _shellNavigatorKey,
|
||||
builder: (context, state, child) {
|
||||
final index = _selectedIndexFromLocation(state.uri.path);
|
||||
return _AppShell(
|
||||
selectedIndex: index,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
name: 'home',
|
||||
builder: (context, state) => const HomePage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/calendar',
|
||||
name: 'calendar',
|
||||
builder: (context, state) => const CalendarPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/mood',
|
||||
name: 'mood',
|
||||
builder: (context, state) => const MoodPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/search',
|
||||
name: 'search',
|
||||
builder: (context, state) => const SearchPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
name: 'profile',
|
||||
builder: (context, state) => const ProfilePage(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 全屏页面(无底部导航)
|
||||
GoRoute(
|
||||
path: '/editor',
|
||||
name: 'editor',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) {
|
||||
final journalId = state.uri.queryParameters['id'];
|
||||
return EditorPage(journalId: journalId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/class',
|
||||
name: 'class',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const ClassPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/teacher',
|
||||
name: 'teacher',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const TeacherPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/parent',
|
||||
name: 'parent',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const ParentPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/achievements',
|
||||
name: 'achievements',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const AchievementPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/stickers',
|
||||
name: 'stickers',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const StickerLibraryPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/templates',
|
||||
name: 'templates',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const TemplateGalleryPage(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 路径 → Tab index 映射
|
||||
int _selectedIndexFromLocation(String location) {
|
||||
@@ -138,7 +202,24 @@ int _selectedIndexFromLocation(String location) {
|
||||
if (location.startsWith('/mood')) return 2;
|
||||
if (location.startsWith('/search')) return 3;
|
||||
if (location.startsWith('/profile')) return 4;
|
||||
return 0; // 默认首页
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// AuthBloc 变化监听器 — 驱动 GoRouter refreshListenable
|
||||
class _AuthListenable extends ChangeNotifier {
|
||||
_AuthListenable(AuthBloc authBloc) {
|
||||
_subscription = authBloc.stream.listen((_) {
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
late final StreamSubscription<AuthState> _subscription;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// App Shell — 包裹 ResponsiveScaffold
|
||||
|
||||
54
app/lib/data/models/auth_token.dart
Normal file
54
app/lib/data/models/auth_token.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
// 认证令牌模型 — 匹配后端 LoginResp
|
||||
//
|
||||
// 管理访问令牌和刷新令牌,支持自动计算过期时间。
|
||||
// 令牌通过 flutter_secure_storage 安全持久化(PIPL 合规要求)。
|
||||
|
||||
/// 认证令牌 — 包含访问令牌、刷新令牌和过期信息
|
||||
class AuthToken {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresIn;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const AuthToken({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresIn,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
/// 令牌是否已过期
|
||||
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||
|
||||
/// 令牌是否即将过期(5 分钟内)
|
||||
bool get isExpiringSoon =>
|
||||
DateTime.now().isAfter(expiresAt.subtract(const Duration(minutes: 5)));
|
||||
|
||||
/// 从后端 LoginResp JSON 创建
|
||||
factory AuthToken.fromJson(Map<String, dynamic> json) {
|
||||
final expiresIn = (json['expires_in'] as int?) ?? 3600;
|
||||
return AuthToken(
|
||||
accessToken: json['access_token'] as String,
|
||||
refreshToken: json['refresh_token'] as String,
|
||||
expiresIn: expiresIn,
|
||||
expiresAt: DateTime.now().add(Duration(seconds: expiresIn)),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'access_token': accessToken,
|
||||
'refresh_token': refreshToken,
|
||||
'expires_in': expiresIn,
|
||||
'expires_at': expiresAt.toIso8601String(),
|
||||
};
|
||||
|
||||
/// 从持久化存储恢复(使用保存的过期时间)
|
||||
factory AuthToken.fromStorage(Map<String, dynamic> json) => AuthToken(
|
||||
accessToken: json['access_token'] as String,
|
||||
refreshToken: json['refresh_token'] as String,
|
||||
expiresIn: (json['expires_in'] as int?) ?? 3600,
|
||||
expiresAt: json['expires_at'] != null
|
||||
? DateTime.parse(json['expires_at'] as String)
|
||||
: DateTime.now().add(const Duration(hours: 1)),
|
||||
);
|
||||
}
|
||||
153
app/lib/data/models/user.dart
Normal file
153
app/lib/data/models/user.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
// 用户数据模型 — 匹配后端 UserResp / RoleResp
|
||||
//
|
||||
// 暖记用户包含四种角色:老师、学生、家长、独立用户。
|
||||
// 角色决定用户可访问的功能模块和页面。
|
||||
|
||||
/// 用户角色枚举 — 对应后端 role code
|
||||
enum UserRoleType {
|
||||
teacher('teacher'),
|
||||
student('student'),
|
||||
parent('parent'),
|
||||
independent('independent');
|
||||
|
||||
const UserRoleType(this.code);
|
||||
final String code;
|
||||
|
||||
/// 从后端角色代码解析,未知代码默认为独立用户
|
||||
static UserRoleType fromCode(String code) => UserRoleType.values.firstWhere(
|
||||
(r) => r.code == code,
|
||||
orElse: () => UserRoleType.independent,
|
||||
);
|
||||
}
|
||||
|
||||
/// 角色信息 — 匹配后端 RoleResp
|
||||
class UserRole {
|
||||
final String id;
|
||||
final String name;
|
||||
final String code;
|
||||
final String? description;
|
||||
final bool isSystem;
|
||||
final int version;
|
||||
|
||||
const UserRole({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.code,
|
||||
this.description,
|
||||
this.isSystem = false,
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
/// 获取标准化的角色类型
|
||||
UserRoleType get type => UserRoleType.fromCode(code);
|
||||
|
||||
factory UserRole.fromJson(Map<String, dynamic> json) => UserRole(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
code: json['code'] as String,
|
||||
description: json['description'] as String?,
|
||||
isSystem: (json['is_system'] as bool?) ?? false,
|
||||
version: (json['version'] as int?) ?? 1,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'code': code,
|
||||
'description': description,
|
||||
'is_system': isSystem,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
/// 用户信息 — 匹配后端 UserResp
|
||||
///
|
||||
/// 包含用户基本信息和角色列表。
|
||||
/// 角色由后端 RBAC 系统管理,前端据此控制页面可见性和功能访问。
|
||||
class User {
|
||||
final String id;
|
||||
final String username;
|
||||
final String? email;
|
||||
final String? phone;
|
||||
final String? displayName;
|
||||
final String? avatarUrl;
|
||||
final String status;
|
||||
final List<UserRole> roles;
|
||||
final int version;
|
||||
|
||||
const User({
|
||||
required this.id,
|
||||
required this.username,
|
||||
this.email,
|
||||
this.phone,
|
||||
this.displayName,
|
||||
this.avatarUrl,
|
||||
this.status = 'active',
|
||||
this.roles = const [],
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
User copyWith({
|
||||
String? displayName,
|
||||
String? avatarUrl,
|
||||
List<UserRole>? roles,
|
||||
int? version,
|
||||
}) =>
|
||||
User(
|
||||
id: id,
|
||||
username: username,
|
||||
email: email,
|
||||
phone: phone,
|
||||
displayName: displayName ?? this.displayName,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
status: status,
|
||||
roles: roles ?? this.roles,
|
||||
version: version ?? this.version,
|
||||
);
|
||||
|
||||
/// 获取用户的主角色类型(第一个角色的类型)
|
||||
UserRoleType get primaryRoleType =>
|
||||
roles.isNotEmpty ? roles.first.type : UserRoleType.independent;
|
||||
|
||||
/// 用户是否为老师
|
||||
bool get isTeacher => roles.any((r) => r.type == UserRoleType.teacher);
|
||||
|
||||
/// 用户是否为学生
|
||||
bool get isStudent => roles.any((r) => r.type == UserRoleType.student);
|
||||
|
||||
/// 用户是否为家长
|
||||
bool get isParent => roles.any((r) => r.type == UserRoleType.parent);
|
||||
|
||||
/// 用户是否已完成角色选择
|
||||
bool get hasRole => roles.isNotEmpty;
|
||||
|
||||
/// 显示名称:优先使用 displayName,回退到 username
|
||||
String get displayLabel => displayName ?? username;
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) => User(
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String,
|
||||
email: json['email'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
displayName: json['display_name'] as String?,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
status: (json['status'] as String?) ?? 'active',
|
||||
roles: (json['roles'] as List?)
|
||||
?.map((r) => UserRole.fromJson(r as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
version: (json['version'] as int?) ?? 1,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'username': username,
|
||||
'email': email,
|
||||
'phone': phone,
|
||||
'display_name': displayName,
|
||||
'avatar_url': avatarUrl,
|
||||
'status': status,
|
||||
'roles': roles.map((r) => r.toJson()).toList(),
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
264
app/lib/data/repositories/auth_repository.dart
Normal file
264
app/lib/data/repositories/auth_repository.dart
Normal file
@@ -0,0 +1,264 @@
|
||||
// 认证仓库 — 登录/注册/令牌管理的统一入口
|
||||
//
|
||||
// 职责:
|
||||
// - 封装后端认证 API 调用(登录/注册/刷新令牌/登出)
|
||||
// - 通过 flutter_secure_storage 安全持久化 JWT 令牌(PIPL 合规)
|
||||
// - 为 AuthBloc 提供干净的认证数据访问接口
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import '../models/auth_token.dart';
|
||||
import '../models/user.dart';
|
||||
import '../remote/api_client.dart';
|
||||
|
||||
/// 安全存储键名
|
||||
const _keyAccessToken = 'auth_access_token';
|
||||
const _keyRefreshToken = 'auth_refresh_token';
|
||||
const _keyExpiresAt = 'auth_expires_at';
|
||||
const _keyUserJson = 'auth_user_json';
|
||||
|
||||
/// 认证异常 — 认证流程中出现的错误
|
||||
class AuthException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
|
||||
const AuthException(this.message, {this.statusCode});
|
||||
|
||||
@override
|
||||
String toString() => 'AuthException: $message';
|
||||
}
|
||||
|
||||
/// 认证仓库 — 管理用户登录状态和令牌
|
||||
///
|
||||
/// 使用 [ApiClient] 与后端通信,使用 [FlutterSecureStorage] 持久化令牌。
|
||||
/// 所有令牌操作都是加密存储,满足儿童数据 PIPL 合规要求。
|
||||
class AuthRepository {
|
||||
final ApiClient _apiClient;
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||
|
||||
AuthToken? _currentToken;
|
||||
User? _currentUser;
|
||||
|
||||
AuthRepository({
|
||||
required ApiClient apiClient,
|
||||
FlutterSecureStorage? secureStorage,
|
||||
}) : _apiClient = apiClient,
|
||||
_secureStorage = secureStorage ??
|
||||
const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
/// 当前用户(可能为 null)
|
||||
User? get currentUser => _currentUser;
|
||||
|
||||
/// 当前令牌(可能为 null)
|
||||
AuthToken? get currentToken => _currentToken;
|
||||
|
||||
/// 是否已登录
|
||||
bool get isAuthenticated => _currentToken != null && !_currentToken!.isExpired;
|
||||
|
||||
// ===== 登录 =====
|
||||
|
||||
/// 用户名密码登录
|
||||
///
|
||||
/// 调用后端 `POST /auth/login`,成功后保存令牌和用户信息。
|
||||
Future<User> login({
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
_logger.i('登录请求: $username');
|
||||
|
||||
final response = await _apiClient.post('/auth/login', data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
'client_type': 'mobile',
|
||||
});
|
||||
|
||||
final data = _extractData(response.data);
|
||||
final token = AuthToken.fromJson(data);
|
||||
final user = User.fromJson(data['user'] as Map<String, dynamic>);
|
||||
|
||||
await _saveAuth(token, user);
|
||||
_apiClient.setToken(token.accessToken);
|
||||
|
||||
_logger.i('登录成功: ${user.displayLabel}');
|
||||
return user;
|
||||
}
|
||||
|
||||
// ===== 注册 =====
|
||||
|
||||
/// 注册新用户
|
||||
///
|
||||
/// 调用后端 `POST /users`,成功后自动登录。
|
||||
Future<User> register({
|
||||
required String username,
|
||||
required String password,
|
||||
String? displayName,
|
||||
}) async {
|
||||
_logger.i('注册请求: $username');
|
||||
|
||||
await _apiClient.post('/users', data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (displayName != null) 'display_name': displayName,
|
||||
});
|
||||
|
||||
// 注册成功后自动登录
|
||||
return await login(username: username, password: password);
|
||||
}
|
||||
|
||||
// ===== 刷新令牌 =====
|
||||
|
||||
/// 刷新访问令牌
|
||||
///
|
||||
/// 调用后端 `POST /auth/refresh`,使用 refresh_token 获取新的 access_token。
|
||||
Future<AuthToken> refreshToken() async {
|
||||
if (_currentToken == null) {
|
||||
throw const AuthException('无可用令牌,请重新登录');
|
||||
}
|
||||
|
||||
_logger.d('刷新令牌');
|
||||
|
||||
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);
|
||||
_apiClient.setToken(token.accessToken);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// ===== 登出 =====
|
||||
|
||||
/// 登出
|
||||
///
|
||||
/// 调用后端 `POST /auth/logout` 并清除本地存储。
|
||||
Future<void> logout() async {
|
||||
_logger.i('登出');
|
||||
|
||||
try {
|
||||
if (isAuthenticated) {
|
||||
await _apiClient.post('/auth/logout');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.w('登出 API 调用失败(忽略): $e');
|
||||
}
|
||||
|
||||
await _clearAuth();
|
||||
_apiClient.clearToken();
|
||||
}
|
||||
|
||||
// ===== 本地恢复 =====
|
||||
|
||||
/// 从安全存储恢复认证状态
|
||||
///
|
||||
/// App 启动时调用,检查是否有有效的持久化令牌。
|
||||
/// 如果令牌即将过期,自动刷新。
|
||||
Future<User?> restoreAuth() async {
|
||||
_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);
|
||||
|
||||
if (accessToken == null || refreshTokenStr == null || userJsonStr == null) {
|
||||
_logger.d('无存储的认证信息');
|
||||
return null;
|
||||
}
|
||||
|
||||
final expiresAt = DateTime.parse(expiresAtStr ?? DateTime.now().subtract(const Duration(hours: 1)).toIso8601String());
|
||||
|
||||
_currentToken = AuthToken(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshTokenStr,
|
||||
expiresIn: 0,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
|
||||
_currentUser = User.fromJson(
|
||||
jsonDecode(userJsonStr) as Map<String, dynamic>,
|
||||
);
|
||||
|
||||
// 令牌已过期 → 尝试刷新
|
||||
if (_currentToken!.isExpired) {
|
||||
_logger.d('令牌已过期,尝试刷新');
|
||||
try {
|
||||
await refreshToken();
|
||||
} catch (e) {
|
||||
_logger.w('令牌刷新失败,需要重新登录: $e');
|
||||
await _clearAuth();
|
||||
return null;
|
||||
}
|
||||
} else if (_currentToken!.isExpiringSoon) {
|
||||
// 即将过期 → 后台刷新(不阻塞)
|
||||
_logger.d('令牌即将过期,后台刷新');
|
||||
refreshToken().catchError((e) {
|
||||
_logger.w('后台刷新失败: $e');
|
||||
return _currentToken!; // 返回当前令牌作为 fallback
|
||||
});
|
||||
}
|
||||
|
||||
_apiClient.setToken(_currentToken!.accessToken);
|
||||
_logger.i('认证恢复成功: ${_currentUser!.displayLabel}');
|
||||
return _currentUser;
|
||||
} catch (e) {
|
||||
_logger.e('认证恢复失败: $e');
|
||||
await _clearAuth();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 私有方法 =====
|
||||
|
||||
/// 从 API 响应中提取 data 字段
|
||||
Map<String, dynamic> _extractData(dynamic responseData) {
|
||||
if (responseData is Map<String, dynamic>) {
|
||||
// 后端 ApiResponse 格式: { success: bool, data: T, message: String? }
|
||||
if (responseData.containsKey('data')) {
|
||||
return responseData['data'] as Map<String, dynamic>;
|
||||
}
|
||||
return responseData;
|
||||
}
|
||||
throw const AuthException('服务器响应格式异常');
|
||||
}
|
||||
|
||||
/// 保存令牌和用户到安全存储
|
||||
Future<void> _saveAuth(AuthToken token, User user) async {
|
||||
_currentToken = token;
|
||||
_currentUser = user;
|
||||
await _saveToken(token);
|
||||
await _secureStorage.write(
|
||||
key: _keyUserJson,
|
||||
value: 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());
|
||||
}
|
||||
|
||||
/// 清除所有认证数据
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,231 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// 成就页面 — 徽章收集展示
|
||||
|
||||
import 'package:flutter/material.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,
|
||||
});
|
||||
}
|
||||
|
||||
/// 成就页面 — 徽章收集和展示
|
||||
class AchievementPage extends StatelessWidget {
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Text('成就 - 占位页面'),
|
||||
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,
|
||||
),
|
||||
),
|
||||
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],
|
||||
colorScheme: colorScheme,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 成就进度卡片
|
||||
class _AchievementProgressCard extends StatelessWidget {
|
||||
const _AchievementProgressCard({
|
||||
required this.unlocked,
|
||||
required this.total,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
final int unlocked;
|
||||
final int total;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final progress = total > 0 ? unlocked / total : 0.0;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
color: colorScheme.primaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'收集进度',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$unlocked / $total',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 10,
|
||||
backgroundColor: colorScheme.primary.withValues(alpha: 0.15),
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 成就卡片
|
||||
class _AchievementCard extends StatelessWidget {
|
||||
const _AchievementCard({
|
||||
required this.achievement,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
final Achievement achievement;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: achievement.isUnlocked
|
||||
? AppColors.accent.withValues(alpha: 0.4)
|
||||
: colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: achievement.isUnlocked
|
||||
? AppColors.accent.withValues(alpha: 0.15)
|
||||
: colorScheme.onSurface.withValues(alpha: 0.05),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: achievement.isUnlocked
|
||||
? Text(achievement.icon, style: const TextStyle(fontSize: 28))
|
||||
: Icon(
|
||||
Icons.lock_outline,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
achievement.name,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: achievement.isUnlocked
|
||||
? colorScheme.onSurface
|
||||
: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
if (achievement.description != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
achievement.description!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: achievement.isUnlocked ? 0.6 : 0.3,
|
||||
),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
180
app/lib/features/auth/bloc/auth_bloc.dart
Normal file
180
app/lib/features/auth/bloc/auth_bloc.dart
Normal file
@@ -0,0 +1,180 @@
|
||||
// 认证 BLoC — 管理用户登录状态和认证流程
|
||||
//
|
||||
// 状态机: AuthInitial → AuthLoading → Unauthenticated/Authenticated
|
||||
// ↕
|
||||
// Authenticating → Authenticated/AuthError
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
||||
import '../../../data/models/auth_token.dart';
|
||||
import '../../../data/models/user.dart';
|
||||
import '../../../data/remote/api_client.dart';
|
||||
import '../../../data/repositories/auth_repository.dart';
|
||||
|
||||
part 'auth_event.dart';
|
||||
part 'auth_state.dart';
|
||||
|
||||
/// 认证 BLoC — 处理所有认证相关的状态转换
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final AuthRepository _authRepository;
|
||||
final Logger _logger = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||
|
||||
AuthBloc({required AuthRepository authRepository})
|
||||
: _authRepository = authRepository,
|
||||
super(const AuthInitial()) {
|
||||
// 注册事件处理器
|
||||
on<AppStarted>(_onAppStarted);
|
||||
on<LoginRequested>(_onLoginRequested);
|
||||
on<RegisterRequested>(_onRegisterRequested);
|
||||
on<RoleSelected>(_onRoleSelected);
|
||||
on<ClassCodeSubmitted>(_onClassCodeSubmitted);
|
||||
on<LogoutRequested>(_onLogoutRequested);
|
||||
on<TokenRefreshed>(_onTokenRefreshed);
|
||||
on<AuthExpired>(_onAuthExpired);
|
||||
}
|
||||
|
||||
/// App 启动 — 从本地存储恢复认证状态
|
||||
Future<void> _onAppStarted(
|
||||
AppStarted event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const AuthLoading());
|
||||
try {
|
||||
final user = await _authRepository.restoreAuth();
|
||||
if (user != null) {
|
||||
emit(Authenticated(
|
||||
user: user,
|
||||
needsRoleSelection: !user.hasRole,
|
||||
));
|
||||
} else {
|
||||
emit(const Unauthenticated());
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.e('恢复认证状态失败: $e');
|
||||
emit(const Unauthenticated());
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户登录
|
||||
Future<void> _onLoginRequested(
|
||||
LoginRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const Authenticating());
|
||||
try {
|
||||
final user = await _authRepository.login(
|
||||
username: event.username,
|
||||
password: event.password,
|
||||
);
|
||||
emit(Authenticated(
|
||||
user: user,
|
||||
needsRoleSelection: !user.hasRole,
|
||||
));
|
||||
} on AuthException catch (e) {
|
||||
_logger.w('登录失败: ${e.message}');
|
||||
emit(AuthError(e.message));
|
||||
} on OfflineException {
|
||||
emit(const AuthError('网络不可用,请检查网络连接', retryable: true));
|
||||
} catch (e) {
|
||||
_logger.e('登录异常: $e');
|
||||
emit(const AuthError('登录失败,请稍后重试'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户注册
|
||||
Future<void> _onRegisterRequested(
|
||||
RegisterRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
emit(const Authenticating(isRegister: true));
|
||||
try {
|
||||
final user = await _authRepository.register(
|
||||
username: event.username,
|
||||
password: event.password,
|
||||
displayName: event.displayName,
|
||||
);
|
||||
// 注册成功后需要选择角色
|
||||
emit(Authenticated(
|
||||
user: user,
|
||||
needsRoleSelection: true,
|
||||
));
|
||||
} on AuthException catch (e) {
|
||||
_logger.w('注册失败: ${e.message}');
|
||||
emit(AuthError(e.message));
|
||||
} on OfflineException {
|
||||
emit(const AuthError('网络不可用,请检查网络连接', retryable: true));
|
||||
} catch (e) {
|
||||
_logger.e('注册异常: $e');
|
||||
emit(const AuthError('注册失败,请稍后重试'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 用户选择角色
|
||||
Future<void> _onRoleSelected(
|
||||
RoleSelected event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
if (currentState is! Authenticated) return;
|
||||
|
||||
// 根据角色决定下一步
|
||||
final needsClassCode =
|
||||
event.role == UserRoleType.student || event.role == UserRoleType.parent;
|
||||
|
||||
emit(currentState.copyWith(
|
||||
needsRoleSelection: false,
|
||||
needsClassCode: needsClassCode,
|
||||
));
|
||||
|
||||
_logger.i('角色选择: ${event.role.name}, 需要班级码: $needsClassCode');
|
||||
}
|
||||
|
||||
/// 班级码加入
|
||||
Future<void> _onClassCodeSubmitted(
|
||||
ClassCodeSubmitted event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
if (currentState is! Authenticated) return;
|
||||
|
||||
// TODO: 调用后端 API 验证班级码并加入班级
|
||||
// 当前先标记为已完成,班级码验证在 F8 阶段完善
|
||||
emit(currentState.copyWith(
|
||||
needsClassCode: false,
|
||||
));
|
||||
|
||||
_logger.i('班级码加入: ${event.classCode}');
|
||||
}
|
||||
|
||||
/// 用户登出
|
||||
Future<void> _onLogoutRequested(
|
||||
LogoutRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _authRepository.logout();
|
||||
} catch (e) {
|
||||
_logger.w('登出失败(忽略): $e');
|
||||
}
|
||||
emit(const Unauthenticated());
|
||||
}
|
||||
|
||||
/// 令牌刷新成功
|
||||
Future<void> _onTokenRefreshed(
|
||||
TokenRefreshed event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
_logger.d('令牌已刷新');
|
||||
// 不改变当前状态,仅更新令牌
|
||||
}
|
||||
|
||||
/// 认证过期(401 拦截器触发)
|
||||
Future<void> _onAuthExpired(
|
||||
AuthExpired event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
_logger.w('认证过期,需要重新登录');
|
||||
emit(const Unauthenticated());
|
||||
}
|
||||
}
|
||||
68
app/lib/features/auth/bloc/auth_event.dart
Normal file
68
app/lib/features/auth/bloc/auth_event.dart
Normal file
@@ -0,0 +1,68 @@
|
||||
// 认证事件 — AuthBloc 接收的用户操作和系统事件
|
||||
|
||||
part of 'auth_bloc.dart';
|
||||
|
||||
/// 认证事件基类 — 使用 sealed class 实现穷尽匹配
|
||||
sealed class AuthEvent {
|
||||
const AuthEvent();
|
||||
}
|
||||
|
||||
/// App 启动 — 检查本地存储的认证状态
|
||||
final class AppStarted extends AuthEvent {
|
||||
const AppStarted();
|
||||
}
|
||||
|
||||
/// 用户请求登录
|
||||
final class LoginRequested extends AuthEvent {
|
||||
final String username;
|
||||
final String password;
|
||||
|
||||
const LoginRequested({
|
||||
required this.username,
|
||||
required this.password,
|
||||
});
|
||||
}
|
||||
|
||||
/// 用户请求注册
|
||||
final class RegisterRequested extends AuthEvent {
|
||||
final String username;
|
||||
final String password;
|
||||
final String? displayName;
|
||||
|
||||
const RegisterRequested({
|
||||
required this.username,
|
||||
required this.password,
|
||||
this.displayName,
|
||||
});
|
||||
}
|
||||
|
||||
/// 用户选择角色(注册后的角色选择步骤)
|
||||
final class RoleSelected extends AuthEvent {
|
||||
final UserRoleType role;
|
||||
|
||||
const RoleSelected(this.role);
|
||||
}
|
||||
|
||||
/// 班级码加入(学生/家长加入班级)
|
||||
final class ClassCodeSubmitted extends AuthEvent {
|
||||
final String classCode;
|
||||
|
||||
const ClassCodeSubmitted(this.classCode);
|
||||
}
|
||||
|
||||
/// 用户请求登出
|
||||
final class LogoutRequested extends AuthEvent {
|
||||
const LogoutRequested();
|
||||
}
|
||||
|
||||
/// 令牌刷新成功(由拦截器触发)
|
||||
final class TokenRefreshed extends AuthEvent {
|
||||
final AuthToken token;
|
||||
|
||||
const TokenRefreshed(this.token);
|
||||
}
|
||||
|
||||
/// 认证失败(由 401 拦截器触发)
|
||||
final class AuthExpired extends AuthEvent {
|
||||
const AuthExpired();
|
||||
}
|
||||
76
app/lib/features/auth/bloc/auth_state.dart
Normal file
76
app/lib/features/auth/bloc/auth_state.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
// 认证状态 — AuthBloc 输出的 UI 状态
|
||||
|
||||
part of 'auth_bloc.dart';
|
||||
|
||||
/// 认证状态基类 — 使用 sealed class 实现穷尽匹配
|
||||
sealed class AuthState {
|
||||
const AuthState();
|
||||
}
|
||||
|
||||
/// 初始状态 — App 刚启动
|
||||
final class AuthInitial extends AuthState {
|
||||
const AuthInitial();
|
||||
}
|
||||
|
||||
/// 加载中 — 正在检查本地存储的认证状态
|
||||
final class AuthLoading extends AuthState {
|
||||
const AuthLoading();
|
||||
}
|
||||
|
||||
/// 未认证 — 需要登录
|
||||
final class Unauthenticated extends AuthState {
|
||||
const Unauthenticated();
|
||||
}
|
||||
|
||||
/// 认证中 — 正在登录或注册
|
||||
final class Authenticating extends AuthState {
|
||||
/// 是否为注册模式(显示不同的 UI 提示)
|
||||
final bool isRegister;
|
||||
|
||||
const Authenticating({this.isRegister = false});
|
||||
}
|
||||
|
||||
/// 已认证 — 用户已登录
|
||||
final class Authenticated extends AuthState {
|
||||
final User user;
|
||||
|
||||
/// 是否需要角色选择(新注册用户还没有角色)
|
||||
final bool needsRoleSelection;
|
||||
|
||||
/// 是否需要班级码加入(学生/家长角色)
|
||||
final bool needsClassCode;
|
||||
|
||||
const Authenticated({
|
||||
required this.user,
|
||||
this.needsRoleSelection = false,
|
||||
this.needsClassCode = false,
|
||||
});
|
||||
|
||||
Authenticated copyWith({
|
||||
User? user,
|
||||
bool? needsRoleSelection,
|
||||
bool? needsClassCode,
|
||||
}) =>
|
||||
Authenticated(
|
||||
user: user ?? this.user,
|
||||
needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection,
|
||||
needsClassCode: needsClassCode ?? this.needsClassCode,
|
||||
);
|
||||
}
|
||||
|
||||
/// 认证错误 — 登录/注册失败
|
||||
final class AuthError extends AuthState {
|
||||
final String message;
|
||||
|
||||
/// 是否可以重试
|
||||
final bool retryable;
|
||||
|
||||
const AuthError(this.message, {this.retryable = true});
|
||||
}
|
||||
|
||||
/// 需要家长授权 — 未满 14 岁用户需要家长确认
|
||||
final class ParentAuthRequired extends AuthState {
|
||||
final User user;
|
||||
|
||||
const ParentAuthRequired(this.user);
|
||||
}
|
||||
189
app/lib/features/auth/views/class_code_join_page.dart
Normal file
189
app/lib/features/auth/views/class_code_join_page.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
// 班级码加入页面 — 学生/家长通过 6 位码加入班级
|
||||
//
|
||||
// 设计要点:
|
||||
// - 6 位独立输入框,自动聚焦下一位
|
||||
// - 输入完成后自动提交验证
|
||||
// - 安全限制:5 次错误后锁定 30 分钟
|
||||
// - 友好的状态反馈(验证中/成功/失败)
|
||||
|
||||
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 '../bloc/auth_bloc.dart';
|
||||
|
||||
/// 班级码加入页面
|
||||
class ClassCodeJoinPage extends StatefulWidget {
|
||||
const ClassCodeJoinPage({super.key});
|
||||
|
||||
@override
|
||||
State<ClassCodeJoinPage> createState() => _ClassCodeJoinPageState();
|
||||
}
|
||||
|
||||
class _ClassCodeJoinPageState extends State<ClassCodeJoinPage> {
|
||||
final List<TextEditingController> _controllers = List.generate(
|
||||
DesignTokens.classCodeLength,
|
||||
(_) => TextEditingController(),
|
||||
);
|
||||
final List<FocusNode> _focusNodes = List.generate(
|
||||
DesignTokens.classCodeLength,
|
||||
(_) => FocusNode(),
|
||||
);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 自动聚焦第一个输入框
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNodes[0].requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _controllers) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final f in _focusNodes) {
|
||||
f.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 获取当前输入的班级码
|
||||
String get _classCode => _controllers.map((c) => c.text).join();
|
||||
|
||||
/// 是否所有位都已输入
|
||||
bool get _isComplete =>
|
||||
_controllers.every((c) => c.text.isNotEmpty);
|
||||
|
||||
void _onChanged(int index, String value) {
|
||||
if (value.isEmpty && index > 0) {
|
||||
// 退格清空 → 跳到前一位
|
||||
_focusNodes[index - 1].requestFocus();
|
||||
} else if (value.isNotEmpty && index < DesignTokens.classCodeLength - 1) {
|
||||
// 输入字符 → 跳到下一位
|
||||
_focusNodes[index + 1].requestFocus();
|
||||
}
|
||||
|
||||
// 全部输入完成 → 自动提交
|
||||
if (_isComplete) {
|
||||
_submit();
|
||||
}
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_isComplete) return;
|
||||
context.read<AuthBloc>().add(ClassCodeSubmitted(_classCode));
|
||||
|
||||
// 提交后跳转到首页(班级码验证由 BLoC 处理)
|
||||
final state = context.read<AuthBloc>().state;
|
||||
if (state is Authenticated && !state.needsClassCode) {
|
||||
context.go('/home');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
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),
|
||||
|
||||
// 标题
|
||||
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),
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 单个班级码输入框
|
||||
Widget _buildCodeInput(BuildContext context, int index, ColorScheme colorScheme) {
|
||||
return SizedBox(
|
||||
width: 48,
|
||||
height: 56,
|
||||
child: TextField(
|
||||
controller: _controllers[index],
|
||||
focusNode: _focusNodes[index],
|
||||
textAlign: TextAlign.center,
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
maxLength: 1,
|
||||
keyboardType: TextInputType.text,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 2),
|
||||
),
|
||||
),
|
||||
onChanged: (value) => _onChanged(index, value),
|
||||
onSubmitted: (_) {
|
||||
if (_isComplete) _submit();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,329 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// 登录页面 — 用户名密码登录 + 注册切换
|
||||
//
|
||||
// 设计要点:
|
||||
// - 温暖治愈风格,使用珊瑚色主色调
|
||||
// - 表单验证友好提示(面向小学生,语言简单)
|
||||
// - 密码可切换可见性
|
||||
// - 登录/注册模式平滑切换
|
||||
|
||||
class LoginPage extends StatelessWidget {
|
||||
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 '../bloc/auth_bloc.dart';
|
||||
|
||||
/// 登录/注册页面
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _displayNameController = TextEditingController();
|
||||
|
||||
bool _isRegister = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
late final AnimationController _animController;
|
||||
late final Animation<double> _fadeAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animController = AnimationController(
|
||||
vsync: this,
|
||||
duration: DesignTokens.animNormal,
|
||||
);
|
||||
_fadeAnim = CurvedAnimation(
|
||||
parent: _animController,
|
||||
curve: DesignTokens.warmCurve,
|
||||
);
|
||||
_animController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_displayNameController.dispose();
|
||||
_animController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
if (_isRegister) {
|
||||
context.read<AuthBloc>().add(RegisterRequested(
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
displayName: _displayNameController.text.trim().isEmpty
|
||||
? null
|
||||
: _displayNameController.text.trim(),
|
||||
));
|
||||
} else {
|
||||
context.read<AuthBloc>().add(LoginRequested(
|
||||
username: _usernameController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Text('登录 - 占位页面'),
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, state) {
|
||||
if (state is Authenticated) {
|
||||
if (state.needsRoleSelection) {
|
||||
context.go('/role-selection');
|
||||
} else if (state.needsClassCode) {
|
||||
context.go('/class-code');
|
||||
} else {
|
||||
context.go('/home');
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.spacing32,
|
||||
),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnim,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildHeader(context, colorScheme),
|
||||
const SizedBox(height: DesignTokens.spacing48),
|
||||
_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),
|
||||
BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
if (state is AuthError) {
|
||||
return _buildErrorMessage(state.message, colorScheme);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing16),
|
||||
Text(
|
||||
'暖记',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing4),
|
||||
Text(
|
||||
'记录温暖,书写成长',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm(BuildContext context, ThemeData theme, ColorScheme colorScheme) {
|
||||
return Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: DesignTokens.animNormal,
|
||||
curve: DesignTokens.warmCurve,
|
||||
child: AnimatedSwitcher(
|
||||
duration: DesignTokens.animNormal,
|
||||
child: _isRegister
|
||||
? Padding(
|
||||
key: const ValueKey('display-name'),
|
||||
padding: const EdgeInsets.only(bottom: DesignTokens.spacing16),
|
||||
child: TextFormField(
|
||||
controller: _displayNameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '昵称',
|
||||
hintText: '你想被叫什么名字?',
|
||||
prefixIcon: const Icon(Icons.face_rounded),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('display-name-hide')),
|
||||
),
|
||||
),
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '账号',
|
||||
hintText: _isRegister ? '设置一个账号名' : '输入你的账号',
|
||||
prefixIcon: const Icon(Icons.person_rounded),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入账号';
|
||||
}
|
||||
if (value.trim().length < 3) {
|
||||
return '账号至少需要 3 个字符';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
hintText: _isRegister ? '设置一个密码' : '输入你的密码',
|
||||
prefixIcon: const Icon(Icons.lock_rounded),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_off_rounded
|
||||
: Icons.visibility_rounded,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入密码';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return '密码至少需要 6 个字符';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmitButton(BuildContext context, ColorScheme colorScheme) {
|
||||
return BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoading = state is Authenticating;
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: FilledButton(
|
||||
onPressed: isLoading ? null : _submit,
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
child: isLoading
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_isRegister ? '注册' : '登录',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeToggle(BuildContext context, ColorScheme colorScheme) {
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isRegister = !_isRegister;
|
||||
});
|
||||
_formKey.currentState?.reset();
|
||||
},
|
||||
child: Text(
|
||||
_isRegister ? '已有账号?去登录' : '没有账号?去注册',
|
||||
style: TextStyle(color: colorScheme.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorMessage(String message, ColorScheme colorScheme) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, size: 20, color: colorScheme.onErrorContainer),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: TextStyle(color: colorScheme.onErrorContainer, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
211
app/lib/features/auth/views/role_selection_page.dart
Normal file
211
app/lib/features/auth/views/role_selection_page.dart
Normal file
@@ -0,0 +1,211 @@
|
||||
// 角色选择页面 — 注册后选择身份角色
|
||||
//
|
||||
// 暖记四种角色:
|
||||
// - 🎓 老师 — 创建班级、布置主题、点评日记
|
||||
// - ✏️ 学生 — 加入班级、写日记、查看点评
|
||||
// - 👨👩👧 家长 — 查看孩子日记、管理数据
|
||||
// - 📖 独立用户 — 个人日记、不加入班级
|
||||
|
||||
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 '../../../data/models/user.dart';
|
||||
import '../bloc/auth_bloc.dart';
|
||||
|
||||
/// 角色卡片数据
|
||||
class _RoleCard {
|
||||
final UserRoleType type;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _RoleCard({
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
}
|
||||
|
||||
/// 角色选择页面
|
||||
class RoleSelectionPage extends StatelessWidget {
|
||||
const RoleSelectionPage({super.key});
|
||||
|
||||
static const _roles = [
|
||||
_RoleCard(
|
||||
type: UserRoleType.student,
|
||||
title: '我是学生',
|
||||
subtitle: '加入班级,记录每一天',
|
||||
icon: Icons.school_rounded,
|
||||
color: Color(0xFF81B29A), // 鼠尾草绿
|
||||
),
|
||||
_RoleCard(
|
||||
type: UserRoleType.teacher,
|
||||
title: '我是老师',
|
||||
subtitle: '创建班级,陪伴学生成长',
|
||||
icon: Icons.auto_stories_rounded,
|
||||
color: Color(0xFFE07A5F), // 珊瑚色
|
||||
),
|
||||
_RoleCard(
|
||||
type: UserRoleType.parent,
|
||||
title: '我是家长',
|
||||
subtitle: '关注孩子的成长记录',
|
||||
icon: Icons.family_restroom_rounded,
|
||||
color: Color(0xFFF2CC8F), // 暖金
|
||||
),
|
||||
_RoleCard(
|
||||
type: UserRoleType.independent,
|
||||
title: '独立使用',
|
||||
subtitle: '个人日记,随心记录',
|
||||
icon: Icons.menu_book_rounded,
|
||||
color: Color(0xFFD4A5A5), // 玫瑰粉
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: DesignTokens.spacing24,
|
||||
vertical: DesignTokens.spacing32,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Text(
|
||||
'你好!👋',
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
Text(
|
||||
'告诉我你的身份,我会为你定制体验',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing32),
|
||||
|
||||
// 角色卡片网格
|
||||
Expanded(
|
||||
child: GridView.count(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: DesignTokens.spacing16,
|
||||
crossAxisSpacing: DesignTokens.spacing16,
|
||||
childAspectRatio: 0.85,
|
||||
children: _roles.map((role) => _RoleCardWidget(
|
||||
role: role,
|
||||
onTap: () => _selectRole(context, role.type),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectRole(BuildContext context, UserRoleType role) {
|
||||
context.read<AuthBloc>().add(RoleSelected(role));
|
||||
|
||||
final state = context.read<AuthBloc>().state;
|
||||
if (state is Authenticated) {
|
||||
if (state.needsClassCode) {
|
||||
context.go('/class-code');
|
||||
} else {
|
||||
context.go('/home');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 角色卡片组件
|
||||
class _RoleCardWidget extends StatelessWidget {
|
||||
final _RoleCard role;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _RoleCardWidget({
|
||||
required this.role,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
child: Ink(
|
||||
decoration: BoxDecoration(
|
||||
color: role.color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: role.color.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 图标
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: role.color.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
role.icon,
|
||||
size: 28,
|
||||
color: role.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
|
||||
// 标题
|
||||
Text(
|
||||
role.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: role.color,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing4),
|
||||
|
||||
// 副标题
|
||||
Text(
|
||||
role.subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
175
app/lib/features/calendar/bloc/calendar_bloc.dart
Normal file
175
app/lib/features/calendar/bloc/calendar_bloc.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
// 日历 BLoC — 管理日历视图状态和日记列表
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
|
||||
// ===== Events =====
|
||||
|
||||
sealed class CalendarEvent {
|
||||
const CalendarEvent();
|
||||
}
|
||||
|
||||
/// 切换到指定月份
|
||||
final class CalendarMonthChanged extends CalendarEvent {
|
||||
final DateTime month;
|
||||
const CalendarMonthChanged(this.month);
|
||||
}
|
||||
|
||||
/// 选择某一天
|
||||
final class CalendarDaySelected extends CalendarEvent {
|
||||
final DateTime day;
|
||||
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 =====
|
||||
|
||||
/// 日历视图模式
|
||||
enum CalendarViewMode { month, week, timeline }
|
||||
|
||||
/// 日历状态
|
||||
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({
|
||||
required this.focusedMonth,
|
||||
required this.selectedDay,
|
||||
required this.journalsByDate,
|
||||
required this.selectedDayJournals,
|
||||
this.viewMode = CalendarViewMode.month,
|
||||
this.isLoading = false,
|
||||
});
|
||||
|
||||
CalendarLoaded copyWith({
|
||||
DateTime? focusedMonth,
|
||||
DateTime? selectedDay,
|
||||
Map<DateTime, List<JournalEntry>>? journalsByDate,
|
||||
List<JournalEntry>? selectedDayJournals,
|
||||
CalendarViewMode? viewMode,
|
||||
bool? isLoading,
|
||||
}) =>
|
||||
CalendarLoaded(
|
||||
focusedMonth: focusedMonth ?? this.focusedMonth,
|
||||
selectedDay: selectedDay ?? this.selectedDay,
|
||||
journalsByDate: journalsByDate ?? this.journalsByDate,
|
||||
selectedDayJournals: selectedDayJournals ?? this.selectedDayJournals,
|
||||
viewMode: viewMode ?? this.viewMode,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
);
|
||||
}
|
||||
|
||||
/// 加载失败
|
||||
final class CalendarError extends CalendarState {
|
||||
final String message;
|
||||
const CalendarError(this.message);
|
||||
}
|
||||
|
||||
// ===== BLoC =====
|
||||
|
||||
class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
|
||||
CalendarBloc() : super(const CalendarInitial()) {
|
||||
on<CalendarMonthChanged>(_onMonthChanged);
|
||||
on<CalendarDaySelected>(_onDaySelected);
|
||||
on<CalendarViewModeChanged>(_onViewModeChanged);
|
||||
on<CalendarLoadJournals>(_onLoadJournals);
|
||||
}
|
||||
|
||||
void _onMonthChanged(
|
||||
CalendarMonthChanged event,
|
||||
Emitter<CalendarState> emit,
|
||||
) {
|
||||
final currentState = state is CalendarLoaded ? state as CalendarLoaded : null;
|
||||
|
||||
emit(CalendarLoaded(
|
||||
focusedMonth: event.month,
|
||||
selectedDay: event.month,
|
||||
journalsByDate: currentState?.journalsByDate ?? {},
|
||||
selectedDayJournals: [],
|
||||
viewMode: currentState?.viewMode ?? CalendarViewMode.month,
|
||||
));
|
||||
|
||||
add(CalendarLoadJournals(event.month));
|
||||
}
|
||||
|
||||
void _onDaySelected(
|
||||
CalendarDaySelected event,
|
||||
Emitter<CalendarState> emit,
|
||||
) {
|
||||
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] ?? [];
|
||||
|
||||
emit(current.copyWith(
|
||||
selectedDay: event.day,
|
||||
selectedDayJournals: dayJournals,
|
||||
));
|
||||
}
|
||||
|
||||
void _onViewModeChanged(
|
||||
CalendarViewModeChanged event,
|
||||
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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,489 @@
|
||||
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/data/models/journal_entry.dart';
|
||||
import '../bloc/calendar_bloc.dart';
|
||||
|
||||
/// 日历页面 — 月视图 + 选中日期的日记列表
|
||||
class CalendarPage extends StatelessWidget {
|
||||
const CalendarPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Text('日历 - 占位页面'),
|
||||
return BlocProvider(
|
||||
create: (context) => CalendarBloc()
|
||||
..add(CalendarMonthChanged(DateTime.now())),
|
||||
child: const _CalendarView(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CalendarView extends StatelessWidget {
|
||||
const _CalendarView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return BlocBuilder<CalendarBloc, CalendarState>(
|
||||
builder: (context, state) {
|
||||
if (state is CalendarInitial) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
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('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is! CalendarLoaded) return const SizedBox.shrink();
|
||||
final loaded = state;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 月份导航
|
||||
_MonthNavigator(
|
||||
month: loaded.focusedMonth,
|
||||
onPrevious: () {
|
||||
final prev = DateTime(
|
||||
loaded.focusedMonth.year,
|
||||
loaded.focusedMonth.month - 1,
|
||||
);
|
||||
context.read<CalendarBloc>().add(CalendarMonthChanged(prev));
|
||||
},
|
||||
onNext: () {
|
||||
final next = DateTime(
|
||||
loaded.focusedMonth.year,
|
||||
loaded.focusedMonth.month + 1,
|
||||
);
|
||||
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));
|
||||
},
|
||||
),
|
||||
|
||||
const Divider(height: 1),
|
||||
|
||||
// 选中日期的日记列表
|
||||
Expanded(
|
||||
child: loaded.selectedDayJournals.isEmpty
|
||||
? _EmptyDayView(selectedDay: loaded.selectedDay)
|
||||
: _DayJournalList(journals: loaded.selectedDayJournals),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 月份导航栏
|
||||
class _MonthNavigator extends StatelessWidget {
|
||||
const _MonthNavigator({
|
||||
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 monthName = _formatMonth(month);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onPrevious,
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
tooltip: '上个月',
|
||||
),
|
||||
Text(
|
||||
monthName,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onNext,
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
tooltip: '下个月',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatMonth(DateTime date) {
|
||||
const months = [
|
||||
'1月', '2月', '3月', '4月', '5月', '6月',
|
||||
'7月', '8月', '9月', '10月', '11月', '12月',
|
||||
];
|
||||
return '${date.year}年 ${months[date.month - 1]}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 星期标题行
|
||||
class _WeekdayHeader extends StatelessWidget {
|
||||
const _WeekdayHeader({required this.colorScheme});
|
||||
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: weekdays.map((day) {
|
||||
return Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
day,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 日历网格 — 6行7列
|
||||
class _CalendarGrid extends StatelessWidget {
|
||||
const _CalendarGrid({
|
||||
required this.month,
|
||||
required this.selectedDay,
|
||||
required this.journalsByDate,
|
||||
required this.onDaySelected,
|
||||
});
|
||||
|
||||
final DateTime month;
|
||||
final DateTime selectedDay;
|
||||
final Map<DateTime, List<JournalEntry>> journalsByDate;
|
||||
final ValueChanged<DateTime> onDaySelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final today = DateTime.now();
|
||||
final days = _generateDays(month);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: days.map((week) {
|
||||
return Row(
|
||||
children: week.map((dayInfo) {
|
||||
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,
|
||||
onTap: () => onDaySelected(dayInfo.date),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 生成当月日历数据(包含前后补齐)
|
||||
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,
|
||||
));
|
||||
}
|
||||
|
||||
// 后面的补齐到完整行
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
class _DayInfo {
|
||||
const _DayInfo({required this.date, required this.isCurrentMonth});
|
||||
final DateTime date;
|
||||
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.onTap,
|
||||
});
|
||||
|
||||
final _DayInfo dayInfo;
|
||||
final bool isToday;
|
||||
final bool isSelected;
|
||||
final bool hasJournals;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
height: 44,
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 日记指示点
|
||||
if (hasJournals)
|
||||
Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(top: 2),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected
|
||||
? colorScheme.onPrimary
|
||||
: AppColors.accent,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 无日记的空状态
|
||||
class _EmptyDayView extends StatelessWidget {
|
||||
const _EmptyDayView({required this.selectedDay});
|
||||
|
||||
final DateTime selectedDay;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_note_rounded,
|
||||
size: 64,
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'${selectedDay.month}月${selectedDay.day}日',
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'这一天还没有日记',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.tonal(
|
||||
onPressed: () => context.go('/editor'),
|
||||
child: const Text('写一篇'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 日记列表
|
||||
class _DayJournalList extends StatelessWidget {
|
||||
const _DayJournalList({required this.journals});
|
||||
|
||||
final List<JournalEntry> journals;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: journals.length,
|
||||
itemBuilder: (context, index) {
|
||||
final journal = journals[index];
|
||||
final moodColor = AppColors.moodColors[journal.mood.value] ?? colorScheme.primary;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => context.go('/editor?id=${journal.id}'),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 心情图标
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: moodColor.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
_moodEmoji(journal.mood),
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 标题和标签
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
journal.title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (journal.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
journal.tags.take(3).join(' · '),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _moodEmoji(Mood mood) {
|
||||
return switch (mood) {
|
||||
Mood.happy => '😊',
|
||||
Mood.calm => '😌',
|
||||
Mood.sad => '😢',
|
||||
Mood.angry => '😠',
|
||||
Mood.thinking => '🤔',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
// 编辑器 BLoC — 手写状态管理 + 撤销/重做
|
||||
// 编辑器 BLoC — 手写状态管理 + 元素管理 + 撤销/重做 + 自动保存
|
||||
//
|
||||
// 状态机:
|
||||
// - 手写层:笔画列表 + 画笔设置 + 撤销/重做栈
|
||||
// - 元素层:贴纸/照片/文字元素列表 + 选中元素 + 拖拽状态
|
||||
// - 工具栏:当前活动工具 + 颜色面板 + 笔刷大小
|
||||
// - 自动保存:笔画/元素变更 debounce → 触发保存回调
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../widgets/stroke_model.dart';
|
||||
import '../../../data/models/journal_element.dart';
|
||||
|
||||
// ===== Events =====
|
||||
// ============================================================
|
||||
// 事件
|
||||
// ============================================================
|
||||
|
||||
/// 编辑器事件基类
|
||||
abstract class EditorEvent {}
|
||||
|
||||
// --- 手写事件 ---
|
||||
|
||||
class BrushChanged extends EditorEvent {
|
||||
final BrushType type;
|
||||
final String color;
|
||||
@@ -30,9 +45,90 @@ class StrokesLoaded extends EditorEvent {
|
||||
StrokesLoaded(this.strokes);
|
||||
}
|
||||
|
||||
// ===== State =====
|
||||
// --- 元素事件 ---
|
||||
|
||||
/// 添加元素(贴纸/照片/文字)
|
||||
class ElementAdded extends EditorEvent {
|
||||
final JournalElement element;
|
||||
ElementAdded(this.element);
|
||||
}
|
||||
|
||||
/// 删除元素
|
||||
class ElementRemoved extends EditorEvent {
|
||||
final String elementId;
|
||||
ElementRemoved(this.elementId);
|
||||
}
|
||||
|
||||
/// 元素位置更新(拖拽中)
|
||||
class ElementMoved extends EditorEvent {
|
||||
final String elementId;
|
||||
final double positionX;
|
||||
final double positionY;
|
||||
ElementMoved({
|
||||
required this.elementId,
|
||||
required this.positionX,
|
||||
required this.positionY,
|
||||
});
|
||||
}
|
||||
|
||||
/// 元素尺寸更新(缩放中)
|
||||
class ElementResized extends EditorEvent {
|
||||
final String elementId;
|
||||
final double width;
|
||||
final double height;
|
||||
ElementResized({
|
||||
required this.elementId,
|
||||
required this.width,
|
||||
required this.height,
|
||||
});
|
||||
}
|
||||
|
||||
/// 元素旋转更新
|
||||
class ElementRotated extends EditorEvent {
|
||||
final String elementId;
|
||||
final double rotation;
|
||||
ElementRotated({required this.elementId, required this.rotation});
|
||||
}
|
||||
|
||||
/// 选中/取消选中元素
|
||||
class ElementSelected extends EditorEvent {
|
||||
final String? elementId;
|
||||
ElementSelected(this.elementId);
|
||||
}
|
||||
|
||||
// --- 工具栏事件 ---
|
||||
|
||||
/// 切换活动工具
|
||||
class ToolChanged extends EditorEvent {
|
||||
final EditorTool tool;
|
||||
ToolChanged(this.tool);
|
||||
}
|
||||
|
||||
/// 加载已有元素
|
||||
class ElementsLoaded extends EditorEvent {
|
||||
final List<JournalElement> elements;
|
||||
ElementsLoaded(this.elements);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 状态
|
||||
// ============================================================
|
||||
|
||||
/// 编辑器工具枚举
|
||||
enum EditorTool {
|
||||
pen, // 钢笔
|
||||
pencil, // 铅笔
|
||||
marker, // 马克笔
|
||||
eraser, // 橡皮擦
|
||||
select, // 选择/移动元素
|
||||
text, // 文字输入
|
||||
sticker, // 贴纸
|
||||
image, // 照片
|
||||
}
|
||||
|
||||
/// 编辑器状态
|
||||
class EditorState {
|
||||
// 手写层
|
||||
final List<Stroke> strokes;
|
||||
final List<Stroke> redoStack;
|
||||
final BrushType brushType;
|
||||
@@ -40,6 +136,17 @@ class EditorState {
|
||||
final double brushWidth;
|
||||
final int maxUndoSteps;
|
||||
|
||||
// 元素层
|
||||
final List<JournalElement> elements;
|
||||
final String? selectedElementId;
|
||||
|
||||
// 工具栏
|
||||
final EditorTool activeTool;
|
||||
|
||||
// 自动保存
|
||||
final bool isDirty;
|
||||
final DateTime? lastSavedAt;
|
||||
|
||||
const EditorState({
|
||||
this.strokes = const [],
|
||||
this.redoStack = const [],
|
||||
@@ -47,6 +154,11 @@ class EditorState {
|
||||
this.brushColor = '#2D2420',
|
||||
this.brushWidth = 3.0,
|
||||
this.maxUndoSteps = 50,
|
||||
this.elements = const [],
|
||||
this.selectedElementId,
|
||||
this.activeTool = EditorTool.pen,
|
||||
this.isDirty = false,
|
||||
this.lastSavedAt,
|
||||
});
|
||||
|
||||
EditorState copyWith({
|
||||
@@ -55,6 +167,12 @@ class EditorState {
|
||||
BrushType? brushType,
|
||||
String? brushColor,
|
||||
double? brushWidth,
|
||||
List<JournalElement>? elements,
|
||||
String? selectedElementId,
|
||||
bool clearSelection = false,
|
||||
EditorTool? activeTool,
|
||||
bool? isDirty,
|
||||
DateTime? lastSavedAt,
|
||||
}) =>
|
||||
EditorState(
|
||||
strokes: strokes ?? this.strokes,
|
||||
@@ -63,21 +181,75 @@ class EditorState {
|
||||
brushColor: brushColor ?? this.brushColor,
|
||||
brushWidth: brushWidth ?? this.brushWidth,
|
||||
maxUndoSteps: maxUndoSteps,
|
||||
elements: elements ?? this.elements,
|
||||
selectedElementId: clearSelection ? null : (selectedElementId ?? this.selectedElementId),
|
||||
activeTool: activeTool ?? this.activeTool,
|
||||
isDirty: isDirty ?? this.isDirty,
|
||||
lastSavedAt: lastSavedAt ?? this.lastSavedAt,
|
||||
);
|
||||
|
||||
/// 是否处于手写模式(画笔/橡皮工具)
|
||||
bool get isDrawingMode =>
|
||||
activeTool == EditorTool.pen ||
|
||||
activeTool == EditorTool.pencil ||
|
||||
activeTool == EditorTool.marker ||
|
||||
activeTool == EditorTool.eraser;
|
||||
|
||||
/// 是否处于元素操作模式
|
||||
bool get isElementMode =>
|
||||
activeTool == EditorTool.select ||
|
||||
activeTool == EditorTool.text ||
|
||||
activeTool == EditorTool.sticker ||
|
||||
activeTool == EditorTool.image;
|
||||
}
|
||||
|
||||
// ===== BLoC =====
|
||||
// ============================================================
|
||||
// BLoC
|
||||
// ============================================================
|
||||
|
||||
/// 编辑器 BLoC
|
||||
class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
EditorBloc() : super(const EditorState()) {
|
||||
/// 自动保存回调 — 由上层(EditorPage)提供
|
||||
final void Function(EditorState state)? onSave;
|
||||
|
||||
/// 自动保存 debounce 定时器
|
||||
Timer? _saveTimer;
|
||||
|
||||
/// 自动保存延迟
|
||||
static const _saveDelay = Duration(seconds: 2);
|
||||
|
||||
EditorBloc({this.onSave}) : super(const EditorState()) {
|
||||
// 手写事件
|
||||
on<BrushChanged>(_onBrushChanged);
|
||||
on<StrokeCompleted>(_onStrokeCompleted);
|
||||
on<Undo>(_onUndo);
|
||||
on<Redo>(_onRedo);
|
||||
on<ClearCanvas>(_onClearCanvas);
|
||||
on<StrokesLoaded>(_onStrokesLoaded);
|
||||
|
||||
// 元素事件
|
||||
on<ElementAdded>(_onElementAdded);
|
||||
on<ElementRemoved>(_onElementRemoved);
|
||||
on<ElementMoved>(_onElementMoved);
|
||||
on<ElementResized>(_onElementResized);
|
||||
on<ElementRotated>(_onElementRotated);
|
||||
on<ElementSelected>(_onElementSelected);
|
||||
on<ElementsLoaded>(_onElementsLoaded);
|
||||
|
||||
// 工具栏事件
|
||||
on<ToolChanged>(_onToolChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_saveTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 手写事件处理
|
||||
// ============================================================
|
||||
|
||||
void _onBrushChanged(BrushChanged event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(
|
||||
brushType: event.type,
|
||||
@@ -89,15 +261,16 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
void _onStrokeCompleted(StrokeCompleted event, Emitter<EditorState> emit) {
|
||||
final updatedStrokes = List<Stroke>.from(state.strokes)..add(event.stroke);
|
||||
|
||||
// 超过最大撤销步数时移除最旧的
|
||||
if (updatedStrokes.length > state.maxUndoSteps) {
|
||||
updatedStrokes.removeAt(0);
|
||||
}
|
||||
|
||||
emit(state.copyWith(
|
||||
strokes: updatedStrokes,
|
||||
redoStack: [], // 新笔画清空重做栈
|
||||
redoStack: [],
|
||||
isDirty: true,
|
||||
));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onUndo(Undo event, Emitter<EditorState> emit) {
|
||||
@@ -110,7 +283,9 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
emit(state.copyWith(
|
||||
strokes: updatedStrokes,
|
||||
redoStack: updatedRedoStack,
|
||||
isDirty: true,
|
||||
));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onRedo(Redo event, Emitter<EditorState> emit) {
|
||||
@@ -123,14 +298,113 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
emit(state.copyWith(
|
||||
strokes: updatedStrokes,
|
||||
redoStack: updatedRedoStack,
|
||||
isDirty: true,
|
||||
));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onClearCanvas(ClearCanvas event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(strokes: [], redoStack: []));
|
||||
emit(state.copyWith(
|
||||
strokes: [],
|
||||
redoStack: [],
|
||||
isDirty: true,
|
||||
));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onStrokesLoaded(StrokesLoaded event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(strokes: event.strokes, redoStack: []));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 元素事件处理
|
||||
// ============================================================
|
||||
|
||||
void _onElementAdded(ElementAdded event, Emitter<EditorState> emit) {
|
||||
final updated = List<JournalElement>.from(state.elements)..add(event.element);
|
||||
emit(state.copyWith(
|
||||
elements: updated,
|
||||
selectedElementId: event.element.id,
|
||||
isDirty: true,
|
||||
));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onElementRemoved(ElementRemoved event, Emitter<EditorState> emit) {
|
||||
final updated = List<JournalElement>.from(state.elements)
|
||||
..removeWhere((e) => e.id == event.elementId);
|
||||
emit(state.copyWith(
|
||||
elements: updated,
|
||||
clearSelection: state.selectedElementId == event.elementId,
|
||||
isDirty: true,
|
||||
));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onElementMoved(ElementMoved event, Emitter<EditorState> emit) {
|
||||
final updated = state.elements.map((e) {
|
||||
if (e.id != event.elementId) return e;
|
||||
return e.copyWith(
|
||||
positionX: event.positionX,
|
||||
positionY: event.positionY,
|
||||
);
|
||||
}).toList();
|
||||
emit(state.copyWith(elements: updated, isDirty: true));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onElementResized(ElementResized event, Emitter<EditorState> emit) {
|
||||
final updated = state.elements.map((e) {
|
||||
if (e.id != event.elementId) return e;
|
||||
return e.copyWith(width: event.width, height: event.height);
|
||||
}).toList();
|
||||
emit(state.copyWith(elements: updated, isDirty: true));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onElementRotated(ElementRotated event, Emitter<EditorState> emit) {
|
||||
final updated = state.elements.map((e) {
|
||||
if (e.id != event.elementId) return e;
|
||||
return e.copyWith(rotation: event.rotation);
|
||||
}).toList();
|
||||
emit(state.copyWith(elements: updated, isDirty: true));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onElementSelected(ElementSelected event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(
|
||||
selectedElementId: event.elementId,
|
||||
clearSelection: event.elementId == null,
|
||||
));
|
||||
}
|
||||
|
||||
void _onElementsLoaded(ElementsLoaded event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(elements: event.elements));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具栏事件处理
|
||||
// ============================================================
|
||||
|
||||
void _onToolChanged(ToolChanged event, Emitter<EditorState> emit) {
|
||||
// 切换工具时取消元素选中
|
||||
emit(state.copyWith(
|
||||
activeTool: event.tool,
|
||||
clearSelection: true,
|
||||
));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 自动保存
|
||||
// ============================================================
|
||||
|
||||
/// 调度自动保存 — debounce 2 秒
|
||||
void _scheduleAutoSave() {
|
||||
_saveTimer?.cancel();
|
||||
_saveTimer = Timer(_saveDelay, () {
|
||||
if (state.isDirty && onSave != null) {
|
||||
onSave!(state.copyWith(isDirty: false, lastSavedAt: DateTime.now()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// 手账编辑器页面 — 三层 Stack 架构
|
||||
//
|
||||
// Layer 1 (底层): HandwritingCanvas — 手写画布
|
||||
// Layer 2 (中层): DraggableElements — 贴纸/照片/文字元素
|
||||
// Layer 3 (顶层): EditorToolbar — 底部工具栏 + 顶栏操作
|
||||
//
|
||||
// 交互逻辑:
|
||||
// - 画笔模式 → Layer 1 接收手势,Layer 2 透传
|
||||
// - 选择模式 → Layer 2 接收手势,Layer 1 透传
|
||||
// - 工具栏 → 始终在最顶层
|
||||
|
||||
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 '../../../data/models/journal_element.dart';
|
||||
import '../bloc/editor_bloc.dart';
|
||||
import '../widgets/handwriting_canvas.dart';
|
||||
import '../widgets/draggable_element.dart';
|
||||
import '../widgets/editor_toolbar.dart';
|
||||
|
||||
/// 手账编辑器页面
|
||||
class EditorPage extends StatelessWidget {
|
||||
final String? journalId;
|
||||
|
||||
@@ -7,14 +28,214 @@ class EditorPage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (_) => EditorBloc(
|
||||
onSave: (state) {
|
||||
// TODO: 通过 JournalRepository 保存到 Isar
|
||||
debugPrint('自动保存: ${state.strokes.length} 笔画, ${state.elements.length} 元素');
|
||||
},
|
||||
),
|
||||
child: _EditorView(journalId: journalId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EditorView extends StatelessWidget {
|
||||
final String? journalId;
|
||||
|
||||
const _EditorView({this.journalId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Text(
|
||||
journalId != null
|
||||
? '编辑日记 ($journalId) - 占位页面'
|
||||
: '新建日记 - 占位页面',
|
||||
backgroundColor: colorScheme.surface,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶栏
|
||||
_buildTopBar(context),
|
||||
|
||||
// 编辑区域(三层 Stack)
|
||||
Expanded(
|
||||
child: BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, state) {
|
||||
return _EditorStack(state: state);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 底部工具栏
|
||||
BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, state) {
|
||||
return EditorToolbar(
|
||||
state: state,
|
||||
onEvent: (event) => context.read<EditorBloc>().add(event),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 顶部操作栏 — 返回/日记标题/完成
|
||||
Widget _buildTopBar(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
height: 52,
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(color: colorScheme.outline.withValues(alpha: 0.1)),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
IconButton(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
tooltip: '返回',
|
||||
),
|
||||
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
|
||||
// 日记标题
|
||||
Expanded(
|
||||
child: Text(
|
||||
journalId != null ? '编辑日记' : '新建日记',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 完成按钮
|
||||
FilledButton.tonal(
|
||||
onPressed: () {
|
||||
// TODO: 保存并返回
|
||||
context.pop();
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing16),
|
||||
minimumSize: const Size(0, 36),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
child: const Text('完成'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 编辑器三层 Stack
|
||||
// ============================================================
|
||||
|
||||
/// 编辑器 Stack — 三层叠加结构
|
||||
///
|
||||
/// Layer 1 (底层): HandwritingCanvas
|
||||
/// Layer 2 (中层): 可拖拽元素(贴纸/照片/文字)
|
||||
/// Layer 3 (顶层): 由 _EditorView 中的工具栏处理
|
||||
class _EditorStack extends StatelessWidget {
|
||||
final EditorState state;
|
||||
|
||||
const _EditorStack({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// Layer 1: 手写画布(底层)
|
||||
if (state.isDrawingMode)
|
||||
HandwritingCanvas(
|
||||
brushType: state.brushType,
|
||||
brushColor: state.brushColor,
|
||||
brushWidth: state.brushWidth,
|
||||
strokes: state.strokes,
|
||||
onStrokeCompleted: (stroke) {
|
||||
context.read<EditorBloc>().add(StrokeCompleted(stroke));
|
||||
},
|
||||
)
|
||||
else
|
||||
// 非绘画模式:显示已有笔画(不可交互)
|
||||
IgnorePointer(
|
||||
child: HandwritingCanvas(
|
||||
brushType: state.brushType,
|
||||
brushColor: state.brushColor,
|
||||
brushWidth: state.brushWidth,
|
||||
strokes: state.strokes,
|
||||
),
|
||||
),
|
||||
|
||||
// Layer 2: 可拖拽元素(中层)
|
||||
if (state.elements.isNotEmpty)
|
||||
_buildElementLayer(context),
|
||||
|
||||
// 空状态提示
|
||||
if (state.strokes.isEmpty && state.elements.isEmpty)
|
||||
_buildEmptyHint(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 元素层 — 所有日记元素叠加显示
|
||||
Widget _buildElementLayer(BuildContext context) {
|
||||
// 按 zIndex 排序
|
||||
final sorted = List<JournalElement>.from(state.elements)
|
||||
..sort((a, b) => a.zIndex.compareTo(b.zIndex));
|
||||
|
||||
return Stack(
|
||||
children: sorted.map((element) {
|
||||
return DraggableElement(
|
||||
key: ValueKey(element.id),
|
||||
element: element,
|
||||
isSelected: state.selectedElementId == element.id,
|
||||
onTap: (id) {
|
||||
context.read<EditorBloc>().add(ElementSelected(id));
|
||||
},
|
||||
onMoved: (id, x, y) {
|
||||
context.read<EditorBloc>().add(ElementMoved(
|
||||
elementId: id,
|
||||
positionX: x,
|
||||
positionY: y,
|
||||
));
|
||||
},
|
||||
onDeleted: (id) {
|
||||
context.read<EditorBloc>().add(ElementRemoved(id));
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 空状态提示
|
||||
Widget _buildEmptyHint(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.draw_rounded,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.15),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
Text(
|
||||
'在这里开始书写吧 ✏️',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
224
app/lib/features/editor/widgets/draggable_element.dart
Normal file
224
app/lib/features/editor/widgets/draggable_element.dart
Normal file
@@ -0,0 +1,224 @@
|
||||
// 可拖拽元素组件 — 日记页面中的贴纸/照片/文字交互层
|
||||
//
|
||||
// 支持操作:
|
||||
// - 拖拽移动(单指)
|
||||
// - 双指缩放
|
||||
// - 双指旋转
|
||||
// - 单击选中/取消选中
|
||||
// - 选中时显示边框和删除按钮
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/models/journal_element.dart';
|
||||
|
||||
/// 可拖拽日记元素组件
|
||||
class DraggableElement extends StatefulWidget {
|
||||
final JournalElement element;
|
||||
final bool isSelected;
|
||||
final ValueChanged<String> onTap;
|
||||
final void Function(String id, double x, double y) onMoved;
|
||||
final ValueChanged<String> onDeleted;
|
||||
|
||||
const DraggableElement({
|
||||
super.key,
|
||||
required this.element,
|
||||
this.isSelected = false,
|
||||
required this.onTap,
|
||||
required this.onMoved,
|
||||
required this.onDeleted,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DraggableElement> createState() => _DraggableElementState();
|
||||
}
|
||||
|
||||
class _DraggableElementState extends State<DraggableElement> {
|
||||
late double _x;
|
||||
late double _y;
|
||||
late double _width;
|
||||
late double _height;
|
||||
late double _rotation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncFromElement();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(DraggableElement oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 外部更新时同步(如撤销/重做)
|
||||
if (oldWidget.element.positionX != widget.element.positionX ||
|
||||
oldWidget.element.positionY != widget.element.positionY ||
|
||||
oldWidget.element.width != widget.element.width ||
|
||||
oldWidget.element.height != widget.element.height ||
|
||||
oldWidget.element.rotation != widget.element.rotation) {
|
||||
_syncFromElement();
|
||||
}
|
||||
}
|
||||
|
||||
void _syncFromElement() {
|
||||
_x = widget.element.positionX;
|
||||
_y = widget.element.positionY;
|
||||
_width = widget.element.width;
|
||||
_height = widget.element.height;
|
||||
_rotation = widget.element.rotation;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
left: _x,
|
||||
top: _y,
|
||||
child: Transform.rotate(
|
||||
angle: _rotation,
|
||||
child: GestureDetector(
|
||||
// 拖拽移动
|
||||
onPanUpdate: (details) {
|
||||
setState(() {
|
||||
_x += details.delta.dx;
|
||||
_y += details.delta.dy;
|
||||
});
|
||||
widget.onMoved(widget.element.id, _x, _y);
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
// 确保最终位置已通知
|
||||
widget.onMoved(widget.element.id, _x, _y);
|
||||
},
|
||||
// 点击选中
|
||||
onTap: () => widget.onTap(widget.element.id),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// 元素内容
|
||||
Container(
|
||||
width: _width,
|
||||
height: _height,
|
||||
decoration: widget.isSelected
|
||||
? BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
)
|
||||
: null,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: _buildElementContent(context),
|
||||
),
|
||||
),
|
||||
|
||||
// 选中时显示删除按钮
|
||||
if (widget.isSelected)
|
||||
Positioned(
|
||||
top: -12,
|
||||
right: -12,
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.onDeleted(widget.element.id),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close_rounded,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据元素类型构建内容
|
||||
Widget _buildElementContent(BuildContext context) {
|
||||
final element = widget.element;
|
||||
|
||||
switch (element.elementType) {
|
||||
case ElementType.text:
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
padding: const EdgeInsets.all(8),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
element.content['text'] as String? ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: (element.content['fontSize'] as num?)?.toDouble() ?? 16,
|
||||
color: _parseColor(element.content['fontColor'] as String?),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
case ElementType.sticker:
|
||||
return Container(
|
||||
color: Colors.transparent,
|
||||
alignment: Alignment.center,
|
||||
child: _buildStickerPlaceholder(context, element),
|
||||
);
|
||||
|
||||
case ElementType.image:
|
||||
return Container(
|
||||
color: Colors.grey.shade200,
|
||||
alignment: Alignment.center,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.image_rounded, size: 32, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'照片',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
case ElementType.tape:
|
||||
final tapeColor = _parseColor(element.content['tapeColor'] as String?);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: tapeColor.withValues(alpha: 0.6),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
|
||||
case ElementType.handwritingRef:
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
/// 贴纸占位 — 显示 emoji 或图标
|
||||
Widget _buildStickerPlaceholder(BuildContext context, JournalElement element) {
|
||||
final emoji = element.content['emoji'] as String?;
|
||||
if (emoji != null) {
|
||||
return Text(emoji, style: TextStyle(fontSize: _width * 0.6));
|
||||
}
|
||||
|
||||
// 默认贴纸图标
|
||||
return Icon(
|
||||
Icons.emoji_emotions_rounded,
|
||||
size: _width * 0.5,
|
||||
color: Theme.of(context).colorScheme.tertiary,
|
||||
);
|
||||
}
|
||||
|
||||
/// 解析颜色字符串
|
||||
Color _parseColor(String? hex) {
|
||||
if (hex == null) return const Color(0xFF2D2420);
|
||||
final hexStr = hex.replaceFirst('#', '');
|
||||
if (hexStr.length != 6) return const Color(0xFF2D2420);
|
||||
final value = int.tryParse(hexStr, radix: 16);
|
||||
if (value == null) return const Color(0xFF2D2420);
|
||||
return Color(0xFF000000 + value);
|
||||
}
|
||||
}
|
||||
295
app/lib/features/editor/widgets/editor_toolbar.dart
Normal file
295
app/lib/features/editor/widgets/editor_toolbar.dart
Normal file
@@ -0,0 +1,295 @@
|
||||
// 编辑器工具栏 — 底部工具面板
|
||||
//
|
||||
// 三段式布局:
|
||||
// - 工具选择行(画笔/选择/文字/贴纸/照片)
|
||||
// - 工具选项行(颜色/大小 — 根据当前工具动态变化)
|
||||
// - 操作行(撤销/重做/清除)
|
||||
//
|
||||
// 设计规范:触摸目标 ≥ 44px,圆角 22px (pill)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/constants/design_tokens.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../bloc/editor_bloc.dart';
|
||||
|
||||
/// 工具栏高度
|
||||
const double _toolbarHeight = 160;
|
||||
|
||||
/// 编辑器工具栏
|
||||
class EditorToolbar extends StatelessWidget {
|
||||
final EditorState state;
|
||||
final ValueChanged<EditorEvent> onEvent;
|
||||
|
||||
const EditorToolbar({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onEvent,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
height: _toolbarHeight,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(22)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 工具选择行
|
||||
_buildToolRow(context, colorScheme),
|
||||
const Divider(height: 1),
|
||||
|
||||
// 工具选项行(颜色/大小)
|
||||
_buildOptionsRow(context, colorScheme),
|
||||
const Divider(height: 1),
|
||||
|
||||
// 操作行(撤销/重做/清除)
|
||||
_buildActionRow(context, colorScheme),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具选择行
|
||||
// ============================================================
|
||||
|
||||
Widget _buildToolRow(BuildContext context, ColorScheme colorScheme) {
|
||||
return SizedBox(
|
||||
height: 52,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_toolButton(context, EditorTool.pen, Icons.gesture_rounded, '钢笔'),
|
||||
_toolButton(context, EditorTool.pencil, Icons.edit_rounded, '铅笔'),
|
||||
_toolButton(context, EditorTool.marker, Icons.brush_rounded, '马克笔'),
|
||||
_toolButton(context, EditorTool.eraser, Icons.auto_fix_high_rounded, '橡皮'),
|
||||
_toolButton(context, EditorTool.select, Icons.near_me_rounded, '选择'),
|
||||
_toolButton(context, EditorTool.text, Icons.text_fields_rounded, '文字'),
|
||||
_toolButton(context, EditorTool.sticker, Icons.emoji_emotions_rounded, '贴纸'),
|
||||
_toolButton(context, EditorTool.image, Icons.add_photo_alternate_rounded, '照片'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _toolButton(
|
||||
BuildContext context,
|
||||
EditorTool tool,
|
||||
IconData icon,
|
||||
String label,
|
||||
) {
|
||||
final isActive = state.activeTool == tool;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return SizedBox(
|
||||
width: 44,
|
||||
height: 44,
|
||||
child: IconButton(
|
||||
onPressed: () => onEvent(ToolChanged(tool)),
|
||||
icon: Icon(icon, size: 22),
|
||||
color: isActive ? colorScheme.primary : colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: isActive
|
||||
? colorScheme.primaryContainer.withValues(alpha: 0.3)
|
||||
: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
tooltip: label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具选项行(颜色 + 大小)
|
||||
// ============================================================
|
||||
|
||||
static const _colors = [
|
||||
'#2D2420', // 主文字
|
||||
'#E07A5F', // 珊瑚
|
||||
'#81B29A', // 鼠尾草绿
|
||||
'#F2CC8F', // 暖金
|
||||
'#D4A5A5', // 玫瑰粉
|
||||
'#42A5F5', // 信息蓝
|
||||
'#9C27B0', // 紫色
|
||||
'#FFFFFF', // 白色
|
||||
];
|
||||
|
||||
static const _widths = [1.5, 3.0, 5.0, 8.0, 12.0];
|
||||
|
||||
Widget _buildOptionsRow(BuildContext context, ColorScheme colorScheme) {
|
||||
if (!state.isDrawingMode) {
|
||||
return const SizedBox(height: 44, child: Center(child: Text('选择元素或添加内容')));
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: Row(
|
||||
children: [
|
||||
// 颜色选择
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing12),
|
||||
itemCount: _colors.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 6),
|
||||
itemBuilder: (context, index) {
|
||||
final color = _colors[index];
|
||||
final isActive = state.brushColor == color;
|
||||
return GestureDetector(
|
||||
onTap: () => onEvent(BrushChanged(
|
||||
type: state.brushType,
|
||||
color: color,
|
||||
width: state.brushWidth,
|
||||
)),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: _parseHexColor(color),
|
||||
shape: BoxShape.circle,
|
||||
border: isActive
|
||||
? Border.all(color: colorScheme.primary, width: 2.5)
|
||||
: Border.all(color: Colors.grey.shade300, width: 1),
|
||||
),
|
||||
child: color == '#FFFFFF'
|
||||
? const Icon(Icons.check, size: 16, color: Colors.grey)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 分隔线
|
||||
Container(width: 1, height: 24, color: colorScheme.outline.withValues(alpha: 0.2)),
|
||||
|
||||
// 笔刷大小
|
||||
SizedBox(
|
||||
width: 160,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: _widths.map((w) {
|
||||
final isActive = (state.brushWidth - w).abs() < 0.5;
|
||||
return GestureDetector(
|
||||
onTap: () => onEvent(BrushChanged(
|
||||
type: state.brushType,
|
||||
color: state.brushColor,
|
||||
width: w,
|
||||
)),
|
||||
child: Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: isActive
|
||||
? Border.all(color: colorScheme.primary, width: 2)
|
||||
: null,
|
||||
),
|
||||
child: Container(
|
||||
width: (w / 12 * 16 + 4).clamp(4, 20),
|
||||
height: (w / 12 * 16 + 4).clamp(4, 20),
|
||||
decoration: BoxDecoration(
|
||||
color: _parseHexColor(state.brushColor),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 操作行
|
||||
// ============================================================
|
||||
|
||||
Widget _buildActionRow(BuildContext context, ColorScheme colorScheme) {
|
||||
return SizedBox(
|
||||
height: 44,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// 撤销
|
||||
IconButton(
|
||||
onPressed: state.strokes.isNotEmpty
|
||||
? () => onEvent(Undo())
|
||||
: null,
|
||||
icon: const Icon(Icons.undo_rounded),
|
||||
tooltip: '撤销',
|
||||
),
|
||||
|
||||
// 重做
|
||||
IconButton(
|
||||
onPressed: state.redoStack.isNotEmpty
|
||||
? () => onEvent(Redo())
|
||||
: null,
|
||||
icon: const Icon(Icons.redo_rounded),
|
||||
tooltip: '重做',
|
||||
),
|
||||
|
||||
// 清除
|
||||
IconButton(
|
||||
onPressed: state.strokes.isNotEmpty || state.elements.isNotEmpty
|
||||
? () => onEvent(ClearCanvas())
|
||||
: null,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
tooltip: '清除',
|
||||
),
|
||||
|
||||
// 保存状态指示
|
||||
if (state.isDirty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8),
|
||||
child: Text(
|
||||
'未保存',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (state.lastSavedAt != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: DesignTokens.spacing8),
|
||||
child: Text(
|
||||
'已保存',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具函数
|
||||
// ============================================================
|
||||
|
||||
Color _parseHexColor(String hex) {
|
||||
final hexStr = hex.replaceFirst('#', '');
|
||||
if (hexStr.length != 6) return const Color(0xFF2D2420);
|
||||
final value = int.tryParse(hexStr, radix: 16);
|
||||
if (value == null) return const Color(0xFF2D2420);
|
||||
return Color(0xFF000000 + value);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// 首页 — 日记流 + 心情概览
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
|
||||
/// 首页 — 展示最近日记流和心情概览
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Text('首页 - 占位页面'),
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'暖记',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontFamily: 'Caveat',
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go('/stickers'),
|
||||
icon: const Icon(Icons.emoji_emotions_outlined),
|
||||
tooltip: '贴纸库',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/templates'),
|
||||
icon: const Icon(Icons.dashboard_customize_outlined),
|
||||
tooltip: '模板',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 心情快速选择卡片
|
||||
_QuickMoodCard(colorScheme: colorScheme),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 最近日记标题
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'最近日记',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/calendar'),
|
||||
child: const Text('查看全部'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 日记流占位 — 待数据层集成后替换
|
||||
const _EmptyJournalState(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 心情快速选择卡片
|
||||
class _QuickMoodCard extends StatelessWidget {
|
||||
const _QuickMoodCard({required this.colorScheme});
|
||||
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final moods = [
|
||||
('😊', '开心', Mood.happy),
|
||||
('😌', '平静', Mood.calm),
|
||||
('😢', '难过', Mood.sad),
|
||||
('😠', '生气', Mood.angry),
|
||||
('🤔', '思考', Mood.thinking),
|
||||
];
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'今天心情如何?',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: moods.map((mood) {
|
||||
return GestureDetector(
|
||||
onTap: () => context.go('/editor'),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: (AppColors.moodColors[mood.$3.value] ??
|
||||
colorScheme.primary)
|
||||
.withValues(alpha: 0.15),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(mood.$1, style: const TextStyle(fontSize: 22)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
mood.$2,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 空日记状态
|
||||
class _EmptyJournalState extends StatelessWidget {
|
||||
const _EmptyJournalState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.edit_note_rounded,
|
||||
size: 64,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.2),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'开始你的第一篇手账日记吧!',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton.icon(
|
||||
onPressed: () => context.go('/editor'),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text('写日记'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
128
app/lib/features/mood/bloc/mood_bloc.dart
Normal file
128
app/lib/features/mood/bloc/mood_bloc.dart
Normal file
@@ -0,0 +1,128 @@
|
||||
// 心情 BLoC — 管理心情统计和趋势数据
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
|
||||
// ===== 心情统计模型 =====
|
||||
|
||||
/// 心情统计数据
|
||||
class MoodStats {
|
||||
final List<MoodCount> moodCounts;
|
||||
final int streakDays;
|
||||
final int totalJournals;
|
||||
final Mood? dominantMood;
|
||||
|
||||
const MoodStats({
|
||||
this.moodCounts = const [],
|
||||
this.streakDays = 0,
|
||||
this.totalJournals = 0,
|
||||
this.dominantMood,
|
||||
});
|
||||
}
|
||||
|
||||
/// 单种心情的统计
|
||||
class MoodCount {
|
||||
final Mood mood;
|
||||
final int count;
|
||||
final double percentage;
|
||||
|
||||
const MoodCount({
|
||||
required this.mood,
|
||||
required this.count,
|
||||
required this.percentage,
|
||||
});
|
||||
}
|
||||
|
||||
/// 心情趋势数据点
|
||||
class MoodTrendPoint {
|
||||
final DateTime date;
|
||||
final Mood mood;
|
||||
final int journalCount;
|
||||
|
||||
const MoodTrendPoint({
|
||||
required this.date,
|
||||
required this.mood,
|
||||
required this.journalCount,
|
||||
});
|
||||
}
|
||||
|
||||
/// 统计周期
|
||||
enum StatsPeriod { week, month, quarter }
|
||||
|
||||
// ===== 心情页面状态 =====
|
||||
|
||||
/// 心情页面状态
|
||||
class MoodState {
|
||||
final MoodStats stats;
|
||||
final List<MoodTrendPoint> trendData;
|
||||
final StatsPeriod selectedPeriod;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
|
||||
const MoodState({
|
||||
this.stats = const MoodStats(),
|
||||
this.trendData = const [],
|
||||
this.selectedPeriod = StatsPeriod.week,
|
||||
this.isLoading = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
MoodState copyWith({
|
||||
MoodStats? stats,
|
||||
List<MoodTrendPoint>? trendData,
|
||||
StatsPeriod? selectedPeriod,
|
||||
bool? isLoading,
|
||||
String? errorMessage,
|
||||
}) =>
|
||||
MoodState(
|
||||
stats: stats ?? this.stats,
|
||||
trendData: trendData ?? this.trendData,
|
||||
selectedPeriod: selectedPeriod ?? this.selectedPeriod,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 心情 BLoC =====
|
||||
|
||||
class MoodBloc extends ChangeNotifier {
|
||||
MoodState _state = const MoodState();
|
||||
MoodState get state => _state;
|
||||
|
||||
/// 切换统计周期
|
||||
void changePeriod(StatsPeriod period) {
|
||||
_state = _state.copyWith(selectedPeriod: period, isLoading: true);
|
||||
notifyListeners();
|
||||
_loadStats();
|
||||
}
|
||||
|
||||
/// 加载统计数据
|
||||
Future<void> _loadStats() async {
|
||||
// Phase 1: 占位数据,待 API 集成后替换
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
|
||||
_state = _state.copyWith(
|
||||
isLoading: false,
|
||||
stats: MoodStats(
|
||||
moodCounts: [
|
||||
const MoodCount(mood: Mood.happy, count: 12, percentage: 40.0),
|
||||
const MoodCount(mood: Mood.calm, count: 8, percentage: 26.7),
|
||||
const MoodCount(mood: Mood.thinking, count: 5, percentage: 16.7),
|
||||
const MoodCount(mood: Mood.sad, count: 3, percentage: 10.0),
|
||||
const MoodCount(mood: Mood.angry, count: 2, percentage: 6.6),
|
||||
],
|
||||
streakDays: 7,
|
||||
totalJournals: 30,
|
||||
dominantMood: Mood.happy,
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 初始加载
|
||||
void load() {
|
||||
_state = _state.copyWith(isLoading: true);
|
||||
notifyListeners();
|
||||
_loadStats();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,356 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// 心情页面 — 心情统计 + 趋势图 + 连续天数
|
||||
|
||||
class MoodPage extends StatelessWidget {
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import '../bloc/mood_bloc.dart';
|
||||
|
||||
/// 心情页面 — 统计卡片 + 心情分布饼图 + 趋势折线图
|
||||
class MoodPage extends StatefulWidget {
|
||||
const MoodPage({super.key});
|
||||
|
||||
@override
|
||||
State<MoodPage> createState() => _MoodPageState();
|
||||
}
|
||||
|
||||
class _MoodPageState extends State<MoodPage> {
|
||||
final _bloc = MoodBloc();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bloc.load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bloc.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Text('心情 - 占位页面'),
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: _bloc,
|
||||
builder: (context, _) {
|
||||
final state = _bloc.state;
|
||||
|
||||
if (state.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 统计概览卡片
|
||||
_StatsOverviewCard(stats: state.stats, colorScheme: colorScheme),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 周期选择器
|
||||
_PeriodSelector(
|
||||
selectedPeriod: state.selectedPeriod,
|
||||
onPeriodChanged: _bloc.changePeriod,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 心情分布饼图
|
||||
_MoodDistributionChart(
|
||||
moodCounts: state.stats.moodCounts,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 心情详情列表
|
||||
Text(
|
||||
'心情详情',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...state.stats.moodCounts.map((mc) => _MoodCountTile(mc: mc)),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 连续天数鼓励卡片
|
||||
_StreakCard(streakDays: state.stats.streakDays),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计概览卡片
|
||||
class _StatsOverviewCard extends StatelessWidget {
|
||||
const _StatsOverviewCard({
|
||||
required this.stats,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
final MoodStats stats;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dominantEmoji = stats.dominantMood != null
|
||||
? _moodEmoji(stats.dominantMood!)
|
||||
: '📝';
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
color: colorScheme.primaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
// 主导心情图标
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: colorScheme.primary.withValues(alpha: 0.15),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(dominantEmoji, style: const TextStyle(fontSize: 28)),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'心情概览',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'共 ${stats.totalJournals} 篇日记 · 连续 ${stats.streakDays} 天',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 周期选择器
|
||||
class _PeriodSelector extends StatelessWidget {
|
||||
const _PeriodSelector({
|
||||
required this.selectedPeriod,
|
||||
required this.onPeriodChanged,
|
||||
});
|
||||
|
||||
final StatsPeriod selectedPeriod;
|
||||
final ValueChanged<StatsPeriod> onPeriodChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SegmentedButton<StatsPeriod>(
|
||||
segments: const [
|
||||
ButtonSegment(value: StatsPeriod.week, label: Text('周')),
|
||||
ButtonSegment(value: StatsPeriod.month, label: Text('月')),
|
||||
ButtonSegment(value: StatsPeriod.quarter, label: Text('季')),
|
||||
],
|
||||
selected: {selectedPeriod},
|
||||
onSelectionChanged: (set) => onPeriodChanged(set.first),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 心情分布饼图
|
||||
class _MoodDistributionChart extends StatelessWidget {
|
||||
const _MoodDistributionChart({
|
||||
required this.moodCounts,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
final List<MoodCount> moodCounts;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (moodCounts.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sections: moodCounts.map((mc) {
|
||||
final color = AppColors.moodColors[mc.mood.value] ?? colorScheme.primary;
|
||||
return PieChartSectionData(
|
||||
value: mc.count.toDouble(),
|
||||
color: color,
|
||||
radius: 50,
|
||||
title: '${mc.percentage.toStringAsFixed(0)}%',
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 心情计数列表项
|
||||
class _MoodCountTile extends StatelessWidget {
|
||||
const _MoodCountTile({required this.mc});
|
||||
|
||||
final MoodCount mc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final color = AppColors.moodColors[mc.mood.value] ?? theme.colorScheme.primary;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(_moodEmoji(mc.mood), style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_moodLabel(mc.mood),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: mc.percentage / 100,
|
||||
backgroundColor: color.withValues(alpha: 0.15),
|
||||
color: color,
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 48,
|
||||
child: Text(
|
||||
'${mc.count} 篇',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 连续天数鼓励卡片
|
||||
class _StreakCard extends StatelessWidget {
|
||||
const _StreakCard({required this.streakDays});
|
||||
|
||||
final int streakDays;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
color: AppColors.tertiary.withValues(alpha: 0.15),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text('🔥', style: TextStyle(fontSize: 32)),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'连续 $streakDays 天',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
streakDays >= 7
|
||||
? '太棒了!你已经坚持了一周 ✨'
|
||||
: '继续加油,坚持就是胜利!',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 辅助函数 =====
|
||||
|
||||
String _moodEmoji(Mood mood) => switch (mood) {
|
||||
Mood.happy => '😊',
|
||||
Mood.calm => '😌',
|
||||
Mood.sad => '😢',
|
||||
Mood.angry => '😠',
|
||||
Mood.thinking => '🤔',
|
||||
};
|
||||
|
||||
String _moodLabel(Mood mood) => switch (mood) {
|
||||
Mood.happy => '开心',
|
||||
Mood.calm => '平静',
|
||||
Mood.sad => '难过',
|
||||
Mood.angry => '生气',
|
||||
Mood.thinking => '思考',
|
||||
};
|
||||
|
||||
@@ -1,13 +1,215 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// 贴纸库页面 — 贴纸包浏览 + 贴纸网格
|
||||
|
||||
class StickerLibraryPage extends StatelessWidget {
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
|
||||
/// 贴纸包数据模型
|
||||
class StickerPack {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? coverEmoji;
|
||||
final int stickerCount;
|
||||
final bool isFree;
|
||||
final String? category;
|
||||
|
||||
const StickerPack({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.coverEmoji,
|
||||
this.stickerCount = 0,
|
||||
this.isFree = true,
|
||||
this.category,
|
||||
});
|
||||
}
|
||||
|
||||
/// 贴纸库页面 — 分类浏览贴纸包
|
||||
class StickerLibraryPage extends StatefulWidget {
|
||||
const StickerLibraryPage({super.key});
|
||||
|
||||
@override
|
||||
State<StickerLibraryPage> createState() => _StickerLibraryPageState();
|
||||
}
|
||||
|
||||
class _StickerLibraryPageState extends State<StickerLibraryPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
|
||||
// Phase 1 占位数据
|
||||
final _categories = ['全部', '动物', '食物', '自然', '节日', '表情'];
|
||||
|
||||
final _packs = const [
|
||||
StickerPack(id: '1', name: '可爱猫咪', coverEmoji: '🐱', stickerCount: 24, isFree: true, category: '动物'),
|
||||
StickerPack(id: '2', name: '小兔子系列', coverEmoji: '🐰', stickerCount: 20, isFree: true, category: '动物'),
|
||||
StickerPack(id: '3', name: '甜品派对', coverEmoji: '🍰', stickerCount: 18, isFree: true, category: '食物'),
|
||||
StickerPack(id: '4', name: '花朵合集', coverEmoji: '🌸', stickerCount: 22, isFree: true, category: '自然'),
|
||||
StickerPack(id: '5', name: '夏日清凉', coverEmoji: '🍉', stickerCount: 16, isFree: true, category: '食物'),
|
||||
StickerPack(id: '6', name: '星空物语', coverEmoji: '⭐', stickerCount: 20, isFree: false, category: '自然'),
|
||||
StickerPack(id: '7', name: '开心表情', coverEmoji: '😄', stickerCount: 30, isFree: true, category: '表情'),
|
||||
StickerPack(id: '8', name: '新年快乐', coverEmoji: '🎉', stickerCount: 15, isFree: false, category: '节日'),
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: _categories.length, vsync: this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tabController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Text('贴纸库 - 占位页面'),
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('贴纸库'),
|
||||
bottom: TabBar(
|
||||
controller: _tabController,
|
||||
isScrollable: true,
|
||||
tabAlignment: TabAlignment.start,
|
||||
tabs: _categories.map((c) => Tab(text: c)).toList(),
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
controller: _tabController,
|
||||
children: _categories.map((category) {
|
||||
final filtered = category == '全部'
|
||||
? _packs
|
||||
: _packs.where((p) => p.category == category).toList();
|
||||
return _StickerPackGrid(packs: filtered, colorScheme: colorScheme);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 贴纸包网格
|
||||
class _StickerPackGrid extends StatelessWidget {
|
||||
const _StickerPackGrid({
|
||||
required this.packs,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
final List<StickerPack> packs;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (packs.isEmpty) {
|
||||
return const Center(child: Text('暂无贴纸包'));
|
||||
}
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.85,
|
||||
),
|
||||
itemCount: packs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final pack = packs[index];
|
||||
return _StickerPackCard(pack: pack, colorScheme: colorScheme);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 贴纸包卡片
|
||||
class _StickerPackCard extends StatelessWidget {
|
||||
const _StickerPackCard({
|
||||
required this.pack,
|
||||
required this.colorScheme,
|
||||
});
|
||||
|
||||
final StickerPack pack;
|
||||
final ColorScheme colorScheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Phase 1: 展示贴纸包详情页(待实现)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('打开贴纸包: ${pack.name}')),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 贴纸包封面图标
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
pack.coverEmoji ?? '🎨',
|
||||
style: const TextStyle(fontSize: 32),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 名称
|
||||
Text(
|
||||
pack.name,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 数量和价格标签
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'${pack.stickerCount} 张',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
if (!pack.isFree) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'积分',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: AppColors.accent,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,189 @@
|
||||
import 'package:flutter/material.dart';
|
||||
// 模板画廊页面 — 日记模板浏览和选择
|
||||
|
||||
class TemplateGalleryPage extends StatelessWidget {
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
|
||||
/// 模板数据模型
|
||||
class Template {
|
||||
final String id;
|
||||
final String name;
|
||||
final String emoji;
|
||||
final String? category;
|
||||
final bool isFree;
|
||||
final String? description;
|
||||
|
||||
const Template({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.emoji,
|
||||
this.category,
|
||||
this.isFree = true,
|
||||
this.description,
|
||||
});
|
||||
}
|
||||
|
||||
/// 模板画廊页面 — 浏览和选择日记模板
|
||||
class TemplateGalleryPage extends StatefulWidget {
|
||||
const TemplateGalleryPage({super.key});
|
||||
|
||||
@override
|
||||
State<TemplateGalleryPage> createState() => _TemplateGalleryPageState();
|
||||
}
|
||||
|
||||
class _TemplateGalleryPageState extends State<TemplateGalleryPage> {
|
||||
String _selectedCategory = '全部';
|
||||
|
||||
final _categories = ['全部', '日常', '旅行', '校园', '节日', '创意'];
|
||||
|
||||
// Phase 1 占位数据
|
||||
final _templates = const [
|
||||
Template(id: '1', name: '今日心情', emoji: '💭', category: '日常', description: '记录今天的心情和感受'),
|
||||
Template(id: '2', name: '校园日记', emoji: '📚', category: '校园', description: '在学校的一天'),
|
||||
Template(id: '3', name: '旅行手账', emoji: '🗺️', category: '旅行', description: '记录旅行中的美好瞬间'),
|
||||
Template(id: '4', name: '美食记录', emoji: '🍜', category: '日常', description: '记录今天吃到的美食'),
|
||||
Template(id: '5', name: '读书笔记', emoji: '📖', category: '校园', description: '记录读完一本书的感想'),
|
||||
Template(id: '6', name: '节日特辑', emoji: '🎄', category: '节日', description: '特别的节日记录'),
|
||||
Template(id: '7', name: '自然观察', emoji: '🌿', category: '创意', description: '记录大自然的发现'),
|
||||
Template(id: '8', name: '梦想清单', emoji: '✨', category: '创意', description: '写下心中的梦想'),
|
||||
Template(id: '9', name: '周末时光', emoji: '☀️', category: '日常', description: '悠闲的周末记录'),
|
||||
Template(id: '10', name: '运动打卡', emoji: '🏃', category: '日常', description: '记录运动和锻炼'),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Text('模板画廊 - 占位页面'),
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
final filtered = _selectedCategory == '全部'
|
||||
? _templates
|
||||
: _templates.where((t) => t.category == _selectedCategory).toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('模板画廊'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
// 分类选择器
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
children: _categories.map((cat) {
|
||||
final isSelected = cat == _selectedCategory;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: FilterChip(
|
||||
selected: isSelected,
|
||||
label: Text(cat),
|
||||
onSelected: (_) {
|
||||
setState(() => _selectedCategory = cat);
|
||||
},
|
||||
selectedColor: colorScheme.primaryContainer,
|
||||
checkmarkColor: colorScheme.primary,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 模板网格
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 0.78,
|
||||
),
|
||||
itemCount: filtered.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _TemplateCard(template: filtered[index]);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 模板卡片
|
||||
class _TemplateCard extends StatelessWidget {
|
||||
const _TemplateCard({required this.template});
|
||||
|
||||
final Template template;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// 使用模板创建日记
|
||||
context.go('/editor?template=${template.id}');
|
||||
},
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 模板预览区
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colorScheme.primaryContainer.withValues(alpha: 0.5),
|
||||
AppColors.tertiary.withValues(alpha: 0.3),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
template.emoji,
|
||||
style: const TextStyle(fontSize: 36),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 模板名称
|
||||
Text(
|
||||
template.name,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 描述
|
||||
if (template.description != null)
|
||||
Text(
|
||||
template.description!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// 暖记 App 入口
|
||||
//
|
||||
// 初始化流程:
|
||||
// 1. 确保 Flutter 绑定就绪
|
||||
// 2. 运行 App(认证状态恢复在 AuthBloc.AppStarted 中处理)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app.dart';
|
||||
|
||||
|
||||
@@ -366,6 +366,54 @@ packages:
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.flutter-io.cn"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
||||
@@ -30,6 +30,9 @@ dependencies:
|
||||
# 连接检测
|
||||
connectivity_plus: ^6.1.0
|
||||
|
||||
# 安全存储(JWT 令牌持久化,PIPL 合规)
|
||||
flutter_secure_storage: ^9.2.0
|
||||
|
||||
# 手写引擎
|
||||
perfect_freehand: ^1.0.0
|
||||
|
||||
|
||||
@@ -178,3 +178,109 @@ pub struct CommentResp {
|
||||
pub content: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
// ========== 通知 ==========
|
||||
|
||||
/// 通知类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NotificationType {
|
||||
/// 收到评语
|
||||
CommentReceived,
|
||||
/// 主题布置
|
||||
TopicAssigned,
|
||||
/// 成就解锁
|
||||
AchievementUnlocked,
|
||||
/// 班级动态
|
||||
ClassUpdate,
|
||||
}
|
||||
|
||||
/// SSE 通知推送负载
|
||||
#[derive(Debug, Clone, Serialize, ToSchema)]
|
||||
pub struct NotificationPayload {
|
||||
/// 通知类型
|
||||
pub notification_type: NotificationType,
|
||||
/// 目标用户 ID
|
||||
pub recipient_id: uuid::Uuid,
|
||||
/// 通知标题
|
||||
pub title: String,
|
||||
/// 通知内容
|
||||
pub body: String,
|
||||
/// 关联业务 ID(评语 ID / 主题 ID / 成就 ID)
|
||||
pub business_id: Option<uuid::Uuid>,
|
||||
/// 附加数据
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub extra: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// ========== 心情统计 ==========
|
||||
|
||||
/// 心情统计响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct MoodStatsResp {
|
||||
/// 统计周期内各心情出现次数
|
||||
pub mood_counts: Vec<MoodCount>,
|
||||
/// 连续写日记天数
|
||||
pub streak_days: i32,
|
||||
/// 统计周期内总日记数
|
||||
pub total_journals: i32,
|
||||
/// 最常用的心情
|
||||
pub dominant_mood: Option<Mood>,
|
||||
}
|
||||
|
||||
/// 单种心情的统计
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct MoodCount {
|
||||
pub mood: Mood,
|
||||
pub count: i32,
|
||||
pub percentage: f64,
|
||||
}
|
||||
|
||||
// ========== 贴纸/模板 ==========
|
||||
|
||||
/// 贴纸包响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct StickerPackResp {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub cover_image_url: Option<String>,
|
||||
pub sticker_count: i32,
|
||||
pub is_free: bool,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// 贴纸响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct StickerResp {
|
||||
pub id: uuid::Uuid,
|
||||
pub pack_id: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub image_url: String,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
/// 模板响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TemplateResp {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub preview_url: Option<String>,
|
||||
pub template_data: Option<serde_json::Value>,
|
||||
pub category: Option<String>,
|
||||
pub is_free: bool,
|
||||
}
|
||||
|
||||
/// 成就响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AchievementResp {
|
||||
pub id: uuid::Uuid,
|
||||
pub code: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub category: String,
|
||||
pub is_unlocked: bool,
|
||||
pub unlocked_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
78
crates/erp-diary/src/handler/achievement_handler.rs
Normal file
78
crates/erp-diary/src/handler/achievement_handler.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
// 成就 API 处理器
|
||||
|
||||
use axum::extract::{Extension, FromRef, Path, State};
|
||||
use axum::response::Json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
|
||||
use crate::dto::AchievementResp;
|
||||
use crate::service::achievement_service::AchievementService;
|
||||
use crate::state::DiaryState;
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/diary/achievements",
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<AchievementResp>>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "成就管理"
|
||||
)]
|
||||
/// GET /api/v1/diary/achievements
|
||||
///
|
||||
/// 获取所有成就列表(含当前用户解锁状态)。需要 `diary.journal.read` 权限。
|
||||
pub async fn list_achievements<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<AchievementResp>>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "diary.journal.read")?;
|
||||
|
||||
let resp =
|
||||
AchievementService::list_achievements(ctx.tenant_id, ctx.user_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/diary/achievements/{code}/unlock",
|
||||
params(("code" = String, Path, description = "成就编码")),
|
||||
responses(
|
||||
(status = 200, description = "解锁成功", body = ApiResponse<AchievementResp>),
|
||||
(status = 404, description = "成就不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "成就管理"
|
||||
)]
|
||||
/// POST /api/v1/diary/achievements/:code/unlock
|
||||
///
|
||||
/// 解锁成就(幂等)。需要 `diary.journal.read` 权限。
|
||||
pub async fn unlock_achievement<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(code): Path<String>,
|
||||
) -> Result<Json<ApiResponse<AchievementResp>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "diary.journal.read")?;
|
||||
|
||||
let resp = AchievementService::unlock_achievement(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
&code,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use crate::state::DiaryState;
|
||||
(status = 200, description = "点评成功", body = ApiResponse<CommentResp>),
|
||||
(status = 400, description = "验证失败或内容安全检查未通过"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
(status = 403, description = "权限不足或不是本班老师"),
|
||||
(status = 404, description = "日记不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
@@ -30,6 +30,7 @@ use crate::state::DiaryState;
|
||||
/// POST /api/v1/diary/journals/:journal_id/comments
|
||||
///
|
||||
/// 老师点评日记。需要 `diary.comment.write` 权限。
|
||||
/// 仅本班老师可以点评,私密日记不允许点评。
|
||||
pub async fn create_comment<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
@@ -88,3 +89,34 @@ where
|
||||
let resp = CommentService::list_comments(ctx.tenant_id, journal_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/v1/diary/comments/{comment_id}",
|
||||
params(("comment_id" = Uuid, Path, description = "评语ID")),
|
||||
responses(
|
||||
(status = 200, description = "删除成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足或不是评语作者"),
|
||||
(status = 404, description = "评语不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "评语管理"
|
||||
)]
|
||||
/// DELETE /api/v1/diary/comments/:comment_id
|
||||
///
|
||||
/// 删除评语。仅评语作者可以删除自己的评语。需要 `diary.comment.delete` 权限。
|
||||
pub async fn delete_comment<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(comment_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "diary.comment.delete")?;
|
||||
|
||||
CommentService::delete_comment(ctx.tenant_id, ctx.user_id, comment_id, &state.db).await?;
|
||||
Ok(Json(ApiResponse::ok(())))
|
||||
}
|
||||
|
||||
@@ -5,3 +5,6 @@ pub mod sync_handler;
|
||||
pub mod class_handler;
|
||||
pub mod topic_handler;
|
||||
pub mod comment_handler;
|
||||
pub mod sticker_handler;
|
||||
pub mod achievement_handler;
|
||||
pub mod stats_handler;
|
||||
|
||||
66
crates/erp-diary/src/handler/stats_handler.rs
Normal file
66
crates/erp-diary/src/handler/stats_handler.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
// 统计 API 处理器
|
||||
|
||||
use axum::extract::{Extension, FromRef, Query, State};
|
||||
use axum::response::Json;
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
|
||||
use crate::dto::MoodStatsResp;
|
||||
use crate::service::mood_stats_service::{MoodStatsService, StatsPeriod};
|
||||
use crate::state::DiaryState;
|
||||
|
||||
/// 统计查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct StatsQuery {
|
||||
/// 统计周期:week / month / quarter(默认 month)
|
||||
pub period: Option<String>,
|
||||
}
|
||||
|
||||
fn parse_period(s: &Option<String>) -> StatsPeriod {
|
||||
match s.as_deref() {
|
||||
Some("week") => StatsPeriod::Week,
|
||||
Some("quarter") => StatsPeriod::Quarter,
|
||||
_ => StatsPeriod::Month,
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/diary/stats/mood",
|
||||
params(StatsQuery),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<MoodStatsResp>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "统计"
|
||||
)]
|
||||
/// GET /api/v1/diary/stats/mood
|
||||
///
|
||||
/// 获取当前用户的心情统计。需要 `diary.journal.read` 权限。
|
||||
pub async fn get_mood_stats<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(query): Query<StatsQuery>,
|
||||
) -> Result<Json<ApiResponse<MoodStatsResp>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "diary.journal.read")?;
|
||||
|
||||
let period = parse_period(&query.period);
|
||||
|
||||
let resp = MoodStatsService::get_mood_stats(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
period,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
157
crates/erp-diary/src/handler/sticker_handler.rs
Normal file
157
crates/erp-diary/src/handler/sticker_handler.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
// 贴纸与模板 API 处理器
|
||||
|
||||
use axum::extract::{Extension, FromRef, Path, Query, State};
|
||||
use axum::response::Json;
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
use erp_core::rbac::require_permission;
|
||||
use erp_core::types::{ApiResponse, TenantContext};
|
||||
|
||||
use crate::dto::{StickerPackResp, StickerResp, TemplateResp};
|
||||
use crate::service::sticker_service::StickerService;
|
||||
use crate::state::DiaryState;
|
||||
|
||||
/// 贴纸包查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct StickerPackQuery {
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/diary/sticker-packs",
|
||||
params(StickerPackQuery),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<StickerPackResp>>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "贴纸管理"
|
||||
)]
|
||||
/// GET /api/v1/diary/sticker-packs
|
||||
///
|
||||
/// 获取贴纸包列表。需要 `diary.journal.read` 权限。
|
||||
pub async fn list_sticker_packs<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(query): Query<StickerPackQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<StickerPackResp>>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "diary.journal.read")?;
|
||||
|
||||
let resp = StickerService::list_sticker_packs(
|
||||
ctx.tenant_id,
|
||||
query.category,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/diary/sticker-packs/{pack_id}/stickers",
|
||||
params(("pack_id" = Uuid, Path, description = "贴纸包ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<StickerResp>>),
|
||||
(status = 404, description = "贴纸包不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "贴纸管理"
|
||||
)]
|
||||
/// GET /api/v1/diary/sticker-packs/:pack_id/stickers
|
||||
///
|
||||
/// 获取贴纸包内的贴纸列表。需要 `diary.journal.read` 权限。
|
||||
pub async fn list_stickers_in_pack<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(pack_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<Vec<StickerResp>>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "diary.journal.read")?;
|
||||
|
||||
let resp =
|
||||
StickerService::list_stickers_in_pack(ctx.tenant_id, pack_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
/// 模板查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct TemplateQuery {
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/diary/templates",
|
||||
params(TemplateQuery),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<Vec<TemplateResp>>),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "模板管理"
|
||||
)]
|
||||
/// GET /api/v1/diary/templates
|
||||
///
|
||||
/// 获取模板列表。需要 `diary.journal.read` 权限。
|
||||
pub async fn list_templates<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Query(query): Query<TemplateQuery>,
|
||||
) -> Result<Json<ApiResponse<Vec<TemplateResp>>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "diary.journal.read")?;
|
||||
|
||||
let resp = StickerService::list_templates(
|
||||
ctx.tenant_id,
|
||||
query.category,
|
||||
&state.db,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/diary/templates/{template_id}",
|
||||
params(("template_id" = Uuid, Path, description = "模板ID")),
|
||||
responses(
|
||||
(status = 200, description = "成功", body = ApiResponse<TemplateResp>),
|
||||
(status = 404, description = "模板不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "模板管理"
|
||||
)]
|
||||
/// GET /api/v1/diary/templates/:template_id
|
||||
///
|
||||
/// 获取模板详情(含布局数据)。需要 `diary.journal.read` 权限。
|
||||
pub async fn get_template<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(template_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<TemplateResp>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "diary.journal.read")?;
|
||||
|
||||
let resp =
|
||||
StickerService::get_template(ctx.tenant_id, template_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
@@ -10,7 +10,10 @@ pub use state::DiaryState;
|
||||
|
||||
use erp_core::module::ErpModule;
|
||||
|
||||
use crate::handler::{journal_handler, sync_handler, class_handler, topic_handler, comment_handler};
|
||||
use crate::handler::{
|
||||
journal_handler, sync_handler, class_handler, topic_handler, comment_handler,
|
||||
sticker_handler, achievement_handler, stats_handler,
|
||||
};
|
||||
|
||||
/// 暖记日记业务模块
|
||||
pub struct DiaryModule;
|
||||
@@ -80,6 +83,12 @@ impl ErpModule for DiaryModule {
|
||||
module: "diary".into(),
|
||||
description: "允许老师点评日记".into(),
|
||||
},
|
||||
erp_core::module::PermissionDescriptor {
|
||||
code: "diary.comment.delete".into(),
|
||||
name: "删除评语".into(),
|
||||
module: "diary".into(),
|
||||
description: "允许删除自己的评语".into(),
|
||||
},
|
||||
erp_core::module::PermissionDescriptor {
|
||||
code: "diary.parent.bind".into(),
|
||||
name: "家长绑定".into(),
|
||||
@@ -157,5 +166,41 @@ impl DiaryModule {
|
||||
axum::routing::post(comment_handler::create_comment)
|
||||
.get(comment_handler::list_comments),
|
||||
)
|
||||
.route(
|
||||
"/diary/comments/{comment_id}",
|
||||
axum::routing::delete(comment_handler::delete_comment),
|
||||
)
|
||||
// 贴纸管理
|
||||
.route(
|
||||
"/diary/sticker-packs",
|
||||
axum::routing::get(sticker_handler::list_sticker_packs),
|
||||
)
|
||||
.route(
|
||||
"/diary/sticker-packs/{pack_id}/stickers",
|
||||
axum::routing::get(sticker_handler::list_stickers_in_pack),
|
||||
)
|
||||
// 模板管理
|
||||
.route(
|
||||
"/diary/templates",
|
||||
axum::routing::get(sticker_handler::list_templates),
|
||||
)
|
||||
.route(
|
||||
"/diary/templates/{template_id}",
|
||||
axum::routing::get(sticker_handler::get_template),
|
||||
)
|
||||
// 成就管理
|
||||
.route(
|
||||
"/diary/achievements",
|
||||
axum::routing::get(achievement_handler::list_achievements),
|
||||
)
|
||||
.route(
|
||||
"/diary/achievements/{code}/unlock",
|
||||
axum::routing::post(achievement_handler::unlock_achievement),
|
||||
)
|
||||
// 统计
|
||||
.route(
|
||||
"/diary/stats/mood",
|
||||
axum::routing::get(stats_handler::get_mood_stats),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
158
crates/erp-diary/src/service/achievement_service.rs
Normal file
158
crates/erp-diary/src/service/achievement_service.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
// 成就服务 — 成就定义与解锁逻辑
|
||||
|
||||
use chrono::Utc;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::AchievementResp;
|
||||
use crate::entity::{achievement, user_achievement};
|
||||
use crate::error::{DiaryError, DiaryResult};
|
||||
use crate::service::notification_service::NotificationService;
|
||||
use erp_core::events::EventBus;
|
||||
|
||||
/// 成就服务 — 规则引擎 + 徽章解锁
|
||||
///
|
||||
/// Phase 1 成就规则(客户端触发):
|
||||
/// - first_diary: 写第一篇日记
|
||||
/// - streak_7: 连续写日记 7 天
|
||||
/// - streak_30: 连续写日记 30 天
|
||||
/// - sticker_collector: 收集 10 张贴纸
|
||||
/// - social_butterfly: 分享 5 篇日记到班级
|
||||
pub struct AchievementService;
|
||||
|
||||
impl AchievementService {
|
||||
/// 获取所有成就列表(含用户解锁状态)
|
||||
pub async fn list_achievements(
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<Vec<AchievementResp>> {
|
||||
// 查询所有成就定义
|
||||
let achievements = achievement::Entity::find()
|
||||
.filter(achievement::Column::TenantId.eq(tenant_id))
|
||||
.filter(achievement::Column::DeletedAt.is_null())
|
||||
.order_by_asc(achievement::Column::SortOrder)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
// 查询用户已解锁的成就
|
||||
let unlocked = user_achievement::Entity::find()
|
||||
.filter(user_achievement::Column::UserId.eq(user_id))
|
||||
.filter(user_achievement::Column::TenantId.eq(tenant_id))
|
||||
.filter(user_achievement::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
// 构建已解锁集合
|
||||
let unlocked_map: std::collections::HashMap<Uuid, chrono::DateTime<Utc>> = unlocked
|
||||
.into_iter()
|
||||
.map(|ua| (ua.achievement_id, ua.unlocked_at))
|
||||
.collect();
|
||||
|
||||
Ok(achievements
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
let (is_unlocked, unlocked_at) = unlocked_map
|
||||
.get(&a.id)
|
||||
.map(|t| (true, Some(*t)))
|
||||
.unwrap_or((false, None));
|
||||
|
||||
AchievementResp {
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
description: a.description,
|
||||
icon: a.icon,
|
||||
category: a.category,
|
||||
is_unlocked,
|
||||
unlocked_at,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 解锁成就
|
||||
///
|
||||
/// 幂等操作:如果已解锁则直接返回。解锁后发送 SSE 通知。
|
||||
pub async fn unlock_achievement(
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
achievement_code: &str,
|
||||
db: &DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) -> DiaryResult<AchievementResp> {
|
||||
// 查找成就定义
|
||||
let ach = achievement::Entity::find()
|
||||
.filter(achievement::Column::TenantId.eq(tenant_id))
|
||||
.filter(achievement::Column::Code.eq(achievement_code))
|
||||
.filter(achievement::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
DiaryError::NotFound(format!("成就 {} 不存在", achievement_code))
|
||||
})?;
|
||||
|
||||
// 检查是否已解锁
|
||||
let existing = user_achievement::Entity::find()
|
||||
.filter(user_achievement::Column::UserId.eq(user_id))
|
||||
.filter(user_achievement::Column::AchievementId.eq(ach.id))
|
||||
.filter(user_achievement::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
// 已解锁,幂等返回
|
||||
return Ok(AchievementResp {
|
||||
id: ach.id,
|
||||
code: ach.code.clone(),
|
||||
name: ach.name.clone(),
|
||||
description: ach.description.clone(),
|
||||
icon: ach.icon.clone(),
|
||||
category: ach.category.clone(),
|
||||
is_unlocked: true,
|
||||
unlocked_at: existing.map(|e| e.unlocked_at),
|
||||
});
|
||||
}
|
||||
|
||||
// 创建解锁记录
|
||||
let now = Utc::now();
|
||||
let system_user = Uuid::nil();
|
||||
let model = user_achievement::ActiveModel {
|
||||
user_id: Set(user_id),
|
||||
achievement_id: Set(ach.id),
|
||||
tenant_id: Set(tenant_id),
|
||||
unlocked_at: Set(now),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(system_user),
|
||||
updated_by: Set(system_user),
|
||||
deleted_at: Set(None),
|
||||
version: Set(1),
|
||||
};
|
||||
model.insert(db).await?;
|
||||
|
||||
// 发送成就解锁通知
|
||||
NotificationService::notify_achievement_unlocked(
|
||||
tenant_id,
|
||||
user_id,
|
||||
ach.code.clone(),
|
||||
ach.name.clone(),
|
||||
db,
|
||||
event_bus,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(AchievementResp {
|
||||
id: ach.id,
|
||||
code: ach.code,
|
||||
name: ach.name,
|
||||
description: ach.description,
|
||||
icon: ach.icon,
|
||||
category: ach.category,
|
||||
is_unlocked: true,
|
||||
unlocked_at: Some(now),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use chrono::{Months, Utc};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter,
|
||||
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
|
||||
Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
@@ -268,7 +268,7 @@ impl ClassService {
|
||||
|
||||
/// 生成 6 位班级码(UUID 前 6 位字符)
|
||||
fn generate_class_code() -> String {
|
||||
Uuid::new_v4()
|
||||
Uuid::now_v7()
|
||||
.to_string()
|
||||
.replace("-", "")
|
||||
.chars()
|
||||
|
||||
@@ -7,18 +7,27 @@ use sea_orm::{
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::CommentResp;
|
||||
use crate::entity::{comment, journal_entry};
|
||||
use crate::entity::{class_member, comment, journal_entry};
|
||||
use crate::error::{DiaryError, DiaryResult};
|
||||
use crate::service::notification_service::NotificationService;
|
||||
use erp_core::events::{DomainEvent, EventBus};
|
||||
|
||||
/// 评语服务 — 老师对学生日记的点评
|
||||
///
|
||||
/// 权限约束:
|
||||
/// - 仅本班老师可以点评学生日记
|
||||
/// - 老师必须与日记作者属于同一班级
|
||||
pub struct CommentService;
|
||||
|
||||
impl CommentService {
|
||||
/// 添加评语(老师点评学生日记)
|
||||
///
|
||||
/// 验证日记存在,执行基础内容安全检查,
|
||||
/// 创建评论记录,发布 CommentCreated 事件。
|
||||
/// 流程:
|
||||
/// 1. 验证日记存在且未删除
|
||||
/// 2. 验证点评者是日记所属班级的老师
|
||||
/// 3. 执行内容安全检查
|
||||
/// 4. 创建评论记录
|
||||
/// 5. 发布 CommentCreated 事件(触发 SSE 推送)
|
||||
pub async fn create_comment(
|
||||
tenant_id: Uuid,
|
||||
author_id: Uuid,
|
||||
@@ -36,7 +45,15 @@ impl CommentService {
|
||||
.await?
|
||||
.ok_or_else(|| DiaryError::NotFound(format!("日记 {} 不存在", journal_id)))?;
|
||||
|
||||
// 2. 简单内容安全检查(基础敏感词过滤)
|
||||
// 2. 班级成员验证:点评者必须是日记所属班级的老师
|
||||
if let Some(class_id) = journal.class_id {
|
||||
Self::verify_teacher_in_class(tenant_id, author_id, class_id, db).await?;
|
||||
} else {
|
||||
// 私密日记(无班级)不允许点评
|
||||
return Err(DiaryError::Forbidden);
|
||||
}
|
||||
|
||||
// 3. 简单内容安全检查(基础敏感词过滤)
|
||||
if contains_sensitive_words(&content) {
|
||||
return Err(DiaryError::ContentSafetyViolation);
|
||||
}
|
||||
@@ -44,13 +61,13 @@ impl CommentService {
|
||||
let now = Utc::now();
|
||||
let id = Uuid::now_v7();
|
||||
|
||||
// 3. 创建评论记录
|
||||
// 4. 创建评论记录
|
||||
let model = comment::ActiveModel {
|
||||
id: Set(id),
|
||||
tenant_id: Set(tenant_id),
|
||||
journal_id: Set(journal_id),
|
||||
author_id: Set(author_id),
|
||||
content: Set(content),
|
||||
content: Set(content.clone()),
|
||||
created_at: Set(now),
|
||||
updated_at: Set(now),
|
||||
created_by: Set(author_id),
|
||||
@@ -60,7 +77,7 @@ impl CommentService {
|
||||
};
|
||||
let inserted = model.insert(db).await?;
|
||||
|
||||
// 4. 发布 CommentCreated 事件
|
||||
// 5. 发布 CommentCreated 事件
|
||||
event_bus
|
||||
.publish(
|
||||
DomainEvent::new(
|
||||
@@ -71,12 +88,26 @@ impl CommentService {
|
||||
"journal_id": journal_id,
|
||||
"teacher_id": author_id,
|
||||
"student_id": journal.author_id,
|
||||
"content_preview": content.chars().take(50).collect::<String>(),
|
||||
}),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 6. 发送 SSE 通知给学生
|
||||
NotificationService::notify_comment_created(
|
||||
tenant_id,
|
||||
journal.author_id,
|
||||
author_id,
|
||||
id,
|
||||
journal_id,
|
||||
content.chars().take(50).collect(),
|
||||
db,
|
||||
event_bus,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(comment_model_to_resp(inserted))
|
||||
}
|
||||
|
||||
@@ -98,6 +129,63 @@ impl CommentService {
|
||||
|
||||
Ok(comments.into_iter().map(comment_model_to_resp).collect())
|
||||
}
|
||||
|
||||
/// 删除评语(仅作者可删除自己的评语)
|
||||
pub async fn delete_comment(
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
comment_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<()> {
|
||||
let model = comment::Entity::find()
|
||||
.filter(comment::Column::Id.eq(comment_id))
|
||||
.filter(comment::Column::TenantId.eq(tenant_id))
|
||||
.filter(comment::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| DiaryError::NotFound(format!("评语 {} 不存在", comment_id)))?;
|
||||
|
||||
// 仅评语作者可以删除
|
||||
if model.author_id != user_id {
|
||||
return Err(DiaryError::Forbidden);
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let current_version = model.version;
|
||||
let mut active: comment::ActiveModel = model.into();
|
||||
active.deleted_at = Set(Some(now));
|
||||
active.updated_at = Set(now);
|
||||
active.updated_by = Set(user_id);
|
||||
active.version = Set(current_version + 1);
|
||||
active.update(db).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 验证用户是指定班级的老师
|
||||
///
|
||||
/// 检查 class_members 表中是否存在 (class_id, user_id, role=teacher) 记录。
|
||||
async fn verify_teacher_in_class(
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
class_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<()> {
|
||||
let membership = class_member::Entity::find()
|
||||
.filter(class_member::Column::ClassId.eq(class_id))
|
||||
.filter(class_member::Column::UserId.eq(user_id))
|
||||
.filter(class_member::Column::TenantId.eq(tenant_id))
|
||||
.filter(class_member::Column::Role.eq("teacher"))
|
||||
.filter(class_member::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?;
|
||||
|
||||
if membership.is_none() {
|
||||
return Err(DiaryError::Forbidden);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// comment::Model -> CommentResp
|
||||
@@ -113,11 +201,11 @@ fn comment_model_to_resp(model: comment::Model) -> CommentResp {
|
||||
|
||||
/// 基础敏感词检查
|
||||
///
|
||||
/// Phase 1 使用简单字符串匹配,后续迭代替换为完整词库。
|
||||
/// Phase 1 使用简单字符串匹配,B6 阶段替换为 ContentSafetyService。
|
||||
fn contains_sensitive_words(content: &str) -> bool {
|
||||
const SENSITIVE_WORDS: &[&str] = &[
|
||||
// 占位 — Phase 1 仅检查是否为空或过短
|
||||
// 完整词库将在后续迭代中添加
|
||||
// 完整词库将在 B6 ContentSafetyService 中添加
|
||||
];
|
||||
|
||||
if content.trim().is_empty() {
|
||||
@@ -132,3 +220,20 @@ fn contains_sensitive_words(content: &str) -> bool {
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_content_is_sensitive() {
|
||||
assert!(contains_sensitive_words(""));
|
||||
assert!(contains_sensitive_words(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_content_is_not_sensitive() {
|
||||
assert!(!contains_sensitive_words("今天天气真好!"));
|
||||
assert!(!contains_sensitive_words("老师点评:写得不错"));
|
||||
}
|
||||
}
|
||||
|
||||
110
crates/erp-diary/src/service/content_safety_service.rs
Normal file
110
crates/erp-diary/src/service/content_safety_service.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
// 内容安全服务 — 敏感词过滤(含谐音/拼音变体)
|
||||
//
|
||||
// Phase 1 使用基础字符串匹配 + 简单变体检测。
|
||||
// 后续迭代可接入第三方内容安全 API。
|
||||
|
||||
/// 内容安全服务 — 敏感词过滤
|
||||
pub struct ContentSafetyService;
|
||||
|
||||
/// 敏感词级别
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SafetyLevel {
|
||||
/// 安全
|
||||
Safe,
|
||||
/// 需审核
|
||||
NeedsReview,
|
||||
/// 违规
|
||||
Violation,
|
||||
}
|
||||
|
||||
/// 安全检查结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SafetyCheckResult {
|
||||
/// 安全级别
|
||||
pub level: SafetyLevel,
|
||||
/// 命中的敏感词列表
|
||||
pub matched_words: Vec<String>,
|
||||
/// 过滤后的内容(敏感词替换为 ***)
|
||||
pub filtered_content: String,
|
||||
}
|
||||
|
||||
impl ContentSafetyService {
|
||||
/// 检查内容安全性
|
||||
///
|
||||
/// 返回检查结果,包含安全级别、命中的敏感词和过滤后的内容。
|
||||
/// Phase 1 仅使用基础词库,B6 阶段将扩展为完整词库。
|
||||
pub fn check_content(content: &str) -> SafetyCheckResult {
|
||||
let mut matched_words = Vec::new();
|
||||
let mut filtered = content.to_string();
|
||||
|
||||
// Phase 1 基础敏感词库
|
||||
// 完整词库将在后续迭代中从配置文件加载
|
||||
const SENSITIVE_WORDS: &[&str] = &[
|
||||
// 占位 — Phase 1 仅做框架搭建
|
||||
// 实际词库将包含:暴力、色情、政治、侮辱等类别
|
||||
// 以及常见谐音和拼音变体
|
||||
];
|
||||
|
||||
for word in SENSITIVE_WORDS {
|
||||
if content.contains(word) {
|
||||
matched_words.push(word.to_string());
|
||||
filtered = filtered.replace(word, "***");
|
||||
}
|
||||
}
|
||||
|
||||
let level = if matched_words.is_empty() {
|
||||
SafetyLevel::Safe
|
||||
} else {
|
||||
SafetyLevel::Violation
|
||||
};
|
||||
|
||||
SafetyCheckResult {
|
||||
level,
|
||||
matched_words,
|
||||
filtered_content: filtered,
|
||||
}
|
||||
}
|
||||
|
||||
/// 快速检查内容是否安全
|
||||
///
|
||||
/// 返回 true 表示内容安全,false 表示包含敏感内容。
|
||||
pub fn is_safe(content: &str) -> bool {
|
||||
Self::check_content(content).level == SafetyLevel::Safe
|
||||
}
|
||||
|
||||
/// 过滤内容中的敏感词
|
||||
///
|
||||
/// 返回过滤后的内容,敏感词替换为 ***。
|
||||
pub fn filter_content(content: &str) -> String {
|
||||
Self::check_content(content).filtered_content
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn safe_content_passes() {
|
||||
let result = ContentSafetyService::check_content("今天天气真好,我和同学一起写日记");
|
||||
assert_eq!(result.level, SafetyLevel::Safe);
|
||||
assert!(result.matched_words.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_content_is_safe() {
|
||||
let result = ContentSafetyService::check_content("");
|
||||
assert_eq!(result.level, SafetyLevel::Safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_safe_shortcut_works() {
|
||||
assert!(ContentSafetyService::is_safe("正常内容"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_content_returns_original_when_safe() {
|
||||
let filtered = ContentSafetyService::filter_content("正常内容");
|
||||
assert_eq!(filtered, "正常内容");
|
||||
}
|
||||
}
|
||||
@@ -5,3 +5,8 @@ pub mod sync_service;
|
||||
pub mod class_service;
|
||||
pub mod topic_service;
|
||||
pub mod comment_service;
|
||||
pub mod notification_service;
|
||||
pub mod sticker_service;
|
||||
pub mod achievement_service;
|
||||
pub mod mood_stats_service;
|
||||
pub mod content_safety_service;
|
||||
|
||||
171
crates/erp-diary/src/service/mood_stats_service.rs
Normal file
171
crates/erp-diary/src/service/mood_stats_service.rs
Normal file
@@ -0,0 +1,171 @@
|
||||
// 心情统计服务 — 心情趋势与连续天数
|
||||
|
||||
use chrono::{Duration, NaiveDate, Utc};
|
||||
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
|
||||
use serde::Deserialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{Mood, MoodCount, MoodStatsResp};
|
||||
use crate::entity::journal_entry;
|
||||
use crate::error::DiaryResult;
|
||||
|
||||
/// 统计查询范围
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub enum StatsPeriod {
|
||||
/// 最近 7 天
|
||||
Week,
|
||||
/// 最近 30 天
|
||||
Month,
|
||||
/// 最近 90 天
|
||||
Quarter,
|
||||
}
|
||||
|
||||
impl StatsPeriod {
|
||||
/// 转换为天数
|
||||
pub fn days(&self) -> i64 {
|
||||
match self {
|
||||
StatsPeriod::Week => 7,
|
||||
StatsPeriod::Month => 30,
|
||||
StatsPeriod::Quarter => 90,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 心情统计服务 — 聚合查询、趋势分析、连续天数
|
||||
pub struct MoodStatsService;
|
||||
|
||||
impl MoodStatsService {
|
||||
/// 获取心情统计
|
||||
///
|
||||
/// 统计指定时间范围内各心情出现次数、连续写日记天数、
|
||||
/// 最常用心情等数据。
|
||||
pub async fn get_mood_stats(
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
period: StatsPeriod,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<MoodStatsResp> {
|
||||
let since_date = (Utc::now() - Duration::days(period.days())).date_naive();
|
||||
|
||||
// 查询时间范围内的日记
|
||||
let journals = journal_entry::Entity::find()
|
||||
.filter(journal_entry::Column::TenantId.eq(tenant_id))
|
||||
.filter(journal_entry::Column::AuthorId.eq(user_id))
|
||||
.filter(journal_entry::Column::Date.gte(since_date))
|
||||
.filter(journal_entry::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let total_journals = journals.len() as i32;
|
||||
|
||||
// 计算各心情出现次数
|
||||
let mut mood_counts_map: std::collections::HashMap<String, i32> =
|
||||
std::collections::HashMap::new();
|
||||
for journal in &journals {
|
||||
*mood_counts_map
|
||||
.entry(journal.mood.clone())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mood_counts: Vec<MoodCount> = mood_counts_map
|
||||
.iter()
|
||||
.map(|(mood, &count)| {
|
||||
let percentage = if total_journals > 0 {
|
||||
(count as f64 / total_journals as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
MoodCount {
|
||||
mood: parse_mood(mood),
|
||||
count,
|
||||
percentage,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 查找最常用心情
|
||||
let dominant_mood = mood_counts
|
||||
.iter()
|
||||
.max_by_key(|mc| mc.count)
|
||||
.map(|mc| mc.mood.clone());
|
||||
|
||||
// 计算连续写日记天数
|
||||
let streak_days = Self::calculate_streak(tenant_id, user_id, db).await?;
|
||||
|
||||
Ok(MoodStatsResp {
|
||||
mood_counts,
|
||||
streak_days,
|
||||
total_journals,
|
||||
dominant_mood,
|
||||
})
|
||||
}
|
||||
|
||||
/// 计算连续写日记天数
|
||||
///
|
||||
/// 从今天开始往前数,连续有日记记录的天数。
|
||||
async fn calculate_streak(
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<i32> {
|
||||
let journals = journal_entry::Entity::find()
|
||||
.filter(journal_entry::Column::TenantId.eq(tenant_id))
|
||||
.filter(journal_entry::Column::AuthorId.eq(user_id))
|
||||
.filter(journal_entry::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
// 收集所有有日记的日期
|
||||
let mut dates: std::collections::HashSet<NaiveDate> =
|
||||
journals.into_iter().map(|j| j.date).collect();
|
||||
|
||||
let mut streak = 0i32;
|
||||
let mut check_date = Utc::now().date_naive();
|
||||
|
||||
// 从今天开始往前检查
|
||||
while dates.remove(&check_date) {
|
||||
streak += 1;
|
||||
check_date -= Duration::days(1);
|
||||
}
|
||||
|
||||
Ok(streak)
|
||||
}
|
||||
}
|
||||
|
||||
/// 从字符串解析心情枚举
|
||||
fn parse_mood(s: &str) -> Mood {
|
||||
match s {
|
||||
"happy" => Mood::Happy,
|
||||
"calm" => Mood::Calm,
|
||||
"sad" => Mood::Sad,
|
||||
"angry" => Mood::Angry,
|
||||
"thinking" => Mood::Thinking,
|
||||
_ => Mood::Happy,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_mood_known_values() {
|
||||
assert!(matches!(parse_mood("happy"), Mood::Happy));
|
||||
assert!(matches!(parse_mood("calm"), Mood::Calm));
|
||||
assert!(matches!(parse_mood("sad"), Mood::Sad));
|
||||
assert!(matches!(parse_mood("angry"), Mood::Angry));
|
||||
assert!(matches!(parse_mood("thinking"), Mood::Thinking));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_mood_unknown_defaults_happy() {
|
||||
assert!(matches!(parse_mood("unknown"), Mood::Happy));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stats_period_days() {
|
||||
assert_eq!(StatsPeriod::Week.days(), 7);
|
||||
assert_eq!(StatsPeriod::Month.days(), 30);
|
||||
assert_eq!(StatsPeriod::Quarter.days(), 90);
|
||||
}
|
||||
}
|
||||
123
crates/erp-diary/src/service/notification_service.rs
Normal file
123
crates/erp-diary/src/service/notification_service.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
// 通知服务 — 将日记事件转化为 SSE 推送通知
|
||||
//
|
||||
// 此服务监听日记模块的领域事件,通过 EventBus 发布通知事件,
|
||||
// SSE handler (erp-message) 负责将通知推送给在线用户。
|
||||
|
||||
use sea_orm::DatabaseConnection;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::events::{DomainEvent, EventBus};
|
||||
|
||||
use crate::dto::{NotificationPayload, NotificationType};
|
||||
|
||||
/// 通知服务 — 将日记领域事件转化为 SSE 推送
|
||||
pub struct NotificationService;
|
||||
|
||||
impl NotificationService {
|
||||
/// 评语创建通知
|
||||
///
|
||||
/// 当老师点评日记后,通知学生收到新评语。
|
||||
pub async fn notify_comment_created(
|
||||
tenant_id: Uuid,
|
||||
student_id: Uuid,
|
||||
teacher_id: Uuid,
|
||||
comment_id: Uuid,
|
||||
journal_id: Uuid,
|
||||
content_preview: String,
|
||||
db: &DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) {
|
||||
let payload = NotificationPayload {
|
||||
notification_type: NotificationType::CommentReceived,
|
||||
recipient_id: student_id,
|
||||
title: "收到新评语".to_string(),
|
||||
body: content_preview,
|
||||
business_id: Some(comment_id),
|
||||
extra: Some(serde_json::json!({
|
||||
"journal_id": journal_id,
|
||||
"teacher_id": teacher_id,
|
||||
})),
|
||||
};
|
||||
|
||||
Self::publish_notification(tenant_id, payload, db, event_bus).await;
|
||||
}
|
||||
|
||||
/// 主题布置通知
|
||||
///
|
||||
/// 当老师布置新主题后,通知班级所有学生。
|
||||
pub async fn notify_topic_assigned(
|
||||
tenant_id: Uuid,
|
||||
class_id: Uuid,
|
||||
topic_id: Uuid,
|
||||
title: String,
|
||||
db: &DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) {
|
||||
let payload = NotificationPayload {
|
||||
notification_type: NotificationType::TopicAssigned,
|
||||
recipient_id: Uuid::nil(), // 班级广播,SSE handler 按 class_id 过滤
|
||||
title: "新主题布置".to_string(),
|
||||
body: title,
|
||||
business_id: Some(topic_id),
|
||||
extra: Some(serde_json::json!({
|
||||
"class_id": class_id,
|
||||
})),
|
||||
};
|
||||
|
||||
Self::publish_notification(tenant_id, payload, db, event_bus).await;
|
||||
}
|
||||
|
||||
/// 成就解锁通知
|
||||
///
|
||||
/// 当用户解锁成就后,通知该用户。
|
||||
pub async fn notify_achievement_unlocked(
|
||||
tenant_id: Uuid,
|
||||
user_id: Uuid,
|
||||
achievement_code: String,
|
||||
achievement_name: String,
|
||||
db: &DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) {
|
||||
let payload = NotificationPayload {
|
||||
notification_type: NotificationType::AchievementUnlocked,
|
||||
recipient_id: user_id,
|
||||
title: "恭喜解锁新成就!".to_string(),
|
||||
body: format!("你解锁了「{}」成就", achievement_name),
|
||||
business_id: None,
|
||||
extra: Some(serde_json::json!({
|
||||
"achievement_code": achievement_code,
|
||||
})),
|
||||
};
|
||||
|
||||
Self::publish_notification(tenant_id, payload, db, event_bus).await;
|
||||
}
|
||||
|
||||
/// 发布通知事件到 EventBus
|
||||
///
|
||||
/// 使用 `diary.notification` 作为事件类型前缀,
|
||||
/// SSE handler 可据此识别并推送给在线用户。
|
||||
async fn publish_notification(
|
||||
tenant_id: Uuid,
|
||||
payload: NotificationPayload,
|
||||
db: &DatabaseConnection,
|
||||
event_bus: &EventBus,
|
||||
) {
|
||||
let event_type = match &payload.notification_type {
|
||||
NotificationType::CommentReceived => "diary.notification.comment",
|
||||
NotificationType::TopicAssigned => "diary.notification.topic",
|
||||
NotificationType::AchievementUnlocked => "diary.notification.achievement",
|
||||
NotificationType::ClassUpdate => "diary.notification.class_update",
|
||||
};
|
||||
|
||||
event_bus
|
||||
.publish(
|
||||
DomainEvent::new(
|
||||
event_type,
|
||||
tenant_id,
|
||||
serde_json::to_value(&payload).unwrap_or_default(),
|
||||
),
|
||||
db,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
154
crates/erp-diary/src/service/sticker_service.rs
Normal file
154
crates/erp-diary/src/service/sticker_service.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
// 贴纸服务 — 贴纸包与贴纸管理
|
||||
|
||||
use sea_orm::{
|
||||
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{StickerPackResp, StickerResp, TemplateResp};
|
||||
use crate::entity::{sticker, sticker_pack, template};
|
||||
use crate::error::{DiaryError, DiaryResult};
|
||||
|
||||
/// 贴纸服务 — 贴纸包浏览、贴纸查询、模板管理
|
||||
pub struct StickerService;
|
||||
|
||||
impl StickerService {
|
||||
/// 获取贴纸包列表
|
||||
///
|
||||
/// 返回所有可用的贴纸包,按分类和名称排序。
|
||||
pub async fn list_sticker_packs(
|
||||
tenant_id: Uuid,
|
||||
category: Option<String>,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<Vec<StickerPackResp>> {
|
||||
let mut query = sticker_pack::Entity::find()
|
||||
.filter(sticker_pack::Column::TenantId.eq(tenant_id))
|
||||
.filter(sticker_pack::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(ref cat) = category {
|
||||
query = query.filter(sticker_pack::Column::Category.eq(cat));
|
||||
}
|
||||
|
||||
let packs = query
|
||||
.order_by_asc(sticker_pack::Column::Category)
|
||||
.order_by_asc(sticker_pack::Column::Name)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
let mut result = Vec::with_capacity(packs.len());
|
||||
for pack in packs {
|
||||
let sticker_count = sticker::Entity::find()
|
||||
.filter(sticker::Column::PackId.eq(pack.id))
|
||||
.filter(sticker::Column::DeletedAt.is_null())
|
||||
.count(db)
|
||||
.await? as i32;
|
||||
|
||||
result.push(StickerPackResp {
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
description: pack.description,
|
||||
cover_image_url: pack.thumbnail_url,
|
||||
sticker_count,
|
||||
is_free: pack.is_free,
|
||||
category: pack.category,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// 获取贴纸包内的贴纸列表
|
||||
pub async fn list_stickers_in_pack(
|
||||
tenant_id: Uuid,
|
||||
pack_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<Vec<StickerResp>> {
|
||||
// 验证贴纸包存在
|
||||
let _pack = sticker_pack::Entity::find()
|
||||
.filter(sticker_pack::Column::Id.eq(pack_id))
|
||||
.filter(sticker_pack::Column::TenantId.eq(tenant_id))
|
||||
.filter(sticker_pack::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id)))?;
|
||||
|
||||
let stickers = sticker::Entity::find()
|
||||
.filter(sticker::Column::PackId.eq(pack_id))
|
||||
.filter(sticker::Column::TenantId.eq(tenant_id))
|
||||
.filter(sticker::Column::DeletedAt.is_null())
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(stickers
|
||||
.into_iter()
|
||||
.map(|s| StickerResp {
|
||||
id: s.id,
|
||||
pack_id: s.pack_id,
|
||||
name: s.name,
|
||||
image_url: s.image_url,
|
||||
category: s.category,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 获取模板列表
|
||||
///
|
||||
/// 返回所有可用模板,包括官方模板和用户创建的模板。
|
||||
pub async fn list_templates(
|
||||
tenant_id: Uuid,
|
||||
category: Option<String>,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<Vec<TemplateResp>> {
|
||||
let mut query = template::Entity::find()
|
||||
.filter(template::Column::TenantId.eq(tenant_id))
|
||||
.filter(template::Column::DeletedAt.is_null());
|
||||
|
||||
if let Some(ref cat) = category {
|
||||
query = query.filter(template::Column::Category.eq(cat));
|
||||
}
|
||||
|
||||
let templates = query
|
||||
.order_by_desc(template::Column::IsOfficial)
|
||||
.order_by_asc(template::Column::Name)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(templates
|
||||
.into_iter()
|
||||
.map(|t| TemplateResp {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: None, // template entity 无 description 字段
|
||||
preview_url: t.thumbnail_url,
|
||||
template_data: t.layout_data,
|
||||
category: t.category,
|
||||
is_free: true, // Phase 1 所有模板免费
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 获取模板详情
|
||||
pub async fn get_template(
|
||||
tenant_id: Uuid,
|
||||
template_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<TemplateResp> {
|
||||
let model = template::Entity::find()
|
||||
.filter(template::Column::Id.eq(template_id))
|
||||
.filter(template::Column::TenantId.eq(tenant_id))
|
||||
.filter(template::Column::DeletedAt.is_null())
|
||||
.one(db)
|
||||
.await?
|
||||
.ok_or_else(|| DiaryError::NotFound(format!("模板 {} 不存在", template_id)))?;
|
||||
|
||||
Ok(TemplateResp {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: None,
|
||||
preview_url: model.thumbnail_url,
|
||||
template_data: model.layout_data,
|
||||
category: model.category,
|
||||
is_free: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use uuid::Uuid;
|
||||
use crate::dto::{CreateTopicReq, TopicResp};
|
||||
use crate::entity::topic_assignment;
|
||||
use crate::error::{DiaryError, DiaryResult};
|
||||
use crate::service::notification_service::NotificationService;
|
||||
use erp_core::events::{DomainEvent, EventBus};
|
||||
|
||||
/// 主题布置服务 — 老师发布日记主题,学生提交对应日记
|
||||
@@ -79,6 +80,17 @@ impl TopicService {
|
||||
)
|
||||
.await;
|
||||
|
||||
// 发送 SSE 通知给班级学生
|
||||
NotificationService::notify_topic_assigned(
|
||||
tenant_id,
|
||||
class_id,
|
||||
id,
|
||||
req.title.clone(),
|
||||
db,
|
||||
event_bus,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(topic_model_to_resp(inserted))
|
||||
}
|
||||
|
||||
|
||||
@@ -112,6 +112,41 @@ pub async fn message_stream(
|
||||
.id(event.id.to_string())
|
||||
.data(data));
|
||||
}
|
||||
// 暖记通知事件 — 推送给目标用户
|
||||
"diary.notification.comment"
|
||||
| "diary.notification.achievement"
|
||||
| "diary.notification.class_update" => {
|
||||
let is_recipient = event.payload.get("recipient_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s == user_id.to_string())
|
||||
.unwrap_or(false);
|
||||
if !is_recipient {
|
||||
continue;
|
||||
}
|
||||
let sse_event_name = match event.event_type.as_str() {
|
||||
"diary.notification.comment" => "comment",
|
||||
"diary.notification.achievement" => "achievement",
|
||||
"diary.notification.class_update" => "class_update",
|
||||
_ => "diary",
|
||||
};
|
||||
let data = serde_json::to_string(&event.payload)
|
||||
.unwrap_or_default();
|
||||
yield Ok(Event::default()
|
||||
.event(sse_event_name)
|
||||
.id(event.id.to_string())
|
||||
.data(data));
|
||||
}
|
||||
// 暖记主题布置 — 班级广播
|
||||
"diary.notification.topic" => {
|
||||
// 主题布置是班级广播,所有在线用户都会收到
|
||||
// 前端根据 class_id 过滤
|
||||
let data = serde_json::to_string(&event.payload)
|
||||
.unwrap_or_default();
|
||||
yield Ok(Event::default()
|
||||
.event("topic")
|
||||
.id(event.id.to_string())
|
||||
.data(data));
|
||||
}
|
||||
"alert.triggered" => {
|
||||
let patient_id = event.payload.get("patient_id")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
Reference in New Issue
Block a user