Compare commits
46 Commits
11d0971a67
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7af7cd64e6 | ||
|
|
ec8a04c80a | ||
|
|
750605e479 | ||
|
|
346c751cbb | ||
|
|
2f96f9a4f4 | ||
|
|
f64355946c | ||
|
|
1f48a67db5 | ||
|
|
225af89e41 | ||
|
|
dbb74b6545 | ||
|
|
3c3d70c751 | ||
|
|
ed8252d7c8 | ||
|
|
41ef28f20b | ||
|
|
d67eedf7de | ||
|
|
a05374e8d1 | ||
|
|
a5d2b0409f | ||
|
|
3bc2ca7332 | ||
|
|
4cb91f3ac9 | ||
|
|
c253c8ddcf | ||
|
|
bb388ed8ff | ||
|
|
c441aa4e34 | ||
|
|
e635557e67 | ||
|
|
138bfa9723 | ||
|
|
b72009718f | ||
|
|
9fce34f4ef | ||
|
|
988ee7335a | ||
|
|
9c92cba87f | ||
|
|
f6d394afb6 | ||
|
|
4cd08535d3 | ||
|
|
271f0c4f29 | ||
|
|
4cd381295a | ||
|
|
8300822232 | ||
|
|
367f21de08 | ||
|
|
1766cefde9 | ||
|
|
38592d61ce | ||
|
|
e8df3a9562 | ||
|
|
32a91551c4 | ||
|
|
b6ffc60331 | ||
|
|
4e5c1287a6 | ||
|
|
3258acaa77 | ||
|
|
0c9ada242a | ||
|
|
99db8e5cb0 | ||
|
|
a34c9fd176 | ||
|
|
45949e3ed0 | ||
|
|
c4b2de8294 | ||
|
|
cca2d77ea2 | ||
|
|
6d7ac05d0f |
@@ -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,8 +1,20 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- 网络权限 — API 同步、图片上传 -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<!-- 相机权限 — 日记拍照 -->
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<!-- 照片权限 — Android 13+ 细化媒体权限 -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<!-- 照片权限 — Android 12 及以下 -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
|
||||
<application
|
||||
android:label="nuanji_app"
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="false">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
app/android/app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- 网络安全配置 — 强制 HTTPS,仅允许 localhost 明文(开发用)
|
||||
审计 ID: 6b-C01 — Flutter 默认 HTTP 明文传输修复
|
||||
-->
|
||||
<network-security-config>
|
||||
<!-- 生产配置:强制 HTTPS -->
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<!-- 开发配置:允许 localhost/10.0.2.2 明文(模拟器/本地调试)
|
||||
生产构建时应移除此段 -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">10.0.2.2</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -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()
|
||||
|
||||
106
app/lib/app.dart
@@ -10,11 +10,12 @@
|
||||
// └─ BlocProvider<AuthBloc>
|
||||
// └─ MaterialApp.router
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart' show ListenableProvider;
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'config/app_config.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
@@ -31,64 +32,99 @@ import 'features/auth/bloc/auth_bloc.dart';
|
||||
import 'features/profile/bloc/settings_bloc.dart';
|
||||
|
||||
/// 暖记 App — 根组件
|
||||
class NuanjiApp extends StatelessWidget {
|
||||
class NuanjiApp extends StatefulWidget {
|
||||
const NuanjiApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 创建全局依赖(App 生命周期内单例)
|
||||
final config = AppConfig.fromEnvironment();
|
||||
final apiClient = ApiClient(baseUrl: config.apiBaseUrl);
|
||||
State<NuanjiApp> createState() => _NuanjiAppState();
|
||||
}
|
||||
|
||||
class _NuanjiAppState extends State<NuanjiApp> {
|
||||
late final ApiClient _apiClient;
|
||||
late final AuthRepository _authRepository;
|
||||
late final JournalRepository _journalRepository;
|
||||
late final RemoteJournalRepository _remoteJournalRepository;
|
||||
late final SyncEngine _syncEngine;
|
||||
late final ClassRepository _classRepository;
|
||||
late final SettingsBloc _settingsBloc;
|
||||
late final AuthBloc _authBloc;
|
||||
|
||||
bool _initialized = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initApp();
|
||||
}
|
||||
|
||||
Future<void> _initApp() async {
|
||||
final config = kDebugMode ? AppConfig.dev : AppConfig.fromEnvironment();
|
||||
_apiClient = ApiClient(baseUrl: config.apiBaseUrl);
|
||||
final tokenStore = createSecureTokenStore();
|
||||
final authRepository = AuthRepository(apiClient: apiClient, tokenStore: tokenStore);
|
||||
// 离线优先:Isar 为主要本地仓库,Remote 供 SyncEngine 推送
|
||||
// Web 平台:Isar 3.x 不支持 Web,直接使用远程仓库
|
||||
final journalRepository = kIsWeb
|
||||
? RemoteJournalRepository(api: apiClient)
|
||||
_authRepository = AuthRepository(apiClient: _apiClient, tokenStore: tokenStore);
|
||||
_journalRepository = kIsWeb
|
||||
? RemoteJournalRepository(api: _apiClient)
|
||||
: IsarJournalRepository();
|
||||
final remoteJournalRepository = RemoteJournalRepository(api: apiClient);
|
||||
final syncEngine = SyncEngine(apiClient: apiClient);
|
||||
final classRepository = ClassRepository(api: apiClient);
|
||||
final settingsBloc = SettingsBloc();
|
||||
final authBloc = AuthBloc(
|
||||
authRepository: authRepository,
|
||||
classRepository: classRepository,
|
||||
_remoteJournalRepository = RemoteJournalRepository(api: _apiClient);
|
||||
_syncEngine = SyncEngine(apiClient: _apiClient);
|
||||
_classRepository = ClassRepository(api: _apiClient);
|
||||
_settingsBloc = SettingsBloc(
|
||||
prefs: await SharedPreferences.getInstance(),
|
||||
);
|
||||
_authBloc = AuthBloc(
|
||||
authRepository: _authRepository,
|
||||
classRepository: _classRepository,
|
||||
);
|
||||
|
||||
// 启动时检查认证状态
|
||||
authBloc.add(const AppStarted());
|
||||
_authBloc.add(const AppStarted());
|
||||
|
||||
// 异步恢复 SyncEngine 持久化队列(fire-and-forget,不阻塞 UI)
|
||||
syncEngine.restorePendingQueue();
|
||||
// 启动网络监听 — 网络恢复时自动触发 trySync()
|
||||
syncEngine.startAutoSync();
|
||||
// 异步恢复 SyncEngine 持久化队列
|
||||
_syncEngine.restorePendingQueue();
|
||||
_syncEngine.startAutoSync();
|
||||
|
||||
// 认证状态监听:登出时清除 token
|
||||
// 注意:登录时 token 由 AuthRepository.login() 直接注入 ApiClient
|
||||
authBloc.stream.listen((state) {
|
||||
_authBloc.stream.listen((state) {
|
||||
if (state is! Authenticated) {
|
||||
apiClient.clearToken();
|
||||
_apiClient.clearToken();
|
||||
}
|
||||
});
|
||||
|
||||
// Token 刷新彻底失败时 → 派发 AuthExpired
|
||||
_apiClient.onAuthFailed = () {
|
||||
_authBloc.add(const AuthExpired());
|
||||
};
|
||||
|
||||
setState(() => _initialized = true);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_initialized) {
|
||||
return const MaterialApp(
|
||||
debugShowCheckedModeBanner: false,
|
||||
home: Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
);
|
||||
}
|
||||
|
||||
return MultiRepositoryProvider(
|
||||
providers: [
|
||||
RepositoryProvider<ApiClient>.value(value: apiClient),
|
||||
RepositoryProvider<AuthRepository>.value(value: authRepository),
|
||||
RepositoryProvider<JournalRepository>.value(value: journalRepository),
|
||||
RepositoryProvider<RemoteJournalRepository>.value(value: remoteJournalRepository),
|
||||
RepositoryProvider<SyncEngine>.value(value: syncEngine),
|
||||
RepositoryProvider<ClassRepository>.value(value: classRepository),
|
||||
RepositoryProvider<ApiClient>.value(value: _apiClient),
|
||||
RepositoryProvider<AuthRepository>.value(value: _authRepository),
|
||||
RepositoryProvider<JournalRepository>.value(value: _journalRepository),
|
||||
RepositoryProvider<RemoteJournalRepository>.value(value: _remoteJournalRepository),
|
||||
RepositoryProvider<SyncEngine>.value(value: _syncEngine),
|
||||
RepositoryProvider<ClassRepository>.value(value: _classRepository),
|
||||
],
|
||||
child: ListenableProvider<SettingsBloc>.value(
|
||||
value: settingsBloc,
|
||||
value: _settingsBloc,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final settings = context.watch<SettingsBloc>();
|
||||
return BlocProvider<AuthBloc>.value(
|
||||
value: authBloc,
|
||||
value: _authBloc,
|
||||
child: _AppView(
|
||||
router: createAppRouter(authBloc),
|
||||
router: createAppRouter(_authBloc),
|
||||
themeMode: settings.state.themeMode,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// 应用环境配置 — 通过 --dart-define 注入
|
||||
//
|
||||
// 使用方式:
|
||||
// flutter run --dart-define=API_BASE_URL=http://localhost:3000/api/v1
|
||||
// flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1
|
||||
// flutter run # 开发模式(localhost)
|
||||
// flutter run --dart-define=API_BASE_URL=https://api.nuanji.app/api/v1 # 生产模式
|
||||
//
|
||||
// 安全说明:
|
||||
// - 生产环境强制 HTTPS(Android network_security_config 禁止明文流量)
|
||||
// - 开发模式使用 localhost(Android 网络安全配置已允许 localhost 明文)
|
||||
|
||||
/// 应用环境配置 — 集中管理所有外部服务地址
|
||||
class AppConfig {
|
||||
@@ -19,19 +23,20 @@ class AppConfig {
|
||||
|
||||
/// 从编译时环境变量构建配置
|
||||
///
|
||||
/// 使用 `--dart-define` 注入,未设置时使用默认值。
|
||||
/// 使用 `--dart-define` 注入,未设置时使用生产 HTTPS 默认值。
|
||||
/// 开发环境使用 [dev] 常量或通过 --dart-define 覆盖。
|
||||
factory AppConfig.fromEnvironment({
|
||||
String defaultApiBaseUrl = 'http://localhost:3000/api/v1',
|
||||
String defaultSseBaseUrl = 'http://localhost:3000/api/v1',
|
||||
String defaultApiBaseUrl = 'https://api.nuanji.app/api/v1',
|
||||
String defaultSseBaseUrl = 'https://api.nuanji.app/api/v1',
|
||||
}) {
|
||||
// const String.fromEnvironment 在编译时求值
|
||||
const apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://localhost:3000/api/v1',
|
||||
defaultValue: 'https://api.nuanji.app/api/v1',
|
||||
);
|
||||
const sseBaseUrl = String.fromEnvironment(
|
||||
'SSE_BASE_URL',
|
||||
defaultValue: 'http://localhost:3000/api/v1',
|
||||
defaultValue: 'https://api.nuanji.app/api/v1',
|
||||
);
|
||||
|
||||
return AppConfig(
|
||||
@@ -40,7 +45,7 @@ class AppConfig {
|
||||
);
|
||||
}
|
||||
|
||||
/// 开发环境默认配置
|
||||
/// 开发环境默认配置(localhost 明文 — 仅用于本地调试)
|
||||
static const dev = AppConfig(
|
||||
apiBaseUrl: 'http://localhost:3000/api/v1',
|
||||
sseBaseUrl: 'http://localhost:3000/api/v1',
|
||||
|
||||
@@ -28,6 +28,7 @@ import '../../features/profile/views/profile_page.dart';
|
||||
import '../../features/editor/views/editor_page.dart';
|
||||
import '../../features/auth/views/login_page.dart';
|
||||
import '../../features/auth/views/role_selection_page.dart';
|
||||
import '../../features/auth/views/parental_consent_page.dart';
|
||||
import '../../features/auth/views/class_code_join_page.dart';
|
||||
import '../../features/onboarding/views/splash_page.dart';
|
||||
import '../../features/onboarding/views/onboarding_page.dart';
|
||||
@@ -41,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';
|
||||
|
||||
@@ -49,7 +51,7 @@ final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
final _shellNavigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
/// 不需要认证的白名单路径
|
||||
const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/class-code'];
|
||||
const _publicPaths = ['/splash', '/onboarding', '/login', '/role-selection', '/parental-consent', '/class-code'];
|
||||
|
||||
/// 创建路由配置 — 需要注入 AuthBloc
|
||||
GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
@@ -74,6 +76,7 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
// 已认证 + 访问公开页面 → 根据状态重定向
|
||||
if (isAuthenticated && isPublicPath) {
|
||||
if (authState.needsRoleSelection) return '/role-selection';
|
||||
if (authState.needsParentalConsent) return '/parental-consent';
|
||||
if (authState.needsClassCode) return '/class-code';
|
||||
return '/home';
|
||||
}
|
||||
@@ -83,9 +86,14 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
if (authState.needsRoleSelection && currentPath != '/role-selection') {
|
||||
return '/role-selection';
|
||||
}
|
||||
if (authState.needsParentalConsent &&
|
||||
currentPath != '/parental-consent') {
|
||||
return '/parental-consent';
|
||||
}
|
||||
if (authState.needsClassCode &&
|
||||
currentPath != '/class-code' &&
|
||||
currentPath != '/role-selection') {
|
||||
currentPath != '/role-selection' &&
|
||||
currentPath != '/parental-consent') {
|
||||
return '/class-code';
|
||||
}
|
||||
return null;
|
||||
@@ -125,6 +133,11 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
name: 'roleSelection',
|
||||
builder: (context, state) => const RoleSelectionPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/parental-consent',
|
||||
name: 'parentalConsent',
|
||||
builder: (context, state) => const ParentalConsentPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/class-code',
|
||||
name: 'classCode',
|
||||
@@ -156,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(
|
||||
|
||||
@@ -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), // 🤔 思考 — 灰棕
|
||||
};
|
||||
|
||||
// ===== 浅色主题色彩方案 =====
|
||||
|
||||
@@ -16,7 +16,7 @@ extension GetJournalElementCollectionCollection on Isar {
|
||||
|
||||
const JournalElementCollectionSchema = CollectionSchema(
|
||||
name: r'JournalElementCollection',
|
||||
id: -1002,
|
||||
id: -3625932583395690305,
|
||||
properties: {
|
||||
r'contentJson': PropertySchema(
|
||||
id: 0,
|
||||
@@ -96,7 +96,7 @@ const JournalElementCollectionSchema = CollectionSchema(
|
||||
idName: r'isarId',
|
||||
indexes: {
|
||||
r'id': IndexSchema(
|
||||
id: -2002,
|
||||
id: -3268401673993471357,
|
||||
name: r'id',
|
||||
unique: false,
|
||||
replace: false,
|
||||
@@ -109,7 +109,7 @@ const JournalElementCollectionSchema = CollectionSchema(
|
||||
],
|
||||
),
|
||||
r'journalId': IndexSchema(
|
||||
id: 3001,
|
||||
id: 1745640946427815323,
|
||||
name: r'journalId',
|
||||
unique: false,
|
||||
replace: false,
|
||||
|
||||
@@ -16,7 +16,8 @@ class JournalEntryCollection {
|
||||
@Index()
|
||||
String id = '';
|
||||
|
||||
/// 作者 ID
|
||||
/// 作者 ID(索引 + 组合索引 authorId+dateEpoch,覆盖按作者查询并按日期排序的场景)
|
||||
@Index(composite: [CompositeIndex('dateEpoch')])
|
||||
String authorId = '';
|
||||
|
||||
/// 班级 ID(可选)
|
||||
@@ -25,7 +26,8 @@ class JournalEntryCollection {
|
||||
/// 日记标题
|
||||
String title = '';
|
||||
|
||||
/// 日记日期(epoch milliseconds)
|
||||
/// 日记日期(epoch milliseconds)— 单独索引支持日期范围查询
|
||||
@Index()
|
||||
int dateEpoch = 0;
|
||||
|
||||
/// 心情(enum → string)
|
||||
|
||||
@@ -16,7 +16,7 @@ extension GetJournalEntryCollectionCollection on Isar {
|
||||
|
||||
const JournalEntryCollectionSchema = CollectionSchema(
|
||||
name: r'JournalEntryCollection',
|
||||
id: -1001,
|
||||
id: -6325316395299921961,
|
||||
properties: {
|
||||
r'assignedTopicId': PropertySchema(
|
||||
id: 0,
|
||||
@@ -106,7 +106,7 @@ const JournalEntryCollectionSchema = CollectionSchema(
|
||||
idName: r'isarId',
|
||||
indexes: {
|
||||
r'id': IndexSchema(
|
||||
id: -2001,
|
||||
id: -3268401673993471357,
|
||||
name: r'id',
|
||||
unique: false,
|
||||
replace: false,
|
||||
@@ -117,6 +117,37 @@ const JournalEntryCollectionSchema = CollectionSchema(
|
||||
caseSensitive: true,
|
||||
)
|
||||
],
|
||||
),
|
||||
r'authorId_dateEpoch': IndexSchema(
|
||||
id: -4869847655132214108,
|
||||
name: r'authorId_dateEpoch',
|
||||
unique: false,
|
||||
replace: false,
|
||||
properties: [
|
||||
IndexPropertySchema(
|
||||
name: r'authorId',
|
||||
type: IndexType.hash,
|
||||
caseSensitive: true,
|
||||
),
|
||||
IndexPropertySchema(
|
||||
name: r'dateEpoch',
|
||||
type: IndexType.value,
|
||||
caseSensitive: false,
|
||||
)
|
||||
],
|
||||
),
|
||||
r'dateEpoch': IndexSchema(
|
||||
id: 359017825055613028,
|
||||
name: r'dateEpoch',
|
||||
unique: false,
|
||||
replace: false,
|
||||
properties: [
|
||||
IndexPropertySchema(
|
||||
name: r'dateEpoch',
|
||||
type: IndexType.value,
|
||||
caseSensitive: false,
|
||||
)
|
||||
],
|
||||
)
|
||||
},
|
||||
links: {},
|
||||
@@ -277,6 +308,15 @@ extension JournalEntryCollectionQueryWhereSort
|
||||
return query.addWhereClause(const IdWhereClause.any());
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection, QAfterWhere>
|
||||
anyDateEpoch() {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(
|
||||
const IndexWhereClause.any(indexName: r'dateEpoch'),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension JournalEntryCollectionQueryWhere on QueryBuilder<
|
||||
@@ -393,6 +433,242 @@ extension JournalEntryCollectionQueryWhere on QueryBuilder<
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> authorIdEqualToAnyDateEpoch(String authorId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
value: [authorId],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> authorIdNotEqualToAnyDateEpoch(String authorId) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [],
|
||||
upper: [authorId],
|
||||
includeUpper: false,
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
));
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [],
|
||||
upper: [authorId],
|
||||
includeUpper: false,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause>
|
||||
authorIdDateEpochEqualTo(String authorId, int dateEpoch) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
value: [authorId, dateEpoch],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause>
|
||||
authorIdEqualToDateEpochNotEqualTo(String authorId, int dateEpoch) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId],
|
||||
upper: [authorId, dateEpoch],
|
||||
includeUpper: false,
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId, dateEpoch],
|
||||
includeLower: false,
|
||||
upper: [authorId],
|
||||
));
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId, dateEpoch],
|
||||
includeLower: false,
|
||||
upper: [authorId],
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId],
|
||||
upper: [authorId, dateEpoch],
|
||||
includeUpper: false,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> authorIdEqualToDateEpochGreaterThan(
|
||||
String authorId,
|
||||
int dateEpoch, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId, dateEpoch],
|
||||
includeLower: include,
|
||||
upper: [authorId],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> authorIdEqualToDateEpochLessThan(
|
||||
String authorId,
|
||||
int dateEpoch, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId],
|
||||
upper: [authorId, dateEpoch],
|
||||
includeUpper: include,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> authorIdEqualToDateEpochBetween(
|
||||
String authorId,
|
||||
int lowerDateEpoch,
|
||||
int upperDateEpoch, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'authorId_dateEpoch',
|
||||
lower: [authorId, lowerDateEpoch],
|
||||
includeLower: includeLower,
|
||||
upper: [authorId, upperDateEpoch],
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> dateEpochEqualTo(int dateEpoch) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.equalTo(
|
||||
indexName: r'dateEpoch',
|
||||
value: [dateEpoch],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> dateEpochNotEqualTo(int dateEpoch) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
if (query.whereSort == Sort.asc) {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [],
|
||||
upper: [dateEpoch],
|
||||
includeUpper: false,
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [dateEpoch],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
));
|
||||
} else {
|
||||
return query
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [dateEpoch],
|
||||
includeLower: false,
|
||||
upper: [],
|
||||
))
|
||||
.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [],
|
||||
upper: [dateEpoch],
|
||||
includeUpper: false,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> dateEpochGreaterThan(
|
||||
int dateEpoch, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [dateEpoch],
|
||||
includeLower: include,
|
||||
upper: [],
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> dateEpochLessThan(
|
||||
int dateEpoch, {
|
||||
bool include = false,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [],
|
||||
upper: [dateEpoch],
|
||||
includeUpper: include,
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
QueryBuilder<JournalEntryCollection, JournalEntryCollection,
|
||||
QAfterWhereClause> dateEpochBetween(
|
||||
int lowerDateEpoch,
|
||||
int upperDateEpoch, {
|
||||
bool includeLower = true,
|
||||
bool includeUpper = true,
|
||||
}) {
|
||||
return QueryBuilder.apply(this, (query) {
|
||||
return query.addWhereClause(IndexWhereClause.between(
|
||||
indexName: r'dateEpoch',
|
||||
lower: [lowerDateEpoch],
|
||||
includeLower: includeLower,
|
||||
upper: [upperDateEpoch],
|
||||
includeUpper: includeUpper,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
extension JournalEntryCollectionQueryFilter on QueryBuilder<
|
||||
|
||||
@@ -16,7 +16,7 @@ extension GetPendingOperationCollectionCollection on Isar {
|
||||
|
||||
const PendingOperationCollectionSchema = CollectionSchema(
|
||||
name: r'PendingOperationCollection',
|
||||
id: -1003,
|
||||
id: -6885010264946527864,
|
||||
properties: {
|
||||
r'createdAtEpoch': PropertySchema(
|
||||
id: 0,
|
||||
@@ -61,7 +61,7 @@ const PendingOperationCollectionSchema = CollectionSchema(
|
||||
idName: r'isarId',
|
||||
indexes: {
|
||||
r'id': IndexSchema(
|
||||
id: -2003,
|
||||
id: -3268401673993471357,
|
||||
name: r'id',
|
||||
unique: false,
|
||||
replace: false,
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
// 根据平台创建对应的 SecureTokenStore 实现。
|
||||
// 运行时判断 kIsWeb,避免 Web 编译时加载 flutter_secure_storage。
|
||||
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
import 'secure_token_store.dart';
|
||||
import 'secure_token_store_web.dart';
|
||||
|
||||
|
||||
176
app/lib/data/models/sync_models.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
// 同步协议模型 — 与 Rust 端 SyncReq/SyncResp/SyncChange/ConflictInfo 一一对应
|
||||
//
|
||||
// 端点: POST /api/v1/diary/sync
|
||||
// Rust DTO: crates/erp-diary/src/dto.rs (SyncReq, SyncResp, SyncChange, ConflictInfo)
|
||||
|
||||
/// 同步请求 — 与 Rust SyncReq 对应
|
||||
///
|
||||
/// ```rust
|
||||
/// pub struct SyncReq {
|
||||
/// pub last_sync_time: Option<DateTime<Utc>>,
|
||||
/// pub changes: Vec<SyncChange>,
|
||||
/// }
|
||||
/// ```
|
||||
class SyncReq {
|
||||
final DateTime? lastSyncTime;
|
||||
final List<SyncChange> changes;
|
||||
|
||||
const SyncReq({this.lastSyncTime, this.changes = const []});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (lastSyncTime != null)
|
||||
'last_sync_time': lastSyncTime!.toUtc().toIso8601String(),
|
||||
'changes': changes.map((c) => c.toJson()).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 同步变更条目 — 与 Rust SyncChange 枚举对应
|
||||
///
|
||||
/// ```rust
|
||||
/// pub enum SyncChange {
|
||||
/// CreateJournal { data: serde_json::Value },
|
||||
/// UpdateJournal { id: Uuid, version: i32, data: serde_json::Value },
|
||||
/// DeleteJournal { id: Uuid, version: i32 },
|
||||
/// }
|
||||
/// ```
|
||||
sealed class SyncChange {
|
||||
const SyncChange();
|
||||
|
||||
Map<String, dynamic> toJson();
|
||||
|
||||
/// 从 JSON 反序列化
|
||||
factory SyncChange.fromJson(Map<String, dynamic> json) {
|
||||
if (json.containsKey('CreateJournal')) {
|
||||
return SyncChangeCreateJournal(
|
||||
data: json['CreateJournal']['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
if (json.containsKey('UpdateJournal')) {
|
||||
final inner = json['UpdateJournal'] as Map<String, dynamic>;
|
||||
return SyncChangeUpdateJournal(
|
||||
id: inner['id'] as String,
|
||||
version: inner['version'] as int,
|
||||
data: inner['data'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
if (json.containsKey('DeleteJournal')) {
|
||||
final inner = json['DeleteJournal'] as Map<String, dynamic>;
|
||||
return SyncChangeDeleteJournal(
|
||||
id: inner['id'] as String,
|
||||
version: inner['version'] as int,
|
||||
);
|
||||
}
|
||||
throw FormatException('Unknown SyncChange variant: $json');
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建日记变更
|
||||
class SyncChangeCreateJournal extends SyncChange {
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
const SyncChangeCreateJournal({required this.data});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'CreateJournal': {'data': data},
|
||||
};
|
||||
}
|
||||
|
||||
/// 更新日记变更
|
||||
class SyncChangeUpdateJournal extends SyncChange {
|
||||
final String id;
|
||||
final int version;
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
const SyncChangeUpdateJournal({
|
||||
required this.id,
|
||||
required this.version,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'UpdateJournal': {
|
||||
'id': id,
|
||||
'version': version,
|
||||
'data': data,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// 删除日记变更
|
||||
class SyncChangeDeleteJournal extends SyncChange {
|
||||
final String id;
|
||||
final int version;
|
||||
|
||||
const SyncChangeDeleteJournal({
|
||||
required this.id,
|
||||
required this.version,
|
||||
});
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() => {
|
||||
'DeleteJournal': {
|
||||
'id': id,
|
||||
'version': version,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// 同步响应 — 与 Rust SyncResp 对应
|
||||
///
|
||||
/// ```rust
|
||||
/// pub struct SyncResp {
|
||||
/// pub server_changes: Vec<serde_json::Value>,
|
||||
/// pub conflicts: Vec<ConflictInfo>,
|
||||
/// pub sync_time: DateTime<Utc>,
|
||||
/// }
|
||||
/// ```
|
||||
class SyncResp {
|
||||
final List<Map<String, dynamic>> serverChanges;
|
||||
final List<ConflictInfo> conflicts;
|
||||
final DateTime syncTime;
|
||||
|
||||
const SyncResp({
|
||||
required this.serverChanges,
|
||||
required this.conflicts,
|
||||
required this.syncTime,
|
||||
});
|
||||
|
||||
factory SyncResp.fromJson(Map<String, dynamic> json) => SyncResp(
|
||||
serverChanges: (json['server_changes'] as List)
|
||||
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList(),
|
||||
conflicts: (json['conflicts'] as List)
|
||||
.map((e) => ConflictInfo.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList(),
|
||||
syncTime: DateTime.parse(json['sync_time'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// 冲突信息 — 与 Rust ConflictInfo 对应
|
||||
///
|
||||
/// ```rust
|
||||
/// pub struct ConflictInfo {
|
||||
/// pub journal_id: Uuid,
|
||||
/// pub local_version: i32,
|
||||
/// pub server_version: i32,
|
||||
/// }
|
||||
/// ```
|
||||
class ConflictInfo {
|
||||
final String journalId;
|
||||
final int localVersion;
|
||||
final int serverVersion;
|
||||
|
||||
const ConflictInfo({
|
||||
required this.journalId,
|
||||
required this.localVersion,
|
||||
required this.serverVersion,
|
||||
});
|
||||
|
||||
factory ConflictInfo.fromJson(Map<String, dynamic> json) => ConflictInfo(
|
||||
journalId: json['journal_id'] as String,
|
||||
localVersion: json['local_version'] as int,
|
||||
serverVersion: json['server_version'] as int,
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
// API 客户端 — Dio 封装 + JWT 注入 + 离线感知
|
||||
// API 客户端 — Dio 封装 + JWT 注入 + Token 自动刷新 + 离线感知
|
||||
//
|
||||
// 核心职责:
|
||||
// - 封装 Dio HTTP 客户端,统一配置超时和头信息
|
||||
// - JWT token 自动注入(请求拦截器)
|
||||
// - 401 自动刷新 token + 重试原请求(审计 9a-AUTH-01)
|
||||
// - 离线状态感知(网络不可用时抛出明确异常)
|
||||
// - 为 SyncEngine 提供远程操作能力
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
|
||||
import '../models/sync_models.dart';
|
||||
|
||||
/// 网络离线异常 — 网络不可用时由 ApiClient 抛出
|
||||
class OfflineException implements Exception {
|
||||
final String message;
|
||||
@@ -26,7 +29,27 @@ class ApiClient {
|
||||
/// 基础 URL,默认指向本地开发服务器
|
||||
final String baseUrl;
|
||||
|
||||
ApiClient({this.baseUrl = 'http://localhost:3000/api/v1'}) {
|
||||
/// Token 刷新回调 — 由 AuthRepository 在构造后注册
|
||||
///
|
||||
/// 返回新的 access token,失败返回 null。
|
||||
/// 使用回调模式避免 ApiClient ↔ AuthRepository 循环依赖。
|
||||
Future<String?> Function()? onRefreshToken;
|
||||
|
||||
/// 认证彻底失败回调 — 刷新 token 失败后由 app.dart 注册
|
||||
///
|
||||
/// 通知 AuthBloc 派发 AuthExpired 事件,触发路由重定向到登录页。
|
||||
/// 解决审计 9a-AUTH-01:刷新失败时用户不会被留在死页面。
|
||||
void Function()? onAuthFailed;
|
||||
|
||||
/// 是否正在刷新 token(防止并发 401 触发多次刷新)
|
||||
bool _isRefreshing = false;
|
||||
|
||||
/// 创建 API 客户端
|
||||
///
|
||||
/// [baseUrl] 默认使用 HTTPS 生产地址。
|
||||
/// 开发环境可通过构造参数覆盖为 http://localhost:3000/api/v1
|
||||
/// (Android 网络安全配置已允许 localhost 明文)。
|
||||
ApiClient({this.baseUrl = 'https://api.nuanji.app/api/v1'}) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
@@ -48,12 +71,39 @@ class ApiClient {
|
||||
},
|
||||
));
|
||||
|
||||
// 响应拦截器:统一错误处理
|
||||
// 响应拦截器:401 自动刷新 token + 重试
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onError: (error, handler) {
|
||||
// 401 时自动清除 token(需要重新登录)
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
// 不对刷新端点本身重试(避免无限循环)
|
||||
final isRefreshRequest =
|
||||
error.requestOptions.path.endsWith('/auth/refresh');
|
||||
|
||||
if (!isRefreshRequest &&
|
||||
onRefreshToken != null &&
|
||||
!_isRefreshing) {
|
||||
_isRefreshing = true;
|
||||
try {
|
||||
final newToken = await onRefreshToken!();
|
||||
if (newToken != null) {
|
||||
_token = newToken;
|
||||
// 用新 token 重试原始请求
|
||||
error.requestOptions.headers['Authorization'] =
|
||||
'Bearer $newToken';
|
||||
_isRefreshing = false;
|
||||
return handler.resolve(
|
||||
await _dio.fetch(error.requestOptions),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// 刷新失败,继续走 401 逻辑
|
||||
}
|
||||
_isRefreshing = false;
|
||||
}
|
||||
|
||||
// 刷新失败或无刷新回调 → 清除 token,通知全局认证失效
|
||||
_token = null;
|
||||
onAuthFailed?.call();
|
||||
}
|
||||
handler.next(error);
|
||||
},
|
||||
@@ -146,4 +196,19 @@ class ApiClient {
|
||||
});
|
||||
return _dio.post<T>(path, data: formData);
|
||||
}
|
||||
|
||||
// ===== 同步 API =====
|
||||
|
||||
/// 批量同步 — POST /diary/sync
|
||||
///
|
||||
/// 将客户端变更批量提交到服务端,返回服务端变更和冲突信息。
|
||||
/// 对应 Rust sync_handler::sync_journals 端点。
|
||||
Future<SyncResp> sync(SyncReq req) async {
|
||||
await _ensureOnline();
|
||||
final response = await _dio.post<Map<String, dynamic>>(
|
||||
'/diary/sync',
|
||||
data: req.toJson(),
|
||||
);
|
||||
return SyncResp.fromJson(response.data!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,10 @@ class AuthRepository {
|
||||
required ApiClient apiClient,
|
||||
required SecureTokenStore tokenStore,
|
||||
}) : _apiClient = apiClient,
|
||||
_tokenStore = tokenStore;
|
||||
_tokenStore = tokenStore {
|
||||
// 注册 token 自动刷新回调 — 401 时 ApiClient 自动调用
|
||||
_apiClient.onRefreshToken = _handleAutoRefresh;
|
||||
}
|
||||
|
||||
/// 当前用户(可能为 null)
|
||||
User? get currentUser => _currentUser;
|
||||
@@ -215,6 +218,33 @@ class AuthRepository {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Token 自动刷新 =====
|
||||
|
||||
/// ApiClient 401 拦截器调用的自动刷新处理
|
||||
///
|
||||
/// 使用 refresh_token 获取新 access_token,更新 ApiClient 的 token,
|
||||
/// 返回新 access_token(失败返回 null)。
|
||||
Future<String?> _handleAutoRefresh() async {
|
||||
if (_currentToken == null) return null;
|
||||
|
||||
_logger.d('自动刷新令牌(401 触发)');
|
||||
try {
|
||||
final response = await _apiClient.post('/auth/refresh', data: {
|
||||
'refresh_token': _currentToken!.refreshToken,
|
||||
});
|
||||
|
||||
final data = _extractData(response.data);
|
||||
final token = AuthToken.fromJson(data);
|
||||
|
||||
await _saveToken(token);
|
||||
_logger.i('自动刷新令牌成功');
|
||||
return token.accessToken;
|
||||
} catch (e) {
|
||||
_logger.w('自动刷新令牌失败: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 私有方法 =====
|
||||
|
||||
/// 从 API 响应中提取 data 字段
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
// - JournalEntry ↔ JournalEntryCollection(通过 toCollection/fromCollection)
|
||||
// - JournalElement ↔ JournalElementCollection(通过 toCollection/fromCollection)
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../local/isar_database.dart';
|
||||
import '../local/isar_database_native.dart';
|
||||
import '../local/collections/journal_entry_collection.dart';
|
||||
import '../local/collections/journal_element_collection.dart';
|
||||
import '../models/journal_entry.dart';
|
||||
@@ -20,7 +21,12 @@ import 'journal_repository.dart';
|
||||
|
||||
/// Isar 本地日记仓库 — JournalRepository 的 Isar 实现
|
||||
class IsarJournalRepository implements JournalRepository {
|
||||
Isar get _isar => IsarDatabase.instance!;
|
||||
Isar get _isar => IsarDatabase.instance;
|
||||
|
||||
final StreamController<void> _changeController = StreamController<void>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<void> get onJournalChanged => _changeController.stream;
|
||||
|
||||
// ============================================================
|
||||
// 日记 CRUD
|
||||
@@ -64,19 +70,18 @@ class IsarJournalRepository implements JournalRepository {
|
||||
query = query.and().classIdEqualTo(classId);
|
||||
}
|
||||
|
||||
// 按日期降序排列
|
||||
var results = await query
|
||||
.sortByDateEpochDesc()
|
||||
.findAll();
|
||||
|
||||
// 分页
|
||||
// 按日期降序排列 + DB 层分页(替代全量加载后 Dart 层 sublist)
|
||||
if (page != null && pageSize != null) {
|
||||
final start = (page - 1) * pageSize;
|
||||
if (start >= results.length) return [];
|
||||
final end = (start + pageSize).clamp(0, results.length);
|
||||
results = results.sublist(start, end);
|
||||
final offset = (page - 1) * pageSize;
|
||||
final results = await query
|
||||
.sortByDateEpochDesc()
|
||||
.offset(offset)
|
||||
.limit(pageSize)
|
||||
.findAll();
|
||||
return results.map(_fromCollection).toList();
|
||||
}
|
||||
|
||||
final results = await query.sortByDateEpochDesc().findAll();
|
||||
return results.map(_fromCollection).toList();
|
||||
}
|
||||
|
||||
@@ -108,6 +113,7 @@ class IsarJournalRepository implements JournalRepository {
|
||||
await _isar.writeTxn(() async {
|
||||
await _isar.journalEntryCollections.put(col);
|
||||
});
|
||||
_changeController.add(null);
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -144,6 +150,7 @@ class IsarJournalRepository implements JournalRepository {
|
||||
await _isar.journalEntryCollections.put(col);
|
||||
});
|
||||
|
||||
_changeController.add(null);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -177,6 +184,7 @@ class IsarJournalRepository implements JournalRepository {
|
||||
await _isar.journalElementCollections.put(el);
|
||||
}
|
||||
});
|
||||
_changeController.add(null);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -7,6 +7,9 @@ import '../models/journal_entry.dart';
|
||||
import '../models/journal_element.dart';
|
||||
import 'journal_repository.dart';
|
||||
|
||||
/// 空的变更通知流 — Web 平台 stub
|
||||
const _emptyStream = Stream<void>.empty();
|
||||
|
||||
/// Isar 本地日记仓库 — Web 空实现(抛出 UnsupportedError)
|
||||
class IsarJournalRepository implements JournalRepository {
|
||||
@override
|
||||
@@ -56,4 +59,7 @@ class IsarJournalRepository implements JournalRepository {
|
||||
@override
|
||||
Future<void> removeElement(String elementId) =>
|
||||
throw UnsupportedError('IsarJournalRepository 不支持 Web 平台');
|
||||
|
||||
@override
|
||||
Stream<void> get onJournalChanged => _emptyStream;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// - SyncEngine 负责协调本地和远程仓库之间的数据同步
|
||||
// - 内存实现 [InMemoryJournalRepository] 用于开发阶段快速迭代
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../models/journal_entry.dart';
|
||||
import '../models/journal_element.dart';
|
||||
|
||||
@@ -52,6 +54,9 @@ abstract class JournalRepository {
|
||||
|
||||
/// 从日记中移除元素
|
||||
Future<void> removeElement(String elementId);
|
||||
|
||||
/// 日记变更通知流 — create/update/delete 时发出信号
|
||||
Stream<void> get onJournalChanged;
|
||||
}
|
||||
|
||||
/// 内存实现 — 用于开发阶段快速迭代和单元测试
|
||||
@@ -61,6 +66,10 @@ abstract class JournalRepository {
|
||||
class InMemoryJournalRepository implements JournalRepository {
|
||||
final Map<String, JournalEntry> _journals = {};
|
||||
final Map<String, JournalElement> _elements = {};
|
||||
final StreamController<void> _changeController = StreamController<void>.broadcast();
|
||||
|
||||
@override
|
||||
Stream<void> get onJournalChanged => _changeController.stream;
|
||||
|
||||
@override
|
||||
Future<List<JournalEntry>> getJournals({
|
||||
@@ -122,6 +131,7 @@ class InMemoryJournalRepository implements JournalRepository {
|
||||
@override
|
||||
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||
_journals[entry.id] = entry;
|
||||
_changeController.add(null);
|
||||
return entry;
|
||||
}
|
||||
|
||||
@@ -145,6 +155,7 @@ class InMemoryJournalRepository implements JournalRepository {
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
_journals[entry.id] = updated;
|
||||
_changeController.add(null);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -154,6 +165,7 @@ class InMemoryJournalRepository implements JournalRepository {
|
||||
_journals.remove(id);
|
||||
// 同时移除关联元素
|
||||
_elements.removeWhere((_, e) => e.journalId == id);
|
||||
_changeController.add(null);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// 远程日记仓库 — 通过 API 客户端连接后端
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../models/journal_element.dart';
|
||||
import '../models/journal_entry.dart';
|
||||
import '../remote/api_client.dart';
|
||||
@@ -11,6 +13,10 @@ import 'journal_repository.dart';
|
||||
class RemoteJournalRepository implements JournalRepository {
|
||||
final ApiClient _api;
|
||||
|
||||
/// 变更通知流控制器 — 写操作成功后通知 UI 刷新
|
||||
final StreamController<void> _changeController =
|
||||
StreamController<void>.broadcast();
|
||||
|
||||
RemoteJournalRepository({required ApiClient api}) : _api = api;
|
||||
|
||||
@override
|
||||
@@ -39,7 +45,9 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
|
||||
final response = await _api.get('/diary/journals', queryParams: queryParams);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final items = body['data'] as List? ?? [];
|
||||
// 后端信封格式: { success, data: { data: [...], total, page, ... }, message }
|
||||
final envelope = body['data'] as Map<String, dynamic>? ?? {};
|
||||
final items = envelope['data'] as List? ?? [];
|
||||
return items
|
||||
.map((json) => JournalEntry.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
@@ -52,7 +60,9 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
'page_size': 1,
|
||||
});
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
return (body['total'] as int?) ?? 0;
|
||||
// 后端信封格式: { success, data: { data: [...], total, ... }, message }
|
||||
final envelope = body['data'] as Map<String, dynamic>? ?? {};
|
||||
return (envelope['total'] as int?) ?? 0;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -69,9 +79,14 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
|
||||
@override
|
||||
Future<JournalEntry> createJournal(JournalEntry entry) async {
|
||||
final response = await _api.post('/diary/journals', data: entry.toJson());
|
||||
// 后端 CreateJournalReq.date 是 NaiveDate(只有日期),需转换格式
|
||||
final json = entry.toJson();
|
||||
json['date'] = entry.date.toIso8601String().substring(0, 10);
|
||||
final response = await _api.post('/diary/journals', data: json);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||
final created = JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||
_changeController.add(null); // 通知 UI 刷新列表
|
||||
return created;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -89,12 +104,14 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
},
|
||||
);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
_changeController.add(null); // 通知 UI 刷新列表
|
||||
return JournalEntry.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteJournal(String id) async {
|
||||
await _api.delete('/diary/journals/$id');
|
||||
_changeController.add(null); // 通知 UI 刷新列表
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -131,6 +148,10 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
Future<void> removeElement(String elementId) async {
|
||||
await _api.delete('/diary/elements/$elementId');
|
||||
}
|
||||
|
||||
/// 变更通知流 — 写操作后广播,供 HomeBloc 自动刷新
|
||||
@override
|
||||
Stream<void> get onJournalChanged => _changeController.stream;
|
||||
}
|
||||
|
||||
/// API 异常封装 — 后端返回非 2xx 状态码时抛出
|
||||
|
||||
184
app/lib/data/services/content_filter_service.dart
Normal file
@@ -0,0 +1,184 @@
|
||||
// 内容安全过滤服务 — 本地敏感词检测
|
||||
//
|
||||
// 提供 checkText() 纯函数,用于在分享日记前检查文本内容是否包含敏感词。
|
||||
// 检测策略:精确匹配 + 谐音/形近/数字变体匹配。
|
||||
// 返回匹配列表,空列表表示内容安全。不自动屏蔽,由 UI 层决定提示方式。
|
||||
|
||||
import 'sensitive_words.dart';
|
||||
|
||||
/// 敏感词匹配结果
|
||||
class SensitiveWordMatch {
|
||||
/// 匹配到的敏感词原文
|
||||
final String word;
|
||||
|
||||
/// 所属分类
|
||||
final SensitiveCategory category;
|
||||
|
||||
/// 在预处理后文本中的起始位置
|
||||
final int position;
|
||||
|
||||
const SensitiveWordMatch({
|
||||
required this.word,
|
||||
required this.category,
|
||||
required this.position,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'SensitiveWordMatch("$word", ${category.label}, @$position)';
|
||||
}
|
||||
|
||||
/// 内容安全过滤服务
|
||||
///
|
||||
/// 纯静态方法,无状态,可安全在任何地方调用。
|
||||
/// 性能:~200 条词 × contains() 检查,<1ms 完成。
|
||||
class ContentFilterService {
|
||||
ContentFilterService._();
|
||||
|
||||
/// 检查文本内容,返回所有匹配到的敏感词。
|
||||
///
|
||||
/// 对输入文本进行预处理(去空格/特殊符号/零宽字符/小写化),
|
||||
/// 然后遍历全量词库做精确匹配和谐音变体匹配。
|
||||
/// 返回空列表表示内容安全。
|
||||
static List<SensitiveWordMatch> checkText(String text) {
|
||||
if (text.isEmpty) return const [];
|
||||
|
||||
final normalized = _normalize(text);
|
||||
if (normalized.isEmpty) return const [];
|
||||
|
||||
final matches = <SensitiveWordMatch>[];
|
||||
final seen = <String>{}; // 去重:同一词不重复报告
|
||||
|
||||
for (final entry in kSensitiveWords.entries) {
|
||||
final category = entry.key;
|
||||
|
||||
for (final word in entry.value) {
|
||||
// 精确匹配
|
||||
final pos = normalized.indexOf(word);
|
||||
if (pos >= 0 && seen.add(word)) {
|
||||
matches.add(SensitiveWordMatch(
|
||||
word: word,
|
||||
category: category,
|
||||
position: pos,
|
||||
));
|
||||
}
|
||||
|
||||
// 谐音/变体匹配 — 将词中每个有变体映射的字替换为变体,检查是否命中
|
||||
if (_matchesWithVariants(normalized, word)) {
|
||||
if (seen.add('variant:$word')) {
|
||||
matches.add(SensitiveWordMatch(
|
||||
word: word,
|
||||
category: category,
|
||||
position: -1, // 变体匹配无法精确定位
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 整词变体匹配 — kHomophoneVariants 中的多字 key(如 "卧槽")
|
||||
for (final variantEntry in kHomophoneVariants.entries) {
|
||||
final originalKey = variantEntry.key;
|
||||
if (originalKey.length <= 1) continue; // 单字已在上面处理
|
||||
|
||||
// 找到这个变体 key 对应的分类
|
||||
SensitiveCategory? foundCategory;
|
||||
for (final entry in kSensitiveWords.entries) {
|
||||
if (entry.value.contains(originalKey)) {
|
||||
foundCategory = entry.key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundCategory == null) continue;
|
||||
|
||||
for (final variant in variantEntry.value) {
|
||||
if (variant.isEmpty) continue;
|
||||
final vPos = normalized.indexOf(variant.toLowerCase());
|
||||
if (vPos >= 0) {
|
||||
final key = 'wvariant:$originalKey:$variant';
|
||||
if (seen.add(key)) {
|
||||
matches.add(SensitiveWordMatch(
|
||||
word: originalKey,
|
||||
category: foundCategory,
|
||||
position: vPos,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/// 检查文本是否包含敏感词(快捷方法)
|
||||
static bool hasSensitiveContent(String text) => checkText(text).isNotEmpty;
|
||||
|
||||
/// 获取匹配到的分类标签集合(用于 UI 展示)
|
||||
static Set<String> getMatchedCategories(List<SensitiveWordMatch> matches) {
|
||||
return matches.map((m) => m.category.label).toSet();
|
||||
}
|
||||
|
||||
/// 变体匹配:检查文本中是否出现了词的谐音/形近/数字变体版本
|
||||
///
|
||||
/// 将敏感词中每个有变体映射的字符逐一替换为变体,检查替换后的
|
||||
/// 字符串是否出现在文本中。例如 "去死" → 检查 "去4" 是否在文本中。
|
||||
static bool _matchesWithVariants(String normalizedText, String word) {
|
||||
final chars = word.split('');
|
||||
final variantChars = <List<String>>[];
|
||||
|
||||
for (final char in chars) {
|
||||
final variants = kHomophoneVariants[char];
|
||||
if (variants != null && variants.isNotEmpty) {
|
||||
// 原字符 + 所有变体
|
||||
variantChars.add([char, ...variants]);
|
||||
} else {
|
||||
variantChars.add([char]);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成所有变体组合并检查
|
||||
return _checkCombinations(normalizedText, variantChars, 0, '');
|
||||
}
|
||||
|
||||
/// 递归生成变体组合并检查文本
|
||||
static bool _checkCombinations(
|
||||
String text,
|
||||
List<List<String>> variantChars,
|
||||
int index,
|
||||
String current,
|
||||
) {
|
||||
if (index == variantChars.length) {
|
||||
return text.contains(current);
|
||||
}
|
||||
for (final char in variantChars[index]) {
|
||||
if (_checkCombinations(text, variantChars, index + 1, current + char)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 文本预处理:去除干扰字符,统一为小写
|
||||
///
|
||||
/// 1. 去除零宽字符(U+200B~U+200F, U+FEFF)
|
||||
/// 2. 去除空格、制表符、换行
|
||||
/// 3. 去除常见特殊符号(用于绕过的 @#$%^&* 等)
|
||||
/// 4. 转小写(对英文词有效)
|
||||
static String _normalize(String text) {
|
||||
final buffer = StringBuffer();
|
||||
for (final rune in text.runes) {
|
||||
// 跳过零宽字符
|
||||
if (rune >= 0x200B && rune <= 0x200F) continue;
|
||||
if (rune == 0xFEFF) continue;
|
||||
// 跳过空白
|
||||
if (rune == 0x20 || rune == 0x09 || rune == 0x0A || rune == 0x0D) continue;
|
||||
// 跳过常见绕过符号
|
||||
if (rune == 0x2E || rune == 0x2C || rune == 0x2D || rune == 0x5F) continue; // . , - _
|
||||
if (rune == 0x21 || rune == 0x40 || rune == 0x23 || rune == 0x24) continue; // ! @ # $
|
||||
if (rune == 0x25 || rune == 0x5E || rune == 0x26 || rune == 0x2A) continue; // % ^ & *
|
||||
if (rune == 0x7E || rune == 0x60) continue; // ~ `
|
||||
|
||||
buffer.writeCharCode(rune);
|
||||
}
|
||||
return buffer.toString().toLowerCase();
|
||||
}
|
||||
}
|
||||
141
app/lib/data/services/sensitive_words.dart
Normal file
@@ -0,0 +1,141 @@
|
||||
// 敏感词库 — 本地静态词库常量,面向小学生场景
|
||||
//
|
||||
// 分类:暴力、色情、欺凌、毒品、赌博、政治、诈骗、粗口
|
||||
// 每个分类包含基础词 + 谐音/形近/数字变体
|
||||
// 词库为 const 编译期常量,零运行时开销
|
||||
//
|
||||
// 注意:本词库仅为 Phase 1 基础覆盖,Phase 2 将接入服务端 AI + 可更新词库。
|
||||
|
||||
/// 敏感词分类
|
||||
enum SensitiveCategory {
|
||||
violence('暴力'),
|
||||
sexual('色情'),
|
||||
bullying('欺凌'),
|
||||
drugs('毒品'),
|
||||
gambling('赌博'),
|
||||
politics('政治敏感'),
|
||||
fraud('诈骗'),
|
||||
profanity('粗口');
|
||||
|
||||
const SensitiveCategory(this.label);
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// ============================================================
|
||||
/// 各分类敏感词
|
||||
/// ============================================================
|
||||
|
||||
/// 暴力类
|
||||
const _violenceWords = [
|
||||
// 直接暴力
|
||||
'杀人', '砍人', '捅人', '打死', '打死你', '弄死', '弄死你',
|
||||
'揍你', '揍死', '打死他', '砍死', '捅死',
|
||||
'杀了他', '打死他', '砍了他', '捅了他',
|
||||
'去死', '你去死', '怎么不去死',
|
||||
'割腕', '割脖子', '跳楼', '上吊',
|
||||
// 武器
|
||||
'炸弹', '手枪', '步枪', '子弹', '刀杀',
|
||||
// 自残/伤害暗示
|
||||
'自杀', '自残', '不想活',
|
||||
];
|
||||
|
||||
/// 色情类
|
||||
const _sexualWords = [
|
||||
'色情', '裸体', '裸照', '黄色', '黄片',
|
||||
'做爱', '性行为', '性交', '强奸', '强暴',
|
||||
'猥亵', '性骚扰', '偷拍',
|
||||
'发情', '骚货', '贱人',
|
||||
];
|
||||
|
||||
/// 欺凌类
|
||||
const _bullyingWords = [
|
||||
'废物', '垃圾', '蠢货', '白痴', '弱智',
|
||||
'傻子', '笨蛋', '猪头', '丑八怪',
|
||||
'滚开', '滚蛋', '闭嘴', '别烦我',
|
||||
'讨厌鬼', '没人要', '没朋友',
|
||||
'不和你玩', '不要和你玩',
|
||||
'大家不要理', '孤立',
|
||||
'偷东西', '小偷',
|
||||
];
|
||||
|
||||
/// 毒品类
|
||||
const _drugsWords = [
|
||||
'毒品', '吸毒', '贩毒', '大麻', '海洛因',
|
||||
'冰毒', '摇头丸', '可卡因', '吗啡',
|
||||
'鸦片', 'K粉', '安非他命',
|
||||
'上瘾', '毒瘾',
|
||||
];
|
||||
|
||||
/// 赌博类
|
||||
const _gamblingWords = [
|
||||
'赌博', '赌钱', '下注', '押注', '赌场',
|
||||
'买彩票', '时时彩', '六合彩',
|
||||
'百家乐', '老虎机', '扑克赌',
|
||||
'赌债', '借钱赌',
|
||||
];
|
||||
|
||||
/// 政治敏感类
|
||||
const _politicsWords = [
|
||||
'反动', '颠覆', '分裂', '暴动', '造反',
|
||||
'推翻', '政变', '游行示威',
|
||||
];
|
||||
|
||||
/// 诈骗类
|
||||
const _fraudWords = [
|
||||
'诈骗', '骗钱', '骗密码', '骗账号',
|
||||
'中奖了', '恭喜中奖', '免费领取',
|
||||
'点击链接领奖', '转账给我',
|
||||
'刷单', '兼职刷单', '高薪兼职',
|
||||
'传销', '拉人头',
|
||||
];
|
||||
|
||||
/// 粗口类
|
||||
const _profanityWords = [
|
||||
'操你', '妈的', '他妈', '去你的', '狗屎',
|
||||
'滚', '屁', '放屁', '扯淡', '王八蛋',
|
||||
'混蛋', '靠', '我去', '卧槽',
|
||||
'我靠', '我擦',
|
||||
];
|
||||
|
||||
/// 全量词库:分类 → 词列表
|
||||
const Map<SensitiveCategory, List<String>> kSensitiveWords = {
|
||||
SensitiveCategory.violence: _violenceWords,
|
||||
SensitiveCategory.sexual: _sexualWords,
|
||||
SensitiveCategory.bullying: _bullyingWords,
|
||||
SensitiveCategory.drugs: _drugsWords,
|
||||
SensitiveCategory.gambling: _gamblingWords,
|
||||
SensitiveCategory.politics: _politicsWords,
|
||||
SensitiveCategory.fraud: _fraudWords,
|
||||
SensitiveCategory.profanity: _profanityWords,
|
||||
};
|
||||
|
||||
/// ============================================================
|
||||
/// 谐音/形近/数字变体映射
|
||||
/// ============================================================
|
||||
|
||||
/// 原词 → 变体列表
|
||||
///
|
||||
/// 变体检测在预处理后的文本上运行,可以捕获常见的绕过手法:
|
||||
/// - 数字谐音: "死" → "4"
|
||||
/// - 形近替换: "傻" → "纱"
|
||||
/// - 拼音缩写: "牛逼" → "nb"
|
||||
const Map<String, List<String>> kHomophoneVariants = {
|
||||
// 暴力相关
|
||||
'死': ['4', '④', '亖', '☠'],
|
||||
'杀': ['莎', '纱', '沙'],
|
||||
'砍': ['砍人'],
|
||||
'捅': ['捅人'],
|
||||
// 欺凌相关
|
||||
'傻': ['纱', '沙', '啥'],
|
||||
'笨': [], // 无实际变体
|
||||
'蠢': ['春'],
|
||||
'废物': ['费物', '废无'],
|
||||
'垃圾': ['拉吉', '垃 圾'],
|
||||
// 粗口相关
|
||||
'操': ['草', '艹', '槽'],
|
||||
'卧槽': ['我槽', '我草', 'wc', 'WC', 'Wc'],
|
||||
'我靠': ['我 k', '我K'],
|
||||
// 欺凌
|
||||
'滚': ['衮'],
|
||||
'屁': ['辟'],
|
||||
};
|
||||
@@ -20,8 +20,9 @@ import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
import '../local/isar_database.dart';
|
||||
import '../local/isar_database_native.dart';
|
||||
import '../local/collections/pending_operation_collection.dart';
|
||||
import '../models/sync_models.dart';
|
||||
import '../remote/api_client.dart';
|
||||
|
||||
/// 同步操作类型
|
||||
@@ -136,21 +137,86 @@ class SyncEngine {
|
||||
bool get isSyncing => _status == SyncStatus.syncing;
|
||||
|
||||
/// 添加待同步操作到队列尾部
|
||||
///
|
||||
/// 合并策略(8b-N01):同一资源(endpoint 相同)的连续操作只保留最新一条。
|
||||
/// create+update → create(使用最新数据)
|
||||
/// update+update → update(使用最新数据)
|
||||
/// update+delete → delete(资源最终被删除)
|
||||
/// create+delete → 取消(资源从未存在)
|
||||
///
|
||||
/// 私密日记(is_private=true)不入队 — 仅保存在本地,不上传后端。
|
||||
void enqueue(PendingOperation operation) {
|
||||
_pendingQueue.add(operation);
|
||||
// 防御性检查:私密日记不入队
|
||||
final isPrivate = operation.data['is_private'] as bool? ?? false;
|
||||
if (isPrivate) {
|
||||
debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}');
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找队列中同一资源的最后一个操作
|
||||
PendingOperation? existing;
|
||||
for (final op in _pendingQueue) {
|
||||
if (op.endpoint == operation.endpoint) {
|
||||
existing = op;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
final merged = _mergeOperations(existing, operation);
|
||||
_pendingQueue.remove(existing);
|
||||
if (merged != null) {
|
||||
_pendingQueue.add(merged);
|
||||
}
|
||||
// merged == null → create+delete 取消,不添加
|
||||
} else {
|
||||
_pendingQueue.add(operation);
|
||||
}
|
||||
|
||||
if (_status == SyncStatus.idle) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量添加待同步操作
|
||||
/// 批量添加待同步操作(每个操作独立走合并逻辑)
|
||||
void enqueueAll(List<PendingOperation> operations) {
|
||||
for (final op in operations) {
|
||||
_pendingQueue.add(op);
|
||||
enqueue(op);
|
||||
}
|
||||
if (_status == SyncStatus.idle && _pendingQueue.isNotEmpty) {
|
||||
_status = SyncStatus.paused;
|
||||
}
|
||||
|
||||
/// 合并同一资源的两个操作
|
||||
///
|
||||
/// 返回合并后的操作,或 null 表示应取消(create+delete)。
|
||||
PendingOperation? _mergeOperations(
|
||||
PendingOperation existing,
|
||||
PendingOperation incoming,
|
||||
) {
|
||||
// create + delete → 取消(资源从未同步到服务端)
|
||||
if (existing.type == SyncOperationType.create &&
|
||||
incoming.type == SyncOperationType.delete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// create + update → create(使用最新数据)
|
||||
if (existing.type == SyncOperationType.create &&
|
||||
incoming.type == SyncOperationType.update) {
|
||||
return existing.copyWith(data: incoming.data, version: incoming.version);
|
||||
}
|
||||
|
||||
// update + update → update(使用最新数据)
|
||||
if (existing.type == SyncOperationType.update &&
|
||||
incoming.type == SyncOperationType.update) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// update + delete → delete
|
||||
if (existing.type == SyncOperationType.update &&
|
||||
incoming.type == SyncOperationType.delete) {
|
||||
return incoming;
|
||||
}
|
||||
|
||||
// 其他组合(delete+create, create+create 等)不合并
|
||||
return incoming;
|
||||
}
|
||||
|
||||
/// 检查网络状态并尝试同步全部待处理操作
|
||||
@@ -221,6 +287,79 @@ class SyncEngine {
|
||||
await persistPendingQueue();
|
||||
}
|
||||
|
||||
/// 批量同步 — 使用 POST /diary/sync 端点一次性提交所有变更
|
||||
///
|
||||
/// 将队列中的 PendingOperation 转换为 SyncChange 列表,
|
||||
/// 调用 Rust sync_handler 批量处理,获取服务端变更和冲突。
|
||||
/// 成功后清空队列;失败时保留队列供重试。
|
||||
Future<SyncResp?> tryBatchSync({DateTime? lastSyncTime}) async {
|
||||
if (_status == SyncStatus.syncing) return null;
|
||||
if (_pendingQueue.isEmpty) {
|
||||
_status = SyncStatus.idle;
|
||||
return null;
|
||||
}
|
||||
|
||||
_status = SyncStatus.syncing;
|
||||
_lastError = null;
|
||||
|
||||
try {
|
||||
// 转换: PendingOperation → SyncChange
|
||||
final changes = _pendingQueue.map(_operationToSyncChange).toList();
|
||||
|
||||
final req = SyncReq(
|
||||
lastSyncTime: lastSyncTime,
|
||||
changes: changes,
|
||||
);
|
||||
|
||||
final resp = await _apiClient.sync(req);
|
||||
|
||||
// 处理冲突 — 将冲突的操作保留在队列中
|
||||
if (resp.conflicts.isNotEmpty) {
|
||||
final conflictIds = resp.conflicts.map((c) => c.journalId).toSet();
|
||||
// 移除已成功同步的非冲突操作,保留冲突操作
|
||||
_pendingQueue.removeWhere(
|
||||
(op) => !conflictIds.contains(op.id),
|
||||
);
|
||||
_lastError = '${resp.conflicts.length} 个操作存在版本冲突';
|
||||
} else {
|
||||
// 全部成功,清空队列
|
||||
_pendingQueue.clear();
|
||||
}
|
||||
|
||||
_status = _pendingQueue.isEmpty ? SyncStatus.idle : SyncStatus.paused;
|
||||
await persistPendingQueue();
|
||||
|
||||
return resp;
|
||||
} on OfflineException {
|
||||
_status = SyncStatus.paused;
|
||||
_lastError = '同步中断:网络不可用';
|
||||
return null;
|
||||
} catch (e) {
|
||||
_status = SyncStatus.error;
|
||||
_lastError = '批量同步失败: $e';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// PendingOperation → SyncChange 转换
|
||||
SyncChange _operationToSyncChange(PendingOperation op) {
|
||||
switch (op.type) {
|
||||
case SyncOperationType.create:
|
||||
return SyncChangeCreateJournal(data: op.data);
|
||||
case SyncOperationType.update:
|
||||
return SyncChangeUpdateJournal(
|
||||
id: op.id,
|
||||
version: op.version,
|
||||
data: op.data,
|
||||
);
|
||||
case SyncOperationType.delete:
|
||||
return SyncChangeDeleteJournal(
|
||||
id: op.id,
|
||||
version: op.version,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 执行单个同步操作
|
||||
Future<void> _executeOperation(PendingOperation operation) async {
|
||||
switch (operation.type) {
|
||||
@@ -256,7 +395,7 @@ class SyncEngine {
|
||||
/// 在 app 退出、isolate 暂停、或同步完成后调用。
|
||||
Future<void> persistPendingQueue() async {
|
||||
if (!IsarDatabase.isAvailable) return;
|
||||
final isar = IsarDatabase.instance!;
|
||||
final isar = IsarDatabase.instance;
|
||||
final ops = snapshot;
|
||||
|
||||
await isar.writeTxn(() async {
|
||||
@@ -277,7 +416,7 @@ class SyncEngine {
|
||||
/// Web 平台上 Isar 不可用,跳过恢复。
|
||||
Future<void> restorePendingQueue() async {
|
||||
if (!IsarDatabase.isAvailable) return;
|
||||
final isar = IsarDatabase.instance!;
|
||||
final isar = IsarDatabase.instance;
|
||||
final persisted = await isar.pendingOperationCollections
|
||||
.where()
|
||||
.anyIsarId()
|
||||
|
||||
@@ -90,7 +90,15 @@ class SyncEngine {
|
||||
int get pendingCount => _pendingQueue.length;
|
||||
bool get isSyncing => _status == SyncStatus.syncing;
|
||||
|
||||
/// 入队待同步操作 — 私密日记(is_private=true)不入队
|
||||
void enqueue(PendingOperation operation) {
|
||||
// 防御性检查:私密日记仅保存在本地,不上传后端
|
||||
final isPrivate = operation.data['is_private'] as bool? ?? false;
|
||||
if (isPrivate) {
|
||||
debugPrint('SyncEngine.enqueue: 跳过私密日记 ${operation.id}');
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingQueue.add(operation);
|
||||
if (_status == SyncStatus.idle) {
|
||||
_status = SyncStatus.paused;
|
||||
|
||||
@@ -34,6 +34,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
on<LoginRequested>(_onLoginRequested);
|
||||
on<RegisterRequested>(_onRegisterRequested);
|
||||
on<RoleSelected>(_onRoleSelected);
|
||||
on<ParentalConsentAccepted>(_onParentalConsentAccepted);
|
||||
on<ClassCodeSubmitted>(_onClassCodeSubmitted);
|
||||
on<LogoutRequested>(_onLogoutRequested);
|
||||
on<TokenRefreshed>(_onTokenRefreshed);
|
||||
@@ -124,16 +125,38 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final currentState = state;
|
||||
if (currentState is! Authenticated) return;
|
||||
|
||||
// 学生角色需要先经过家长同意确认(PIPL 第28条)
|
||||
final needsParentalConsent = event.role == UserRoleType.student;
|
||||
|
||||
// 根据角色决定下一步
|
||||
final needsClassCode =
|
||||
event.role == UserRoleType.student || event.role == UserRoleType.parent;
|
||||
|
||||
emit(currentState.copyWith(
|
||||
needsRoleSelection: false,
|
||||
needsClassCode: needsClassCode,
|
||||
needsParentalConsent: needsParentalConsent,
|
||||
needsClassCode: needsClassCode && !needsParentalConsent,
|
||||
selectedRole: event.role,
|
||||
));
|
||||
|
||||
_logger.i('角色选择: ${event.role.name}, 需要班级码: $needsClassCode');
|
||||
_logger.i('角色选择: ${event.role.name}, 需要家长同意: $needsParentalConsent');
|
||||
}
|
||||
|
||||
/// 家长/监护人同意信息收集(PIPL 合规)
|
||||
Future<void> _onParentalConsentAccepted(
|
||||
ParentalConsentAccepted event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
if (currentState is! Authenticated) return;
|
||||
|
||||
_logger.i('家长同意已确认: ${event.consentAt}');
|
||||
|
||||
emit(currentState.copyWith(
|
||||
needsParentalConsent: false,
|
||||
needsClassCode: true,
|
||||
parentalConsentAt: event.consentAt,
|
||||
));
|
||||
}
|
||||
|
||||
/// 班级码加入
|
||||
|
||||
@@ -43,6 +43,12 @@ final class RoleSelected extends AuthEvent {
|
||||
const RoleSelected(this.role);
|
||||
}
|
||||
|
||||
/// 家长/监护人同意 PIPL 信息收集(审计 S-03)
|
||||
final class ParentalConsentAccepted extends AuthEvent {
|
||||
final DateTime consentAt;
|
||||
const ParentalConsentAccepted(this.consentAt);
|
||||
}
|
||||
|
||||
/// 班级码加入(学生/家长加入班级)
|
||||
final class ClassCodeSubmitted extends AuthEvent {
|
||||
final String classCode;
|
||||
|
||||
@@ -37,6 +37,9 @@ final class Authenticated extends AuthState {
|
||||
/// 是否需要角色选择(新注册用户还没有角色)
|
||||
final bool needsRoleSelection;
|
||||
|
||||
/// 是否需要家长/监护人同意(PIPL 第28条 — 学生角色)
|
||||
final bool needsParentalConsent;
|
||||
|
||||
/// 是否需要班级码加入(学生/家长角色)
|
||||
final bool needsClassCode;
|
||||
|
||||
@@ -46,27 +49,43 @@ final class Authenticated extends AuthState {
|
||||
/// 班级码验证错误信息
|
||||
final String? classCodeError;
|
||||
|
||||
/// 已选择的角色(角色选择后暂存)
|
||||
final UserRoleType? selectedRole;
|
||||
|
||||
/// 家长同意时间戳
|
||||
final DateTime? parentalConsentAt;
|
||||
|
||||
const Authenticated({
|
||||
required this.user,
|
||||
this.needsRoleSelection = false,
|
||||
this.needsParentalConsent = false,
|
||||
this.needsClassCode = false,
|
||||
this.isLoading = false,
|
||||
this.classCodeError,
|
||||
this.selectedRole,
|
||||
this.parentalConsentAt,
|
||||
});
|
||||
|
||||
Authenticated copyWith({
|
||||
User? user,
|
||||
bool? needsRoleSelection,
|
||||
bool? needsParentalConsent,
|
||||
bool? needsClassCode,
|
||||
bool? isLoading,
|
||||
String? classCodeError,
|
||||
UserRoleType? selectedRole,
|
||||
DateTime? parentalConsentAt,
|
||||
}) =>
|
||||
Authenticated(
|
||||
user: user ?? this.user,
|
||||
needsRoleSelection: needsRoleSelection ?? this.needsRoleSelection,
|
||||
needsParentalConsent:
|
||||
needsParentalConsent ?? this.needsParentalConsent,
|
||||
needsClassCode: needsClassCode ?? this.needsClassCode,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
classCodeError: classCodeError,
|
||||
selectedRole: selectedRole ?? this.selectedRole,
|
||||
parentalConsentAt: parentalConsentAt ?? this.parentalConsentAt,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +171,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 装饰圆圈
|
||||
Positioned(left: 20, top: -10, child: _decorCircle(60, AppColors.accent, 0.15)),
|
||||
@@ -180,6 +181,7 @@ class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMix
|
||||
Positioned(left: -10, top: 50, child: _decorCircle(25, AppColors.secondary, 0.13)),
|
||||
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Logo — 自定义笔记本图标
|
||||
Container(
|
||||
|
||||
254
app/lib/features/auth/views/parental_consent_page.dart
Normal file
@@ -0,0 +1,254 @@
|
||||
// 家长同意确认页面 — PIPL 第28条合规
|
||||
//
|
||||
// 未满 14 岁用户选择"学生"角色后,必须经过家长/监护人确认。
|
||||
// 页面展示隐私政策要点,要求家长勾选同意并确认。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/constants/design_tokens.dart';
|
||||
import '../../../core/theme/app_radius.dart';
|
||||
import '../bloc/auth_bloc.dart';
|
||||
|
||||
/// 家长同意确认页面
|
||||
class ParentalConsentPage extends StatefulWidget {
|
||||
const ParentalConsentPage({super.key});
|
||||
|
||||
@override
|
||||
State<ParentalConsentPage> createState() => _ParentalConsentPageState();
|
||||
}
|
||||
|
||||
class _ParentalConsentPageState extends State<ParentalConsentPage> {
|
||||
bool _consentGiven = false;
|
||||
bool _privacyPolicyAccepted = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final canProceed = _consentGiven && _privacyPolicyAccepted;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
title: const Text('家长/监护人确认'),
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题
|
||||
Icon(
|
||||
Icons.shield_rounded,
|
||||
size: 48,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
Text(
|
||||
'儿童个人信息保护',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
Text(
|
||||
'根据《中华人民共和国个人信息保护法》第28条,'
|
||||
'未满14周岁未成年人的个人信息处理需要取得父母或监护人的同意。',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
|
||||
// 信息收集说明卡片
|
||||
_buildInfoCard(
|
||||
context,
|
||||
icon: Icons.info_outline_rounded,
|
||||
title: '我们会收集哪些信息',
|
||||
items: const [
|
||||
'昵称和年级(不收集真实姓名和身份证号)',
|
||||
'日记内容和手写笔画',
|
||||
'心情标签和照片',
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
|
||||
// 用途说明卡片
|
||||
_buildInfoCard(
|
||||
context,
|
||||
icon: Icons.security_rounded,
|
||||
title: '信息如何保护',
|
||||
items: const [
|
||||
'所有数据加密存储和传输',
|
||||
'仅用于日记记录和班级互动',
|
||||
'不会用于商业广告或分享给第三方',
|
||||
'您可以随时查阅、更正或删除孩子数据',
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
|
||||
// 同意复选框
|
||||
_buildCheckbox(
|
||||
value: _privacyPolicyAccepted,
|
||||
onChanged: (v) =>
|
||||
setState(() => _privacyPolicyAccepted = v ?? false),
|
||||
text: '我已阅读并同意《暖记隐私政策》和《儿童个人信息保护规则》',
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing4),
|
||||
|
||||
_buildCheckbox(
|
||||
value: _consentGiven,
|
||||
onChanged: (v) => setState(() => _consentGiven = v ?? false),
|
||||
text: '我是该用户的家长/监护人,同意暖记收集和处理上述信息',
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing32),
|
||||
|
||||
// 确认按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
onPressed: canProceed ? _onConfirm : null,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: DesignTokens.spacing12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
),
|
||||
),
|
||||
child: const Text('确认同意,继续'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
|
||||
// 拒绝按钮
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => context.pop(),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: DesignTokens.spacing12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.pill),
|
||||
),
|
||||
),
|
||||
child: const Text('不同意,返回'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required List<String> items,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: DesignTokens.spacing8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
...items.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: DesignTokens.spacing4,
|
||||
left: DesignTokens.spacing12,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'• ',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCheckbox({
|
||||
required bool value,
|
||||
required ValueChanged<bool?> onChanged,
|
||||
required String text,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return InkWell(
|
||||
onTap: () => onChanged(!value),
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: DesignTokens.spacing4,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
const SizedBox(width: DesignTokens.spacing4),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
text,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 确认同意 — 发出事件继续注册流程
|
||||
void _onConfirm() {
|
||||
final consentAt = DateTime.now();
|
||||
context.read<AuthBloc>().add(ParentalConsentAccepted(consentAt));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '暂无主题布置',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
78
app/lib/features/discover/bloc/discover_bloc.dart
Normal 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 '请稍后重试';
|
||||
}
|
||||
}
|
||||
16
app/lib/features/discover/bloc/discover_event.dart
Normal 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();
|
||||
}
|
||||
30
app/lib/features/discover/bloc/discover_state.dart
Normal 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);
|
||||
}
|
||||
160
app/lib/features/discover/models/discover_models.dart
Normal 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 赞';
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -99,6 +99,16 @@ class ElementSelected extends EditorEvent {
|
||||
ElementSelected(this.elementId);
|
||||
}
|
||||
|
||||
/// 图层顺序调整方向
|
||||
enum LayerChange { bringToFront, sendToBack }
|
||||
|
||||
/// 调整元素图层顺序
|
||||
class ElementLayerChanged extends EditorEvent {
|
||||
final String elementId;
|
||||
final LayerChange change;
|
||||
ElementLayerChanged({required this.elementId, required this.change});
|
||||
}
|
||||
|
||||
// --- 工具栏事件 ---
|
||||
|
||||
/// 切换活动工具
|
||||
@@ -107,6 +117,12 @@ class ToolChanged extends EditorEvent {
|
||||
ToolChanged(this.tool);
|
||||
}
|
||||
|
||||
/// 再次点击已激活的工具 — 重新弹出设置面板
|
||||
class ToolReactivated extends EditorEvent {
|
||||
final EditorTool tool;
|
||||
ToolReactivated(this.tool);
|
||||
}
|
||||
|
||||
/// 加载已有元素
|
||||
class ElementsLoaded extends EditorEvent {
|
||||
final List<JournalElement> elements;
|
||||
@@ -227,6 +243,9 @@ class EditorState {
|
||||
final bool isDirty;
|
||||
final DateTime? lastSavedAt;
|
||||
|
||||
// 工具重新激活时间戳(用于驱动面板重新弹出)
|
||||
final int toolReactivatedAt;
|
||||
|
||||
const EditorState({
|
||||
this.strokes = const [],
|
||||
this.redoStack = const [],
|
||||
@@ -243,6 +262,7 @@ class EditorState {
|
||||
this.title = '',
|
||||
this.isDirty = false,
|
||||
this.lastSavedAt,
|
||||
this.toolReactivatedAt = 0,
|
||||
});
|
||||
|
||||
EditorState copyWith({
|
||||
@@ -261,6 +281,7 @@ class EditorState {
|
||||
String? title,
|
||||
bool? isDirty,
|
||||
DateTime? lastSavedAt,
|
||||
int? toolReactivatedAt,
|
||||
}) =>
|
||||
EditorState(
|
||||
strokes: strokes ?? this.strokes,
|
||||
@@ -279,6 +300,7 @@ class EditorState {
|
||||
title: title ?? this.title,
|
||||
isDirty: isDirty ?? this.isDirty,
|
||||
lastSavedAt: lastSavedAt ?? this.lastSavedAt,
|
||||
toolReactivatedAt: toolReactivatedAt ?? this.toolReactivatedAt,
|
||||
);
|
||||
|
||||
/// 是否处于手写模式
|
||||
@@ -323,6 +345,7 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
on<ElementResized>(_onElementResized);
|
||||
on<ElementRotated>(_onElementRotated);
|
||||
on<ElementSelected>(_onElementSelected);
|
||||
on<ElementLayerChanged>(_onElementLayerChanged);
|
||||
on<ElementsLoaded>(_onElementsLoaded);
|
||||
|
||||
// 日记加载事件
|
||||
@@ -330,6 +353,7 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
|
||||
// 工具栏事件
|
||||
on<ToolChanged>(_onToolChanged);
|
||||
on<ToolReactivated>(_onToolReactivated);
|
||||
|
||||
// 标签/心情/标题事件
|
||||
on<TagAdded>(_onTagAdded);
|
||||
@@ -479,6 +503,36 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
));
|
||||
}
|
||||
|
||||
/// 调整元素图层顺序 — 置顶或置底
|
||||
void _onElementLayerChanged(
|
||||
ElementLayerChanged event,
|
||||
Emitter<EditorState> emit,
|
||||
) {
|
||||
final elements = List<JournalElement>.from(state.elements);
|
||||
final index = elements.indexWhere((e) => e.id == event.elementId);
|
||||
if (index == -1) return;
|
||||
|
||||
switch (event.change) {
|
||||
case LayerChange.bringToFront:
|
||||
// 设为最大 zIndex + 1
|
||||
final maxZ = elements.fold<int>(
|
||||
0,
|
||||
(max, e) => e.zIndex > max ? e.zIndex : max,
|
||||
);
|
||||
elements[index] = elements[index].copyWith(zIndex: maxZ + 1);
|
||||
case LayerChange.sendToBack:
|
||||
// 设为最小 zIndex - 1
|
||||
final minZ = elements.fold<int>(
|
||||
0,
|
||||
(min, e) => e.zIndex < min ? e.zIndex : min,
|
||||
);
|
||||
elements[index] = elements[index].copyWith(zIndex: minZ - 1);
|
||||
}
|
||||
|
||||
emit(state.copyWith(elements: elements, isDirty: true));
|
||||
_scheduleAutoSave();
|
||||
}
|
||||
|
||||
void _onElementsLoaded(ElementsLoaded event, Emitter<EditorState> emit) {
|
||||
emit(state.copyWith(elements: event.elements));
|
||||
}
|
||||
@@ -514,6 +568,13 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
));
|
||||
}
|
||||
|
||||
void _onToolReactivated(ToolReactivated event, Emitter<EditorState> emit) {
|
||||
// 不改变 activeTool,仅递增时间戳驱动 UI 层重新弹出面板
|
||||
emit(state.copyWith(
|
||||
toolReactivatedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 标签/心情/标题事件处理
|
||||
// ============================================================
|
||||
|
||||
@@ -37,12 +37,26 @@ import '../widgets/brush_panel.dart';
|
||||
import '../widgets/dot_grid_painter.dart';
|
||||
|
||||
/// 手账编辑器页面
|
||||
class EditorPage extends StatelessWidget {
|
||||
class EditorPage extends StatefulWidget {
|
||||
final String? journalId;
|
||||
final String? templateId;
|
||||
|
||||
const EditorPage({super.key, this.journalId, this.templateId});
|
||||
|
||||
@override
|
||||
State<EditorPage> createState() => _EditorPageState();
|
||||
}
|
||||
|
||||
class _EditorPageState extends State<EditorPage> {
|
||||
/// 跟踪已保存的日记 ID — 新建日记首次保存后赋值
|
||||
String? _savedJournalId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_savedJournalId = widget.journalId;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 从 Provider 树获取 JournalRepository(IsarJournalRepository)
|
||||
@@ -50,10 +64,6 @@ class EditorPage extends StatelessWidget {
|
||||
// 从 Provider 树获取 SyncEngine(同步到后端)
|
||||
final syncEngine = context.read<SyncEngine>();
|
||||
|
||||
// 可变闭包变量:跟踪已保存的日记 ID
|
||||
// 新建日记首次保存后赋值,后续自动更新使用此 ID
|
||||
String? savedJournalId = journalId;
|
||||
|
||||
return BlocProvider(
|
||||
create: (_) => EditorBloc(
|
||||
onSave: (state) async {
|
||||
@@ -66,7 +76,7 @@ class EditorPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
await _persistState(
|
||||
repo, state, (id) => savedJournalId = id, savedJournalId,
|
||||
repo, state, (id) => _savedJournalId = id, _savedJournalId,
|
||||
syncEngine: syncEngine,
|
||||
authorId: authorId,
|
||||
);
|
||||
@@ -76,12 +86,12 @@ class EditorPage extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
child: _EditorView(
|
||||
journalId: journalId,
|
||||
templateId: templateId,
|
||||
savedJournalId: savedJournalId,
|
||||
journalId: widget.journalId,
|
||||
templateId: widget.templateId,
|
||||
savedJournalId: _savedJournalId,
|
||||
repo: repo,
|
||||
onSaveComplete: () {
|
||||
_showShareSheetAndNavigate(context, repo, savedJournalId);
|
||||
_showShareSheetAndNavigate(context, repo, _savedJournalId);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -111,43 +121,59 @@ class EditorPage extends StatelessWidget {
|
||||
title: '${now.month}月${now.day}日的日记',
|
||||
date: now,
|
||||
);
|
||||
await repo.createJournal(entry);
|
||||
setId(entry.id);
|
||||
|
||||
// 保存笔画
|
||||
// 保存到仓库(Web=远程API,原生=Isar本地)
|
||||
// 远程仓库返回服务端生成的 ID,必须使用返回值
|
||||
final saved = await repo.createJournal(entry);
|
||||
final journalId = saved.id;
|
||||
setId(journalId);
|
||||
|
||||
// 保存笔画 — 使用 saved.id(与仓库一致)
|
||||
if (state.strokes.isNotEmpty) {
|
||||
await _saveStrokesAsElement(repo, entry.id, state.strokes);
|
||||
await _saveStrokesAsElement(repo, journalId, state.strokes);
|
||||
}
|
||||
|
||||
// 保存其他元素
|
||||
for (final element in state.elements) {
|
||||
await repo.addElement(element.copyWith(journalId: entry.id));
|
||||
await repo.addElement(element.copyWith(journalId: journalId));
|
||||
}
|
||||
|
||||
// 入队 SyncEngine 等待同步到后端
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: entry.id,
|
||||
type: SyncOperationType.create,
|
||||
endpoint: '/diary/journals',
|
||||
data: entry.toJson(),
|
||||
version: entry.version,
|
||||
createdAt: now,
|
||||
));
|
||||
// 仅非私密日记入队 SyncEngine 等待同步到后端
|
||||
// 私密日记(is_private=true)仅保存在本地,不上传
|
||||
if (!saved.isPrivate) {
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: journalId,
|
||||
type: SyncOperationType.create,
|
||||
endpoint: '/diary/journals',
|
||||
data: saved.toJson(),
|
||||
version: saved.version,
|
||||
createdAt: now,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// --- 更新已有日记 ---
|
||||
final existing = await repo.getJournal(savedJournalId);
|
||||
if (existing != null) {
|
||||
await repo.updateJournal(existing);
|
||||
// 将编辑器当前状态合并到已有日记中
|
||||
final updated = existing.copyWith(
|
||||
title: state.title.isNotEmpty ? state.title : existing.title,
|
||||
mood: state.selectedMood,
|
||||
tags: state.tags.isNotEmpty ? state.tags : existing.tags,
|
||||
updatedAt: now,
|
||||
);
|
||||
await repo.updateJournal(updated);
|
||||
|
||||
// 入队 SyncEngine 等待同步到后端
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: existing.id,
|
||||
type: SyncOperationType.update,
|
||||
endpoint: '/diary/journals/${existing.id}',
|
||||
data: existing.toJson(),
|
||||
version: existing.version,
|
||||
createdAt: now,
|
||||
));
|
||||
// 仅非私密日记入队 SyncEngine
|
||||
if (!updated.isPrivate) {
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: updated.id,
|
||||
type: SyncOperationType.update,
|
||||
endpoint: '/diary/journals/${updated.id}',
|
||||
data: updated.toJson(),
|
||||
version: updated.version,
|
||||
createdAt: now,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// 更新笔画
|
||||
@@ -198,6 +224,11 @@ class EditorPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 显示分享面板并在用户选择后导航
|
||||
///
|
||||
/// 分享行为:
|
||||
/// - 分享到班级/所有人 → is_private=false + shared_to_class=对应值
|
||||
/// - 仅自己可见 → is_private=true,不上传到后端
|
||||
/// - 首次将私密日记变为公开时,入队 SyncEngine create 操作上传
|
||||
static Future<void> _showShareSheetAndNavigate(
|
||||
BuildContext context,
|
||||
JournalRepository repo,
|
||||
@@ -229,14 +260,41 @@ class EditorPage extends StatelessWidget {
|
||||
classId: userClassId,
|
||||
className: userClassName,
|
||||
onDecision: (shareToClass) async {
|
||||
// 更新日记的 sharedToClass 状态
|
||||
if (savedJournalId != null) {
|
||||
try {
|
||||
final entry = await repo.getJournal(savedJournalId);
|
||||
if (entry != null) {
|
||||
await repo.updateJournal(
|
||||
entry.copyWith(sharedToClass: shareToClass),
|
||||
final wasPrivate = entry.isPrivate;
|
||||
// 分享到班级/所有人 → 取消私密标记
|
||||
final updated = entry.copyWith(
|
||||
isPrivate: false,
|
||||
sharedToClass: shareToClass,
|
||||
);
|
||||
await repo.updateJournal(updated);
|
||||
|
||||
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
|
||||
if (wasPrivate && !updated.isPrivate) {
|
||||
final syncEngine = context.read<SyncEngine>();
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: updated.id,
|
||||
type: SyncOperationType.create,
|
||||
endpoint: '/diary/journals',
|
||||
data: updated.toJson(),
|
||||
version: updated.version,
|
||||
createdAt: DateTime.now(),
|
||||
));
|
||||
} else if (!updated.isPrivate) {
|
||||
// 已公开日记的分享状态更新
|
||||
final syncEngine = context.read<SyncEngine>();
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: updated.id,
|
||||
type: SyncOperationType.update,
|
||||
endpoint: '/diary/journals/${updated.id}',
|
||||
data: updated.toJson(),
|
||||
version: updated.version,
|
||||
createdAt: DateTime.now(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('更新分享状态失败: $e');
|
||||
@@ -276,15 +334,29 @@ class _EditorView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EditorViewState extends State<_EditorView> {
|
||||
/// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式
|
||||
bool _isViewMode = false;
|
||||
|
||||
/// 保存中状态 — 用于显示"保存中..."指示器
|
||||
bool _isSaving = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 当 journalId 非空时,从 Isar 加载已有日记数据
|
||||
// 当 journalId 非空时,进入查看模式
|
||||
_isViewMode = widget.journalId != null;
|
||||
if (widget.journalId != null) {
|
||||
_loadExistingJournal(widget.journalId!);
|
||||
}
|
||||
}
|
||||
|
||||
/// 从查看模式切换到编辑模式
|
||||
void _enterEditMode() {
|
||||
setState(() => _isViewMode = false);
|
||||
// 切换到画笔工具,进入编辑状态
|
||||
context.read<EditorBloc>().add(ToolChanged(EditorTool.brush));
|
||||
}
|
||||
|
||||
/// 从 Isar 加载已有日记 — 使用 LoadJournal 原子事件一次性还原
|
||||
Future<void> _loadExistingJournal(String id) async {
|
||||
try {
|
||||
@@ -323,6 +395,11 @@ class _EditorViewState extends State<_EditorView> {
|
||||
elements: otherElements,
|
||||
lastSavedAt: entry.updatedAt,
|
||||
));
|
||||
|
||||
// 查看模式下使用 select 工具,避免自动弹出画笔面板
|
||||
if (_isViewMode) {
|
||||
context.read<EditorBloc>().add(ToolChanged(EditorTool.select));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('加载日记数据失败: $e');
|
||||
}
|
||||
@@ -347,26 +424,31 @@ class _EditorViewState extends State<_EditorView> {
|
||||
Expanded(
|
||||
child: BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, state) {
|
||||
return _EditorStack(state: state, journalId: widget.journalId);
|
||||
return _EditorStack(
|
||||
state: state,
|
||||
journalId: widget.journalId,
|
||||
isViewMode: _isViewMode,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 底部工具栏(自带底部安全区)
|
||||
BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, state) {
|
||||
return EditorToolbar(
|
||||
state: state,
|
||||
onEvent: (event) => context.read<EditorBloc>().add(event),
|
||||
);
|
||||
},
|
||||
),
|
||||
// 底部工具栏 — 仅编辑模式显示
|
||||
if (!_isViewMode)
|
||||
BlocBuilder<EditorBloc, EditorState>(
|
||||
builder: (context, state) {
|
||||
return EditorToolbar(
|
||||
state: state,
|
||||
onEvent: (event) => context.read<EditorBloc>().add(event),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 顶部操作栏 — 日期/撤销重做/标签/心情/完成
|
||||
/// 顶部操作栏 — 查看模式: 返回/日期/评语/编辑按钮;编辑模式: 返回/日期/撤销重做/标签/完成
|
||||
Widget _buildTopBar(BuildContext context, EditorState state) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
@@ -409,58 +491,83 @@ class _EditorViewState extends State<_EditorView> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 撤销
|
||||
IconButton(
|
||||
icon: const Icon(Icons.undo_rounded, size: 18),
|
||||
onPressed: () => context.read<EditorBloc>().add(Undo()),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// 重做
|
||||
IconButton(
|
||||
icon: const Icon(Icons.redo_rounded, size: 18),
|
||||
onPressed: () => context.read<EditorBloc>().add(Redo()),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// 自动保存状态
|
||||
_buildAutosaveIndicator(state),
|
||||
// 标签按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.sell_rounded, size: 18),
|
||||
onPressed: () => _showTagPanel(context, state),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// 评语按钮(仅已有日记显示)
|
||||
if (widget.journalId != null)
|
||||
if (_isViewMode) ...[
|
||||
// 查看模式:评语按钮 + 编辑按钮
|
||||
if (widget.journalId != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
|
||||
onPressed: () => _showComments(context),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: FilledButton.tonal(
|
||||
onPressed: _enterEditMode,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
// 编辑模式:撤销/重做/标签/评语/完成
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
|
||||
onPressed: () => _showComments(context),
|
||||
icon: const Icon(Icons.undo_rounded, size: 18),
|
||||
onPressed: () => context.read<EditorBloc>().add(Undo()),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
// 完成/保存按钮
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _handleSave(context, state),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: const Text('完成', style: TextStyle(fontSize: 14)),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.redo_rounded, size: 18),
|
||||
onPressed: () => context.read<EditorBloc>().add(Redo()),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
),
|
||||
_buildAutosaveIndicator(state),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.sell_rounded, size: 18),
|
||||
onPressed: () => _showTagPanel(context, state),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
if (widget.journalId != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chat_bubble_outline_rounded, size: 18),
|
||||
onPressed: () => _showComments(context),
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: FilledButton.tonal(
|
||||
onPressed: () => _handleSave(context, state),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
minimumSize: const Size(0, 32),
|
||||
),
|
||||
child: const Text('完成', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 日期 + 心情条 (40px)
|
||||
_buildDateMoodStrip(context, state),
|
||||
// 日期 + 心情条 (40px) — 仅编辑模式显示
|
||||
if (!_isViewMode) _buildDateMoodStrip(context, state),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 返回处理
|
||||
/// 返回处理 — 有未保存修改时弹出确认
|
||||
void _handleBack(BuildContext context) {
|
||||
final bloc = context.read<EditorBloc>();
|
||||
if (bloc.state.isDirty) {
|
||||
_showDiscardDialog(context);
|
||||
} else {
|
||||
_doNavigateBack(context);
|
||||
}
|
||||
}
|
||||
|
||||
void _doNavigateBack(BuildContext context) {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
@@ -468,9 +575,39 @@ class _EditorViewState extends State<_EditorView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存处理
|
||||
void _showDiscardDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('放弃编辑?'),
|
||||
content: const Text('你有未保存的修改,确定要离开吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('继续编辑'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
_doNavigateBack(context);
|
||||
},
|
||||
child: const Text('放弃'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 保存处理 — 显示"保存中..."后触发保存
|
||||
void _handleSave(BuildContext context, EditorState state) {
|
||||
widget.onSaveComplete();
|
||||
setState(() => _isSaving = true);
|
||||
// 短暂延迟让 UI 显示"保存中..."状态
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
if (mounted) {
|
||||
setState(() => _isSaving = false);
|
||||
widget.onSaveComplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 显示评论列表
|
||||
@@ -497,7 +634,26 @@ class _EditorViewState extends State<_EditorView> {
|
||||
}
|
||||
|
||||
/// 自动保存状态指示器
|
||||
/// 保存指示器 — 三态: 未保存 / 保存中 / 已保存
|
||||
Widget _buildAutosaveIndicator(EditorState state) {
|
||||
// 保存中 — 琥珀色脉冲点
|
||||
if (_isSaving) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_PulsingDot(color: AppColors.tertiary),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'保存中...',
|
||||
style: TextStyle(fontSize: 11, color: Colors.amber[700]),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
// 未保存
|
||||
if (state.lastSavedAt == null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
@@ -507,6 +663,7 @@ class _EditorViewState extends State<_EditorView> {
|
||||
),
|
||||
);
|
||||
}
|
||||
// 已保存 — 绿色点
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Row(
|
||||
@@ -616,8 +773,13 @@ class _EditorViewState extends State<_EditorView> {
|
||||
class _EditorStack extends StatefulWidget {
|
||||
final EditorState state;
|
||||
final String? journalId;
|
||||
final bool isViewMode;
|
||||
|
||||
const _EditorStack({required this.state, this.journalId});
|
||||
const _EditorStack({
|
||||
required this.state,
|
||||
this.journalId,
|
||||
this.isViewMode = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_EditorStack> createState() => _EditorStackState();
|
||||
@@ -625,6 +787,7 @@ class _EditorStack extends StatefulWidget {
|
||||
|
||||
class _EditorStackState extends State<_EditorStack> {
|
||||
EditorTool? _lastTool;
|
||||
int _lastReactivatedAt = 0;
|
||||
late final TextEditingController _titleController;
|
||||
|
||||
@override
|
||||
@@ -675,6 +838,26 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
});
|
||||
}
|
||||
_lastTool = currentTool;
|
||||
|
||||
// 工具重新激活(再次点击已选中的工具)→ 重新弹出面板
|
||||
final reactivatedAt = widget.state.toolReactivatedAt;
|
||||
if (reactivatedAt != _lastReactivatedAt) {
|
||||
_lastReactivatedAt = reactivatedAt;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
|
||||
switch (currentTool) {
|
||||
case EditorTool.brush:
|
||||
_showBrushPanel();
|
||||
case EditorTool.sticker:
|
||||
_showStickerPicker();
|
||||
case EditorTool.more:
|
||||
_showMoreSheet();
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 显示贴纸选择底部面板
|
||||
@@ -821,6 +1004,7 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: TextField(
|
||||
controller: _titleController,
|
||||
enabled: !widget.isViewMode,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Quicksand',
|
||||
fontSize: 18,
|
||||
@@ -864,8 +1048,8 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
if (state.elements.isNotEmpty)
|
||||
_buildElementLayer(context, state),
|
||||
|
||||
// 文字输入覆盖层(文字工具激活时显示)
|
||||
if (state.activeTool == EditorTool.text)
|
||||
// 文字输入覆盖层(文字工具激活时显示)— 仅编辑模式
|
||||
if (!widget.isViewMode && state.activeTool == EditorTool.text)
|
||||
TextInputOverlay(
|
||||
onConfirmed: (text, fontSize, fontColor) {
|
||||
final center = Offset(
|
||||
@@ -888,8 +1072,8 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
},
|
||||
),
|
||||
|
||||
// 图片选择覆盖层(图片工具激活时显示)
|
||||
if (state.activeTool == EditorTool.photo)
|
||||
// 图片选择覆盖层(图片工具激活时显示)— 仅编辑模式
|
||||
if (!widget.isViewMode && state.activeTool == EditorTool.photo)
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -909,8 +1093,11 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
),
|
||||
),
|
||||
|
||||
// 空状态提示
|
||||
if (state.strokes.isEmpty && state.elements.isEmpty && state.activeTool == EditorTool.select)
|
||||
// 空状态提示 — 仅编辑模式显示
|
||||
if (!widget.isViewMode &&
|
||||
state.strokes.isEmpty &&
|
||||
state.elements.isEmpty &&
|
||||
state.activeTool == EditorTool.select)
|
||||
_buildEmptyHint(context),
|
||||
],
|
||||
);
|
||||
@@ -962,9 +1149,27 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
positionY: y,
|
||||
));
|
||||
},
|
||||
onResized: (id, w, h) {
|
||||
context.read<EditorBloc>().add(ElementResized(
|
||||
elementId: id,
|
||||
width: w,
|
||||
height: h,
|
||||
));
|
||||
},
|
||||
onRotated: (id, r) {
|
||||
context.read<EditorBloc>().add(ElementRotated(
|
||||
elementId: id,
|
||||
rotation: r,
|
||||
));
|
||||
},
|
||||
onDeleted: (id) {
|
||||
context.read<EditorBloc>().add(ElementRemoved(id));
|
||||
},
|
||||
onLayerChanged: (id, change) {
|
||||
context.read<EditorBloc>().add(
|
||||
ElementLayerChanged(elementId: id, change: change),
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
@@ -1036,3 +1241,53 @@ class _ImageSourceButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 脉冲圆点动画 — 用于"保存中..."指示器
|
||||
class _PulsingDot extends StatefulWidget {
|
||||
const _PulsingDot({required this.color});
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
State<_PulsingDot> createState() => _PulsingDotState();
|
||||
}
|
||||
|
||||
class _PulsingDotState extends State<_PulsingDot>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
)..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, _) {
|
||||
final scale = 0.6 + 0.4 * _controller.value;
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,16 @@ class ActiveStrokePainter extends CustomPainter {
|
||||
|
||||
final path = buildStrokePath(outlinePoints);
|
||||
|
||||
// 橡皮擦实时反馈:绘制半透明灰色,让用户看到擦除范围
|
||||
// 实际擦除在笔画完成后的合成图中通过 BlendMode.dstOut 执行
|
||||
if (brushType == BrushType.eraser) {
|
||||
canvas.drawPath(path, Paint()
|
||||
..color = const Color(0x40808080) // 25% 灰色
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 构造临时 Stroke 用于获取 Paint
|
||||
final stroke = Stroke(
|
||||
id: '__active__',
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/models/journal_element.dart';
|
||||
import '../bloc/editor_bloc.dart' show LayerChange;
|
||||
|
||||
/// 可拖拽日记元素组件
|
||||
class DraggableElement extends StatefulWidget {
|
||||
@@ -19,7 +20,10 @@ class DraggableElement extends StatefulWidget {
|
||||
final bool isSelected;
|
||||
final ValueChanged<String> onTap;
|
||||
final void Function(String id, double x, double y) onMoved;
|
||||
final void Function(String id, double w, double h)? onResized;
|
||||
final void Function(String id, double rotation)? onRotated;
|
||||
final ValueChanged<String> onDeleted;
|
||||
final void Function(String id, LayerChange change)? onLayerChanged;
|
||||
|
||||
const DraggableElement({
|
||||
super.key,
|
||||
@@ -27,7 +31,10 @@ class DraggableElement extends StatefulWidget {
|
||||
this.isSelected = false,
|
||||
required this.onTap,
|
||||
required this.onMoved,
|
||||
this.onResized,
|
||||
this.onRotated,
|
||||
required this.onDeleted,
|
||||
this.onLayerChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -41,6 +48,11 @@ class _DraggableElementState extends State<DraggableElement> {
|
||||
late double _height;
|
||||
late double _rotation;
|
||||
|
||||
// Scale 手势状态
|
||||
double _baseWidth = 0;
|
||||
double _baseHeight = 0;
|
||||
double _baseRotation = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -76,15 +88,35 @@ class _DraggableElementState extends State<DraggableElement> {
|
||||
child: Transform.rotate(
|
||||
angle: _rotation,
|
||||
child: GestureDetector(
|
||||
// 拖拽移动
|
||||
onPanUpdate: (details) {
|
||||
// 缩放开始 — 记录基准值
|
||||
onScaleStart: (details) {
|
||||
_baseWidth = _width;
|
||||
_baseHeight = _height;
|
||||
_baseRotation = _rotation;
|
||||
},
|
||||
// 缩放更新 — 支持单指拖拽 + 双指缩放/旋转
|
||||
onScaleUpdate: (details) {
|
||||
setState(() {
|
||||
_x += details.delta.dx;
|
||||
_y += details.delta.dy;
|
||||
// 拖拽(单指和双指都支持)
|
||||
_x += details.focalPointDelta.dx;
|
||||
_y += details.focalPointDelta.dy;
|
||||
|
||||
// 双指缩放 + 旋转
|
||||
if (details.pointerCount >= 2) {
|
||||
final newW = (_baseWidth * details.scale).clamp(40.0, 400.0);
|
||||
final newH = (_baseHeight * details.scale).clamp(40.0, 400.0);
|
||||
_width = newW;
|
||||
_height = newH;
|
||||
_rotation = _baseRotation + details.rotation;
|
||||
}
|
||||
});
|
||||
widget.onMoved(widget.element.id, _x, _y);
|
||||
if (details.pointerCount >= 2) {
|
||||
widget.onResized?.call(widget.element.id, _width, _height);
|
||||
widget.onRotated?.call(widget.element.id, _rotation);
|
||||
}
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
onScaleEnd: (_) {
|
||||
// 确保最终位置已通知
|
||||
widget.onMoved(widget.element.id, _x, _y);
|
||||
},
|
||||
@@ -113,26 +145,41 @@ class _DraggableElementState extends State<DraggableElement> {
|
||||
),
|
||||
),
|
||||
|
||||
// 选中时显示删除按钮
|
||||
// 选中时显示操作按钮:图层 + 删除
|
||||
if (widget.isSelected)
|
||||
Positioned(
|
||||
top: -12,
|
||||
right: -12,
|
||||
child: GestureDetector(
|
||||
onTap: () => widget.onDeleted(widget.element.id),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 置顶
|
||||
_ActionButton(
|
||||
icon: Icons.flip_to_front_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onTap: () => widget.onLayerChanged?.call(
|
||||
widget.element.id,
|
||||
LayerChange.bringToFront,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// 置底
|
||||
_ActionButton(
|
||||
icon: Icons.flip_to_back_rounded,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
onTap: () => widget.onLayerChanged?.call(
|
||||
widget.element.id,
|
||||
LayerChange.sendToBack,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// 删除
|
||||
_ActionButton(
|
||||
icon: Icons.close_rounded,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
shape: BoxShape.circle,
|
||||
onTap: () => widget.onDeleted(widget.element.id),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close_rounded,
|
||||
size: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -250,3 +297,32 @@ class _DraggableElementState extends State<DraggableElement> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 选中元素的操作按钮(图层/删除)
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionButton({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 16, color: Colors.white),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,10 @@ class EditorToolbar extends StatelessWidget {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onEvent(ToolChanged(tool)),
|
||||
onTap: () => onEvent(isActive ? ToolReactivated(tool) : ToolChanged(tool)),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
constraints: const BoxConstraints(minWidth: 44, minHeight: 44),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -134,10 +134,15 @@ class _HandwritingCanvasState extends State<HandwritingCanvas> {
|
||||
}
|
||||
|
||||
/// 在 build 完成后同步缓存(避免在 build 中触发异步操作)
|
||||
///
|
||||
/// syncStrokes 重建合成图后必须调用 setState,
|
||||
/// 否则 CachedStrokesPainter 不知道缓存已更新,不会触发重绘。
|
||||
void _syncCacheAfterBuild() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_cache.syncStrokes(widget.strokes);
|
||||
_cache.syncStrokes(widget.strokes).then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -235,7 +240,10 @@ class _HandwritingCanvasState extends State<HandwritingCanvas> {
|
||||
widget.onStrokeCompleted?.call(stroke);
|
||||
|
||||
// 光栅化新笔画到缓存(异步,不阻塞 UI)
|
||||
_cache.addStroke(stroke);
|
||||
// 完成后 setState 确保 Layer 1 (CachedStrokesPainter) 用新合成图重绘
|
||||
_cache.addStroke(stroke).then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
/// 指针取消(如来电打断):丢弃当前笔画。
|
||||
|
||||
@@ -4,20 +4,27 @@
|
||||
// - 温暖友好的文案(面向小学生)
|
||||
// - 分享到班级(有班级时显示)/ 仅自己可见
|
||||
// - 无班级时提示加入班级后可分享
|
||||
// - 分享前自动进行内容安全检查(敏感词过滤)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../data/services/content_filter_service.dart';
|
||||
|
||||
/// 编辑器完成后的分享选择面板
|
||||
class ShareBottomSheet extends StatelessWidget {
|
||||
final String? classId;
|
||||
final String className;
|
||||
final void Function(bool shareToClass) onDecision;
|
||||
|
||||
/// 用于内容安全检查的文本内容(标题 + 文本元素)
|
||||
final String contentText;
|
||||
|
||||
const ShareBottomSheet({
|
||||
super.key,
|
||||
required this.classId,
|
||||
required this.className,
|
||||
required this.onDecision,
|
||||
this.contentText = '',
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -65,10 +72,7 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: FilledButton.icon(
|
||||
onPressed: () {
|
||||
onDecision(true);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onPressed: () => _handleShare(context, shareToClass: true),
|
||||
icon: const Icon(Icons.group),
|
||||
label: Text('分享到 $className'),
|
||||
style: FilledButton.styleFrom(
|
||||
@@ -86,10 +90,7 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
onDecision(false);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onPressed: () => _handleShare(context, shareToClass: false),
|
||||
icon: const Icon(Icons.lock_outline),
|
||||
label: const Text('仅自己可见'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
@@ -116,4 +117,80 @@ class ShareBottomSheet extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 处理分享/保存决定
|
||||
void _handleShare(BuildContext context, {required bool shareToClass}) {
|
||||
// 仅在分享到班级时进行内容安全检查
|
||||
if (shareToClass && contentText.isNotEmpty) {
|
||||
final matches = ContentFilterService.checkText(contentText);
|
||||
if (matches.isNotEmpty) {
|
||||
_showContentWarning(context, matches);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 安全或仅自己可见 → 直接执行
|
||||
onDecision(shareToClass);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
/// 显示内容安全警告对话框
|
||||
void _showContentWarning(
|
||||
BuildContext context,
|
||||
List<SensitiveWordMatch> matches,
|
||||
) {
|
||||
final categories = ContentFilterService.getMatchedCategories(matches);
|
||||
final words = matches.map((m) => ' "${m.word}"').toSet().toList();
|
||||
final wordList = words.take(5).join('、');
|
||||
final categoryList = categories.join('、');
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.orange),
|
||||
SizedBox(width: 8),
|
||||
Text('内容提醒'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('日记中可能包含不太合适分享的内容:'),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
wordList,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'涉及:$categoryList',
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'建议修改后再分享,或者先保存为仅自己可见。',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text('返回修改'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(dialogContext); // 关闭对话框
|
||||
onDecision(true); // 仍然分享
|
||||
Navigator.pop(context); // 关闭 BottomSheet
|
||||
},
|
||||
child: const Text('仍然分享'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -20,8 +20,10 @@ import 'stroke_renderer.dart';
|
||||
class _CacheEntry {
|
||||
final ui.Image image;
|
||||
final Stroke stroke;
|
||||
/// BBox 偏移量 — 光栅化时裁剪的起点,合成时用于定位
|
||||
final Offset offset;
|
||||
|
||||
const _CacheEntry({required this.image, required this.stroke});
|
||||
const _CacheEntry({required this.image, required this.stroke, required this.offset});
|
||||
}
|
||||
|
||||
// ===== 光栅化缓存 =====
|
||||
@@ -76,18 +78,22 @@ class StrokeRasterCache {
|
||||
|
||||
/// 添加一条已完成笔画到缓存
|
||||
///
|
||||
/// 光栅化该笔画为 ui.Image,然后增量合成到 compositeImage。
|
||||
/// 光栅化该笔画为 ui.Image(仅 BBox 区域),然后增量合成到 compositeImage。
|
||||
Future<void> addStroke(Stroke stroke) async {
|
||||
if (_canvasSize == Size.zero) return;
|
||||
|
||||
// 光栅化单笔画
|
||||
final image = await _rasterizeStroke(stroke);
|
||||
if (image == null) return;
|
||||
// 光栅化单笔画(BBox 裁剪)
|
||||
final result = await _rasterizeStroke(stroke);
|
||||
if (result == null) return;
|
||||
|
||||
_cache[stroke.id] = _CacheEntry(image: image, stroke: stroke);
|
||||
_cache[stroke.id] = _CacheEntry(
|
||||
image: result.image,
|
||||
stroke: stroke,
|
||||
offset: result.offset,
|
||||
);
|
||||
|
||||
// 增量合成:将新笔画绘制到现有 compositeImage 之上
|
||||
await _compositeIncremental(stroke, image);
|
||||
await _compositeIncremental(stroke, result.image, result.offset);
|
||||
}
|
||||
|
||||
/// 同步笔画列表(用于撤销/重做后与 BLoC 状态对齐)
|
||||
@@ -108,9 +114,13 @@ class StrokeRasterCache {
|
||||
final toAdd = currentIds.difference(cachedIds);
|
||||
for (final stroke in strokes) {
|
||||
if (toAdd.contains(stroke.id)) {
|
||||
final image = await _rasterizeStroke(stroke);
|
||||
if (image != null) {
|
||||
_cache[stroke.id] = _CacheEntry(image: image, stroke: stroke);
|
||||
final result = await _rasterizeStroke(stroke);
|
||||
if (result != null) {
|
||||
_cache[stroke.id] = _CacheEntry(
|
||||
image: result.image,
|
||||
stroke: stroke,
|
||||
offset: result.offset,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,8 +165,12 @@ class StrokeRasterCache {
|
||||
|
||||
// ===== 光栅化 =====
|
||||
|
||||
/// 将单条笔画光栅化为 ui.Image
|
||||
Future<ui.Image?> _rasterizeStroke(Stroke stroke) async {
|
||||
/// 将单条笔画光栅化为 ui.Image — 仅光栅化 BBox 区域(性能优化 8b-M02)
|
||||
///
|
||||
/// 计算笔画的包围盒 (bounding box),仅对该区域光栅化,
|
||||
/// 大幅减少 GPU 内存占用(短笔画从全画布 4096×4096 降到实际尺寸)。
|
||||
/// 返回 null 表示笔画无有效点。
|
||||
Future<({ui.Image image, Offset offset})?> _rasterizeStroke(Stroke stroke) async {
|
||||
final outlinePoints = pointsToOutline(
|
||||
stroke.points,
|
||||
stroke.brushType,
|
||||
@@ -165,16 +179,38 @@ class StrokeRasterCache {
|
||||
);
|
||||
if (outlinePoints.isEmpty) return null;
|
||||
|
||||
// 计算笔画包围盒
|
||||
double minX = double.infinity, minY = double.infinity;
|
||||
double maxX = double.negativeInfinity, maxY = double.negativeInfinity;
|
||||
for (final p in outlinePoints) {
|
||||
if (p.dx < minX) minX = p.dx;
|
||||
if (p.dy < minY) minY = p.dy;
|
||||
if (p.dx > maxX) maxX = p.dx;
|
||||
if (p.dy > maxY) maxY = p.dy;
|
||||
}
|
||||
|
||||
// 添加边距(抗锯齿 + 笔触溢出)
|
||||
const padding = 4.0;
|
||||
final bboxLeft = (minX - padding).clamp(0.0, _canvasSize.width);
|
||||
final bboxTop = (minY - padding).clamp(0.0, _canvasSize.height);
|
||||
final bboxRight = (maxX + padding).clamp(0.0, _canvasSize.width);
|
||||
final bboxBottom = (maxY + padding).clamp(0.0, _canvasSize.height);
|
||||
final bboxWidth = (bboxRight - bboxLeft).clamp(1.0, 4096.0);
|
||||
final bboxHeight = (bboxBottom - bboxTop).clamp(1.0, 4096.0);
|
||||
|
||||
final path = buildStrokePath(outlinePoints);
|
||||
final paint = createPaintForStroke(stroke);
|
||||
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
|
||||
// 平移坐标系,使 BBox 左上角对齐 (0, 0)
|
||||
canvas.translate(-bboxLeft, -bboxTop);
|
||||
|
||||
// 橡皮擦需要 saveLayer 保护,避免穿透
|
||||
if (stroke.brushType == BrushType.eraser) {
|
||||
canvas.saveLayer(
|
||||
Rect.fromLTWH(0, 0, _canvasSize.width, _canvasSize.height),
|
||||
Rect.fromLTWH(0, 0, bboxWidth, bboxHeight),
|
||||
Paint(),
|
||||
);
|
||||
}
|
||||
@@ -186,16 +222,20 @@ class StrokeRasterCache {
|
||||
}
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
return picture.toImage(
|
||||
_canvasSize.width.toInt().clamp(1, 4096),
|
||||
_canvasSize.height.toInt().clamp(1, 4096),
|
||||
final image = await picture.toImage(
|
||||
bboxWidth.toInt().clamp(1, 4096),
|
||||
bboxHeight.toInt().clamp(1, 4096),
|
||||
);
|
||||
|
||||
return (image: image, offset: Offset(bboxLeft, bboxTop));
|
||||
}
|
||||
|
||||
// ===== 合成 =====
|
||||
|
||||
/// 增量合成:将新笔画图像绘制到现有 compositeImage 上
|
||||
Future<void> _compositeIncremental(Stroke stroke, ui.Image strokeImage) async {
|
||||
///
|
||||
/// strokeImage 是 BBox 裁剪后的图像,offset 是其原始位置偏移。
|
||||
Future<void> _compositeIncremental(Stroke stroke, ui.Image strokeImage, Offset offset) async {
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
|
||||
@@ -210,15 +250,15 @@ class StrokeRasterCache {
|
||||
canvas.drawImage(_compositeImage!, Offset.zero, Paint());
|
||||
}
|
||||
|
||||
// 再绘制新笔画(橡皮擦用 dstOut)
|
||||
// 再绘制新笔画(橡皮擦用 dstOut),使用 BBox offset 定位
|
||||
if (stroke.brushType == BrushType.eraser) {
|
||||
canvas.drawImage(
|
||||
strokeImage,
|
||||
Offset.zero,
|
||||
offset,
|
||||
Paint()..blendMode = BlendMode.dstOut,
|
||||
);
|
||||
} else {
|
||||
canvas.drawImage(strokeImage, Offset.zero, Paint());
|
||||
canvas.drawImage(strokeImage, offset, Paint());
|
||||
}
|
||||
|
||||
canvas.restore();
|
||||
|
||||
@@ -39,19 +39,21 @@ class _BrushConfig {
|
||||
|
||||
/// 各画笔的渲染参数。
|
||||
const Map<BrushType, _BrushConfig> _brushConfigs = {
|
||||
/// 钢笔:中等粗细,强压感变化,模拟毛笔效果
|
||||
/// 钢笔:粗壮平滑,模拟签字笔效果
|
||||
BrushType.pen: _BrushConfig(
|
||||
size: 8,
|
||||
thinning: 0.7,
|
||||
smoothing: 0.5,
|
||||
size: 10,
|
||||
thinning: 0.65,
|
||||
smoothing: 0.65,
|
||||
streamline: 0.6,
|
||||
simulatePressure: true,
|
||||
),
|
||||
|
||||
/// 铅笔:细线,轻微压感,高平滑度产生自然线条
|
||||
/// 铅笔:纤细有质感,保留书写抖动
|
||||
BrushType.pencil: _BrushConfig(
|
||||
size: 4,
|
||||
thinning: 0.3,
|
||||
smoothing: 0.7,
|
||||
size: 3,
|
||||
thinning: 0.4,
|
||||
smoothing: 0.35,
|
||||
streamline: 0.3,
|
||||
simulatePressure: true,
|
||||
),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// 首页 BLoC — 加载最近日记和心情概览
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
@@ -78,12 +80,24 @@ final class HomeError extends HomeState {
|
||||
|
||||
class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
final JournalRepository _journalRepo;
|
||||
StreamSubscription<void>? _changeSubscription;
|
||||
|
||||
HomeBloc({required JournalRepository journalRepository})
|
||||
: _journalRepo = journalRepository,
|
||||
super(const HomeInitial()) {
|
||||
on<HomeLoadData>(_onLoadData);
|
||||
on<HomeRefresh>(_onRefresh);
|
||||
|
||||
// 监听日记变更,自动刷新首页数据
|
||||
_changeSubscription = _journalRepo.onJournalChanged.listen((_) {
|
||||
add(const HomeRefresh());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_changeSubscription?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
Future<void> _onLoadData(
|
||||
@@ -116,9 +130,14 @@ class HomeBloc extends Bloc<HomeEvent, HomeState> {
|
||||
// 推算连续天数
|
||||
final streakDays = _calculateStreak(journals);
|
||||
|
||||
// 本月日记数(spec §3.4 quick-stats)
|
||||
final monthCount = journals.where((j) =>
|
||||
j.date.year == today.year && j.date.month == today.month).length;
|
||||
// 本月日记数 — 使用日期范围查询,不受分页限制(修复 8b-D03)
|
||||
final monthStart = DateTime(today.year, today.month, 1);
|
||||
final monthEnd = DateTime(today.year, today.month + 1, 1);
|
||||
final monthJournals = await _journalRepo.getJournals(
|
||||
dateFrom: monthStart,
|
||||
dateTo: monthEnd,
|
||||
);
|
||||
final monthCount = monthJournals.length;
|
||||
|
||||
// 总日记数 — 使用仓库计数方法(不受分页限制)
|
||||
final totalCount = await _journalRepo.getJournalCount();
|
||||
|
||||
@@ -24,6 +24,10 @@ import '../../../core/theme/app_shadows.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../data/models/journal_entry.dart';
|
||||
import '../../../data/repositories/journal_repository.dart';
|
||||
import '../../../data/repositories/class_repository.dart';
|
||||
import '../../../data/services/sync_engine.dart';
|
||||
import '../../auth/bloc/auth_bloc.dart';
|
||||
import '../../editor/widgets/share_bottom_sheet.dart';
|
||||
import '../bloc/home_bloc.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
@@ -87,7 +91,10 @@ class _HomeView extends StatelessWidget {
|
||||
children: [
|
||||
_GreetingHeader(
|
||||
greeting: greeting,
|
||||
username: '小暖',
|
||||
username: context.select<AuthBloc, String>((bloc) {
|
||||
final s = bloc.state;
|
||||
return s is Authenticated ? s.user.displayLabel : '同学';
|
||||
}),
|
||||
dateText: dateText,
|
||||
onSearchTap: () => context.push('/search'),
|
||||
),
|
||||
@@ -659,9 +666,22 @@ class _JournalCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${journal.date.month}月${journal.date.day}日',
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'${journal.date.month}月${journal.date.day}日',
|
||||
style: TextStyle(fontSize: 11, color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
// 可见性标签
|
||||
_VisibilityBadge(
|
||||
isPrivate: journal.isPrivate,
|
||||
sharedToClass: journal.sharedToClass,
|
||||
onTap: journal.isPrivate
|
||||
? () => _sharePrivateJournal(context, journal)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
@@ -702,6 +722,152 @@ class _JournalCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 分享私密日记 — 弹出分享面板,将日记变为公开并上传到后端
|
||||
Future<void> _sharePrivateJournal(BuildContext context, JournalEntry entry) async {
|
||||
String? userClassId;
|
||||
String userClassName = '我的班级';
|
||||
|
||||
try {
|
||||
final authState = context.read<AuthBloc>().state;
|
||||
if (authState is Authenticated) {
|
||||
try {
|
||||
final classRepo = context.read<ClassRepository>();
|
||||
final classes = await classRepo.getMyClasses();
|
||||
if (classes.isNotEmpty) {
|
||||
userClassId = classes.first.id;
|
||||
userClassName = classes.first.name;
|
||||
}
|
||||
} catch (_) {
|
||||
// 没有班级信息,使用默认值
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (sheetContext) => ShareBottomSheet(
|
||||
classId: userClassId,
|
||||
className: userClassName,
|
||||
onDecision: (shareToClass) async {
|
||||
try {
|
||||
final repo = context.read<JournalRepository>();
|
||||
// 将私密日记变为公开
|
||||
final updated = entry.copyWith(
|
||||
isPrivate: false,
|
||||
sharedToClass: shareToClass,
|
||||
);
|
||||
await repo.updateJournal(updated);
|
||||
|
||||
// 首次从私密变为公开 → 入队 SyncEngine 上传到后端
|
||||
final syncEngine = context.read<SyncEngine>();
|
||||
syncEngine.enqueue(PendingOperation(
|
||||
id: updated.id,
|
||||
type: SyncOperationType.create,
|
||||
endpoint: '/diary/journals',
|
||||
data: updated.toJson(),
|
||||
version: updated.version,
|
||||
createdAt: DateTime.now(),
|
||||
));
|
||||
|
||||
// 刷新首页列表
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<HomeBloc>().add(const HomeRefresh());
|
||||
} catch (e) {
|
||||
debugPrint('分享日记失败: $e');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 可见性标签 — 显示日记的可见性状态
|
||||
///
|
||||
/// - 私密:🔒 仅自己可见(可点击分享)
|
||||
/// - 分享到班级:🏫 班级可见
|
||||
/// - 公开:🌐 所有人可见
|
||||
class _VisibilityBadge extends StatelessWidget {
|
||||
const _VisibilityBadge({
|
||||
required this.isPrivate,
|
||||
required this.sharedToClass,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final bool isPrivate;
|
||||
final bool sharedToClass;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isPrivate) {
|
||||
// 私密日记 — 显示锁定图标,可点击分享
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const StadiumBorder(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.tertiarySoftLight,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.lock_outline, size: 12, color: Color(0xFFB8860B)),
|
||||
SizedBox(width: 3),
|
||||
Text(
|
||||
'仅自己可见',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFFB8860B)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (sharedToClass) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.secondary.withValues(alpha: 0.15),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.groups, size: 12, color: AppColors.secondary),
|
||||
SizedBox(width: 3),
|
||||
Text(
|
||||
'班级可见',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.secondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.accent.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.public, size: 12, color: AppColors.accent),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'公开',
|
||||
style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: AppColors.accent),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyJournalState extends StatelessWidget {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
221
app/test/data/services/content_filter_service_test.dart
Normal file
@@ -0,0 +1,221 @@
|
||||
// ContentFilterService 单元测试
|
||||
//
|
||||
// 覆盖:精确匹配、谐音变体匹配、文本预处理、各分类检测、边界条件
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuanji_app/data/services/content_filter_service.dart';
|
||||
import 'package:nuanji_app/data/services/sensitive_words.dart';
|
||||
|
||||
void main() {
|
||||
// ============================================================
|
||||
// 精确匹配 — 各分类
|
||||
// ============================================================
|
||||
group('精确匹配', () {
|
||||
test('暴力类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('我要打死你');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.violence), isTrue);
|
||||
expect(matches.any((m) => m.word == '打死你'), isTrue);
|
||||
});
|
||||
|
||||
test('色情类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('这个视频很色情');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.sexual), isTrue);
|
||||
});
|
||||
|
||||
test('欺凌类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('你是个废物');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.bullying), isTrue);
|
||||
expect(matches.any((m) => m.word == '废物'), isTrue);
|
||||
});
|
||||
|
||||
test('毒品类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('他在吸毒');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.drugs), isTrue);
|
||||
});
|
||||
|
||||
test('赌博类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('我们去赌钱吧');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.gambling), isTrue);
|
||||
});
|
||||
|
||||
test('粗口类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('卧槽太厉害了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.profanity), isTrue);
|
||||
});
|
||||
|
||||
test('诈骗类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('恭喜中奖了,点击链接领奖');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.fraud), isTrue);
|
||||
});
|
||||
|
||||
test('政治敏感类词汇检测', () {
|
||||
final matches = ContentFilterService.checkText('要造反了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.category == SensitiveCategory.politics), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 安全内容
|
||||
// ============================================================
|
||||
group('安全内容', () {
|
||||
test('正常日记文本不触发', () {
|
||||
final text = '今天天气很好,我和小明一起去公园玩,非常开心。'
|
||||
'我们玩了滑梯、秋千,还吃了冰淇淋。';
|
||||
final matches = ContentFilterService.checkText(text);
|
||||
expect(matches, isEmpty);
|
||||
});
|
||||
|
||||
test('学习相关文本不触发', () {
|
||||
final text = '今天数学课学了乘法,我觉得很有趣。'
|
||||
'老师表扬了我,说我进步很大。';
|
||||
final matches = ContentFilterService.checkText(text);
|
||||
expect(matches, isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 文本预处理 — 绕过手法
|
||||
// ============================================================
|
||||
group('文本预处理', () {
|
||||
test('空格分隔不影响检测', () {
|
||||
final matches = ContentFilterService.checkText('我 要 打 死 你');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '打死你'), isTrue);
|
||||
});
|
||||
|
||||
test('特殊符号插入不影响检测', () {
|
||||
final matches = ContentFilterService.checkText('废.物.垃.圾');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '废物'), isTrue);
|
||||
});
|
||||
|
||||
test('零宽字符不影响检测', () {
|
||||
// U+200B 零宽空格
|
||||
final matches = ContentFilterService.checkText('废物');
|
||||
expect(matches, isNotEmpty);
|
||||
});
|
||||
|
||||
test('下划线连字符不影响检测', () {
|
||||
final matches = ContentFilterService.checkText('废_物');
|
||||
expect(matches, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 谐音/变体匹配
|
||||
// ============================================================
|
||||
group('谐音变体匹配', () {
|
||||
test('数字谐音 "4" 匹配含 "死" 的词', () {
|
||||
// "去死" 在词库中 → "去4" 应触发匹配
|
||||
final matches = ContentFilterService.checkText('你怎么不去4');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '去死'), isTrue);
|
||||
});
|
||||
|
||||
test('形近字 "草" 匹配含 "操" 的词', () {
|
||||
// "操你" 在词库中 → "草你" 应触发匹配
|
||||
final matches = ContentFilterService.checkText('我草你太牛了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '操你'), isTrue);
|
||||
});
|
||||
|
||||
test('变体 "wc" 匹配 "卧槽"', () {
|
||||
final matches = ContentFilterService.checkText('wc这个好厉害');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '卧槽'), isTrue);
|
||||
});
|
||||
|
||||
test('变体 "莎" 匹配含 "杀" 的词', () {
|
||||
// "杀人" 在词库中 → "莎人" 应触发匹配
|
||||
final matches = ContentFilterService.checkText('我要莎人了');
|
||||
expect(matches, isNotEmpty);
|
||||
expect(matches.any((m) => m.word == '杀人'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 边界条件
|
||||
// ============================================================
|
||||
group('边界条件', () {
|
||||
test('空字符串返回空列表', () {
|
||||
expect(ContentFilterService.checkText(''), isEmpty);
|
||||
});
|
||||
|
||||
test('纯空格返回空列表', () {
|
||||
expect(ContentFilterService.checkText(' '), isEmpty);
|
||||
});
|
||||
|
||||
test('纯符号返回空列表', () {
|
||||
expect(ContentFilterService.checkText('!@#\$%^&*'), isEmpty);
|
||||
});
|
||||
|
||||
test('超长文本不崩溃', () {
|
||||
final longText = '今天天气很好。' * 10000; // ~80,000 字符
|
||||
final matches = ContentFilterService.checkText(longText);
|
||||
expect(matches, isEmpty); // 正常内容
|
||||
});
|
||||
|
||||
test('多次出现同一词精确匹配只报告一次', () {
|
||||
final matches = ContentFilterService.checkText('白痴白痴');
|
||||
final exactMatches = matches.where((m) => m.word == '白痴' && m.position >= 0).toList();
|
||||
expect(exactMatches.length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 辅助方法
|
||||
// ============================================================
|
||||
group('辅助方法', () {
|
||||
test('hasSensitiveContent 正确判断', () {
|
||||
expect(ContentFilterService.hasSensitiveContent('你好世界'), isFalse);
|
||||
expect(ContentFilterService.hasSensitiveContent('你是废物'), isTrue);
|
||||
});
|
||||
|
||||
test('getMatchedCategories 返回分类标签', () {
|
||||
final matches = ContentFilterService.checkText('废物你去死');
|
||||
final categories = ContentFilterService.getMatchedCategories(matches);
|
||||
expect(categories, isNotEmpty);
|
||||
// 至少包含欺凌和暴力
|
||||
expect(categories.any((c) => c == '欺凌'), isTrue);
|
||||
expect(categories.any((c) => c == '暴力'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 词库完整性
|
||||
// ============================================================
|
||||
group('词库完整性', () {
|
||||
test('8 个分类都有词', () {
|
||||
expect(kSensitiveWords.length, 8);
|
||||
for (final entry in kSensitiveWords.entries) {
|
||||
expect(entry.value, isNotEmpty, reason: '${entry.key.label} 分类不应为空');
|
||||
}
|
||||
});
|
||||
|
||||
test('总词量 >= 100', () {
|
||||
final total = kSensitiveWords.values.fold(0, (sum, list) => sum + list.length);
|
||||
expect(total, greaterThanOrEqualTo(100));
|
||||
});
|
||||
|
||||
test('谐音变体映射的 key 都在词库中', () {
|
||||
final allWords = kSensitiveWords.values.expand((w) => w).toSet();
|
||||
for (final key in kHomophoneVariants.keys) {
|
||||
// 变体 key 应该在词库中存在(单字映射除外)
|
||||
// 有些变体 key 是单字如 "死",对应词库中的 "去死" 等
|
||||
expect(
|
||||
allWords.any((w) => w.contains(key)),
|
||||
isTrue,
|
||||
reason: '变体 key "$key" 不在词库中',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -108,6 +108,37 @@ void main() {
|
||||
expect(loaded.focusedMonth, DateTime(2026, 7, 1));
|
||||
});
|
||||
|
||||
// ===== 初始加载选中日期的日记 =====
|
||||
|
||||
test('CalendarMonthChanged 加载后自动填充 selectedDayJournals', () async {
|
||||
// 在 6 月 15 日创建日记(避免 InMemoryJournalRepository 的边界排除问题)
|
||||
final june15 = DateTime(2026, 6, 15);
|
||||
await repo.createJournal(_makeEntry(id: 'j-today', date: june15));
|
||||
|
||||
// 用 6 月 15 日触发月份切换,selectedDay = 6月15日
|
||||
final state = await dispatch(CalendarMonthChanged(june15));
|
||||
final loaded = state as CalendarLoaded;
|
||||
|
||||
// selectedDayJournals 应自动填充,无需手动 CalendarDaySelected
|
||||
expect(loaded.selectedDayJournals, isNotEmpty);
|
||||
expect(loaded.selectedDayJournals.length, 1);
|
||||
expect(loaded.selectedDayJournals.first.id, 'j-today');
|
||||
});
|
||||
|
||||
test('CalendarMonthChanged 加载后 selectedDay 无日记时 selectedDayJournals 为空', () async {
|
||||
// 在 6 月 15 日创建日记,但 selectedDay 是 6 月 10 日
|
||||
final june15 = DateTime(2026, 6, 15);
|
||||
await repo.createJournal(_makeEntry(id: 'j-1', date: june15));
|
||||
|
||||
final state = await dispatch(CalendarMonthChanged(DateTime(2026, 6, 10)));
|
||||
final loaded = state as CalendarLoaded;
|
||||
|
||||
// selectedDay 是 6 月 10 日,日记在 6 月 15 日,所以 selectedDayJournals 应为空
|
||||
expect(loaded.selectedDayJournals, isEmpty);
|
||||
// 但 journalsByDate 应有数据
|
||||
expect(loaded.journalsByDate, isNotEmpty);
|
||||
});
|
||||
|
||||
// ===== 日期选择 =====
|
||||
|
||||
test('CalendarDaySelected 有日记 → selectedDayJournals 不为空', () async {
|
||||
@@ -233,4 +264,7 @@ class _FailingJournalRepository implements JournalRepository {
|
||||
|
||||
@override
|
||||
Future<void> removeElement(String elementId) async {}
|
||||
|
||||
@override
|
||||
Stream<void> get onJournalChanged => const Stream<void>.empty();
|
||||
}
|
||||
|
||||
372
app/test/features/class_/bloc/class_bloc_test.dart
Normal file
@@ -0,0 +1,372 @@
|
||||
// ClassBloc 单元测试
|
||||
//
|
||||
// 覆盖:班级列表加载、选中详情、成员/日记墙/主题/评语加载、创建班级、加入班级、布置主题
|
||||
// 使用 mocktail mock ClassRepository + JournalRepository
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/models/school_class.dart';
|
||||
import 'package:nuanji_app/data/repositories/class_repository.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
import 'package:nuanji_app/features/class_/bloc/class_bloc.dart';
|
||||
|
||||
// ===== Mocks =====
|
||||
|
||||
class MockClassRepository extends Mock implements ClassRepository {}
|
||||
|
||||
class MockJournalRepository extends Mock implements JournalRepository {}
|
||||
|
||||
// ===== 测试数据工厂 =====
|
||||
|
||||
SchoolClass _makeClass({
|
||||
String id = 'class-1',
|
||||
String name = '三年级一班',
|
||||
String schoolName = '暖阳小学',
|
||||
String teacherId = 'teacher-1',
|
||||
String classCode = 'ABC123',
|
||||
int memberCount = 25,
|
||||
}) {
|
||||
return SchoolClass(
|
||||
id: id,
|
||||
name: name,
|
||||
schoolName: schoolName,
|
||||
teacherId: teacherId,
|
||||
classCode: classCode,
|
||||
memberCount: memberCount,
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
updatedAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
}
|
||||
|
||||
JournalEntry _makeJournal({
|
||||
String id = 'j-1',
|
||||
String title = '今天的心情',
|
||||
Mood mood = Mood.happy,
|
||||
bool sharedToClass = true,
|
||||
}) {
|
||||
return JournalEntry(
|
||||
id: id,
|
||||
authorId: 'user-1',
|
||||
title: title,
|
||||
date: DateTime(2026, 6, 1),
|
||||
mood: mood,
|
||||
createdAt: DateTime(2026, 6, 1),
|
||||
updatedAt: DateTime(2026, 6, 1),
|
||||
sharedToClass: sharedToClass,
|
||||
);
|
||||
}
|
||||
|
||||
ClassMemberDto _makeMember({
|
||||
String userId = 'student-1',
|
||||
String role = 'student',
|
||||
String? nickname = '小明',
|
||||
}) {
|
||||
return ClassMemberDto(
|
||||
userId: userId,
|
||||
role: role,
|
||||
nickname: nickname,
|
||||
joinedAt: DateTime(2026, 1, 15),
|
||||
);
|
||||
}
|
||||
|
||||
TopicDto _makeTopic({
|
||||
String id = 'topic-1',
|
||||
String classId = 'class-1',
|
||||
String title = '我的暑假计划',
|
||||
}) {
|
||||
return TopicDto(
|
||||
id: id,
|
||||
classId: classId,
|
||||
teacherId: 'teacher-1',
|
||||
title: title,
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
|
||||
CommentDto _makeComment({
|
||||
String id = 'comment-1',
|
||||
String journalId = 'j-1',
|
||||
String content = '写得很好!',
|
||||
}) {
|
||||
return CommentDto(
|
||||
id: id,
|
||||
journalId: journalId,
|
||||
authorId: 'teacher-1',
|
||||
content: content,
|
||||
createdAt: DateTime(2026, 6, 2),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late MockClassRepository mockClassRepo;
|
||||
late MockJournalRepository mockJournalRepo;
|
||||
late ClassBloc bloc;
|
||||
|
||||
setUp(() {
|
||||
mockClassRepo = MockClassRepository();
|
||||
mockJournalRepo = MockJournalRepository();
|
||||
bloc = ClassBloc(
|
||||
classRepository: mockClassRepo,
|
||||
journalRepository: mockJournalRepo,
|
||||
);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
bloc.close();
|
||||
});
|
||||
|
||||
// ===== 辅助:收集事件触发后的最终状态 =====
|
||||
|
||||
Future<ClassState> dispatch(ClassEvent event) async {
|
||||
bloc.add(event);
|
||||
// 等待所有异步事件处理完毕
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
return bloc.state;
|
||||
}
|
||||
|
||||
// ===== 班级列表 =====
|
||||
|
||||
group('ClassLoadMyClasses', () {
|
||||
test('成功加载班级列表', () async {
|
||||
final classes = [_makeClass(id: 'c1'), _makeClass(id: 'c2', name: '三年级二班')];
|
||||
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => classes);
|
||||
|
||||
final state = await dispatch(const ClassLoadMyClasses());
|
||||
|
||||
expect(state, isA<ClassListLoaded>());
|
||||
final loaded = state as ClassListLoaded;
|
||||
expect(loaded.classes.length, 2);
|
||||
expect(loaded.classes[0].id, 'c1');
|
||||
expect(loaded.classes[1].name, '三年级二班');
|
||||
expect(loaded.isLoading, false);
|
||||
expect(loaded.error, isNull);
|
||||
});
|
||||
|
||||
test('加载失败返回空列表', () async {
|
||||
when(() => mockClassRepo.getMyClasses()).thenThrow(Exception('网络错误'));
|
||||
|
||||
final state = await dispatch(const ClassLoadMyClasses());
|
||||
|
||||
expect(state, isA<ClassListLoaded>());
|
||||
final loaded = state as ClassListLoaded;
|
||||
expect(loaded.classes, isEmpty);
|
||||
expect(loaded.isLoading, false);
|
||||
});
|
||||
|
||||
test('加载中状态先触发', () async {
|
||||
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
return [_makeClass()];
|
||||
});
|
||||
|
||||
bloc.add(const ClassLoadMyClasses());
|
||||
// 立即检查,应该在 loading 状态
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
// state 可能是 loading,也可能已完成,取决于调度
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 选中班级 =====
|
||||
|
||||
group('ClassSelected', () {
|
||||
test('成功选中班级并触发子事件加载', () async {
|
||||
final classInfo = _makeClass();
|
||||
final members = [_makeMember()];
|
||||
final journals = [_makeJournal(sharedToClass: true)];
|
||||
final topics = [_makeTopic()];
|
||||
|
||||
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => classInfo);
|
||||
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => journals);
|
||||
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => members);
|
||||
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => topics);
|
||||
|
||||
final state = await dispatch(const ClassSelected('class-1'));
|
||||
|
||||
expect(state, isA<ClassDetailLoaded>());
|
||||
final detail = state as ClassDetailLoaded;
|
||||
expect(detail.classInfo.id, 'class-1');
|
||||
expect(detail.members.length, 1);
|
||||
expect(detail.topics.length, 1);
|
||||
});
|
||||
|
||||
test('加载失败返回 ClassError', () async {
|
||||
when(() => mockClassRepo.getClass('class-1')).thenThrow(Exception('不存在'));
|
||||
|
||||
final state = await dispatch(const ClassSelected('class-1'));
|
||||
|
||||
expect(state, isA<ClassError>());
|
||||
expect((state as ClassError).message, contains('加载班级失败'));
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 成员加载 =====
|
||||
|
||||
group('ClassLoadMembers', () {
|
||||
test('在 ClassDetailLoaded 状态下加载成员', () async {
|
||||
// 先进入 ClassDetailLoaded
|
||||
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
|
||||
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
|
||||
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
|
||||
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
|
||||
|
||||
await dispatch(const ClassSelected('class-1'));
|
||||
|
||||
// 现在加载成员
|
||||
final members = [_makeMember(), _makeMember(userId: 'student-2', nickname: '小红')];
|
||||
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => members);
|
||||
|
||||
final state = await dispatch(const ClassLoadMembers('class-1'));
|
||||
|
||||
expect(state, isA<ClassDetailLoaded>());
|
||||
final detail = state as ClassDetailLoaded;
|
||||
expect(detail.members.length, 2);
|
||||
expect(detail.members[0].nickname, '小明');
|
||||
expect(detail.members[1].nickname, '小红');
|
||||
});
|
||||
|
||||
test('非 ClassDetailLoaded 状态下忽略', () async {
|
||||
// 初始状态 ClassInitial,直接加载成员应被忽略
|
||||
final state = await dispatch(const ClassLoadMembers('class-1'));
|
||||
|
||||
expect(state, isA<ClassInitial>());
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 日记墙 =====
|
||||
|
||||
group('ClassLoadDiaryWall', () {
|
||||
test('只包含 sharedToClass 为 true 的日记', () async {
|
||||
// 手动进入 ClassDetailLoaded 状态
|
||||
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
|
||||
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
|
||||
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
|
||||
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
|
||||
await dispatch(const ClassSelected('class-1'));
|
||||
// 等待 ClassSelected 的子事件完成
|
||||
await Future<void>.delayed(const Duration(milliseconds: 150));
|
||||
// 此时应该已经在 ClassDetailLoaded 状态
|
||||
expect(bloc.state, isA<ClassDetailLoaded>());
|
||||
|
||||
// 现在单独测试 ClassLoadDiaryWall 过滤逻辑
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', sharedToClass: true),
|
||||
_makeJournal(id: 'j2', sharedToClass: false),
|
||||
_makeJournal(id: 'j3', sharedToClass: true),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const ClassLoadDiaryWall('class-1'));
|
||||
|
||||
expect(state, isA<ClassDetailLoaded>());
|
||||
final detail = state as ClassDetailLoaded;
|
||||
expect(detail.diaryWall.length, 2);
|
||||
expect(detail.diaryWall.every((j) => j.sharedToClass), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 创建班级 =====
|
||||
|
||||
group('ClassCreate', () {
|
||||
test('成功创建班级并添加到列表', () async {
|
||||
// 先加载列表
|
||||
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => [_makeClass(id: 'c1')]);
|
||||
await dispatch(const ClassLoadMyClasses());
|
||||
|
||||
// 创建新班级
|
||||
final newClass = _makeClass(id: 'c2', name: '新班级');
|
||||
when(() => mockClassRepo.createClass(name: '新班级')).thenAnswer((_) async => newClass);
|
||||
|
||||
final state = await dispatch(const ClassCreate(name: '新班级'));
|
||||
|
||||
expect(state, isA<ClassListLoaded>());
|
||||
final loaded = state as ClassListLoaded;
|
||||
expect(loaded.classes.length, 2);
|
||||
expect(loaded.classes.last.id, 'c2');
|
||||
});
|
||||
|
||||
test('创建失败设置 error', () async {
|
||||
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => []);
|
||||
await dispatch(const ClassLoadMyClasses());
|
||||
|
||||
when(() => mockClassRepo.createClass(name: '失败')).thenThrow(Exception('创建失败'));
|
||||
|
||||
final state = await dispatch(const ClassCreate(name: '失败'));
|
||||
|
||||
expect(state, isA<ClassListLoaded>());
|
||||
expect((state as ClassListLoaded).error, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 加入班级 =====
|
||||
|
||||
group('ClassJoin', () {
|
||||
test('加入成功后触发列表刷新', () async {
|
||||
when(() => mockClassRepo.joinClass('ABC123')).thenAnswer((_) async => _makeClass());
|
||||
when(() => mockClassRepo.getMyClasses()).thenAnswer((_) async => [_makeClass()]);
|
||||
|
||||
await dispatch(const ClassJoin(classCode: 'ABC123'));
|
||||
|
||||
// 应该触发 ClassLoadMyClasses,最终状态为 ClassListLoaded
|
||||
expect(bloc.state, isA<ClassListLoaded>());
|
||||
verify(() => mockClassRepo.joinClass('ABC123')).called(1);
|
||||
verify(() => mockClassRepo.getMyClasses()).called(1);
|
||||
});
|
||||
|
||||
test('加入失败返回 ClassError', () async {
|
||||
when(() => mockClassRepo.joinClass('INVALID')).thenThrow(Exception('班级码错误'));
|
||||
|
||||
final state = await dispatch(const ClassJoin(classCode: 'INVALID'));
|
||||
|
||||
expect(state, isA<ClassError>());
|
||||
expect((state as ClassError).message, contains('加入班级失败'));
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 布置主题 =====
|
||||
|
||||
group('TopicAssign', () {
|
||||
test('成功布置主题并添加到列表', () async {
|
||||
// 进入 ClassDetailLoaded
|
||||
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
|
||||
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
|
||||
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
|
||||
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
|
||||
await dispatch(const ClassSelected('class-1'));
|
||||
|
||||
final newTopic = _makeTopic(id: 'topic-2', title: '新主题');
|
||||
when(() => mockClassRepo.assignTopic(classId: 'class-1', title: '新主题'))
|
||||
.thenAnswer((_) async => newTopic);
|
||||
|
||||
final state = await dispatch(const TopicAssign(classId: 'class-1', title: '新主题'));
|
||||
|
||||
expect(state, isA<ClassDetailLoaded>());
|
||||
final detail = state as ClassDetailLoaded;
|
||||
expect(detail.topics.length, 1);
|
||||
expect(detail.topics.first.title, '新主题');
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 评语 =====
|
||||
|
||||
group('ClassLoadComments', () {
|
||||
test('加载评语并设置 selectedJournalId', () async {
|
||||
// 进入 ClassDetailLoaded
|
||||
when(() => mockClassRepo.getClass('class-1')).thenAnswer((_) async => _makeClass());
|
||||
when(() => mockJournalRepo.getJournals(classId: 'class-1')).thenAnswer((_) async => []);
|
||||
when(() => mockClassRepo.getMembers('class-1')).thenAnswer((_) async => []);
|
||||
when(() => mockClassRepo.getTopics('class-1')).thenAnswer((_) async => []);
|
||||
await dispatch(const ClassSelected('class-1'));
|
||||
|
||||
final comments = [_makeComment(), _makeComment(id: 'comment-2', content: '继续加油')];
|
||||
when(() => mockClassRepo.getComments('j-1')).thenAnswer((_) async => comments);
|
||||
|
||||
final state = await dispatch(const ClassLoadComments('j-1'));
|
||||
|
||||
expect(state, isA<ClassDetailLoaded>());
|
||||
final detail = state as ClassDetailLoaded;
|
||||
expect(detail.comments.length, 2);
|
||||
expect(detail.selectedJournalId, 'j-1');
|
||||
});
|
||||
});
|
||||
}
|
||||
272
app/test/features/editor/widgets/handwriting_canvas_test.dart
Normal file
@@ -0,0 +1,272 @@
|
||||
// HandwritingCanvas Widget 集成测试 — 指针事件驱动笔画完成回调
|
||||
//
|
||||
// 验证:
|
||||
// 1. Widget 正确渲染(双层 CustomPaint)
|
||||
// 2. 手势事件触发 onStrokeCompleted 回调
|
||||
// 3. 不同画笔类型/颜色/宽度正确传递
|
||||
// 4. 去抖过滤(微小移动被丢弃)
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuanji_app/features/editor/widgets/handwriting_canvas.dart';
|
||||
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
|
||||
|
||||
void main() {
|
||||
// ============================================================
|
||||
// 辅助
|
||||
// ============================================================
|
||||
|
||||
/// 包裹 HandwritingCanvas 在必要的父组件中,提供约束尺寸
|
||||
Widget buildTestSubject({
|
||||
Key? key,
|
||||
BrushType brushType = BrushType.pen,
|
||||
String brushColor = '#2D2420',
|
||||
double brushWidth = 3.0,
|
||||
List<Stroke> strokes = const [],
|
||||
ValueChanged<Stroke>? onStrokeCompleted,
|
||||
}) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 800,
|
||||
height: 600,
|
||||
child: HandwritingCanvas(
|
||||
key: key,
|
||||
brushType: brushType,
|
||||
brushColor: brushColor,
|
||||
brushWidth: brushWidth,
|
||||
strokes: strokes,
|
||||
onStrokeCompleted: onStrokeCompleted,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 使用标准 TestGesture 模拟一条完整的拖拽手势
|
||||
Future<void> simulateDragStroke(
|
||||
WidgetTester tester,
|
||||
List<Offset> points,
|
||||
) async {
|
||||
assert(points.length >= 2, '至少需要 down 和 up 两个点');
|
||||
|
||||
final gesture = await tester.startGesture(points.first);
|
||||
await tester.pump();
|
||||
|
||||
for (var i = 1; i < points.length; i++) {
|
||||
await gesture.moveTo(points[i]);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
await gesture.up();
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 渲染结构验证
|
||||
// ============================================================
|
||||
group('HandwritingCanvas — 渲染结构', () {
|
||||
testWidgets('正确渲染双层 CustomPaint', (tester) async {
|
||||
await tester.pumpWidget(buildTestSubject());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 应找到 CustomPaint(两层:CachedStrokesPainter + ActiveStrokePainter)
|
||||
final customPaints = find.byType(CustomPaint);
|
||||
expect(customPaints, findsAtLeast(2));
|
||||
|
||||
// 应找到 Listener(HandwritingCanvas 的 Listener + Gesture 识别器可能有额外 Listener)
|
||||
expect(find.byType(Listener), findsAtLeast(1));
|
||||
|
||||
// 应找到 RepaintBoundary(MaterialApp/Scaffold 可能添加额外的)
|
||||
expect(find.byType(RepaintBoundary), findsAtLeast(1));
|
||||
});
|
||||
|
||||
testWidgets('初始无笔画时仍正确渲染', (tester) async {
|
||||
await tester.pumpWidget(buildTestSubject());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(HandwritingCanvas), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 手势事件 → onStrokeCompleted
|
||||
// ============================================================
|
||||
group('HandwritingCanvas — 笔画完成回调', () {
|
||||
testWidgets('有效拖拽触发 onStrokeCompleted', (tester) async {
|
||||
Stroke? completedStroke;
|
||||
await tester.pumpWidget(buildTestSubject(
|
||||
onStrokeCompleted: (stroke) => completedStroke = stroke,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 模拟 5 个点的笔画(距离足够大,避免去抖过滤)
|
||||
await simulateDragStroke(tester, [
|
||||
const Offset(100, 100),
|
||||
const Offset(150, 120),
|
||||
const Offset(200, 140),
|
||||
const Offset(250, 160),
|
||||
const Offset(300, 180),
|
||||
]);
|
||||
|
||||
// 应触发回调
|
||||
expect(completedStroke, isNotNull);
|
||||
expect(completedStroke!.points.length, greaterThanOrEqualTo(2));
|
||||
expect(completedStroke!.brushType, BrushType.pen);
|
||||
expect(completedStroke!.color, '#2D2420');
|
||||
expect(completedStroke!.width, 3.0);
|
||||
});
|
||||
|
||||
testWidgets('笔画携带正确的画笔类型', (tester) async {
|
||||
Stroke? completedStroke;
|
||||
await tester.pumpWidget(buildTestSubject(
|
||||
brushType: BrushType.marker,
|
||||
onStrokeCompleted: (stroke) => completedStroke = stroke,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await simulateDragStroke(tester, [
|
||||
const Offset(100, 100),
|
||||
const Offset(200, 200),
|
||||
const Offset(300, 100),
|
||||
]);
|
||||
|
||||
expect(completedStroke, isNotNull);
|
||||
expect(completedStroke!.brushType, BrushType.marker);
|
||||
});
|
||||
|
||||
testWidgets('笔画携带正确的颜色和宽度', (tester) async {
|
||||
Stroke? completedStroke;
|
||||
await tester.pumpWidget(buildTestSubject(
|
||||
brushColor: '#E07A5F',
|
||||
brushWidth: 8.0,
|
||||
onStrokeCompleted: (stroke) => completedStroke = stroke,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await simulateDragStroke(tester, [
|
||||
const Offset(50, 50),
|
||||
const Offset(150, 150),
|
||||
const Offset(250, 50),
|
||||
]);
|
||||
|
||||
expect(completedStroke!.color, '#E07A5F');
|
||||
expect(completedStroke!.width, 8.0);
|
||||
});
|
||||
|
||||
testWidgets('tap(无拖拽)不触发回调', (tester) async {
|
||||
Stroke? completedStroke;
|
||||
await tester.pumpWidget(buildTestSubject(
|
||||
onStrokeCompleted: (stroke) => completedStroke = stroke,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 仅 tap(down + up 在同一位置)— 单点不足以构成笔画
|
||||
await tester.tapAt(const Offset(100, 100));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap 产生 1 个点(down 和 up 位置相同),不触发回调
|
||||
expect(completedStroke, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 预加载笔画
|
||||
// ============================================================
|
||||
group('HandwritingCanvas — 预加载笔画', () {
|
||||
testWidgets('初始笔画列表正确传入', (tester) async {
|
||||
final existingStrokes = [
|
||||
Stroke(
|
||||
id: 'existing-1',
|
||||
points: [
|
||||
const StrokePoint(x: 10, y: 10),
|
||||
const StrokePoint(x: 100, y: 100),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(buildTestSubject(
|
||||
strokes: existingStrokes,
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(HandwritingCanvas), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('笔画更新触发 didUpdateWidget', (tester) async {
|
||||
final key = GlobalKey();
|
||||
|
||||
// 第一次渲染 — 无笔画
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 800,
|
||||
height: 600,
|
||||
child: HandwritingCanvas(
|
||||
key: key,
|
||||
strokes: const [],
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 更新 — 添加笔画
|
||||
final updatedStrokes = [
|
||||
Stroke(
|
||||
id: 'new-1',
|
||||
points: [
|
||||
const StrokePoint(x: 50, y: 50),
|
||||
const StrokePoint(x: 200, y: 200),
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 800,
|
||||
height: 600,
|
||||
child: HandwritingCanvas(
|
||||
key: key,
|
||||
strokes: updatedStrokes,
|
||||
),
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(HandwritingCanvas), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 连续多笔画
|
||||
// ============================================================
|
||||
group('HandwritingCanvas — 连续多笔画', () {
|
||||
testWidgets('连续绘制多条笔画,每条都触发回调', (tester) async {
|
||||
final completedStrokes = <Stroke>[];
|
||||
await tester.pumpWidget(buildTestSubject(
|
||||
onStrokeCompleted: (stroke) => completedStrokes.add(stroke),
|
||||
));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 第一条笔画
|
||||
await simulateDragStroke(tester, [
|
||||
const Offset(50, 50),
|
||||
const Offset(150, 100),
|
||||
const Offset(250, 50),
|
||||
]);
|
||||
|
||||
// 第二条笔画(使用新的 gesture)
|
||||
await simulateDragStroke(tester, [
|
||||
const Offset(100, 200),
|
||||
const Offset(200, 300),
|
||||
const Offset(300, 200),
|
||||
]);
|
||||
|
||||
expect(completedStrokes.length, 2);
|
||||
expect(completedStrokes[0].id, isNot(equals(completedStrokes[1].id)));
|
||||
});
|
||||
});
|
||||
}
|
||||
238
app/test/features/editor/widgets/stroke_cache_test.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
// StrokeRasterCache 单元测试 — 光栅化缓存管理器
|
||||
//
|
||||
// 注意:ui.PictureRecorder().endRecording().toImage() 需要 Flutter Test 绑定,
|
||||
// 因此这些测试在 flutter test 环境中运行(自动提供 TestWidgetsFlutterBinding)。
|
||||
// 使用足够大的画布尺寸(>0)才能使光栅化生效。
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuanji_app/features/editor/widgets/stroke_cache.dart';
|
||||
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
|
||||
|
||||
void main() {
|
||||
// ============================================================
|
||||
// 辅助
|
||||
// ============================================================
|
||||
|
||||
/// 构造一条从 (x0,y0) 到 (x1,y1) 的简单笔画
|
||||
Stroke makeStroke(
|
||||
String id, {
|
||||
double x0 = 10.0,
|
||||
double y0 = 10.0,
|
||||
double x1 = 200.0,
|
||||
double y1 = 200.0,
|
||||
BrushType brushType = BrushType.pen,
|
||||
String color = '#2D2420',
|
||||
double width = 3.0,
|
||||
int pointCount = 10,
|
||||
}) {
|
||||
return Stroke(
|
||||
id: id,
|
||||
points: List.generate(
|
||||
pointCount,
|
||||
(i) {
|
||||
final t = i / (pointCount - 1);
|
||||
return StrokePoint(
|
||||
x: x0 + (x1 - x0) * t,
|
||||
y: y0 + (y1 - y0) * t,
|
||||
pressure: 0.5,
|
||||
timestamp: i * 16,
|
||||
);
|
||||
},
|
||||
),
|
||||
brushType: brushType,
|
||||
color: color,
|
||||
width: width,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 生命周期与基本属性
|
||||
// ============================================================
|
||||
group('StrokeRasterCache — 生命周期', () {
|
||||
test('初始状态为空', () {
|
||||
final cache = StrokeRasterCache();
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
expect(cache.compositeImage, isNull);
|
||||
expect(cache.layerVersion, 0);
|
||||
expect(cache.length, 0);
|
||||
expect(cache.cachedStrokeIds, isEmpty);
|
||||
});
|
||||
|
||||
test('dispose 后可安全调用', () {
|
||||
final cache = StrokeRasterCache();
|
||||
cache.dispose();
|
||||
// 不应抛异常
|
||||
expect(cache.compositeImage, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 尺寸管理
|
||||
// ============================================================
|
||||
group('StrokeRasterCache — 尺寸管理', () {
|
||||
test('ensureSize 设置画布尺寸', () {
|
||||
final cache = StrokeRasterCache();
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
cache.ensureSize(const Size(800, 600));
|
||||
expect(cache.canvasSize, const Size(800, 600));
|
||||
});
|
||||
|
||||
test('ensureSize 相同尺寸不触发失效', () {
|
||||
final cache = StrokeRasterCache();
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
cache.ensureSize(const Size(800, 600));
|
||||
final v1 = cache.layerVersion;
|
||||
cache.ensureSize(const Size(800, 600)); // 相同尺寸
|
||||
expect(cache.layerVersion, v1); // 版本不变
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 笔画操作
|
||||
// ============================================================
|
||||
group('StrokeRasterCache — 笔画操作', () {
|
||||
late StrokeRasterCache cache;
|
||||
|
||||
setUp(() {
|
||||
cache = StrokeRasterCache();
|
||||
cache.ensureSize(const Size(800, 600));
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
cache.dispose();
|
||||
});
|
||||
|
||||
test('addStroke 缓存笔画并递增版本', () async {
|
||||
final stroke = makeStroke('s1');
|
||||
await cache.addStroke(stroke);
|
||||
|
||||
expect(cache.length, 1);
|
||||
expect(cache.cachedStrokeIds, contains('s1'));
|
||||
expect(cache.layerVersion, greaterThan(0));
|
||||
expect(cache.compositeImage, isNotNull);
|
||||
});
|
||||
|
||||
test('addStroke 在画布尺寸为零时跳过', () async {
|
||||
final emptyCache = StrokeRasterCache();
|
||||
addTearDown(emptyCache.dispose);
|
||||
|
||||
// 未调用 ensureSize,canvasSize == Size.zero
|
||||
await emptyCache.addStroke(makeStroke('s1'));
|
||||
expect(emptyCache.length, 0);
|
||||
});
|
||||
|
||||
test('多条笔画增量合成', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
final v1 = cache.layerVersion;
|
||||
|
||||
await cache.addStroke(makeStroke('s2'));
|
||||
final v2 = cache.layerVersion;
|
||||
|
||||
await cache.addStroke(makeStroke('s3'));
|
||||
|
||||
expect(cache.length, 3);
|
||||
expect(v2, greaterThan(v1));
|
||||
expect(cache.layerVersion, greaterThan(v2));
|
||||
});
|
||||
|
||||
test('不同画笔类型均可光栅化', () async {
|
||||
for (final bt in BrushType.values) {
|
||||
final id = 'stroke-${bt.value}';
|
||||
await cache.addStroke(makeStroke(
|
||||
id,
|
||||
brushType: bt,
|
||||
color: bt == BrushType.eraser ? '#FFFFFF' : '#E07A5F',
|
||||
));
|
||||
expect(cache.cachedStrokeIds, contains(id), reason: '$bt 应能光栅化');
|
||||
}
|
||||
expect(cache.length, BrushType.values.length);
|
||||
});
|
||||
|
||||
test('clear 清除所有缓存', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
await cache.addStroke(makeStroke('s2'));
|
||||
expect(cache.length, 2);
|
||||
|
||||
await cache.clear();
|
||||
expect(cache.length, 0);
|
||||
expect(cache.compositeImage, isNull);
|
||||
expect(cache.cachedStrokeIds, isEmpty);
|
||||
});
|
||||
|
||||
test('syncStrokes 添加缺失笔画', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
expect(cache.length, 1);
|
||||
|
||||
// syncStrokes 传入 s1 + s2,应只添加 s2
|
||||
await cache.syncStrokes([makeStroke('s1'), makeStroke('s2')]);
|
||||
expect(cache.length, 2);
|
||||
expect(cache.cachedStrokeIds, containsAll(['s1', 's2']));
|
||||
});
|
||||
|
||||
test('syncStrokes 移除多余笔画(模拟撤销)', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
await cache.addStroke(makeStroke('s2'));
|
||||
expect(cache.length, 2);
|
||||
|
||||
// syncStrokes 只保留 s1,移除 s2
|
||||
await cache.syncStrokes([makeStroke('s1')]);
|
||||
expect(cache.length, 1);
|
||||
expect(cache.cachedStrokeIds, {'s1'});
|
||||
});
|
||||
|
||||
test('syncStrokes 无变化时不增加版本', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
final v = cache.layerVersion;
|
||||
|
||||
// 传入完全相同的笔画列表
|
||||
await cache.syncStrokes([makeStroke('s1')]);
|
||||
expect(cache.layerVersion, v);
|
||||
});
|
||||
|
||||
test('invalidateAll 重建所有缓存', () async {
|
||||
await cache.addStroke(makeStroke('s1'));
|
||||
final v1 = cache.layerVersion;
|
||||
|
||||
// 尺寸变化触发 invalidateAll
|
||||
cache.ensureSize(const Size(1024, 768));
|
||||
await cache.addStroke(makeStroke('s1')); // 重建后重新添加
|
||||
|
||||
// 版本应高于之前(因为 invalidateAll + addStroke 都递增)
|
||||
expect(cache.layerVersion, greaterThan(v1));
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 空边界条件
|
||||
// ============================================================
|
||||
group('StrokeRasterCache — 边界条件', () {
|
||||
test('单点笔画不会生成缓存条目', () async {
|
||||
final cache = StrokeRasterCache();
|
||||
cache.ensureSize(const Size(800, 600));
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
final singlePointStroke = Stroke(
|
||||
id: 'single',
|
||||
points: [const StrokePoint(x: 50, y: 50)],
|
||||
);
|
||||
await cache.addStroke(singlePointStroke);
|
||||
|
||||
// 单点 → pointsToOutline 返回空 → _rasterizeStroke 返回 null
|
||||
expect(cache.length, 0);
|
||||
});
|
||||
|
||||
test('空笔画列表的 syncStrokes 不报错', () async {
|
||||
final cache = StrokeRasterCache();
|
||||
cache.ensureSize(const Size(800, 600));
|
||||
addTearDown(cache.dispose);
|
||||
|
||||
await cache.syncStrokes([]); // 不应抛
|
||||
expect(cache.length, 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
159
app/test/features/editor/widgets/stroke_model_test.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
// StrokeModel 单元测试 — 笔画数据模型的序列化与不可变性验证
|
||||
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
|
||||
|
||||
void main() {
|
||||
// ============================================================
|
||||
// StrokePoint
|
||||
// ============================================================
|
||||
group('StrokePoint', () {
|
||||
test('构造函数设置默认值', () {
|
||||
const point = StrokePoint(x: 10.0, y: 20.0);
|
||||
expect(point.x, 10.0);
|
||||
expect(point.y, 20.0);
|
||||
expect(point.pressure, 0.5);
|
||||
expect(point.timestamp, 0);
|
||||
});
|
||||
|
||||
test('copyWith 返回新实例,原实例不变', () {
|
||||
const original = StrokePoint(x: 1.0, y: 2.0, pressure: 0.3, timestamp: 100);
|
||||
final copied = original.copyWith(x: 10.0, pressure: 0.8);
|
||||
|
||||
expect(copied.x, 10.0);
|
||||
expect(copied.y, 2.0); // 未变
|
||||
expect(copied.pressure, 0.8);
|
||||
expect(copied.timestamp, 100); // 未变
|
||||
|
||||
// 原实例不变
|
||||
expect(original.x, 1.0);
|
||||
expect(original.pressure, 0.3);
|
||||
});
|
||||
|
||||
test('toJson → fromJson 往返一致', () {
|
||||
const point = StrokePoint(x: 123.456, y: 789.012, pressure: 0.75, timestamp: 1700000000);
|
||||
final json = point.toJson();
|
||||
final restored = StrokePoint.fromJson(json);
|
||||
|
||||
expect(restored.x, closeTo(point.x, 0.001));
|
||||
expect(restored.y, closeTo(point.y, 0.001));
|
||||
expect(restored.pressure, closeTo(point.pressure, 0.001));
|
||||
expect(restored.timestamp, point.timestamp);
|
||||
});
|
||||
|
||||
test('fromJson 处理缺失字段使用默认值', () {
|
||||
final restored = StrokePoint.fromJson({'x': 5.0, 'y': 10.0});
|
||||
expect(restored.pressure, 0.5);
|
||||
expect(restored.timestamp, 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Stroke
|
||||
// ============================================================
|
||||
group('Stroke', () {
|
||||
List<StrokePoint> makePoints(int count) => List.generate(
|
||||
count,
|
||||
(i) => StrokePoint(x: i * 10.0, y: i * 5.0, pressure: 0.5, timestamp: i * 16),
|
||||
);
|
||||
|
||||
test('构造函数设置默认值', () {
|
||||
final stroke = Stroke(id: 'test-1', points: makePoints(3));
|
||||
expect(stroke.id, 'test-1');
|
||||
expect(stroke.brushType, BrushType.pen);
|
||||
expect(stroke.color, '#2D2420');
|
||||
expect(stroke.width, 3.0);
|
||||
});
|
||||
|
||||
test('copyWith 返回新实例', () {
|
||||
final original = Stroke(
|
||||
id: 's1',
|
||||
points: makePoints(3),
|
||||
brushType: BrushType.marker,
|
||||
color: '#FF0000',
|
||||
width: 5.0,
|
||||
);
|
||||
final copied = original.copyWith(color: '#00FF00', width: 8.0);
|
||||
|
||||
expect(copied.id, 's1'); // 未变
|
||||
expect(copied.brushType, BrushType.marker); // 未变
|
||||
expect(copied.color, '#00FF00');
|
||||
expect(copied.width, 8.0);
|
||||
});
|
||||
|
||||
test('toJson → fromJson 往返一致', () {
|
||||
final stroke = Stroke(
|
||||
id: 'abc-123',
|
||||
points: makePoints(5),
|
||||
brushType: BrushType.pencil,
|
||||
color: '#81B29A',
|
||||
width: 2.0,
|
||||
);
|
||||
final json = stroke.toJson();
|
||||
final restored = Stroke.fromJson(json);
|
||||
|
||||
expect(restored.id, stroke.id);
|
||||
expect(restored.brushType, stroke.brushType);
|
||||
expect(restored.color, stroke.color);
|
||||
expect(restored.width, stroke.width);
|
||||
expect(restored.points.length, stroke.points.length);
|
||||
for (var i = 0; i < stroke.points.length; i++) {
|
||||
expect(restored.points[i].x, closeTo(stroke.points[i].x, 0.001));
|
||||
expect(restored.points[i].y, closeTo(stroke.points[i].y, 0.001));
|
||||
}
|
||||
});
|
||||
|
||||
test('fromJson 产生不可变点列表', () {
|
||||
final stroke = Stroke.fromJson({
|
||||
'id': 'immutable-test',
|
||||
'points': [
|
||||
{'x': 1.0, 'y': 2.0},
|
||||
{'x': 3.0, 'y': 4.0},
|
||||
],
|
||||
});
|
||||
expect(stroke.points, isA<UnmodifiableListView<StrokePoint>>());
|
||||
});
|
||||
|
||||
test('fromJson 处理缺失可选字段使用默认值', () {
|
||||
final restored = Stroke.fromJson({
|
||||
'id': 'minimal',
|
||||
'points': [
|
||||
{'x': 0.0, 'y': 0.0},
|
||||
],
|
||||
});
|
||||
expect(restored.brushType, BrushType.pen);
|
||||
expect(restored.color, '#2D2420');
|
||||
expect(restored.width, 3.0);
|
||||
});
|
||||
|
||||
test('fromJson 处理未知 brushType 回退到 pen', () {
|
||||
final restored = Stroke.fromJson({
|
||||
'id': 'unknown-brush',
|
||||
'points': [
|
||||
{'x': 0.0, 'y': 0.0},
|
||||
],
|
||||
'brushType': 'nonexistent',
|
||||
});
|
||||
expect(restored.brushType, BrushType.pen);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// BrushType 枚举
|
||||
// ============================================================
|
||||
group('BrushType', () {
|
||||
test('包含全部 4 种画笔', () {
|
||||
expect(BrushType.values.length, 4);
|
||||
expect(BrushType.values.map((b) => b.value), ['pen', 'pencil', 'marker', 'eraser']);
|
||||
});
|
||||
|
||||
test('value 与枚举一一对应', () {
|
||||
for (final bt in BrushType.values) {
|
||||
final found = BrushType.values.firstWhere((b) => b.value == bt.value);
|
||||
expect(found, bt);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
214
app/test/features/editor/widgets/stroke_renderer_test.dart
Normal file
@@ -0,0 +1,214 @@
|
||||
// StrokeRenderer 单元测试 — 纯函数验证
|
||||
//
|
||||
// 覆盖:pointsToOutline、buildStrokePath、parseHexColor、createPaintForStroke
|
||||
// 不依赖 Flutter 绑定(dart:ui 的 Canvas/Image),仅测试纯逻辑。
|
||||
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuanji_app/features/editor/widgets/stroke_model.dart';
|
||||
import 'package:nuanji_app/features/editor/widgets/stroke_renderer.dart';
|
||||
|
||||
void main() {
|
||||
// ============================================================
|
||||
// parseHexColor
|
||||
// ============================================================
|
||||
group('parseHexColor', () {
|
||||
test('解析标准 #RRGGBB 格式', () {
|
||||
final color = parseHexColor('#E07A5F');
|
||||
expect(color.value, const Color(0xFFE07A5F).value);
|
||||
});
|
||||
|
||||
test('解析不带 # 的 6 位十六进制', () {
|
||||
// parseHexColor 会先 replaceFirst('#', ''),所以直接传 6 位也应该工作
|
||||
final color = parseHexColor('#2D2420');
|
||||
expect(color, const Color(0xFF2D2420));
|
||||
});
|
||||
|
||||
test('全黑 #000000', () {
|
||||
expect(parseHexColor('#000000').value, const Color(0xFF000000).value);
|
||||
});
|
||||
|
||||
test('全白 #FFFFFF', () {
|
||||
expect(parseHexColor('#FFFFFF').value, const Color(0xFFFFFFFF).value);
|
||||
});
|
||||
|
||||
test('无效长度回退到默认色', () {
|
||||
const fallback = Color(0xFF2D2420);
|
||||
expect(parseHexColor('#FFF').value, fallback.value);
|
||||
expect(parseHexColor('#12345').value, fallback.value);
|
||||
expect(parseHexColor('').value, fallback.value);
|
||||
});
|
||||
|
||||
test('无效字符回退到默认色', () {
|
||||
expect(parseHexColor('#GGGGGG').value, const Color(0xFF2D2420).value);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// pointsToOutline
|
||||
// ============================================================
|
||||
group('pointsToOutline', () {
|
||||
/// 构造 N 个均匀分布的点
|
||||
List<StrokePoint> makeLinearPoints(int count) => List.generate(
|
||||
count,
|
||||
(i) => StrokePoint(
|
||||
x: i * 10.0,
|
||||
y: i * 10.0,
|
||||
pressure: 0.5,
|
||||
timestamp: i * 16,
|
||||
),
|
||||
);
|
||||
|
||||
test('少于 2 个点返回空列表', () {
|
||||
final empty = pointsToOutline([], BrushType.pen, 3.0);
|
||||
expect(empty, isEmpty);
|
||||
|
||||
final onePoint = pointsToOutline(
|
||||
[const StrokePoint(x: 0, y: 0)],
|
||||
BrushType.pen,
|
||||
3.0,
|
||||
);
|
||||
expect(onePoint, isEmpty);
|
||||
});
|
||||
|
||||
test('2 个点生成非空轮廓', () {
|
||||
final points = makeLinearPoints(2);
|
||||
final outline = pointsToOutline(points, BrushType.pen, 3.0);
|
||||
expect(outline, isNotEmpty);
|
||||
// perfect_freehand 生成的是封闭轮廓,点数远多于输入点
|
||||
expect(outline.length, greaterThan(points.length));
|
||||
});
|
||||
|
||||
test('4 种画笔类型均能生成轮廓', () {
|
||||
final points = makeLinearPoints(10);
|
||||
for (final bt in BrushType.values) {
|
||||
final outline = pointsToOutline(points, bt, 3.0);
|
||||
expect(outline, isNotEmpty, reason: '$bt 应生成非空轮廓');
|
||||
}
|
||||
});
|
||||
|
||||
test('宽度影响轮廓大小 — 更大的 width 产生更大的轮廓', () {
|
||||
final points = makeLinearPoints(10);
|
||||
final outlineThin = pointsToOutline(points, BrushType.pen, 1.0);
|
||||
final outlineThick = pointsToOutline(points, BrushType.pen, 8.0);
|
||||
|
||||
// 计算轮廓的包围盒面积作为粗略大小指标
|
||||
double bboxArea(List<Offset> pts) {
|
||||
if (pts.isEmpty) return 0;
|
||||
double minX = double.infinity, maxX = double.negativeInfinity;
|
||||
double minY = double.infinity, maxY = double.negativeInfinity;
|
||||
for (final p in pts) {
|
||||
if (p.dx < minX) minX = p.dx;
|
||||
if (p.dx > maxX) maxX = p.dx;
|
||||
if (p.dy < minY) minY = p.dy;
|
||||
if (p.dy > maxY) maxY = p.dy;
|
||||
}
|
||||
return (maxX - minX) * (maxY - minY);
|
||||
}
|
||||
|
||||
expect(bboxArea(outlineThick), greaterThan(bboxArea(outlineThin)));
|
||||
});
|
||||
|
||||
test('isComplete 参数影响输出(端点处理)', () {
|
||||
final points = makeLinearPoints(5);
|
||||
final complete = pointsToOutline(points, BrushType.pen, 3.0, isComplete: true);
|
||||
final active = pointsToOutline(points, BrushType.pen, 3.0, isComplete: false);
|
||||
|
||||
// 两者都应该生成轮廓,但端点处理不同
|
||||
expect(complete, isNotEmpty);
|
||||
expect(active, isNotEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// buildStrokePath
|
||||
// ============================================================
|
||||
group('buildStrokePath', () {
|
||||
test('空列表返回空 Path', () {
|
||||
final path = buildStrokePath([]);
|
||||
// 空 path 的 bounds 是零矩形
|
||||
expect(path.getBounds().isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('非空点列表返回有效 Path', () {
|
||||
final points = [
|
||||
const Offset(0, 0),
|
||||
const Offset(10, 10),
|
||||
const Offset(20, 5),
|
||||
const Offset(30, 15),
|
||||
];
|
||||
final path = buildStrokePath(points);
|
||||
expect(path.getBounds().isEmpty, isFalse);
|
||||
expect(path.getBounds().width, greaterThan(0));
|
||||
expect(path.getBounds().height, greaterThan(0));
|
||||
});
|
||||
|
||||
test('路径包围盒包含所有输入点', () {
|
||||
final points = [
|
||||
const Offset(5, 5),
|
||||
const Offset(100, 50),
|
||||
const Offset(200, 100),
|
||||
];
|
||||
final path = buildStrokePath(points);
|
||||
final bounds = path.getBounds();
|
||||
|
||||
for (final p in points) {
|
||||
expect(bounds.left, lessThanOrEqualTo(p.dx));
|
||||
expect(bounds.top, lessThanOrEqualTo(p.dy));
|
||||
expect(bounds.right, greaterThanOrEqualTo(p.dx));
|
||||
expect(bounds.bottom, greaterThanOrEqualTo(p.dy));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// createPaintForStroke
|
||||
// ============================================================
|
||||
group('createPaintForStroke', () {
|
||||
Stroke makeStroke(BrushType type, {String color = '#2D2420', double width = 3.0}) {
|
||||
return Stroke(
|
||||
id: 'test',
|
||||
points: [
|
||||
const StrokePoint(x: 0, y: 0),
|
||||
const StrokePoint(x: 100, y: 100),
|
||||
],
|
||||
brushType: type,
|
||||
color: color,
|
||||
width: width,
|
||||
);
|
||||
}
|
||||
|
||||
test('钢笔 — 不透明实心填充', () {
|
||||
final paint = createPaintForStroke(makeStroke(BrushType.pen));
|
||||
expect(paint.color.value, parseHexColor('#2D2420').value);
|
||||
expect(paint.style, PaintingStyle.fill);
|
||||
expect(paint.isAntiAlias, isTrue);
|
||||
});
|
||||
|
||||
test('铅笔 — 不透明实心填充', () {
|
||||
final paint = createPaintForStroke(makeStroke(BrushType.pencil));
|
||||
expect(paint.style, PaintingStyle.fill);
|
||||
expect(paint.isAntiAlias, isTrue);
|
||||
});
|
||||
|
||||
test('马克笔 — 半透明', () {
|
||||
final paint = createPaintForStroke(makeStroke(BrushType.marker, color: '#E07A5F'));
|
||||
// alpha = 0.4
|
||||
expect(paint.color.alpha, closeTo(102, 1)); // 0.4 * 255 ≈ 102
|
||||
expect(paint.style, PaintingStyle.fill);
|
||||
});
|
||||
|
||||
test('橡皮擦 — dstOut 混合模式', () {
|
||||
final paint = createPaintForStroke(makeStroke(BrushType.eraser));
|
||||
expect(paint.blendMode, BlendMode.dstOut);
|
||||
expect(paint.style, PaintingStyle.fill);
|
||||
expect(paint.isAntiAlias, isTrue);
|
||||
});
|
||||
|
||||
test('颜色正确传递', () {
|
||||
final paint = createPaintForStroke(makeStroke(BrushType.pen, color: '#81B29A'));
|
||||
expect(paint.color.value, parseHexColor('#81B29A').value);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -271,6 +271,9 @@ class _FailingJournalRepository implements JournalRepository {
|
||||
Future<void> removeElement(String elementId) async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<void> get onJournalChanged => const Stream<void>.empty();
|
||||
}
|
||||
|
||||
/// 在指定 bloc 上触发事件并等待处理完毕
|
||||
|
||||
293
app/test/features/search/bloc/search_bloc_test.dart
Normal file
@@ -0,0 +1,293 @@
|
||||
// SearchBloc 单元测试
|
||||
//
|
||||
// 覆盖:关键词搜索、标签搜索、心情筛选、搜索历史、清除搜索、tab 切换
|
||||
// 使用 mocktail mock JournalRepository
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/data/repositories/journal_repository.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:nuanji_app/features/search/bloc/search_bloc.dart';
|
||||
|
||||
// ===== Mock =====
|
||||
|
||||
class MockJournalRepository extends Mock implements JournalRepository {}
|
||||
|
||||
// ===== 测试数据工厂 =====
|
||||
|
||||
JournalEntry _makeJournal({
|
||||
String id = 'j-1',
|
||||
String title = '今天的心情日记',
|
||||
Mood mood = Mood.happy,
|
||||
String? contentExcerpt,
|
||||
List<String> tags = const [],
|
||||
}) {
|
||||
return JournalEntry(
|
||||
id: id,
|
||||
authorId: 'user-1',
|
||||
title: title,
|
||||
date: DateTime(2026, 6, 1),
|
||||
mood: mood,
|
||||
contentExcerpt: contentExcerpt,
|
||||
tags: tags,
|
||||
createdAt: DateTime(2026, 6, 1),
|
||||
updatedAt: DateTime(2026, 6, 1),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late MockJournalRepository mockJournalRepo;
|
||||
late SearchBloc bloc;
|
||||
|
||||
setUp(() {
|
||||
mockJournalRepo = MockJournalRepository();
|
||||
bloc = SearchBloc(journalRepository: mockJournalRepo);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
bloc.close();
|
||||
});
|
||||
|
||||
/// 辅助:dispatch 并等待处理完成
|
||||
Future<SearchState> dispatch(SearchEvent event) async {
|
||||
bloc.add(event);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
return bloc.state;
|
||||
}
|
||||
|
||||
// ===== 关键词搜索 =====
|
||||
|
||||
group('SearchByKeyword', () {
|
||||
test('空关键词不触发搜索,返回空结果', () async {
|
||||
final state = await dispatch(const SearchByKeyword(''));
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results, isEmpty);
|
||||
});
|
||||
|
||||
test('纯空格关键词视为空', () async {
|
||||
final state = await dispatch(const SearchByKeyword(' '));
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results, isEmpty);
|
||||
});
|
||||
|
||||
test('匹配标题中的关键词', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', title: '今天的心情日记'),
|
||||
_makeJournal(id: 'j2', title: '周末旅行记'),
|
||||
_makeJournal(id: 'j3', title: '读后感'),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const SearchByKeyword('心情'));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.results.length, 1);
|
||||
expect(loaded.results.first.id, 'j1');
|
||||
expect(loaded.activeKeyword, '心情');
|
||||
});
|
||||
|
||||
test('匹配内容摘要中的关键词', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', title: '日记', contentExcerpt: '今天心情很好,阳光明媚'),
|
||||
_makeJournal(id: 'j2', title: '随笔', contentExcerpt: '天气阴沉'),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const SearchByKeyword('阳光'));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results.length, 1);
|
||||
expect((state as SearchLoaded).results.first.id, 'j1');
|
||||
});
|
||||
|
||||
test('匹配标签中的关键词', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', tags: ['旅行', '周末']),
|
||||
_makeJournal(id: 'j2', tags: ['学习']),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const SearchByKeyword('旅行'));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results.length, 1);
|
||||
});
|
||||
|
||||
test('大小写不敏感搜索', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', title: 'Happy Day'),
|
||||
_makeJournal(id: 'j2', title: 'happy mood'),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const SearchByKeyword('HAPPY'));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results.length, 2);
|
||||
});
|
||||
|
||||
test('搜索失败返回 SearchError', () async {
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenThrow(Exception('网络错误'));
|
||||
|
||||
final state = await dispatch(const SearchByKeyword('测试'));
|
||||
|
||||
expect(state, isA<SearchError>());
|
||||
});
|
||||
|
||||
test('搜索历史记录', () async {
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => []);
|
||||
|
||||
await dispatch(const SearchByKeyword('关键词A'));
|
||||
await dispatch(const SearchByKeyword('关键词B'));
|
||||
|
||||
final state = await dispatch(const SearchByKeyword(''));
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.searchHistory, ['关键词B', '关键词A']);
|
||||
});
|
||||
|
||||
test('搜索历史去重', () async {
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => []);
|
||||
|
||||
await dispatch(const SearchByKeyword('重复'));
|
||||
await dispatch(const SearchByKeyword('其他'));
|
||||
await dispatch(const SearchByKeyword('重复'));
|
||||
|
||||
final state = await dispatch(const SearchByKeyword(''));
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.searchHistory, ['重复', '其他']);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 心情筛选 =====
|
||||
|
||||
group('SearchByMood', () {
|
||||
test('null mood 返回空结果', () async {
|
||||
final state = await dispatch(const SearchByMood(null));
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).results, isEmpty);
|
||||
expect((state as SearchLoaded).activeMood, isNull);
|
||||
});
|
||||
|
||||
test('按心情筛选日记', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', mood: Mood.happy),
|
||||
_makeJournal(id: 'j2', mood: Mood.happy),
|
||||
_makeJournal(id: 'j3', mood: Mood.sad),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(mood: 'happy', page: 1, pageSize: 50))
|
||||
.thenAnswer((_) async => journals.where((j) => j.mood == Mood.happy).toList());
|
||||
|
||||
final state = await dispatch(const SearchByMood(Mood.happy));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.results.length, 2);
|
||||
expect(loaded.activeMood, 'happy');
|
||||
});
|
||||
|
||||
test('心情筛选失败返回 SearchError', () async {
|
||||
when(() => mockJournalRepo.getJournals(mood: 'happy', page: 1, pageSize: 50))
|
||||
.thenThrow(Exception('网络错误'));
|
||||
|
||||
final state = await dispatch(const SearchByMood(Mood.happy));
|
||||
|
||||
expect(state, isA<SearchError>());
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 标签搜索 =====
|
||||
|
||||
group('SearchByTag', () {
|
||||
test('按标签筛选日记', () async {
|
||||
final journals = [
|
||||
_makeJournal(id: 'j1', tags: ['旅行', '周末']),
|
||||
];
|
||||
when(() => mockJournalRepo.getJournals(tag: '旅行', page: 1, pageSize: 50))
|
||||
.thenAnswer((_) async => journals);
|
||||
|
||||
final state = await dispatch(const SearchByTag('旅行'));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.results.length, 1);
|
||||
expect(loaded.activeTag, '旅行');
|
||||
expect(loaded.searchHistory, contains('旅行'));
|
||||
});
|
||||
|
||||
test('标签搜索失败返回 SearchError', () async {
|
||||
when(() => mockJournalRepo.getJournals(tag: '不存在', page: 1, pageSize: 50))
|
||||
.thenThrow(Exception('网络错误'));
|
||||
|
||||
final state = await dispatch(const SearchByTag('不存在'));
|
||||
|
||||
expect(state, isA<SearchError>());
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 清除搜索 =====
|
||||
|
||||
group('SearchClear', () {
|
||||
test('清除搜索返回空结果', () async {
|
||||
// 先执行一次搜索
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => [_makeJournal()]);
|
||||
await dispatch(const SearchByKeyword('测试'));
|
||||
|
||||
// 清除
|
||||
final state = await dispatch(const SearchClear());
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
final loaded = state as SearchLoaded;
|
||||
expect(loaded.results, isEmpty);
|
||||
expect(loaded.activeKeyword, isNull);
|
||||
expect(loaded.activeMood, isNull);
|
||||
expect(loaded.activeTag, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
// ===== Tab 切换 =====
|
||||
|
||||
group('SearchTabChanged', () {
|
||||
test('切换 tab 更新 activeTab', () async {
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => []);
|
||||
await dispatch(const SearchByKeyword('测试'));
|
||||
|
||||
final state = await dispatch(const SearchTabChanged(SearchResultTab.journal));
|
||||
|
||||
expect(state, isA<SearchLoaded>());
|
||||
expect((state as SearchLoaded).activeTab, SearchResultTab.journal);
|
||||
});
|
||||
|
||||
test('非 SearchLoaded 状态下切换 tab 无效', () async {
|
||||
// 初始状态 SearchInitial,不响应 tab 切换
|
||||
final state = await dispatch(const SearchTabChanged(SearchResultTab.tag));
|
||||
expect(state, isA<SearchInitial>());
|
||||
});
|
||||
});
|
||||
|
||||
// ===== hasActiveFilter =====
|
||||
|
||||
group('SearchLoaded.hasActiveFilter', () {
|
||||
test('无筛选条件时 hasActiveFilter 为 false', () async {
|
||||
final state = await dispatch(const SearchClear());
|
||||
expect((state as SearchLoaded).hasActiveFilter, isFalse);
|
||||
});
|
||||
|
||||
test('有关键词时 hasActiveFilter 为 true', () async {
|
||||
when(() => mockJournalRepo.getJournals(page: 1, pageSize: 200))
|
||||
.thenAnswer((_) async => []);
|
||||
final state = await dispatch(const SearchByKeyword('测试'));
|
||||
expect((state as SearchLoaded).hasActiveFilter, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"name": "web",
|
||||
"name": "nuanji-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"description": "暖记管理后台 — 班级管理·日记审核·成长追踪",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ====================================================================
|
||||
* ERP Platform — Design System Tokens & Global Styles
|
||||
* Soft UI Evolution: Professional, warm, accessible for all industries
|
||||
* Generated by UI UX Pro Max
|
||||
* 暖记管理后台 — Design System Tokens & Global Styles
|
||||
* 温暖治愈风格 · 手账日记管理 · Soft UI Evolution
|
||||
* ==================================================================== */
|
||||
|
||||
/* --- Design Tokens (CSS Custom Properties) --- */
|
||||
:root {
|
||||
/* Primary Palette — Trust Blue */
|
||||
/* Primary Palette — 珊瑚暖色 (warm 主题为默认,:root 为基线) */
|
||||
--erp-primary: #2563eb;
|
||||
--erp-primary-hover: #1d4ed8;
|
||||
--erp-primary-active: #1e40af;
|
||||
@@ -69,7 +68,7 @@
|
||||
--erp-space-xl: 32px;
|
||||
--erp-space-2xl: 48px;
|
||||
|
||||
/* Typography — Noto Sans SC for Chinese-first ERP */
|
||||
/* Typography — Noto Sans SC for Chinese-first 暖记 */
|
||||
--erp-font-family: 'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto,
|
||||
'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif;
|
||||
--erp-font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function PluginMarket() {
|
||||
<Title level={3} style={{ marginBottom: 8 }}>
|
||||
<AppstoreOutlined /> 插件市场
|
||||
</Title>
|
||||
<Text type="secondary">发现和安装行业插件,扩展 ERP 能力</Text>
|
||||
<Text type="secondary">发现和安装行业插件,扩展暖记能力</Text>
|
||||
</div>
|
||||
|
||||
{/* 搜索和分类 */}
|
||||
|
||||
@@ -61,10 +61,12 @@ fn is_token_revoked(token: &str, _exp: i64) -> bool {
|
||||
}
|
||||
|
||||
fn token_hash(token: &str) -> String {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
token.hash(&mut hasher);
|
||||
format!("{:016x}", hasher.finish())
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token.as_bytes());
|
||||
format!("{:016x}", u64::from_be_bytes(
|
||||
hasher.finalize().as_slice()[0..8].try_into().unwrap_or([0u8; 8])
|
||||
))
|
||||
}
|
||||
|
||||
/// JWT authentication middleware function.
|
||||
|
||||
@@ -4,6 +4,14 @@ use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use validator::Validate;
|
||||
|
||||
/// 标签字符串验证:单个标签最长 30 字符
|
||||
const TAG_MAX_LEN: usize = 30;
|
||||
|
||||
/// 班级码正则:仅允许字母和数字
|
||||
fn validate_class_code(code: &str) -> bool {
|
||||
code.chars().all(|c| c.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
/// 日记心情枚举
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -41,6 +49,22 @@ pub struct CreateJournalReq {
|
||||
pub assigned_topic_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
impl CreateJournalReq {
|
||||
/// 验证标签内容:每个标签非空且不超过 30 字符
|
||||
pub fn validate_tags(&self) -> Result<(), String> {
|
||||
for tag in &self.tags {
|
||||
let trimmed = tag.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("标签不能为空".to_string());
|
||||
}
|
||||
if trimmed.len() > TAG_MAX_LEN {
|
||||
return Err(format!("标签「{}」超过 {} 字符", trimmed, TAG_MAX_LEN));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新日记请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateJournalReq {
|
||||
@@ -52,9 +76,28 @@ pub struct UpdateJournalReq {
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub is_private: Option<bool>,
|
||||
pub shared_to_class: Option<bool>,
|
||||
#[validate(range(min = 0, message = "版本号不能为负数"))]
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
impl UpdateJournalReq {
|
||||
/// 验证标签内容
|
||||
pub fn validate_tags(&self) -> Result<(), String> {
|
||||
if let Some(ref tags) = self.tags {
|
||||
for tag in tags {
|
||||
let trimmed = tag.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("标签不能为空".to_string());
|
||||
}
|
||||
if trimmed.len() > TAG_MAX_LEN {
|
||||
return Err(format!("标签「{}」超过 {} 字符", trimmed, TAG_MAX_LEN));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 日记响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct JournalResp {
|
||||
@@ -68,6 +111,7 @@ pub struct JournalResp {
|
||||
pub tags: Vec<String>,
|
||||
pub is_private: bool,
|
||||
pub shared_to_class: bool,
|
||||
pub assigned_topic_id: Option<uuid::Uuid>,
|
||||
pub version: i32,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
@@ -89,6 +133,16 @@ pub struct JoinClassReq {
|
||||
pub class_code: String,
|
||||
}
|
||||
|
||||
impl JoinClassReq {
|
||||
/// 验证班级码仅含字母数字
|
||||
pub fn validate_code(&self) -> Result<(), String> {
|
||||
if !validate_class_code(&self.class_code) {
|
||||
return Err("班级码仅允许字母和数字".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新班级请求
|
||||
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||
pub struct UpdateClassReq {
|
||||
@@ -99,6 +153,7 @@ pub struct UpdateClassReq {
|
||||
#[validate(length(max = 100, message = "学校名称最长 100 字符"))]
|
||||
pub school_name: Option<String>,
|
||||
/// 乐观锁版本号
|
||||
#[validate(range(min = 0, message = "版本号不能为负数"))]
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -131,6 +186,32 @@ pub struct SyncReq {
|
||||
pub changes: Vec<SyncChange>,
|
||||
}
|
||||
|
||||
/// 单条同步变更的 JSON data 最大字节数
|
||||
const SYNC_DATA_MAX_BYTES: usize = 1024 * 1024; // 1 MB
|
||||
|
||||
impl SyncReq {
|
||||
/// 验证每条 SyncChange 的 data 字段大小
|
||||
pub fn validate_changes_data(&self) -> Result<(), String> {
|
||||
for (i, change) in self.changes.iter().enumerate() {
|
||||
match change {
|
||||
SyncChange::CreateJournal { data } | SyncChange::UpdateJournal { data, .. } => {
|
||||
let len = serde_json::to_string(data)
|
||||
.map(|s| s.len())
|
||||
.unwrap_or(SYNC_DATA_MAX_BYTES + 1);
|
||||
if len > SYNC_DATA_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"第 {} 条变更数据过大 ({} > {} 字节)",
|
||||
i + 1, len, SYNC_DATA_MAX_BYTES
|
||||
));
|
||||
}
|
||||
}
|
||||
SyncChange::DeleteJournal { .. } => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步变更条目
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub enum SyncChange {
|
||||
@@ -215,6 +296,7 @@ pub struct UpdateTopicReq {
|
||||
/// 截止日期
|
||||
pub due_date: Option<chrono::NaiveDate>,
|
||||
/// 乐观锁版本号
|
||||
#[validate(range(min = 0, message = "版本号不能为负数"))]
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -228,11 +310,13 @@ pub struct CreateStickerPackReq {
|
||||
#[validate(length(max = 500, message = "描述最长 500 字符"))]
|
||||
pub description: Option<String>,
|
||||
/// 缩略图 URL
|
||||
#[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))]
|
||||
pub thumbnail_url: Option<String>,
|
||||
/// 是否免费
|
||||
#[serde(default = "default_true")]
|
||||
pub is_free: bool,
|
||||
/// 价格(积分)
|
||||
#[validate(range(min = 0, message = "价格不能为负数"))]
|
||||
#[serde(default)]
|
||||
pub price: i32,
|
||||
/// 分类
|
||||
@@ -252,10 +336,12 @@ pub struct UpdateStickerPackReq {
|
||||
#[validate(length(max = 500, message = "描述最长 500 字符"))]
|
||||
pub description: Option<String>,
|
||||
/// 缩略图 URL
|
||||
#[validate(length(max = 500, message = "缩略图 URL 最长 500 字符"))]
|
||||
pub thumbnail_url: Option<String>,
|
||||
/// 是否免费
|
||||
pub is_free: Option<bool>,
|
||||
/// 价格(积分)
|
||||
#[validate(range(min = 0, message = "价格不能为负数"))]
|
||||
pub price: Option<i32>,
|
||||
/// 分类
|
||||
#[validate(length(max = 30, message = "分类最长 30 字符"))]
|
||||
@@ -271,6 +357,8 @@ pub struct CreateStickerReq {
|
||||
/// 图片 URL
|
||||
#[validate(length(min = 1, max = 500, message = "图片 URL 长度 1-500 字符"))]
|
||||
pub image_url: String,
|
||||
/// 贴纸包 ID
|
||||
pub pack_id: Option<uuid::Uuid>,
|
||||
/// 分类
|
||||
#[validate(length(max = 30, message = "分类最长 30 字符"))]
|
||||
pub category: Option<String>,
|
||||
@@ -379,6 +467,51 @@ pub struct TemplateResp {
|
||||
pub is_free: bool,
|
||||
}
|
||||
|
||||
// ========== 发现页 ==========
|
||||
|
||||
/// 发现页聚合响应 — 一次返回全部板块数据
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct DiscoverResp {
|
||||
/// 每日推荐(无共享日记时为 null)
|
||||
pub daily_inspiration: Option<InspirationItem>,
|
||||
/// 热门话题(标签频率 TOP 8)
|
||||
pub hot_topics: Vec<TagCount>,
|
||||
/// 精选模板(官方模板)
|
||||
pub featured_templates: Vec<TemplateResp>,
|
||||
/// 达人日记(不同作者最近共享日记)
|
||||
pub expert_diaries: Vec<ExpertDiaryItem>,
|
||||
}
|
||||
|
||||
/// 每日推荐条目
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct InspirationItem {
|
||||
pub journal_id: uuid::Uuid,
|
||||
pub title: String,
|
||||
pub author_name: String,
|
||||
pub mood: String,
|
||||
pub date: chrono::NaiveDate,
|
||||
}
|
||||
|
||||
/// 热门话题
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct TagCount {
|
||||
pub tag: String,
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// 达人日记条目
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ExpertDiaryItem {
|
||||
pub journal_id: uuid::Uuid,
|
||||
pub title: String,
|
||||
pub author_id: uuid::Uuid,
|
||||
pub author_name: String,
|
||||
pub author_emoji: String,
|
||||
pub content_preview: String,
|
||||
pub like_count: i64,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// 成就响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AchievementResp {
|
||||
|
||||
@@ -1,61 +1,262 @@
|
||||
// erp-diary 事件定义
|
||||
//
|
||||
// DiaryEvent 是日记模块的领域事件枚举,提供类型安全的事件构建。
|
||||
// 通过 `to_domain_event(tenant_id)` 转换为基座 DomainEvent 后发布到 EventBus。
|
||||
//
|
||||
// 使用方式(Service 层):
|
||||
// use crate::event::DiaryEvent;
|
||||
// let evt = DiaryEvent::JournalCreated { journal_id, author_id, class_id };
|
||||
// event_bus.publish(evt.to_domain_event(tenant_id), db).await;
|
||||
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use erp_core::events::DomainEvent;
|
||||
|
||||
/// 日记模块领域事件
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DiaryEvent {
|
||||
/// 日记创建
|
||||
JournalCreated {
|
||||
journal_id: uuid::Uuid,
|
||||
author_id: uuid::Uuid,
|
||||
class_id: Option<uuid::Uuid>,
|
||||
journal_id: Uuid,
|
||||
author_id: Uuid,
|
||||
class_id: Option<Uuid>,
|
||||
},
|
||||
/// 日记更新
|
||||
JournalUpdated {
|
||||
journal_id: uuid::Uuid,
|
||||
author_id: uuid::Uuid,
|
||||
journal_id: Uuid,
|
||||
author_id: Uuid,
|
||||
version: i32,
|
||||
},
|
||||
/// 日记删除
|
||||
JournalDeleted {
|
||||
journal_id: uuid::Uuid,
|
||||
author_id: uuid::Uuid,
|
||||
journal_id: Uuid,
|
||||
author_id: Uuid,
|
||||
},
|
||||
/// 日记分享到班级
|
||||
JournalShared {
|
||||
journal_id: uuid::Uuid,
|
||||
author_id: uuid::Uuid,
|
||||
class_id: uuid::Uuid,
|
||||
journal_id: Uuid,
|
||||
author_id: Uuid,
|
||||
class_id: Uuid,
|
||||
},
|
||||
/// 班级创建
|
||||
ClassCreated {
|
||||
class_id: uuid::Uuid,
|
||||
teacher_id: uuid::Uuid,
|
||||
class_id: Uuid,
|
||||
teacher_id: Uuid,
|
||||
},
|
||||
/// 学生加入班级
|
||||
StudentJoinedClass {
|
||||
class_id: uuid::Uuid,
|
||||
student_id: uuid::Uuid,
|
||||
class_id: Uuid,
|
||||
student_id: Uuid,
|
||||
},
|
||||
/// 老师布置主题
|
||||
TopicAssigned {
|
||||
topic_id: uuid::Uuid,
|
||||
class_id: uuid::Uuid,
|
||||
teacher_id: uuid::Uuid,
|
||||
topic_id: Uuid,
|
||||
class_id: Uuid,
|
||||
teacher_id: Uuid,
|
||||
},
|
||||
/// 老师点评
|
||||
CommentCreated {
|
||||
comment_id: uuid::Uuid,
|
||||
journal_id: uuid::Uuid,
|
||||
teacher_id: uuid::Uuid,
|
||||
student_id: uuid::Uuid,
|
||||
comment_id: Uuid,
|
||||
journal_id: Uuid,
|
||||
teacher_id: Uuid,
|
||||
student_id: Uuid,
|
||||
},
|
||||
/// 家长绑定孩子
|
||||
ParentBound {
|
||||
parent_id: uuid::Uuid,
|
||||
child_id: uuid::Uuid,
|
||||
parent_id: Uuid,
|
||||
child_id: Uuid,
|
||||
},
|
||||
/// 成就解锁
|
||||
AchievementUnlocked {
|
||||
user_id: uuid::Uuid,
|
||||
user_id: Uuid,
|
||||
achievement_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl DiaryEvent {
|
||||
/// 返回事件类型字符串(用于 DomainEvent.event_type)
|
||||
pub fn event_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::JournalCreated { .. } => "diary.created",
|
||||
Self::JournalUpdated { .. } => "diary.updated",
|
||||
Self::JournalDeleted { .. } => "diary.deleted",
|
||||
Self::JournalShared { .. } => "diary.shared",
|
||||
Self::ClassCreated { .. } => "diary.class.created",
|
||||
Self::StudentJoinedClass { .. } => "diary.class.student_joined",
|
||||
Self::TopicAssigned { .. } => "diary.topic.assigned",
|
||||
Self::CommentCreated { .. } => "diary.comment.created",
|
||||
Self::ParentBound { .. } => "diary.parent.binding_confirmed",
|
||||
Self::AchievementUnlocked { .. } => "diary.achievement.unlocked",
|
||||
}
|
||||
}
|
||||
|
||||
/// 返回事件 payload(JSON 格式)
|
||||
pub fn payload(&self) -> serde_json::Value {
|
||||
match self {
|
||||
Self::JournalCreated {
|
||||
journal_id,
|
||||
author_id,
|
||||
class_id,
|
||||
} => json!({
|
||||
"journal_id": journal_id,
|
||||
"author_id": author_id,
|
||||
"class_id": class_id,
|
||||
}),
|
||||
Self::JournalUpdated {
|
||||
journal_id,
|
||||
author_id,
|
||||
version,
|
||||
} => json!({
|
||||
"journal_id": journal_id,
|
||||
"author_id": author_id,
|
||||
"version": version,
|
||||
}),
|
||||
Self::JournalDeleted {
|
||||
journal_id,
|
||||
author_id,
|
||||
} => json!({
|
||||
"journal_id": journal_id,
|
||||
"author_id": author_id,
|
||||
}),
|
||||
Self::JournalShared {
|
||||
journal_id,
|
||||
author_id,
|
||||
class_id,
|
||||
} => json!({
|
||||
"journal_id": journal_id,
|
||||
"author_id": author_id,
|
||||
"class_id": class_id,
|
||||
}),
|
||||
Self::ClassCreated {
|
||||
class_id,
|
||||
teacher_id,
|
||||
} => json!({
|
||||
"class_id": class_id,
|
||||
"teacher_id": teacher_id,
|
||||
}),
|
||||
Self::StudentJoinedClass {
|
||||
class_id,
|
||||
student_id,
|
||||
} => json!({
|
||||
"class_id": class_id,
|
||||
"student_id": student_id,
|
||||
}),
|
||||
Self::TopicAssigned {
|
||||
topic_id,
|
||||
class_id,
|
||||
teacher_id,
|
||||
} => json!({
|
||||
"topic_id": topic_id,
|
||||
"class_id": class_id,
|
||||
"teacher_id": teacher_id,
|
||||
}),
|
||||
Self::CommentCreated {
|
||||
comment_id,
|
||||
journal_id,
|
||||
teacher_id,
|
||||
student_id,
|
||||
} => json!({
|
||||
"comment_id": comment_id,
|
||||
"journal_id": journal_id,
|
||||
"teacher_id": teacher_id,
|
||||
"student_id": student_id,
|
||||
}),
|
||||
Self::ParentBound {
|
||||
parent_id,
|
||||
child_id,
|
||||
} => json!({
|
||||
"parent_id": parent_id,
|
||||
"child_id": child_id,
|
||||
}),
|
||||
Self::AchievementUnlocked {
|
||||
user_id,
|
||||
achievement_id,
|
||||
} => json!({
|
||||
"user_id": user_id,
|
||||
"achievement_id": achievement_id,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 转换为基座 DomainEvent,可直接发布到 EventBus
|
||||
pub fn to_domain_event(&self, tenant_id: Uuid) -> DomainEvent {
|
||||
DomainEvent::new(self.event_type(), tenant_id, self.payload())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn journal_created_event_type() {
|
||||
let id = Uuid::now_v7();
|
||||
let evt = DiaryEvent::JournalCreated {
|
||||
journal_id: id,
|
||||
author_id: id,
|
||||
class_id: None,
|
||||
};
|
||||
assert_eq!(evt.event_type(), "diary.created");
|
||||
assert_eq!(evt.payload()["journal_id"], id.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_created_event_type() {
|
||||
let id = Uuid::now_v7();
|
||||
let evt = DiaryEvent::ClassCreated {
|
||||
class_id: id,
|
||||
teacher_id: id,
|
||||
};
|
||||
assert_eq!(evt.event_type(), "diary.class.created");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_domain_event_preserves_fields() {
|
||||
let tid = Uuid::now_v7();
|
||||
let jid = Uuid::now_v7();
|
||||
let aid = Uuid::now_v7();
|
||||
|
||||
let de = DiaryEvent::JournalCreated {
|
||||
journal_id: jid,
|
||||
author_id: aid,
|
||||
class_id: Some(tid),
|
||||
}
|
||||
.to_domain_event(tid);
|
||||
|
||||
assert_eq!(de.event_type, "diary.created");
|
||||
assert_eq!(de.tenant_id, tid);
|
||||
assert_eq!(de.payload["journal_id"], jid.to_string());
|
||||
assert_eq!(de.payload["author_id"], aid.to_string());
|
||||
assert_eq!(de.payload["class_id"], tid.to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_have_correct_event_type() {
|
||||
let id = Uuid::now_v7();
|
||||
let variants: Vec<DiaryEvent> = vec![
|
||||
DiaryEvent::JournalCreated { journal_id: id, author_id: id, class_id: None },
|
||||
DiaryEvent::JournalUpdated { journal_id: id, author_id: id, version: 1 },
|
||||
DiaryEvent::JournalDeleted { journal_id: id, author_id: id },
|
||||
DiaryEvent::JournalShared { journal_id: id, author_id: id, class_id: id },
|
||||
DiaryEvent::ClassCreated { class_id: id, teacher_id: id },
|
||||
DiaryEvent::StudentJoinedClass { class_id: id, student_id: id },
|
||||
DiaryEvent::TopicAssigned { topic_id: id, class_id: id, teacher_id: id },
|
||||
DiaryEvent::CommentCreated { comment_id: id, journal_id: id, teacher_id: id, student_id: id },
|
||||
DiaryEvent::ParentBound { parent_id: id, child_id: id },
|
||||
DiaryEvent::AchievementUnlocked { user_id: id, achievement_id: "first_diary".into() },
|
||||
];
|
||||
|
||||
let types: Vec<&str> = variants.iter().map(|v| v.event_type()).collect();
|
||||
assert!(types.contains(&"diary.created"));
|
||||
assert!(types.contains(&"diary.updated"));
|
||||
assert!(types.contains(&"diary.deleted"));
|
||||
assert!(types.contains(&"diary.shared"));
|
||||
assert!(types.contains(&"diary.class.created"));
|
||||
assert!(types.contains(&"diary.class.student_joined"));
|
||||
assert!(types.contains(&"diary.topic.assigned"));
|
||||
assert!(types.contains(&"diary.comment.created"));
|
||||
assert!(types.contains(&"diary.parent.binding_confirmed"));
|
||||
assert!(types.contains(&"diary.achievement.unlocked"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 成就 API 处理器
|
||||
|
||||
use axum::extract::{Extension, FromRef, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
|
||||
use erp_core::error::AppError;
|
||||
@@ -44,7 +45,7 @@ where
|
||||
path = "/api/v1/diary/achievements/{code}/unlock",
|
||||
params(("code" = String, Path, description = "成就编码")),
|
||||
responses(
|
||||
(status = 200, description = "解锁成功", body = ApiResponse<AchievementResp>),
|
||||
(status = 201, description = "解锁成功", body = ApiResponse<AchievementResp>),
|
||||
(status = 404, description = "成就不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
@@ -57,7 +58,7 @@ pub async fn unlock_achievement<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(code): Path<String>,
|
||||
) -> Result<Json<ApiResponse<AchievementResp>>, AppError>
|
||||
) -> Result<(StatusCode, Json<ApiResponse<AchievementResp>>), AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
@@ -73,5 +74,5 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 班级 API 处理器 — 创建班级、加入班级、查询班级
|
||||
|
||||
use axum::extract::{Extension, FromRef, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
@@ -18,7 +19,7 @@ use crate::state::DiaryState;
|
||||
path = "/api/v1/diary/classes",
|
||||
request_body = CreateClassReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<ClassResp>),
|
||||
(status = 201, description = "创建成功", body = ApiResponse<ClassResp>),
|
||||
(status = 400, description = "验证失败"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
@@ -33,7 +34,7 @@ pub async fn create_class<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateClassReq>,
|
||||
) -> Result<Json<ApiResponse<ClassResp>>, AppError>
|
||||
) -> Result<(StatusCode, Json<ApiResponse<ClassResp>>), AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
@@ -55,7 +56,7 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -63,7 +64,7 @@ where
|
||||
path = "/api/v1/diary/classes/join",
|
||||
request_body = JoinClassReq,
|
||||
responses(
|
||||
(status = 200, description = "加入成功", body = ApiResponse<ClassResp>),
|
||||
(status = 201, description = "加入成功", body = ApiResponse<ClassResp>),
|
||||
(status = 400, description = "班级码无效或已过期"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
@@ -78,12 +79,13 @@ pub async fn join_class<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<JoinClassReq>,
|
||||
) -> Result<Json<ApiResponse<ClassResp>>, AppError>
|
||||
) -> Result<(StatusCode, Json<ApiResponse<ClassResp>>), AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
req.validate_code().map_err(AppError::Validation)?;
|
||||
require_permission(&ctx, "diary.journal.create")?;
|
||||
|
||||
if req.class_code.trim().is_empty() {
|
||||
@@ -101,7 +103,7 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 评语 API 处理器 — 老师点评学生日记
|
||||
|
||||
use axum::extract::{Extension, FromRef, Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
@@ -19,7 +20,7 @@ use crate::state::DiaryState;
|
||||
params(("journal_id" = Uuid, Path, description = "日记ID")),
|
||||
request_body = CreateCommentReq,
|
||||
responses(
|
||||
(status = 200, description = "点评成功", body = ApiResponse<CommentResp>),
|
||||
(status = 201, description = "点评成功", body = ApiResponse<CommentResp>),
|
||||
(status = 400, description = "验证失败或内容安全检查未通过"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足或不是本班老师"),
|
||||
@@ -37,7 +38,7 @@ pub async fn create_comment<S>(
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(journal_id): Path<Uuid>,
|
||||
Json(req): Json<CreateCommentReq>,
|
||||
) -> Result<Json<ApiResponse<CommentResp>>, AppError>
|
||||
) -> Result<(StatusCode, Json<ApiResponse<CommentResp>>), AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
@@ -59,7 +60,7 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
|
||||
40
crates/erp-diary/src/handler/discover_handler.rs
Normal 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)))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// 日记 API 处理器 — CRUD + 列表
|
||||
|
||||
use axum::extract::{Extension, FromRef, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::Deserialize;
|
||||
use utoipa::IntoParams;
|
||||
@@ -39,7 +40,7 @@ pub struct JournalListParams {
|
||||
path = "/api/v1/diary/journals",
|
||||
request_body = CreateJournalReq,
|
||||
responses(
|
||||
(status = 200, description = "创建成功", body = ApiResponse<JournalResp>),
|
||||
(status = 201, description = "创建成功", body = ApiResponse<JournalResp>),
|
||||
(status = 400, description = "验证失败"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
@@ -54,12 +55,13 @@ pub async fn create_journal<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<CreateJournalReq>,
|
||||
) -> Result<Json<ApiResponse<JournalResp>>, AppError>
|
||||
) -> Result<(StatusCode, Json<ApiResponse<JournalResp>>), AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
req.validate_tags().map_err(AppError::Validation)?;
|
||||
require_permission(&ctx, "diary.journal.create")?;
|
||||
|
||||
// 基础验证
|
||||
@@ -76,7 +78,7 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(resp))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -148,6 +150,7 @@ where
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
req.validate_tags().map_err(AppError::Validation)?;
|
||||
require_permission(&ctx, "diary.journal.update")?;
|
||||
|
||||
let resp = JournalService::update(
|
||||
@@ -164,9 +167,10 @@ where
|
||||
}
|
||||
|
||||
/// 删除日记请求体(包含版本号)
|
||||
#[derive(Debug, Deserialize, utoipa::ToSchema)]
|
||||
#[derive(Debug, Deserialize, Validate, utoipa::ToSchema)]
|
||||
pub struct DeleteJournalReq {
|
||||
/// 当前版本号(乐观锁)
|
||||
#[validate(range(min = 0, message = "版本号不能为负数"))]
|
||||
pub version: i32,
|
||||
}
|
||||
|
||||
@@ -201,6 +205,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "diary.journal.delete")?;
|
||||
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
JournalService::delete(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
@@ -250,7 +256,7 @@ where
|
||||
|
||||
// IDOR 修复:非管理角色只能查看自己的日记
|
||||
// - 学生:强制 author_id = ctx.user_id
|
||||
// - 老师/管理员:允许查看所有日记
|
||||
// - 老师/管理员:允许查看所有日记,但排除其他用户的私密日记
|
||||
// - 家长:应通过 parent_service 专用端点查看孩子日记
|
||||
let author_id = if ctx.roles.iter().any(|r| r == "teacher" || r == "admin") {
|
||||
// 管理角色可查看任意作者的日记
|
||||
@@ -260,6 +266,11 @@ where
|
||||
Some(ctx.user_id)
|
||||
};
|
||||
|
||||
// 管理角色查看他人日记时,排除 is_private=true 的私密日记
|
||||
// 学生查看自己的日记时,包含私密日记(那是他们自己的)
|
||||
let exclude_private = ctx.roles.iter().any(|r| r == "teacher" || r == "admin")
|
||||
&& author_id != Some(ctx.user_id);
|
||||
|
||||
let (items, total) = JournalService::list(
|
||||
ctx.tenant_id,
|
||||
author_id,
|
||||
@@ -267,6 +278,7 @@ where
|
||||
params.date_from,
|
||||
params.date_to,
|
||||
params.class_id,
|
||||
exclude_private,
|
||||
page,
|
||||
page_size,
|
||||
&state.db,
|
||||
|
||||
@@ -9,3 +9,4 @@ pub mod sticker_handler;
|
||||
pub mod achievement_handler;
|
||||
pub mod stats_handler;
|
||||
pub mod parent_handler;
|
||||
pub mod discover_handler;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 家长中心 API 处理器 — PIPL 合规: 绑定/查阅/导出/删除
|
||||
|
||||
use axum::extract::{Extension, FromRef, Query, State};
|
||||
use axum::extract::{Extension, FromRef, Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
@@ -71,7 +72,7 @@ pub struct DeleteResultResp {
|
||||
path = "/api/v1/diary/parent/bind",
|
||||
request_body = BindChildReq,
|
||||
responses(
|
||||
(status = 200, description = "绑定成功", body = ApiResponse<BindingResp>),
|
||||
(status = 201, description = "绑定成功", body = ApiResponse<BindingResp>),
|
||||
(status = 400, description = "已绑定该孩子"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "权限不足"),
|
||||
@@ -86,13 +87,15 @@ pub async fn bind_child<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Json(req): Json<BindChildReq>,
|
||||
) -> Result<Json<ApiResponse<BindingResp>>, AppError>
|
||||
) -> Result<(StatusCode, Json<ApiResponse<BindingResp>>), AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
require_permission(&ctx, "diary.parent.bind")?;
|
||||
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let binding = ParentService::bind_child(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
@@ -102,11 +105,11 @@ where
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(Json(ApiResponse::ok(BindingResp {
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(BindingResp {
|
||||
binding_id: binding.id,
|
||||
child_id: binding.child_id,
|
||||
verified_at: binding.verified_at,
|
||||
})))
|
||||
}))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
@@ -258,6 +261,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "diary.parent.bind")?;
|
||||
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
let count = ParentService::delete_child_data(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
@@ -300,6 +305,8 @@ where
|
||||
{
|
||||
require_permission(&ctx, "diary.parent.bind")?;
|
||||
|
||||
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
|
||||
|
||||
ParentService::unbind_child(ctx.tenant_id, ctx.user_id, req.child_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
@@ -309,6 +316,124 @@ where
|
||||
}))
|
||||
}
|
||||
|
||||
/// 确认绑定请求的路径参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct BindingIdPath {
|
||||
/// 绑定请求 ID
|
||||
pub binding_id: Uuid,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/v1/diary/parent/pending",
|
||||
responses(
|
||||
(status = 200, description = "待确认绑定列表", body = ApiResponse<Vec<BindingResp>>),
|
||||
(status = 401, description = "未授权"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "家长中心"
|
||||
)]
|
||||
/// GET /api/v1/diary/parent/pending
|
||||
///
|
||||
/// 孩子查看自己的待确认绑定请求列表。
|
||||
pub async fn list_pending_bindings<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
) -> Result<Json<ApiResponse<Vec<BindingResp>>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let bindings =
|
||||
ParentService::list_pending_for_child(ctx.tenant_id, ctx.user_id, &state.db).await?;
|
||||
|
||||
let resp: Vec<BindingResp> = bindings
|
||||
.into_iter()
|
||||
.map(|b| BindingResp {
|
||||
binding_id: b.id,
|
||||
child_id: b.parent_id, // 对于孩子端,显示家长 ID
|
||||
verified_at: b.verified_at,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApiResponse::ok(resp)))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/diary/parent/bindings/{binding_id}/confirm",
|
||||
params(("binding_id" = Uuid, Path, description = "绑定请求ID")),
|
||||
responses(
|
||||
(status = 201, description = "确认成功", body = ApiResponse<BindingResp>),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "无权确认此绑定"),
|
||||
(status = 404, description = "绑定请求不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "家长中心"
|
||||
)]
|
||||
/// POST /api/v1/diary/parent/bindings/:binding_id/confirm
|
||||
///
|
||||
/// 孩子确认家长绑定请求。确认后家长获得查看日记等权限。
|
||||
pub async fn confirm_binding<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(binding_id): Path<Uuid>,
|
||||
) -> Result<(StatusCode, Json<ApiResponse<BindingResp>>), AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
let binding = ParentService::confirm_binding(
|
||||
ctx.tenant_id,
|
||||
ctx.user_id,
|
||||
binding_id,
|
||||
&state.db,
|
||||
&state.event_bus,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, Json(ApiResponse::ok(BindingResp {
|
||||
binding_id: binding.id,
|
||||
child_id: binding.parent_id,
|
||||
verified_at: binding.verified_at,
|
||||
}))))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/v1/diary/parent/bindings/{binding_id}/reject",
|
||||
params(("binding_id" = Uuid, Path, description = "绑定请求ID")),
|
||||
responses(
|
||||
(status = 200, description = "拒绝成功"),
|
||||
(status = 401, description = "未授权"),
|
||||
(status = 403, description = "无权拒绝此绑定"),
|
||||
(status = 404, description = "绑定请求不存在"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "家长中心"
|
||||
)]
|
||||
/// POST /api/v1/diary/parent/bindings/:binding_id/reject
|
||||
///
|
||||
/// 孩子拒绝家长绑定请求。
|
||||
pub async fn reject_binding<S>(
|
||||
State(state): State<DiaryState>,
|
||||
Extension(ctx): Extension<TenantContext>,
|
||||
Path(binding_id): Path<Uuid>,
|
||||
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||
where
|
||||
DiaryState: FromRef<S>,
|
||||
S: Clone + Send + Sync + 'static,
|
||||
{
|
||||
ParentService::reject_binding(ctx.tenant_id, ctx.user_id, binding_id, &state.db).await?;
|
||||
|
||||
Ok(Json(ApiResponse {
|
||||
success: true,
|
||||
data: None,
|
||||
message: Some("已拒绝绑定请求".to_string()),
|
||||
}))
|
||||
}
|
||||
|
||||
/// journal_entry::Model -> JournalResp DTO 转换
|
||||
///
|
||||
/// 与 journal_service 中的 model_to_resp 逻辑一致,
|
||||
@@ -334,6 +459,7 @@ fn journal_model_to_resp(model: crate::entity::journal_entry::Model) -> JournalR
|
||||
tags,
|
||||
is_private: model.is_private,
|
||||
shared_to_class: model.shared_to_class,
|
||||
assigned_topic_id: model.assigned_topic_id,
|
||||
version: model.version,
|
||||
created_at: model.created_at,
|
||||
updated_at: model.updated_at,
|
||||
|
||||