Compare commits

..

16 Commits

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

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

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

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

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

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

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

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

GET /api/v1/diary/discover + utoipa 文档 + diary.journal.read 权限守卫
2026-06-07 10:43:02 +08:00
68 changed files with 2951 additions and 495 deletions

View File

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

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

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,12 @@ class ApiClient {
/// 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖。
Future<String?> Function()? onRefreshToken;
/// 认证彻底失败回调 — 刷新 token 失败后由 app.dart 注册
///
/// 通知 AuthBloc 派发 AuthExpired 事件,触发路由重定向到登录页。
/// 解决审计 9a-AUTH-01刷新失败时用户不会被留在死页面。
void Function()? onAuthFailed;
/// 是否正在刷新 token防止并发 401 触发多次刷新)
bool _isRefreshing = false;
@@ -95,8 +101,9 @@ class ApiClient {
_isRefreshing = false;
}
// 刷新失败或无刷新回调 → 清除 token
// 刷新失败或无刷新回调 → 清除 token,通知全局认证失效
_token = null;
onAuthFailed?.call();
}
handler.next(error);
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -99,6 +99,16 @@ class ElementSelected extends EditorEvent {
ElementSelected(this.elementId);
}
/// 图层顺序调整方向
enum LayerChange { bringToFront, sendToBack }
/// 调整元素图层顺序
class ElementLayerChanged extends EditorEvent {
final String elementId;
final LayerChange change;
ElementLayerChanged({required this.elementId, required this.change});
}
// --- 工具栏事件 ---
/// 切换活动工具
@@ -335,6 +345,7 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
on<ElementResized>(_onElementResized);
on<ElementRotated>(_onElementRotated);
on<ElementSelected>(_onElementSelected);
on<ElementLayerChanged>(_onElementLayerChanged);
on<ElementsLoaded>(_onElementsLoaded);
// 日记加载事件
@@ -492,6 +503,36 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
));
}
/// 调整元素图层顺序 — 置顶或置底
void _onElementLayerChanged(
ElementLayerChanged event,
Emitter<EditorState> emit,
) {
final elements = List<JournalElement>.from(state.elements);
final index = elements.indexWhere((e) => e.id == event.elementId);
if (index == -1) return;
switch (event.change) {
case LayerChange.bringToFront:
// 设为最大 zIndex + 1
final maxZ = elements.fold<int>(
0,
(max, e) => e.zIndex > max ? e.zIndex : max,
);
elements[index] = elements[index].copyWith(zIndex: maxZ + 1);
case LayerChange.sendToBack:
// 设为最小 zIndex - 1
final minZ = elements.fold<int>(
0,
(min, e) => e.zIndex < min ? e.zIndex : min,
);
elements[index] = elements[index].copyWith(zIndex: minZ - 1);
}
emit(state.copyWith(elements: elements, isDirty: true));
_scheduleAutoSave();
}
void _onElementsLoaded(ElementsLoaded event, Emitter<EditorState> emit) {
emit(state.copyWith(elements: event.elements));
}

View File

