fix(app): 修复 P2~P4 共 10 项前端问题
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled

P2 必须修复:
- 教师布置主题 classId 从硬编码改为班级下拉选择器
- 班级日记墙使用服务端 classId 过滤替代前端过滤
- Profile 统计栏接入 JournalRepository 真实数据
- WeeklyPage 从全硬编码改为 JournalRepository 数据驱动

P3 建议改进:
- 提取 mood_utils.dart 公共函数,消除 4 处重复定义
- 贴纸库搜索框连接 StickerBloc 按名称过滤

P4 细节打磨:
- 家长页多孩子时显示 DropdownButton 选择器
- 搜索结果日记卡片点击跳转 /editor?id=
- MonthlyPage 照片数量从 JournalElement 统计
- calendar_page/mood_page/search_page 统一使用 moodToEmoji/moodToLabel
This commit is contained in:
iven
2026-06-02 20:21:51 +08:00
parent 75db6a7eb7
commit 7e928ae1e1
17 changed files with 2537 additions and 799 deletions

View File

@@ -1,20 +1,25 @@
// 搜索页面 — 标签+心情筛选日记
// 搜索页面 — 搜索历史 + 热门搜索 + 关键词高亮 + 分类 Tab
//
// 通过 SearchBloc 驱动搜索状态:
// - 关键词输入 → SearchByKeyword event
// - 标签点击 → SearchByTag event
// - 心情选择 → SearchByMood event
// - Tab 切换 → SearchTabChanged event
// - 清除按钮 → SearchClear event
// 搜索结果由 BlocBuilder<SearchBloc, SearchState> 响应式渲染。
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
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 '../bloc/search_bloc.dart';
/// 搜索页面 — 标签+心情筛选日记
/// 搜索页面 — 搜索历史 + 热门搜索 + 结果分类
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@@ -24,14 +29,24 @@ class SearchPage extends StatefulWidget {
class _SearchPageState extends State<SearchPage> {
final _searchController = TextEditingController();
final _searchFocusNode = FocusNode();
// Phase 1 占位标签数据
final _recentTags = ['日常', '学校', '旅行', '美食', '读书', '心情'];
final _moodFilters = Mood.values;
// 热门搜索占位数据
final _hotSearches = ['日常', '学校', '旅行', '美食', '读书', '心情', '手账', '贴纸'];
@override
void initState() {
super.initState();
// 自动弹出键盘
WidgetsBinding.instance.addPostFrameCallback((_) {
_searchFocusNode.requestFocus();
});
}
@override
void dispose() {
_searchController.dispose();
_searchFocusNode.dispose();
super.dispose();
}
@@ -39,120 +54,341 @@ class _SearchPageState extends State<SearchPage> {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isDark = theme.brightness == Brightness.dark;
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),
body: SafeArea(
child: Column(
children: [
// 6A: 搜索头部 — 返回 + 输入框 + 取消
_buildSearchHeader(
context, theme, colorScheme, isDark),
// 6C: 结果分类 Tab有结果时显示
if (state case SearchLoaded(:final hasActiveFilter) when hasActiveFilter)
_buildResultTabs(context, state),
// 主体内容
Expanded(
child: _buildBody(
context, theme, colorScheme, isDark, state),
),
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
// ===== 6A: 搜索头部 =====
Widget _buildSearchHeader(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
bool isDark,
) {
final surfaceWarmColor = isDark
? AppColors.surfaceWarmDark
: AppColors.surfaceWarmLight;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: Row(
children: [
// 返回按钮
SizedBox(
width: 44,
height: 44,
child: IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(
Icons.arrow_back_ios_new,
size: 18,
color: isDark
? AppColors.fgDark
: AppColors.fgLight,
),
style: IconButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(AppRadius.pill),
),
),
),
),
const SizedBox(width: 4),
// 搜索输入框
Expanded(
child: SizedBox(
height: 44,
child: TextField(
controller: _searchController,
focusNode: _searchFocusNode,
decoration: InputDecoration(
hintText: '搜索日记...',
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: isDark
? AppColors.mutedDark
: AppColors.mutedLight,
),
prefixIcon: Icon(
Icons.search,
size: 20,
color: isDark
? AppColors.mutedDark
: AppColors.mutedLight,
),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(
Icons.clear,
size: 18,
color: isDark
? AppColors.mutedDark
: AppColors.mutedLight,
),
onPressed: () {
_searchController.clear();
_clearSearch();
},
)
: null,
filled: true,
fillColor: surfaceWarmColor,
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(AppRadius.pill),
borderSide: BorderSide.none,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16),
isDense: true,
),
textInputAction: TextInputAction.search,
onSubmitted: (value) {
if (value.trim().isNotEmpty) {
context
.read<SearchBloc>()
.add(SearchByKeyword(value.trim()));
}
},
),
),
),
const SizedBox(width: 4),
// 取消按钮
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'取消',
style: theme.textTheme.bodyMedium?.copyWith(
color: AppColors.accent,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
// ===== 6C: 结果分类 Tab =====
Widget _buildResultTabs(
BuildContext context, SearchLoaded state) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: SearchResultTab.values.map((tab) {
final isActive = state.activeTab == tab;
return GestureDetector(
onTap: () {
context
.read<SearchBloc>()
.add(SearchTabChanged(tab));
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: isActive
? AppColors.accent
: Colors.transparent,
width: 3,
),
),
),
child: Text(
tab.label,
style: theme.textTheme.bodySmall?.copyWith(
color: isActive
? AppColors.accent
: (isDark
? AppColors.mutedDark
: AppColors.mutedLight),
fontWeight: isActive
? FontWeight.w600
: FontWeight.normal,
),
),
),
);
}).toList(),
),
);
}
// ===== 主体内容 =====
Widget _buildBody(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
bool isDark,
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),
SearchInitial() => _buildSuggestions(
context, theme, colorScheme, isDark, []),
SearchLoading() =>
const Center(child: CircularProgressIndicator()),
SearchLoaded(
:final results,
:final activeMood,
:final activeTag,
:final activeKeyword,
:final activeTab,
:final searchHistory,
) =>
hasActiveFilter(activeMood, activeTag, activeKeyword)
? _buildFilteredResults(
context,
theme,
colorScheme,
isDark,
results,
activeKeyword,
activeTab,
)
: _buildSuggestions(
context, theme, colorScheme, isDark, searchHistory),
SearchError(:final message) =>
_buildError(colorScheme, message),
};
}
bool _hasActiveFilter(String? mood, String? tag) =>
mood != null || tag != null;
bool hasActiveFilter(String? mood, String? tag, String? keyword) =>
mood != null || tag != null || keyword != null;
// ===== 6B: 搜索历史 + 热门搜索 =====
/// 建议区域 — 标签云 + 心情选择
Widget _buildSuggestions(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
bool isDark,
List<String> searchHistory,
) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 搜索历史
if (searchHistory.isNotEmpty) ...[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'最近搜索',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
GestureDetector(
onTap: _clearSearch,
child: Text(
'清除',
style: theme.textTheme.bodySmall?.copyWith(
color: AppColors.accent,
),
),
),
],
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: searchHistory.map((keyword) {
return ActionChip(
label: Text(keyword),
onPressed: () {
_searchController.text = keyword;
context
.read<SearchBloc>()
.add(SearchByKeyword(keyword));
},
);
}).toList(),
),
const SizedBox(height: 24),
],
// 热门搜索
Text(
'常用标签',
style:
theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
'热门搜索',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _recentTags.map((tag) {
children: _hotSearches.map((keyword) {
return ActionChip(
label: Text(tag),
label: Text(keyword),
onPressed: () {
_searchController.text = tag;
context.read<SearchBloc>().add(SearchByTag(tag));
_searchController.text = keyword;
context
.read<SearchBloc>()
.add(SearchByKeyword(keyword));
},
);
}).toList(),
),
const SizedBox(height: 24),
// 心情筛选
Text(
'按心情筛选',
style:
theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
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;
children: Mood.values.map((mood) {
final color = AppColors.moodColors[mood.value] ??
colorScheme.primary;
return GestureDetector(
onTap: () {
_searchController.text = _moodLabel(mood);
context.read<SearchBloc>().add(SearchByMood(mood));
_searchController.text = moodToLabel(mood);
context
.read<SearchBloc>()
.add(SearchByMood(mood));
},
child: Column(
children: [
@@ -164,11 +400,12 @@ class _SearchPageState extends State<SearchPage> {
color: color.withValues(alpha: 0.15),
),
alignment: Alignment.center,
child: Text(_moodEmoji(mood),
child: Text(moodToEmoji(mood),
style: const TextStyle(fontSize: 24)),
),
const SizedBox(height: 4),
Text(_moodLabel(mood), style: theme.textTheme.labelSmall),
Text(moodToLabel(mood),
style: theme.textTheme.labelSmall),
],
),
);
@@ -179,12 +416,16 @@ class _SearchPageState extends State<SearchPage> {
);
}
/// 搜索结果列表
Widget _buildResults(
// ===== 按分类 Tab 过滤结果 =====
Widget _buildFilteredResults(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
bool isDark,
List<JournalEntry> results,
String? keyword,
SearchResultTab activeTab,
) {
if (results.isEmpty) {
return Center(
@@ -198,7 +439,8 @@ class _SearchPageState extends State<SearchPage> {
Text(
'没有找到匹配的日记',
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.5),
color:
colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 16),
@@ -211,17 +453,173 @@ class _SearchPageState extends State<SearchPage> {
);
}
// 根据活跃 Tab 选择展示内容
switch (activeTab) {
case SearchResultTab.all:
case SearchResultTab.journal:
return _buildJournalResults(
context, theme, colorScheme, isDark, results, keyword);
case SearchResultTab.template:
return _buildTemplateResults(theme, isDark);
case SearchResultTab.tag:
return _buildTagResults(theme, isDark, results);
}
}
// ===== 日记结果列表 =====
Widget _buildJournalResults(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
bool isDark,
List<JournalEntry> results,
String? keyword,
) {
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);
return _JournalCard(
entry: entry,
keyword: keyword,
);
},
);
}
// ===== 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),
),
),
],
),
),
],
),
);
},
);
}
// ===== 6E: 标签结果 =====
Widget _buildTagResults(
ThemeData theme, bool isDark, List<JournalEntry> results) {
// 从搜索结果中提取所有标签及其频次
final tagFreq = <String, int>{};
for (final entry in results) {
for (final tag in entry.tags) {
tagFreq[tag] = (tagFreq[tag] ?? 0) + 1;
}
}
final sortedTags = tagFreq.keys.toList()
..sort((a, b) => tagFreq[b]!.compareTo(tagFreq[a]!));
if (sortedTags.isEmpty) {
return Center(
child: Text(
'没有找到相关标签',
style: theme.textTheme.bodyMedium?.copyWith(
color: isDark ? AppColors.mutedDark : AppColors.mutedLight,
),
),
);
}
return Wrap(
spacing: 10,
runSpacing: 10,
children: sortedTags.map((tag) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: isDark
? AppColors.surfaceDark
: AppColors.surfaceLight,
borderRadius: BorderRadius.circular(AppRadius.pill),
border: Border.all(
color: isDark
? AppColors.borderDark
: AppColors.borderLight,
),
),
child: Text(
'$tag (${tagFreq[tag]})',
style: theme.textTheme.bodyMedium,
),
);
}).toList(),
).padAll(16);
}
/// 错误提示
Widget _buildError(ColorScheme colorScheme, String message) {
return Center(
@@ -253,29 +651,50 @@ class _SearchPageState extends State<SearchPage> {
_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 => '思考',
};
}
/// 日记卡片 — 在搜索结果中展示单条日记摘要
// ===== 6D: 关键词高亮辅助函数 =====
/// 将文本中的关键词部分用高亮样式包裹
Widget _highlightText(String text, String? keyword) {
if (keyword == null || keyword.isEmpty || !text.toLowerCase().contains(keyword.toLowerCase())) {
return Text(text);
}
final lowerText = text.toLowerCase();
final lowerKeyword = keyword.toLowerCase();
final spans = <TextSpan>[];
int start = 0;
while (start < text.length) {
final index = lowerText.indexOf(lowerKeyword, start);
if (index == -1) {
spans.add(TextSpan(text: text.substring(start)));
break;
}
if (index > start) {
spans.add(TextSpan(text: text.substring(start, index)));
}
spans.add(TextSpan(
text: text.substring(index, index + keyword.length),
style: const TextStyle(
color: AppColors.accent,
backgroundColor: AppColors.tertiarySoftLight,
fontWeight: FontWeight.w600,
),
));
start = index + keyword.length;
}
return RichText(text: TextSpan(style: const TextStyle(), children: spans));
}
/// 日记卡片 — 在搜索结果中展示单条日记摘要(支持关键词高亮)
class _JournalCard extends StatelessWidget {
final JournalEntry entry;
final String? keyword;
const _JournalCard({required this.entry});
const _JournalCard({required this.entry, this.keyword});
@override
Widget build(BuildContext context) {
@@ -293,30 +712,23 @@ class _JournalCard extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
// TODO: 导航到日记详情页
context.push('/editor?id=${entry.id}');
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 第一行:心情 emoji + 标题 + 日期
// 第一行:心情 emoji + 标题(高亮)+ 日期
Row(
children: [
Text(
_moodEmoji(entry.mood),
moodToEmoji(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,
),
child: _highlightText(entry.title, keyword),
),
Text(
DateFormat('MM/dd').format(entry.date),
@@ -360,11 +772,12 @@ class _JournalCard extends StatelessWidget {
);
}
String _moodEmoji(Mood mood) => switch (mood) {
Mood.happy => '😊',
Mood.calm => '😌',
Mood.sad => '😢',
Mood.angry => '😠',
Mood.thinking => '🤔',
};
}
/// Padding 扩展 — 简化 Wrap 的 padding
extension _PadAll on Widget {
Widget padAll(double value) => Padding(
padding: EdgeInsets.all(value),
child: this,
);
}