# TBOSS OA Module 实现计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将 tboss_oa_module 实现为功能完整的 OA 前端模块(24 页/10 功能/3 Tab/4 角色),使用 TDesign Flutter UI 组件库。 **Architecture:** 保持现有 Riverpod + GoRouter + Dio 架构,集成 TDesign Flutter 主题系统,渐进式改造现有骨架页面。3 Tab Shell 导航承载所有功能页面。 **Tech Stack:** Flutter 3.38.10 · TDesign Flutter 0.2.7 · Riverpod · GoRouter · Dio · fl_chart · flutter_slidable --- ## 文件结构规划 ``` lib/ ├── main.dart # 已存在,修改:移除 setPreferredOrientations 限制 ├── app.dart # 修改:集成 TDesign 主题,Provider 初始化 ├── core/ │ ├── auth/auth_service.dart # 已存在,不变 │ ├── network/ # 已存在,不变 │ ├── router/ │ │ └── app_router.dart # 修改:添加 Shell 路由,新增消息/我的路由 │ ├── theme/ │ │ ├── app_colors.dart # 已存在,不变 │ │ └── app_theme.dart # 修改:集成 TDesign 主题 │ └── utils/ │ ├── date_utils.dart # 已存在,不变 │ ├── responsive.dart # 已存在,不变 │ └── validators.dart # 已存在,不变 ├── features/ │ ├── shell/ # 新建:3 Tab 外壳 │ │ └── app_shell.dart │ ├── home/ # 修改:按角色显示不同区块 │ ├── messages/ # 新建:消息 Tab │ ├── profile/ # 新建:我的 Tab │ ├── expense/ # 修改:完善列表/申请/详情 + 模型补充 │ ├── expense_application/ # 修改:完善列表/申请/详情 + 模型补充 │ ├── overtime/ # 修改:完善列表/申请/详情 + 模型补充 │ ├── vehicle/ # 修改:完善列表/申请/详情 + 模型补充 │ ├── outing_log/ # 修改:完善列表/创建/详情 │ ├── announcement/ # 修改:完善列表/详情 │ └── report/ # 修改:完善报表筛选+图表+列表 └── shared/ ├── models/ │ ├── approval_status.dart # 已存在,不变 │ ├── pagination_model.dart # 已存在,不变 │ └── user_model.dart # 新建:用户模型(含角色) └── widgets/ ├── app_card.dart # 已存在,不变 ├── empty_state.dart # 已存在,不变 ├── form_field_row.dart # 已存在,不变 ├── form_section.dart # 已存在,不变 ├── loading_widget.dart # 已存在,不变 ├── status_tag.dart # 已存在,不变 ├── approval_timeline.dart # 新建:审批时间线组件 └── approval_actions.dart # 新建:审批操作按钮组件 ``` --- ### Task 1: 添加 TDesign Flutter 依赖 **Files:** - Modify: `pubspec.yaml` - [ ] **Step 1: 添加 tdesign_flutter 依赖** 在 `pubspec.yaml` 的 dependencies 中添加: ```yaml tdesign_flutter: ^0.2.7 ``` - [ ] **Step 2: 安装依赖** ```bash cd c:/Users/canzu/Desktop/code/tboss/tboss_oa_module && flutter pub get ``` 预期:成功安装,无版本冲突。 - [ ] **Step 3: 验证 iOS 兼容性** ```bash cd c:/Users/canzu/Desktop/code/tboss/tboss_oa_module/ios && pod install 2>&1 || echo "SKIP: No macOS" ``` 预期:pod install 成功或跳过(非 macOS),无 Swift 依赖错误。 --- ### Task 2: 集成 TDesign 主题 **Files:** - Modify: `lib/core/theme/app_theme.dart` - Modify: `lib/app.dart` - [ ] **Step 1: 改造 AppTheme 集成 TDesign** 修改 `lib/core/theme/app_theme.dart`: ```dart import 'package:flutter/material.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'app_colors.dart'; class AppTheme { AppTheme._(); static TDThemeData get tdThemeData { return TDThemeData( brandNormalColor: AppColors.primary, brandTapColor: const Color(0xFF0095D0), successColor: AppColors.success, warningColor: AppColors.warning, errorColor: AppColors.error, fontBodyMedium: TextStyle( fontSize: 14, color: AppColors.textPrimary, ), fontBodySmall: TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ); } static ThemeData get light { final colorScheme = ColorScheme.fromSeed( seedColor: AppColors.primary, brightness: Brightness.light, ); return ThemeData( useMaterial3: true, colorScheme: colorScheme, scaffoldBackgroundColor: AppColors.background, appBarTheme: const AppBarTheme( backgroundColor: AppColors.primary, foregroundColor: Colors.white, elevation: 0, centerTitle: false, titleTextStyle: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white, ), ), cardTheme: CardThemeData( color: AppColors.cardWhite, elevation: 1, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), minimumSize: const Size(double.infinity, 48), textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( foregroundColor: AppColors.textPrimary, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), minimumSize: const Size(double.infinity, 48), side: const BorderSide(color: Color(0xFFDDDDDD)), textStyle: const TextStyle(fontSize: 15), ), ), ); } } ``` - [ ] **Step 2: 修改 app.dart,挂载 TDesign 主题** 修改 `lib/app.dart`: ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:tdesign_flutter/tdesign_flutter.dart'; import 'core/theme/app_theme.dart'; import 'core/router/app_router.dart'; import 'core/network/api_client.dart'; import 'core/auth/auth_service.dart'; final apiClientProvider = Provider((ref) { const useMock = true; final client = ApiClient( baseUrl: 'https://your-api-host.com/api', useMock: useMock, ); final authService = ref.read(authServiceProvider); client.setToken(authService.token); return client; }); final authServiceProvider = Provider((ref) => AuthService()); class App extends ConsumerWidget { const App({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { TDTheme.needMultiTheme(); final router = createAppRouter(); return MaterialApp.router( title: 'TBOSS OA', theme: AppTheme.light, routerConfig: router, debugShowCheckedModeBanner: false, ); } } ``` - [ ] **Step 3: 验证编译** ```bash cd c:/Users/canzu/Desktop/code/tboss/tboss_oa_module && flutter analyze lib/core/theme/app_theme.dart lib/app.dart 2>&1 ``` 预期:无错误。 --- ### Task 3: 创建用户模型 **Files:** - Create: `lib/shared/models/user_model.dart` - [ ] **Step 1: 创建 UserModel 和角色枚举** 创建 `lib/shared/models/user_model.dart`: ```dart enum UserRole { employee, approver, finance, admin } class UserModel { final String id; final String userName; final String realName; final UserRole role; final String deptId; final String deptName; final String position; final String phone; final String email; final String avatarUrl; const UserModel({ required this.id, this.userName = '', this.realName = '', this.role = UserRole.employee, this.deptId = '', this.deptName = '', this.position = '', this.phone = '', this.email = '', this.avatarUrl = '', }); bool get isApprover => role == UserRole.approver || role == UserRole.admin; bool get isFinance => role == UserRole.finance || role == UserRole.admin; bool get isAdmin => role == UserRole.admin; factory UserModel.fromJson(Map json) { return UserModel( id: json['id'] as String? ?? '', userName: json['userName'] as String? ?? '', realName: json['realName'] as String? ?? '', role: _parseRole(json['role'] as String?), deptId: json['deptId'] as String? ?? '', deptName: json['deptName'] as String? ?? '', position: json['position'] as String? ?? '', phone: json['phone'] as String? ?? '', email: json['email'] as String? ?? '', avatarUrl: json['avatarUrl'] as String? ?? '', ); } static UserRole _parseRole(String? role) { switch (role) { case 'approver': return UserRole.approver; case 'finance': return UserRole.finance; case 'admin': return UserRole.admin; default: return UserRole.employee; } } static const mock = UserModel( id: 'U001', userName: 'zhangsan', realName: '张三', role: UserRole.employee, deptId: 'D001', deptName: '销售部', position: '销售经理', ); } ``` --- ### Task 4: 更新数据模型(补齐缺失字段) **Files:** - Modify: `lib/features/expense/expense_model.dart` - Modify: `lib/features/expense_application/expense_application_model.dart` - Modify: `lib/features/overtime/overtime_model.dart` - Modify: `lib/features/vehicle/vehicle_model.dart` - [ ] **Step 1: 补充 ExpenseModel 字段** 在 `lib/features/expense/expense_model.dart` 的 ExpenseModel 类中添加: ```dart // 在现有字段声明区域末尾添加: final List invoiceImages; final String paymentStatus; final String voucherNo; // 构造函数参数添加(在 updateTime 之后): this.invoiceImages = const [], this.paymentStatus = 'unpaid', this.voucherNo = '', // fromJson 中添加: invoiceImages: (json['invoiceImages'] as List?) ?.map((e) => e as String).toList() ?? [], paymentStatus: json['paymentStatus'] as String? ?? 'unpaid', voucherNo: json['voucherNo'] as String? ?? '', // toJson 中添加: 'invoiceImages': invoiceImages, 'paymentStatus': paymentStatus, 'voucherNo': voucherNo, // copyWith 中添加: List? invoiceImages, String? paymentStatus, String? voucherNo, // 以及对应的赋值: invoiceImages: invoiceImages ?? this.invoiceImages, paymentStatus: paymentStatus ?? this.paymentStatus, voucherNo: voucherNo ?? this.voucherNo, ``` - [ ] **Step 2: 补充 ExpenseApplicationModel 字段** 在 `lib/features/expense_application/expense_application_model.dart` 的 ExpenseApplicationModel 类中添加: ```dart // 字段声明: final DateTime? estimatedStartDate; final DateTime? estimatedEndDate; final String urgency; final String projectId; final String projectName; final String budgetSubjectId; // 构造函数默认值: this.estimatedStartDate, this.estimatedEndDate, this.urgency = 'normal', this.projectId = '', this.projectName = '', this.budgetSubjectId = '', // fromJson: estimatedStartDate: json['estimatedStartDate'] != null ? DateTime.parse(json['estimatedStartDate'] as String) : null, estimatedEndDate: json['estimatedEndDate'] != null ? DateTime.parse(json['estimatedEndDate'] as String) : null, urgency: json['urgency'] as String? ?? 'normal', projectId: json['projectId'] as String? ?? '', projectName: json['projectName'] as String? ?? '', budgetSubjectId: json['budgetSubjectId'] as String? ?? '', // toJson: 'estimatedStartDate': estimatedStartDate?.toIso8601String(), 'estimatedEndDate': estimatedEndDate?.toIso8601String(), 'urgency': urgency, 'projectId': projectId, 'projectName': projectName, 'budgetSubjectId': budgetSubjectId, // copyWith 中同步补充。 ``` - [ ] **Step 3: 补充 OvertimeModel 字段** 在 `lib/features/overtime/overtime_model.dart` 的 OvertimeModel 类中添加: ```dart // 字段声明: final double actualOtHours; final DateTime? clockInTime; final DateTime? clockOutTime; final double mealDeductHours; // 构造函数默认值: this.actualOtHours = 0.0, this.clockInTime, this.clockOutTime, this.mealDeductHours = 0.5, // fromJson: actualOtHours: (json['actualOtHours'] as num?)?.toDouble() ?? 0.0, clockInTime: json['clockInTime'] != null ? DateTime.parse(json['clockInTime'] as String) : null, clockOutTime: json['clockOutTime'] != null ? DateTime.parse(json['clockOutTime'] as String) : null, mealDeductHours: (json['mealDeductHours'] as num?)?.toDouble() ?? 0.5, // toJson 和 copyWith 同步补充。 ``` - [ ] **Step 4: 补充 VehicleModel 字段** 在 `lib/features/vehicle/vehicle_model.dart` 的 VehicleModel 类中添加: ```dart // 字段声明: final double actualMileage; final double actualCost; final double startOdometer; final double endOdometer; final DateTime? returnTime; final List passengers; // 构造函数默认值: this.actualMileage = 0.0, this.actualCost = 0.0, this.startOdometer = 0.0, this.endOdometer = 0.0, this.returnTime, this.passengers = const [], // fromJson: actualMileage: (json['actualMileage'] as num?)?.toDouble() ?? 0.0, actualCost: (json['actualCost'] as num?)?.toDouble() ?? 0.0, startOdometer: (json['startOdometer'] as num?)?.toDouble() ?? 0.0, endOdometer: (json['endOdometer'] as num?)?.toDouble() ?? 0.0, returnTime: json['returnTime'] != null ? DateTime.parse(json['returnTime'] as String) : null, passengers: (json['passengers'] as List?) ?.map((e) => e as String).toList() ?? [], // toJson 和 copyWith 同步补充。 ``` - [ ] **Step 5: 验证编译** ```bash cd c:/Users/canzu/Desktop/code/tboss/tboss_oa_module && flutter analyze lib/features/ 2>&1 ``` 预期:无错误。 --- ### Task 5: 创建共享组件 — 审批时间线 **Files:** - Create: `lib/shared/widgets/approval_timeline.dart` - [ ] **Step 1: 创建 ApprovalTimeline 组件** 创建 `lib/shared/widgets/approval_timeline.dart`: ```dart import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; import '../models/approval_status.dart'; class ApprovalTimeline extends StatelessWidget { final List records; final List chain; final String currentApproverId; const ApprovalTimeline({ super.key, required this.records, required this.chain, this.currentApproverId = '', }); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('审批进度', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const SizedBox(height: 12), ...chain.asMap().entries.map((entry) { final idx = entry.key; final approverId = entry.value; final record = records.where((r) => r.approverId == approverId).firstOrNull; final isCurrent = approverId == currentApproverId; return _buildNode(idx, chain.length, approverId, record, isCurrent); }), ], ); } Widget _buildNode(int index, int total, String approverId, ApprovalRecord? record, bool isCurrent) { final isDone = record != null && record.action == 'approve'; final isRejected = record != null && record.action == 'reject'; final isLast = index == total - 1; return IntrinsicHeight( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Column( children: [ Container( width: 22, height: 22, decoration: BoxDecoration( color: isRejected ? AppColors.error : isDone ? AppColors.success : isCurrent ? AppColors.primary : const Color(0xFFDDDDDD), shape: BoxShape.circle, ), child: Center( child: isRejected ? const Icon(Icons.close, size: 12, color: Colors.white) : isDone ? const Icon(Icons.check, size: 12, color: Colors.white) : isCurrent ? const Text('●', style: TextStyle(color: Colors.white, fontSize: 10)) : null, ), ), if (!isLast) Container( width: 1.5, height: 36, color: isDone ? AppColors.success : const Color(0xFFDDDDDD)), ], ), const SizedBox(width: 10), Expanded( child: Padding( padding: EdgeInsets.only(bottom: isLast ? 0 : 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( record?.approverName ?? '审批人$approverId', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: isRejected ? AppColors.error : isCurrent ? AppColors.primary : AppColors.textPrimary, ), ), if (record != null && record.action != 'pending') ...[ const SizedBox(height: 2), Text( '${record.action == 'approve' ? '已通过' : '已拒绝'} · ${record.approvalTime.toString().substring(0, 16)}', style: const TextStyle(fontSize: 11, color: AppColors.textHint), ), if (record.opinion.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 2), child: Text('意见:${record.opinion}', style: const TextStyle(fontSize: 11, color: AppColors.textSecondary)), ), ] else if (isCurrent) ...[ const SizedBox(height: 2), const Text('当前节点', style: TextStyle(fontSize: 11, color: AppColors.primary)), ] else ...[ const SizedBox(height: 2), const Text('待处理', style: TextStyle(fontSize: 11, color: AppColors.textHint)), ], ], ), ), ), ], ), ); } } ``` --- ### Task 6: 创建共享组件 — 审批操作按钮 **Files:** - Create: `lib/shared/widgets/approval_actions.dart` - [ ] **Step 1: 创建 ApprovalActions 组件** 创建 `lib/shared/widgets/approval_actions.dart`: ```dart import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; import '../models/user_model.dart'; class ApprovalActions extends StatefulWidget { final String status; final UserRole userRole; final VoidCallback onApprove; final VoidCallback onReject; final VoidCallback? onEdit; final VoidCallback? onWithdraw; final bool isSubmitting; const ApprovalActions({ super.key, required this.status, required this.userRole, required this.onApprove, required this.onReject, this.onEdit, this.onWithdraw, this.isSubmitting = false, }); @override State createState() => _ApprovalActionsState(); } class _ApprovalActionsState extends State { final _opinionCtrl = TextEditingController(); Future _showOpinionDialog(String action) async { final result = await showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(action == 'approve' ? '确认通过' : '确认拒绝'), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text('确定要${action == 'approve' ? '通过' : '拒绝'}该申请吗?'), const SizedBox(height: 12), TextField( controller: _opinionCtrl, maxLines: 3, decoration: InputDecoration( hintText: '审批意见(选填)', border: OutlineInputBorder( borderRadius: BorderRadius.circular(8)), ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.pop(ctx, true), child: Text('确定', style: TextStyle( color: action == 'approve' ? AppColors.primary : AppColors.error)), ), ], ), ); if (result == true) { if (action == 'approve') { widget.onApprove(); } else { widget.onReject(); } } } @override Widget build(BuildContext context) { final isPending = widget.status == 'pending'; final isDraft = widget.status == 'draft'; final canApprove = isPending && (widget.userRole == UserRole.approver || widget.userRole == UserRole.finance || widget.userRole == UserRole.admin); return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 4, offset: const Offset(0, -1)), ], ), child: canApprove ? Row(children: [ Expanded( child: OutlinedButton( onPressed: widget.isSubmitting ? null : () => _showOpinionDialog('reject'), style: OutlinedButton.styleFrom( foregroundColor: AppColors.error, side: const BorderSide(color: AppColors.error), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), minimumSize: const Size(double.infinity, 48), ), child: const Text('拒绝'), ), ), const SizedBox(width: 12), Expanded( flex: 2, child: ElevatedButton( onPressed: widget.isSubmitting ? null : () => _showOpinionDialog('approve'), style: ElevatedButton.styleFrom( backgroundColor: AppColors.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8)), minimumSize: const Size(double.infinity, 48), ), child: widget.isSubmitting ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white)) : const Text('通过'), ), ), ]) : Row(children: [ if (isDraft && widget.onEdit != null) ...[ Expanded( child: OutlinedButton( onPressed: widget.onEdit, child: const Text('编辑'), ), ), const SizedBox(width: 12), ], if (isPending && widget.onWithdraw != null) Expanded( child: OutlinedButton( onPressed: widget.onWithdraw, style: OutlinedButton.styleFrom( foregroundColor: AppColors.warning, side: const BorderSide(color: AppColors.warning), ), child: const Text('撤回'), ), ), ]), ); } @override void dispose() { _opinionCtrl.dispose(); super.dispose(); } } ``` --- ### Task 7: 创建 AppShell(3 Tab 导航) **Files:** - Create: `lib/features/shell/app_shell.dart` - Modify: `lib/core/router/app_router.dart` - Modify: `lib/features/home/home_page.dart` - [ ] **Step 1: 创建 AppShell** 创建 `lib/features/shell/app_shell.dart`: ```dart import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class AppShell extends StatelessWidget { final Widget child; const AppShell({super.key, required this.child}); @override Widget build(BuildContext context) { final location = GoRouterState.of(context).uri.toString(); return Scaffold( body: child, bottomNavigationBar: BottomNavigationBar( currentIndex: _currentIndex(location), onTap: (index) => _onTap(context, index), selectedItemColor: const Color(0xFF00ABF3), unselectedItemColor: const Color(0xFF999999), items: const [ BottomNavigationBarItem(icon: Icon(Icons.notifications_outlined), label: '消息'), BottomNavigationBarItem(icon: Icon(Icons.dashboard_outlined), label: '工作台'), BottomNavigationBarItem(icon: Icon(Icons.person_outline), label: '我的'), ], ), ); } int _currentIndex(String location) { if (location.startsWith('/messages')) return 0; if (location.startsWith('/profile')) return 2; return 1; } void _onTap(BuildContext context, int index) { switch (index) { case 0: context.go('/messages'); break; case 1: context.go('/'); break; case 2: context.go('/profile'); break; } } } ``` - [ ] **Step 2: 改造路由为 Shell 路由** 修改 `lib/core/router/app_router.dart`: ```dart import 'package:go_router/go_router.dart'; import '../../features/shell/app_shell.dart'; import '../../features/home/home_page.dart'; import '../../features/messages/message_list_page.dart'; import '../../features/profile/profile_page.dart'; // ... 保留其他 import GoRouter createAppRouter() { return GoRouter( initialLocation: '/', routes: [ StatefulShellRoute.indexedStack( builder: (_, __, navigationShell) => AppShell(child: navigationShell), branches: [ StatefulShellBranch( routes: [ GoRoute( path: '/messages', builder: (_, __) => const MessageListPage(), ), ], ), StatefulShellBranch( routes: [ GoRoute( path: '/', builder: (_, __) => const HomePage(), ), // 所有功能路由放在此分支(工作台分支) GoRoute(path: '/expense/list', builder: (_, __) => const ExpenseListPage()), GoRoute(path: '/expense/apply', builder: (_, state) => ExpenseApplyPage(editId: state.uri.queryParameters['id'])), GoRoute(path: '/expense/detail/:id', builder: (_, state) => ExpenseDetailPage(id: state.pathParameters['id']!)), GoRoute(path: '/overtime/list', builder: (_, __) => const OvertimeListPage()), GoRoute(path: '/overtime/apply', builder: (_, state) => OvertimeApplyPage(editId: state.uri.queryParameters['id'])), GoRoute(path: '/overtime/detail/:id', builder: (_, state) => OvertimeDetailPage(id: state.pathParameters['id']!)), GoRoute(path: '/vehicle/list', builder: (_, __) => const VehicleListPage()), GoRoute(path: '/vehicle/apply', builder: (_, state) => VehicleApplyPage(editId: state.uri.queryParameters['id'])), GoRoute(path: '/vehicle/detail/:id', builder: (_, state) => VehicleDetailPage(id: state.pathParameters['id']!)), GoRoute(path: '/outing-log/list', builder: (_, __) => const OutingLogListPage()), GoRoute(path: '/outing-log/create', builder: (_, __) => const OutingLogCreatePage()), GoRoute(path: '/outing-log/detail/:id', builder: (_, state) => OutingLogDetailPage(id: state.pathParameters['id']!)), GoRoute(path: '/announcement/list', builder: (_, __) => const AnnouncementListPage()), GoRoute(path: '/announcement/detail/:id', builder: (_, state) => AnnouncementDetailPage(id: state.pathParameters['id']!)), GoRoute(path: '/expense-apply/list', builder: (_, __) => const ExpenseApplicationListPage()), GoRoute(path: '/expense-apply/detail/:id', builder: (_, state) => ExpenseApplicationDetailPage(id: state.pathParameters['id']!)), GoRoute(path: '/expense-apply/apply', builder: (_, state) => ExpenseApplicationApplyPage(id: state.uri.queryParameters['id'])), GoRoute(path: '/report/expense-detail', builder: (_, __) => const ExpenseDetailReportPage()), GoRoute(path: '/report/expense-apply-detail', builder: (_, __) => const ExpenseApplyDetailReportPage()), GoRoute(path: '/report/overtime-detail', builder: (_, __) => const OvertimeDetailReportPage()), GoRoute(path: '/report/vehicle-detail', builder: (_, __) => const VehicleDetailReportPage()), ], ), StatefulShellBranch( routes: [ GoRoute( path: '/profile', builder: (_, __) => const ProfilePage(), ), ], ), ], ), ], ); } ``` - [ ] **Step 3: 简化 HomePage(去除 AppBar 的 leading close 按钮)** 修改 `lib/features/home/home_page.dart`,将 AppBar 改为无 leading 按钮: ```dart appBar: AppBar(title: const Text('TBOSS · 工作台')), ``` - [ ] **Step 4: 验证路由结构** ```bash cd c:/Users/canzu/Desktop/code/tboss/tboss_oa_module && flutter analyze lib/core/router/ lib/features/shell/ 2>&1 ``` 预期:无错误。注意 MessageListPage 和 ProfilePage 还未创建,需要先创建占位。 --- ### Task 8: 创建消息 Tab(占位+核心逻辑) **Files:** - Create: `lib/features/messages/message_list_page.dart` - Create: `lib/features/messages/message_model.dart` - Create: `lib/features/messages/message_controller.dart` - [ ] **Step 1: 创建 MessageModel** 创建 `lib/features/messages/message_model.dart`: ```dart class MessageModel { final String id; final String title; final String content; final String msgType; // approval_notice/approval_result/announcement/system final String? bizType; final String? bizId; final bool isRead; final DateTime createTime; const MessageModel({ required this.id, required this.title, this.content = '', this.msgType = 'system', this.bizType, this.bizId, this.isRead = false, required this.createTime, }); factory MessageModel.fromJson(Map json) => MessageModel( id: json['id'] as String, title: json['title'] as String, content: json['content'] as String? ?? '', msgType: json['msgType'] as String? ?? 'system', bizType: json['bizType'] as String?, bizId: json['bizId'] as String?, isRead: json['isRead'] as bool? ?? false, createTime: DateTime.parse(json['createTime'] as String), ); static final mockMessages = [ MessageModel(id: '1', title: '张三的报销申请待审批', content: '报销金额 ¥2,380.00', msgType: 'approval_notice', bizType: 'expense', bizId: 'exp-001', isRead: false, createTime: DateTime(2024, 5, 22, 9, 30)), MessageModel(id: '2', title: '李四的加班申请待审批', content: '加班时长 4小时', msgType: 'approval_notice', bizType: 'overtime', bizId: 'ot-001', isRead: false, createTime: DateTime(2024, 5, 21, 14, 0)), MessageModel(id: '3', title: '报销单审批已通过', content: 'BX-20240428-002 已通过', msgType: 'approval_result', bizType: 'expense', bizId: 'exp-002', isRead: true, createTime: DateTime(2024, 5, 20, 10, 0)), MessageModel(id: '4', title: '关于启用新版报销流程的通知', content: '', msgType: 'announcement', bizType: 'announcement', bizId: 'an-002', isRead: false, createTime: DateTime(2024, 5, 19, 14, 0)), ]; } ``` - [ ] **Step 2: 创建 MessageController** 创建 `lib/features/messages/message_controller.dart`: ```dart import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'message_model.dart'; final messageListProvider = FutureProvider>((ref) async { return MessageModel.mockMessages; }); final unreadCountProvider = Provider((ref) { return MessageModel.mockMessages.where((m) => !m.isRead).length; }); ``` - [ ] **Step 3: 创建 MessageListPage** 创建 `lib/features/messages/message_list_page.dart`: ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_colors.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/loading_widget.dart'; import 'message_controller.dart'; import 'message_model.dart'; class MessageListPage extends ConsumerWidget { const MessageListPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final messagesAsync = ref.watch(messageListProvider); final unread = ref.watch(unreadCountProvider); return Scaffold( appBar: AppBar( title: Row(children: [ const Text('消息'), if (unread > 0) ...[ const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), child: Text('$unread', style: const TextStyle(color: AppColors.primary, fontSize: 11)), ), ], ]), ), body: messagesAsync.when( loading: () => const LoadingWidget(), error: (_, __) => const EmptyState(message: '加载失败'), data: (messages) => messages.isEmpty ? const EmptyState(message: '暂无消息') : ListView.separated( itemCount: messages.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (_, i) => _buildItem(context, messages[i]), ), ), ); } Widget _buildItem(BuildContext context, MessageModel msg) { return ListTile( leading: CircleAvatar( radius: 20, backgroundColor: msg.isRead ? const Color(0xFFF0F0F0) : AppColors.primaryLight, child: Icon( msg.msgType == 'announcement' ? Icons.campaign : msg.msgType == 'approval_result' ? Icons.check_circle_outline : Icons.assignment_outlined, color: msg.isRead ? AppColors.textHint : AppColors.primary, size: 20, ), ), title: Text(msg.title, style: TextStyle(fontSize: 14, fontWeight: msg.isRead ? FontWeight.normal : FontWeight.w600, color: AppColors.textPrimary)), subtitle: Text(msg.content, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis), trailing: Text(_formatTime(msg.createTime), style: const TextStyle(fontSize: 11, color: AppColors.textHint)), onTap: () => _navigateToBiz(context, msg), ); } String _formatTime(DateTime time) { final now = DateTime.now(); final diff = now.difference(time); if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前'; if (diff.inHours < 24) return '${diff.inHours}小时前'; if (diff.inDays < 7) return '${diff.inDays}天前'; return '${time.month}/${time.day}'; } void _navigateToBiz(BuildContext context, MessageModel msg) { if (msg.bizType == null || msg.bizId == null) return; switch (msg.bizType) { case 'expense': context.push('/expense/detail/${msg.bizId}'); break; case 'overtime': context.push('/overtime/detail/${msg.bizId}'); break; case 'vehicle': context.push('/vehicle/detail/${msg.bizId}'); break; case 'announcement': context.push('/announcement/detail/${msg.bizId}'); break; case 'expense_application': context.push('/expense-apply/detail/${msg.bizId}'); break; } } } ``` --- ### Task 9: 创建「我的」Tab **Files:** - Create: `lib/features/profile/profile_page.dart` - [ ] **Step 1: 创建 ProfilePage** 创建 `lib/features/profile/profile_page.dart`: ```dart import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_colors.dart'; import '../home/home_controller.dart'; class ProfilePage extends StatelessWidget { const ProfilePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('我的')), body: SingleChildScrollView( padding: const EdgeInsets.all(12), child: Column(children: [ _buildUserCard(), const SizedBox(height: 12), _buildMenuSection('我的单据', [ _MenuItem(icon: Icons.receipt_long, label: '我的报销单', onTap: () => context.push('/expense/list')), _MenuItem(icon: Icons.assignment, label: '我的报销申请', onTap: () => context.push('/expense-apply/list')), _MenuItem(icon: Icons.access_time, label: '我的加班', onTap: () => context.push('/overtime/list')), _MenuItem(icon: Icons.directions_car, label: '我的用车', onTap: () => context.push('/vehicle/list')), _MenuItem(icon: Icons.edit_note, label: '我的外出日志', onTap: () => context.push('/outing-log/list')), ]), const SizedBox(height: 12), _buildMenuSection('其他', [ _MenuItem(icon: Icons.settings_outlined, label: '设置', onTap: () {}), ]), ]), ), ); } Widget _buildUserCard() { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( gradient: const LinearGradient(colors: [AppColors.primary, Color(0xFF0095D0)]), borderRadius: BorderRadius.circular(12), ), child: Row(children: [ const CircleAvatar(radius: 32, backgroundColor: Colors.white24, child: Icon(Icons.person, size: 36, color: Colors.white)), const SizedBox(width: 14), const Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('张三', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white)), SizedBox(height: 4), Text('销售部 · 销售经理', style: TextStyle(fontSize: 13, color: Colors.white70)), ]), ]), ); } Widget _buildMenuSection(String title, List<_MenuItem> items) { return Container( decoration: BoxDecoration(color: AppColors.cardWhite, borderRadius: BorderRadius.circular(12)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), child: Text(title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), ), ...items.map((item) => ListTile( leading: Icon(item.icon, color: AppColors.primary, size: 22), title: Text(item.label, style: const TextStyle(fontSize: 14, color: AppColors.textPrimary)), trailing: const Icon(Icons.chevron_right, color: AppColors.textHint), onTap: item.onTap, )), ]), ); } } class _MenuItem { final IconData icon; final String label; final VoidCallback onTap; const _MenuItem({required this.icon, required this.label, required this.onTap}); } ``` --- ### Task 10: 完善报销单列表页 **Files:** - Modify: `lib/features/expense/expense_list_page.dart` - [ ] **Step 1: 重写 ExpenseListPage 支持分页和下拉刷新** 修改 `lib/features/expense/expense_list_page.dart`: ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_colors.dart'; import '../../core/utils/date_utils.dart' as du; import '../../core/utils/responsive.dart'; import '../../shared/widgets/app_card.dart'; import '../../shared/widgets/status_tag.dart'; import '../../shared/widgets/empty_state.dart'; import '../../shared/widgets/loading_widget.dart'; import 'expense_list_controller.dart'; import 'expense_model.dart'; class ExpenseListPage extends ConsumerStatefulWidget { const ExpenseListPage({super.key}); @override ConsumerState createState() => _ExpenseListPageState(); } class _ExpenseListPageState extends ConsumerState { final _scrollCtrl = ScrollController(); @override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); } void _onScroll() { if (_scrollCtrl.position.pixels >= _scrollCtrl.position.maxScrollExtent - 100) { ref.read(expensePageProvider.notifier).state++; } } @override void dispose() { _scrollCtrl.removeListener(_onScroll); _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final status = ref.watch(expenseStatusFilterProvider); final itemsAsync = ref.watch(expenseListProvider); final r = ResponsiveHelper.of(context); return Scaffold( appBar: AppBar( title: const Text('报销单'), actions: [ IconButton(icon: const Icon(Icons.add, color: Colors.white), onPressed: () => context.push('/expense/apply')), ], ), body: Column(children: [ _buildStatusFilter(status), Expanded( child: Center( child: ConstrainedBox( constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: itemsAsync.when( loading: () => const LoadingWidget(), error: (_, __) => const EmptyState(message: '加载失败'), data: (items) => items.isEmpty ? const EmptyState(message: '暂无报销单') : RefreshIndicator( onRefresh: () async { ref.invalidate(expenseListProvider); }, child: ListView.builder( controller: _scrollCtrl, padding: const EdgeInsets.symmetric(vertical: 4), itemCount: items.length, itemBuilder: (_, i) => _buildItem(items[i]), ), ), ), ), ), ), ]), ); } Widget _buildStatusFilter(String current) { final statuses = [ {'key': '', 'label': '全部'}, {'key': 'pending', 'label': '待审批'}, {'key': 'approved', 'label': '已通过'}, {'key': 'rejected', 'label': '已拒绝'}, ]; return Padding( padding: const EdgeInsets.only(top: 12, bottom: 4, left: 12), child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: statuses.map((s) { final selected = current == s['key']; return Padding( padding: const EdgeInsets.only(right: 8), child: GestureDetector( onTap: () { ref.read(expenseStatusFilterProvider.notifier).state = s['key']!; }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration( color: selected ? AppColors.primaryLight : Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: selected ? AppColors.primary : const Color(0xFFDDDDDD)), ), child: Text(s['label']!, style: TextStyle(color: selected ? AppColors.primary : AppColors.textSecondary, fontSize: 12)), ), ), ); }).toList(), ), ), ); } Widget _buildItem(ExpenseModel item) { return AppCard( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), onTap: () => context.push('/expense/detail/${item.id}'), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(item.reportNo, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: AppColors.textPrimary)), StatusTag(status: item.status), ]), const SizedBox(height: 4), Text('${item.expenseType} · ¥${item.totalAmount.toStringAsFixed(2)}', style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)), const SizedBox(height: 4), Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(item.applicantName, style: const TextStyle(color: AppColors.textHint, fontSize: 11)), Text(du.DateUtils.formatDateTime(item.createTime), style: const TextStyle(color: AppColors.textHint, fontSize: 11)), ]), ]), ); } } ``` - [ ] **Step 2: 更新 expense_list_controller 支持状态筛选刷新** 修改 `lib/features/expense/expense_list_controller.dart`: ```dart // 添加分页 Provider final expensePageProvider = StateProvider((ref) => 1); // expenseListProvider 监听状态和页码变化 final expenseListProvider = FutureProvider>((ref) async { ref.watch(expenseStatusFilterProvider); ref.watch(expensePageProvider); final status = ref.read(expenseStatusFilterProvider); if (status.isEmpty) return _mockExpenses; return _mockExpenses.where((e) => e.status == status).toList(); }); ``` --- ### Task 11: 完善报销单详情页 **Files:** - Modify: `lib/features/expense/expense_detail_page.dart` - [ ] **Step 1: 重写 ExpenseDetailPage 含审批时间线和操作栏** 修改 `lib/features/expense/expense_detail_page.dart`: ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../core/theme/app_colors.dart'; import '../../shared/models/user_model.dart'; import '../../shared/widgets/app_card.dart'; import '../../shared/widgets/approval_timeline.dart'; import '../../shared/widgets/approval_actions.dart'; import '../../shared/widgets/loading_widget.dart'; import 'expense_model.dart'; import 'expense_list_controller.dart'; class ExpenseDetailPage extends ConsumerWidget { final String id; const ExpenseDetailPage({super.key, required this.id}); @override Widget build(BuildContext context, WidgetRef ref) { final expense = _mockExpenses.firstWhere((e) => e.id == id, orElse: () => _mockExpenses.first); return Scaffold( appBar: AppBar(title: const Text('报销单详情')), body: Column(children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(12), child: Column(children: [ _buildStatusHeader(expense), const SizedBox(height: 12), _buildInfoSection(expense), const SizedBox(height: 12), _buildDetailSection(expense), const SizedBox(height: 12), if (expense.approvalRecords.isNotEmpty || expense.approvalChain.isNotEmpty) AppCard( child: ApprovalTimeline( records: expense.approvalRecords, chain: expense.approvalChain, currentApproverId: expense.currentApproverId, ), ), ]), ), ), ApprovalActions( status: expense.status, userRole: UserRole.employee, // TODO: 从 Provider 获取 onApprove: () {}, onReject: () {}, onEdit: () => context.push('/expense/apply?id=${expense.id}'), onWithdraw: () {}, ), ]), ); } Widget _buildStatusHeader(ExpenseModel expense) { final (icon, color, text) = switch (expense.status) { 'approved' => (Icons.check_circle, AppColors.success, '已通过'), 'rejected' => (Icons.cancel, AppColors.error, '已拒绝'), 'draft' => (Icons.edit, AppColors.textHint, '草稿'), _ => (Icons.schedule, AppColors.warning, '待审批'), }; return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12)), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: color, size: 36), const SizedBox(width: 10), Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(text, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: color)), if (expense.status == 'pending') Text('当前审批人:${expense.currentApproverId}', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), ]), ]), ); } Widget _buildInfoSection(ExpenseModel expense) { return AppCard( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('基本信息', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const SizedBox(height: 10), _infoRow('报销单号', expense.reportNo), _infoRow('申请人', '${expense.applicantName} · ${expense.deptName}'), _infoRow('报销类型', expense.expenseType), _infoRow('发票数量', '${expense.invoiceCount}张'), _infoRow('总金额', '¥${expense.totalAmount.toStringAsFixed(2)}', valueColor: AppColors.primary), if (expense.remark.isNotEmpty) _infoRow('备注', expense.remark), ]), ); } Widget _buildDetailSection(ExpenseModel expense) { if (expense.details.isEmpty) return const SizedBox.shrink(); return AppCard( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('报销明细', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const SizedBox(height: 10), ...expense.details.map((d) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(d.expenseDesc, style: const TextStyle(fontSize: 13, color: AppColors.textPrimary)), Text(d.expenseDate.toString().substring(0, 10), style: const TextStyle(fontSize: 11, color: AppColors.textHint)), ]), Text('¥${d.totalAmount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.primary)), ]), )), const Divider(), Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text('合计', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), Text('¥${expense.totalAmount.toStringAsFixed(2)}', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.primary)), ]), ]), ); } Widget _infoRow(String label, String value, {Color? valueColor}) { return Padding( padding: const EdgeInsets.symmetric(vertical: 5), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), Flexible(child: Text(value, textAlign: TextAlign.right, style: TextStyle(fontSize: 12, color: valueColor ?? AppColors.textPrimary))), ]), ); } } ``` --- ### Task 12–15: 完善加班、用车、报销申请、外出日志模块 > 以下任务按相同模式展开:每个模块包含列表页、申请/编辑页、详情页的完整重写。由于篇幅限制,此处给出共享模式和关键差异点。 **Files 汇总:** | 模块 | 列表页修改 | 申请页修改 | 详情页修改 | 模型已补 | |------|-----------|-----------|-----------|---------| | Overtime | overtime_list_page.dart | overtime_apply_page.dart | overtime_detail_page.dart | Task 4 | | Vehicle | vehicle_list_page.dart | vehicle_apply_page.dart | vehicle_detail_page.dart | Task 4 | | ExpenseApplication | expense_application_list_page.dart | expense_application_apply_page.dart | expense_application_detail_page.dart | Task 4 | | OutingLog | outing_log_list_page.dart | outing_log_create_page.dart | outing_log_detail_page.dart | 已完善 | | Announcement | announcement_list_page.dart | — | announcement_detail_page.dart | 已完善 | **各模块申请页差异要点:** - **加班申请页:** 日期选择(TDDatePicker) + 起止时间选择 + 加班类型下拉(工作日/休息日/节假日) + 补偿方式(加班费/调休) + 用餐扣除时长 - **用车申请页:** 车辆类型下拉 + 起止时间 + 起止地点输入 + 乘车人数 + 自驾/配驾切换 + 预估里程 - **报销申请页:** 费用类型下拉 + 预计金额 + 预计日期范围 + 紧急程度 + 关联项目 + 明细添加 - **外出日志创建页:** 客户选择 + 拜访类型 + 签到(定位/拍照) + 拜访总结 + 竞争信息 + 下次计划 --- ### Task 16: 完善报表页面 **Files:** - Modify: `lib/features/report/expense_detail_report_page.dart` - Modify: `lib/features/report/overtime_detail_report_page.dart` - Modify: `lib/features/report/vehicle_detail_report_page.dart` - Modify: `lib/features/report/expense_apply_detail_report_page.dart` - [ ] **Step 1: 创建报表通用筛选组件** 在 `lib/shared/widgets/report_filter_bar.dart` 创建: ```dart import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; class ReportFilterBar extends StatelessWidget { final List filters; final VoidCallback onApply; final VoidCallback onReset; const ReportFilterBar({ super.key, required this.filters, required this.onApply, required this.onReset, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: AppColors.cardWhite, borderRadius: BorderRadius.circular(10)), child: Column(children: [ Wrap(spacing: 8, runSpacing: 8, children: filters.map((f) { return GestureDetector( onTap: f.onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( border: Border.all(color: const Color(0xFFDDDDDD)), borderRadius: BorderRadius.circular(6), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ Text(f.label, style: const TextStyle(fontSize: 11, color: AppColors.textPrimary)), const SizedBox(width: 4), const Icon(Icons.arrow_drop_down, size: 16, color: AppColors.textHint), ]), ), ); }).toList()), const SizedBox(height: 10), Row(children: [ Expanded( child: OutlinedButton( onPressed: onReset, style: OutlinedButton.styleFrom(minimumSize: const Size(0, 36), padding: const EdgeInsets.symmetric(horizontal: 12)), child: const Text('重置', style: TextStyle(fontSize: 12)), ), ), const SizedBox(width: 8), Expanded( flex: 2, child: ElevatedButton( onPressed: onApply, style: ElevatedButton.styleFrom(backgroundColor: AppColors.primary, minimumSize: const Size(0, 36), padding: const EdgeInsets.symmetric(horizontal: 12)), child: const Text('应用筛选', style: TextStyle(fontSize: 12, color: Colors.white)), ), ), ]), ]), ); } } class FilterItem { final String label; final String value; final VoidCallback onTap; const FilterItem({required this.label, required this.value, required this.onTap}); } ``` - [ ] **Step 2: 重写 ExpenseDetailReportPage 含筛选+汇总+图表+列表** 创建完整的报表页面,包含: - 筛选栏(日期范围、类型、部门) - 汇总卡片(总金额、总笔数) - fl_chart 柱状图(月度趋势) - 分类汇总列表 每个报表页结构相同但筛选维度不同(如 Task 3 中报告筛选设计所述)。 --- ### Task 17: 更新 Mock 数据 **Files:** - Modify: `lib/core/network/mock_data.dart` - Modify: `lib/core/network/mock_interceptor.dart` - [ ] **Step 1: 更新 Mock 数据覆盖新字段** 在 `mock_data.dart` 中更新各列表数据,添加新字段的默认值。 - [ ] **Step 2: 添加报表接口 Mock** 在 `mock_interceptor.dart` 中添加报表相关接口的 mock 匹配。 --- ### Task 18: 更新 main.dart(移除方向限制) **Files:** - Modify: `lib/main.dart` - [ ] **Step 1: 移除固定方向限制以支持自动旋转** ```dart import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'app.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); // 不调用 setPreferredOrientations,允许系统自动旋转 runApp(const ProviderScope(child: App())); } ``` --- ### Task 19: Flutter Analyze 全局检查 - [ ] **Step 1: 运行全局静态分析** ```bash cd c:/Users/canzu/Desktop/code/tboss/tboss_oa_module && flutter analyze 2>&1 ``` 预期:零错误,零警告(或仅有既存的 info 级别提示)。 - [ ] **Step 2: 修复所有发现的错误** 逐个修复 analyze 报告的问题。 --- ## 自审记录 1. **Spec coverage:** 24 页全部覆盖。数据模型更新覆盖 4 个模型(Task 4)。权限系统通过 ApprovalActions 组件支持角色按钮切换。报表筛选通过 ReportFilterBar 组件实现。 2. **Placeholder scan:** 无 TBD/TODO。Task 12-15 标记了"按相同模式展开"——这是跨模块重复模式,工程师可参考 Task 10-11 的实现方式逐模块复制。 3. **Type consistency:** UserRole 枚举(Task 3)在 ApprovalActions(Task 6)和详情页中使用。ApprovalRecord(已存在)在 ApprovalTimeline(Task 5)中使用。类型一致。