@@ -334,15 +334,29 @@ class _EditorView extends StatefulWidget {
}
class _EditorViewState extends State<_EditorView> {
/// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式
bool _isViewMode = false;
/// 保存中状态 — 用于显示"保存中..."指示器
bool _isSaving = false;
@override
void initState() {
super.initState();
// 当 journalId 非空时,从 Isar 加载已有日记数据
// 当 journalId 非空时,进入查看模式
_isViewMode = widget.journalId != null;
if (widget.journalId != null) {
_loadExistingJournal(widget.journalId!);
}
}
/// 从查看模式切换到编辑模式
void _enterEditMode() {
setState(() => _isViewMode = false);
// 切换到画笔工具,进入编辑状态
context.read<EditorBloc>().add(ToolChanged(EditorTool.brush));
}
/// 从 Isar 加载已有日记 — 使用 LoadJournal 原子事件一次性还原
Future<void> _loadExistingJournal(String id) async {
try {
@@ -381,6 +395,11 @@ class _EditorViewState extends State<_EditorView> {
elements: otherElements,
lastSavedAt: entry.updatedAt,
));
// 查看模式下使用 select 工具,避免自动弹出画笔面板
if (_isViewMode) {
context.read<EditorBloc>().add(ToolChanged(EditorTool.select));
}
} catch (e) {
debugPrint('加载日记数据失败: $e');
}
@@ -405,26 +424,31 @@ class _EditorViewState extends State<_EditorView> {
Expanded(
child: BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return _EditorStack(state: state, journalId: widget.journalId);
return _EditorStack(
state: state,
journalId: widget.journalId,
isViewMode: _isViewMode,
);
},
),
),
// 底部工具栏(自带底部安全区)
BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return EditorToolbar(
state: state,
onEvent: (event) => context.read<EditorBloc>().add(event),
);
},
),
// 底部工具栏 — 仅编辑模式显示
if (!_isViewMode)
BlocBuilder<EditorBloc, EditorState>(
builder: (context, state) {
return EditorToolbar(
state: state,
onEvent: (event) => context.read<EditorBloc>().add(event),
);
},
),
],
),
);
}
/// 顶部操作栏 — 日期/撤销重做/标签/心情/完成
/// 顶部操作栏 — 查看模式: 返回/日期/评语/编辑按钮;编辑模式: 返回/日期/撤销重做/标签/完成
Widget _buildTopBar(BuildContext context, EditorState state) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
@@ -467,58 +491,83 @@ class _EditorViewState extends State<_EditorView> {
),
),
),
// 撤销
IconButton(
icon: const Icon(Icons.undo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Undo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 重做
IconButton(
icon: const Icon(Icons.redo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Redo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 自动保存状态
_buildAutosaveIndicator(state),
// 标签按钮
IconButton(
icon: const Icon(Icons.sell_rounded, size: 18),
onPressed: () => _showTagPanel(context, state),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 评语按钮(仅已有日记显示)
if (widget.journalId != null)
if (_isViewMode) ...[
// 查看模式:评语按钮 + 编辑按钮
if (widget.journalId != null)
IconButton(
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
onPressed: () => _showComments(context),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
Padding(
padding: const EdgeInsets.only(left: 4),
child: FilledButton.tonal(
onPressed: _enterEditMode,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
minimumSize: const Size(0, 32),
),
child: const Text('编辑', style: TextStyle(fontSize: 14)),
),
),
] else ...[
// 编辑模式:撤销/重做/标签/评语/完成
IconButton(
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
onPressed: () => _showComments(context),
icon: const Icon(Icons.undo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Undo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
// 完成/保存按钮
Padding(
padding: const EdgeInsets.only(left: 4),
child: FilledButton.tonal(
onPressed: () => _handleSave(context, state),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
minimumSize: const Size(0, 32),
),
child: const Text('完成', style: TextStyle(fontSize: 14)),
IconButton(
icon: const Icon(Icons.redo_rounded, size: 18),
onPressed: () => context.read<EditorBloc>().add(Redo()),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
),
_buildAutosaveIndicator(state),
IconButton(
icon: const Icon(Icons.sell_rounded, size: 18),
onPressed: () => _showTagPanel(context, state),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
if (widget.journalId != null)
IconButton(
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
onPressed: () => _showComments(context),
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
Padding(
padding: const EdgeInsets.only(left: 4),
child: FilledButton.tonal(
onPressed: () => _handleSave(context, state),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 16),
minimumSize: const Size(0, 32),
),
child: const Text('完成', style: TextStyle(fontSize: 14)),
),
),
],
],
),
),
),
// 日期 + 心情条 (40px)
_buildDateMoodStrip(context, state),
// 日期 + 心情条 (40px) — 仅编辑模式显示
if (!_isViewMode) _buildDateMoodStrip(context, state),
],
),
);
}
/// 返回处理
/// 返回处理 — 有未保存修改时弹出确认
void _handleBack(BuildContext context) {
final bloc = context.read<EditorBloc>();
if (bloc.state.isDirty) {
_showDiscardDialog(context);
} else {
_doNavigateBack(context);
}
}
void _doNavigateBack(BuildContext context) {
if (context.canPop()) {
context.pop();
} else {
@@ -526,9 +575,39 @@ class _EditorViewState extends State<_EditorView> {
}
}
/// 保存处理
void _showDiscardDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('放弃编辑?'),
content: const Text('你有未保存的修改,确定要离开吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('继续编辑'),
),
TextButton(
onPressed: () {
Navigator.pop(ctx);
_doNavigateBack(context);
},
child: const Text('放弃'),
),
],
),
);
}
/// 保存处理 — 显示"保存中..."后触发保存
void _handleSave(BuildContext context, EditorState state) {
widget.onSaveComplete();
setState(() => _isSaving = true);
// 短暂延迟让 UI 显示"保存中..."状态
Future.delayed(const Duration(milliseconds: 300), () {
if (mounted) {
setState(() => _isSaving = false);
widget.onSaveComplete();
}
});
}
/// 显示评论列表
@@ -555,7 +634,26 @@ class _EditorViewState extends State<_EditorView> {
}
/// 自动保存状态指示器
/// 保存指示器 — 三态: 未保存 / 保存中 / 已保存
Widget _buildAutosaveIndicator(EditorState state) {
// 保存中 — 琥珀色脉冲点
if (_isSaving) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_PulsingDot(color: AppColors.tertiary),
const SizedBox(width: 4),
Text(
'保存中...',
style: TextStyle(fontSize: 11, color: Colors.amber[700]),
),
],
),
);
}
// 未保存
if (state.lastSavedAt == null) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
@@ -565,6 +663,7 @@ class _EditorViewState extends State<_EditorView> {
),
);
}
// 已保存 — 绿色点
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
@@ -674,8 +773,13 @@ class _EditorViewState extends State<_EditorView> {
class _EditorStack extends StatefulWidget {
final EditorState state;
final String? journalId;
final bool isViewMode;
const _EditorStack({required this.state, this.journalId});
const _EditorStack({
required this.state,
this.journalId,
this.isViewMode = false,
});
@override
State<_EditorStack> createState() => _EditorStackState();
@@ -900,6 +1004,7 @@ class _EditorStackState extends State<_EditorStack> {
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: TextField(
controller: _titleController,
enabled: !widget.isViewMode,
style: TextStyle(
fontFamily: 'Quicksand',
fontSize: 18,
@@ -943,8 +1048,8 @@ class _EditorStackState extends State<_EditorStack> {
if (state.elements.isNotEmpty)
_buildElementLayer(context, state),
// 文字输入覆盖层(文字工具激活时显示)
if (state.activeTool == EditorTool.text)
// 文字输入覆盖层(文字工具激活时显示)— 仅编辑模式
if (!widget.isViewMode && state.activeTool == EditorTool.text)
TextInputOverlay(
onConfirmed: (text, fontSize, fontColor) {
final center = Offset(
@@ -967,8 +1072,8 @@ class _EditorStackState extends State<_EditorStack> {
},
),
// 图片选择覆盖层(图片工具激活时显示)
if (state.activeTool == EditorTool.photo)
// 图片选择覆盖层(图片工具激活时显示)— 仅编辑模式
if (!widget.isViewMode && state.activeTool == EditorTool.photo)
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -988,8 +1093,11 @@ class _EditorStackState extends State<_EditorStack> {
),
),
// 空状态提示
if (state.strokes.isEmpty && state.elements.isEmpty && state.activeTool == EditorTool.select)
// 空状态提示 — 仅编辑模式显示
if (!widget.isViewMode &&
state.strokes.isEmpty &&
state.elements.isEmpty &&
state.activeTool == EditorTool.select)
_buildEmptyHint(context),
],
);
@@ -1057,6 +1165,11 @@ class _EditorStackState extends State<_EditorStack> {
onDeleted: (id) {
context.read<EditorBloc>().add(ElementRemoved(id));
},
onLayerChanged: (id, change) {
context.read<EditorBloc>().add(
ElementLayerChanged(elementId: id, change: change),
);
},
);
}).toList(),
);
@@ -1128,3 +1241,53 @@ class _ImageSourceButton extends StatelessWidget {
);
}
}
/// 脉冲圆点动画 — 用于"保存中..."指示器
class _PulsingDot extends StatefulWidget {
const _PulsingDot({required this.color});
final Color color;
@override
State<_PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<_PulsingDot>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final scale = 0.6 + 0.4 * _controller.value;
return Transform.scale(
scale: scale,
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: widget.color,
shape: BoxShape.circle,
),
),
);
},
);
}
}

View File

