Compare commits
9 Commits
3c3d70c751
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af7cd64e6 | ||
|
|
ec8a04c80a | ||
|
|
750605e479 | ||
|
|
346c751cbb | ||
|
|
2f96f9a4f4 | ||
|
|
f64355946c | ||
|
|
1f48a67db5 | ||
|
|
225af89e41 | ||
|
|
dbb74b6545 |
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
BIN
app/android/app/src/main/res/drawable/launch_logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 12 KiB |
4
app/android/app/src/main/res/values-night/colors.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="launch_bg">#1A1614</color>
|
||||
</resources>
|
||||
@@ -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>
|
||||
|
||||
4
app/android/app/src/main/res/values-zh/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">暖记</string>
|
||||
</resources>
|
||||
9
app/android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
4
app/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">暖记</string>
|
||||
</resources>
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
103
app/lib/app.dart
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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), // 🤔 思考 — 灰棕
|
||||
};
|
||||
|
||||
// ===== 浅色主题色彩方案 =====
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 星期标题行 =====
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '暂无主题布置',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ 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';
|
||||
|
||||
@@ -126,66 +128,19 @@ class DiscoverPage extends StatelessWidget {
|
||||
|
||||
/// 错误状态
|
||||
Widget _buildError(BuildContext context, String message) {
|
||||
return Column(
|
||||
children: [
|
||||
const _LoadingSkeleton(height: 140),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rose.withValues(alpha: 0.1),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.cloud_off_rounded,
|
||||
size: 32, color: AppColors.rose),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
Text(message,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
TextButton.icon(
|
||||
onPressed: () => context
|
||||
.read<DiscoverBloc>()
|
||||
.add(const DiscoverLoadData()),
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
return ErrorStateWidget(
|
||||
message: message,
|
||||
onRetry: () =>
|
||||
context.read<DiscoverBloc>().add(const DiscoverLoadData()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 空数据提示
|
||||
Widget _buildEmptyHint(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Text('✨', style: TextStyle(fontSize: 40)),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
Text('还没有发现内容',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
)),
|
||||
const SizedBox(height: 4),
|
||||
Text('写下你的第一篇日记,出现在这里吧!',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant
|
||||
.withValues(alpha: 0.7),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
return const EmptyStateWidget(
|
||||
icon: Icons.explore_rounded,
|
||||
title: '还没有发现内容',
|
||||
subtitle: '试试写一篇日记分享给大家吧',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -337,6 +337,9 @@ class _EditorViewState extends State<_EditorView> {
|
||||
/// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式
|
||||
bool _isViewMode = false;
|
||||
|
||||
/// 保存中状态 — 用于显示"保存中..."指示器
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -554,8 +557,17 @@ class _EditorViewState extends State<_EditorView> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 返回处理
|
||||
/// 返回处理 — 有未保存修改时弹出确认
|
||||
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 {
|
||||
@@ -563,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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 显示评论列表
|
||||
@@ -592,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),
|
||||
@@ -602,6 +663,7 @@ class _EditorViewState extends State<_EditorView> {
|
||||
),
|
||||
);
|
||||
}
|
||||
// 已保存 — 绿色点
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
@@ -1179,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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -425,7 +425,7 @@ class _LiveStatsBarState extends State<_LiveStatsBar> {
|
||||
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||||
_StatItem(label: '本月日记', value: '$_monthCount', valueColor: widget.colorScheme.onSurface),
|
||||
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||||
_StatItem(label: '贴纸数', value: _totalCount > 0 ? '$_totalCount' : '0', valueColor: widget.colorScheme.onSurface),
|
||||
_StatItem(label: '贴纸数', value: '--', valueColor: widget.colorScheme.onSurface),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
/// 贴纸库页面 — 分类浏览贴纸包
|
||||
@@ -64,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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,7 +163,10 @@ class _StickerLibraryPageState extends State<StickerLibraryPage> {
|
||||
// ---- 贴纸包网格 ----
|
||||
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:
|
||||
|
||||
95
app/lib/widgets/empty_state_widget.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
72
app/lib/widgets/error_state_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
102
app/lib/widgets/offline_banner.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
156
app/lib/widgets/skeleton_loading.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
306
plans/next-steps-roadmap.md
Normal 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. **时间预期**: 从现在到"可内测"你期望多久?
|
||||
|
||||
---
|
||||
|
||||
*文档结束 — 确认方向后按优先级逐一推进*
|
||||