feat: Week 4 收尾 + 架构治理 — 搜索/家长中心/Feature Flag/Docker/环境配置
架构治理: - Feature Flag 落地: Cargo.toml [features] default=["diary"] + main.rs cfg 条件编译 - 环境配置统一: AppConfig 类 + --dart-define 注入 + SSE 端口 8080→3000 修复 搜索替代方案 (无 FTS): - SearchBloc + 标签/心情筛选接入后端 API - JournalRepository 扩展 mood/tag 筛选参数 - 搜索页 UI 接入实际数据(替换占位文本) 家长中心最小集 (PIPL 合规): - 后端: parent_service (绑定/查看/导出/删除/解绑) + parent_handler (6 个 API 端点) - 前端: ParentBloc + ParentPage 功能完整实现 - 绑定孩子、只读查看日记、导出数据、删除数据、解绑 Docker 部署: - verify.sh 健康检查脚本 (Axum/PG/Redis/OpenAPI 四项检查) 测试修复: - home_bloc_test / calendar_bloc_test 适配 JournalRepository 新参数 验证: flutter test 84/84 pass, cargo test 76/76 pass, cargo check pass
This commit is contained in:
@@ -16,6 +16,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart' show ListenableProvider;
|
||||
|
||||
import 'config/app_config.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'core/routing/app_router.dart';
|
||||
import 'data/remote/api_client.dart';
|
||||
@@ -35,7 +36,8 @@ class NuanjiApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 创建全局依赖(App 生命周期内单例)
|
||||
final apiClient = ApiClient();
|
||||
final config = AppConfig.fromEnvironment();
|
||||
final apiClient = ApiClient(baseUrl: config.apiBaseUrl);
|
||||
final authRepository = AuthRepository(apiClient: apiClient);
|
||||
// 离线优先:Isar 为主要本地仓库,Remote 供 SyncEngine 推送
|
||||
// Web 平台:Isar 3.x 不支持 Web,直接使用远程仓库
|
||||
|
||||
48
app/lib/config/app_config.dart
Normal file
48
app/lib/config/app_config.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
// 应用环境配置 — 通过 --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
|
||||
|
||||
/// 应用环境配置 — 集中管理所有外部服务地址
|
||||
class AppConfig {
|
||||
/// API 基础 URL(后端 Axum 服务地址)
|
||||
final String apiBaseUrl;
|
||||
|
||||
/// SSE 推送服务 URL(通常与 API 同一地址)
|
||||
final String sseBaseUrl;
|
||||
|
||||
const AppConfig({
|
||||
required this.apiBaseUrl,
|
||||
required this.sseBaseUrl,
|
||||
});
|
||||
|
||||
/// 从编译时环境变量构建配置
|
||||
///
|
||||
/// 使用 `--dart-define` 注入,未设置时使用默认值。
|
||||
factory AppConfig.fromEnvironment({
|
||||
String defaultApiBaseUrl = 'http://localhost:3000/api/v1',
|
||||
String defaultSseBaseUrl = 'http://localhost:3000/api/v1',
|
||||
}) {
|
||||
// const String.fromEnvironment 在编译时求值
|
||||
const apiBaseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://localhost:3000/api/v1',
|
||||
);
|
||||
const sseBaseUrl = String.fromEnvironment(
|
||||
'SSE_BASE_URL',
|
||||
defaultValue: 'http://localhost:3000/api/v1',
|
||||
);
|
||||
|
||||
return AppConfig(
|
||||
apiBaseUrl: apiBaseUrl,
|
||||
sseBaseUrl: sseBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/// 开发环境默认配置
|
||||
static const dev = AppConfig(
|
||||
apiBaseUrl: 'http://localhost:3000/api/v1',
|
||||
sseBaseUrl: 'http://localhost:3000/api/v1',
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export '../../widgets/responsive_scaffold.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../widgets/responsive_scaffold.dart';
|
||||
@@ -32,11 +33,15 @@ import '../../features/onboarding/views/onboarding_page.dart';
|
||||
import '../../features/class_/views/class_page.dart';
|
||||
import '../../features/teacher/views/teacher_page.dart';
|
||||
import '../../features/parent/views/parent_page.dart';
|
||||
import '../../features/parent/bloc/parent_bloc.dart';
|
||||
import '../../features/achievement/views/achievement_page.dart';
|
||||
import '../../features/stickers/views/sticker_library_page.dart';
|
||||
import '../../features/templates/views/template_gallery_page.dart';
|
||||
import '../../features/settings/views/settings_page.dart';
|
||||
import '../../features/auth/bloc/auth_bloc.dart';
|
||||
import '../../features/search/bloc/search_bloc.dart';
|
||||
import '../../data/repositories/journal_repository.dart';
|
||||
import '../../data/remote/api_client.dart';
|
||||
|
||||
// Shell 分支键
|
||||
final _rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
@@ -146,11 +151,17 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
name: 'calendar',
|
||||
builder: (context, state) => const CalendarPage(),
|
||||
),
|
||||
// 发现页(复用搜索页,后续可替换为独立 DiscoverPage)
|
||||
// 发现页(搜索页 — 标签+心情筛选日记)
|
||||
GoRoute(
|
||||
path: '/discover',
|
||||
name: 'discover',
|
||||
builder: (context, state) => const SearchPage(),
|
||||
builder: (context, state) {
|
||||
final journalRepo = context.read<JournalRepository>();
|
||||
return BlocProvider(
|
||||
create: (_) => SearchBloc(journalRepository: journalRepo),
|
||||
child: const SearchPage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
@@ -178,6 +189,20 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const MoodPage(),
|
||||
),
|
||||
// 周概览(全屏,从日历页进入)
|
||||
GoRoute(
|
||||
path: '/weekly',
|
||||
name: 'weekly',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const WeeklyPage(),
|
||||
),
|
||||
// 月度概览(全屏,从日历页进入)
|
||||
GoRoute(
|
||||
path: '/monthly',
|
||||
name: 'monthly',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const MonthlyPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/class',
|
||||
name: 'class',
|
||||
@@ -194,7 +219,12 @@ GoRouter createAppRouter(AuthBloc authBloc) {
|
||||
path: '/parent',
|
||||
name: 'parent',
|
||||
parentNavigatorKey: _rootNavigatorKey,
|
||||
builder: (context, state) => const ParentPage(),
|
||||
builder: (context, state) {
|
||||
return BlocProvider(
|
||||
create: (_) => ParentBloc(api: context.read<ApiClient>()),
|
||||
child: const ParentPage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/achievements',
|
||||
|
||||
@@ -32,6 +32,8 @@ class IsarJournalRepository implements JournalRepository {
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
}) async {
|
||||
var query = _isar.journalEntryCollections
|
||||
.where()
|
||||
@@ -46,6 +48,16 @@ class IsarJournalRepository implements JournalRepository {
|
||||
query = query.and().dateEpochLessThan(dateTo.millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
// 心情过滤
|
||||
if (mood != null) {
|
||||
query = query.and().moodEqualTo(mood);
|
||||
}
|
||||
|
||||
// 标签过滤:Isar tagsJson 字段存储 JSON 数组,用 contains 匹配
|
||||
if (tag != null) {
|
||||
query = query.and().tagsJsonContains(tag);
|
||||
}
|
||||
|
||||
// 按日期降序排列
|
||||
var results = await query
|
||||
.sortByDateEpochDesc()
|
||||
|
||||
@@ -15,12 +15,14 @@ import '../models/journal_element.dart';
|
||||
/// - [dateFrom]/[dateTo]: 日期范围过滤(闭区间)
|
||||
/// - [page]/[pageSize]: 分页参数,从 1 开始
|
||||
abstract class JournalRepository {
|
||||
/// 获取日记列表(支持日期范围过滤和分页)
|
||||
/// 获取日记列表(支持日期范围、心情、标签过滤和分页)
|
||||
Future<List<JournalEntry>> getJournals({
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
});
|
||||
|
||||
/// 获取单篇日记(返回 null 表示不存在)
|
||||
@@ -62,6 +64,8 @@ class InMemoryJournalRepository implements JournalRepository {
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
}) async {
|
||||
var results = _journals.values.toList();
|
||||
|
||||
@@ -73,6 +77,16 @@ class InMemoryJournalRepository implements JournalRepository {
|
||||
results = results.where((j) => j.date.isBefore(dateTo)).toList();
|
||||
}
|
||||
|
||||
// 心情过滤
|
||||
if (mood != null) {
|
||||
results = results.where((j) => j.mood.value == mood).toList();
|
||||
}
|
||||
|
||||
// 标签过滤(日记 tags 列表包含指定标签)
|
||||
if (tag != null) {
|
||||
results = results.where((j) => j.tags.contains(tag)).toList();
|
||||
}
|
||||
|
||||
// 按日期降序排列(最新在前)
|
||||
results.sort((a, b) => b.date.compareTo(a.date));
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{};
|
||||
// 后端 NaiveDateTime 格式: "2026-06-01T00:00:00"(不带毫秒)
|
||||
@@ -30,6 +32,8 @@ class RemoteJournalRepository implements JournalRepository {
|
||||
}
|
||||
if (page != null) queryParams['page'] = page;
|
||||
if (pageSize != null) queryParams['page_size'] = pageSize;
|
||||
if (mood != null) queryParams['mood'] = mood;
|
||||
if (tag != null) queryParams['tag'] = tag;
|
||||
|
||||
final response = await _api.get('/diary/journals', queryParams: queryParams);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
|
||||
@@ -39,7 +39,7 @@ class SseNotificationService {
|
||||
|
||||
SseNotificationService({
|
||||
required String token,
|
||||
String baseUrl = 'http://localhost:8080/api/v1',
|
||||
String baseUrl = 'http://localhost:3000/api/v1',
|
||||
}) : _token = token,
|
||||
_baseUrl = baseUrl;
|
||||
|
||||
|
||||
140
app/lib/features/parent/bloc/parent_bloc.dart
Normal file
140
app/lib/features/parent/bloc/parent_bloc.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
// 家长中心 BLoC — 管理家长-孩子绑定和数据操作
|
||||
//
|
||||
// 状态机: ParentInitial → ParentLoading → ParentChildrenLoaded / ParentJournalsLoaded / ParentDataExported / ParentDataDeleted / ParentError
|
||||
// API: /diary/parent/* 端点
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/remote/api_client.dart';
|
||||
|
||||
part 'parent_event.dart';
|
||||
part 'parent_state.dart';
|
||||
|
||||
/// 家长中心 BLoC — 处理孩子绑定、日记查看、数据导出/删除
|
||||
class ParentBloc extends Bloc<ParentEvent, ParentState> {
|
||||
final ApiClient _api;
|
||||
|
||||
ParentBloc({required ApiClient api})
|
||||
: _api = api,
|
||||
super(const ParentInitial()) {
|
||||
on<ParentLoadChildren>(_onLoadChildren);
|
||||
on<ParentBindChild>(_onBindChild);
|
||||
on<ParentViewJournals>(_onViewJournals);
|
||||
on<ParentExportData>(_onExportData);
|
||||
on<ParentDeleteData>(_onDeleteData);
|
||||
on<ParentUnbindChild>(_onUnbindChild);
|
||||
}
|
||||
|
||||
/// 加载已绑定的孩子列表
|
||||
Future<void> _onLoadChildren(
|
||||
ParentLoadChildren event,
|
||||
Emitter<ParentState> emit,
|
||||
) async {
|
||||
emit(const ParentLoading());
|
||||
try {
|
||||
final response = await _api.get('/diary/parent/children');
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final items = body['data'] as List? ?? [];
|
||||
final children = items
|
||||
.map((j) => ChildBinding.fromJson(j as Map<String, dynamic>))
|
||||
.toList();
|
||||
emit(ParentChildrenLoaded(children));
|
||||
} catch (e) {
|
||||
emit(const ParentError('加载孩子列表失败'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 绑定孩子(输入孩子 ID)
|
||||
Future<void> _onBindChild(
|
||||
ParentBindChild event,
|
||||
Emitter<ParentState> emit,
|
||||
) async {
|
||||
emit(const ParentLoading());
|
||||
try {
|
||||
await _api.post('/diary/parent/bind', data: {
|
||||
'child_id': event.childId,
|
||||
});
|
||||
// 绑定成功后重新加载列表
|
||||
add(const ParentLoadChildren());
|
||||
} catch (e) {
|
||||
emit(const ParentError('绑定失败,请检查孩子 ID'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 查看孩子日记(只读)
|
||||
Future<void> _onViewJournals(
|
||||
ParentViewJournals event,
|
||||
Emitter<ParentState> emit,
|
||||
) async {
|
||||
emit(const ParentLoading());
|
||||
try {
|
||||
final response = await _api.get(
|
||||
'/diary/parent/journals',
|
||||
queryParams: {
|
||||
'child_id': event.childId,
|
||||
'page': 1,
|
||||
'page_size': 50,
|
||||
},
|
||||
);
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
final items = body['data'] as List? ?? [];
|
||||
emit(ParentJournalsLoaded(
|
||||
childId: event.childId,
|
||||
journals: items.cast<Map<String, dynamic>>(),
|
||||
));
|
||||
} catch (e) {
|
||||
emit(const ParentError('加载日记失败'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 导出孩子数据(PIPL 合规)
|
||||
Future<void> _onExportData(
|
||||
ParentExportData event,
|
||||
Emitter<ParentState> emit,
|
||||
) async {
|
||||
emit(const ParentLoading());
|
||||
try {
|
||||
final response = await _api.get(
|
||||
'/diary/parent/export',
|
||||
queryParams: {'child_id': event.childId},
|
||||
);
|
||||
emit(ParentDataExported(
|
||||
childId: event.childId,
|
||||
data: response.data as Map<String, dynamic>,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(const ParentError('导出失败'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除孩子数据(PIPL 合规,需二次确认)
|
||||
Future<void> _onDeleteData(
|
||||
ParentDeleteData event,
|
||||
Emitter<ParentState> emit,
|
||||
) async {
|
||||
emit(const ParentLoading());
|
||||
try {
|
||||
await _api.delete('/diary/parent/data', data: {
|
||||
'child_id': event.childId,
|
||||
});
|
||||
emit(ParentDataDeleted(event.childId));
|
||||
} catch (e) {
|
||||
emit(const ParentError('删除失败'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 解绑孩子
|
||||
Future<void> _onUnbindChild(
|
||||
ParentUnbindChild event,
|
||||
Emitter<ParentState> emit,
|
||||
) async {
|
||||
try {
|
||||
await _api.delete('/diary/parent/bind', data: {
|
||||
'child_id': event.childId,
|
||||
});
|
||||
add(const ParentLoadChildren());
|
||||
} catch (e) {
|
||||
emit(const ParentError('解绑失败'));
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/lib/features/parent/bloc/parent_event.dart
Normal file
43
app/lib/features/parent/bloc/parent_event.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
// 家长中心事件 — ParentBloc 接收的用户操作
|
||||
|
||||
part of 'parent_bloc.dart';
|
||||
|
||||
/// 家长中心事件基类
|
||||
sealed class ParentEvent {
|
||||
const ParentEvent();
|
||||
}
|
||||
|
||||
/// 加载绑定的孩子列表
|
||||
final class ParentLoadChildren extends ParentEvent {
|
||||
const ParentLoadChildren();
|
||||
}
|
||||
|
||||
/// 绑定孩子(输入孩子 ID)
|
||||
final class ParentBindChild extends ParentEvent {
|
||||
final String childId;
|
||||
const ParentBindChild(this.childId);
|
||||
}
|
||||
|
||||
/// 查看孩子日记
|
||||
final class ParentViewJournals extends ParentEvent {
|
||||
final String childId;
|
||||
const ParentViewJournals(this.childId);
|
||||
}
|
||||
|
||||
/// 导出孩子数据
|
||||
final class ParentExportData extends ParentEvent {
|
||||
final String childId;
|
||||
const ParentExportData(this.childId);
|
||||
}
|
||||
|
||||
/// 删除孩子数据
|
||||
final class ParentDeleteData extends ParentEvent {
|
||||
final String childId;
|
||||
const ParentDeleteData(this.childId);
|
||||
}
|
||||
|
||||
/// 解绑孩子
|
||||
final class ParentUnbindChild extends ParentEvent {
|
||||
final String childId;
|
||||
const ParentUnbindChild(this.childId);
|
||||
}
|
||||
79
app/lib/features/parent/bloc/parent_state.dart
Normal file
79
app/lib/features/parent/bloc/parent_state.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
// 家长中心状态 — ParentBloc 输出的 UI 状态
|
||||
|
||||
part of 'parent_bloc.dart';
|
||||
|
||||
/// 家长中心状态基类
|
||||
sealed class ParentState {
|
||||
const ParentState();
|
||||
}
|
||||
|
||||
/// 初始状态
|
||||
final class ParentInitial extends ParentState {
|
||||
const ParentInitial();
|
||||
}
|
||||
|
||||
/// 加载中
|
||||
final class ParentLoading extends ParentState {
|
||||
const ParentLoading();
|
||||
}
|
||||
|
||||
/// 孩子列表已加载
|
||||
final class ParentChildrenLoaded extends ParentState {
|
||||
final List<ChildBinding> children;
|
||||
const ParentChildrenLoaded(this.children);
|
||||
}
|
||||
|
||||
/// 孩子日记已加载(只读)
|
||||
final class ParentJournalsLoaded extends ParentState {
|
||||
final String childId;
|
||||
final List<Map<String, dynamic>> journals;
|
||||
const ParentJournalsLoaded({
|
||||
required this.childId,
|
||||
required this.journals,
|
||||
});
|
||||
}
|
||||
|
||||
/// 数据已导出
|
||||
final class ParentDataExported extends ParentState {
|
||||
final String childId;
|
||||
final Map<String, dynamic> data;
|
||||
const ParentDataExported({
|
||||
required this.childId,
|
||||
required this.data,
|
||||
});
|
||||
}
|
||||
|
||||
/// 数据已删除
|
||||
final class ParentDataDeleted extends ParentState {
|
||||
final String childId;
|
||||
const ParentDataDeleted(this.childId);
|
||||
}
|
||||
|
||||
/// 出错
|
||||
final class ParentError extends ParentState {
|
||||
final String message;
|
||||
const ParentError(this.message);
|
||||
}
|
||||
|
||||
// ===== 模型 =====
|
||||
|
||||
/// 家长-孩子绑定关系
|
||||
class ChildBinding {
|
||||
final String bindingId;
|
||||
final String childId;
|
||||
final DateTime? verifiedAt;
|
||||
|
||||
const ChildBinding({
|
||||
required this.bindingId,
|
||||
required this.childId,
|
||||
this.verifiedAt,
|
||||
});
|
||||
|
||||
factory ChildBinding.fromJson(Map<String, dynamic> json) => ChildBinding(
|
||||
bindingId: json['binding_id'] as String,
|
||||
childId: json['child_id'] as String,
|
||||
verifiedAt: json['verified_at'] != null
|
||||
? DateTime.tryParse(json['verified_at'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
79
app/lib/features/search/bloc/search_bloc.dart
Normal file
79
app/lib/features/search/bloc/search_bloc.dart
Normal file
@@ -0,0 +1,79 @@
|
||||
// 搜索 BLoC — 标签+心情筛选日记
|
||||
//
|
||||
// 状态机: SearchInitial → SearchLoading → SearchLoaded/SearchError
|
||||
// Phase 1 使用简单的标签+心情筛选,后续可扩展全文搜索。
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../data/models/journal_entry.dart';
|
||||
import '../../../data/repositories/journal_repository.dart';
|
||||
|
||||
part 'search_event.dart';
|
||||
part 'search_state.dart';
|
||||
|
||||
/// 搜索 BLoC — 处理标签和心情筛选日记的状态转换
|
||||
class SearchBloc extends Bloc<SearchEvent, SearchState> {
|
||||
final JournalRepository _journalRepo;
|
||||
|
||||
SearchBloc({required JournalRepository journalRepository})
|
||||
: _journalRepo = journalRepository,
|
||||
super(const SearchInitial()) {
|
||||
on<SearchByMood>(_onSearchByMood);
|
||||
on<SearchByTag>(_onSearchByTag);
|
||||
on<SearchClear>(_onSearchClear);
|
||||
}
|
||||
|
||||
/// 按心情筛选日记
|
||||
Future<void> _onSearchByMood(
|
||||
SearchByMood event,
|
||||
Emitter<SearchState> emit,
|
||||
) async {
|
||||
emit(const SearchLoading());
|
||||
try {
|
||||
if (event.mood == null) {
|
||||
emit(const SearchLoaded());
|
||||
return;
|
||||
}
|
||||
final results = await _journalRepo.getJournals(
|
||||
mood: event.mood!.value,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
);
|
||||
emit(SearchLoaded(
|
||||
results: results,
|
||||
activeMood: event.mood!.value,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(const SearchError('搜索失败,请重试'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 按标签筛选日记
|
||||
Future<void> _onSearchByTag(
|
||||
SearchByTag event,
|
||||
Emitter<SearchState> emit,
|
||||
) async {
|
||||
emit(const SearchLoading());
|
||||
try {
|
||||
final results = await _journalRepo.getJournals(
|
||||
tag: event.tag,
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
);
|
||||
emit(SearchLoaded(
|
||||
results: results,
|
||||
activeTag: event.tag,
|
||||
));
|
||||
} catch (e) {
|
||||
emit(const SearchError('搜索失败,请重试'));
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除搜索结果
|
||||
void _onSearchClear(
|
||||
SearchClear event,
|
||||
Emitter<SearchState> emit,
|
||||
) {
|
||||
emit(const SearchLoaded());
|
||||
}
|
||||
}
|
||||
25
app/lib/features/search/bloc/search_event.dart
Normal file
25
app/lib/features/search/bloc/search_event.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
// 搜索事件 — SearchBloc 接收的用户操作
|
||||
|
||||
part of 'search_bloc.dart';
|
||||
|
||||
/// 搜索事件基类
|
||||
sealed class SearchEvent {
|
||||
const SearchEvent();
|
||||
}
|
||||
|
||||
/// 按心情筛选日记
|
||||
final class SearchByMood extends SearchEvent {
|
||||
final Mood? mood;
|
||||
const SearchByMood(this.mood);
|
||||
}
|
||||
|
||||
/// 按标签筛选日记
|
||||
final class SearchByTag extends SearchEvent {
|
||||
final String tag;
|
||||
const SearchByTag(this.tag);
|
||||
}
|
||||
|
||||
/// 清除搜索结果
|
||||
final class SearchClear extends SearchEvent {
|
||||
const SearchClear();
|
||||
}
|
||||
56
app/lib/features/search/bloc/search_state.dart
Normal file
56
app/lib/features/search/bloc/search_state.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
// 搜索状态 — SearchBloc 输出的 UI 状态
|
||||
|
||||
part of 'search_bloc.dart';
|
||||
|
||||
/// 搜索状态基类
|
||||
sealed class SearchState {
|
||||
const SearchState();
|
||||
}
|
||||
|
||||
/// 初始状态 — 未执行任何搜索
|
||||
final class SearchInitial extends SearchState {
|
||||
const SearchInitial();
|
||||
}
|
||||
|
||||
/// 加载中 — 正在查询日记
|
||||
final class SearchLoading extends SearchState {
|
||||
const SearchLoading();
|
||||
}
|
||||
|
||||
/// 搜索结果已加载
|
||||
final class SearchLoaded extends SearchState {
|
||||
/// 搜索结果列表(空列表表示无匹配)
|
||||
final List<JournalEntry> results;
|
||||
|
||||
/// 当前活跃的心情筛选条件
|
||||
final String? activeMood;
|
||||
|
||||
/// 当前活跃的标签筛选条件
|
||||
final String? activeTag;
|
||||
|
||||
const SearchLoaded({
|
||||
this.results = const [],
|
||||
this.activeMood,
|
||||
this.activeTag,
|
||||
});
|
||||
|
||||
/// 是否有活跃的筛选条件
|
||||
bool get hasActiveFilter => activeMood != null || activeTag != null;
|
||||
|
||||
SearchLoaded copyWith({
|
||||
List<JournalEntry>? results,
|
||||
String? activeMood,
|
||||
String? activeTag,
|
||||
}) =>
|
||||
SearchLoaded(
|
||||
results: results ?? this.results,
|
||||
activeMood: activeMood ?? this.activeMood,
|
||||
activeTag: activeTag ?? this.activeTag,
|
||||
);
|
||||
}
|
||||
|
||||
/// 搜索出错
|
||||
final class SearchError extends SearchState {
|
||||
final String message;
|
||||
const SearchError(this.message);
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
// 搜索页面 — 日记搜索 + 标签筛选
|
||||
// 搜索页面 — 标签+心情筛选日记
|
||||
//
|
||||
// 通过 SearchBloc 驱动搜索状态:
|
||||
// - 标签点击 → SearchByTag event
|
||||
// - 心情选择 → SearchByMood event
|
||||
// - 清除按钮 → SearchClear event
|
||||
// 搜索结果由 BlocBuilder<SearchBloc, SearchState> 响应式渲染。
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:nuanji_app/core/theme/app_colors.dart';
|
||||
import 'package:nuanji_app/data/models/journal_entry.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// 搜索页面 — 全文搜索日记(Phase 1 占位 UI)
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../data/models/journal_entry.dart';
|
||||
import '../bloc/search_bloc.dart';
|
||||
|
||||
/// 搜索页面 — 标签+心情筛选日记
|
||||
class SearchPage extends StatefulWidget {
|
||||
const SearchPage({super.key});
|
||||
|
||||
@@ -14,9 +24,8 @@ class SearchPage extends StatefulWidget {
|
||||
|
||||
class _SearchPageState extends State<SearchPage> {
|
||||
final _searchController = TextEditingController();
|
||||
bool _hasSearched = false;
|
||||
|
||||
// Phase 1 占位数据
|
||||
// Phase 1 占位标签数据
|
||||
final _recentTags = ['日常', '学校', '旅行', '美食', '读书', '心情'];
|
||||
final _moodFilters = Mood.values;
|
||||
|
||||
@@ -31,48 +40,88 @@ class _SearchPageState extends State<SearchPage> {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索日记...',
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
return BlocBuilder<SearchBloc, SearchState>(
|
||||
builder: (context, state) {
|
||||
final hasFilter = state is SearchLoaded && state.hasActiveFilter;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索日记...',
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: hasFilter
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.filter_alt_off),
|
||||
tooltip: '清除筛选',
|
||||
onPressed: _clearSearch,
|
||||
)
|
||||
: (_searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_clearSearch();
|
||||
},
|
||||
)
|
||||
: null),
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (value) {
|
||||
if (value.trim().isNotEmpty) {
|
||||
context.read<SearchBloc>().add(SearchByTag(value.trim()));
|
||||
}
|
||||
},
|
||||
),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _hasSearched = false);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: (_) => _doSearch(),
|
||||
),
|
||||
),
|
||||
body: _hasSearched
|
||||
? _buildSearchResults(context, colorScheme)
|
||||
: _buildSuggestions(context, theme, colorScheme),
|
||||
body: _buildBody(context, theme, colorScheme, state),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _doSearch() {
|
||||
setState(() => _hasSearched = true);
|
||||
/// 根据搜索状态构建 body
|
||||
Widget _buildBody(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ColorScheme colorScheme,
|
||||
SearchState state,
|
||||
) {
|
||||
return switch (state) {
|
||||
SearchInitial() => _buildSuggestions(context, theme, colorScheme),
|
||||
SearchLoading() => const Center(child: CircularProgressIndicator()),
|
||||
SearchLoaded(:final results, :final activeMood, :final activeTag) =>
|
||||
_hasActiveFilter(activeMood, activeTag)
|
||||
? _buildResults(context, theme, colorScheme, results)
|
||||
: _buildSuggestions(context, theme, colorScheme),
|
||||
SearchError(:final message) => _buildError(colorScheme, message),
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildSuggestions(BuildContext context, ThemeData theme, ColorScheme colorScheme) {
|
||||
bool _hasActiveFilter(String? mood, String? tag) =>
|
||||
mood != null || tag != null;
|
||||
|
||||
/// 建议区域 — 标签云 + 心情选择
|
||||
Widget _buildSuggestions(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('常用标签', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
'常用标签',
|
||||
style:
|
||||
theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
@@ -82,23 +131,28 @@ class _SearchPageState extends State<SearchPage> {
|
||||
label: Text(tag),
|
||||
onPressed: () {
|
||||
_searchController.text = tag;
|
||||
_doSearch();
|
||||
context.read<SearchBloc>().add(SearchByTag(tag));
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text('按心情筛选', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
'按心情筛选',
|
||||
style:
|
||||
theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: _moodFilters.map((mood) {
|
||||
final color = AppColors.moodColors[mood.value] ?? colorScheme.primary;
|
||||
final color =
|
||||
AppColors.moodColors[mood.value] ?? colorScheme.primary;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_searchController.text = _moodLabel(mood);
|
||||
_doSearch();
|
||||
context.read<SearchBloc>().add(SearchByMood(mood));
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -110,7 +164,8 @@ class _SearchPageState extends State<SearchPage> {
|
||||
color: color.withValues(alpha: 0.15),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(_moodEmoji(mood), style: const TextStyle(fontSize: 24)),
|
||||
child: Text(_moodEmoji(mood),
|
||||
style: const TextStyle(fontSize: 24)),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(_moodLabel(mood), style: theme.textTheme.labelSmall),
|
||||
@@ -124,24 +179,81 @@ class _SearchPageState extends State<SearchPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResults(BuildContext context, ColorScheme colorScheme) {
|
||||
/// 搜索结果列表
|
||||
Widget _buildResults(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ColorScheme colorScheme,
|
||||
List<JournalEntry> results,
|
||||
) {
|
||||
if (results.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.search_off_rounded,
|
||||
size: 48,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.2)),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'没有找到匹配的日记',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonal(
|
||||
onPressed: _clearSearch,
|
||||
child: const Text('清除筛选'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: results.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final entry = results[index];
|
||||
return _JournalCard(entry: entry);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 错误提示
|
||||
Widget _buildError(ColorScheme colorScheme, String message) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.search_off_rounded, size: 48, color: colorScheme.onSurface.withValues(alpha: 0.2)),
|
||||
Icon(Icons.error_outline_rounded,
|
||||
size: 48,
|
||||
color: colorScheme.error.withValues(alpha: 0.6)),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Phase 1: 搜索功能待 Isar FTS 集成',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.5),
|
||||
message,
|
||||
style: TextStyle(
|
||||
color: colorScheme.error,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.tonal(
|
||||
onPressed: _clearSearch,
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
_searchController.clear();
|
||||
context.read<SearchBloc>().add(const SearchClear());
|
||||
}
|
||||
|
||||
String _moodEmoji(Mood mood) => switch (mood) {
|
||||
Mood.happy => '😊',
|
||||
Mood.calm => '😌',
|
||||
@@ -158,3 +270,101 @@ class _SearchPageState extends State<SearchPage> {
|
||||
Mood.thinking => '思考',
|
||||
};
|
||||
}
|
||||
|
||||
/// 日记卡片 — 在搜索结果中展示单条日记摘要
|
||||
class _JournalCard extends StatelessWidget {
|
||||
final JournalEntry entry;
|
||||
|
||||
const _JournalCard({required this.entry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final moodColor =
|
||||
AppColors.moodColors[entry.mood.value] ?? colorScheme.primary;
|
||||
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
// TODO: 导航到日记详情页
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 第一行:心情 emoji + 标题 + 日期
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_moodEmoji(entry.mood),
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
DateFormat('MM/dd').format(entry.date),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 第二行:标签
|
||||
if (entry.tags.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: entry.tags.take(4).map((tag) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: moodColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
tag,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: moodColor,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _moodEmoji(Mood mood) => switch (mood) {
|
||||
Mood.happy => '😊',
|
||||
Mood.calm => '😌',
|
||||
Mood.sad => '😢',
|
||||
Mood.angry => '😠',
|
||||
Mood.thinking => '🤔',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +200,8 @@ class _FailingJournalRepository implements JournalRepository {
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
}) async {
|
||||
throw Exception('模拟网络错误');
|
||||
}
|
||||
|
||||
@@ -220,6 +220,8 @@ class _FailingJournalRepository implements JournalRepository {
|
||||
DateTime? dateTo,
|
||||
int? page,
|
||||
int? pageSize,
|
||||
String? mood,
|
||||
String? tag,
|
||||
}) async {
|
||||
throw Exception('网络不可用');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user