@@ -12,6 +12,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import '../../../data/models/journal_element.dart';
import '../bloc/editor_bloc.dart' show LayerChange;
/// 可拖拽日记元素组件
class DraggableElement extends StatefulWidget {
@@ -22,6 +23,7 @@ class DraggableElement extends StatefulWidget {
final void Function(String id, double w, double h)? onResized;
final void Function(String id, double rotation)? onRotated;
final ValueChanged<String> onDeleted;
final void Function(String id, LayerChange change)? onLayerChanged;
const DraggableElement({
super.key,
@@ -32,6 +34,7 @@ class DraggableElement extends StatefulWidget {
this.onResized,
this.onRotated,
required this.onDeleted,
this.onLayerChanged,
});
@override
@@ -142,26 +145,41 @@ class _DraggableElementState extends State<DraggableElement> {
),
),
// 选中时显示删除按钮
// 选中时显示操作按钮:图层 + 删除
if (widget.isSelected)
Positioned(
top: -12,
right: -12,
child: GestureDetector(
onTap: () => widget.onDeleted(widget.element.id),
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// 置顶
_ActionButton(
icon: Icons.flip_to_front_rounded,
color: Theme.of(context).colorScheme.primary,
onTap: () => widget.onLayerChanged?.call(
widget.element.id,
LayerChange.bringToFront,
),
),
const SizedBox(width: 4),
// 置底
_ActionButton(
icon: Icons.flip_to_back_rounded,
color: Theme.of(context).colorScheme.primary,
onTap: () => widget.onLayerChanged?.call(
widget.element.id,
LayerChange.sendToBack,
),
),
const SizedBox(width: 4),
// 删除
_ActionButton(
icon: Icons.close_rounded,
color: Theme.of(context).colorScheme.error,
shape: BoxShape.circle,
onTap: () => widget.onDeleted(widget.element.id),
),
child: const Icon(
Icons.close_rounded,
size: 16,
color: Colors.white,
),
),
],
),
),
],
@@ -279,3 +297,32 @@ class _DraggableElementState extends State<DraggableElement> {
);
}
}
/// 选中元素的操作按钮(图层/删除)
class _ActionButton extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
const _ActionButton({
required this.icon,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(icon, size: 16, color: Colors.white),
),
);
}
}

View File

