feat: Week 4 收尾 + 架构治理 — 搜索/家长中心/Feature Flag/Docker/环境配置
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

架构治理:
- 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:
iven
2026-06-01 23:53:34 +08:00
parent ffde0c9e77
commit 749ef55b89
27 changed files with 2589 additions and 151 deletions

View 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('解绑失败'));
}
}
}

View 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);
}

View 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,
);
}