Compare commits
7 Commits
4cb91f3ac9
...
3c3d70c751
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c3d70c751 | ||
|
|
ed8252d7c8 | ||
|
|
41ef28f20b | ||
|
|
d67eedf7de | ||
|
|
a05374e8d1 | ||
|
|
a5d2b0409f | ||
|
|
3bc2ca7332 |
@@ -42,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';
|
||||
|
||||
@@ -168,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(
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -313,7 +313,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),
|
||||
),
|
||||
),
|
||||
|
||||
78
app/lib/features/discover/bloc/discover_bloc.dart
Normal file
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
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
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
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,48 +19,198 @@ 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 '../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: 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 Column(
|
||||
children: [
|
||||
const _LoadingSkeleton(height: 140),
|
||||
const SizedBox(height: DesignTokens.spacing24),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(DesignTokens.spacing16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rose.withValues(alpha: 0.1),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.cloud_off_rounded,
|
||||
size: 32, color: AppColors.rose),
|
||||
const SizedBox(height: DesignTokens.spacing8),
|
||||
Text(message,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
_SearchBar(onTap: () => context.push('/search')),
|
||||
const SizedBox(height: DesignTokens.spacing20),
|
||||
const _InspirationCard(
|
||||
title: '今日推荐:图书馆的午后时光',
|
||||
author: '小暖 · 5月31日',
|
||||
emoji: '📚',
|
||||
TextButton.icon(
|
||||
onPressed: () => context
|
||||
.read<DiscoverBloc>()
|
||||
.add(const DiscoverLoadData()),
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 空数据提示
|
||||
Widget _buildEmptyHint(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: DesignTokens.spacing32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
const Text('✨', style: TextStyle(fontSize: 40)),
|
||||
const SizedBox(height: DesignTokens.spacing12),
|
||||
Text('还没有发现内容',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
)),
|
||||
const SizedBox(height: 4),
|
||||
Text('写下你的第一篇日记,出现在这里吧!',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant
|
||||
.withValues(alpha: 0.7),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -77,7 +229,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 +238,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 +257,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 +376,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 +405,10 @@ class _InspirationCard extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.month}月${date.day}日';
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
@@ -241,12 +432,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 +442,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 +478,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 +492,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 +571,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 +585,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 +612,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 +623,7 @@ class _ExpertDiariesList extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
e.$2,
|
||||
diary.authorName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
@@ -432,12 +633,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 +654,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 +672,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});
|
||||
}
|
||||
|
||||
// --- 工具栏事件 ---
|
||||
|
||||
/// 切换活动工具
|
||||
@@ -335,6 +345,7 @@ class EditorBloc extends Bloc<EditorEvent, EditorState> {
|
||||
on<ElementResized>(_onElementResized);
|
||||
on<ElementRotated>(_onElementRotated);
|
||||
on<ElementSelected>(_onElementSelected);
|
||||
on<ElementLayerChanged>(_onElementLayerChanged);
|
||||
on<ElementsLoaded>(_onElementsLoaded);
|
||||
|
||||
// 日记加载事件
|
||||
@@ -492,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));
|
||||
}
|
||||
|
||||
@@ -334,15 +334,26 @@ class _EditorView extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _EditorViewState extends State<_EditorView> {
|
||||
/// 查看模式:打开已有日记时默认只读,点击"编辑"后进入编辑模式
|
||||
bool _isViewMode = 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 {
|
||||
@@ -381,6 +392,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');
|
||||
}
|
||||
@@ -405,26 +421,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(
|
||||
@@ -467,51 +488,67 @@ 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),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -674,8 +711,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();
|
||||
@@ -900,6 +942,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,
|
||||
@@ -943,8 +986,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(
|
||||
@@ -967,8 +1010,8 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
},
|
||||
),
|
||||
|
||||
// 图片选择覆盖层(图片工具激活时显示)
|
||||
if (state.activeTool == EditorTool.photo)
|
||||
// 图片选择覆盖层(图片工具激活时显示)— 仅编辑模式
|
||||
if (!widget.isViewMode && state.activeTool == EditorTool.photo)
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -988,8 +1031,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),
|
||||
],
|
||||
);
|
||||
@@ -1057,6 +1103,11 @@ class _EditorStackState extends State<_EditorStack> {
|
||||
onDeleted: (id) {
|
||||
context.read<EditorBloc>().add(ElementRemoved(id));
|
||||
},
|
||||
onLayerChanged: (id, change) {
|
||||
context.read<EditorBloc>().add(
|
||||
ElementLayerChanged(elementId: id, change: change),
|
||||
);
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
@@ -22,6 +23,7 @@ class DraggableElement extends StatefulWidget {
|
||||
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,
|
||||
@@ -32,6 +34,7 @@ class DraggableElement extends StatefulWidget {
|
||||
this.onResized,
|
||||
this.onRotated,
|
||||
required this.onDeleted,
|
||||
this.onLayerChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -142,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -279,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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -91,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'),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
@@ -430,9 +425,79 @@ class _LiveStatsBarState extends State<_LiveStatsBar> {
|
||||
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||||
_StatItem(label: '本月日记', value: '$_monthCount', valueColor: widget.colorScheme.onSurface),
|
||||
VerticalDivider(width: 1, indent: 4, endIndent: 4, color: widget.borderSoft),
|
||||
_StatItem(label: '贴纸数', value: '--', valueColor: widget.colorScheme.onSurface),
|
||||
_StatItem(label: '贴纸数', value: _totalCount > 0 ? '$_totalCount' : '0', valueColor: widget.colorScheme.onSurface),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 成就徽章动态组件 — 从 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,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() {
|
||||
@@ -120,7 +129,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,13 +157,13 @@ 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),
|
||||
|
||||
// ---- 贴纸包网格 ----
|
||||
@@ -188,9 +197,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 +208,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 +224,6 @@ class _FeaturedPackCard extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// emoji 图标区域
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
@@ -223,30 +232,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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -380,6 +380,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 {
|
||||
|
||||
40
crates/erp-diary/src/handler/discover_handler.rs
Normal file
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)))
|
||||
}
|
||||
@@ -9,3 +9,4 @@ pub mod sticker_handler;
|
||||
pub mod achievement_handler;
|
||||
pub mod stats_handler;
|
||||
pub mod parent_handler;
|
||||
pub mod discover_handler;
|
||||
|
||||
@@ -12,7 +12,7 @@ use erp_core::module::ErpModule;
|
||||
|
||||
use crate::handler::{
|
||||
journal_handler, sync_handler, class_handler, topic_handler, comment_handler,
|
||||
sticker_handler, achievement_handler, stats_handler, parent_handler,
|
||||
sticker_handler, achievement_handler, stats_handler, parent_handler, discover_handler,
|
||||
};
|
||||
|
||||
/// 暖记日记业务模块
|
||||
@@ -268,5 +268,10 @@ impl DiaryModule {
|
||||
"/diary/parent/bindings/{binding_id}/reject",
|
||||
axum::routing::post(parent_handler::reject_binding),
|
||||
)
|
||||
// 发现页 — 灵感、热门话题、精选模板、达人日记
|
||||
.route(
|
||||
"/diary/discover",
|
||||
axum::routing::get(discover_handler::get_discover),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
312
crates/erp-diary/src/service/discover_service.rs
Normal file
312
crates/erp-diary/src/service/discover_service.rs
Normal file
@@ -0,0 +1,312 @@
|
||||
// 发现页服务 — 聚合热门话题、精选模板、每日推荐、达人日记
|
||||
|
||||
use sea_orm::{
|
||||
ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder,
|
||||
QuerySelect, Statement,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::dto::{DiscoverResp, ExpertDiaryItem, InspirationItem, TagCount, TemplateResp};
|
||||
use crate::entity::template;
|
||||
use crate::error::DiaryResult;
|
||||
|
||||
/// 发现页服务 — 聚合查询,一次返回全部板块数据
|
||||
pub struct DiscoverService;
|
||||
|
||||
/// 心情 → emoji 映射
|
||||
fn mood_to_emoji(mood: &str) -> &'static str {
|
||||
match mood {
|
||||
"happy" => "😊",
|
||||
"calm" => "😌",
|
||||
"sad" => "😢",
|
||||
"angry" => "😤",
|
||||
"thinking" => "🤔",
|
||||
_ => "📝",
|
||||
}
|
||||
}
|
||||
|
||||
impl DiscoverService {
|
||||
/// 获取发现页全部数据(4 个板块并发查询)
|
||||
pub async fn get_discover(
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<DiscoverResp> {
|
||||
let (inspiration, topics, templates, experts) = tokio::join!(
|
||||
Self::daily_inspiration(tenant_id, db),
|
||||
Self::hot_topics(tenant_id, db),
|
||||
Self::featured_templates(tenant_id, db),
|
||||
Self::expert_diaries(tenant_id, db),
|
||||
);
|
||||
|
||||
Ok(DiscoverResp {
|
||||
daily_inspiration: inspiration?,
|
||||
hot_topics: topics?,
|
||||
featured_templates: templates?,
|
||||
expert_diaries: experts?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 每日推荐 — 基于日期种子的确定性随机,选取一篇共享日记
|
||||
///
|
||||
/// 使用日期字符串作为盐,与 UUID 拼接后取哈希,得到每天固定但不同的结果。
|
||||
/// 无共享日记时返回 None。
|
||||
async fn daily_inspiration(
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<Option<InspirationItem>> {
|
||||
let date_seed = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
|
||||
let sql = r#"
|
||||
SELECT id, title, author_id, mood, date
|
||||
FROM journal_entries
|
||||
WHERE tenant_id = $1
|
||||
AND is_private = false
|
||||
AND shared_to_class = true
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY (
|
||||
('x' || md5(id::text || $2))::bit(32)::int
|
||||
) DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
|
||||
let stmt = Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into(), date_seed.into()],
|
||||
);
|
||||
|
||||
let rows = db.query_all(stmt).await?;
|
||||
|
||||
if let Some(row) = rows.into_iter().next() {
|
||||
let journal_id: Uuid = row.try_get_by_index::<Uuid>(0)?;
|
||||
let title: String = row.try_get_by_index::<String>(1)?;
|
||||
let author_id: Uuid = row.try_get_by_index::<Uuid>(2)?;
|
||||
let mood: String = row.try_get_by_index::<String>(3)?;
|
||||
let date: chrono::NaiveDate = row.try_get_by_index::<chrono::NaiveDate>(4)?;
|
||||
|
||||
// Phase 1: 用 author_id 前 4 位作为昵称后缀
|
||||
let author_hex = author_id.to_string().replace('-', "");
|
||||
let suffix = &author_hex[..4];
|
||||
let author_name = format!("小暖·{}", suffix);
|
||||
|
||||
Ok(Some(InspirationItem {
|
||||
journal_id,
|
||||
title,
|
||||
author_name,
|
||||
mood,
|
||||
date,
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// 热门话题 — 统计所有非私密日记的标签频率,返回 TOP 8
|
||||
async fn hot_topics(
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<Vec<TagCount>> {
|
||||
let sql = r#"
|
||||
SELECT tag, COUNT(*) AS count
|
||||
FROM (
|
||||
SELECT jsonb_array_elements_text(tags) AS tag
|
||||
FROM journal_entries
|
||||
WHERE tenant_id = $1
|
||||
AND is_private = false
|
||||
AND deleted_at IS NULL
|
||||
AND tags IS NOT NULL
|
||||
) sub
|
||||
GROUP BY tag
|
||||
ORDER BY count DESC
|
||||
LIMIT 8
|
||||
"#;
|
||||
|
||||
let stmt = Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
);
|
||||
|
||||
let rows = db.query_all(stmt).await?;
|
||||
|
||||
let topics = rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let tag: String = row.try_get_by_index::<String>(0).ok()?;
|
||||
let count: i64 = row.try_get_by_index::<i64>(1).ok()?;
|
||||
Some(TagCount { tag, count })
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(topics)
|
||||
}
|
||||
|
||||
/// 精选模板 — 官方模板,按名称排序,最多 6 个
|
||||
async fn featured_templates(
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<Vec<TemplateResp>> {
|
||||
let templates = template::Entity::find()
|
||||
.filter(template::Column::TenantId.eq(tenant_id))
|
||||
.filter(template::Column::IsOfficial.eq(true))
|
||||
.filter(template::Column::DeletedAt.is_null())
|
||||
.order_by_asc(template::Column::Name)
|
||||
.limit(6)
|
||||
.all(db)
|
||||
.await?;
|
||||
|
||||
Ok(templates
|
||||
.into_iter()
|
||||
.map(|t| TemplateResp {
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
description: None,
|
||||
preview_url: t.thumbnail_url,
|
||||
template_data: None, // 发现页不需要完整布局数据
|
||||
category: t.category,
|
||||
is_free: true,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// 达人日记 — 不同作者最近共享的日记,以评论数作为热度代理
|
||||
async fn expert_diaries(
|
||||
tenant_id: Uuid,
|
||||
db: &DatabaseConnection,
|
||||
) -> DiaryResult<Vec<ExpertDiaryItem>> {
|
||||
let sql = r#"
|
||||
SELECT
|
||||
j.id, j.title, j.author_id, j.mood,
|
||||
j.created_at,
|
||||
COUNT(c.id) AS comment_count
|
||||
FROM journal_entries j
|
||||
LEFT JOIN comments c
|
||||
ON c.journal_id = j.id
|
||||
AND c.deleted_at IS NULL
|
||||
WHERE j.tenant_id = $1
|
||||
AND j.is_private = false
|
||||
AND j.shared_to_class = true
|
||||
AND j.deleted_at IS NULL
|
||||
GROUP BY j.id
|
||||
ORDER BY j.created_at DESC
|
||||
LIMIT 20
|
||||
"#;
|
||||
|
||||
let stmt = Statement::from_sql_and_values(
|
||||
sea_orm::DatabaseBackend::Postgres,
|
||||
sql,
|
||||
[tenant_id.into()],
|
||||
);
|
||||
|
||||
let rows = db.query_all(stmt).await?;
|
||||
|
||||
// 去重:每个作者只保留最新一篇,最多 5 位作者
|
||||
let mut seen_authors = std::collections::HashSet::new();
|
||||
let mut experts = Vec::new();
|
||||
|
||||
for row in rows {
|
||||
let author_id: Uuid = row.try_get_by_index::<Uuid>(2)?;
|
||||
if seen_authors.contains(&author_id) {
|
||||
continue;
|
||||
}
|
||||
if experts.len() >= 5 {
|
||||
break;
|
||||
}
|
||||
seen_authors.insert(author_id);
|
||||
|
||||
let journal_id: Uuid = row.try_get_by_index::<Uuid>(0)?;
|
||||
let title: String = row.try_get_by_index::<String>(1)?;
|
||||
let mood: String = row.try_get_by_index::<String>(3)?;
|
||||
let created_at: chrono::DateTime<chrono::Utc> =
|
||||
row.try_get_by_index::<chrono::DateTime<chrono::Utc>>(4)?;
|
||||
let comment_count: i64 = row.try_get_by_index::<i64>(5)?;
|
||||
|
||||
let author_hex = author_id.to_string().replace('-', "");
|
||||
let suffix = &author_hex[..4];
|
||||
let author_name = format!("日记达人·{}", suffix);
|
||||
|
||||
experts.push(ExpertDiaryItem {
|
||||
journal_id,
|
||||
title,
|
||||
author_id,
|
||||
author_name,
|
||||
author_emoji: mood_to_emoji(&mood).to_string(),
|
||||
content_preview: String::new(), // Phase 1: 无 content_preview 列,暂留空
|
||||
like_count: comment_count,
|
||||
created_at,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(experts)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mood_to_emoji_maps_correctly() {
|
||||
assert_eq!(mood_to_emoji("happy"), "😊");
|
||||
assert_eq!(mood_to_emoji("calm"), "😌");
|
||||
assert_eq!(mood_to_emoji("sad"), "😢");
|
||||
assert_eq!(mood_to_emoji("angry"), "😤");
|
||||
assert_eq!(mood_to_emoji("thinking"), "🤔");
|
||||
assert_eq!(mood_to_emoji("unknown"), "📝");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_resp_structure() {
|
||||
let resp = DiscoverResp {
|
||||
daily_inspiration: Some(InspirationItem {
|
||||
journal_id: Uuid::nil(),
|
||||
title: "测试日记".into(),
|
||||
author_name: "小暖·a3f2".into(),
|
||||
mood: "happy".into(),
|
||||
date: chrono::NaiveDate::from_ymd_opt(2026, 6, 7).unwrap(),
|
||||
}),
|
||||
hot_topics: vec![
|
||||
TagCount {
|
||||
tag: "期末备考".into(),
|
||||
count: 42,
|
||||
},
|
||||
],
|
||||
featured_templates: vec![],
|
||||
expert_diaries: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
assert!(json.contains("\"daily_inspiration\""));
|
||||
assert!(json.contains("\"hot_topics\""));
|
||||
assert!(json.contains("\"期末备考\""));
|
||||
assert!(json.contains("\"count\":42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_resp_null_inspiration() {
|
||||
let resp = DiscoverResp {
|
||||
daily_inspiration: None,
|
||||
hot_topics: vec![],
|
||||
featured_templates: vec![],
|
||||
expert_diaries: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&resp).unwrap();
|
||||
assert!(json.contains("\"daily_inspiration\":null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expert_diary_item_serializes() {
|
||||
let item = ExpertDiaryItem {
|
||||
journal_id: Uuid::nil(),
|
||||
title: "春日漫步手账".into(),
|
||||
author_id: Uuid::nil(),
|
||||
author_name: "日记达人·abcd".into(),
|
||||
author_emoji: "🌸".into(),
|
||||
content_preview: "记录春天的每一朵花开...".into(),
|
||||
like_count: 342,
|
||||
created_at: chrono::Utc::now(),
|
||||
};
|
||||
let json = serde_json::to_string(&item).unwrap();
|
||||
assert!(json.contains("\"like_count\":342"));
|
||||
assert!(json.contains("\"author_emoji\":\"🌸\""));
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,4 @@ pub mod achievement_service;
|
||||
pub mod mood_stats_service;
|
||||
pub mod content_safety_service;
|
||||
pub mod parent_service;
|
||||
pub mod discover_service;
|
||||
|
||||
@@ -208,6 +208,7 @@ struct MessageApiDoc;
|
||||
erp_diary::handler::parent_handler::list_pending_bindings,
|
||||
erp_diary::handler::parent_handler::confirm_binding,
|
||||
erp_diary::handler::parent_handler::reject_binding,
|
||||
erp_diary::handler::discover_handler::get_discover,
|
||||
),
|
||||
components(schemas(
|
||||
erp_diary::dto::CreateJournalReq,
|
||||
@@ -241,6 +242,10 @@ struct MessageApiDoc;
|
||||
erp_diary::handler::parent_handler::DeleteChildDataReq,
|
||||
erp_diary::handler::parent_handler::BindingResp,
|
||||
erp_diary::handler::parent_handler::DeleteResultResp,
|
||||
erp_diary::dto::DiscoverResp,
|
||||
erp_diary::dto::InspirationItem,
|
||||
erp_diary::dto::TagCount,
|
||||
erp_diary::dto::ExpertDiaryItem,
|
||||
))
|
||||
)]
|
||||
struct DiaryApiDoc;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 数据层
|
||||
updated: 2026-06-01
|
||||
updated: 2026-06-07
|
||||
status: active
|
||||
tags: [isar, offline-first, sync, repository-pattern]
|
||||
---
|
||||
@@ -112,7 +112,7 @@ syncEngine.restorePendingQueue(); // fire-and-forget 恢复队列
|
||||
| authorId 硬编码 'local' | HIGH | 待修 | EditorPage 未接入 AuthBloc 获取真实用户 |
|
||||
| SyncEngine 仅 WiFi | MEDIUM | Phase 2 | 蜂窝数据同步未实现 |
|
||||
| 版本冲突静默覆盖 | MEDIUM | Phase 2 | "本地优先"策略,需 UI 手动解决 |
|
||||
| 编辑器未加载已有数据 | MEDIUM | 待做 | journalId 非空时未从 Isar 读取 |
|
||||
| ~~编辑器未加载已有数据~~ | ~~MEDIUM~~ | ✅ 已修复 | _loadExistingJournal 读取日记 + 元素 + 笔画 |
|
||||
|
||||
### 历史教训
|
||||
|
||||
@@ -123,5 +123,6 @@ syncEngine.restorePendingQueue(); // fire-and-forget 恢复队列
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-06-07 | 编辑器加载已有数据已修复、打开已有日记默认查看模式 |
|
||||
| 2026-06-01 | Isar 集成完成:3 Collection + Repository + SyncEngine 持久化 (2481c8f) |
|
||||
| 2026-06-01 | 初始创建 — 数据层架构、Isar 踩坑记录 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: erp-diary 后端模块
|
||||
updated: 2026-06-01
|
||||
updated: 2026-06-07
|
||||
status: active
|
||||
tags: [rust, axum, seaorm, diary, api]
|
||||
---
|
||||
@@ -29,23 +29,23 @@ tags: [rust, axum, seaorm, diary, api]
|
||||
|
||||
```
|
||||
crates/erp-diary/src/
|
||||
├── lib.rs (206 行) — DiaryModule 实现 + Feature Flag 注册
|
||||
├── dto.rs (569 行) — 请求/响应 DTO + Validate 注解
|
||||
├── lib.rs (280 行) — DiaryModule 实现 + Feature Flag 注册
|
||||
├── dto.rs (640 行) — 请求/响应 DTO + Validate 注解
|
||||
├── error.rs (193 行) — DiaryError 15 种变体 → HTTP 状态码
|
||||
├── event.rs (61 行) — 事件定义 (diary.created 等)
|
||||
├── state.rs (13 行) — DiaryState (DiaryModule 专用状态)
|
||||
├── entity/ (15 文件) — SeaORM Entity
|
||||
├── service/ (10 文件) — 业务逻辑
|
||||
└── handler/ (8 文件) — HTTP Handler + utoipa 注解
|
||||
├── service/ (12 文件) — 业务逻辑
|
||||
└── handler/ (10 文件) — HTTP Handler + utoipa 注解
|
||||
```
|
||||
|
||||
### Entity 清单 (15 个)
|
||||
|
||||
achievement, class_member, comment, handwriting_stroke, journal_element, journal_entry, parent_child_binding, school_class, sticker, sticker_pack, teacher_profile, template, topic_assignment, user_achievement, user_settings
|
||||
|
||||
### Service 清单 (10 个)
|
||||
### Service 清单 (12 个)
|
||||
|
||||
journal, class, comment, content_safety, achievement, mood_stats, notification, sticker, sync, topic
|
||||
journal, class, comment, content_safety, achievement, mood_stats, notification, sticker, sync, topic, **parent**, **discover**
|
||||
|
||||
### API 端点
|
||||
|
||||
@@ -60,6 +60,8 @@ journal, class, comment, content_safety, achievement, mood_stats, notification,
|
||||
| `/api/v1/diary/stickers` | sticker_handler | 贴纸管理 |
|
||||
| `/api/v1/diary/stats` | stats_handler | 心情/写作统计 |
|
||||
| `/api/v1/diary/sync` | sync_handler | 增量同步 API |
|
||||
| `/api/v1/diary/discover` | discover_handler | 发现页聚合(每日推荐/热门话题/精选模板/达人日记) |
|
||||
| `/api/v1/diary/parent` | parent_handler | 家长绑定 + 数据管理 |
|
||||
|
||||
### 集成契约
|
||||
|
||||
@@ -95,6 +97,7 @@ journal, class, comment, content_safety, achievement, mood_stats, notification,
|
||||
| 文件上传未实现 | MEDIUM | 待做 | 照片/贴纸文件上传参考健康模块 |
|
||||
| 代码分布 | INFO | 参考 | service 层 51.7%、handler 20.1%、entity 15.6%、dto 11.1% |
|
||||
| 班级码硬编码 | LOW | 待修 | 前端 teacher 模块班级码 'a1b2c3' 未接入后端 |
|
||||
| 发现页硬编码 | — | ✅ 已修复 | DiscoverBloc + GET /diary/discover 全链路打通 |
|
||||
|
||||
### 代码量参考
|
||||
|
||||
@@ -111,5 +114,6 @@ journal, class, comment, content_safety, achievement, mood_stats, notification,
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-06-07 | 新增 discover_service + discover_handler (GET /diary/discover)、parent_handler 补充文档 |
|
||||
| 2026-06-01 | 补充代码量分布、班级码硬编码问题 |
|
||||
| 2026-06-01 | 初始创建 — Entity/Service/Handler 清单、API 端点、集成契约 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Flutter 前端
|
||||
updated: 2026-06-01
|
||||
updated: 2026-06-07
|
||||
status: active
|
||||
tags: [flutter, bloc, design-system, responsive]
|
||||
---
|
||||
@@ -43,9 +43,10 @@ Stroke/StrokePoint 是热路径高频创建对象,freezed 生成的代码有
|
||||
| stickers | StickerBloc | 贴纸库浏览 + 选择 |
|
||||
| templates | TemplateBloc | 模板画廊 |
|
||||
| profile | SettingsBloc | 主题切换 + 个人设置 |
|
||||
| search | — | 日记搜索 (Isar FTS 待实现) |
|
||||
| search | SearchBloc | 日记搜索(按心情/标签/关键词) |
|
||||
| teacher | — | 老师主题发布 + 批改 |
|
||||
| parent | — | 家长监护 + 数据管理 |
|
||||
| parent | ParentBloc | 家长监护 + 数据管理 |
|
||||
| discover | DiscoverBloc | 发现页(每日推荐/热门话题/精选模板/达人日记) |
|
||||
| settings | — | 设置页面 UI |
|
||||
|
||||
### 注入链 (app.dart)
|
||||
@@ -100,10 +101,12 @@ AppTheme.light() / AppTheme.dark()
|
||||
|
||||
| 问题 | 级别 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 编辑器不加载已有数据 | HIGH | 待做 | journalId 非空时需从 Isar 读取 |
|
||||
| SSE 端口不一致 | HIGH | 待修 | SSE 用 8080,API 用 3000,推送必然失败 |
|
||||
| ~~编辑器不加载已有数据~~ | ~~HIGH~~ | ✅ 已修复 | _loadExistingJournal 从 Isar 读取日记 + 元素 + 笔画 |
|
||||
| ~~SSE 端口不一致~~ | ~~HIGH~~ | ✅ 已修复 | AppConfig 统一管理 apiBaseUrl/sseBaseUrl,均指向 3000 |
|
||||
| ~~编辑器打开即弹出画笔~~ | ~~HIGH~~ | ✅ 已修复 | 打开已有日记默认查看模式,点"编辑"才进入编辑模式 |
|
||||
| API base URL 硬编码 | HIGH | 待修 | localhost:3000 硬编码,生产环境需配置化 |
|
||||
| 前端测试为零 | HIGH | 待做 | 70 个 Dart 文件无任何测试覆盖 |
|
||||
| 多处硬编码数据未对接 API | HIGH | 部分修复 | 见下方「硬编码数据清单」 |
|
||||
| 前端测试覆盖不足 | MEDIUM | 持续 | 15 个测试文件 203 个用例,auth_bloc 有 1 个失败待修 |
|
||||
| 状态管理不统一 | MEDIUM | 待规划 | 5 模块用 BLoC,5 模块用 ChangeNotifier |
|
||||
| freezed 声明未使用 | MEDIUM | 待清理 | pubspec 声明了但全部手写不可变类 |
|
||||
| SyncEngine 缺少网络监听 | MEDIUM | 待做 | 只有 trySync() 方法,无自动触发 |
|
||||
@@ -112,6 +115,44 @@ AppTheme.light() / AppTheme.dark()
|
||||
| core/utils/ 空目录 | LOW | 待填充 | 缺少日期格式化、颜色解析等通用工具 |
|
||||
| 深色模式细节 | LOW | 持续 | 部分组件深色适配需检查 |
|
||||
|
||||
### 硬编码数据清单
|
||||
|
||||
> 2026-06-07 全面排查结果。标记 ✅ 已修复 / ❌ 待修复。
|
||||
|
||||
**HIGH — 已有 API,应直接替换:**
|
||||
|
||||
| 页面 | 文件 | 硬编码内容 | 后端 API | 状态 |
|
||||
|------|------|-----------|----------|------|
|
||||
| 首页 | home_page.dart | 用户名 `'小暖'` | AuthBloc.user | ❌ |
|
||||
| 个人 | profile_page.dart | 头像 emoji `'😊'` | AuthBloc.user | ❌ |
|
||||
| 个人 | profile_page.dart | 6 个固定成就徽章 | AchievementBloc | ❌ |
|
||||
| 搜索 | search_page.dart | 4 个假模板名称 | TemplateBloc | ❌ |
|
||||
| 贴纸 | sticker_library_page.dart | 精选贴纸包"治愈小动物" | StickerBloc | ❌ |
|
||||
| 教师 | teacher_page.dart | 班级码 `'a1b2c3'` | ClassBloc | ❌ |
|
||||
| 班级 | class_page.dart | 头像首字固定 `'同'` | JournalEntry.authorId | ❌ |
|
||||
| 发现 | discover_page.dart | 全部 4 板块假数据 | GET /diary/discover | ✅ |
|
||||
|
||||
**MEDIUM — 需要 API 或可延后:**
|
||||
|
||||
| 页面 | 文件 | 硬编码内容 | 备注 |
|
||||
|------|------|-----------|------|
|
||||
| 搜索 | search_page.dart | 热门搜索 8 个关键词 | 需新增后端 API |
|
||||
| 编辑器 | sticker_picker_sheet.dart | 60 个内置 emoji 贴纸 | Phase 1 占位,贴纸包 API 已有 |
|
||||
| 编辑器 | tag_panel.dart | 10 个推荐标签 | 可从用户历史标签推导 |
|
||||
| 贴纸 | sticker_library_page.dart | 8 个固定分类名 | StickerBloc 已有分类数据 |
|
||||
| 模板 | template_gallery_page.dart | 每张卡片固定"学生专属"/"简约"标签 | 模板 API 已有 |
|
||||
| 个人 | profile_page.dart | 贴纸数 `'--'` | 需新增统计 API |
|
||||
|
||||
**LOW — 可接受的 UI 设计常量:**
|
||||
|
||||
| 页面 | 内容 | 说明 |
|
||||
|------|------|------|
|
||||
| 编辑器 | 画笔/文本颜色面板 | 设计工具调色板 |
|
||||
| 编辑器 | 字号选项 [小/中/大] | 编辑器选项 |
|
||||
| 首页/日历 | 心情/天气 emoji 映射 | enum → UI 展示映射 |
|
||||
| 引导页 | 3 步引导内容 | 静态引导文案 |
|
||||
| 班级 | 快捷评语模板 7 条 | 内置教学工具 |
|
||||
|
||||
### 历史教训
|
||||
|
||||
- F11 深色模式修复需要 bloat bloc 测试套件同步更新 (05317d5)
|
||||
@@ -123,6 +164,10 @@ AppTheme.light() / AppTheme.dark()
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-06-07 | 发现页全链路打通:DiscoverBloc + GET /diary/discover,替换全部硬编码 |
|
||||
| 2026-06-07 | 全面排查硬编码数据,新增「硬编码数据清单」章节 |
|
||||
| 2026-06-07 | 编辑器新增查看模式(打开已有日记默认只读)、元素图层调整(置顶/置底)|
|
||||
| 2026-06-07 | 日历页面修复:初始加载自动填充 selectedDayJournals |
|
||||
| 2026-06-01 | 补充状态管理不统一、SSE 端口问题、测试缺失等新发现 |
|
||||
| 2026-06-01 | IsarJournalRepository 注入为主 JournalRepository (2481c8f) |
|
||||
| 2026-06-01 | 设置页 UI + Mood/成就/贴纸 BLoC (8331db6) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 暖记知识库首页
|
||||
updated: 2026-06-02
|
||||
updated: 2026-06-07
|
||||
status: active
|
||||
---
|
||||
|
||||
@@ -10,21 +10,21 @@ status: active
|
||||
|
||||
## 关键数字
|
||||
|
||||
> 最后更新: 2026-06-02 | 基线: main (8111471)
|
||||
> 最后更新: 2026-06-07 | 基线: main (4cb91f3)
|
||||
|
||||
| 指标 | 值 |
|
||||
|------|-----|
|
||||
| Rust crate | 8 个(6 基座 + 1 入口 + erp-diary) |
|
||||
| Rust 总代码 | ~51,500 行 |
|
||||
| erp-diary 新增 | 5,108 行(41 个文件) |
|
||||
| Dart 文件 | 74 个(~19,500 行) |
|
||||
| Rust 总代码 | ~52,000 行 |
|
||||
| erp-diary 新增 | ~5,600 行(45 个文件) |
|
||||
| Dart 文件 | 112 个(~27,000 行) |
|
||||
| 管理端前端 (React) | ~317 个 TypeScript 文件 |
|
||||
| SeaORM Entity | 15 个(erp-diary) + 50+(基座) |
|
||||
| 数据库迁移 | 58 个(42 基座 + 15 diary + 1 role seed) |
|
||||
| 后端测试 | 77 个通过 ✅ |
|
||||
| 前端 BLoC 测试 | 84 个通过 ✅ |
|
||||
| 后端测试 | 88 个通过 ✅ |
|
||||
| 前端测试 | 15 个文件、203 个用例通过 ✅(1 个失败待修) |
|
||||
| flutter analyze | 0 error ✅ |
|
||||
| Git 提交 | 22 次 |
|
||||
| Git 提交 | 107 次 |
|
||||
|
||||
## 三端架构
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
---
|
||||
title: 项目健康度评估
|
||||
updated: 2026-06-01
|
||||
updated: 2026-06-07
|
||||
status: active
|
||||
tags: [health, tech-debt, risk, improvement]
|
||||
---
|
||||
|
||||
# 项目健康度评估
|
||||
|
||||
> 从 [[index]] 导航。本文档基于 2026-06-01 全量代码分析生成。
|
||||
> 从 [[index]] 导航。本文档基于 2026-06-01 全量代码分析生成,2026-06-07 更新。
|
||||
|
||||
## 总体评分
|
||||
|
||||
@@ -15,7 +15,7 @@ tags: [health, tech-debt, risk, improvement]
|
||||
|------|------|------|
|
||||
| 架构设计 | ⭐⭐⭐⭐⭐ | 模块化 ErpModule trait、分层清晰、基座复用 |
|
||||
| 代码质量 | ⭐⭐⭐⭐ | Rust 错误处理规范、Flutter 注释质量高、分层一致 |
|
||||
| 测试覆盖 | ⭐⭐ | 后端 ~50 测试尚可、前端 **0 测试** 是最大短板 |
|
||||
| 测试覆盖 | ⭐⭐⭐ | 后端 ~77 测试通过、前端 15 个测试文件 203 个用例(仍有 1 个失败待修)|
|
||||
| 安全合规 | ⭐⭐⭐⭐⭐ | PIPL 合规框架完整、PII 加密、RLS 多租户隔离 |
|
||||
| 文档维护 | ⭐⭐⭐⭐⭐ | wiki 6 页 + 技术债看板 + CLAUDE.md,同步度高 |
|
||||
| DevOps | ⭐⭐ | Docker 配置完善但未验证、CI/CD 缺失、无自动化 |
|
||||
@@ -30,7 +30,7 @@ tags: [health, tech-debt, risk, improvement]
|
||||
| TD-1 | authorId 硬编码 'local' | P0 | 0.5 天 |
|
||||
| TD-3 | Docker 部署未验证 | P0 | 0.5 天 |
|
||||
| TD-7 | Settings 持久化未实现 | P1 | 0.5 天 |
|
||||
| TD-8 | 编辑器不加载已有日记 | P1 | 1 天 |
|
||||
| ~~TD-8~~ | ~~编辑器不加载已有日记~~ | ~~P1~~ ✅ | ~~1 天~~ |
|
||||
| TD-4 | toImage() 同步阻塞主线程 | P1 | 1 天 |
|
||||
| TD-2 | CI/CD 未建立 | P2 | 1 天 |
|
||||
| TD-5 | 前端测试为零 | P2 | 3 天 |
|
||||
@@ -42,8 +42,8 @@ tags: [health, tech-debt, risk, improvement]
|
||||
|
||||
| 编号 | 债务 | 优先级 | 预估 | 位置 |
|
||||
|------|------|--------|------|------|
|
||||
| NEW-1 | 前端测试缺失(TD-5 补充:0 回归保护) | **Critical** | 3 天 | `app/test/` |
|
||||
| NEW-2 | SSE 端口不一致 (8080 vs 3000) | **Critical** | 0.5 天 | `sse_notification_service.dart:42` |
|
||||
| ~~NEW-1~~ | ~~前端测试缺失(0 回归保护)~~ | ~~Critical~~ ✅ | ~~3 天~~ | 15 个测试文件 203 用例,auth_bloc 1 个失败待修 |
|
||||
| ~~NEW-2~~ | ~~SSE 端口不一致 (8080 vs 3000)~~ | ~~Critical~~ ✅ | ~~0.5 天~~ | AppConfig 统一管理,均指向 3000 |
|
||||
| NEW-3 | Dockerfile 不存在(生产部署引用) | **High** | 1 天 | `docker-compose.production.yml` |
|
||||
| NEW-4 | API base URL 硬编码 localhost | **High** | 0.5 天 | `api_client.dart:29` |
|
||||
| NEW-5 | 班级码后端验证未实现 | **High** | 1 天 | `auth_bloc.dart:141` TODO |
|
||||
@@ -57,12 +57,12 @@ tags: [health, tech-debt, risk, improvement]
|
||||
| 模块 | 完成度 | 关键缺失 |
|
||||
|------|--------|---------|
|
||||
| 手写引擎 | **95%** | toImage 异步化 |
|
||||
| 编辑器 | **95%** | 文字输入/图片上传为占位 |
|
||||
| 编辑器 | **98%** | 查看模式 + 图层调整已实现,文字输入/图片上传为占位 |
|
||||
| 认证 | **85%** | 班级码后端验证 TODO |
|
||||
| 首页 | **90%** | — |
|
||||
| 设计系统 | **90%** | `core/utils/` 空目录 |
|
||||
| 设置 | **85%** | 持久化未实现 |
|
||||
| 日历 | **85%** | 周视图/时间线未实现 |
|
||||
| 日历 | **90%** | 初始加载已修复,周视图/时间线未实现 |
|
||||
| 数据层 | **85%** | SyncEngine 缺少网络监听 |
|
||||
| 班级 | **80%** | — |
|
||||
| 心情统计 | **80%** | — |
|
||||
@@ -91,10 +91,10 @@ erp-core ← erp-auth ← erp-server
|
||||
|
||||
| 风险 | 概率 | 影响 | 缓解 |
|
||||
|------|------|------|------|
|
||||
| 前端无测试导致回归 | 高 | 高 | TD-5: 建立核心 BLoC/Repository 单元测试 |
|
||||
| 前端测试覆盖不足 | 中 | 中 | 15 个测试文件已建立,auth_bloc 1 个失败待修 |
|
||||
| Feature Flag 未实现限制扩展 | 中 | 中 | NEW-8: 补充 Cargo features 配置 |
|
||||
| Docker 生产部署无法构建 | 高 | 高 | NEW-3: 创建 Dockerfile + 验证 |
|
||||
| SSE 端口不匹配致推送失败 | 确定 | 中 | NEW-2: 统一为 3000 端口 |
|
||||
| ~~SSE 端口不匹配致推送失败~~ | ~~确定~~ ✅ | ~~中~~ | AppConfig 已统一为 3000 |
|
||||
| 手写 toImage 卡 UI | 中 | 中 | TD-4: compute() isolate 异步光栅化 |
|
||||
| 大文件超 800 行限制 | 中 | 低 | erp-plugin manifest.rs(1809) + data_service.rs(1907) 需拆分 |
|
||||
|
||||
@@ -102,7 +102,7 @@ erp-core ← erp-auth ← erp-server
|
||||
|
||||
### 第一阶段(1-2 天)— 紧急修复
|
||||
|
||||
1. **NEW-2**: SSE 端口统一为 3000
|
||||
1. ~~**NEW-2**: SSE 端口统一为 3000~~ ✅
|
||||
2. **TD-1**: authorId 接入 AuthBloc
|
||||
3. **NEW-4**: API base URL 环境配置化
|
||||
|
||||
@@ -110,17 +110,18 @@ erp-core ← erp-auth ← erp-server
|
||||
|
||||
4. **TD-2**: CI/CD 基础流水线
|
||||
5. **TD-3 + NEW-3**: Docker 部署验证 + Dockerfile 创建
|
||||
6. **TD-5**: 核心模块单元测试(AuthBloc, EditorBloc, JournalRepository)
|
||||
6. ~~**TD-5**: 核心模块单元测试~~ ✅ 已有 15 个测试文件,持续补充
|
||||
|
||||
### 第三阶段(持续)— 质量提升
|
||||
|
||||
7. **NEW-7**: 通用 catch(e) → 类型化异常处理
|
||||
8. **NEW-8**: Feature Flag 配置落地
|
||||
9. **TD-4**: toImage() 异步光栅化
|
||||
10. **TD-8**: 编辑器加载已有日记
|
||||
10. ~~**TD-8**: 编辑器加载已有日记~~ ✅
|
||||
|
||||
## 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
|------|------|
|
||||
| 2026-06-07 | 更新:TD-8/NEW-1/NEW-2 已修复,测试覆盖 ⭐⭐→⭐⭐⭐,编辑器 95%→98%,日历 85%→90% |
|
||||
| 2026-06-01 | 初始创建 — 基于 4 代理并行分析结果 |
|
||||
|
||||
Reference in New Issue
Block a user