From 3d6ae7695f03b5f9c37e03daa8144c596c055bb6 Mon Sep 17 00:00:00 2001 From: qzl Date: Fri, 27 Feb 2026 18:36:21 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=E6=97=A5?= =?UTF-8?q?=E5=8E=86=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E4=B8=8E=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E8=BE=93=E5=85=A5=E6=A1=86=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?API=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=8A=BD=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 8 + apps/lib/core/api/i_api_client.dart | 60 + apps/lib/core/api/mock_api_client.dart | 87 ++ apps/lib/core/config/env.dart | 8 + apps/lib/core/di/injection.dart | 3 + apps/lib/core/router/app_router.dart | 16 +- .../calendar/ui/calendar_state_manager.dart | 51 + .../calendar/ui/calendar_time_utils.dart | 58 + .../ui/screens/calendar_dayweek_screen.dart | 222 +-- .../ui/screens/calendar_month_screen.dart | 109 +- .../calendar/ui/widgets/bottom_dock.dart | 38 +- .../features/home/ui/screens/home_screen.dart | 64 +- .../ui/screens/todo_quadrants_screen.dart | 15 +- apps/lib/main.dart | 13 +- .../calendar/ui/calendar_time_utils_test.dart | 51 + .../2026-02-26-auth-ux-enhancement-design.md | 204 --- .../2026-02-26-auth-ux-enhancement-plan.md | 402 ------ .../2026-02-27-schedule-items-api-design.md | 191 +++ ...02-27-schedule-items-api-implementation.md | 1244 +++++++++++++++++ docs/runtime/frontend-runbook.md | 103 ++ 20 files changed, 2146 insertions(+), 801 deletions(-) create mode 100644 apps/lib/core/api/i_api_client.dart create mode 100644 apps/lib/core/api/mock_api_client.dart create mode 100644 apps/lib/features/calendar/ui/calendar_state_manager.dart create mode 100644 apps/lib/features/calendar/ui/calendar_time_utils.dart create mode 100644 apps/test/features/calendar/ui/calendar_time_utils_test.dart delete mode 100644 docs/plans/2026-02-26-auth-ux-enhancement-design.md delete mode 100644 docs/plans/2026-02-26-auth-ux-enhancement-plan.md create mode 100644 docs/plans/2026-02-27-schedule-items-api-design.md create mode 100644 docs/plans/2026-02-27-schedule-items-api-implementation.md create mode 100644 docs/runtime/frontend-runbook.md diff --git a/AGENTS.md b/AGENTS.md index ae2dd3c..ed67fc4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,3 +37,11 @@ Follow this hierarchy when developing: - Default branch: `dev` - Feature development: use worktree `git worktree add -b feature/xxx ../feature-xxx dev` - Never develop directly on `main` + +## Supabase Services + +Project uses locally hosted Supabase for development. + +- Docker config: `infra/docker/docker-compose.yml` +- Start services: `cd infra/docker && docker compose up -d` +- Stop services: `cd infra/docker && docker compose down` diff --git a/apps/lib/core/api/i_api_client.dart b/apps/lib/core/api/i_api_client.dart new file mode 100644 index 0000000..bd1e22a --- /dev/null +++ b/apps/lib/core/api/i_api_client.dart @@ -0,0 +1,60 @@ +import 'package:dio/dio.dart'; +import 'api_client.dart'; +import 'mock_api_client.dart'; + +abstract class IApiClient { + Future> get(String path, {Options? options}); + Future> post(String path, {dynamic data, Options? options}); + Future> patch(String path, {dynamic data, Options? options}); + Future> delete(String path, {dynamic data, Options? options}); +} + +class ApiClientWrapper implements IApiClient { + final ApiClient _client; + + ApiClientWrapper(this._client); + + @override + Future> get(String path, {Options? options}) => + _client.get(path, options: options); + + @override + Future> post(String path, {dynamic data, Options? options}) => + _client.post(path, data: data, options: options); + + @override + Future> patch(String path, {dynamic data, Options? options}) => + _client.patch(path, data: data, options: options); + + @override + Future> delete( + String path, { + dynamic data, + Options? options, + }) => _client.delete(path, data: data, options: options); +} + +class MockApiClientWrapper implements IApiClient { + final MockApiClient _client; + + MockApiClientWrapper(this._client); + + @override + Future> get(String path, {Options? options}) => + _client.get(path, options: options); + + @override + Future> post(String path, {dynamic data, Options? options}) => + _client.post(path, data: data, options: options); + + @override + Future> patch(String path, {dynamic data, Options? options}) => + _client.patch(path, data: data, options: options); + + @override + Future> delete( + String path, { + dynamic data, + Options? options, + }) => _client.delete(path, data: data, options: options); +} diff --git a/apps/lib/core/api/mock_api_client.dart b/apps/lib/core/api/mock_api_client.dart new file mode 100644 index 0000000..aef76c4 --- /dev/null +++ b/apps/lib/core/api/mock_api_client.dart @@ -0,0 +1,87 @@ +import 'package:dio/dio.dart'; + +class MockApiClient { + final Map _handlers = {}; + + void registerHandler(String path, String method, _MockHandler handler) { + final key = '$path:$method'; + _handlers[key] = handler; + } + + void clearMocks() { + _handlers.clear(); + } + + Future> get(String path, {Options? options}) async { + return _handleRequest('GET', path, options: options); + } + + Future> post( + String path, { + dynamic data, + Options? options, + }) async { + return _handleRequest('POST', path, data: data, options: options); + } + + Future> patch( + String path, { + dynamic data, + Options? options, + }) async { + return _handleRequest('PATCH', path, data: data, options: options); + } + + Future> delete( + String path, { + dynamic data, + Options? options, + }) async { + return _handleRequest('DELETE', path, data: data, options: options); + } + + Future> _handleRequest( + String method, + String path, { + dynamic data, + Options? options, + }) async { + await Future.delayed(const Duration(milliseconds: 200)); + + final key = '$path:$method'; + final handler = _handlers[key]; + + if (handler != null) { + final response = handler(data); + if (response is Response) { + return response as Response; + } + return Response( + data: response as T?, + statusCode: 200, + requestOptions: RequestOptions(path: path), + ); + } + + return Response( + data: null, + statusCode: 404, + requestOptions: RequestOptions(path: path), + ); + } +} + +typedef _MockHandler = dynamic Function(dynamic data); + +class MockApiClientHolder { + static MockApiClient? _instance; + + static MockApiClient get instance { + _instance ??= MockApiClient(); + return _instance!; + } + + static void reset() { + _instance = null; + } +} diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart index c2a7e32..964d58a 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -9,4 +9,12 @@ class Env { } return 'http://localhost:5775'; } + + static bool get isMockApi { + final fromDefine = const String.fromEnvironment('MOCK_API'); + if (fromDefine.isNotEmpty) { + return fromDefine == 'true'; + } + return false; + } } diff --git a/apps/lib/core/di/injection.dart b/apps/lib/core/di/injection.dart index 4005bb2..87f0db9 100644 --- a/apps/lib/core/di/injection.dart +++ b/apps/lib/core/di/injection.dart @@ -8,6 +8,7 @@ import '../../features/auth/data/auth_api.dart'; import '../../features/auth/data/auth_repository.dart'; import '../../features/auth/data/auth_repository_impl.dart'; import '../../features/auth/presentation/bloc/auth_bloc.dart'; +import '../../features/calendar/ui/calendar_state_manager.dart'; final sl = GetIt.instance; @@ -46,4 +47,6 @@ Future configureDependencies() async { }); sl.registerSingleton(AuthBloc(authRepository)); + + sl.registerSingleton(CalendarStateManager()); } diff --git a/apps/lib/core/router/app_router.dart b/apps/lib/core/router/app_router.dart index 4f0a0b4..ae8b0bf 100644 --- a/apps/lib/core/router/app_router.dart +++ b/apps/lib/core/router/app_router.dart @@ -14,6 +14,7 @@ import '../../features/contacts/ui/screens/add_contact_screen.dart'; import '../../features/calendar/ui/screens/calendar_dayweek_screen.dart'; import '../../features/calendar/ui/screens/calendar_month_screen.dart'; import '../../features/calendar/ui/screens/calendar_event_detail_screen.dart'; +import '../../features/calendar/ui/calendar_time_utils.dart'; import '../../features/todo/ui/screens/todo_quadrants_screen.dart'; import '../../features/todo/ui/screens/todo_detail_screen.dart'; import '../../features/settings/ui/screens/settings_screen.dart'; @@ -44,6 +45,7 @@ GoRouter createAppRouter(AuthBloc authBloc) { final authState = authBloc.state; final isAuthenticated = authState is AuthAuthenticated; final isAuthRoute = + state.matchedLocation == '/' || state.matchedLocation.startsWith('/login') || state.matchedLocation.startsWith('/register'); final isProtected = _protectedRoutes.any( @@ -91,11 +93,21 @@ GoRouter createAppRouter(AuthBloc authBloc) { ), GoRoute( path: '/calendar/dayweek', - builder: (context, state) => const CalendarDayWeekScreen(), + builder: (context, state) { + final fromHome = state.uri.queryParameters['from'] == 'home'; + final initialDate = parseYmd(state.uri.queryParameters['date']); + return CalendarDayWeekScreen( + initialDate: initialDate, + resetToToday: fromHome, + ); + }, ), GoRoute( path: '/calendar/month', - builder: (context, state) => const CalendarMonthScreen(), + builder: (context, state) { + final fromHome = state.uri.queryParameters['from'] == 'home'; + return CalendarMonthScreen(resetToToday: fromHome); + }, ), GoRoute( path: '/calendar/events/:id', diff --git a/apps/lib/features/calendar/ui/calendar_state_manager.dart b/apps/lib/features/calendar/ui/calendar_state_manager.dart new file mode 100644 index 0000000..c343c8e --- /dev/null +++ b/apps/lib/features/calendar/ui/calendar_state_manager.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +enum CalendarViewType { day, month } + +class CalendarState { + final CalendarViewType viewType; + final DateTime selectedDate; + + CalendarState({required this.viewType, required this.selectedDate}); + + CalendarState copyWith({CalendarViewType? viewType, DateTime? selectedDate}) { + return CalendarState( + viewType: viewType ?? this.viewType, + selectedDate: selectedDate ?? this.selectedDate, + ); + } +} + +class CalendarStateManager extends ChangeNotifier { + CalendarState _state; + + CalendarStateManager() + : _state = CalendarState( + viewType: CalendarViewType.month, + selectedDate: DateTime.now(), + ); + + CalendarState get state => _state; + + CalendarViewType get viewType => _state.viewType; + DateTime get selectedDate => _state.selectedDate; + + void setViewType(CalendarViewType type) { + _state = _state.copyWith(viewType: type); + notifyListeners(); + } + + void setSelectedDate(DateTime date) { + _state = _state.copyWith(selectedDate: date); + notifyListeners(); + } + + void resetToToday() { + final now = DateTime.now(); + _state = CalendarState( + viewType: CalendarViewType.month, + selectedDate: DateTime(now.year, now.month, now.day), + ); + notifyListeners(); + } +} diff --git a/apps/lib/features/calendar/ui/calendar_time_utils.dart b/apps/lib/features/calendar/ui/calendar_time_utils.dart new file mode 100644 index 0000000..3ffa21b --- /dev/null +++ b/apps/lib/features/calendar/ui/calendar_time_utils.dart @@ -0,0 +1,58 @@ +DateTime weekStartFor(DateTime date) { + return date.subtract(Duration(days: date.weekday % 7)); +} + +bool isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; +} + +bool shouldShowCurrentMarker(DateTime selectedDate, DateTime now) { + return isSameDay(selectedDate, now); +} + +String formatHm(DateTime dateTime) { + final hour = dateTime.hour.toString().padLeft(2, '0'); + final minute = dateTime.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; +} + +String formatHour(int hour) { + if (hour == 24) { + return '00:00'; + } + return '${hour.toString().padLeft(2, '0')}:00'; +} + +DateTime? parseYmd(String? ymd) { + if (ymd == null) { + return null; + } + final matched = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(ymd); + if (matched == null) { + return null; + } + final year = int.parse(matched.group(1)!); + final month = int.parse(matched.group(2)!); + final day = int.parse(matched.group(3)!); + final parsed = DateTime(year, month, day); + if (parsed.year != year || parsed.month != month || parsed.day != day) { + return null; + } + return parsed; +} + +String formatYmd(DateTime dateTime) { + final year = dateTime.year.toString().padLeft(4, '0'); + final month = dateTime.month.toString().padLeft(2, '0'); + final day = dateTime.day.toString().padLeft(2, '0'); + return '$year-$month-$day'; +} + +List monthDatesFor(DateTime date) { + final monthStart = DateTime(date.year, date.month, 1); + final monthEnd = DateTime(date.year, date.month + 1, 0); + return List.generate( + monthEnd.day, + (index) => DateTime(monthStart.year, monthStart.month, index + 1), + ); +} diff --git a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart index bb888dc..9f8aa91 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart @@ -1,34 +1,66 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; +import '../calendar_state_manager.dart'; +import '../calendar_time_utils.dart'; import '../widgets/bottom_dock.dart'; class CalendarDayWeekScreen extends StatefulWidget { - const CalendarDayWeekScreen({super.key}); + final DateTime? initialDate; + final bool resetToToday; + + const CalendarDayWeekScreen({ + super.key, + this.initialDate, + this.resetToToday = false, + }); @override State createState() => _CalendarDayWeekScreenState(); } class _CalendarDayWeekScreenState extends State { - DateTime _selectedDate = DateTime(2026, 2, 9); - late DateTime _weekStart; + static const double _dayItemWidth = 44; + static const double _dayItemGap = 12; + + late final CalendarStateManager _calendarManager; + late DateTime _selectedDate; + late List _monthDates; + final ScrollController _dayStripController = ScrollController(); @override void initState() { super.initState(); - _weekStart = _getWeekStart(_selectedDate); + _calendarManager = sl(); + + if (widget.resetToToday) { + _calendarManager.resetToToday(); + } + + _selectedDate = _calendarManager.selectedDate; + _updateMonthDates(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSelectedDate(); + }); } - DateTime _getWeekStart(DateTime date) { - return date.subtract(Duration(days: date.weekday % 7)); + void _updateMonthDates() { + _monthDates = monthDatesFor(_selectedDate); + } + + @override + void dispose() { + _dayStripController.dispose(); + super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), + backgroundColor: AppColors.todoBg, body: SafeArea( child: Column( children: [ @@ -37,8 +69,8 @@ class _CalendarDayWeekScreenState extends State { child: SingleChildScrollView( child: Padding( padding: const EdgeInsets.only( - left: 16, - right: 16, + left: AppSpacing.lg, + right: AppSpacing.lg, top: 2, bottom: 104, ), @@ -67,14 +99,14 @@ class _CalendarDayWeekScreenState extends State { child: Row( children: [ GestureDetector( - onTap: () => Navigator.of(context).pop(), + onTap: () => context.go('/calendar/month'), child: Container( height: 36, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( - color: const Color(0xFFF8FAFF), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: const Color(0xFFDEE7F6)), + color: AppColors.messageBtnWrap, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.messageBtnBorder), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -106,29 +138,70 @@ class _CalendarDayWeekScreenState extends State { Widget _buildWeekStrip() { return SizedBox( height: 86, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate(7, (index) { - final date = _weekStart.add(Duration(days: index)); - final isSelected = - date.day == _selectedDate.day && - date.month == _selectedDate.month && - date.year == _selectedDate.year; - final isWeekend = index == 0 || index == 6; + child: ListView.separated( + controller: _dayStripController, + scrollDirection: Axis.horizontal, + itemCount: _monthDates.length, + separatorBuilder: (context, index) => + const SizedBox(width: _dayItemGap), + itemBuilder: (context, index) { + final date = _monthDates[index]; + final isSelected = isSameDay(date, _selectedDate); + final isWeekend = date.weekday % 7 == 0 || date.weekday % 7 == 6; return GestureDetector( onTap: () { setState(() { _selectedDate = date; }); + _calendarManager.setSelectedDate(date); + _updateMonthDates(); + _scrollToSelectedDate(animate: true); }, - child: _buildDayItem(date, isSelected, isWeekend), + child: SizedBox( + width: _dayItemWidth, + child: _buildDayItem(date, isSelected, isWeekend), + ), ); - }), + }, ), ); } + void _scrollToSelectedDate({bool animate = false}) { + if (!_dayStripController.hasClients) { + return; + } + final index = _monthDates.indexWhere( + (date) => isSameDay(date, _selectedDate), + ); + if (index < 0) { + return; + } + + final targetCenter = + index * (_dayItemWidth + _dayItemGap) + (_dayItemWidth / 2); + final viewport = _dayStripController.position.viewportDimension; + var offset = targetCenter - (viewport / 2); + final max = _dayStripController.position.maxScrollExtent; + if (offset < 0) { + offset = 0; + } + if (offset > max) { + offset = max; + } + + if (animate) { + _dayStripController.animateTo( + offset, + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + ); + return; + } + _dayStripController.jumpTo(offset); + } + Widget _buildDayItem(DateTime date, bool isSelected, bool isWeekend) { final dayNames = ['日', '一', '二', '三', '四', '五', '六']; @@ -146,7 +219,7 @@ class _CalendarDayWeekScreenState extends State { Text( '${date.day}', style: TextStyle( - fontSize: isSelected ? 17 : (isWeekend ? 17 : 17), + fontSize: 17, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w600, color: isSelected ? AppColors.blue600 @@ -158,49 +231,23 @@ class _CalendarDayWeekScreenState extends State { } Widget _buildTimelineBoard() { - return Column( - children: [ - _buildTimelineRow('07:00', false), - _buildTimelineRow('08:00', false), - _buildTimelineRow( - '09:00', - true, - eventText: '购票提醒', - eventColor: AppColors.slate500, - ), - _buildTimelineRow('10:00', false), - _buildTimelineRow('11:00', false), - _buildTimelineRow('12:00', false), - _buildTimelineRow('13:00', false), - _buildTimelineRow('14:00', false), - _buildTimelineRow('15:00', false), - _buildTimelineRow('15:28', false, isCurrentTime: true), - _buildTimelineRow( - '16:00', - true, - eventText: '购票提醒', - eventColor: const Color(0xFF6B21A8), - eventBg: const Color(0xFFE9D5FF), - eventBorder: const Color(0xFFD8B4FE), - ), - _buildTimelineRow('17:00', false), - _buildTimelineRow('18:00', false), - _buildTimelineRow('19:00', false), - _buildTimelineRow('20:00', false), - _buildTimelineRow('21:00', false), - _buildTimelineRow('22:00', false), - _buildTimelineRow('00:00', false, isDisabled: true), - ], - ); + final now = DateTime.now(); + final showCurrent = shouldShowCurrentMarker(_selectedDate, now); + final rows = []; + + for (var hour = 7; hour <= 22; hour++) { + rows.add(_buildTimelineRow(formatHour(hour))); + if (showCurrent && now.hour == hour) { + rows.add(_buildTimelineRow(formatHm(now), isCurrentTime: true)); + } + } + + rows.add(_buildTimelineRow(formatHour(24), isDisabled: true)); + return Column(children: rows); } Widget _buildTimelineRow( - String time, - bool hasEvent, { - String? eventText, - Color? eventColor, - Color? eventBg, - Color? eventBorder, + String time, { bool isCurrentTime = false, bool isDisabled = false, }) { @@ -215,7 +262,7 @@ class _CalendarDayWeekScreenState extends State { width: 44, height: 18, decoration: BoxDecoration( - color: const Color(0xFFEF4444), + color: AppColors.red500, borderRadius: BorderRadius.circular(9), ), child: Center( @@ -237,7 +284,7 @@ class _CalendarDayWeekScreenState extends State { fontWeight: FontWeight.w600, color: isDisabled ? AppColors.slate300 - : const Color(0xFF9CA3AF), + : AppColors.slate400, ), ), ), @@ -247,38 +294,13 @@ class _CalendarDayWeekScreenState extends State { ? Container( height: 2, decoration: BoxDecoration( - color: const Color(0xFFEF4444), + color: AppColors.red500, borderRadius: BorderRadius.circular(99), ), ) - : hasEvent - ? Container( - height: 22, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: eventBg ?? const Color(0xFFE5E7EB), - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: eventBorder ?? const Color(0xFFD1D5DB), - ), - ), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - eventText ?? '', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: eventColor ?? const Color(0xFF6B7280), - ), - ), - ), - ) : Container( height: 1, - color: isDisabled - ? const Color(0xFFECEFF4) - : const Color(0xFFE5E7EB), + color: isDisabled ? AppColors.blue50 : AppColors.border, ), ), ], @@ -289,9 +311,15 @@ class _CalendarDayWeekScreenState extends State { Widget _buildBottomDock() { return BottomDock( activeTab: DockTab.calendar, - onTodoTap: () => context.push('/todo'), - onCalendarTap: () {}, - onHomeTap: () => Navigator.of(context).pop(), + onTodoTap: () { + _calendarManager.setViewType(CalendarViewType.day); + context.push('/todo'); + }, + onCalendarTap: () { + _calendarManager.setViewType(CalendarViewType.day); + context.go('/calendar/month'); + }, + onHomeTap: () => context.go('/home'), ); } } diff --git a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart index 9cf2f1d..7e597c5 100644 --- a/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart +++ b/apps/lib/features/calendar/ui/screens/calendar_month_screen.dart @@ -2,24 +2,44 @@ import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; +import '../calendar_state_manager.dart'; +import '../calendar_time_utils.dart'; import '../widgets/bottom_dock.dart'; class CalendarMonthScreen extends StatefulWidget { - const CalendarMonthScreen({super.key}); + final bool resetToToday; + + const CalendarMonthScreen({super.key, this.resetToToday = false}); @override State createState() => _CalendarMonthScreenState(); } class _CalendarMonthScreenState extends State { - DateTime _currentMonth = DateTime(2026, 2, 1); - DateTime? _selectedDate; + late final CalendarStateManager _calendarManager; + late DateTime _currentMonth; + late DateTime _selectedDate; + + @override + void initState() { + super.initState(); + _calendarManager = sl(); + + if (widget.resetToToday) { + _calendarManager.resetToToday(); + } + + final savedDate = _calendarManager.selectedDate; + _selectedDate = savedDate; + _currentMonth = DateTime(savedDate.year, savedDate.month, 1); + } @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), + backgroundColor: AppColors.todoBg, body: SafeArea( child: Column( children: [ @@ -84,7 +104,7 @@ class _CalendarMonthScreenState extends State { return Column( children: [ _buildWeekdayHeader(), - Container(height: 1, color: const Color(0xFFE5E7EB)), + Container(height: 1, color: AppColors.border), ..._buildWeeks(), ], ); @@ -108,7 +128,7 @@ class _CalendarMonthScreenState extends State { style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, - color: Color(0xFF9CA3AF), + color: AppColors.slate400, ), ), ), @@ -140,7 +160,7 @@ class _CalendarMonthScreenState extends State { for (var weekStart = 0; weekStart < totalCells; weekStart += 7) { weeks.add(_buildWeekRow(weekStart, startWeekday, daysInMonth)); if (weekStart + 7 < totalCells) { - weeks.add(Container(height: 1, color: const Color(0xFFE5E7EB))); + weeks.add(Container(height: 1, color: AppColors.border)); } } @@ -165,24 +185,23 @@ class _CalendarMonthScreenState extends State { _currentMonth.month, dayIndex, ); - final isSelected = - _selectedDate != null && - _selectedDate!.day == dayIndex && - _selectedDate!.month == _currentMonth.month; + final isSelected = isSameDay(_selectedDate, date); return GestureDetector( onTap: () { setState(() { _selectedDate = date; }); + _calendarManager.setSelectedDate(date); + _calendarManager.setViewType(CalendarViewType.month); + final ymd = formatYmd(date); + context.push('/calendar/dayweek?date=$ymd'); }, child: Container( width: 36, height: 36, decoration: BoxDecoration( - color: isSelected - ? const Color(0xFFDBEAFE) - : Colors.transparent, + color: isSelected ? AppColors.blue100 : Colors.transparent, borderRadius: BorderRadius.circular(18), ), child: Center( @@ -217,64 +236,15 @@ class _CalendarMonthScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: List.generate(7, (index) { final dayIndex = weekStart + index - startWeekday + 1; - if (dayIndex == 10) { - return _buildEventDot(); + if (dayIndex < 1 || dayIndex > daysInMonth) { + return const SizedBox(width: 38, height: 1); } - return const SizedBox(width: 38, height: 1); + return const SizedBox(width: 38, height: 20); }), ), ); } - Widget _buildEventDot() { - return SizedBox( - width: 76, - height: 100, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - height: 20, - padding: const EdgeInsets.symmetric(horizontal: 6), - decoration: BoxDecoration( - color: const Color(0xFFE5E7EB), - borderRadius: BorderRadius.circular(6), - ), - child: const Center( - child: Text( - '购票提醒', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: Color(0xFF6B7280), - ), - ), - ), - ), - const SizedBox(height: 4), - Container( - height: 20, - padding: const EdgeInsets.symmetric(horizontal: 6), - decoration: BoxDecoration( - color: const Color(0xFFE9D5FF), - borderRadius: BorderRadius.circular(6), - ), - child: const Center( - child: Text( - '购票提醒', - style: TextStyle( - fontSize: 9, - fontWeight: FontWeight.w500, - color: Color(0xFF6B21A8), - ), - ), - ), - ), - ], - ), - ); - } - void _showMonthPicker() { showModalBottomSheet( context: context, @@ -351,9 +321,12 @@ class _CalendarMonthScreenState extends State { Widget _buildBottomDock() { return BottomDock( activeTab: DockTab.calendar, - onTodoTap: () => context.push('/todo'), + onTodoTap: () { + _calendarManager.setViewType(CalendarViewType.month); + context.push('/todo'); + }, onCalendarTap: () {}, - onHomeTap: () => Navigator.of(context).pop(), + onHomeTap: () => context.go('/home'), ); } } diff --git a/apps/lib/features/calendar/ui/widgets/bottom_dock.dart b/apps/lib/features/calendar/ui/widgets/bottom_dock.dart index 57c25ad..dd59336 100644 --- a/apps/lib/features/calendar/ui/widgets/bottom_dock.dart +++ b/apps/lib/features/calendar/ui/widgets/bottom_dock.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import '../../../../core/theme/design_tokens.dart'; enum DockTab { todo, calendar } @@ -20,8 +21,13 @@ class BottomDock extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - height: 61, - padding: const EdgeInsets.only(left: 20, right: 20, top: 12, bottom: 18), + height: 72, + padding: const EdgeInsets.only( + left: AppSpacing.xl, + right: AppSpacing.xl, + top: AppSpacing.md, + bottom: AppSpacing.lg, + ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [_buildToggle(), _buildHomeBtn()], @@ -33,9 +39,9 @@ class BottomDock extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 4), decoration: BoxDecoration( - color: const Color(0xFFFDFEFF), - borderRadius: BorderRadius.circular(24), - border: Border.all(color: const Color(0xFFDCE6F4)), + color: AppColors.todoToggleBg, + borderRadius: BorderRadius.circular(AppRadius.xxl), + border: Border.all(color: AppColors.todoToggleBorder), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -67,16 +73,18 @@ class BottomDock extends StatelessWidget { width: 44, height: 44, decoration: BoxDecoration( - color: isActive ? const Color(0xFFD6E6FF) : Colors.transparent, - borderRadius: BorderRadius.circular(18), + color: isActive ? AppColors.todoToggleActiveBg : Colors.transparent, + borderRadius: BorderRadius.circular(AppRadius.xl), border: Border.all( - color: isActive ? const Color(0xFFBFD6FB) : Colors.transparent, + color: isActive + ? AppColors.todoToggleActiveBorder + : Colors.transparent, ), ), child: Icon( icon, size: 20, - color: isActive ? const Color(0xFF1D4ED8) : const Color(0xFF334155), + color: isActive ? AppColors.blue600 : AppColors.slate700, ), ), ); @@ -89,11 +97,15 @@ class BottomDock extends StatelessWidget { width: 44, height: 44, decoration: BoxDecoration( - color: const Color(0xFFE6EEFB), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: const Color(0xFFC9D8EE)), + color: AppColors.todoHomeBtnBg, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.todoHomeBtnBorder), + ), + child: const Icon( + LucideIcons.home, + size: 20, + color: AppColors.slate700, ), - child: const Icon(LucideIcons.home, size: 20, color: Color(0xFF1E3A8A)), ), ); } diff --git a/apps/lib/features/home/ui/screens/home_screen.dart b/apps/lib/features/home/ui/screens/home_screen.dart index fa1d68c..6a5d2cc 100644 --- a/apps/lib/features/home/ui/screens/home_screen.dart +++ b/apps/lib/features/home/ui/screens/home_screen.dart @@ -4,9 +4,35 @@ import 'package:lucide_icons/lucide_icons.dart'; import '../../../../core/theme/design_tokens.dart'; import 'home_sheet.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + final TextEditingController _messageController = TextEditingController(); + + bool get _hasMessage => _messageController.text.trim().isNotEmpty; + + @override + void initState() { + super.initState(); + _messageController.addListener(_onMessageChanged); + } + + @override + void dispose() { + _messageController.removeListener(_onMessageChanged); + _messageController.dispose(); + super.dispose(); + } + + void _onMessageChanged() { + setState(() {}); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -47,7 +73,7 @@ class HomeScreen extends StatelessWidget { size: 24, color: AppColors.slate900, ), - onPressed: () => context.push('/calendar/dayweek'), + onPressed: () => context.push('/calendar/dayweek?from=home'), ), const SizedBox(width: 16), IconButton( @@ -153,10 +179,10 @@ class HomeScreen extends StatelessWidget { Widget _buildInputContainer(BuildContext context) { return Container( - height: 80, padding: const EdgeInsets.all(16), color: const Color(0xFFF8FAFC), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ GestureDetector( onTap: () => _showBottomSheet(context), @@ -166,7 +192,7 @@ class HomeScreen extends StatelessWidget { decoration: BoxDecoration( color: AppColors.white, shape: BoxShape.circle, - border: Border.all(color: const Color(0xFFE2E8F0)), + border: Border.all(color: AppColors.slate300), ), child: const Icon( LucideIcons.plus, @@ -178,28 +204,40 @@ class HomeScreen extends StatelessWidget { const SizedBox(width: 8), Expanded( child: Container( - height: 48, - padding: const EdgeInsets.symmetric(horizontal: 16), + constraints: const BoxConstraints(minHeight: 48), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: AppColors.white, + color: Colors.transparent, borderRadius: BorderRadius.circular(24), + border: Border.all(color: AppColors.slate300), ), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - const Expanded( + Expanded( child: TextField( - decoration: InputDecoration( + controller: _messageController, + minLines: 1, + maxLines: 3, + decoration: const InputDecoration( hintText: '输入消息...', border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, isDense: true, contentPadding: EdgeInsets.zero, + filled: false, ), ), ), - const Icon( - LucideIcons.mic, - size: 20, - color: AppColors.slate500, + const SizedBox(width: 8), + Icon( + _hasMessage ? LucideIcons.send : LucideIcons.mic, + size: 24, + color: _hasMessage ? AppColors.blue600 : AppColors.slate500, ), ], ), diff --git a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart index 566db8d..d8ef176 100644 --- a/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart +++ b/apps/lib/features/todo/ui/screens/todo_quadrants_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons/lucide_icons.dart'; +import '../../../../core/di/injection.dart'; import '../../../../core/theme/design_tokens.dart'; +import '../../../calendar/ui/calendar_state_manager.dart'; class TodoQuadrantsScreen extends StatelessWidget { const TodoQuadrantsScreen({super.key}); @@ -216,7 +218,18 @@ class TodoQuadrantsScreen extends StatelessWidget { ), const SizedBox(width: 4), GestureDetector( - onTap: () => context.push('/calendar/dayweek'), + onTap: () { + final manager = sl(); + final viewType = manager.viewType; + final date = manager.selectedDate; + final dateStr = + '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + if (viewType == CalendarViewType.month) { + context.push('/calendar/month'); + } else { + context.push('/calendar/dayweek?date=$dateStr'); + } + }, child: Container( width: 44, height: 44, diff --git a/apps/lib/main.dart b/apps/lib/main.dart index 9f9c061..07807b0 100644 --- a/apps/lib/main.dart +++ b/apps/lib/main.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'core/config/env.dart'; import 'core/di/injection.dart'; import 'core/router/app_router.dart'; import 'core/theme/app_theme.dart'; +import 'features/auth/data/models/auth_response.dart'; import 'features/auth/presentation/bloc/auth_bloc.dart'; import 'features/auth/presentation/bloc/auth_event.dart'; @@ -11,7 +13,16 @@ void main() async { await configureDependencies(); final authBloc = sl(); - authBloc.add(AuthStarted()); + + if (Env.isMockApi) { + authBloc.add( + AuthLoggedIn( + user: AuthUser(id: 'user_001', email: 'test@example.com'), + ), + ); + } else { + authBloc.add(AuthStarted()); + } runApp(LinksyApp(authBloc: authBloc)); } diff --git a/apps/test/features/calendar/ui/calendar_time_utils_test.dart b/apps/test/features/calendar/ui/calendar_time_utils_test.dart new file mode 100644 index 0000000..b9518b0 --- /dev/null +++ b/apps/test/features/calendar/ui/calendar_time_utils_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:social_app/features/calendar/ui/calendar_time_utils.dart'; + +void main() { + group('calendar_time_utils', () { + test('returns week start on sunday', () { + final date = DateTime(2026, 2, 11); + final weekStart = weekStartFor(date); + + expect(weekStart.year, 2026); + expect(weekStart.month, 2); + expect(weekStart.day, 8); + }); + + test('shows current marker only for selected today', () { + final now = DateTime(2026, 2, 11, 15, 28); + + expect(shouldShowCurrentMarker(DateTime(2026, 2, 11), now), isTrue); + expect(shouldShowCurrentMarker(DateTime(2026, 2, 10), now), isFalse); + }); + + test('formats hour minute with zero pad', () { + expect(formatHm(DateTime(2026, 2, 11, 7, 5)), '07:05'); + expect(formatHm(DateTime(2026, 2, 11, 15, 28)), '15:28'); + }); + + test('parses and formats ymd date string', () { + final parsed = parseYmd('2026-02-11'); + + expect(parsed, isNotNull); + expect(parsed!.year, 2026); + expect(parsed.month, 2); + expect(parsed.day, 11); + expect(formatYmd(parsed), '2026-02-11'); + }); + + test('returns null for invalid ymd date string', () { + expect(parseYmd('2026/02/11'), isNull); + expect(parseYmd('bad-input'), isNull); + expect(parseYmd(null), isNull); + }); + + test('builds all dates for month', () { + final dates = monthDatesFor(DateTime(2026, 2, 9)); + + expect(dates.length, 28); + expect(formatYmd(dates.first), '2026-02-01'); + expect(formatYmd(dates.last), '2026-02-28'); + }); + }); +} diff --git a/docs/plans/2026-02-26-auth-ux-enhancement-design.md b/docs/plans/2026-02-26-auth-ux-enhancement-design.md deleted file mode 100644 index 049c201..0000000 --- a/docs/plans/2026-02-26-auth-ux-enhancement-design.md +++ /dev/null @@ -1,204 +0,0 @@ -# Auth UX Enhancement Design - -**日期**: 2026-02-26 -**状态**: 可实施(修订版) - -## 目标 - -本次改动聚焦 4 个问题: -1. 注册验证码页增加首次提示,降低用户困惑。 -2. 增加忘记密码流程(验证码模式)。 -3. 注册页增加邀请码输入(前端收集,后端暂不消费)。 -4. 修复用户名非唯一导致的用户查询问题,改为搜索接口。 - -## 非目标 - -- 不实现邀请码校验/入库。 -- 不改动 Supabase 邮件模板基础设施(当前 self-hosted 已配置 recovery 模板 URL)。 -- 不在本次引入新的认证机制(仅沿用 Supabase OTP + session)。 - ---- - -## 1. 忘记密码的可落地后端方案(对外两步) - -### 1.1 约束说明(关键) - -当前 Python SDK 的 `verify_otp` 参数模型不支持在验码时直接携带 `new_password`。因此不能走“单接口验码并改密”的实现。 - -### 1.2 可执行流程 - -对客户端暴露两步流程,第二步在后端内部执行两段动作: - -1. `POST /auth/password-reset`:调用 Supabase `reset_password_email` 发送 recovery 验证码。 -2. `POST /auth/password-reset/confirm`:接收 `email + token + new_password`,后端内部先调用 `verify_otp(type="recovery")`,再基于该会话调用 `update_user(password=...)`。 - -这样既匹配 SDK 能力(`verify_otp` 不支持直接带 `new_password`),又保持前端体验为两步。 - -### 1.3 API 设计 - -#### POST /auth/password-reset - -发送重置验证码。 - -Request -```json -{ - "email": "string(email)", - "redirect_to": "string(optional)" -} -``` - -Response: `204 No Content` - -Errors: -- `422` 参数错误 -- `429` 频率受限 - -#### POST /auth/password-reset/confirm - -验证 recovery 验证码并完成改密。 - -Request -```json -{ - "email": "string(email)", - "token": "string(6 digits)", - "new_password": "string(min 6)" -} -``` - -Response: `204 No Content` - -Errors: -- `401` 验证码无效或过期 -- `422` 参数错误 -- `429` 频率受限 - -### 1.4 安全边界 - -- 用户档案更新走 `users` 域(`/users/me` -> `UserService` -> `Profile`),仅允许公开资料字段。 -- 密码修改走 `auth` 域(Supabase Auth),不复用 `users` service/repository。 -- `POST /auth/password-reset/confirm` 必须在同一请求内完成“验码 + 改密”,禁止单独暴露“仅改密”接口。 -- 即使伪造 `/users/me` 请求,也无法触发密码修改路径。 - ---- - -## 2. 前端忘记密码流程 - -流程: - -`登录页` -> `忘记密码页(输入邮箱)` -> `验证码+新密码页` -> `返回登录并用新密码登录` - -关键点: -- 第二步页面一次提交 `email + token + new_password` 到 `/auth/password-reset/confirm`。 -- 所有用户反馈统一使用 `Toast`(遵循 `apps/AGENTS.md`)。 -- 错误提示优先展示后端 `detail`。 - ---- - -## 3. 注册 UX 优化 - -### 3.1 验证码发送提示 - -在 `register_verification_screen.dart` 首次进入页面显示: - -`验证码已发送,如未收到请检查垃圾邮件或确认邮箱已注册` - -### 3.2 邀请码输入 - -在注册页新增可选字段: -- Label: `邀请码(选填)` -- Hint: `请输入邀请码` - -前端请求体可携带 `invite_code`,后端忽略该字段,不返回错误。 - ---- - -## 4. 用户搜索 Bug 修复 - -### 4.1 问题 - -`GET /users/{username}` 隐含“用户名唯一”假设,实际不成立。 - -### 4.2 方案 - -后端删除 `GET /users/{username}`,改为 `POST /users/search`。 - -Request -```json -{ - "query": "string(1-100)" -} -``` - -Response -```json -[ - { - "id": "string", - "username": "string", - "avatar_url": "string|null", - "bio": "string|null" - } -] -``` - -查询策略: -- username: 模糊匹配(`ilike`) -- email: 精确匹配 -- 最多返回 20 条 -- 返回公开字段,不返回 email - -### 4.3 前端联动 - -必须同步迁移 `apps/lib/features/users/data/*`(`getByUsername` -> `searchUsers`),否则删除后端旧路由后前端会直接 404。 - ---- - -## 5. 主要改动文件 - -### 后端 - -- `backend/src/v1/auth/schemas.py` -- `backend/src/v1/auth/service.py` -- `backend/src/v1/auth/gateway.py` -- `backend/src/v1/auth/router.py` -- `backend/src/v1/users/schemas.py` -- `backend/src/v1/users/repository.py` -- `backend/src/v1/users/service.py` -- `backend/src/v1/users/router.py` -- `backend/tests/integration/test_auth_routes.py` -- `backend/tests/integration/test_users_routes.py` - -### 前端 - -- `apps/lib/features/auth/ui/screens/login_screen.dart` -- `apps/lib/features/auth/ui/screens/register_screen.dart` -- `apps/lib/features/auth/ui/screens/register_verification_screen.dart` -- `apps/lib/features/auth/ui/screens/forgot_password_screen.dart`(新增) -- `apps/lib/features/auth/ui/screens/reset_password_screen.dart`(新增) -- `apps/lib/features/auth/presentation/cubits/forgot_password_cubit.dart`(新增) -- `apps/lib/features/auth/presentation/cubits/reset_password_cubit.dart`(新增) -- `apps/lib/features/auth/data/auth_api.dart` -- `apps/lib/features/auth/data/auth_repository.dart` -- `apps/lib/features/auth/data/auth_repository_impl.dart` -- `apps/lib/features/users/data/users_api.dart` -- `apps/lib/features/users/data/users_repository.dart` -- `apps/lib/features/users/data/users_repository_impl.dart` -- `apps/lib/core/router/app_router.dart` - -### 文档 - -- `docs/runtime/runtime-route.md`(按 AGENTS 规则必须同步) - ---- - -## 6. 验收标准 - -- [ ] 注册验证码页首次进入显示提示。 -- [ ] 登录页出现“忘记密码”入口。 -- [ ] 忘记密码流程可完整走通(发码、确认改密、重新登录)。 -- [ ] 注册页可输入邀请码且不影响注册。 -- [ ] `GET /users/{username}` 被移除。 -- [ ] `POST /users/search` 可用且返回不含 email。 -- [ ] 后端与前端相关测试通过,文档已同步。 diff --git a/docs/plans/2026-02-26-auth-ux-enhancement-plan.md b/docs/plans/2026-02-26-auth-ux-enhancement-plan.md deleted file mode 100644 index a00c9c7..0000000 --- a/docs/plans/2026-02-26-auth-ux-enhancement-plan.md +++ /dev/null @@ -1,402 +0,0 @@ -# Auth UX Enhancement Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** 以最小风险完成 Auth UX 优化(忘记密码、注册提示、邀请码、用户搜索修复),并确保与现有 Supabase SDK 能力完全一致。 - -**Architecture:** 对客户端暴露两步重置流程:`password-reset` 发码 + `password-reset/confirm` 确认改密。`confirm` 在后端内部串行执行 `verify_otp(type="recovery")` 与 `update_user(password=...)`,既符合 SDK 限制又保持交互简洁。用户查询从 `GET /users/{username}` 迁移到 `POST /users/search`,并同步前端数据层,避免断链。全流程按 TDD 执行并同步路由文档。 - -**Tech Stack:** FastAPI, Pydantic, SQLAlchemy, Supabase Auth, Flutter, Dio, Bloc/Cubit - ---- - -## Phase 0: 基线与保护 - -### Task 1: 建立基线测试(RED 前准备) - -**Files:** -- Modify: `backend/tests/integration/test_auth_routes.py` -- Modify: `backend/tests/integration/test_users_routes.py` - -**Step 1: 新增失败测试占位(不改实现)** - -新增并期望失败的测试: -- `test_password_reset_request_returns_204` -- `test_password_reset_confirm_returns_204` -- `test_search_users_returns_list` - -**Step 2: 运行后端测试确认 RED** - -Run: `uv run pytest backend/tests/integration/test_auth_routes.py backend/tests/integration/test_users_routes.py -v` - -Expected: 新增用例失败(404/AttributeError/未实现)。 - -**Step 3: Commit** - -```bash -git add backend/tests/integration/test_auth_routes.py backend/tests/integration/test_users_routes.py -git commit -m "test: add failing tests for auth ux enhancement" -``` - ---- - -## Phase 1: 后端密码重置(对外两步) - -### Task 2: 完成密码重置请求接口(发码) - -**Files:** -- Modify: `backend/src/v1/auth/schemas.py` -- Modify: `backend/src/v1/auth/service.py` -- Modify: `backend/src/v1/auth/gateway.py` -- Modify: `backend/src/v1/auth/router.py` -- Test: `backend/tests/integration/test_auth_routes.py` - -**Step 1: 写失败测试(若 Task 1 未覆盖细节)** - -确保 `POST /api/v1/auth/password-reset`: -- 合法邮箱返回 `204` -- 非法参数返回 `422` -- 触发限流返回 `429` - -**Step 2: 跑单测确认失败** - -Run: `uv run pytest backend/tests/integration/test_auth_routes.py::test_password_reset_request_returns_204 -v` - -Expected: FAIL。 - -**Step 3: 最小实现(GREEN)** - -- `schemas.py`: 复用已有 `PasswordResetRequest`,不要重复定义同名模型。 -- `service.py`: 增加 `request_password_reset(...)`。 -- `gateway.py`: 调用 `self._client.auth.reset_password_email(email, options)`。 -- `router.py`: 新增 `POST /auth/password-reset`,返回 `204`,保留限流。 - -**Step 4: 跑测试确认通过** - -Run: `uv run pytest backend/tests/integration/test_auth_routes.py::test_password_reset_request_returns_204 -v` - -Expected: PASS。 - -**Step 5: Commit** - -```bash -git add backend/src/v1/auth/schemas.py backend/src/v1/auth/service.py backend/src/v1/auth/gateway.py backend/src/v1/auth/router.py backend/tests/integration/test_auth_routes.py -git commit -m "feat(auth): add password reset request endpoint" -``` - ---- - -### Task 3: 完成密码重置确认接口(验码 + 改密) - -**Files:** -- Modify: `backend/src/v1/auth/schemas.py` -- Modify: `backend/src/v1/auth/service.py` -- Modify: `backend/src/v1/auth/gateway.py` -- Modify: `backend/src/v1/auth/router.py` -- Test: `backend/tests/integration/test_auth_routes.py` - -**Step 1: 写失败测试** - -测试 `POST /api/v1/auth/password-reset/confirm`: -- 正确 `email + token + new_password` 返回 `204` -- 错误验证码返回 `401` -- 弱密码/参数错误返回 `422` - -**Step 2: 跑测试确认失败** - -Run: `uv run pytest backend/tests/integration/test_auth_routes.py::test_password_reset_confirm_returns_204 -v` - -Expected: FAIL。 - -**Step 3: 最小实现** - -- `schemas.py`: 新增 `PasswordResetConfirmRequest(email, token, new_password)`。 -- `service.py`: 增加 `confirm_password_reset(...)`。 -- `gateway.py`: 在单个方法内先调用 `verify_otp({"type":"recovery", ...})`,再在该会话上下文调用 `update_user({"password": new_password})`。 -- `router.py`: 新增 `POST /auth/password-reset/confirm`,返回 `204`。 - -**Step 4: 跑测试确认通过** - -Run: `uv run pytest backend/tests/integration/test_auth_routes.py::test_password_reset_confirm_returns_204 -v` - -Expected: PASS。 - -**Step 5: Commit** - -```bash -git add backend/src/v1/auth/schemas.py backend/src/v1/auth/service.py backend/src/v1/auth/gateway.py backend/src/v1/auth/router.py backend/tests/integration/test_auth_routes.py -git commit -m "feat(auth): add password reset confirm endpoint" -``` - ---- - -## Phase 2: 后端用户搜索替代用户名路由 - -### Task 4: 新增 POST /users/search 并移除旧路由 - -**Files:** -- Modify: `backend/src/v1/users/schemas.py` -- Modify: `backend/src/v1/users/repository.py` -- Modify: `backend/src/v1/users/service.py` -- Modify: `backend/src/v1/users/router.py` -- Test: `backend/tests/integration/test_users_routes.py` - -**Step 1: 写失败测试** - -新增测试: -- `POST /api/v1/users/search` 成功返回列表 -- `query` 为空返回 `422` -- 删除后 `GET /api/v1/users/{username}` 返回 `404` - -**Step 2: 跑测试确认失败** - -Run: `uv run pytest backend/tests/integration/test_users_routes.py -v` - -Expected: FAIL。 - -**Step 3: 最小实现** - -- `schemas.py`: 增加 `UserSearchRequest`, `UserSearchResult`。 -- `repository.py`: 新增 `search_users(query)`;username `ilike` + email 精确匹配,`limit 20`。 -- `service.py`: 增加 `search_users(...)` 并映射公开字段。 -- `router.py`: 增加 `POST /users/search`;删除 `GET /users/{username}`。 - -**Step 4: 跑测试确认通过** - -Run: `uv run pytest backend/tests/integration/test_users_routes.py -v` - -Expected: PASS。 - -**Step 5: Commit** - -```bash -git add backend/src/v1/users/schemas.py backend/src/v1/users/repository.py backend/src/v1/users/service.py backend/src/v1/users/router.py backend/tests/integration/test_users_routes.py -git commit -m "refactor(users): replace username endpoint with search" -``` - ---- - -## Phase 3: 前端认证 UX - -### Task 5: 数据层支持密码重置两步接口 - -**Files:** -- Modify: `apps/lib/features/auth/data/models/login_request.dart` -- Modify: `apps/lib/features/auth/data/auth_api.dart` -- Modify: `apps/lib/features/auth/data/auth_repository.dart` -- Modify: `apps/lib/features/auth/data/auth_repository_impl.dart` -- Test: `apps/test/features/auth/data/auth_repository_impl_test.dart`(如不存在则创建) - -**Step 1: 写失败测试** - -覆盖: -- request -> confirm 的调用顺序 -- confirm 请求包含 `email + token + new_password` - -**Step 2: 跑测试确认失败** - -Run: `flutter test apps/test/features/auth/data/auth_repository_impl_test.dart` - -Expected: FAIL。 - -**Step 3: 最小实现** - -- 增加 `requestPasswordReset` / `confirmPasswordReset`。 -- 模型保持 snake_case JSON 键与后端一致。 - -**Step 4: 跑测试确认通过** - -Run: `flutter test apps/test/features/auth/data/auth_repository_impl_test.dart` - -Expected: PASS。 - -**Step 5: Commit** - -```bash -git add apps/lib/features/auth/data apps/test/features/auth/data/auth_repository_impl_test.dart -git commit -m "feat(auth): add password reset data layer" -``` - ---- - -### Task 6: 新增忘记密码页面与状态管理 - -**Files:** -- Create: `apps/lib/features/auth/ui/screens/forgot_password_screen.dart` -- Create: `apps/lib/features/auth/ui/screens/reset_password_screen.dart` -- Create: `apps/lib/features/auth/presentation/cubits/forgot_password_cubit.dart` -- Create: `apps/lib/features/auth/presentation/cubits/reset_password_cubit.dart` -- Modify: `apps/lib/features/auth/ui/screens/login_screen.dart` -- Modify: `apps/lib/core/router/app_router.dart` -- Test: `apps/test/features/auth/ui/forgot_password_screen_test.dart`(可新建) - -**Step 1: 写失败测试** - -至少覆盖: -- 登录页点击“忘记密码”可跳转 -- 忘记密码页提交后进入验证码改密页 - -**Step 2: 跑测试确认失败** - -Run: `flutter test apps/test/features/auth/ui/forgot_password_screen_test.dart` - -Expected: FAIL。 - -**Step 3: 最小实现** - -- 使用 `Toast` 呈现提交成功/失败反馈。 -- reset 页面提交时调用单个 confirm 接口完成验码与改密。 -- 成功后跳回登录并提示“密码已重置”。 - -**Step 4: 跑测试确认通过** - -Run: `flutter test apps/test/features/auth/ui/forgot_password_screen_test.dart` - -Expected: PASS。 - -**Step 5: Commit** - -```bash -git add apps/lib/features/auth apps/lib/core/router/app_router.dart apps/test/features/auth/ui/forgot_password_screen_test.dart -git commit -m "feat(auth): add forgot password ui flow" -``` - ---- - -### Task 7: 注册体验优化(提示 + 邀请码) - -**Files:** -- Modify: `apps/lib/features/auth/ui/screens/register_verification_screen.dart` -- Modify: `apps/lib/features/auth/ui/screens/register_screen.dart` -- Modify: `apps/lib/features/auth/presentation/cubits/register_cubit.dart` -- Modify: `apps/lib/features/auth/data/models/signup_request.dart` -- Test: `apps/test/features/auth/ui/register_screen_test.dart`(如不存在则创建) - -**Step 1: 写失败测试** - -覆盖: -- 验证码页首次进入显示提示 Toast -- 注册页存在邀请码输入并为可选 - -**Step 2: 跑测试确认失败** - -Run: `flutter test apps/test/features/auth/ui/register_screen_test.dart` - -Expected: FAIL。 - -**Step 3: 最小实现** - -- `signup_request` 可选字段 `invite_code`。 -- 页面展示邀请码输入框,不做必填校验。 - -**Step 4: 跑测试确认通过** - -Run: `flutter test apps/test/features/auth/ui/register_screen_test.dart` - -Expected: PASS。 - -**Step 5: Commit** - -```bash -git add apps/lib/features/auth/ui/screens/register_verification_screen.dart apps/lib/features/auth/ui/screens/register_screen.dart apps/lib/features/auth/presentation/cubits/register_cubit.dart apps/lib/features/auth/data/models/signup_request.dart apps/test/features/auth/ui/register_screen_test.dart -git commit -m "feat(auth): improve verification hint and invite code input" -``` - ---- - -## Phase 4: 前端 users 数据层迁移 - -### Task 8: 将 getByUsername 迁移为 searchUsers - -**Files:** -- Modify: `apps/lib/features/users/data/users_api.dart` -- Modify: `apps/lib/features/users/data/users_repository.dart` -- Modify: `apps/lib/features/users/data/users_repository_impl.dart` -- Test: `apps/test/features/users/data/users_repository_test.dart`(如不存在则创建) - -**Step 1: 写失败测试** - -覆盖: -- `searchUsers(query)` 发送 `POST /api/v1/users/search` -- 返回列表模型映射正确 - -**Step 2: 跑测试确认失败** - -Run: `flutter test apps/test/features/users/data/users_repository_test.dart` - -Expected: FAIL。 - -**Step 3: 最小实现** - -- 删除 `getByUsername`。 -- 新增 `searchUsers(String query)`。 - -**Step 4: 跑测试确认通过** - -Run: `flutter test apps/test/features/users/data/users_repository_test.dart` - -Expected: PASS。 - -**Step 5: Commit** - -```bash -git add apps/lib/features/users/data apps/test/features/users/data/users_repository_test.dart -git commit -m "refactor(users): migrate client to search endpoint" -``` - ---- - -## Phase 5: 文档与全量验证 - -### Task 9: 更新 API 文档并完成全量检查 - -**Files:** -- Modify: `docs/runtime/runtime-route.md` - -**Step 1: 更新路由文档** - -- 新增: - - `POST /auth/password-reset` - - `POST /auth/password-reset/confirm` - - `POST /users/search` -- 删除: - - `GET /users/{username}` - -文档需包含:请求/响应 schema、状态码、错误格式(RFC 7807)。 - -**Step 2: 跑后端验证** - -Run: `uv run pytest backend/tests -v && uv run basedpyright backend/src` - -Expected: 全通过。 - -**Step 3: 跑前端验证** - -Run: `flutter analyze apps/lib && flutter test` - -Expected: 全通过。 - -**Step 4: 手动验收** - -- 忘记密码完整链路(发码/确认改密/登录) -- 注册页邀请码与验证码提示 -- `POST /users/search` 返回结果正确 - -**Step 5: Commit** - -```bash -git add docs/runtime/runtime-route.md -git commit -m "docs: sync runtime routes for auth ux enhancement" -``` - ---- - -## 验收清单 - -- [ ] 所有新增测试先失败后通过(有 RED/GREEN 记录) -- [ ] 后端密码重置两步接口可用(confirm 内部完成验码 + 改密) -- [ ] 前端忘记密码流程可用 -- [ ] 邀请码输入为选填且不破坏现有注册 -- [ ] `GET /users/{username}` 全链路移除 -- [ ] `POST /users/search` 前后端一致 -- [ ] `docs/runtime/runtime-route.md` 已同步 diff --git a/docs/plans/2026-02-27-schedule-items-api-design.md b/docs/plans/2026-02-27-schedule-items-api-design.md new file mode 100644 index 0000000..ff67cf2 --- /dev/null +++ b/docs/plans/2026-02-27-schedule-items-api-design.md @@ -0,0 +1,191 @@ +# Design: Schedule Items API + +**Date:** 2026-02-27 +**Status:** Approved + +## Overview + +实现日历事项(Schedule Items)的后端 CRUD API,支持用户创建、查询、更新、删除日历事项。 + +## Scope + +- 仅后端 API,不涉及前端 +- 全量 CRUD +- 查询按时间范围筛选 +- 暂不支持重复日程(recurrence_rule 留空) + +## API Endpoints + +### 1. 创建日历事项 + +``` +POST /api/v1/schedule-items +``` + +**Request:** +```json +{ + "title": "string (1-255 chars, required)", + "description": "string? (max 2000 chars)", + "start_at": "string (ISO 8601 datetime, required)", + "end_at": "string? (ISO 8601 datetime, must be after start_at)", + "timezone": "string? (default: UTC)", + "metadata": { + "color": "#FF6B6B", + "location": "会议室A", + "notes": "记得带身份证", + "attachments": [], + "version": 1 + } +} +``` + +**Response:** 201 Created +```json +{ + "id": "uuid", + "title": "string", + "description": "string?", + "start_at": "string", + "end_at": "string?", + "timezone": "string", + "metadata": {...}, + "status": "active", + "source_type": "manual", + "created_at": "string", + "updated_at": "string" +} +``` + +### 2. 查询日历事项列表 + +``` +GET /api/v1/schedule-items?start_at=2026-02-01&end_at=2026-02-28 +``` + +**Query Parameters:** +- `start_at`: ISO 8601 date/datetime(查询范围起始) +- `end_at`: ISO 8601 date/datetime(查询范围结束) + +**Response:** 200 OK +```json +[ + { + "id": "uuid", + "title": "string", + "start_at": "string", + "end_at": "string?", + "timezone": "string", + "status": "active" + } +] +``` + +### 3. 获取单个事项 + +``` +GET /api/v1/schedule-items/{id} +``` + +**Response:** 200 OK(完整字段,同创建响应) + +### 4. 更新事项 + +``` +PATCH /api/v1/schedule-items/{id} +``` + +**Request:** 支持 `title`/`description`/`start_at`/`end_at`/`timezone`/`metadata`/`status` 部分更新 + +**Response:** 200 OK + +### 5. 删除事项 + +``` +DELETE /api/v1/schedule-items/{id} +``` + +**Response:** 204 No Content(软删除) + +## Data Models + +### Metadata 结构(Pydantic) + +```python +from enum import Enum +from pydantic import BaseModel +from uuid import UUID + +class AttachmentType(str, Enum): + DOCUMENT = "document" + REMINDER = "reminder" + +class ScheduleItemMetadataAttachment(BaseModel): + name: str + type: AttachmentType + visible_to: list[UUID] = [] + # document 类型 + url: str | None = None + note: str | None = None + # reminder 类型 + content: str | None = None + +class ScheduleItemMetadata(BaseModel): + color: str | None = None + location: str | None = None + notes: str | None = None + attachments: list[ScheduleItemMetadataAttachment] = [] + version: int = 1 +``` + +### 数据库模型(已有) + +参见 `backend/src/models/schedule_items.py`: +- `id`: UUID +- `owner_id`: UUID +- `title`: String(255) +- `description`: Text +- `start_at`: DateTime(timezone=True) +- `end_at`: DateTime(timezone=True) +- `timezone`: String(50) +- `extra_metadata`: JSONB (mapped as "metadata") +- `recurrence_rule`: String(255) +- `source_type`: Enum (MANUAL/IMPORTED/AGENT_GENERATED) +- `status`: Enum (ACTIVE/COMPLETED/CANCELED/ARCHIVED) +- `created_by`: UUID + +## Architecture + +遵循项目 `schemas / repository / service / router` 分层模式: + +``` +backend/src/v1/schedule_items/ +├── __init__.py +├── schemas.py # Pydantic 请求/响应模型 +├── repository.py # CRUD 操作(无 auth,无 commit) +├── service.py # 业务逻辑 + 授权 + 事务边界 +├── router.py # FastAPI 路由定义 +└── dependencies.py # DI(如有) +``` + +## Security + +- 所有端点需要认证(JWT) +- `owner_id` 从 JWT `sub` 提取,不从请求体读取 +- 用户只能操作自己的日历事项(`owner_id` 过滤) +- RLS 已在数据库层启用(防御边界) + +## Error Handling + +使用 RFC 7807 `application/problem+json` 格式: +- 400: 请求参数无效 +- 401: 未认证 +- 404: 事项不存在或无权限访问 +- 422: 验证失败 + +## Out of Scope + +- 重复日程(recurrence_rule) +- 日程订阅与协作(schedule_subscriptions) +- 待办事项联动(todos/todo_sources) +- 前端实现 diff --git a/docs/plans/2026-02-27-schedule-items-api-implementation.md b/docs/plans/2026-02-27-schedule-items-api-implementation.md new file mode 100644 index 0000000..6513ce4 --- /dev/null +++ b/docs/plans/2026-02-27-schedule-items-api-implementation.md @@ -0,0 +1,1244 @@ +# Schedule Items API Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 实现日历事项(Schedule Items)的后端 CRUD API,支持用户创建、查询、更新、删除日历事项。 + +**Architecture:** 遵循项目 `schemas / repository / service / router` 分层模式,与现有 `/users` API 保持一致。 + +**Tech Stack:** FastAPI, SQLAlchemy, Pydantic, PostgreSQL + +--- + +## Task 1: 创建目录结构和 __init__.py + +**Files:** +- Create: `backend/src/v1/schedule_items/__init__.py` + +**Step 1: 创建目录和 __init__.py** + +```python +# backend/src/v1/schedule_items/__init__.py +``` + +Run: `mkdir -p backend/src/v1/schedule_items && touch backend/src/v1/schedule_items/__init__.py` + +--- + +## Task 2: 创建 Pydantic Schemas + +**Files:** +- Create: `backend/src/v1/schedule_items/schemas.py` + +**Step 1: 写入 schemas.py** + +```python +from __future__ import annotations + +from datetime import datetime +from enum import Enum +from typing import ClassVar +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class AttachmentType(str, Enum): + DOCUMENT = "document" + REMINDER = "reminder" + + +class ScheduleItemMetadataAttachment(BaseModel): + name: str + type: AttachmentType + visible_to: list[UUID] = [] + url: str | None = None + note: str | None = None + content: str | None = None + + +class ScheduleItemMetadata(BaseModel): + color: str | None = None + location: str | None = None + notes: str | None = None + attachments: list[ScheduleItemMetadataAttachment] = [] + version: int = 1 + + +class ScheduleItemStatus(str, Enum): + ACTIVE = "active" + COMPLETED = "completed" + CANCELED = "canceled" + ARCHIVED = "archived" + + +class ScheduleItemSourceType(str, Enum): + MANUAL = "manual" + IMPORTED = "imported" + AGENT_GENERATED = "agent_generated" + + +class ScheduleItemCreateRequest(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + title: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=2000) + start_at: datetime + end_at: datetime | None = None + timezone: str = "UTC" + metadata: ScheduleItemMetadata | None = None + + +class ScheduleItemUpdateRequest(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + + title: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=2000) + start_at: datetime | None = None + end_at: datetime | None = None + timezone: str | None = None + metadata: ScheduleItemMetadata | None = None + status: ScheduleItemStatus | None = None + + @field_validator("end_at", mode="before") + @classmethod + def validate_end_at(cls, v: datetime | None, info) -> datetime | None: + return v + + +class ScheduleItemResponse(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) + + id: UUID + title: str + description: str | None = None + start_at: datetime + end_at: datetime | None = None + timezone: str + metadata: ScheduleItemMetadata | None = None + status: ScheduleItemStatus + source_type: ScheduleItemSourceType + created_at: datetime + updated_at: datetime + + +class ScheduleItemListItem(BaseModel): + model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True) + + id: UUID + title: str + start_at: datetime + end_at: datetime | None = None + timezone: str + status: ScheduleItemStatus + + +class ScheduleItemListRequest(BaseModel): + start_at: datetime + end_at: datetime +``` + +--- + +## Task 3: 创建 Repository + +**Files:** +- Create: `backend/src/v1/schedule_items/repository.py` + +**Step 1: 写入 repository.py** + +```python +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Protocol +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError + +from core.db.base_repository import BaseRepository +from core.logging import get_logger +from models.schedule_items import ScheduleItem + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("v1.schedule_items.repository") + + +class ScheduleItemRepository(Protocol): + async def get_by_id(self, item_id: UUID, owner_id: UUID) -> ScheduleItem | None: ... + async def create(self, data: dict) -> ScheduleItem: ... + async def update_by_id(self, item_id: UUID, owner_id: UUID, data: dict) -> ScheduleItem | None: ... + async def delete_by_id(self, item_id: UUID, owner_id: UUID) -> ScheduleItem | None: ... + async def list_by_date_range(self, owner_id: UUID, start_at: datetime, end_at: datetime) -> list[ScheduleItem]: ... + + +class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]): + def __init__(self, session: AsyncSession) -> None: + super().__init__(session, ScheduleItem) + + async def get_by_id(self, item_id: UUID, owner_id: UUID) -> ScheduleItem | None: + try: + stmt = ( + select(ScheduleItem) + .where(ScheduleItem.id == item_id) + .where(ScheduleItem.owner_id == owner_id) + .where(ScheduleItem.deleted_at.is_(None)) + ) + result = await self._session.execute(stmt) + return result.scalar_one_or_none() + except SQLAlchemyError: + logger.exception("Schedule item lookup failed", item_id=str(item_id), owner_id=str(owner_id)) + raise + + async def create(self, data: dict) -> ScheduleItem: + try: + item = ScheduleItem(**data) + self._session.add(item) + await self._session.flush() + return item + except SQLAlchemyError: + logger.exception("Schedule item creation failed") + raise + + async def update_by_id( + self, item_id: UUID, owner_id: UUID, data: dict + ) -> ScheduleItem | None: + if not data: + return await self.get_by_id(item_id, owner_id) + try: + return await self.update_by_id(item_id, data) + except SQLAlchemyError: + logger.exception("Schedule item update failed", item_id=str(item_id)) + raise + + async def delete_by_id(self, item_id: UUID, owner_id: UUID) -> ScheduleItem | None: + try: + return await self.soft_delete_by_id(item_id) + except SQLAlchemyError: + logger.exception("Schedule item delete failed", item_id=str(item_id)) + raise + + async def list_by_date_range( + self, owner_id: UUID, start_at: datetime, end_at: datetime + ) -> list[ScheduleItem]: + try: + stmt = ( + select(ScheduleItem) + .where(ScheduleItem.owner_id == owner_id) + .where(ScheduleItem.deleted_at.is_(None)) + .where(ScheduleItem.start_at >= start_at) + .where(ScheduleItem.start_at <= end_at) + .order_by(ScheduleItem.start_at.asc()) + ) + result = await self._session.execute(stmt) + return list(result.scalars().all()) + except SQLAlchemyError: + logger.exception("Schedule item list failed", owner_id=str(owner_id)) + raise +``` + +--- + +## Task 4: 创建 Service + +**Files:** +- Create: `backend/src/v1/schedule_items/service.py` + +**Step 1: 写入 service.py** + +```python +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.exc import SQLAlchemyError + +from core.auth.models import CurrentUser +from core.db.base_service import BaseService +from core.logging import get_logger +from models.schedule_items import ScheduleItem, ScheduleItemSourceType, ScheduleItemStatus +from v1.schedule_items.repository import ScheduleItemRepository +from v1.schedule_items.schemas import ( + ScheduleItemCreateRequest, + ScheduleItemListRequest, + ScheduleItemResponse, + ScheduleItemUpdateRequest, +) + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +logger = get_logger("v1.schedule_items.service") + + +class ScheduleItemService(BaseService): + _repository: ScheduleItemRepository + _session: AsyncSession + + def __init__( + self, + repository: ScheduleItemRepository, + session: AsyncSession, + current_user: CurrentUser | None, + ) -> None: + super().__init__(current_user=current_user) + self._repository = repository + self._session = session + + async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse: + user_id = self.require_user_id() + + if request.end_at and request.end_at <= request.start_at: + raise HTTPException(status_code=400, detail="end_at must be after start_at") + + data = { + "owner_id": user_id, + "title": request.title, + "description": request.description, + "start_at": request.start_at, + "end_at": request.end_at, + "timezone": request.timezone, + "metadata": request.metadata.model_dump() if request.metadata else {}, + "source_type": ScheduleItemSourceType.MANUAL, + "status": ScheduleItemStatus.ACTIVE, + "created_by": user_id, + } + + try: + item = await self._repository.create(data) + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + logger.exception("Failed to create schedule item") + raise HTTPException(status_code=503, detail="Schedule item store unavailable") + + return self._to_response(item) + + async def get_by_id(self, item_id: UUID) -> ScheduleItemResponse: + user_id = self.require_user_id() + + try: + item = await self._repository.get_by_id(item_id, user_id) + except SQLAlchemyError: + logger.exception("Failed to get schedule item", item_id=str(item_id)) + raise HTTPException(status_code=503, detail="Schedule item store unavailable") + + if item is None: + raise HTTPException(status_code=404, detail="Schedule item not found") + + return self._to_response(item) + + async def update( + self, item_id: UUID, request: ScheduleItemUpdateRequest + ) -> ScheduleItemResponse: + user_id = self.require_user_id() + + existing = await self._repository.get_by_id(item_id, user_id) + if existing is None: + raise HTTPException(status_code=404, detail="Schedule item not found") + + update_data: dict = {} + if request.title is not None: + update_data["title"] = request.title + if request.description is not None: + update_data["description"] = request.description + if request.start_at is not None: + update_data["start_at"] = request.start_at + if request.end_at is not None: + update_data["end_at"] = request.end_at + if request.timezone is not None: + update_data["timezone"] = request.timezone + if request.status is not None: + update_data["status"] = request.status + if request.metadata is not None: + update_data["metadata"] = request.metadata.model_dump() + + if request.end_at and request.start_at and request.end_at <= request.start_at: + raise HTTPException(status_code=400, detail="end_at must be after start_at") + + if not update_data: + return self._to_response(existing) + + try: + item = await self._repository.update_by_id(item_id, user_id, update_data) + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + logger.exception("Failed to update schedule item", item_id=str(item_id)) + raise HTTPException(status_code=503, detail="Schedule item store unavailable") + + if item is None: + raise HTTPException(status_code=404, detail="Schedule item not found") + + return self._to_response(item) + + async def delete(self, item_id: UUID) -> None: + user_id = self.require_user_id() + + existing = await self._repository.get_by_id(item_id, user_id) + if existing is None: + raise HTTPException(status_code=404, detail="Schedule item not found") + + try: + await self._repository.delete_by_id(item_id, user_id) + await self._session.commit() + except SQLAlchemyError: + await self._session.rollback() + logger.exception("Failed to delete schedule item", item_id=str(item_id)) + raise HTTPException(status_code=503, detail="Schedule item store unavailable") + + async def list_by_date_range( + self, request: ScheduleItemListRequest + ) -> list[ScheduleItemResponse]: + user_id = self.require_user_id() + + if request.end_at <= request.start_at: + raise HTTPException(status_code=400, detail="end_at must be after start_at") + + try: + items = await self._repository.list_by_date_range( + user_id, request.start_at, request.end_at + ) + except SQLAlchemyError: + logger.exception("Failed to list schedule items") + raise HTTPException(status_code=503, detail="Schedule item store unavailable") + + return [self._to_response(item) for item in items] + + def _to_response(self, item: ScheduleItem) -> ScheduleItemResponse: + return ScheduleItemResponse( + id=item.id, + title=item.title, + description=item.description, + start_at=item.start_at, + end_at=item.end_at, + timezone=item.timezone, + metadata=item.extra_metadata, + status=item.status, + source_type=item.source_type, + created_at=item.created_at, + updated_at=item.updated_at, + ) +``` + +--- + +## Task 5: 创建 Dependencies + +**Files:** +- Create: `backend/src/v1/schedule_items/dependencies.py` + +**Step 1: 写入 dependencies.py** + +```python +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_db +from core.auth.models import CurrentUser +from v1.users.dependencies import get_current_user +from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository +from v1.schedule_items.service import ScheduleItemService + + +async def get_schedule_item_repository( + session: Annotated[AsyncSession, Depends(get_db)], +) -> SQLAlchemyScheduleItemRepository: + return SQLAlchemyScheduleItemRepository(session) + + +def get_schedule_item_service( + session: Annotated[AsyncSession, Depends(get_db)], + user: Annotated[CurrentUser, Depends(get_current_user)], +) -> ScheduleItemService: + repository = SQLAlchemyScheduleItemRepository(session) + return ScheduleItemService( + repository=repository, + session=session, + current_user=user, + ) +``` + +--- + +## Task 6: 创建 Router + +**Files:** +- Create: `backend/src/v1/schedule_items/router.py` + +**Step 1: 写入 router.py** + +```python +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from v1.schedule_items.dependencies import get_schedule_item_service +from v1.schedule_items.schemas import ( + ScheduleItemCreateRequest, + ScheduleItemListItem, + ScheduleItemListRequest, + ScheduleItemResponse, + ScheduleItemUpdateRequest, +) +from v1.schedule_items.service import ScheduleItemService + + +router = APIRouter(prefix="/schedule-items", tags=["schedule-items"]) + + +@router.post("", response_model=ScheduleItemResponse, status_code=201) +async def create_schedule_item( + request: ScheduleItemCreateRequest, + service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)], +) -> ScheduleItemResponse: + return await service.create(request) + + +@router.get("", response_model=list[ScheduleItemListItem]) +async def list_schedule_items( + start_at: datetime = Query(..., description="Start date/time for range query"), + end_at: datetime = Query(..., description="End date/time for range query"), + service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)], +) -> list[ScheduleItemListItem]: + request = ScheduleItemListRequest(start_at=start_at, end_at=end_at) + items = await service.list_by_date_range(request) + return items + + +@router.get("/{item_id}", response_model=ScheduleItemResponse) +async def get_schedule_item( + item_id: UUID, + service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)], +) -> ScheduleItemResponse: + return await service.get_by_id(item_id) + + +@router.patch("/{item_id}", response_model=ScheduleItemResponse) +async def update_schedule_item( + item_id: UUID, + request: ScheduleItemUpdateRequest, + service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)], +) -> ScheduleItemResponse: + return await service.update(item_id, request) + + +@router.delete("/{item_id}", status_code=204) +async def delete_schedule_item( + item_id: UUID, + service: Annotated[ScheduleItemService, Depends(get_schedule_item_service)], +) -> None: + await service.delete(item_id) +``` + +--- + +## Task 7: 注册 Router + +**Files:** +- Modify: `backend/src/v1/router.py:9-16` + +**Step 1: 添加 router 导入和注册** + +在 `backend/src/v1/router.py` 中添加: + +```python +from v1.schedule_items.router import router as schedule_items_router +``` + +在 `router.include_router` 部分添加: + +```python +router.include_router(schedule_items_router) +``` + +--- + +## Task 8: 创建单元测试 + +**Files:** +- Create: `backend/tests/unit/v1/schedule_items/test_schemas.py` + +**Step 1: 写入 test_schemas.py** + +```python +from datetime import datetime, timezone +from uuid import UUID + +import pytest +from pydantic import ValidationError + +from v1.schedule_items.schemas import ( + AttachmentType, + ScheduleItemCreateRequest, + ScheduleItemMetadata, + ScheduleItemMetadataAttachment, + ScheduleItemUpdateRequest, +) + + +def test_create_request_valid() -> None: + request = ScheduleItemCreateRequest( + title="Test Event", + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + ) + assert request.title == "Test Event" + assert request.timezone == "UTC" + + +def test_create_request_with_end_at() -> None: + request = ScheduleItemCreateRequest( + title="Test Event", + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 2, 28, 17, 30, 0, tzinfo=timezone.utc), + ) + assert request.end_at is not None + + +def test_create_request_invalid_title_empty() -> None: + with pytest.raises(ValidationError): + ScheduleItemCreateRequest( + title="", + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + ) + + +def test_create_request_invalid_title_too_long() -> None: + with pytest.raises(ValidationError): + ScheduleItemCreateRequest( + title="x" * 256, + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + ) + + +def test_create_request_with_metadata() -> None: + metadata = ScheduleItemMetadata( + color="#FF6B6B", + location="Meeting Room A", + notes="Bring documents", + attachments=[], + ) + request = ScheduleItemCreateRequest( + title="Test Event", + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + metadata=metadata, + ) + assert request.metadata is not None + assert request.metadata.color == "#FF6B6B" + + +def test_update_request_partial() -> None: + request = ScheduleItemUpdateRequest(title="Updated Title") + assert request.title == "Updated Title" + assert request.description is None + + +def test_metadata_attachment_document() -> None: + attachment = ScheduleItemMetadataAttachment( + name="document.pdf", + type=AttachmentType.DOCUMENT, + url="https://example.com/doc.pdf", + ) + assert attachment.type == AttachmentType.DOCUMENT + assert attachment.url == "https://example.com/doc.pdf" + + +def test_metadata_attachment_reminder() -> None: + attachment = ScheduleItemMetadataAttachment( + name="reminder", + type=AttachmentType.REMINDER, + content="Don't forget!", + ) + assert attachment.type == AttachmentType.REMINDER + assert attachment.content == "Don't forget!" +``` + +**Step 2: 运行测试验证** + +Run: `cd backend && uv run pytest tests/unit/v1/schedule_items/test_schemas.py -v` + +Expected: PASS + +--- + +## Task 9: 创建 Service 单元测试 + +**Files:** +- Create: `backend/tests/unit/v1/schedule_items/test_service.py` + +**Step 1: 写入 test_service.py** + +```python +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock +from uuid import UUID, uuid4 + +import pytest +from fastapi import HTTPException +from sqlalchemy.exc import SQLAlchemyError + +from core.auth.models import CurrentUser +from models.schedule_items import ScheduleItem, ScheduleItemSourceType, ScheduleItemStatus +from v1.schedule_items.repository import ScheduleItemRepository +from v1.schedule_items.schemas import ScheduleItemCreateRequest, ScheduleItemUpdateRequest +from v1.schedule_items.service import ScheduleItemService + + +def _create_mock_schedule_item( + item_id: UUID = uuid4(), + owner_id: UUID = UUID("00000000-0000-0000-0000-000000000001"), + title: str = "Test Event", +) -> ScheduleItem: + item = MagicMock(spec=ScheduleItem) + item.id = item_id + item.owner_id = owner_id + item.title = title + item.description = None + item.start_at = datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc) + item.end_at = datetime(2026, 2, 28, 17, 0, 0, tzinfo=timezone.utc) + item.timezone = "UTC" + item.metadata = {} + item.source_type = ScheduleItemSourceType.MANUAL + item.status = ScheduleItemStatus.ACTIVE + item.created_at = datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc) + item.updated_at = datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc) + return item + + +class FakeRepo: + def __init__(self, item: ScheduleItem | None) -> None: + self._item = item + + async def get_by_id(self, item_id: UUID, owner_id: UUID) -> ScheduleItem | None: + if self._item and item_id == self._item.id: + return self._item + return None + + async def create(self, data: dict) -> ScheduleItem: + return _create_mock_schedule_item(**data) + + async def update_by_id(self, item_id: UUID, owner_id: UUID, data: dict) -> ScheduleItem | None: + if not self._item or item_id != self._item.id: + return None + return self._item + + async def delete_by_id(self, item_id: UUID, owner_id: UUID) -> ScheduleItem | None: + if not self._item or item_id != self._item.id: + return None + return self._item + + async def list_by_date_range( + self, owner_id: UUID, start_at: datetime, end_at: datetime + ) -> list[ScheduleItem]: + return [self._item] if self._item else [] + + +@pytest.fixture +def mock_session() -> AsyncMock: + session = AsyncMock() + session.commit = AsyncMock() + session.rollback = AsyncMock() + return session + + +@pytest.mark.asyncio +async def test_create_success(mock_session: AsyncMock) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + request = ScheduleItemCreateRequest( + title="Test Event", + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + ) + service = ScheduleItemService( + repository=FakeRepo(None), + session=mock_session, + current_user=CurrentUser(id=user_id), + ) + + result = await service.create(request) + + assert result.title == "Test Event" + mock_session.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_create_invalid_end_at(mock_session: AsyncMock) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + request = ScheduleItemCreateRequest( + title="Test Event", + start_at=datetime(2026, 2, 28, 17, 0, 0, tzinfo=timezone.utc), + end_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + ) + service = ScheduleItemService( + repository=FakeRepo(None), + session=mock_session, + current_user=CurrentUser(id=user_id), + ) + + with pytest.raises(HTTPException) as exc_info: + await service.create(request) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_get_by_id_success(mock_session: AsyncMock) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item = _create_mock_schedule_item() + service = ScheduleItemService( + repository=FakeRepo(item), + session=mock_session, + current_user=CurrentUser(id=user_id), + ) + + result = await service.get_by_id(item.id) + + assert result.id == item.id + + +@pytest.mark.asyncio +async def test_get_by_id_not_found(mock_session: AsyncMock) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + service = ScheduleItemService( + repository=FakeRepo(None), + session=mock_session, + current_user=CurrentUser(id=user_id), + ) + + with pytest.raises(HTTPException) as exc_info: + await service.get_by_id(uuid4()) + + assert exc_info.value.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_success(mock_session: AsyncMock) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item = _create_mock_schedule_item() + service = ScheduleItemService( + repository=FakeRepo(item), + session=mock_session, + current_user=CurrentUser(id=user_id), + ) + + result = await service.update(item.id, ScheduleItemUpdateRequest(title="Updated")) + + assert result.title == "Updated" + + +@pytest.mark.asyncio +async def test_delete_success(mock_session: AsyncMock) -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item = _create_mock_schedule_item() + service = ScheduleItemService( + repository=FakeRepo(item), + session=mock_session, + current_user=CurrentUser(id=user_id), + ) + + await service.delete(item.id) + + mock_session.commit.assert_awaited_once() +``` + +**Step 2: 运行测试验证** + +Run: `cd backend && uv run pytest tests/unit/v1/schedule_items/test_service.py -v` + +Expected: PASS + +--- + +## Task 10: 创建集成测试 + +**Files:** +- Create: `backend/tests/integration/test_schedule_items_routes.py` + +**Step 1: 写入 test_schedule_items_routes.py** + +```python +from datetime import datetime, timezone +from typing import Callable +from uuid import UUID, uuid4 + +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app import app +from core.auth.models import CurrentUser +from v1.schedule_items.dependencies import get_schedule_item_service +from v1.schedule_items.schemas import ( + ScheduleItemCreateRequest, + ScheduleItemListRequest, + ScheduleItemResponse, + ScheduleItemUpdateRequest, +) +from v1.schedule_items.service import ScheduleItemService + + +class FakeScheduleItemService: + def __init__(self, item: ScheduleItemResponse | None) -> None: + self._item = item + + async def create(self, request: ScheduleItemCreateRequest) -> ScheduleItemResponse: + if not self._item: + raise HTTPException(status_code=503, detail="Store unavailable") + return self._item + + async def get_by_id(self, item_id: UUID) -> ScheduleItemResponse: + if not self._item or str(self._item.id) != str(item_id): + raise HTTPException(status_code=404, detail="Schedule item not found") + return self._item + + async def update( + self, item_id: UUID, request: ScheduleItemUpdateRequest + ) -> ScheduleItemResponse: + if not self._item or str(self._item.id) != str(item_id): + raise HTTPException(status_code=404, detail="Schedule item not found") + return self._item + + async def delete(self, item_id: UUID) -> None: + if not self._item or str(self._item.id) != str(item_id): + raise HTTPException(status_code=404, detail="Schedule item not found") + + async def list_by_date_range( + self, request: ScheduleItemListRequest + ) -> list[ScheduleItemResponse]: + return [self._item] if self._item else [] + + +def _override_schedule_item_service( + service: FakeScheduleItemService, +) -> Callable[[], ScheduleItemService]: + def _get_service() -> ScheduleItemService: + return service # type: ignore[return-value] + + return _get_service + + +def _override_current_user(user_id: UUID) -> Callable[[], CurrentUser]: + def _get_user() -> CurrentUser: + return CurrentUser(id=user_id) + + return _get_user + + +def test_create_schedule_item_returns_201() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item = ScheduleItemResponse( + id=uuid4(), + title="Test Event", + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + timezone="UTC", + status="active", + source_type="manual", + created_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc), + ) + + app.dependency_overrides[get_schedule_item_service] = _override_schedule_item_service( + FakeScheduleItemService(item) + ) + + client = TestClient(app) + try: + response = client.post( + "/api/v1/schedule-items", + json={ + "title": "Test Event", + "start_at": "2026-02-28T16:00:00Z", + }, + ) + assert response.status_code == 201 + finally: + app.dependency_overrides = {} + + +def test_list_schedule_items_returns_200() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item = ScheduleItemResponse( + id=uuid4(), + title="Test Event", + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + timezone="UTC", + status="active", + source_type="manual", + created_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc), + ) + + app.dependency_overrides[get_schedule_item_service] = _override_schedule_item_service( + FakeScheduleItemService(item) + ) + + client = TestClient(app) + try: + response = client.get( + "/api/v1/schedule-items", + params={ + "start_at": "2026-02-01T00:00:00Z", + "end_at": "2026-02-28T23:59:59Z", + }, + ) + assert response.status_code == 200 + assert isinstance(response.json(), list) + finally: + app.dependency_overrides = {} + + +def test_get_schedule_item_returns_200() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item_id = uuid4() + item = ScheduleItemResponse( + id=item_id, + title="Test Event", + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + timezone="UTC", + status="active", + source_type="manual", + created_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc), + ) + + app.dependency_overrides[get_schedule_item_service] = _override_schedule_item_service( + FakeScheduleItemService(item) + ) + + client = TestClient(app) + try: + response = client.get(f"/api/v1/schedule-items/{item_id}") + assert response.status_code == 200 + finally: + app.dependency_overrides = {} + + +def test_update_schedule_item_returns_200() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item_id = uuid4() + item = ScheduleItemResponse( + id=item_id, + title="Updated Event", + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + timezone="UTC", + status="active", + source_type="manual", + created_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc), + ) + + app.dependency_overrides[get_schedule_item_service] = _override_schedule_item_service( + FakeScheduleItemService(item) + ) + + client = TestClient(app) + try: + response = client.patch( + f"/api/v1/schedule-items/{item_id}", + json={"title": "Updated Event"}, + ) + assert response.status_code == 200 + finally: + app.dependency_overrides = {} + + +def test_delete_schedule_item_returns_204() -> None: + user_id = UUID("00000000-0000-0000-0000-000000000001") + item_id = uuid4() + item = ScheduleItemResponse( + id=item_id, + title="Test Event", + start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc), + timezone="UTC", + status="active", + source_type="manual", + created_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc), + updated_at=datetime(2026, 2, 27, 10, 0, 0, tzinfo=timezone.utc), + ) + + app.dependency_overrides[get_schedule_item_service] = _override_schedule_item_service( + FakeScheduleItemService(item) + ) + + client = TestClient(app) + try: + response = client.delete(f"/api/v1/schedule-items/{item_id}") + assert response.status_code == 204 + finally: + app.dependency_overrides = {} +``` + +**Step 2: 运行测试验证** + +Run: `cd backend && uv run pytest tests/integration/test_schedule_items_routes.py -v` + +Expected: PASS + +--- + +## Task 11: 运行 Lint 和 TypeCheck + +**Step 1: 运行 ruff** + +Run: `cd backend && uv run ruff check src/v1/schedule_items/` + +Expected: No errors + +**Step 2: 运行 typecheck** + +Run: `cd backend && uv run basedpyright src/v1/schedule_items/` + +Expected: No errors + +--- + +## Task 12: 更新 API 文档 + +**Files:** +- Modify: `docs/runtime/runtime-route.md` + +在 `## Auth` 后添加: + +```markdown +## Schedule Items + +### POST /schedule-items + +创建日历事项(需要认证)。 + +**Request:** +```json +{ + "title": "string (1-255 chars, required)", + "description": "string? (max 2000 chars)", + "start_at": "string (ISO 8601 datetime, required)", + "end_at": "string? (ISO 8601 datetime)", + "timezone": "string? (default: UTC)", + "metadata": { + "color": "#FF6B6B", + "location": "会议室A", + "notes": "记得带身份证", + "attachments": [], + "version": 1 + } +} +``` + +**Response:** 201 Created +```json +{ + "id": "uuid", + "title": "string", + "description": "string?", + "start_at": "string", + "end_at": "string?", + "timezone": "string", + "metadata": {}, + "status": "active", + "source_type": "manual", + "created_at": "string", + "updated_at": "string" +} +``` + +**Errors:** +- 400: end_at 早于 start_at +- 401: 未认证 +- 503: 服务不可用 + +--- + +### GET /schedule-items + +按时间范围查询日历事项列表(需要认证)。 + +**Query Parameters:** +- `start_at`: ISO 8601 date/datetime(查询范围起始) +- `end_at`: ISO 8601 date/datetime(查询范围结束) + +**Response:** 200 OK +```json +[ + { + "id": "uuid", + "title": "string", + "start_at": "string", + "end_at": "string?", + "timezone": "string", + "status": "active" + } +] +``` + +**Errors:** +- 400: end_at 早于 start_at +- 401: 未认证 + +--- + +### GET /schedule-items/{id} + +获取单个日历事项详情(需要认证)。 + +**Response:** 200 OK + +**Errors:** +- 401: 未认证 +- 404: 事项不存在 + +--- + +### PATCH /schedule-items/{id} + +更新日历事项(需要认证)。 + +**Request:** 支持 `title`/`description`/`start_at`/`end_at`/`timezone`/`metadata`/`status` 部分更新 + +**Response:** 200 OK + +**Errors:** +- 401: 未认证 +- 404: 事项不存在 + +--- + +### DELETE /schedule-items/{id} + +删除日历事项(软删除,需要认证)。 + +**Response:** 204 No Content + +**Errors:** +- 401: 未认证 +- 404: 事项不存在 + +--- +``` + +--- + +## Task 13: 提交代码 + +**Step 1: 提交所有变更** + +Run: `git add -A && git commit -m "feat: add schedule items CRUD API + +- Add ScheduleItem Pydantic schemas with metadata support +- Add repository layer with CRUD operations +- Add service layer with authorization +- Add FastAPI router with all endpoints +- Add unit and integration tests +- Update API documentation"` + +Expected: Commit created successfully diff --git a/docs/runtime/frontend-runbook.md b/docs/runtime/frontend-runbook.md new file mode 100644 index 0000000..769d2d4 --- /dev/null +++ b/docs/runtime/frontend-runbook.md @@ -0,0 +1,103 @@ +# Frontend Runtime Runbook + +**Date:** 2026-02-27 +**Status:** Active +**Audience:** 前端开发 + +--- + +## 开发环境 + +### Mock 模式 + +前端开发时可通过 `--dart-define` 切换 Mock 模式,无需后端即可运行: + +```bash +# Mock 模式(本地开发,无需后端) +flutter run --dart-define=MOCK_API=true + +# 正式模式(需要后端运行) +flutter run +``` + +### Mock 自动登录 + +Mock 模式下,启动 App 时会自动使用测试账号登录并跳转到首页。 + +**测试账号(Mock):** + +| 场景 | 邮箱 | 密码 | 说明 | +|------|------|------|------| +| 正常登录 | 任意非 error@test.com | 任意 | 登录成功 | +| 登录失败 | error@test.com | 任意 | 返回 401 | + +**验证码:** 任意 6 位数字(建议使用 `123456`) + +--- + +## 打包构建 + +### Debug Build + +```bash +# Mock 模式 +flutter build apk --debug --dart-define=MOCK_API=true + +# 正式模式 +flutter build apk --debug +``` + +### Release Build + +Release 构建强制使用正式 API,不受 `MOCK_API` 影响: + +```bash +flutter build apk --release +``` + +--- + +## 调试运行 + +### 命令行调试 + +```bash +# Mock 模式(无需后端,自动登录) +flutter run --dart-define=MOCK_API=true -d emulator-5554 + +# 正式模式(需要后端运行) +flutter run -d emulator-5554 +``` + +### VSCode 调试配置 + +在 `.vscode/launch.json` 中添加配置: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Mock Mode", + "request": "launch", + "type": "dart", + "args": ["--dart-define=MOCK_API=true"] + }, + { + "name": "正式模式", + "request": "launch", + "type": "dart" + } + ] +} +``` + +配置完成后,在 VSCode 左侧 Debug 面板的 dropdown 中选择 "Mock Mode" 或 "正式模式" 进行调试。 + +--- + +## Change Log + +| 日期 | 变更 | +|------|------| +| 2026-02-27 | 新增 Frontend Runbook,支持 --dart-define=MOCK_API=true 切换 Mock 模式 |