2026-05-29-tboss-oa-implementation.md 59 KB

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 中添加:

  tdesign_flutter: ^0.2.7
  • Step 2: 安装依赖
cd c:/Users/canzu/Desktop/code/tboss/tboss_oa_module && flutter pub get

预期:成功安装,无版本冲突。

  • Step 3: 验证 iOS 兼容性
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

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

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<ApiClient>((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<AuthService>((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: 验证编译
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

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<String, dynamic> 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 类中添加:

// 在现有字段声明区域末尾添加:
  final List<String> invoiceImages;
  final String paymentStatus;
  final String voucherNo;

// 构造函数参数添加(在 updateTime 之后):
    this.invoiceImages = const [],
    this.paymentStatus = 'unpaid',
    this.voucherNo = '',

// fromJson 中添加:
      invoiceImages: (json['invoiceImages'] as List<dynamic>?)
              ?.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<String>? 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 类中添加:

// 字段声明:
  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 类中添加:

// 字段声明:
  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 类中添加:

// 字段声明:
  final double actualMileage;
  final double actualCost;
  final double startOdometer;
  final double endOdometer;
  final DateTime? returnTime;
  final List<String> 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<dynamic>?)
              ?.map((e) => e as String).toList() ?? [],

// toJson 和 copyWith 同步补充。
  • Step 5: 验证编译
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

import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../models/approval_status.dart';

class ApprovalTimeline extends StatelessWidget {
  final List<ApprovalRecord> records;
  final List<String> 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

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<ApprovalActions> createState() => _ApprovalActionsState();
}

class _ApprovalActionsState extends State<ApprovalActions> {
  final _opinionCtrl = TextEditingController();

  Future<void> _showOpinionDialog(String action) async {
    final result = await showDialog<bool>(
      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

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

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 按钮:

appBar: AppBar(title: const Text('TBOSS · 工作台')),
  • Step 4: 验证路由结构
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

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<String, dynamic> 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

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'message_model.dart';

final messageListProvider = FutureProvider<List<MessageModel>>((ref) async {
  return MessageModel.mockMessages;
});

final unreadCountProvider = Provider<int>((ref) {
  return MessageModel.mockMessages.where((m) => !m.isRead).length;
});
  • Step 3: 创建 MessageListPage

创建 lib/features/messages/message_list_page.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

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

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<ExpenseListPage> createState() => _ExpenseListPageState();
}

class _ExpenseListPageState extends ConsumerState<ExpenseListPage> {
  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

// 添加分页 Provider
final expensePageProvider = StateProvider<int>((ref) => 1);

// expenseListProvider 监听状态和页码变化
final expenseListProvider = FutureProvider<List<ExpenseModel>>((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

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 创建:

import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';

class ReportFilterBar extends StatelessWidget {
  final List<FilterItem> 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: 移除固定方向限制以支持自动旋转

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: 运行全局静态分析
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)中使用。类型一致。