Files
nj/app/lib/features/search/views/search_page.dart
iven 749ef55b89
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
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
2026-06-01 23:53:34 +08:00

371 lines
12 KiB
Dart

// 搜索页面 — 标签+心情筛选日记
//
// 通过 SearchBloc 驱动搜索状态:
// - 标签点击 → SearchByTag event
// - 心情选择 → SearchByMood event
// - 清除按钮 → SearchClear event
// 搜索结果由 BlocBuilder<SearchBloc, SearchState> 响应式渲染。
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
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});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
final _searchController = TextEditingController();
// Phase 1 占位标签数据
final _recentTags = ['日常', '学校', '旅行', '美食', '读书', '心情'];
final _moodFilters = Mood.values;
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
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()));
}
},
),
),
body: _buildBody(context, theme, colorScheme, state),
);
},
);
}
/// 根据搜索状态构建 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),
};
}
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),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _recentTags.map((tag) {
return ActionChip(
label: Text(tag),
onPressed: () {
_searchController.text = tag;
context.read<SearchBloc>().add(SearchByTag(tag));
},
);
}).toList(),
),
const SizedBox(height: 24),
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;
return GestureDetector(
onTap: () {
_searchController.text = _moodLabel(mood);
context.read<SearchBloc>().add(SearchByMood(mood));
},
child: Column(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: 0.15),
),
alignment: Alignment.center,
child: Text(_moodEmoji(mood),
style: const TextStyle(fontSize: 24)),
),
const SizedBox(height: 4),
Text(_moodLabel(mood), style: theme.textTheme.labelSmall),
],
),
);
}).toList(),
),
],
),
);
}
/// 搜索结果列表
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.error_outline_rounded,
size: 48,
color: colorScheme.error.withValues(alpha: 0.6)),
const SizedBox(height: 12),
Text(
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 => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
String _moodLabel(Mood mood) => switch (mood) {
Mood.happy => '开心',
Mood.calm => '平静',
Mood.sad => '难过',
Mood.angry => '生气',
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 => '🤔',
};
}