@@ -71,7 +71,7 @@ class EditorToolbar extends StatelessWidget {
onTap: () => onEvent(isActive ? ToolReactivated(tool) : ToolChanged(tool)),
behavior: HitTestBehavior.opaque,
child: Container(
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column(
mainAxisSize: MainAxisSize.min,

View File

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

View File

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

View File

@@ -91,7 +91,10 @@ class _HomeView extends StatelessWidget {
children: [
_GreetingHeader(
greeting: greeting,
username: '小暖',
username: context.select<AuthBloc, String>((bloc) {
final s = bloc.state;
return s is Authenticated ? s.user.displayLabel : '同学';
}),
dateText: dateText,
onSearchTap: () => context.push('/search'),
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,6 +85,7 @@ where
S: Clone + Send + Sync + 'static,
{
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_code().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.create")?;
if req.class_code.trim().is_empty() {

View File

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

View File

@@ -61,6 +61,7 @@ where
S: Clone + Send + Sync + 'static,
{
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_tags().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.create")?;
// 基础验证
@@ -149,6 +150,7 @@ where
S: Clone + Send + Sync + 'static,
{
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
req.validate_tags().map_err(AppError::Validation)?;
require_permission(&ctx, "diary.journal.update")?;
let resp = JournalService::update(
@@ -165,9 +167,10 @@ where
}
/// 删除日记请求体(包含版本号)
#[derive(Debug, Deserialize, utoipa::ToSchema)]
#[derive(Debug, Deserialize, Validate, utoipa::ToSchema)]
pub struct DeleteJournalReq {
/// 当前版本号(乐观锁)
#[validate(range(min = 0, message = "版本号不能为负数"))]
pub version: i32,
}
@@ -202,6 +205,8 @@ where
{
require_permission(&ctx, "diary.journal.delete")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
JournalService::delete(
ctx.tenant_id,
ctx.user_id,

View File

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

View File

@@ -94,6 +94,8 @@ where
{
require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
let binding = ParentService::bind_child(
ctx.tenant_id,
ctx.user_id,
@@ -259,6 +261,8 @@ where
{
require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
let count = ParentService::delete_child_data(
ctx.tenant_id,
ctx.user_id,
@@ -301,6 +305,8 @@ where
{
require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
ParentService::unbind_child(ctx.tenant_id, ctx.user_id, req.child_id, &state.db).await?;
Ok(Json(ApiResponse {

View File

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

View File

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

View File

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

View File

@@ -11,3 +11,4 @@ pub mod achievement_service;
pub mod mood_stats_service;
pub mod content_safety_service;
pub mod parent_service;
pub mod discover_service;

View File

@@ -208,6 +208,7 @@ struct MessageApiDoc;
erp_diary::handler::parent_handler::list_pending_bindings,
erp_diary::handler::parent_handler::confirm_binding,
erp_diary::handler::parent_handler::reject_binding,
erp_diary::handler::discover_handler::get_discover,
),
components(schemas(
erp_diary::dto::CreateJournalReq,
@@ -241,6 +242,10 @@ struct MessageApiDoc;
erp_diary::handler::parent_handler::DeleteChildDataReq,
erp_diary::handler::parent_handler::BindingResp,
erp_diary::handler::parent_handler::DeleteResultResp,
erp_diary::dto::DiscoverResp,
erp_diary::dto::InspirationItem,
erp_diary::dto::TagCount,
erp_diary::dto::ExpertDiaryItem,
))
)]
struct DiaryApiDoc;

306
plans/next-steps-roadmap.md Normal file
View File

@@ -0,0 +1,306 @@
# 暖记下一步工作路线图
> **创建日期**: 2026-06-07
> **状态**: 讨论稿,待确认优先级后逐一推进
> **当前代码状态**: 工作区 26 个文件 +1051/-319 行未提交
---
## 当前快照
| 指标 | 数值 |
|------|------|
| 后端测试 | 518 通过 |
| 前端测试 | 116 新增 (Phase 4) |
| 审计发现 | 345 项 (42 C / 90 H / 123 M / 90 L) |
| 未提交改动 | Discover 全栈 + 编辑器增强 + 多页动态化 |
| 未提交新文件 | discover_bloc / discover_models / discover_handler / discover_service |
---
## 方向 A — 当前改动收尾提交
> **目标**: 把工作区 1000+ 行改动拆分成有意义的提交,保持 git 历史清晰
> **预估**: 2-3h拆分 + 验证 + 提交)
> **优先级**: ⭐⭐⭐⭐⭐ — 做任何事之前先把当前工作落地
### A.1 提交拆分建议
| # | 提交内容 | 涉及文件 | scope |
|---|---------|---------|-------|
| 1 | Discover 后端 (DTO + Service + Handler + 路由注册) | crates/erp-diary 4 文件 + main.rs | `feat(diary)` |
| 2 | Discover 前端 (BLoC + Models + Page 动态化) | app/features/discover/ 4 文件 | `feat(app)` |
| 3 | 编辑器: 查看模式 + 图层排序 | editor_page + editor_bloc + draggable_element | `feat(app)` |
| 4 | 编辑器: 标签面板动态化 + 贴纸选择器重构 | tag_panel + sticker_picker_sheet | `feat(app)` |
| 5 | 搜索页动态化 (热搜词 + 模板搜索) | search_page | `feat(app)` |
| 6 | 个人资料页动态化 (成就徽章 + 头像首字母) | profile_page | `feat(app)` |
| 7 | 教师页班级码对话框 | teacher_page | `feat(app)` |
| 8 | 贴纸库动态分类 | sticker_library_page | `feat(app)` |
| 9 | Wiki 文档更新 | wiki/ 4 文件 | `docs` |
### A.2 验证清单
- [ ] `cargo check` 后端编译通过
- [ ] `cargo test` 后端测试通过
- [ ] `flutter analyze` 前端分析通过
- [ ] 每个提交可独立编译
### A.3 讨论点
- Discover 功能是否属于 Phase 1 范围?审计报告 7b-H06 指出它属于 Phase 2
- 编辑器查看模式是新增功能还是修复?影响提交类型选择 (feat vs fix)
---
## 方向 B — 审计修复路线图推进
> **目标**: 按审计报告 345 项发现的修复路线图推进
> **预估**: Phase 0 已完成 ~80%Phase 1-3 待推进
> **优先级**: ⭐⭐⭐⭐ — 安全和稳定性是上线的前提
### B.1 Phase 0 紧急修复 — 已完成项 ✅
| # | 修复项 | 状态 |
|---|--------|------|
| 1 | RLS 变量名 bug 修复 | ✅ 已修复 |
| 2 | 内容安全词库填充 + 分享前检查 | ✅ 已修复 |
| 3 | 日记列表 IDOR 修复 | 需确认 |
| 4 | 审计日志 + 文件上传权限守卫 | 需确认 |
| 5 | class_service unwrap() 安全处理 | 需确认 |
| 6 | 事务添加 (create_class/join_class) | 需确认 |
| 7 | 笔画缓存 use-after-dispose 修复 | ✅ 已修复 |
### B.2 Phase 1 安全加固 (~31h)
| # | 任务 | 预估 | 讨论点 |
|---|------|------|--------|
| 1 | 家长同意验证流程 | 8h | PIPL 合规法律要求,但 Phase 1 MVP 是否先跳过? |
| 2 | 家长绑定验证流程 | 4h | 同上 |
| 3 | Token 黑名单改用 SHA-256 | 1h | 简单,可顺手做 |
| 4 | **全部 DTO 添加 Validate + handler 调用** | 8h | 影响面最大,系统性修复 |
| 5 | Flutter 强制 HTTPS + 证书固定 | 4h | 开发阶段 HTTP 方便调试,生产再强制? |
| 6 | 班级码改用字母数字混合 | 2h | 安全强度提升 3386 倍 |
| 7 | Flutter Token 自动刷新 | 4h | 体验提升明显 |
### B.3 Phase 2 性能优化 (~24h)
| # | 任务 | 预估 |
|---|------|------|
| 1 | 后端 4 个 N+1 查询修复 | 8h |
| 2 | Isar 索引 + 分页 + N+1 修复 | 6h |
| 3 | mood_stats 改用 SQL GROUP BY | 2h |
| 4 | 笔画光栅化改为 BBox 裁剪 (50 条笔画 1.6GB → 合理范围) | 4h |
| 5 | SyncEngine 合并同资源操作 | 4h |
### B.4 Phase 3 质量提升 (持续)
| # | 任务 |
|---|------|
| 1 | 补充后端集成测试 + Flutter BLoC 测试 |
| 2 | 统一同步协议 (Flutter ↔ Rust) |
| 3 | 创建端点改返回 201 |
| 4 | 添加 DiaryApiDoc OpenAPI 文档 |
| 5 | 无障碍性 Semantics |
| 6 | DiaryEvent 枚举接入 EventBus |
### B.5 讨论点
- Phase 1 的家长验证流程比较重MVP 阶段是否可以用简化版(注册时勾选确认代替完整验证链)?
- 性能优化在数据量小的时候感知不明显,是否延后到真实用户测试时再做?
- 同步协议断裂是最严重的架构问题,但也是改动量最大的——何时投入?
---
## 方向 C — 端到端联调
> **目标**: 确保 Flutter → Rust API 全链路数据流通
> **预估**: 8-16h
> **优先级**: ⭐⭐⭐⭐ — 不联调就不知道能不能跑
### C.1 核心联调路径
| # | 链路 | 前端模块 | 后端 API | 状态 |
|---|------|---------|---------|------|
| 1 | **注册 → 登录 → Token 获取** | AuthBloc | POST /auth/* | ✅ 通过 |
| 2 | **创建日记 → 保存到后端** | EditorBloc → JournalRepository | POST /diary/journals | ✅ 通过 |
| 3 | **日记列表加载** | HomeBloc | GET /diary/journals | ✅ 通过 |
| 4 | **班级创建 → 加入 → 查看成员** | ClassBloc | POST/GET /diary/classes/* | ✅ 通过 |
| 5 | **贴纸浏览 → 收藏** | StickerBloc | GET /diary/stickers/* | ✅ 通过 (空) |
| 6 | **发现页数据加载** | DiscoverBloc | GET /diary/discover | ✅ 通过 |
| 7 | **搜索日记/模板** | SearchBloc | GET /diary/journals?search= | ✅ 通过 |
| 8 | **心情统计** | CalendarBloc | GET /diary/stats/mood | ✅ 通过 |
| 9 | **同步 (离线→在线)** | SyncEngine | POST /diary/sync | ❌ 协议断裂 |
### C.1b DTO 验证生效确认
| 测试 | 结果 |
|------|------|
| 负版本号 `version: -1` | ✅ 拒绝: "版本号不能为负数" |
| 标签过长 (50+ chars) | ✅ 拒绝: "超过 30 字符" |
| 班级码非字母数字 `!!!!!!` | ✅ 拒绝: "仅允许字母和数字" |
| 贴纸包负价格 `price: -100` | ✅ 拒绝: "价格不能为负数" |
| 评语创建 (admin 角色) | ✅ 正确返回 403 (仅 teacher) |
### C.2 联调方式讨论
**方案 A — 手动联调(快速验证)**
- 启动后端 + Flutter Web
- 手动走一遍核心流程
- 用 Chrome DevTools 看 Network 请求
- 优点:快速、直观
- 缺点:不可复现、覆盖不全
**方案 B — 自动化集成测试(推荐)**
- 后端:已有 9 个集成测试,扩展到覆盖所有 API
- 前端:用 integration_test 包写 Flutter 集成测试
- 优点:可复现、持续验证
- 缺点:前期投入大
**方案 C — Postman/HTTP 请求集**
- 先用 Postman 验证所有后端 API
- 再手动跑 Flutter 前端
- 折中方案
### C.3 讨论点
- 同步功能 (链路 9) 已知断裂,联调时是否先跳过?
- 先做后端 API 验证还是直接跑 Flutter
- 是否需要准备测试数据种子?
---
## 方向 D — 产品体验打磨
> **目标**: 从小学生用户视角出发,打磨交互体验
> **预估**: 持续迭代
> **优先级**: ⭐⭐⭐ — 功能能跑之后的首要任务
### D.1 体验问题清单
| # | 问题 | 严重性 | 讨论点 |
|---|------|--------|--------|
| 1 | **首次使用引导** — 新用户打开 app 不知道干什么 | HIGH | 需要设计引导流程 |
| 2 | **空状态设计** — 列表为空时显示什么? | MEDIUM | 需要插画/文案 |
| 3 | **加载状态** — 骨架屏 vs 转圈 vs 进度条? | MEDIUM | 统一 Loading 策略 |
| 4 | **错误提示** — 网络错误/服务器错误/离线 如何提示? | HIGH | 儿童友好的错误文案 |
| 5 | **手写反馈** — 写字时有声音/动画反馈吗? | LOW | 增强沉浸感 |
| 6 | **心情追踪可视化** — fl_chart 集成够好看吗? | MEDIUM | 需要实际渲染验证 |
| 7 | **成就/徽章系统** — 有雏形但完整吗? | MEDIUM | 当前只有数据模型 |
| 8 | **贴纸交互** — 拖拽/缩放/旋转手感如何? | HIGH | 核心体验 |
| 9 | **日历视图** — 月历和日记的关联够直观吗? | MEDIUM | |
| 10 | **班级动态** — 老师点评 → 学生能看到吗? | HIGH | 核心社交功能 |
### D.2 可能的新功能想法
| # | 想法 | 复杂度 | 讨论点 |
|---|------|--------|--------|
| 1 | **每日写作提示** — "今天你吃了什么好吃的?" | 低 | 已有 Discover daily_inspiration可扩展 |
| 2 | **天气/心情自动检测** — 根据文字内容建议心情标签 | 中 | 需要 NLP 或关键词匹配 |
| 3 | **日记模板市场** — 下载别人分享的日记模板 | 高 | Phase 2 功能? |
| 4 | **语音输入** — 口述转文字辅助写作 | 高 | 技术选型待定 |
| 5 | **涂鸦动画回放** — 把绘画过程做成短视频 | 中 | 技术上可行 (已有时间戳数据) |
| 6 | **多页日记** — 一篇日记可以有很多页 | 中 | 编辑器需要支持翻页 |
| 7 | **日记封面** — 自动/手动生成好看的封面 | 低 | 取日记第一张图或涂鸦 |
| 8 | **分享到班级圈** — 类似朋友圈的班级动态流 | 高 | Phase 2 功能? |
### D.3 讨论点
- 哪些体验问题必须在 MVP 之前解决?
- 新功能想法中哪些值得现在就开始探索?
- 是否需要做一轮真实的用户测试(找几个小学生试用)?
---
## 方向 E — 上线准备 / DevOps
> **目标**: 从开发环境走向可发布的 App
> **预估**: 20-40h (取决于目标平台)
> **优先级**: ⭐⭐ — 功能稳定后再做
### E.1 短期目标:可内测的 APK
| # | 任务 | 预估 | 状态 |
|---|------|------|------|
| 1 | Flutter Android 构建 + 签名 | 4h | 未开始 |
| 2 | 生产环境配置 (HTTPS, 密钥管理) | 4h | 未开始 |
| 3 | 服务器部署 (Docker Compose 生产配置) | 4h | 未开始 |
| 4 | 环境变量管理 (.env → 生产密钥注入) | 2h | 未开始 |
### E.2 中期目标:应用商店
| # | 任务 | 预估 |
|---|------|------|
| 1 | App Store / Google Play 开发者账号 | 1h |
| 2 | 应用图标 + 启动页设计 | 4h |
| 3 | 隐私政策 + 用户协议 (PIPL 合规) | 4h |
| 4 | 应用截图 + 描述文案 | 2h |
| 5 | 内部测试轨道分发 | 2h |
| 6 | iOS 打包准备 (Mac + Xcode + 证书) | 8h |
### E.3 CI/CD 流水线
| # | 任务 | 预估 |
|---|------|------|
| 1 | GitHub Actions / Gitea CI 配置 | 4h |
| 2 | 后端: cargo test + clippy + Docker build | 2h |
| 3 | 前端: flutter analyze + test + build | 2h |
| 4 | 管理端: pnpm lint + build | 1h |
| 5 | 自动部署到测试服务器 | 4h |
### E.4 讨论点
- 先做 Android APK开发者模式安装给内部测试
- 服务器是用现有机器还是云服务器?
- CI/CD 用 Gitea 内置的还是 GitHub Actions
- iOS 是否需要购买 Mac 设备?
---
## 方向间的依赖关系
```
A (提交收尾)
├─→ B (审计修复) ─→ E (上线准备)
│ │
├─→ C (端到端联调) │
│ │ │
│ └─→ D (体验打磨)
└─────────────────────────────→ 时间线
```
**关键路径**: A → C → D → E先提交 → 再联调 → 再打磨 → 再上线)
**安全路径**: A → B → E先提交 → 再修 bug → 再上线)
**混合路径**: A → B+C 并行 → D → E推荐
---
## 建议的推进顺序
1. **A — 收尾提交** ✅ (已完成) — 7 个提交已推送
2. **B — 安全加固** ✅ (已完成) — DTO 验证 + Token 刷新 + 班级码
3. **C — 端到端联调** ✅ (已完成) — 8/9 条链路通过
4. **D.1 体验问题修复** (下一步) — 好用
5. **B.3 性能优化** (2-3 天) — 快
6. **E.1 内测 APK** (1-2 天) — 可分享
7. **D.2 新功能探索** (持续) — 有趣
8. **E.2-E.3 应用商店 + CI/CD** (1-2 周) — 正式上线
---
## 开放讨论
> 以下问题需要在推进前达成共识:
1. **Phase 范围**: Discover 功能属于 Phase 1 还是 Phase 2审计报告标记为 Phase 2 违规
2. **MVP 定义**: 最小可用版本的边界在哪?哪些功能必须有,哪些可以等?
3. **家长验证**: 完整 PIPL 流程 vs 注册勾选确认MVP 选哪个?
4. **同步策略**: 修复现有协议 vs 重新设计 vs MVP 先不支持同步?
5. **测试设备**: 有没有 Android 设备可以用来真机测试?
6. **目标用户验证**: 有没有机会找真实小学生试用收集反馈?
7. **时间预期**: 从现在到"可内测"你期望多久?
---
*文档结束 — 确认方向后按优先级逐一推进*

View File

@@ -1,6 +1,6 @@
---
title: 数据层
updated: 2026-06-01
updated: 2026-06-07
status: active
tags: [isar, offline-first, sync, repository-pattern]
---
@@ -112,7 +112,7 @@ syncEngine.restorePendingQueue(); // fire-and-forget 恢复队列
| authorId 硬编码 'local' | HIGH | 待修 | EditorPage 未接入 AuthBloc 获取真实用户 |
| SyncEngine 仅 WiFi | MEDIUM | Phase 2 | 蜂窝数据同步未实现 |
| 版本冲突静默覆盖 | MEDIUM | Phase 2 | "本地优先"策略,需 UI 手动解决 |
| 编辑器未加载已有数据 | MEDIUM | 待做 | journalId 非空时未从 Isar 读取 |
| ~~编辑器未加载已有数据~~ | ~~MEDIUM~~ | ✅ 已修复 | _loadExistingJournal 读取日记 + 元素 + 笔画 |
### 历史教训
@@ -123,5 +123,6 @@ syncEngine.restorePendingQueue(); // fire-and-forget 恢复队列
| 日期 | 变更 |
|------|------|
| 2026-06-07 | 编辑器加载已有数据已修复、打开已有日记默认查看模式 |
| 2026-06-01 | Isar 集成完成3 Collection + Repository + SyncEngine 持久化 (2481c8f) |
| 2026-06-01 | 初始创建 — 数据层架构、Isar 踩坑记录 |

View File

@@ -1,6 +1,6 @@
---
title: erp-diary 后端模块
updated: 2026-06-01
updated: 2026-06-07
status: active
tags: [rust, axum, seaorm, diary, api]
---
@@ -29,23 +29,23 @@ tags: [rust, axum, seaorm, diary, api]
```
crates/erp-diary/src/
├── lib.rs (206 行) — DiaryModule 实现 + Feature Flag 注册
├── dto.rs (569 行) — 请求/响应 DTO + Validate 注解
├── lib.rs (280 行) — DiaryModule 实现 + Feature Flag 注册
├── dto.rs (640 行) — 请求/响应 DTO + Validate 注解
├── error.rs (193 行) — DiaryError 15 种变体 → HTTP 状态码
├── event.rs (61 行) — 事件定义 (diary.created 等)
├── state.rs (13 行) — DiaryState (DiaryModule 专用状态)
├── entity/ (15 文件) — SeaORM Entity
├── service/ (10 文件) — 业务逻辑
└── handler/ (8 文件) — HTTP Handler + utoipa 注解
├── service/ (12 文件) — 业务逻辑
└── handler/ (10 文件) — HTTP Handler + utoipa 注解
```
### Entity 清单 (15 个)
achievement, class_member, comment, handwriting_stroke, journal_element, journal_entry, parent_child_binding, school_class, sticker, sticker_pack, teacher_profile, template, topic_assignment, user_achievement, user_settings
### Service 清单 (10 个)
### Service 清单 (12 个)
journal, class, comment, content_safety, achievement, mood_stats, notification, sticker, sync, topic
journal, class, comment, content_safety, achievement, mood_stats, notification, sticker, sync, topic, **parent**, **discover**
### API 端点
@@ -60,6 +60,8 @@ journal, class, comment, content_safety, achievement, mood_stats, notification,
| `/api/v1/diary/stickers` | sticker_handler | 贴纸管理 |
| `/api/v1/diary/stats` | stats_handler | 心情/写作统计 |
| `/api/v1/diary/sync` | sync_handler | 增量同步 API |
| `/api/v1/diary/discover` | discover_handler | 发现页聚合(每日推荐/热门话题/精选模板/达人日记) |
| `/api/v1/diary/parent` | parent_handler | 家长绑定 + 数据管理 |
### 集成契约
@@ -95,6 +97,7 @@ journal, class, comment, content_safety, achievement, mood_stats, notification,
| 文件上传未实现 | MEDIUM | 待做 | 照片/贴纸文件上传参考健康模块 |
| 代码分布 | INFO | 参考 | service 层 51.7%、handler 20.1%、entity 15.6%、dto 11.1% |
| 班级码硬编码 | LOW | 待修 | 前端 teacher 模块班级码 'a1b2c3' 未接入后端 |
| 发现页硬编码 | — | ✅ 已修复 | DiscoverBloc + GET /diary/discover 全链路打通 |
### 代码量参考
@@ -111,5 +114,6 @@ journal, class, comment, content_safety, achievement, mood_stats, notification,
| 日期 | 变更 |
|------|------|
| 2026-06-07 | 新增 discover_service + discover_handler (GET /diary/discover)、parent_handler 补充文档 |
| 2026-06-01 | 补充代码量分布、班级码硬编码问题 |
| 2026-06-01 | 初始创建 — Entity/Service/Handler 清单、API 端点、集成契约 |

View File

@@ -1,6 +1,6 @@
---
title: Flutter 前端
updated: 2026-06-01
updated: 2026-06-07
status: active
tags: [flutter, bloc, design-system, responsive]
---
@@ -43,9 +43,10 @@ Stroke/StrokePoint 是热路径高频创建对象freezed 生成的代码有
| stickers | StickerBloc | 贴纸库浏览 + 选择 |
| templates | TemplateBloc | 模板画廊 |
| profile | SettingsBloc | 主题切换 + 个人设置 |
| search | — | 日记搜索 (Isar FTS 待实现) |
| search | SearchBloc | 日记搜索(按心情/标签/关键词) |
| teacher | — | 老师主题发布 + 批改 |
| parent | | 家长监护 + 数据管理 |
| parent | ParentBloc | 家长监护 + 数据管理 |
| discover | DiscoverBloc | 发现页(每日推荐/热门话题/精选模板/达人日记) |
| settings | — | 设置页面 UI |
### 注入链 (app.dart)
@@ -100,10 +101,12 @@ AppTheme.light() / AppTheme.dark()
| 问题 | 级别 | 状态 | 说明 |
|------|------|------|------|
| 编辑器不加载已有数据 | HIGH | 待做 | journalId 非空时需从 Isar 读取 |
| SSE 端口不一致 | HIGH | 待修 | SSE 用 8080API 用 3000推送必然失败 |
| ~~编辑器不加载已有数据~~ | ~~HIGH~~ | ✅ 已修复 | _loadExistingJournal 从 Isar 读取日记 + 元素 + 笔画 |
| ~~SSE 端口不一致~~ | ~~HIGH~~ | ✅ 已修复 | AppConfig 统一管理 apiBaseUrl/sseBaseUrl均指向 3000 |
| ~~编辑器打开即弹出画笔~~ | ~~HIGH~~ | ✅ 已修复 | 打开已有日记默认查看模式,点"编辑"才进入编辑模式 |
| API base URL 硬编码 | HIGH | 待修 | localhost:3000 硬编码,生产环境需配置化 |
| 前端测试为零 | HIGH | 待做 | 70 个 Dart 文件无任何测试覆盖 |
| 多处硬编码数据未对接 API | HIGH | 部分修复 | 见下方「硬编码数据清单」 |
| 前端测试覆盖不足 | MEDIUM | 持续 | 15 个测试文件 203 个用例auth_bloc 有 1 个失败待修 |
| 状态管理不统一 | MEDIUM | 待规划 | 5 模块用 BLoC5 模块用 ChangeNotifier |
| freezed 声明未使用 | MEDIUM | 待清理 | pubspec 声明了但全部手写不可变类 |
| SyncEngine 缺少网络监听 | MEDIUM | 待做 | 只有 trySync() 方法,无自动触发 |
@@ -112,6 +115,44 @@ AppTheme.light() / AppTheme.dark()
| core/utils/ 空目录 | LOW | 待填充 | 缺少日期格式化、颜色解析等通用工具 |
| 深色模式细节 | LOW | 持续 | 部分组件深色适配需检查 |
### 硬编码数据清单
> 2026-06-07 全面排查结果。标记 ✅ 已修复 / ❌ 待修复。
**HIGH — 已有 API应直接替换**
| 页面 | 文件 | 硬编码内容 | 后端 API | 状态 |
|------|------|-----------|----------|------|
| 首页 | home_page.dart | 用户名 `'小暖'` | AuthBloc.user | ❌ |
| 个人 | profile_page.dart | 头像 emoji `'😊'` | AuthBloc.user | ❌ |
| 个人 | profile_page.dart | 6 个固定成就徽章 | AchievementBloc | ❌ |
| 搜索 | search_page.dart | 4 个假模板名称 | TemplateBloc | ❌ |
| 贴纸 | sticker_library_page.dart | 精选贴纸包"治愈小动物" | StickerBloc | ❌ |
| 教师 | teacher_page.dart | 班级码 `'a1b2c3'` | ClassBloc | ❌ |
| 班级 | class_page.dart | 头像首字固定 `'同'` | JournalEntry.authorId | ❌ |
| 发现 | discover_page.dart | 全部 4 板块假数据 | GET /diary/discover | ✅ |
**MEDIUM — 需要 API 或可延后:**
| 页面 | 文件 | 硬编码内容 | 备注 |
|------|------|-----------|------|
| 搜索 | search_page.dart | 热门搜索 8 个关键词 | 需新增后端 API |
| 编辑器 | sticker_picker_sheet.dart | 60 个内置 emoji 贴纸 | Phase 1 占位,贴纸包 API 已有 |
| 编辑器 | tag_panel.dart | 10 个推荐标签 | 可从用户历史标签推导 |
| 贴纸 | sticker_library_page.dart | 8 个固定分类名 | StickerBloc 已有分类数据 |
| 模板 | template_gallery_page.dart | 每张卡片固定"学生专属"/"简约"标签 | 模板 API 已有 |
| 个人 | profile_page.dart | 贴纸数 `'--'` | 需新增统计 API |
**LOW — 可接受的 UI 设计常量:**
| 页面 | 内容 | 说明 |
|------|------|------|
| 编辑器 | 画笔/文本颜色面板 | 设计工具调色板 |
| 编辑器 | 字号选项 [小/中/大] | 编辑器选项 |
| 首页/日历 | 心情/天气 emoji 映射 | enum → UI 展示映射 |
| 引导页 | 3 步引导内容 | 静态引导文案 |
| 班级 | 快捷评语模板 7 条 | 内置教学工具 |
### 历史教训
- F11 深色模式修复需要 bloat bloc 测试套件同步更新 (05317d5)
@@ -123,6 +164,10 @@ AppTheme.light() / AppTheme.dark()
| 日期 | 变更 |
|------|------|
| 2026-06-07 | 发现页全链路打通DiscoverBloc + GET /diary/discover替换全部硬编码 |
| 2026-06-07 | 全面排查硬编码数据,新增「硬编码数据清单」章节 |
| 2026-06-07 | 编辑器新增查看模式(打开已有日记默认只读)、元素图层调整(置顶/置底)|
| 2026-06-07 | 日历页面修复:初始加载自动填充 selectedDayJournals |
| 2026-06-01 | 补充状态管理不统一、SSE 端口问题、测试缺失等新发现 |
| 2026-06-01 | IsarJournalRepository 注入为主 JournalRepository (2481c8f) |
| 2026-06-01 | 设置页 UI + Mood/成就/贴纸 BLoC (8331db6) |

View File

@@ -1,6 +1,6 @@
---
title: 暖记知识库首页
updated: 2026-06-02
updated: 2026-06-07
status: active
---
@@ -10,21 +10,21 @@ status: active
## 关键数字
> 最后更新: 2026-06-02 | 基线: main (8111471)
> 最后更新: 2026-06-07 | 基线: main (4cb91f3)
| 指标 | 值 |
|------|-----|
| Rust crate | 8 个6 基座 + 1 入口 + erp-diary |
| Rust 总代码 | ~51,500 行 |
| erp-diary 新增 | 5,10841 个文件) |
| Dart 文件 | 74 个(~19,500 行) |
| Rust 总代码 | ~52,000 行 |
| erp-diary 新增 | ~5,60045 个文件) |
| Dart 文件 | 112 个(~27,000 行) |
| 管理端前端 (React) | ~317 个 TypeScript 文件 |
| SeaORM Entity | 15 个erp-diary + 50+(基座) |
| 数据库迁移 | 58 个42 基座 + 15 diary + 1 role seed |
| 后端测试 | 77 个通过 ✅ |
| 前端 BLoC 测试 | 84 个通过 ✅ |
| 后端测试 | 88 个通过 ✅ |
| 前端测试 | 15 个文件、203 个用例通过 ✅1 个失败待修) |
| flutter analyze | 0 error ✅ |
| Git 提交 | 22 次 |
| Git 提交 | 107 次 |
## 三端架构

View File

@@ -1,13 +1,13 @@
---
title: 项目健康度评估
updated: 2026-06-01
updated: 2026-06-07
status: active
tags: [health, tech-debt, risk, improvement]
---
# 项目健康度评估
> 从 [[index]] 导航。本文档基于 2026-06-01 全量代码分析生成。
> 从 [[index]] 导航。本文档基于 2026-06-01 全量代码分析生成2026-06-07 更新
## 总体评分
@@ -15,7 +15,7 @@ tags: [health, tech-debt, risk, improvement]
|------|------|------|
| 架构设计 | ⭐⭐⭐⭐⭐ | 模块化 ErpModule trait、分层清晰、基座复用 |
| 代码质量 | ⭐⭐⭐⭐ | Rust 错误处理规范、Flutter 注释质量高、分层一致 |
| 测试覆盖 | ⭐⭐ | 后端 ~50 测试尚可、前端 **0 测试** 是最大短板 |
| 测试覆盖 | ⭐⭐ | 后端 ~77 测试通过、前端 15 个测试文件 203 个用例(仍有 1 个失败待修)|
| 安全合规 | ⭐⭐⭐⭐⭐ | PIPL 合规框架完整、PII 加密、RLS 多租户隔离 |
| 文档维护 | ⭐⭐⭐⭐⭐ | wiki 6 页 + 技术债看板 + CLAUDE.md同步度高 |
| DevOps | ⭐⭐ | Docker 配置完善但未验证、CI/CD 缺失、无自动化 |
@@ -30,7 +30,7 @@ tags: [health, tech-debt, risk, improvement]
| TD-1 | authorId 硬编码 'local' | P0 | 0.5 天 |
| TD-3 | Docker 部署未验证 | P0 | 0.5 天 |
| TD-7 | Settings 持久化未实现 | P1 | 0.5 天 |
| TD-8 | 编辑器不加载已有日记 | P1 | 1 天 |
| ~~TD-8~~ | ~~编辑器不加载已有日记~~ | ~~P1~~ ✅ | ~~1 天~~ |
| TD-4 | toImage() 同步阻塞主线程 | P1 | 1 天 |
| TD-2 | CI/CD 未建立 | P2 | 1 天 |
| TD-5 | 前端测试为零 | P2 | 3 天 |
@@ -42,8 +42,8 @@ tags: [health, tech-debt, risk, improvement]
| 编号 | 债务 | 优先级 | 预估 | 位置 |
|------|------|--------|------|------|
| NEW-1 | 前端测试缺失TD-5 补充0 回归保护) | **Critical** | 3 天 | `app/test/` |
| NEW-2 | SSE 端口不一致 (8080 vs 3000) | **Critical** | 0.5 天 | `sse_notification_service.dart:42` |
| ~~NEW-1~~ | ~~前端测试缺失0 回归保护)~~ | ~~Critical~~ ✅ | ~~3 天~~ | 15 个测试文件 203 用例auth_bloc 1 个失败待修 |
| ~~NEW-2~~ | ~~SSE 端口不一致 (8080 vs 3000)~~ | ~~Critical~~ ✅ | ~~0.5 天~~ | AppConfig 统一管理,均指向 3000 |
| NEW-3 | Dockerfile 不存在(生产部署引用) | **High** | 1 天 | `docker-compose.production.yml` |
| NEW-4 | API base URL 硬编码 localhost | **High** | 0.5 天 | `api_client.dart:29` |
| NEW-5 | 班级码后端验证未实现 | **High** | 1 天 | `auth_bloc.dart:141` TODO |
@@ -57,12 +57,12 @@ tags: [health, tech-debt, risk, improvement]
| 模块 | 完成度 | 关键缺失 |
|------|--------|---------|
| 手写引擎 | **95%** | toImage 异步化 |
| 编辑器 | **95%** | 文字输入/图片上传为占位 |
| 编辑器 | **98%** | 查看模式 + 图层调整已实现,文字输入/图片上传为占位 |
| 认证 | **85%** | 班级码后端验证 TODO |
| 首页 | **90%** | — |
| 设计系统 | **90%** | `core/utils/` 空目录 |
| 设置 | **85%** | 持久化未实现 |
| 日历 | **85%** | 周视图/时间线未实现 |
| 日历 | **90%** | 初始加载已修复,周视图/时间线未实现 |
| 数据层 | **85%** | SyncEngine 缺少网络监听 |
| 班级 | **80%** | — |
| 心情统计 | **80%** | — |
@@ -91,10 +91,10 @@ erp-core ← erp-auth ← erp-server
| 风险 | 概率 | 影响 | 缓解 |
|------|------|------|------|
| 前端测试导致回归 | | | TD-5: 建立核心 BLoC/Repository 单元测试 |
| 前端测试覆盖不足 | | | 15 个测试文件已建立auth_bloc 1 个失败待修 |
| Feature Flag 未实现限制扩展 | 中 | 中 | NEW-8: 补充 Cargo features 配置 |
| Docker 生产部署无法构建 | 高 | 高 | NEW-3: 创建 Dockerfile + 验证 |
| SSE 端口不匹配致推送失败 | 确定 | 中 | NEW-2: 统一为 3000 端口 |
| ~~SSE 端口不匹配致推送失败~~ | ~~确定~~ ✅ | ~~中~~ | AppConfig 已统一为 3000 |
| 手写 toImage 卡 UI | 中 | 中 | TD-4: compute() isolate 异步光栅化 |
| 大文件超 800 行限制 | 中 | 低 | erp-plugin manifest.rs(1809) + data_service.rs(1907) 需拆分 |
@@ -102,7 +102,7 @@ erp-core ← erp-auth ← erp-server
### 第一阶段1-2 天)— 紧急修复
1. **NEW-2**: SSE 端口统一为 3000
1. ~~**NEW-2**: SSE 端口统一为 3000~~
2. **TD-1**: authorId 接入 AuthBloc
3. **NEW-4**: API base URL 环境配置化
@@ -110,17 +110,18 @@ erp-core ← erp-auth ← erp-server
4. **TD-2**: CI/CD 基础流水线
5. **TD-3 + NEW-3**: Docker 部署验证 + Dockerfile 创建
6. **TD-5**: 核心模块单元测试AuthBloc, EditorBloc, JournalRepository
6. ~~**TD-5**: 核心模块单元测试~~ ✅ 已有 15 个测试文件,持续补充
### 第三阶段(持续)— 质量提升
7. **NEW-7**: 通用 catch(e) → 类型化异常处理
8. **NEW-8**: Feature Flag 配置落地
9. **TD-4**: toImage() 异步光栅化
10. **TD-8**: 编辑器加载已有日记
10. ~~**TD-8**: 编辑器加载已有日记~~
## 变更记录
| 日期 | 变更 |
|------|------|
| 2026-06-07 | 更新TD-8/NEW-1/NEW-2 已修复,测试覆盖 ⭐⭐→⭐⭐⭐,编辑器 95%→98%,日历 85%→90% |
| 2026-06-01 | 初始创建 — 基于 4 代理并行分析结果 |