Bläddra i källkod

feat: complete OA module implementation

- Material 3 theme with ColorScheme.fromSeed(#00abf3)
- 15 routes via GoRouter covering home, expense, overtime, vehicle, outing_log, announcement
- Mock interceptor with 31 test data records for offline UI debugging
- Responsive layout helper (landscape/portrait auto-adaptation)
- Comprehensive data models referencing DingTalk/Feishu/Kingdee/Seeyon standards
- All pure Dart dependencies, zero native code for iOS OC compatibility
- flutter analyze: 0 errors, 0 warnings
chengc 6 dagar sedan
förälder
incheckning
0bc2d37766
72 ändrade filer med 5130 tillägg och 199 borttagningar
  1. 34 0
      lib/app.dart
  2. 0 0
      lib/core/auth/.gitkeep
  3. 20 0
      lib/core/auth/auth_service.dart
  4. 0 0
      lib/core/network/.gitkeep
  5. 97 0
      lib/core/network/api_client.dart
  6. 17 0
      lib/core/network/api_exception.dart
  7. 22 0
      lib/core/network/api_response.dart
  8. 296 0
      lib/core/network/mock_data.dart
  9. 119 0
      lib/core/network/mock_interceptor.dart
  10. 0 0
      lib/core/router/.gitkeep
  11. 94 0
      lib/core/router/app_router.dart
  12. 0 0
      lib/core/theme/.gitkeep
  13. 19 0
      lib/core/theme/app_colors.dart
  14. 53 0
      lib/core/theme/app_theme.dart
  15. 0 0
      lib/core/utils/.gitkeep
  16. 21 0
      lib/core/utils/date_utils.dart
  17. 41 0
      lib/core/utils/responsive.dart
  18. 21 0
      lib/core/utils/validators.dart
  19. 0 0
      lib/features/announcement/.gitkeep
  20. 40 0
      lib/features/announcement/announcement_api.dart
  21. 56 0
      lib/features/announcement/announcement_detail_page.dart
  22. 11 0
      lib/features/announcement/announcement_list_controller.dart
  23. 85 0
      lib/features/announcement/announcement_list_page.dart
  24. 76 0
      lib/features/announcement/announcement_model.dart
  25. 0 0
      lib/features/expense/.gitkeep
  26. 49 0
      lib/features/expense/expense_api.dart
  27. 95 0
      lib/features/expense/expense_apply_controller.dart
  28. 304 0
      lib/features/expense/expense_apply_page.dart
  29. 151 0
      lib/features/expense/expense_detail_page.dart
  30. 14 0
      lib/features/expense/expense_list_controller.dart
  31. 179 0
      lib/features/expense/expense_list_page.dart
  32. 267 0
      lib/features/expense/expense_model.dart
  33. 0 0
      lib/features/home/.gitkeep
  34. 42 0
      lib/features/home/home_controller.dart
  35. 213 0
      lib/features/home/home_page.dart
  36. 0 0
      lib/features/outing_log/.gitkeep
  37. 44 0
      lib/features/outing_log/outing_log_api.dart
  38. 105 0
      lib/features/outing_log/outing_log_create_page.dart
  39. 64 0
      lib/features/outing_log/outing_log_detail_page.dart
  40. 11 0
      lib/features/outing_log/outing_log_list_controller.dart
  41. 85 0
      lib/features/outing_log/outing_log_list_page.dart
  42. 173 0
      lib/features/outing_log/outing_log_model.dart
  43. 0 0
      lib/features/overtime/.gitkeep
  44. 49 0
      lib/features/overtime/overtime_api.dart
  45. 49 0
      lib/features/overtime/overtime_apply_controller.dart
  46. 158 0
      lib/features/overtime/overtime_apply_page.dart
  47. 65 0
      lib/features/overtime/overtime_detail_page.dart
  48. 14 0
      lib/features/overtime/overtime_list_controller.dart
  49. 175 0
      lib/features/overtime/overtime_list_page.dart
  50. 155 0
      lib/features/overtime/overtime_model.dart
  51. 0 0
      lib/features/vehicle/.gitkeep
  52. 49 0
      lib/features/vehicle/vehicle_api.dart
  53. 49 0
      lib/features/vehicle/vehicle_apply_controller.dart
  54. 128 0
      lib/features/vehicle/vehicle_apply_page.dart
  55. 67 0
      lib/features/vehicle/vehicle_detail_page.dart
  56. 14 0
      lib/features/vehicle/vehicle_list_controller.dart
  57. 147 0
      lib/features/vehicle/vehicle_list_page.dart
  58. 174 0
      lib/features/vehicle/vehicle_model.dart
  59. 13 108
      lib/main.dart
  60. 0 0
      lib/shared/models/.gitkeep
  61. 72 0
      lib/shared/models/approval_status.dart
  62. 12 0
      lib/shared/models/pagination_model.dart
  63. 0 0
      lib/shared/widgets/.gitkeep
  64. 41 0
      lib/shared/widgets/app_card.dart
  65. 36 0
      lib/shared/widgets/empty_state.dart
  66. 68 0
      lib/shared/widgets/form_field_row.dart
  67. 57 0
      lib/shared/widgets/form_section.dart
  68. 26 0
      lib/shared/widgets/loading_widget.dart
  69. 51 0
      lib/shared/widgets/status_tag.dart
  70. 526 1
      pubspec.lock
  71. 13 65
      pubspec.yaml
  72. 4 25
      test/widget_test.dart

+ 34 - 0
lib/app.dart

@@ -0,0 +1,34 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.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) {
+    final router = createAppRouter();
+    return MaterialApp.router(
+      title: 'TBOSS OA',
+      theme: AppTheme.light,
+      routerConfig: router,
+      debugShowCheckedModeBanner: false,
+    );
+  }
+}

+ 0 - 0
lib/core/auth/.gitkeep


+ 20 - 0
lib/core/auth/auth_service.dart

@@ -0,0 +1,20 @@
+import 'package:flutter/services.dart';
+
+class AuthService {
+  static const _channel = MethodChannel('com.amtxts.tboss_oa_module/auth');
+
+  String? _token;
+
+  String? get token => _token;
+
+  Future<String?> fetchToken() async {
+    try {
+      _token = await _channel.invokeMethod<String>('getToken');
+      return _token;
+    } on MissingPluginException {
+      return _token = 'mock_token_dev';
+    } catch (_) {
+      return null;
+    }
+  }
+}

+ 0 - 0
lib/core/network/.gitkeep


+ 97 - 0
lib/core/network/api_client.dart

@@ -0,0 +1,97 @@
+import 'package:dio/dio.dart';
+import 'api_exception.dart';
+import 'api_response.dart';
+import 'mock_interceptor.dart';
+
+class ApiClient {
+  late final Dio _dio;
+  String? _token;
+  final String baseUrl;
+  final bool useMock;
+
+  ApiClient({required this.baseUrl, this.useMock = false}) {
+    _dio = Dio(BaseOptions(
+      baseUrl: baseUrl,
+      connectTimeout: const Duration(seconds: 15),
+      receiveTimeout: const Duration(seconds: 15),
+      headers: {'Content-Type': 'application/json'},
+    ));
+
+    _dio.interceptors.add(MockInterceptor(enabled: useMock));
+
+    _dio.interceptors.add(InterceptorsWrapper(
+      onRequest: (options, handler) {
+        if (_token != null) {
+          options.headers['Authorization'] = 'Bearer $_token';
+        }
+        handler.next(options);
+      },
+      onResponse: (response, handler) {
+        final body = response.data;
+        if (body is Map<String, dynamic>) {
+          final code = body['code'] as int? ?? -1;
+          if (code != 0) {
+            handler.reject(DioException(
+              requestOptions: response.requestOptions,
+              response: response,
+              error: ApiException(
+                code: code,
+                message: body['message'] as String? ?? '未知错误',
+              ),
+            ));
+            return;
+          }
+        }
+        handler.next(response);
+      },
+      onError: (error, handler) {
+        if (error.type == DioExceptionType.connectionTimeout ||
+            error.type == DioExceptionType.receiveTimeout) {
+          handler.next(DioException(
+            requestOptions: error.requestOptions,
+            error: const NetworkException('网络连接超时'),
+          ));
+          return;
+        }
+        handler.next(error);
+      },
+    ));
+  }
+
+  void setToken(String? token) => _token = token;
+
+  Future<ApiResponse<T>> get<T>(
+    String path, {
+    Map<String, dynamic>? queryParameters,
+    T Function(dynamic json)? fromJsonT,
+  }) async {
+    final response = await _dio.get(path, queryParameters: queryParameters);
+    return ApiResponse.fromJson(response.data, fromJsonT);
+  }
+
+  Future<ApiResponse<T>> post<T>(
+    String path, {
+    dynamic data,
+    T Function(dynamic json)? fromJsonT,
+  }) async {
+    final response = await _dio.post(path, data: data);
+    return ApiResponse.fromJson(response.data, fromJsonT);
+  }
+
+  Future<ApiResponse<T>> put<T>(
+    String path, {
+    dynamic data,
+    T Function(dynamic json)? fromJsonT,
+  }) async {
+    final response = await _dio.put(path, data: data);
+    return ApiResponse.fromJson(response.data, fromJsonT);
+  }
+
+  Future<ApiResponse<T>> delete<T>(
+    String path, {
+    T Function(dynamic json)? fromJsonT,
+  }) async {
+    final response = await _dio.delete(path);
+    return ApiResponse.fromJson(response.data, fromJsonT);
+  }
+}

+ 17 - 0
lib/core/network/api_exception.dart

@@ -0,0 +1,17 @@
+class ApiException implements Exception {
+  final int code;
+  final String message;
+
+  const ApiException({required this.code, required this.message});
+
+  @override
+  String toString() => 'ApiException($code): $message';
+}
+
+class NetworkException implements Exception {
+  final String message;
+  const NetworkException(this.message);
+
+  @override
+  String toString() => 'NetworkException: $message';
+}

+ 22 - 0
lib/core/network/api_response.dart

@@ -0,0 +1,22 @@
+class ApiResponse<T> {
+  final int code;
+  final String message;
+  final T? data;
+
+  const ApiResponse({required this.code, required this.message, this.data});
+
+  bool get isSuccess => code == 0;
+
+  factory ApiResponse.fromJson(
+    Map<String, dynamic> json,
+    T Function(dynamic json)? fromJsonT,
+  ) {
+    return ApiResponse(
+      code: json['code'] as int,
+      message: json['message'] as String,
+      data: json['data'] != null && fromJsonT != null
+          ? fromJsonT(json['data'])
+          : null,
+    );
+  }
+}

+ 296 - 0
lib/core/network/mock_data.dart

@@ -0,0 +1,296 @@
+class MockData {
+  MockData._();
+
+  static Map<String, dynamic> get success =>
+      {'code': 0, 'message': 'ok', 'data': null};
+
+  static Map<String, dynamic> get homeSummary => {
+        'code': 0,
+        'message': 'ok',
+        'data': {
+          'expensePending': 3,
+          'overtimePending': 1,
+          'vehiclePending': 2,
+          'logCount': 12,
+          'announcementCount': 5,
+          'announcementUnread': 2,
+          'totalCount': 28,
+        },
+      };
+
+  // ==================== 报销 ====================
+
+  static List<Map<String, dynamic>> get expenseList => [
+        _expense('EXP001', 'BX-20240501-001', '张三', '销售部', '差旅费', 2380.0,
+            'pending', 'U100', ['U100', 'U200'], '上海出差拜访客户', '2024-05-01T14:30:00'),
+        _expense('EXP002', 'BX-20240428-002', '张三', '销售部', '办公用品', 156.0,
+            'approved', '', ['U100', 'U200'], '购买办公文具', '2024-04-28T09:15:00'),
+        _expense('EXP003', 'BX-20240420-003', '李四', '技术部', '招待费', 890.0,
+            'rejected', '', ['U100'], '客户接待用餐', '2024-04-20T16:45:00'),
+      ];
+
+  static Map<String, dynamic> expenseDetail(String id) => {
+        'code': 0,
+        'message': 'ok',
+        'data': {
+          'id': id,
+          'reportNo': 'BX-20240501-001',
+          'applicantId': 'U001',
+          'applicantName': '张三',
+          'deptId': 'D001',
+          'deptName': '销售部',
+          'expenseType': '差旅费',
+          'totalAmount': 2380.00,
+          'invoiceCount': 3,
+          'costCenterId': 'CC001',
+          'projectId': 'P001',
+          'projectName': '华东市场拓展',
+          'budgetSubjectId': 'BS001',
+          'loanWriteoffAmount': 0.0,
+          'paymentMethod': '银行转账',
+          'accountId': 'A001',
+          'accountName': '张三-招商银行',
+          'remark': '上海出差拜访客户',
+          'status': 'pending',
+          'currentApproverId': 'U100',
+          'approvalChain': ['U100', 'U200'],
+          'createTime': '2024-05-01T14:30:00',
+          'updateTime': '2024-05-01T14:30:00',
+          'details': [
+            {
+              'id': 'ED001', 'expenseId': id, 'expenseDate': '2024-05-01',
+              'expenseType': '交通费', 'expenseDesc': '上海→北京机票',
+              'amount': 1200.0, 'taxAmount': 108.0, 'totalAmount': 1308.0,
+              'invoiceNo': 'INV20240501001', 'invoiceCode': '3100234567',
+              'invoiceType': '增值税专用发票', 'isDeductible': true, 'taxRate': 0.09,
+              'remark': '', 'attachments': [], 'sortOrder': 1,
+            },
+            {
+              'id': 'ED002', 'expenseId': id, 'expenseDate': '2024-05-01',
+              'expenseType': '住宿费', 'expenseDesc': '酒店住宿3晚',
+              'amount': 800.0, 'taxAmount': 48.0, 'totalAmount': 848.0,
+              'invoiceNo': 'INV20240502001', 'invoiceCode': '3100234568',
+              'invoiceType': '增值税普通发票', 'isDeductible': false, 'taxRate': 0.06,
+              'remark': '', 'attachments': [], 'sortOrder': 2,
+            },
+            {
+              'id': 'ED003', 'expenseId': id, 'expenseDate': '2024-05-02',
+              'expenseType': '交通费', 'expenseDesc': '市内出租车',
+              'amount': 224.0, 'taxAmount': 0.0, 'totalAmount': 224.0,
+              'invoiceNo': '', 'invoiceCode': '', 'invoiceType': '无发票',
+              'isDeductible': false, 'taxRate': 0.0, 'remark': '',
+              'attachments': [], 'sortOrder': 3,
+            },
+          ],
+          'approvalRecords': [
+            {
+              'id': 'AR001', 'bizId': id, 'bizType': 'expense',
+              'approverId': 'U100', 'approverName': '王经理', 'approvalLevel': 1,
+              'action': 'approve', 'opinion': '', 'approvalTime': '2024-05-02T10:00:00',
+            },
+          ],
+        },
+      };
+
+  // ==================== 加班 ====================
+
+  static List<Map<String, dynamic>> get overtimeList => [
+        _overtime('OT001', 'JB-20240501-001', '张三', '销售部', '2024-05-03',
+            '2024-05-03T18:00:00', '2024-05-03T22:00:00', 4.0,
+            '工作日加班', '加班费', '处理华东客户紧急需求', 'pending', 'U100'),
+        _overtime('OT002', 'JB-20240428-002', '王五', '技术部', '2024-04-28',
+            '2024-04-28T09:00:00', '2024-04-28T18:00:00', 8.0,
+            '休息日加班', '调休', '系统上线支持', 'approved', ''),
+      ];
+
+  static Map<String, dynamic> overtimeDetail(String id) => {
+        'code': 0,
+        'message': 'ok',
+        'data': overtimeList.firstWhere((e) => e['id'] == id,
+            orElse: () => overtimeList.first),
+      };
+
+  // ==================== 用车 ====================
+
+  static List<Map<String, dynamic>> get vehicleList => [
+        _vehicle('VH001', 'YC-20240505-001', '张三', '销售部', '商务车', '客户接待',
+            '2024-05-05T09:00:00', '2024-05-05T17:00:00', '公司总部', '浦东机场',
+            3, '赵司机', 80.0, '接送重要客户', 'pending', 'U100'),
+        _vehicle('VH002', 'YC-20240503-002', '李四', '技术部', '轿车', '商务出行',
+            '2024-05-03T13:00:00', '2024-05-03T18:00:00', '公司总部', '张江科技园',
+            2, '自驾', 30.0, '拜访合作公司', 'approved', ''),
+      ];
+
+  static Map<String, dynamic> vehicleDetail(String id) => {
+        'code': 0,
+        'message': 'ok',
+        'data': vehicleList.firstWhere((e) => e['id'] == id,
+            orElse: () => vehicleList.first),
+      };
+
+  // ==================== 外出日志 ====================
+
+  static List<Map<String, dynamic>> get outingLogList => [
+        {
+          'id': 'OL001', 'visitNo': 'VST-20240501-0001',
+          'salespersonId': 'U001', 'salespersonName': '张三',
+          'deptId': 'D001', 'deptName': '销售部',
+          'customerId': 'C001', 'customerName': '华东科技有限公司',
+          'contactId': 'CT001', 'contactName': '陈总',
+          'contactPhone': '13800138001', 'contactPosition': '采购总监',
+          'visitDate': '2024-05-01',
+          'visitStartTime': '2024-05-01T09:00:00',
+          'visitEndTime': '2024-05-01T11:30:00',
+          'visitType': '商务沟通', 'visitPurpose': '洽谈Q3采购合同',
+          'visitLocation': '上海市浦东新区张江路168号',
+          'visitAddressDetail': '华东科技大厦15层',
+          'longitude': 121.58, 'latitude': 31.20,
+          'signInPhoto': '', 'signOutPhoto': '', 'visitPhotos': [],
+          'participants': '李四',
+          'visitSummary': '与陈总进行了2个半小时的会议,讨论了Q3采购合同的具体条款。对方对价格方案基本满意。',
+          'visitResult': '待跟进', 'customerFeedback': '价格需再优惠5%',
+          'competitorInfo': '竞品A报价比我们低3%',
+          'nextVisitTime': '2024-05-15T10:00:00',
+          'nextVisitContent': '提交最终报价方案',
+          'checkInStatus': '正常', 'distanceDeviation': 0,
+          'materials': '产品手册、报价单', 'status': '已完成',
+          'attachments': [],
+          'createTime': '2024-05-01T14:00:00', 'updateTime': '2024-05-01T14:00:00',
+        },
+        {
+          'id': 'OL002', 'visitNo': 'VST-20240428-0002',
+          'salespersonId': 'U001', 'salespersonName': '张三',
+          'deptId': 'D001', 'deptName': '销售部',
+          'customerId': 'C002', 'customerName': '上海智能制造有限公司',
+          'contactId': '', 'contactName': '刘经理',
+          'contactPhone': '13900139002', 'contactPosition': '技术负责人',
+          'visitDate': '2024-04-28',
+          'visitStartTime': '2024-04-28T14:00:00',
+          'visitEndTime': '2024-04-28T16:00:00',
+          'visitType': '产品演示', 'visitPurpose': '演示MES系统新功能',
+          'visitLocation': '上海市嘉定区安亭镇', 'visitAddressDetail': '',
+          'longitude': 121.16, 'latitude': 31.29,
+          'signInPhoto': '', 'signOutPhoto': '', 'visitPhotos': [],
+          'participants': '',
+          'visitSummary': '成功演示MES v3.2版本的新功能,客户对质量追溯模块很感兴趣。',
+          'visitResult': '需再次拜访',
+          'customerFeedback': '希望加入与ERP系统的对接功能',
+          'competitorInfo': '',
+          'nextVisitTime': '2024-05-10T14:00:00',
+          'nextVisitContent': '针对ERP对接进行技术方案讲解',
+          'checkInStatus': '定位偏差', 'distanceDeviation': 150,
+          'materials': '演示环境笔记本', 'status': '已完成',
+          'attachments': [],
+          'createTime': '2024-04-28T17:00:00', 'updateTime': '2024-04-28T17:00:00',
+        },
+      ];
+
+  static Map<String, dynamic> outingLogDetail(String id) => {
+        'code': 0,
+        'message': 'ok',
+        'data': outingLogList.firstWhere((e) => e['id'] == id,
+            orElse: () => outingLogList.first),
+      };
+
+  // ==================== 公告 ====================
+
+  static List<Map<String, dynamic>> get announcementList => [
+        {
+          'id': 'AN001', 'title': '关于2024年国庆节放假安排的通知',
+          'content': '各部门:\n根据国家节假日安排,2024年国庆节放假时间为10月1日至10月7日,共7天。\n\n请各部门提前做好工作安排。\n\n特此通知。',
+          'contentType': 'text', 'type': '通知公告',
+          'publisherId': 'U999', 'publisherName': '行政部',
+          'publishTime': '2024-09-25T10:00:00',
+          'isTop': true, 'privateLevel': 0,
+          'targetDepts': [], 'targetUsers': [],
+          'requireConfirm': true, 'expiryDate': '2024-10-08T00:00:00',
+          'attachments': [], 'readCount': 86, 'unreadCount': 14,
+          'createTime': '2024-09-25T10:00:00',
+        },
+        {
+          'id': 'AN002', 'title': '关于启用新版报销流程的通知',
+          'content': '全体员工:\n自2024年5月1日起,所有费用报销需通过OA系统提交电子报销单。\n\n新版流程:\n1. 填写电子报销单\n2. 上传发票\n3. 部门审批\n4. 财务打款',
+          'contentType': 'text', 'type': '制度文件',
+          'publisherId': 'U998', 'publisherName': '财务部',
+          'publishTime': '2024-04-28T14:00:00',
+          'isTop': false, 'privateLevel': 0,
+          'targetDepts': [], 'targetUsers': [],
+          'requireConfirm': false, 'expiryDate': '2024-12-31T00:00:00',
+          'attachments': [], 'readCount': 72, 'unreadCount': 28,
+          'createTime': '2024-04-28T14:00:00',
+        },
+        {
+          'id': 'AN003', 'title': 'Q2公司团建活动报名通知',
+          'content': '各位同事:\n定于5月18日(周六)举行Q2团建活动,地点为崇明岛生态园。\n\n报名截止日期:5月12日。\n\n欢迎踊跃参加!',
+          'contentType': 'text', 'type': '活动通知',
+          'publisherId': 'U997', 'publisherName': '人力资源部',
+          'publishTime': '2024-04-20T09:00:00',
+          'isTop': false, 'privateLevel': 0,
+          'targetDepts': [], 'targetUsers': [],
+          'requireConfirm': false, 'expiryDate': '2024-05-19T00:00:00',
+          'attachments': [], 'readCount': 95, 'unreadCount': 5,
+          'createTime': '2024-04-20T09:00:00',
+        },
+      ];
+
+  static Map<String, dynamic> announcementDetail(String id) => {
+        'code': 0,
+        'message': 'ok',
+        'data': announcementList.firstWhere((e) => e['id'] == id,
+            orElse: () => announcementList.first),
+      };
+
+  // ==================== helpers ====================
+
+  static Map<String, dynamic> _expense(
+      String id, String no, String name, String dept, String type,
+      double amt, String status, String approver, List<String> chain,
+      String remark, String time) {
+    return {
+      'id': id, 'reportNo': no, 'applicantId': 'U001',
+      'applicantName': name, 'deptId': 'D001', 'deptName': dept,
+      'expenseType': type, 'totalAmount': amt, 'invoiceCount': 1,
+      'costCenterId': 'CC001', 'projectId': '', 'projectName': '',
+      'budgetSubjectId': 'BS001', 'loanWriteoffAmount': 0.0,
+      'paymentMethod': '', 'accountId': '', 'accountName': '',
+      'remark': remark, 'status': status,
+      'currentApproverId': approver, 'approvalChain': chain,
+      'createTime': time, 'updateTime': time,
+      'details': [], 'approvalRecords': [],
+    };
+  }
+
+  static Map<String, dynamic> _overtime(
+      String id, String no, String name, String dept, String date,
+      String start, String end, double hours, String type,
+      String comp, String reason, String status, String approver) {
+    return {
+      'id': id, 'applicationNo': no, 'applicantId': 'U001',
+      'applicantName': name, 'deptId': 'D001', 'deptName': dept,
+      'position': '', 'otDate': date, 'startTime': start, 'endTime': end,
+      'otHours': hours, 'otType': type, 'compensationType': comp,
+      'reason': reason, 'remark': '', 'status': status,
+      'currentApproverId': approver, 'approvalChain': [approver],
+      'createTime': start, 'updateTime': start, 'approvalRecords': [],
+    };
+  }
+
+  static Map<String, dynamic> _vehicle(
+      String id, String no, String name, String dept, String vtype,
+      String purpose, String start, String end, String origin,
+      String dest, int psg, String driver, double mileage,
+      String reason, String status, String approver) {
+    return {
+      'id': id, 'applicationNo': no, 'applicantId': 'U001',
+      'applicantName': name, 'deptId': 'D001', 'deptName': dept,
+      'vehicleType': vtype, 'purpose': purpose,
+      'startTime': start, 'endTime': end,
+      'origin': origin, 'destination': dest,
+      'passengerCount': psg, 'driver': driver, 'licensePlate': '',
+      'estimatedMileage': mileage, 'estimatedCost': mileage * 3.0,
+      'reason': reason, 'status': status, 'currentApproverId': approver,
+      'approvalChain': [approver],
+      'createTime': start, 'updateTime': start, 'approvalRecords': [],
+    };
+  }
+}

+ 119 - 0
lib/core/network/mock_interceptor.dart

@@ -0,0 +1,119 @@
+import 'package:dio/dio.dart';
+import 'mock_data.dart';
+
+class MockInterceptor extends Interceptor {
+  final bool enabled;
+
+  MockInterceptor({this.enabled = false});
+
+  @override
+  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
+    if (!enabled) {
+      handler.next(options);
+      return;
+    }
+
+    final path = options.path;
+    final params = options.queryParameters;
+    final mockData = _matchMock(path, params);
+
+    if (mockData != null) {
+      Future.delayed(const Duration(milliseconds: 300), () {
+        handler.resolve(Response(
+          requestOptions: options,
+          statusCode: 200,
+          data: mockData,
+        ));
+      });
+    } else {
+      handler.resolve(Response(
+        requestOptions: options,
+        statusCode: 200,
+        data: {'code': 0, 'message': 'ok', 'data': null},
+      ));
+    }
+  }
+
+  Map<String, dynamic>? _matchMock(
+      String path, Map<String, dynamic> params) {
+    final status = params['status'] as String? ?? '';
+    final page = params['page'] as String? ?? '1';
+
+    if (path == '/home/summary') return MockData.homeSummary;
+
+    if (path == '/expense/list') {
+      var list = MockData.expenseList;
+      if (status.isNotEmpty) {
+        list = list.where((e) => e['status'] == status).toList();
+      }
+      return _paginate(list, page);
+    }
+    if (path.startsWith('/expense/detail/')) {
+      return MockData.expenseDetail(path.split('/').last);
+    }
+    if (path == '/expense/apply' || path == '/expense/draft') {
+      return MockData.success;
+    }
+
+    if (path == '/overtime/list') {
+      var list = MockData.overtimeList;
+      if (status.isNotEmpty) {
+        list = list.where((e) => e['status'] == status).toList();
+      }
+      return _paginate(list, page);
+    }
+    if (path.startsWith('/overtime/detail/')) {
+      return MockData.overtimeDetail(path.split('/').last);
+    }
+    if (path == '/overtime/apply' || path == '/overtime/draft') {
+      return MockData.success;
+    }
+
+    if (path == '/vehicle/list') {
+      var list = MockData.vehicleList;
+      if (status.isNotEmpty) {
+        list = list.where((e) => e['status'] == status).toList();
+      }
+      return _paginate(list, page);
+    }
+    if (path.startsWith('/vehicle/detail/')) {
+      return MockData.vehicleDetail(path.split('/').last);
+    }
+    if (path == '/vehicle/apply' || path == '/vehicle/draft') {
+      return MockData.success;
+    }
+
+    if (path == '/outing-log/list') {
+      return _paginate(MockData.outingLogList, page);
+    }
+    if (path.startsWith('/outing-log/detail/')) {
+      return MockData.outingLogDetail(path.split('/').last);
+    }
+    if (path == '/outing-log/create') return MockData.success;
+
+    if (path == '/announcement/list') {
+      return _paginate(MockData.announcementList, page);
+    }
+    if (path.startsWith('/announcement/detail/')) {
+      return MockData.announcementDetail(path.split('/').last);
+    }
+
+    return null;
+  }
+
+  Map<String, dynamic> _paginate(
+      List<Map<String, dynamic>> list, String pageStr) {
+    final page = int.tryParse(pageStr) ?? 1;
+    const size = 20;
+    return {
+      'code': 0,
+      'message': 'ok',
+      'data': {
+        'list': list,
+        'page': page,
+        'size': size,
+        'total': list.length,
+      },
+    };
+  }
+}

+ 0 - 0
lib/core/router/.gitkeep


+ 94 - 0
lib/core/router/app_router.dart

@@ -0,0 +1,94 @@
+import 'package:go_router/go_router.dart';
+import '../../features/home/home_page.dart';
+import '../../features/expense/expense_list_page.dart';
+import '../../features/expense/expense_apply_page.dart';
+import '../../features/expense/expense_detail_page.dart';
+import '../../features/overtime/overtime_list_page.dart';
+import '../../features/overtime/overtime_apply_page.dart';
+import '../../features/overtime/overtime_detail_page.dart';
+import '../../features/vehicle/vehicle_list_page.dart';
+import '../../features/vehicle/vehicle_apply_page.dart';
+import '../../features/vehicle/vehicle_detail_page.dart';
+import '../../features/outing_log/outing_log_list_page.dart';
+import '../../features/outing_log/outing_log_create_page.dart';
+import '../../features/outing_log/outing_log_detail_page.dart';
+import '../../features/announcement/announcement_list_page.dart';
+import '../../features/announcement/announcement_detail_page.dart';
+
+GoRouter createAppRouter() {
+  return GoRouter(
+    initialLocation: '/',
+    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']!),
+      ),
+    ],
+  );
+}

+ 0 - 0
lib/core/theme/.gitkeep


+ 19 - 0
lib/core/theme/app_colors.dart

@@ -0,0 +1,19 @@
+import 'package:flutter/material.dart';
+
+class AppColors {
+  AppColors._();
+  static const Color primary = Color(0xFF00ABF3);
+  static const Color primaryLight = Color(0xFFE6F7FF);
+  static const Color success = Color(0xFF52C41A);
+  static const Color successBg = Color(0xFFF6FFED);
+  static const Color warning = Color(0xFFFA8C16);
+  static const Color warningBg = Color(0xFFFFF7E6);
+  static const Color error = Color(0xFFFF4D4F);
+  static const Color errorBg = Color(0xFFFFF2F0);
+  static const Color textPrimary = Color(0xFF333333);
+  static const Color textSecondary = Color(0xFF999999);
+  static const Color textHint = Color(0xFFCCCCCC);
+  static const Color divider = Color(0xFFF0F0F0);
+  static const Color background = Color(0xFFF5F6FA);
+  static const Color cardWhite = Color(0xFFFFFFFF);
+}

+ 53 - 0
lib/core/theme/app_theme.dart

@@ -0,0 +1,53 @@
+import 'package:flutter/material.dart';
+import 'app_colors.dart';
+
+class AppTheme {
+  AppTheme._();
+
+  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),
+        ),
+      ),
+    );
+  }
+}

+ 0 - 0
lib/core/utils/.gitkeep


+ 21 - 0
lib/core/utils/date_utils.dart

@@ -0,0 +1,21 @@
+import 'package:intl/intl.dart';
+
+class DateUtils {
+  DateUtils._();
+
+  static String formatDateTime(DateTime dt) {
+    return DateFormat('yyyy-MM-dd HH:mm').format(dt);
+  }
+
+  static String formatDate(DateTime dt) {
+    return DateFormat('yyyy-MM-dd').format(dt);
+  }
+
+  static String formatTime(DateTime dt) {
+    return DateFormat('HH:mm').format(dt);
+  }
+
+  static String formatMonthDay(DateTime dt) {
+    return DateFormat('MM-dd').format(dt);
+  }
+}

+ 41 - 0
lib/core/utils/responsive.dart

@@ -0,0 +1,41 @@
+import 'package:flutter/widgets.dart';
+
+class ResponsiveHelper {
+  final double width;
+  final double height;
+  final bool isLandscape;
+  final bool isWide;
+
+  const ResponsiveHelper({
+    required this.width,
+    required this.height,
+    required this.isLandscape,
+    required this.isWide,
+  });
+
+  factory ResponsiveHelper.of(BuildContext context) {
+    final media = MediaQuery.of(context);
+    final w = media.size.width;
+    final h = media.size.height;
+    return ResponsiveHelper(
+      width: w,
+      height: h,
+      isLandscape: w > h,
+      isWide: w > 600,
+    );
+  }
+
+  int get gridColumns {
+    if (width > 900) return 4;
+    if (isLandscape) return 3;
+    return 2;
+  }
+
+  double get listMaxWidth => isWide ? 600 : double.infinity;
+
+  double get formMaxWidth => isWide ? 800 : double.infinity;
+
+  bool get formTwoColumns => isWide;
+
+  bool get detailTwoColumns => isWide;
+}

+ 21 - 0
lib/core/utils/validators.dart

@@ -0,0 +1,21 @@
+class Validators {
+  Validators._();
+
+  static String? required(String? value) {
+    if (value == null || value.trim().isEmpty) return '此项为必填';
+    return null;
+  }
+
+  static String? amount(String? value) {
+    if (value == null || value.trim().isEmpty) return '请输入金额';
+    final amount = double.tryParse(value);
+    if (amount == null) return '请输入有效金额';
+    if (amount <= 0) return '金额必须大于0';
+    return null;
+  }
+
+  static String? maxLength(String? value, int max) {
+    if (value != null && value.length > max) return '最多输入$max个字符';
+    return null;
+  }
+}

+ 0 - 0
lib/features/announcement/.gitkeep


+ 40 - 0
lib/features/announcement/announcement_api.dart

@@ -0,0 +1,40 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/network/api_client.dart';
+import '../../app.dart';
+import '../../shared/models/pagination_model.dart';
+import 'announcement_model.dart';
+
+final announcementApiProvider = Provider<AnnouncementApi>(
+    (ref) => AnnouncementApi(ref.read(apiClientProvider)));
+
+class AnnouncementApi {
+  final ApiClient _client;
+  AnnouncementApi(this._client);
+
+  Future<PaginatedData<AnnouncementModel>> fetchList({
+    int page = 1,
+    int size = 20,
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/announcement/list',
+      queryParameters: {'page': page, 'size': size},
+    );
+    final data = response.data!;
+    final list = (data['list'] as List<dynamic>)
+        .map((e) => AnnouncementModel.fromJson(e as Map<String, dynamic>))
+        .toList();
+    return PaginatedData(
+      list: list,
+      page: data['page'] as int,
+      size: data['size'] as int,
+      total: data['total'] as int,
+    );
+  }
+
+  Future<AnnouncementModel> fetchDetail(String id) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/announcement/detail/$id',
+    );
+    return AnnouncementModel.fromJson(response.data!);
+  }
+}

+ 56 - 0
lib/features/announcement/announcement_detail_page.dart

@@ -0,0 +1,56 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/theme/app_colors.dart';
+import '../../core/utils/date_utils.dart' as du;
+import '../../core/utils/responsive.dart';
+import '../../shared/widgets/form_section.dart';
+import '../../shared/widgets/form_field_row.dart';
+import 'announcement_api.dart';
+import 'announcement_model.dart';
+
+final announcementDetailProvider = FutureProvider.autoDispose.family<AnnouncementModel, String>((ref, id) async {
+  return ref.read(announcementApiProvider).fetchDetail(id);
+});
+
+class AnnouncementDetailPage extends ConsumerWidget {
+  final String id;
+  const AnnouncementDetailPage({super.key, required this.id});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final detailAsync = ref.watch(announcementDetailProvider(id));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(title: const Text('公告详情')),
+      body: detailAsync.when(
+        loading: () => const Center(child: CircularProgressIndicator()),
+        error: (_, __) => const Center(child: Text('加载失败')),
+        data: (item) => Center(
+          child: ConstrainedBox(
+            constraints: BoxConstraints(maxWidth: r.detailTwoColumns ? 700 : double.infinity),
+            child: SingleChildScrollView(
+              padding: const EdgeInsets.symmetric(vertical: 8),
+              child: FormSection(
+                title: item.title,
+                children: [
+                  FormFieldRow(label: '发布部门', value: item.publisherName, showArrow: false),
+                  FormFieldRow(label: '发布时间', value: du.DateUtils.formatDate(item.publishTime), showArrow: false),
+                  FormFieldRow(label: '公告类型', value: item.type, showArrow: false),
+                  if (item.requireConfirm)
+                    FormFieldRow(label: '确认要求', value: '需确认已读', showArrow: false),
+                  FormFieldRow(label: '已读', value: '${item.readCount}人', showArrow: false),
+                  const Divider(),
+                  Padding(
+                    padding: const EdgeInsets.all(14),
+                    child: Text(item.content, style: const TextStyle(color: AppColors.textPrimary, fontSize: 14, height: 1.6)),
+                  ),
+                ],
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 11 - 0
lib/features/announcement/announcement_list_controller.dart

@@ -0,0 +1,11 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'announcement_model.dart';
+import 'announcement_api.dart';
+
+final announcementListProvider =
+    FutureProvider.autoDispose.family<List<AnnouncementModel>, int>(
+        (ref, page) async {
+  final api = ref.watch(announcementApiProvider);
+  final result = await api.fetchList(page: page);
+  return result.list;
+});

+ 85 - 0
lib/features/announcement/announcement_list_page.dart

@@ -0,0 +1,85 @@
+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/empty_state.dart';
+import '../../shared/widgets/loading_widget.dart';
+import 'announcement_list_controller.dart';
+import 'announcement_model.dart';
+
+class AnnouncementListPage extends ConsumerStatefulWidget {
+  const AnnouncementListPage({super.key});
+  @override
+  ConsumerState<AnnouncementListPage> createState() => _AnnouncementListPageState();
+}
+
+class _AnnouncementListPageState extends ConsumerState<AnnouncementListPage> {
+  int _page = 1;
+
+  @override
+  Widget build(BuildContext context) {
+    final itemsAsync = ref.watch(announcementListProvider(_page));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(title: const Text('公告通知')),
+      body: 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: '暂无公告')
+                : ListView.builder(
+                    padding: const EdgeInsets.symmetric(vertical: 4),
+                    itemCount: items.length,
+                    itemBuilder: (_, index) => _buildItem(items[index]),
+                  ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildItem(AnnouncementModel item) {
+    return AppCard(
+      onTap: () => context.go('/announcement/detail/${item.id}'),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Row(
+            children: [
+              if (item.isTop)
+                const Text('📌 ', style: TextStyle(fontSize: 14)),
+              Expanded(
+                child: Text(item.title,
+                    style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: AppColors.textPrimary)),
+              ),
+              if (item.unreadCount > 0)
+                Container(
+                  padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
+                  decoration: BoxDecoration(color: AppColors.error, borderRadius: BorderRadius.circular(8)),
+                  child: Text('${item.unreadCount}未读', style: const TextStyle(color: Colors.white, fontSize: 10)),
+                ),
+            ],
+          ),
+          const SizedBox(height: 4),
+          Text(item.content, maxLines: 2, overflow: TextOverflow.ellipsis,
+              style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)),
+          const SizedBox(height: 4),
+          Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              Text(item.publisherName, style: const TextStyle(color: AppColors.textHint, fontSize: 11)),
+              Text(du.DateUtils.formatDate(item.publishTime), style: const TextStyle(color: AppColors.textHint, fontSize: 11)),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 76 - 0
lib/features/announcement/announcement_model.dart

@@ -0,0 +1,76 @@
+class AnnouncementModel {
+  final String id;
+  final String title;
+  final String content;
+  final String contentType;
+  final String type;
+  final String publisherId;
+  final String publisherName;
+  final DateTime publishTime;
+  final bool isTop;
+  final int privateLevel;
+  final List<String> targetDepts;
+  final List<String> targetUsers;
+  final bool requireConfirm;
+  final DateTime expiryDate;
+  final List<String> attachments;
+  final int readCount;
+  final int unreadCount;
+  final DateTime createTime;
+
+  const AnnouncementModel({
+    required this.id,
+    required this.title,
+    required this.content,
+    this.contentType = 'text',
+    this.type = '通知公告',
+    required this.publisherId,
+    required this.publisherName,
+    required this.publishTime,
+    this.isTop = false,
+    this.privateLevel = 0,
+    this.targetDepts = const [],
+    this.targetUsers = const [],
+    this.requireConfirm = false,
+    required this.expiryDate,
+    this.attachments = const [],
+    this.readCount = 0,
+    this.unreadCount = 0,
+    required this.createTime,
+  });
+
+  factory AnnouncementModel.fromJson(Map<String, dynamic> json) {
+    return AnnouncementModel(
+      id: json['id'] as String,
+      title: json['title'] as String,
+      content: json['content'] as String? ?? '',
+      contentType: json['contentType'] as String? ?? 'text',
+      type: json['type'] as String? ?? '通知公告',
+      publisherId: json['publisherId'] as String? ?? '',
+      publisherName: json['publisherName'] as String? ?? '',
+      publishTime: DateTime.parse(json['publishTime'] as String),
+      isTop: json['isTop'] as bool? ?? false,
+      privateLevel: json['privateLevel'] as int? ?? 0,
+      targetDepts:
+          (json['targetDepts'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          [],
+      targetUsers:
+          (json['targetUsers'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          [],
+      requireConfirm: json['requireConfirm'] as bool? ?? false,
+      expiryDate: DateTime.parse(json['expiryDate'] as String),
+      attachments:
+          (json['attachments'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          [],
+      readCount: json['readCount'] as int? ?? 0,
+      unreadCount: json['unreadCount'] as int? ?? 0,
+      createTime: DateTime.parse(json['createTime'] as String),
+    );
+  }
+}

+ 0 - 0
lib/features/expense/.gitkeep


+ 49 - 0
lib/features/expense/expense_api.dart

@@ -0,0 +1,49 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/network/api_client.dart';
+import '../../app.dart';
+import '../../shared/models/pagination_model.dart';
+import 'expense_model.dart';
+
+final expenseApiProvider = Provider<ExpenseApi>(
+    (ref) => ExpenseApi(ref.read(apiClientProvider)));
+
+class ExpenseApi {
+  final ApiClient _client;
+  ExpenseApi(this._client);
+
+  Future<PaginatedData<ExpenseModel>> fetchList({
+    String status = '',
+    int page = 1,
+    int size = 20,
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/expense/list',
+      queryParameters: {'status': status, 'page': page, 'size': size},
+    );
+    final data = response.data!;
+    final list = (data['list'] as List<dynamic>)
+        .map((e) => ExpenseModel.fromJson(e as Map<String, dynamic>))
+        .toList();
+    return PaginatedData(
+      list: list,
+      page: data['page'] as int,
+      size: data['size'] as int,
+      total: data['total'] as int,
+    );
+  }
+
+  Future<ExpenseModel> fetchDetail(String id) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/expense/detail/$id',
+    );
+    return ExpenseModel.fromJson(response.data!);
+  }
+
+  Future<void> submit(ExpenseModel expense) async {
+    await _client.post('/expense/apply', data: expense.toJson());
+  }
+
+  Future<void> saveDraft(ExpenseModel expense) async {
+    await _client.put('/expense/draft', data: expense.toJson());
+  }
+}

+ 95 - 0
lib/features/expense/expense_apply_controller.dart

@@ -0,0 +1,95 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'expense_model.dart';
+import 'expense_api.dart';
+
+class ExpenseApplyState {
+  final ExpenseModel expense;
+  final bool isSubmitting;
+
+  const ExpenseApplyState({required this.expense, this.isSubmitting = false});
+
+  ExpenseApplyState copyWith({ExpenseModel? expense, bool? isSubmitting}) {
+    return ExpenseApplyState(
+      expense: expense ?? this.expense,
+      isSubmitting: isSubmitting ?? this.isSubmitting,
+    );
+  }
+}
+
+class ExpenseApplyController extends StateNotifier<ExpenseApplyState> {
+  final ExpenseApi _api;
+
+  ExpenseApplyController(this._api, {ExpenseModel? initial})
+      : super(ExpenseApplyState(
+          expense: initial ??
+              ExpenseModel(
+                id: '',
+                reportNo: '',
+                applicantId: '',
+                applicantName: '',
+                deptId: '',
+                deptName: '',
+                expenseType: '差旅费',
+                totalAmount: 0.0,
+                remark: '',
+                createTime: DateTime.now(),
+                updateTime: DateTime.now(),
+              ),
+        ));
+
+  void updateType(String type) {
+    state = state.copyWith(expense: state.expense.copyWith(expenseType: type));
+  }
+
+  void updateRemark(String remark) {
+    state = state.copyWith(expense: state.expense.copyWith(remark: remark));
+  }
+
+  void addDetail(ExpenseDetailModel detail) {
+    final details = [...state.expense.details, detail];
+    state = state.copyWith(expense: state.expense.copyWith(details: details));
+  }
+
+  void removeDetail(int index) {
+    final details = [...state.expense.details]..removeAt(index);
+    state = state.copyWith(expense: state.expense.copyWith(details: details));
+  }
+
+  void recalculateAmount() {
+    final total =
+        state.expense.details.fold<double>(0, (sum, d) => sum + d.totalAmount);
+    state =
+        state.copyWith(expense: state.expense.copyWith(totalAmount: total));
+  }
+
+  Future<bool> submit() async {
+    state = state.copyWith(isSubmitting: true);
+    try {
+      await _api.submit(state.expense.copyWith(status: 'pending'));
+      return true;
+    } catch (_) {
+      return false;
+    } finally {
+      state = state.copyWith(isSubmitting: false);
+    }
+  }
+
+  Future<bool> saveDraft() async {
+    state = state.copyWith(isSubmitting: true);
+    try {
+      await _api.saveDraft(state.expense);
+      return true;
+    } catch (_) {
+      return false;
+    } finally {
+      state = state.copyWith(isSubmitting: false);
+    }
+  }
+}
+
+final expenseApplyProvider =
+    StateNotifierProvider.autoDispose.family<ExpenseApplyController,
+        ExpenseApplyState, String?>((ref, editId) {
+  final api = ref.watch(expenseApiProvider);
+  return ExpenseApplyController(api);
+});

+ 304 - 0
lib/features/expense/expense_apply_page.dart

@@ -0,0 +1,304 @@
+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/responsive.dart';
+import '../../shared/widgets/form_section.dart';
+import '../../shared/widgets/form_field_row.dart';
+import 'expense_apply_controller.dart';
+import 'expense_model.dart';
+
+class ExpenseApplyPage extends ConsumerStatefulWidget {
+  final String? editId;
+  const ExpenseApplyPage({super.key, this.editId});
+
+  @override
+  ConsumerState<ExpenseApplyPage> createState() => _ExpenseApplyPageState();
+}
+
+class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
+  final _remarkController = TextEditingController();
+  static const _types = ['差旅费', '办公用品', '招待费', '交通费', '通讯费', '其他'];
+
+  @override
+  void dispose() {
+    _remarkController.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final controller =
+        ref.watch(expenseApplyProvider(widget.editId).notifier);
+    final state = ref.watch(expenseApplyProvider(widget.editId));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(
+        title: Text(widget.editId != null ? '编辑报销' : '费用报销申请'),
+        actions: [
+          if (state.expense.status == 'draft')
+            const Padding(
+              padding: EdgeInsets.only(right: 12),
+              child: Text('草稿',
+                  style: TextStyle(color: Colors.white70, fontSize: 13)),
+            ),
+        ],
+      ),
+      body: Column(
+        children: [
+          Expanded(
+            child: Center(
+              child: ConstrainedBox(
+                constraints: BoxConstraints(maxWidth: r.formMaxWidth),
+                child: SingleChildScrollView(
+                  padding: const EdgeInsets.symmetric(vertical: 8),
+                  child: _buildForm(controller, state),
+                ),
+              ),
+            ),
+          ),
+          _buildBottomButtons(controller, state),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildForm(
+      ExpenseApplyController controller, ExpenseApplyState state) {
+    return Column(
+      children: [
+        FormSection(
+          title: '基本信息',
+          children: [
+            FormFieldRow(
+              label: '报销类型',
+              value: state.expense.expenseType,
+              onTap: () => _showTypePicker(controller),
+            ),
+            FormFieldRow(
+              label: '报销金额',
+              value: '¥${state.expense.totalAmount.toStringAsFixed(2)}',
+              showArrow: false,
+            ),
+            FormFieldRow(
+              label: '备注说明',
+              value: state.expense.remark.isEmpty ? null : state.expense.remark,
+              hint: '选填',
+              onTap: () => _showRemarkEditor(),
+            ),
+          ],
+        ),
+        FormSection(
+          title: '报销明细',
+          trailing: TextButton(
+            onPressed: () => _showAddDetailDialog(controller),
+            child: const Text('+ 添加明细',
+                style: TextStyle(fontSize: 12)),
+          ),
+          children: state.expense.details.isEmpty
+              ? [
+                  const Padding(
+                    padding: EdgeInsets.all(14),
+                    child: Text('暂无明细,点击上方添加',
+                        style: TextStyle(
+                            color: AppColors.textHint, fontSize: 13)),
+                  ),
+                ]
+              : state.expense.details.asMap().entries.map((entry) {
+                  final d = entry.value;
+                  return ListTile(
+                    title: Text(d.expenseDesc,
+                        style: const TextStyle(
+                            fontSize: 13,
+                            color: AppColors.textPrimary)),
+                    trailing: Row(
+                      mainAxisSize: MainAxisSize.min,
+                      children: [
+                        Text(
+                            '¥${d.totalAmount.toStringAsFixed(2)}',
+                            style: const TextStyle(
+                                color: AppColors.primary,
+                                fontSize: 13)),
+                        IconButton(
+                          icon: const Icon(Icons.close,
+                              size: 16,
+                              color: AppColors.textHint),
+                          onPressed: () {
+                            controller.removeDetail(entry.key);
+                            controller.recalculateAmount();
+                          },
+                        ),
+                      ],
+                    ),
+                  );
+                }).toList(),
+        ),
+        const SizedBox(height: 16),
+      ],
+    );
+  }
+
+  Widget _buildBottomButtons(
+      ExpenseApplyController controller, ExpenseApplyState state) {
+    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: Row(
+        children: [
+          Expanded(
+            child: OutlinedButton(
+              onPressed: state.isSubmitting
+                  ? null
+                  : () async {
+                      await controller.saveDraft();
+                      if (context.mounted) context.pop();
+                    },
+              child: const Text('存草稿'),
+            ),
+          ),
+          const SizedBox(width: 12),
+          Expanded(
+            flex: 2,
+            child: ElevatedButton(
+              onPressed: state.isSubmitting
+                  ? null
+                  : () async {
+                      final ok = await controller.submit();
+                      if (context.mounted && ok) context.pop();
+                    },
+              child: state.isSubmitting
+                  ? const SizedBox(
+                      width: 20,
+                      height: 20,
+                      child: CircularProgressIndicator(
+                          strokeWidth: 2, color: Colors.white),
+                    )
+                  : const Text('提交审批'),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  void _showTypePicker(ExpenseApplyController controller) {
+    showModalBottomSheet(
+      context: context,
+      builder: (_) => Column(
+        mainAxisSize: MainAxisSize.min,
+        children: _types
+            .map((t) => ListTile(
+                  title: Text(t),
+                  onTap: () {
+                    controller.updateType(t);
+                    Navigator.pop(context);
+                  },
+                ))
+            .toList(),
+      ),
+    );
+  }
+
+  void _showRemarkEditor() {
+    final state = ref.read(expenseApplyProvider(widget.editId));
+    _remarkController.text = state.expense.remark;
+    showDialog(
+      context: context,
+      builder: (_) => AlertDialog(
+        title: const Text('备注说明'),
+        content: TextField(
+          controller: _remarkController,
+          maxLines: 3,
+          decoration: const InputDecoration(
+              hintText: '请输入备注信息…',
+              border: OutlineInputBorder()),
+        ),
+        actions: [
+          TextButton(
+              onPressed: () => Navigator.pop(context),
+              child: const Text('取消')),
+          TextButton(
+            onPressed: () {
+              ref
+                  .read(expenseApplyProvider(widget.editId).notifier)
+                  .updateRemark(_remarkController.text);
+              Navigator.pop(context);
+            },
+            child: const Text('确定'),
+          ),
+        ],
+      ),
+    );
+  }
+
+  void _showAddDetailDialog(ExpenseApplyController controller) {
+    final nameCtrl = TextEditingController();
+    final amountCtrl = TextEditingController();
+    final descCtrl = TextEditingController();
+    showDialog(
+      context: context,
+      builder: (_) => AlertDialog(
+        title: const Text('添加明细'),
+        content: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            TextField(
+              controller: nameCtrl,
+              decoration: const InputDecoration(
+                  labelText: '费用名称',
+                  border: OutlineInputBorder()),
+            ),
+            const SizedBox(height: 8),
+            TextField(
+              controller: amountCtrl,
+              decoration: const InputDecoration(
+                  labelText: '金额',
+                  border: OutlineInputBorder()),
+              keyboardType: TextInputType.number,
+            ),
+            const SizedBox(height: 8),
+            TextField(
+              controller: descCtrl,
+              decoration: const InputDecoration(
+                  labelText: '描述',
+                  border: OutlineInputBorder()),
+            ),
+          ],
+        ),
+        actions: [
+          TextButton(
+              onPressed: () => Navigator.pop(context),
+              child: const Text('取消')),
+          TextButton(
+            onPressed: () {
+              final amount = double.tryParse(amountCtrl.text) ?? 0.0;
+              controller.addDetail(ExpenseDetailModel(
+                id: DateTime.now().millisecondsSinceEpoch.toString(),
+                expenseId: '',
+                expenseDate: DateTime.now(),
+                expenseType: '',
+                expenseDesc: nameCtrl.text,
+                amount: amount,
+                totalAmount: amount,
+                remark: descCtrl.text,
+              ));
+              controller.recalculateAmount();
+              Navigator.pop(context);
+            },
+            child: const Text('添加'),
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 151 - 0
lib/features/expense/expense_detail_page.dart

@@ -0,0 +1,151 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/theme/app_colors.dart';
+import '../../core/utils/date_utils.dart' as du;
+import '../../core/utils/responsive.dart';
+import '../../shared/widgets/form_section.dart';
+import '../../shared/widgets/form_field_row.dart';
+import '../../shared/widgets/status_tag.dart';
+import 'expense_api.dart';
+import 'expense_model.dart';
+
+final expenseDetailProvider =
+    FutureProvider.autoDispose.family<ExpenseModel, String>(
+        (ref, id) async {
+  return ref.read(expenseApiProvider).fetchDetail(id);
+});
+
+class ExpenseDetailPage extends ConsumerWidget {
+  final String id;
+  const ExpenseDetailPage({super.key, required this.id});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final detailAsync = ref.watch(expenseDetailProvider(id));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(title: const Text('报销单详情')),
+      body: detailAsync.when(
+        loading: () =>
+            const Center(child: CircularProgressIndicator()),
+        error: (_, __) => const Center(child: Text('加载失败')),
+        data: (expense) => Center(
+          child: ConstrainedBox(
+            constraints:
+                BoxConstraints(maxWidth: r.detailTwoColumns ? 700 : double.infinity),
+            child: SingleChildScrollView(
+              padding: const EdgeInsets.symmetric(vertical: 8),
+              child: Column(
+                children: [
+                  FormSection(
+                    title: '基本信息',
+                    children: [
+                      FormFieldRow(
+                          label: '单号',
+                          value: expense.reportNo,
+                          showArrow: false),
+                      FormFieldRow(
+                          label: '报销类型',
+                          value: expense.expenseType,
+                          showArrow: false),
+                      FormFieldRow(
+                          label: '报销金额',
+                          value: '¥${expense.totalAmount.toStringAsFixed(2)}',
+                          showArrow: false),
+                      FormFieldRow(
+                          label: '部门',
+                          value: expense.deptName,
+                          showArrow: false),
+                      FormFieldRow(
+                          label: '申请人',
+                          value: expense.applicantName,
+                          showArrow: false),
+                      FormFieldRow(
+                          label: '创建时间',
+                          value: du.DateUtils.formatDateTime(
+                              expense.createTime),
+                          showArrow: false),
+                      FormFieldRow(
+                          label: '状态',
+                          value: '',
+                          showArrow: false,
+                          onTap: null,
+                        ), // 状态用 StatusTag
+                      Padding(
+                        padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
+                        child: Row(
+                          children: [
+                            const SizedBox(
+                                width: 72,
+                                child: Text('状态',
+                                    style: TextStyle(
+                                        color: AppColors.textSecondary,
+                                        fontSize: 13))),
+                            StatusTag(status: expense.status),
+                          ],
+                        ),
+                      ),
+                      FormFieldRow(
+                          label: '备注',
+                          value: expense.remark.isEmpty
+                              ? '-'
+                              : expense.remark,
+                          showArrow: false),
+                    ],
+                  ),
+                  FormSection(
+                    title: '报销明细',
+                    children: expense.details.isEmpty
+                        ? [
+                            const Padding(
+                              padding: EdgeInsets.all(14),
+                              child: Text('无明细',
+                                  style: TextStyle(
+                                      color: AppColors.textHint,
+                                      fontSize: 13)),
+                            )
+                          ]
+                        : expense.details
+                            .map((d) => Padding(
+                                  padding: const EdgeInsets.symmetric(
+                                      horizontal: 14, vertical: 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.invoiceNo,
+                                              style: const TextStyle(
+                                                  fontSize: 10,
+                                                  color: AppColors
+                                                      .textHint)),
+                                        ],
+                                      ),
+                                      Text(
+                                          '¥${d.totalAmount.toStringAsFixed(2)}',
+                                          style: const TextStyle(
+                                              color: AppColors.primary,
+                                              fontSize: 14)),
+                                    ],
+                                  ),
+                                ))
+                            .toList(),
+                  ),
+                ],
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 14 - 0
lib/features/expense/expense_list_controller.dart

@@ -0,0 +1,14 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'expense_model.dart';
+import 'expense_api.dart';
+
+final expenseStatusFilterProvider = StateProvider<String>((ref) => '');
+
+final expenseListProvider =
+    FutureProvider.autoDispose.family<List<ExpenseModel>, int>(
+        (ref, page) async {
+  final status = ref.watch(expenseStatusFilterProvider);
+  final api = ref.watch(expenseApiProvider);
+  final result = await api.fetchList(status: status, page: page);
+  return result.list;
+});

+ 179 - 0
lib/features/expense/expense_list_page.dart

@@ -0,0 +1,179 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:flutter_slidable/flutter_slidable.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> {
+  int _page = 1;
+
+  @override
+  Widget build(BuildContext context) {
+    final status = ref.watch(expenseStatusFilterProvider);
+    final itemsAsync = ref.watch(expenseListProvider(_page));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('报销单'),
+        actions: [
+          TextButton(
+            onPressed: () => context.go('/expense/apply'),
+            child: const Text('+ 新建',
+                style: TextStyle(color: Colors.white, fontSize: 13)),
+          ),
+        ],
+      ),
+      body: Column(
+        children: [
+          _buildStatusFilter(status, r),
+          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: '暂无报销单')
+                      : ListView.builder(
+                          padding: const EdgeInsets.symmetric(vertical: 4),
+                          itemCount: items.length,
+                          itemBuilder: (_, index) =>
+                              _buildItem(items[index]),
+                        ),
+                ),
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildStatusFilter(String current, ResponsiveHelper r) {
+    final statuses = [
+      {'key': '', 'label': '全部'},
+      {'key': 'pending', 'label': '待审批'},
+      {'key': 'approved', 'label': '已通过'},
+      {'key': 'rejected', 'label': '已拒绝'},
+    ];
+    final filterBar = SingleChildScrollView(
+      scrollDirection: Axis.horizontal,
+      padding: const EdgeInsets.symmetric(horizontal: 12),
+      child: Row(
+        children: statuses.map((s) {
+          final isSelected = current == s['key'];
+          return Padding(
+            padding: const EdgeInsets.only(right: 8),
+            child: GestureDetector(
+              onTap: () {
+                ref.read(expenseStatusFilterProvider.notifier).state =
+                    s['key']!;
+                _page = 1;
+              },
+              child: Container(
+                padding:
+                    const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
+                decoration: BoxDecoration(
+                  color: isSelected ? AppColors.primaryLight : Colors.white,
+                  borderRadius: BorderRadius.circular(16),
+                  border: Border.all(
+                    color: isSelected
+                        ? AppColors.primary
+                        : const Color(0xFFDDDDDD),
+                  ),
+                ),
+                child: Text(
+                  s['label']!,
+                  style: TextStyle(
+                    color: isSelected
+                        ? AppColors.primary
+                        : AppColors.textSecondary,
+                    fontSize: 12,
+                  ),
+                ),
+              ),
+            ),
+          );
+        }).toList(),
+      ),
+    );
+    return Container(
+      color: Colors.white,
+      padding: const EdgeInsets.symmetric(vertical: 8),
+      child: r.isWide
+          ? Center(
+              child: SizedBox(width: r.listMaxWidth, child: filterBar))
+          : filterBar,
+    );
+  }
+
+  Widget _buildItem(ExpenseModel item) {
+    return Slidable(
+      endActionPane: ActionPane(
+        motion: const ScrollMotion(),
+        children: [
+          SlidableAction(
+            onPressed: (_) => context.go('/expense/apply?id=${item.id}'),
+            backgroundColor: AppColors.primary,
+            foregroundColor: Colors.white,
+            icon: Icons.edit,
+            label: '编辑',
+          ),
+        ],
+      ),
+      child: AppCard(
+        onTap: () => context.go('/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)),
+              ],
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 267 - 0
lib/features/expense/expense_model.dart

@@ -0,0 +1,267 @@
+import '../../shared/models/approval_status.dart';
+
+class ExpenseModel {
+  final String id;
+  final String reportNo;
+  final String applicantId;
+  final String applicantName;
+  final String deptId;
+  final String deptName;
+  final String expenseType;
+  final double totalAmount;
+  final int invoiceCount;
+  final String costCenterId;
+  final String projectId;
+  final String projectName;
+  final String budgetSubjectId;
+  final double loanWriteoffAmount;
+  final String paymentMethod;
+  final String accountId;
+  final String accountName;
+  final String remark;
+  final String status;
+  final String currentApproverId;
+  final List<String> approvalChain;
+  final DateTime createTime;
+  final DateTime updateTime;
+  final List<ExpenseDetailModel> details;
+  final List<ApprovalRecord> approvalRecords;
+
+  const ExpenseModel({
+    required this.id,
+    required this.reportNo,
+    required this.applicantId,
+    required this.applicantName,
+    required this.deptId,
+    required this.deptName,
+    required this.expenseType,
+    required this.totalAmount,
+    this.invoiceCount = 0,
+    this.costCenterId = '',
+    this.projectId = '',
+    this.projectName = '',
+    this.budgetSubjectId = '',
+    this.loanWriteoffAmount = 0.0,
+    this.paymentMethod = '',
+    this.accountId = '',
+    this.accountName = '',
+    this.remark = '',
+    this.status = 'draft',
+    this.currentApproverId = '',
+    this.approvalChain = const [],
+    required this.createTime,
+    required this.updateTime,
+    this.details = const [],
+    this.approvalRecords = const [],
+  });
+
+  factory ExpenseModel.fromJson(Map<String, dynamic> json) {
+    return ExpenseModel(
+      id: json['id'] as String,
+      reportNo: json['reportNo'] as String? ?? '',
+      applicantId: json['applicantId'] as String? ?? '',
+      applicantName: json['applicantName'] as String? ?? '',
+      deptId: json['deptId'] as String? ?? '',
+      deptName: json['deptName'] as String? ?? '',
+      expenseType: json['expenseType'] as String? ?? '',
+      totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0.0,
+      invoiceCount: json['invoiceCount'] as int? ?? 0,
+      costCenterId: json['costCenterId'] as String? ?? '',
+      projectId: json['projectId'] as String? ?? '',
+      projectName: json['projectName'] as String? ?? '',
+      budgetSubjectId: json['budgetSubjectId'] as String? ?? '',
+      loanWriteoffAmount:
+          (json['loanWriteoffAmount'] as num?)?.toDouble() ?? 0.0,
+      paymentMethod: json['paymentMethod'] as String? ?? '',
+      accountId: json['accountId'] as String? ?? '',
+      accountName: json['accountName'] as String? ?? '',
+      remark: json['remark'] as String? ?? '',
+      status: json['status'] as String? ?? 'draft',
+      currentApproverId: json['currentApproverId'] as String? ?? '',
+      approvalChain:
+          (json['approvalChain'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          [],
+      createTime: DateTime.parse(json['createTime'] as String),
+      updateTime: DateTime.parse(json['updateTime'] as String),
+      details:
+          (json['details'] as List<dynamic>?)
+              ?.map(
+                  (e) => ExpenseDetailModel.fromJson(e as Map<String, dynamic>))
+              .toList() ??
+          [],
+      approvalRecords:
+          (json['approvalRecords'] as List<dynamic>?)
+              ?.map((e) => ApprovalRecord.fromJson(e as Map<String, dynamic>))
+              .toList() ??
+          [],
+    );
+  }
+
+  Map<String, dynamic> toJson() => {
+        'id': id,
+        'reportNo': reportNo,
+        'applicantId': applicantId,
+        'applicantName': applicantName,
+        'deptId': deptId,
+        'deptName': deptName,
+        'expenseType': expenseType,
+        'totalAmount': totalAmount,
+        'invoiceCount': invoiceCount,
+        'costCenterId': costCenterId,
+        'projectId': projectId,
+        'projectName': projectName,
+        'budgetSubjectId': budgetSubjectId,
+        'loanWriteoffAmount': loanWriteoffAmount,
+        'paymentMethod': paymentMethod,
+        'accountId': accountId,
+        'accountName': accountName,
+        'remark': remark,
+        'status': status,
+        'currentApproverId': currentApproverId,
+        'approvalChain': approvalChain,
+        'createTime': createTime.toIso8601String(),
+        'updateTime': updateTime.toIso8601String(),
+        'details': details.map((d) => d.toJson()).toList(),
+        'approvalRecords': approvalRecords.map((r) => r.toJson()).toList(),
+      };
+
+  ExpenseModel copyWith({
+    String? id,
+    String? reportNo,
+    String? applicantId,
+    String? applicantName,
+    String? deptId,
+    String? deptName,
+    String? expenseType,
+    double? totalAmount,
+    int? invoiceCount,
+    String? costCenterId,
+    String? projectId,
+    String? projectName,
+    String? budgetSubjectId,
+    double? loanWriteoffAmount,
+    String? paymentMethod,
+    String? accountId,
+    String? accountName,
+    String? remark,
+    String? status,
+    String? currentApproverId,
+    List<String>? approvalChain,
+    DateTime? createTime,
+    DateTime? updateTime,
+    List<ExpenseDetailModel>? details,
+    List<ApprovalRecord>? approvalRecords,
+  }) {
+    return ExpenseModel(
+      id: id ?? this.id,
+      reportNo: reportNo ?? this.reportNo,
+      applicantId: applicantId ?? this.applicantId,
+      applicantName: applicantName ?? this.applicantName,
+      deptId: deptId ?? this.deptId,
+      deptName: deptName ?? this.deptName,
+      expenseType: expenseType ?? this.expenseType,
+      totalAmount: totalAmount ?? this.totalAmount,
+      invoiceCount: invoiceCount ?? this.invoiceCount,
+      costCenterId: costCenterId ?? this.costCenterId,
+      projectId: projectId ?? this.projectId,
+      projectName: projectName ?? this.projectName,
+      budgetSubjectId: budgetSubjectId ?? this.budgetSubjectId,
+      loanWriteoffAmount: loanWriteoffAmount ?? this.loanWriteoffAmount,
+      paymentMethod: paymentMethod ?? this.paymentMethod,
+      accountId: accountId ?? this.accountId,
+      accountName: accountName ?? this.accountName,
+      remark: remark ?? this.remark,
+      status: status ?? this.status,
+      currentApproverId: currentApproverId ?? this.currentApproverId,
+      approvalChain: approvalChain ?? this.approvalChain,
+      createTime: createTime ?? this.createTime,
+      updateTime: updateTime ?? this.updateTime,
+      details: details ?? this.details,
+      approvalRecords: approvalRecords ?? this.approvalRecords,
+    );
+  }
+}
+
+class ExpenseDetailModel {
+  final String id;
+  final String expenseId;
+  final DateTime expenseDate;
+  final String expenseType;
+  final String expenseDesc;
+  final double amount;
+  final double taxAmount;
+  final double totalAmount;
+  final String invoiceNo;
+  final String invoiceCode;
+  final String invoiceType;
+  final bool isDeductible;
+  final double taxRate;
+  final String remark;
+  final List<String> attachments;
+  final int sortOrder;
+
+  const ExpenseDetailModel({
+    required this.id,
+    required this.expenseId,
+    required this.expenseDate,
+    required this.expenseType,
+    required this.expenseDesc,
+    required this.amount,
+    this.taxAmount = 0.0,
+    required this.totalAmount,
+    this.invoiceNo = '',
+    this.invoiceCode = '',
+    this.invoiceType = '',
+    this.isDeductible = false,
+    this.taxRate = 0.0,
+    this.remark = '',
+    this.attachments = const [],
+    this.sortOrder = 1,
+  });
+
+  factory ExpenseDetailModel.fromJson(Map<String, dynamic> json) {
+    return ExpenseDetailModel(
+      id: json['id'] as String,
+      expenseId: json['expenseId'] as String? ?? '',
+      expenseDate: DateTime.parse(json['expenseDate'] as String),
+      expenseType: json['expenseType'] as String? ?? '',
+      expenseDesc: json['expenseDesc'] as String? ?? '',
+      amount: (json['amount'] as num?)?.toDouble() ?? 0.0,
+      taxAmount: (json['taxAmount'] as num?)?.toDouble() ?? 0.0,
+      totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0.0,
+      invoiceNo: json['invoiceNo'] as String? ?? '',
+      invoiceCode: json['invoiceCode'] as String? ?? '',
+      invoiceType: json['invoiceType'] as String? ?? '',
+      isDeductible: json['isDeductible'] as bool? ?? false,
+      taxRate: (json['taxRate'] as num?)?.toDouble() ?? 0.0,
+      remark: json['remark'] as String? ?? '',
+      attachments:
+          (json['attachments'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          [],
+      sortOrder: json['sortOrder'] as int? ?? 1,
+    );
+  }
+
+  Map<String, dynamic> toJson() => {
+        'id': id,
+        'expenseId': expenseId,
+        'expenseDate': expenseDate.toIso8601String(),
+        'expenseType': expenseType,
+        'expenseDesc': expenseDesc,
+        'amount': amount,
+        'taxAmount': taxAmount,
+        'totalAmount': totalAmount,
+        'invoiceNo': invoiceNo,
+        'invoiceCode': invoiceCode,
+        'invoiceType': invoiceType,
+        'isDeductible': isDeductible,
+        'taxRate': taxRate,
+        'remark': remark,
+        'attachments': attachments,
+        'sortOrder': sortOrder,
+      };
+}

+ 0 - 0
lib/features/home/.gitkeep


+ 42 - 0
lib/features/home/home_controller.dart

@@ -0,0 +1,42 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../app.dart';
+
+class HomeSummary {
+  final int expensePending;
+  final int overtimePending;
+  final int vehiclePending;
+  final int logCount;
+  final int announcementCount;
+  final int announcementUnread;
+  final int totalCount;
+
+  const HomeSummary({
+    required this.expensePending,
+    required this.overtimePending,
+    required this.vehiclePending,
+    required this.logCount,
+    required this.announcementCount,
+    this.announcementUnread = 0,
+    required this.totalCount,
+  });
+
+  factory HomeSummary.fromJson(dynamic json) {
+    final map = json as Map<String, dynamic>;
+    return HomeSummary(
+      expensePending: map['expensePending'] as int? ?? 0,
+      overtimePending: map['overtimePending'] as int? ?? 0,
+      vehiclePending: map['vehiclePending'] as int? ?? 0,
+      logCount: map['logCount'] as int? ?? 0,
+      announcementCount: map['announcementCount'] as int? ?? 0,
+      announcementUnread: map['announcementUnread'] as int? ?? 0,
+      totalCount: map['totalCount'] as int? ?? 0,
+    );
+  }
+}
+
+final homeSummaryProvider = FutureProvider<HomeSummary>((ref) async {
+  final client = ref.read(apiClientProvider);
+  final response =
+      await client.get('/home/summary', fromJsonT: HomeSummary.fromJson);
+  return response.data!;
+});

+ 213 - 0
lib/features/home/home_page.dart

@@ -0,0 +1,213 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter/services.dart';
+import 'package:go_router/go_router.dart';
+import '../../core/theme/app_colors.dart';
+import '../../core/utils/responsive.dart';
+import 'home_controller.dart';
+
+class HomePage extends ConsumerWidget {
+  const HomePage({super.key});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final summaryAsync = ref.watch(homeSummaryProvider);
+    final canPop = Navigator.of(context).canPop();
+
+    return Scaffold(
+      appBar: AppBar(
+        leading: canPop
+            ? null
+            : IconButton(
+                icon: const Icon(Icons.close),
+                onPressed: () => SystemNavigator.pop(),
+              ),
+        title: const Text('TBOSS · 工作台'),
+      ),
+      body: summaryAsync.when(
+        loading: () =>
+            const Center(child: CircularProgressIndicator()),
+        error: (_, __) => const Center(child: Text('加载失败')),
+        data: (summary) => LayoutBuilder(
+          builder: (context, constraints) {
+            final r = ResponsiveHelper(
+              width: constraints.maxWidth,
+              height: constraints.maxHeight,
+              isLandscape: constraints.maxWidth > constraints.maxHeight,
+              isWide: constraints.maxWidth > 600,
+            );
+            return _buildContent(context, summary, r);
+          },
+        ),
+      ),
+    );
+  }
+
+  Widget _buildContent(
+      BuildContext context, HomeSummary summary, ResponsiveHelper r) {
+    return SingleChildScrollView(
+      padding: const EdgeInsets.all(12),
+      child: Column(
+        children: [
+          _buildSection(
+            title: '我的审批',
+            r: r,
+            items: [
+              _EntryItem(
+                icon: Icons.receipt_long,
+                label: '报销',
+                badge: '${summary.expensePending}待办',
+                color: AppColors.primary,
+                onTap: () => context.go('/expense/list'),
+              ),
+              _EntryItem(
+                icon: Icons.access_time,
+                label: '加班',
+                badge: '${summary.overtimePending}待办',
+                color: AppColors.primary,
+                onTap: () => context.go('/overtime/list'),
+              ),
+              _EntryItem(
+                icon: Icons.directions_car,
+                label: '用车',
+                badge: '${summary.vehiclePending}待办',
+                color: AppColors.warning,
+                onTap: () => context.go('/vehicle/list'),
+              ),
+            ],
+          ),
+          const SizedBox(height: 12),
+          _buildSection(
+            title: '我的数据',
+            r: r,
+            items: [
+              _EntryItem(
+                icon: Icons.edit_note,
+                label: '外出日志',
+                badge: '${summary.logCount}条',
+                color: AppColors.success,
+                onTap: () => context.go('/outing-log/list'),
+              ),
+              _EntryItem(
+                icon: Icons.campaign,
+                label: '公告通知',
+                badge: summary.announcementUnread > 0
+                    ? '${summary.announcementUnread}未读'
+                    : '${summary.announcementCount}条',
+                color: const Color(0xFF722ED1),
+                onTap: () => context.go('/announcement/list'),
+              ),
+              _EntryItem(
+                icon: Icons.description,
+                label: '全部单据',
+                badge: '${summary.totalCount}条',
+                color: const Color(0xFFEB2F96),
+                onTap: () {},
+              ),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildSection({
+    required String title,
+    required ResponsiveHelper r,
+    required List<_EntryItem> items,
+  }) {
+    return Container(
+      padding: const EdgeInsets.all(16),
+      decoration: BoxDecoration(
+        color: AppColors.cardWhite,
+        borderRadius: BorderRadius.circular(12),
+        boxShadow: [
+          BoxShadow(
+            color: Colors.black.withValues(alpha: 0.04),
+            blurRadius: 4,
+            offset: const Offset(0, 1),
+          ),
+        ],
+      ),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Text(
+            title,
+            style: const TextStyle(
+              fontSize: 15,
+              fontWeight: FontWeight.w600,
+              color: AppColors.textPrimary,
+            ),
+          ),
+          const SizedBox(height: 14),
+          if (r.isLandscape)
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceAround,
+              children: items
+                  .map((item) => Expanded(child: _buildEntry(item)))
+                  .toList(),
+            )
+          else
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceAround,
+              children: items.map((item) => _buildEntry(item)).toList(),
+            ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildEntry(_EntryItem item) {
+    return GestureDetector(
+      onTap: item.onTap,
+      child: Column(
+        children: [
+          Container(
+            width: 48,
+            height: 48,
+            decoration: BoxDecoration(
+              color: item.color,
+              borderRadius: BorderRadius.circular(24),
+            ),
+            child: Icon(item.icon, color: Colors.white, size: 24),
+          ),
+          const SizedBox(height: 8),
+          Text(
+            item.label,
+            style: const TextStyle(
+              fontSize: 12,
+              color: AppColors.textSecondary,
+            ),
+          ),
+          const SizedBox(height: 2),
+          Text(
+            item.badge,
+            style: TextStyle(
+              fontSize: 10,
+              color: item.badge.contains('0')
+                  ? AppColors.textHint
+                  : AppColors.error,
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class _EntryItem {
+  final IconData icon;
+  final String label;
+  final String badge;
+  final Color color;
+  final VoidCallback onTap;
+
+  const _EntryItem({
+    required this.icon,
+    required this.label,
+    required this.badge,
+    required this.color,
+    required this.onTap,
+  });
+}

+ 0 - 0
lib/features/outing_log/.gitkeep


+ 44 - 0
lib/features/outing_log/outing_log_api.dart

@@ -0,0 +1,44 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/network/api_client.dart';
+import '../../app.dart';
+import '../../shared/models/pagination_model.dart';
+import 'outing_log_model.dart';
+
+final outingLogApiProvider = Provider<OutingLogApi>(
+    (ref) => OutingLogApi(ref.read(apiClientProvider)));
+
+class OutingLogApi {
+  final ApiClient _client;
+  OutingLogApi(this._client);
+
+  Future<PaginatedData<OutingLogModel>> fetchList({
+    int page = 1,
+    int size = 20,
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/outing-log/list',
+      queryParameters: {'page': page, 'size': size},
+    );
+    final data = response.data!;
+    final list = (data['list'] as List<dynamic>)
+        .map((e) => OutingLogModel.fromJson(e as Map<String, dynamic>))
+        .toList();
+    return PaginatedData(
+      list: list,
+      page: data['page'] as int,
+      size: data['size'] as int,
+      total: data['total'] as int,
+    );
+  }
+
+  Future<OutingLogModel> fetchDetail(String id) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/outing-log/detail/$id',
+    );
+    return OutingLogModel.fromJson(response.data!);
+  }
+
+  Future<void> create(OutingLogModel model) async {
+    await _client.post('/outing-log/create', data: model.toJson());
+  }
+}

+ 105 - 0
lib/features/outing_log/outing_log_create_page.dart

@@ -0,0 +1,105 @@
+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/responsive.dart';
+import '../../shared/widgets/form_section.dart';
+import 'outing_log_api.dart';
+import 'outing_log_model.dart';
+
+class OutingLogCreatePage extends ConsumerStatefulWidget {
+  const OutingLogCreatePage({super.key});
+  @override
+  ConsumerState<OutingLogCreatePage> createState() => _OutingLogCreatePageState();
+}
+
+class _OutingLogCreatePageState extends ConsumerState<OutingLogCreatePage> {
+  final _contentCtrl = TextEditingController();
+  final _clientCtrl = TextEditingController();
+  final _addressCtrl = TextEditingController();
+  DateTime _date = DateTime.now();
+  bool _isSubmitting = false;
+
+  @override
+  void dispose() {
+    _contentCtrl.dispose();
+    _clientCtrl.dispose();
+    _addressCtrl.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(title: const Text('新建外出日志')),
+      body: Column(
+        children: [
+          Expanded(
+            child: Center(
+              child: ConstrainedBox(
+                constraints: BoxConstraints(maxWidth: r.formMaxWidth),
+                child: SingleChildScrollView(
+                  padding: const EdgeInsets.symmetric(vertical: 8),
+                  child: FormSection(
+                    title: '日志信息',
+                    children: [
+                      ListTile(
+                        title: const Text('日期', style: TextStyle(color: AppColors.textSecondary, fontSize: 13)),
+                        trailing: Text('${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
+                            style: const TextStyle(color: AppColors.textPrimary, fontSize: 13)),
+                        onTap: () async {
+                          final picked = await showDatePicker(context: context, initialDate: _date, firstDate: DateTime(2020), lastDate: DateTime(2030));
+                          if (picked != null) setState(() => _date = picked);
+                        },
+                      ),
+                      _buildTextField('拜访客户', _clientCtrl),
+                      _buildTextField('拜访地点', _addressCtrl),
+                      _buildTextField('日志内容', _contentCtrl, maxLines: 5),
+                    ],
+                  ),
+                ),
+              ),
+            ),
+          ),
+          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: ElevatedButton(
+              onPressed: _isSubmitting ? null : _submit,
+              child: _isSubmitting ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Text('提交'),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildTextField(String label, TextEditingController ctrl, {int maxLines = 1}) {
+    return Padding(
+      padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
+      child: TextField(
+        controller: ctrl, maxLines: maxLines,
+        decoration: InputDecoration(labelText: label, border: const OutlineInputBorder(), floatingLabelStyle: const TextStyle(color: AppColors.primary)),
+      ),
+    );
+  }
+
+  Future<void> _submit() async {
+    setState(() => _isSubmitting = true);
+    try {
+      await ref.read(outingLogApiProvider).create(OutingLogModel(
+        id: '', visitNo: '', salespersonId: '', salespersonName: '',
+        deptId: '', deptName: '', customerName: _clientCtrl.text,
+        visitDate: _date, visitStartTime: _date, visitEndTime: _date,
+        visitType: '常规拜访', visitPurpose: '', visitLocation: _addressCtrl.text,
+        visitSummary: _contentCtrl.text, nextVisitTime: _date,
+        createTime: DateTime.now(), updateTime: DateTime.now(),
+      ));
+      if (context.mounted) context.pop();
+    } catch (_) {} finally {
+      if (mounted) setState(() => _isSubmitting = false);
+    }
+  }
+}

+ 64 - 0
lib/features/outing_log/outing_log_detail_page.dart

@@ -0,0 +1,64 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/theme/app_colors.dart';
+import '../../core/utils/date_utils.dart' as du;
+import '../../core/utils/responsive.dart';
+import '../../shared/widgets/form_section.dart';
+import '../../shared/widgets/form_field_row.dart';
+import 'outing_log_api.dart';
+import 'outing_log_model.dart';
+
+final outingLogDetailProvider = FutureProvider.autoDispose.family<OutingLogModel, String>((ref, id) async {
+  return ref.read(outingLogApiProvider).fetchDetail(id);
+});
+
+class OutingLogDetailPage extends ConsumerWidget {
+  final String id;
+  const OutingLogDetailPage({super.key, required this.id});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final detailAsync = ref.watch(outingLogDetailProvider(id));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(title: const Text('日志详情')),
+      body: detailAsync.when(
+        loading: () => const Center(child: CircularProgressIndicator()),
+        error: (_, __) => const Center(child: Text('加载失败')),
+        data: (log) => Center(
+          child: ConstrainedBox(
+            constraints: BoxConstraints(maxWidth: r.detailTwoColumns ? 700 : double.infinity),
+            child: SingleChildScrollView(
+              padding: const EdgeInsets.symmetric(vertical: 8),
+              child: FormSection(
+                title: '外出日志',
+                children: [
+                  FormFieldRow(label: '拜访单号', value: log.visitNo, showArrow: false),
+                  FormFieldRow(label: '日期', value: du.DateUtils.formatDate(log.visitDate), showArrow: false),
+                  FormFieldRow(label: '拜访客户', value: log.customerName.isEmpty ? '-' : log.customerName, showArrow: false),
+                  FormFieldRow(label: '联系人', value: log.contactName.isEmpty ? '-' : log.contactName, showArrow: false),
+                  FormFieldRow(label: '拜访方式', value: log.visitType, showArrow: false),
+                  FormFieldRow(label: '拜访地点', value: log.visitLocation.isEmpty ? '-' : log.visitLocation, showArrow: false),
+                  FormFieldRow(label: '拜访结果', value: log.visitResult.isEmpty ? '-' : log.visitResult, showArrow: false),
+                  FormFieldRow(label: '打卡校验', value: log.checkInStatus, showArrow: false),
+                  FormFieldRow(label: '创建人', value: log.salespersonName, showArrow: false),
+                  FormFieldRow(label: '创建时间', value: du.DateUtils.formatDateTime(log.createTime), showArrow: false),
+                  const SizedBox(height: 8),
+                  const Padding(
+                    padding: EdgeInsets.symmetric(horizontal: 14),
+                    child: Text('日志内容', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textSecondary)),
+                  ),
+                  Padding(
+                    padding: const EdgeInsets.all(14),
+                    child: Text(log.visitSummary.isEmpty ? '-' : log.visitSummary, style: const TextStyle(color: AppColors.textPrimary, fontSize: 14, height: 1.6)),
+                  ),
+                ],
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 11 - 0
lib/features/outing_log/outing_log_list_controller.dart

@@ -0,0 +1,11 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'outing_log_model.dart';
+import 'outing_log_api.dart';
+
+final outingLogListProvider =
+    FutureProvider.autoDispose.family<List<OutingLogModel>, int>(
+        (ref, page) async {
+  final api = ref.watch(outingLogApiProvider);
+  final result = await api.fetchList(page: page);
+  return result.list;
+});

+ 85 - 0
lib/features/outing_log/outing_log_list_page.dart

@@ -0,0 +1,85 @@
+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/empty_state.dart';
+import '../../shared/widgets/loading_widget.dart';
+import 'outing_log_list_controller.dart';
+import 'outing_log_model.dart';
+
+class OutingLogListPage extends ConsumerStatefulWidget {
+  const OutingLogListPage({super.key});
+  @override
+  ConsumerState<OutingLogListPage> createState() => _OutingLogListPageState();
+}
+
+class _OutingLogListPageState extends ConsumerState<OutingLogListPage> {
+  int _page = 1;
+
+  @override
+  Widget build(BuildContext context) {
+    final itemsAsync = ref.watch(outingLogListProvider(_page));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('外出日志'),
+        actions: [
+          TextButton(
+            onPressed: () => context.go('/outing-log/create'),
+            child: const Text('+ 新建', style: TextStyle(color: Colors.white, fontSize: 13)),
+          ),
+        ],
+      ),
+      body: 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: '暂无外出日志')
+                : ListView.builder(
+                    padding: const EdgeInsets.symmetric(vertical: 4),
+                    itemCount: items.length,
+                    itemBuilder: (_, index) => _buildItem(items[index]),
+                  ),
+          ),
+        ),
+      ),
+    );
+  }
+
+  Widget _buildItem(OutingLogModel item) {
+    return AppCard(
+      onTap: () => context.go('/outing-log/detail/${item.id}'),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              Text(du.DateUtils.formatDate(item.visitDate),
+                  style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: AppColors.textPrimary)),
+              Text(item.customerName, style: const TextStyle(color: AppColors.primary, fontSize: 12)),
+            ],
+          ),
+          const SizedBox(height: 4),
+          Text(item.visitSummary, maxLines: 2, overflow: TextOverflow.ellipsis,
+              style: const TextStyle(color: AppColors.textSecondary, fontSize: 12)),
+          const SizedBox(height: 4),
+          Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              Text(item.visitLocation, style: const TextStyle(color: AppColors.textHint, fontSize: 11)),
+              Text(item.salespersonName, style: const TextStyle(color: AppColors.textHint, fontSize: 11)),
+            ],
+          ),
+        ],
+      ),
+    );
+  }
+}

+ 173 - 0
lib/features/outing_log/outing_log_model.dart

@@ -0,0 +1,173 @@
+class OutingLogModel {
+  final String id;
+  final String visitNo;
+  final String salespersonId;
+  final String salespersonName;
+  final String deptId;
+  final String deptName;
+  final String customerId;
+  final String customerName;
+  final String contactId;
+  final String contactName;
+  final String contactPhone;
+  final String contactPosition;
+  final DateTime visitDate;
+  final DateTime visitStartTime;
+  final DateTime visitEndTime;
+  final String visitType;
+  final String visitPurpose;
+  final String visitLocation;
+  final String visitAddressDetail;
+  final double longitude;
+  final double latitude;
+  final String signInPhoto;
+  final String signOutPhoto;
+  final List<String> visitPhotos;
+  final String participants;
+  final String visitSummary;
+  final String visitResult;
+  final String customerFeedback;
+  final String competitorInfo;
+  final DateTime nextVisitTime;
+  final String nextVisitContent;
+  final String checkInStatus;
+  final int distanceDeviation;
+  final String materials;
+  final String status;
+  final List<String> attachments;
+  final DateTime createTime;
+  final DateTime updateTime;
+
+  const OutingLogModel({
+    required this.id,
+    required this.visitNo,
+    required this.salespersonId,
+    required this.salespersonName,
+    required this.deptId,
+    required this.deptName,
+    this.customerId = '',
+    required this.customerName,
+    this.contactId = '',
+    this.contactName = '',
+    this.contactPhone = '',
+    this.contactPosition = '',
+    required this.visitDate,
+    required this.visitStartTime,
+    required this.visitEndTime,
+    required this.visitType,
+    required this.visitPurpose,
+    required this.visitLocation,
+    this.visitAddressDetail = '',
+    this.longitude = 0.0,
+    this.latitude = 0.0,
+    this.signInPhoto = '',
+    this.signOutPhoto = '',
+    this.visitPhotos = const [],
+    this.participants = '',
+    required this.visitSummary,
+    this.visitResult = '',
+    this.customerFeedback = '',
+    this.competitorInfo = '',
+    required this.nextVisitTime,
+    this.nextVisitContent = '',
+    this.checkInStatus = '正常',
+    this.distanceDeviation = 0,
+    this.materials = '',
+    this.status = '已完成',
+    this.attachments = const [],
+    required this.createTime,
+    required this.updateTime,
+  });
+
+  factory OutingLogModel.fromJson(Map<String, dynamic> json) {
+    return OutingLogModel(
+      id: json['id'] as String,
+      visitNo: json['visitNo'] as String? ?? '',
+      salespersonId: json['salespersonId'] as String? ?? '',
+      salespersonName: json['salespersonName'] as String? ?? '',
+      deptId: json['deptId'] as String? ?? '',
+      deptName: json['deptName'] as String? ?? '',
+      customerId: json['customerId'] as String? ?? '',
+      customerName: json['customerName'] as String? ?? '',
+      contactId: json['contactId'] as String? ?? '',
+      contactName: json['contactName'] as String? ?? '',
+      contactPhone: json['contactPhone'] as String? ?? '',
+      contactPosition: json['contactPosition'] as String? ?? '',
+      visitDate: DateTime.parse(json['visitDate'] as String),
+      visitStartTime: DateTime.parse(json['visitStartTime'] as String),
+      visitEndTime: DateTime.parse(json['visitEndTime'] as String),
+      visitType: json['visitType'] as String? ?? '常规拜访',
+      visitPurpose: json['visitPurpose'] as String? ?? '',
+      visitLocation: json['visitLocation'] as String? ?? '',
+      visitAddressDetail: json['visitAddressDetail'] as String? ?? '',
+      longitude: (json['longitude'] as num?)?.toDouble() ?? 0.0,
+      latitude: (json['latitude'] as num?)?.toDouble() ?? 0.0,
+      signInPhoto: json['signInPhoto'] as String? ?? '',
+      signOutPhoto: json['signOutPhoto'] as String? ?? '',
+      visitPhotos:
+          (json['visitPhotos'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          [],
+      participants: json['participants'] as String? ?? '',
+      visitSummary: json['visitSummary'] as String? ?? '',
+      visitResult: json['visitResult'] as String? ?? '',
+      customerFeedback: json['customerFeedback'] as String? ?? '',
+      competitorInfo: json['competitorInfo'] as String? ?? '',
+      nextVisitTime: DateTime.parse(json['nextVisitTime'] as String),
+      nextVisitContent: json['nextVisitContent'] as String? ?? '',
+      checkInStatus: json['checkInStatus'] as String? ?? '正常',
+      distanceDeviation: json['distanceDeviation'] as int? ?? 0,
+      materials: json['materials'] as String? ?? '',
+      status: json['status'] as String? ?? '已完成',
+      attachments:
+          (json['attachments'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          [],
+      createTime: DateTime.parse(json['createTime'] as String),
+      updateTime: DateTime.parse(json['updateTime'] as String),
+    );
+  }
+
+  Map<String, dynamic> toJson() => {
+        'id': id,
+        'visitNo': visitNo,
+        'salespersonId': salespersonId,
+        'salespersonName': salespersonName,
+        'deptId': deptId,
+        'deptName': deptName,
+        'customerId': customerId,
+        'customerName': customerName,
+        'contactId': contactId,
+        'contactName': contactName,
+        'contactPhone': contactPhone,
+        'contactPosition': contactPosition,
+        'visitDate': visitDate.toIso8601String(),
+        'visitStartTime': visitStartTime.toIso8601String(),
+        'visitEndTime': visitEndTime.toIso8601String(),
+        'visitType': visitType,
+        'visitPurpose': visitPurpose,
+        'visitLocation': visitLocation,
+        'visitAddressDetail': visitAddressDetail,
+        'longitude': longitude,
+        'latitude': latitude,
+        'signInPhoto': signInPhoto,
+        'signOutPhoto': signOutPhoto,
+        'visitPhotos': visitPhotos,
+        'participants': participants,
+        'visitSummary': visitSummary,
+        'visitResult': visitResult,
+        'customerFeedback': customerFeedback,
+        'competitorInfo': competitorInfo,
+        'nextVisitTime': nextVisitTime.toIso8601String(),
+        'nextVisitContent': nextVisitContent,
+        'checkInStatus': checkInStatus,
+        'distanceDeviation': distanceDeviation,
+        'materials': materials,
+        'status': status,
+        'attachments': attachments,
+        'createTime': createTime.toIso8601String(),
+        'updateTime': updateTime.toIso8601String(),
+      };
+}

+ 0 - 0
lib/features/overtime/.gitkeep


+ 49 - 0
lib/features/overtime/overtime_api.dart

@@ -0,0 +1,49 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/network/api_client.dart';
+import '../../app.dart';
+import '../../shared/models/pagination_model.dart';
+import 'overtime_model.dart';
+
+final overtimeApiProvider = Provider<OvertimeApi>(
+    (ref) => OvertimeApi(ref.read(apiClientProvider)));
+
+class OvertimeApi {
+  final ApiClient _client;
+  OvertimeApi(this._client);
+
+  Future<PaginatedData<OvertimeModel>> fetchList({
+    String status = '',
+    int page = 1,
+    int size = 20,
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/overtime/list',
+      queryParameters: {'status': status, 'page': page, 'size': size},
+    );
+    final data = response.data!;
+    final list = (data['list'] as List<dynamic>)
+        .map((e) => OvertimeModel.fromJson(e as Map<String, dynamic>))
+        .toList();
+    return PaginatedData(
+      list: list,
+      page: data['page'] as int,
+      size: data['size'] as int,
+      total: data['total'] as int,
+    );
+  }
+
+  Future<OvertimeModel> fetchDetail(String id) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/overtime/detail/$id',
+    );
+    return OvertimeModel.fromJson(response.data!);
+  }
+
+  Future<void> submit(OvertimeModel model) async {
+    await _client.post('/overtime/apply', data: model.toJson());
+  }
+
+  Future<void> saveDraft(OvertimeModel model) async {
+    await _client.put('/overtime/draft', data: model.toJson());
+  }
+}

+ 49 - 0
lib/features/overtime/overtime_apply_controller.dart

@@ -0,0 +1,49 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'overtime_model.dart';
+import 'overtime_api.dart';
+
+class OvertimeApplyState {
+  final OvertimeModel overtime;
+  final bool isSubmitting;
+  const OvertimeApplyState({required this.overtime, this.isSubmitting = false});
+  OvertimeApplyState copyWith({OvertimeModel? overtime, bool? isSubmitting}) =>
+      OvertimeApplyState(overtime: overtime ?? this.overtime, isSubmitting: isSubmitting ?? this.isSubmitting);
+}
+
+class OvertimeApplyController extends StateNotifier<OvertimeApplyState> {
+  final OvertimeApi _api;
+  OvertimeApplyController(this._api)
+      : super(OvertimeApplyState(overtime: OvertimeModel(
+          id: '', applicationNo: '', applicantId: '', applicantName: '',
+          deptId: '', deptName: '', otDate: DateTime.now(),
+          startTime: DateTime.now(), endTime: DateTime.now().add(const Duration(hours: 2)),
+          otHours: 2.0, otType: '工作日加班', compensationType: '加班费',
+          reason: '', createTime: DateTime.now(), updateTime: DateTime.now(),
+        )));
+
+  void updateType(String t) => state = state.copyWith(overtime: state.overtime.copyWith(otType: t));
+  void updateCompensation(String c) => state = state.copyWith(overtime: state.overtime.copyWith(compensationType: c));
+  void updateStartTime(DateTime t) { state = state.copyWith(overtime: state.overtime.copyWith(startTime: t)); _recalc(); }
+  void updateEndTime(DateTime t) { state = state.copyWith(overtime: state.overtime.copyWith(endTime: t)); _recalc(); }
+  void updateReason(String r) => state = state.copyWith(overtime: state.overtime.copyWith(reason: r));
+  void _recalc() {
+    final d = state.overtime.endTime.difference(state.overtime.startTime).inMinutes / 60.0;
+    state = state.copyWith(overtime: state.overtime.copyWith(otHours: d));
+  }
+  Future<bool> submit() async {
+    state = state.copyWith(isSubmitting: true);
+    try { await _api.submit(state.overtime.copyWith(status: 'pending')); return true; }
+    catch (_) { return false; }
+    finally { state = state.copyWith(isSubmitting: false); }
+  }
+  Future<bool> saveDraft() async {
+    state = state.copyWith(isSubmitting: true);
+    try { await _api.saveDraft(state.overtime); return true; }
+    catch (_) { return false; }
+    finally { state = state.copyWith(isSubmitting: false); }
+  }
+}
+
+final overtimeApplyProvider = StateNotifierProvider.autoDispose.family<OvertimeApplyController, OvertimeApplyState, String?>((ref, editId) {
+  return OvertimeApplyController(ref.read(overtimeApiProvider));
+});

+ 158 - 0
lib/features/overtime/overtime_apply_page.dart

@@ -0,0 +1,158 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import '../../core/utils/date_utils.dart' as du;
+import '../../core/utils/responsive.dart';
+import '../../shared/widgets/form_section.dart';
+import '../../shared/widgets/form_field_row.dart';
+import 'overtime_apply_controller.dart';
+
+class OvertimeApplyPage extends ConsumerStatefulWidget {
+  final String? editId;
+  const OvertimeApplyPage({super.key, this.editId});
+  @override
+  ConsumerState<OvertimeApplyPage> createState() => _OvertimeApplyPageState();
+}
+
+class _OvertimeApplyPageState extends ConsumerState<OvertimeApplyPage> {
+  final _reasonController = TextEditingController();
+  static const _types = ['工作日加班', '休息日加班', '节假日加班'];
+  static const _compensations = ['加班费', '调休'];
+
+  @override
+  void dispose() {
+    _reasonController.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final ctrl =
+        ref.watch(overtimeApplyProvider(widget.editId).notifier);
+    final state = ref.watch(overtimeApplyProvider(widget.editId));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(
+          title: Text(widget.editId != null ? '编辑加班' : '加班申请')),
+      body: Column(
+        children: [
+          Expanded(
+            child: Center(
+              child: ConstrainedBox(
+                constraints: BoxConstraints(maxWidth: r.formMaxWidth),
+                child: SingleChildScrollView(
+                  padding: const EdgeInsets.symmetric(vertical: 8),
+                  child: Column(
+                    children: [
+                      FormSection(
+                        title: '加班信息',
+                        children: [
+                          FormFieldRow(
+                            label: '加班类型',
+                            value: state.overtime.otType,
+                            onTap: () => _showPicker(_types, ctrl.updateType),
+                          ),
+                          FormFieldRow(
+                            label: '补偿方式',
+                            value: state.overtime.compensationType,
+                            onTap: () => _showPicker(_compensations, ctrl.updateCompensation),
+                          ),
+                          FormFieldRow(
+                            label: '开始时间',
+                            value: du.DateUtils.formatDateTime(state.overtime.startTime),
+                            onTap: () => _pickDateTime(ctrl.updateStartTime, state.overtime.startTime),
+                          ),
+                          FormFieldRow(
+                            label: '结束时间',
+                            value: du.DateUtils.formatDateTime(state.overtime.endTime),
+                            onTap: () => _pickDateTime(ctrl.updateEndTime, state.overtime.endTime),
+                          ),
+                          FormFieldRow(
+                              label: '预估工时',
+                              value: '${state.overtime.otHours.toStringAsFixed(1)}h',
+                              showArrow: false),
+                        ],
+                      ),
+                      FormSection(
+                        title: '加班事由',
+                        children: [
+                          Padding(
+                            padding: const EdgeInsets.all(14),
+                            child: TextField(
+                              maxLines: 4,
+                              decoration: const InputDecoration(
+                                hintText: '请详细描述加班内容和原因…',
+                                border: OutlineInputBorder(),
+                              ),
+                              onChanged: ctrl.updateReason,
+                            ),
+                          ),
+                        ],
+                      ),
+                    ],
+                  ),
+                ),
+              ),
+            ),
+          ),
+          _buildBottomButtons(ctrl, state),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildBottomButtons(OvertimeApplyController ctrl, OvertimeApplyState state) {
+    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: Row(
+        children: [
+          Expanded(
+            child: OutlinedButton(
+              onPressed: state.isSubmitting ? null : () async {
+                await ctrl.saveDraft();
+                if (context.mounted) context.pop();
+              },
+              child: const Text('存草稿'),
+            ),
+          ),
+          const SizedBox(width: 12),
+          Expanded(
+            flex: 2,
+            child: ElevatedButton(
+              onPressed: state.isSubmitting ? null : () async {
+                final ok = await ctrl.submit();
+                if (context.mounted && ok) context.pop();
+              },
+              child: state.isSubmitting
+                  ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
+                  : const Text('提交申请'),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  void _showPicker(List<String> options, void Function(String) onPick) {
+    showModalBottomSheet(
+      context: context,
+      builder: (_) => Column(
+        mainAxisSize: MainAxisSize.min,
+        children: options.map((t) => ListTile(title: Text(t), onTap: () { onPick(t); Navigator.pop(context); })).toList(),
+      ),
+    );
+  }
+
+  Future<void> _pickDateTime(void Function(DateTime) onPicked, DateTime initial) async {
+    final date = await showDatePicker(context: context, initialDate: initial, firstDate: DateTime(2020), lastDate: DateTime(2030));
+    if (date == null || !context.mounted) return;
+    final time = await showTimePicker(context: context, initialTime: TimeOfDay.fromDateTime(initial));
+    if (time == null) return;
+    onPicked(DateTime(date.year, date.month, date.day, time.hour, time.minute));
+  }
+}

+ 65 - 0
lib/features/overtime/overtime_detail_page.dart

@@ -0,0 +1,65 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/theme/app_colors.dart';
+import '../../core/utils/date_utils.dart' as du;
+import '../../core/utils/responsive.dart';
+import '../../shared/widgets/form_section.dart';
+import '../../shared/widgets/form_field_row.dart';
+import '../../shared/widgets/status_tag.dart';
+import 'overtime_api.dart';
+import 'overtime_model.dart';
+
+final overtimeDetailProvider = FutureProvider.autoDispose.family<OvertimeModel, String>((ref, id) async {
+  return ref.read(overtimeApiProvider).fetchDetail(id);
+});
+
+class OvertimeDetailPage extends ConsumerWidget {
+  final String id;
+  const OvertimeDetailPage({super.key, required this.id});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final detailAsync = ref.watch(overtimeDetailProvider(id));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(title: const Text('加班详情')),
+      body: detailAsync.when(
+        loading: () => const Center(child: CircularProgressIndicator()),
+        error: (_, __) => const Center(child: Text('加载失败')),
+        data: (o) => Center(
+          child: ConstrainedBox(
+            constraints: BoxConstraints(maxWidth: r.detailTwoColumns ? 700 : double.infinity),
+            child: SingleChildScrollView(
+              padding: const EdgeInsets.symmetric(vertical: 8),
+              child: FormSection(
+                title: '加班信息',
+                children: [
+                  FormFieldRow(label: '申请单号', value: o.applicationNo, showArrow: false),
+                  FormFieldRow(label: '加班类型', value: o.otType, showArrow: false),
+                  FormFieldRow(label: '补偿方式', value: o.compensationType, showArrow: false),
+                  FormFieldRow(label: '开始时间', value: du.DateUtils.formatDateTime(o.startTime), showArrow: false),
+                  FormFieldRow(label: '结束时间', value: du.DateUtils.formatDateTime(o.endTime), showArrow: false),
+                  FormFieldRow(label: '预估工时', value: '${o.otHours.toStringAsFixed(1)}h', showArrow: false),
+                  FormFieldRow(label: '部门', value: o.deptName, showArrow: false),
+                  FormFieldRow(label: '申请人', value: o.applicantName, showArrow: false),
+                  FormFieldRow(label: '创建时间', value: du.DateUtils.formatDateTime(o.createTime), showArrow: false),
+                  Padding(
+                    padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
+                    child: Row(
+                      children: [
+                        const SizedBox(width: 72, child: Text('状态', style: TextStyle(color: AppColors.textSecondary, fontSize: 13))),
+                        StatusTag(status: o.status),
+                      ],
+                    ),
+                  ),
+                  FormFieldRow(label: '加班事由', value: o.reason.isEmpty ? '-' : o.reason, showArrow: false),
+                ],
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 14 - 0
lib/features/overtime/overtime_list_controller.dart

@@ -0,0 +1,14 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'overtime_model.dart';
+import 'overtime_api.dart';
+
+final overtimeStatusFilterProvider = StateProvider<String>((ref) => '');
+
+final overtimeListProvider =
+    FutureProvider.autoDispose.family<List<OvertimeModel>, int>(
+        (ref, page) async {
+  final status = ref.watch(overtimeStatusFilterProvider);
+  final api = ref.watch(overtimeApiProvider);
+  final result = await api.fetchList(status: status, page: page);
+  return result.list;
+});

+ 175 - 0
lib/features/overtime/overtime_list_page.dart

@@ -0,0 +1,175 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:flutter_slidable/flutter_slidable.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 'overtime_list_controller.dart';
+import 'overtime_model.dart';
+
+class OvertimeListPage extends ConsumerStatefulWidget {
+  const OvertimeListPage({super.key});
+  @override
+  ConsumerState<OvertimeListPage> createState() => _OvertimeListPageState();
+}
+
+class _OvertimeListPageState extends ConsumerState<OvertimeListPage> {
+  int _page = 1;
+
+  @override
+  Widget build(BuildContext context) {
+    final status = ref.watch(overtimeStatusFilterProvider);
+    final itemsAsync = ref.watch(overtimeListProvider(_page));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('加班申请'),
+        actions: [
+          TextButton(
+            onPressed: () => context.go('/overtime/apply'),
+            child: const Text('+ 新建',
+                style: TextStyle(color: Colors.white, fontSize: 13)),
+          ),
+        ],
+      ),
+      body: Column(
+        children: [
+          _buildStatusFilter(status, r),
+          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: '暂无加班申请')
+                      : ListView.builder(
+                          padding: const EdgeInsets.symmetric(vertical: 4),
+                          itemCount: items.length,
+                          itemBuilder: (_, index) => _buildItem(items[index]),
+                        ),
+                ),
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildStatusFilter(String current, ResponsiveHelper r) {
+    final statuses = [
+      {'key': '', 'label': '全部'},
+      {'key': 'pending', 'label': '待审批'},
+      {'key': 'approved', 'label': '已通过'},
+      {'key': 'rejected', 'label': '已拒绝'},
+    ];
+    final filterBar = SingleChildScrollView(
+      scrollDirection: Axis.horizontal,
+      padding: const EdgeInsets.symmetric(horizontal: 12),
+      child: Row(
+        children: statuses.map((s) {
+          final isSelected = current == s['key'];
+          return Padding(
+            padding: const EdgeInsets.only(right: 8),
+            child: GestureDetector(
+              onTap: () {
+                ref.read(overtimeStatusFilterProvider.notifier).state =
+                    s['key']!;
+                _page = 1;
+              },
+              child: Container(
+                padding:
+                    const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
+                decoration: BoxDecoration(
+                  color: isSelected ? AppColors.primaryLight : Colors.white,
+                  borderRadius: BorderRadius.circular(16),
+                  border: Border.all(
+                    color: isSelected
+                        ? AppColors.primary
+                        : const Color(0xFFDDDDDD),
+                  ),
+                ),
+                child: Text(s['label']!,
+                    style: TextStyle(
+                        color: isSelected
+                            ? AppColors.primary
+                            : AppColors.textSecondary,
+                        fontSize: 12)),
+              ),
+            ),
+          );
+        }).toList(),
+      ),
+    );
+    return Container(
+      color: Colors.white,
+      padding: const EdgeInsets.symmetric(vertical: 8),
+      child: r.isWide
+          ? Center(
+              child: SizedBox(width: r.listMaxWidth, child: filterBar))
+          : filterBar,
+    );
+  }
+
+  Widget _buildItem(OvertimeModel item) {
+    return Slidable(
+      endActionPane: ActionPane(
+        motion: const ScrollMotion(),
+        children: [
+          SlidableAction(
+            onPressed: (_) => context.go('/overtime/apply?id=${item.id}'),
+            backgroundColor: AppColors.primary,
+            foregroundColor: Colors.white,
+            icon: Icons.edit,
+            label: '编辑',
+          ),
+        ],
+      ),
+      child: AppCard(
+        onTap: () => context.go('/overtime/detail/${item.id}'),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Text(item.applicationNo,
+                    style: const TextStyle(
+                        fontWeight: FontWeight.w600,
+                        fontSize: 14,
+                        color: AppColors.textPrimary)),
+                StatusTag(status: item.status),
+              ],
+            ),
+            const SizedBox(height: 4),
+            Text(
+              '${item.otType} · ${item.otHours.toStringAsFixed(1)}h · ${item.compensationType}',
+              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)),
+              ],
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 155 - 0
lib/features/overtime/overtime_model.dart

@@ -0,0 +1,155 @@
+import '../../shared/models/approval_status.dart';
+
+class OvertimeModel {
+  final String id;
+  final String applicationNo;
+  final String applicantId;
+  final String applicantName;
+  final String deptId;
+  final String deptName;
+  final String position;
+  final DateTime otDate;
+  final DateTime startTime;
+  final DateTime endTime;
+  final double otHours;
+  final String otType;
+  final String compensationType;
+  final String reason;
+  final String remark;
+  final String status;
+  final String currentApproverId;
+  final List<String> approvalChain;
+  final DateTime createTime;
+  final DateTime updateTime;
+  final List<ApprovalRecord> approvalRecords;
+
+  const OvertimeModel({
+    required this.id,
+    required this.applicationNo,
+    required this.applicantId,
+    required this.applicantName,
+    required this.deptId,
+    required this.deptName,
+    this.position = '',
+    required this.otDate,
+    required this.startTime,
+    required this.endTime,
+    required this.otHours,
+    required this.otType,
+    required this.compensationType,
+    required this.reason,
+    this.remark = '',
+    this.status = 'draft',
+    this.currentApproverId = '',
+    this.approvalChain = const [],
+    required this.createTime,
+    required this.updateTime,
+    this.approvalRecords = const [],
+  });
+
+  factory OvertimeModel.fromJson(Map<String, dynamic> json) {
+    return OvertimeModel(
+      id: json['id'] as String,
+      applicationNo: json['applicationNo'] as String? ?? '',
+      applicantId: json['applicantId'] as String? ?? '',
+      applicantName: json['applicantName'] as String? ?? '',
+      deptId: json['deptId'] as String? ?? '',
+      deptName: json['deptName'] as String? ?? '',
+      position: json['position'] as String? ?? '',
+      otDate: DateTime.parse(json['otDate'] as String),
+      startTime: DateTime.parse(json['startTime'] as String),
+      endTime: DateTime.parse(json['endTime'] as String),
+      otHours: (json['otHours'] as num?)?.toDouble() ?? 0.0,
+      otType: json['otType'] as String? ?? '工作日加班',
+      compensationType: json['compensationType'] as String? ?? '加班费',
+      reason: json['reason'] as String? ?? '',
+      remark: json['remark'] as String? ?? '',
+      status: json['status'] as String? ?? 'draft',
+      currentApproverId: json['currentApproverId'] as String? ?? '',
+      approvalChain:
+          (json['approvalChain'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          [],
+      createTime: DateTime.parse(json['createTime'] as String),
+      updateTime: DateTime.parse(json['updateTime'] as String),
+      approvalRecords:
+          (json['approvalRecords'] as List<dynamic>?)
+              ?.map((e) => ApprovalRecord.fromJson(e as Map<String, dynamic>))
+              .toList() ??
+          [],
+    );
+  }
+
+  Map<String, dynamic> toJson() => {
+        'id': id,
+        'applicationNo': applicationNo,
+        'applicantId': applicantId,
+        'applicantName': applicantName,
+        'deptId': deptId,
+        'deptName': deptName,
+        'position': position,
+        'otDate': otDate.toIso8601String(),
+        'startTime': startTime.toIso8601String(),
+        'endTime': endTime.toIso8601String(),
+        'otHours': otHours,
+        'otType': otType,
+        'compensationType': compensationType,
+        'reason': reason,
+        'remark': remark,
+        'status': status,
+        'currentApproverId': currentApproverId,
+        'approvalChain': approvalChain,
+        'createTime': createTime.toIso8601String(),
+        'updateTime': updateTime.toIso8601String(),
+        'approvalRecords': approvalRecords.map((r) => r.toJson()).toList(),
+      };
+
+  OvertimeModel copyWith({
+    String? id,
+    String? applicationNo,
+    String? applicantId,
+    String? applicantName,
+    String? deptId,
+    String? deptName,
+    String? position,
+    DateTime? otDate,
+    DateTime? startTime,
+    DateTime? endTime,
+    double? otHours,
+    String? otType,
+    String? compensationType,
+    String? reason,
+    String? remark,
+    String? status,
+    String? currentApproverId,
+    List<String>? approvalChain,
+    DateTime? createTime,
+    DateTime? updateTime,
+    List<ApprovalRecord>? approvalRecords,
+  }) {
+    return OvertimeModel(
+      id: id ?? this.id,
+      applicationNo: applicationNo ?? this.applicationNo,
+      applicantId: applicantId ?? this.applicantId,
+      applicantName: applicantName ?? this.applicantName,
+      deptId: deptId ?? this.deptId,
+      deptName: deptName ?? this.deptName,
+      position: position ?? this.position,
+      otDate: otDate ?? this.otDate,
+      startTime: startTime ?? this.startTime,
+      endTime: endTime ?? this.endTime,
+      otHours: otHours ?? this.otHours,
+      otType: otType ?? this.otType,
+      compensationType: compensationType ?? this.compensationType,
+      reason: reason ?? this.reason,
+      remark: remark ?? this.remark,
+      status: status ?? this.status,
+      currentApproverId: currentApproverId ?? this.currentApproverId,
+      approvalChain: approvalChain ?? this.approvalChain,
+      createTime: createTime ?? this.createTime,
+      updateTime: updateTime ?? this.updateTime,
+      approvalRecords: approvalRecords ?? this.approvalRecords,
+    );
+  }
+}

+ 0 - 0
lib/features/vehicle/.gitkeep


+ 49 - 0
lib/features/vehicle/vehicle_api.dart

@@ -0,0 +1,49 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/network/api_client.dart';
+import '../../app.dart';
+import '../../shared/models/pagination_model.dart';
+import 'vehicle_model.dart';
+
+final vehicleApiProvider = Provider<VehicleApi>(
+    (ref) => VehicleApi(ref.read(apiClientProvider)));
+
+class VehicleApi {
+  final ApiClient _client;
+  VehicleApi(this._client);
+
+  Future<PaginatedData<VehicleModel>> fetchList({
+    String status = '',
+    int page = 1,
+    int size = 20,
+  }) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/vehicle/list',
+      queryParameters: {'status': status, 'page': page, 'size': size},
+    );
+    final data = response.data!;
+    final list = (data['list'] as List<dynamic>)
+        .map((e) => VehicleModel.fromJson(e as Map<String, dynamic>))
+        .toList();
+    return PaginatedData(
+      list: list,
+      page: data['page'] as int,
+      size: data['size'] as int,
+      total: data['total'] as int,
+    );
+  }
+
+  Future<VehicleModel> fetchDetail(String id) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/vehicle/detail/$id',
+    );
+    return VehicleModel.fromJson(response.data!);
+  }
+
+  Future<void> submit(VehicleModel model) async {
+    await _client.post('/vehicle/apply', data: model.toJson());
+  }
+
+  Future<void> saveDraft(VehicleModel model) async {
+    await _client.put('/vehicle/draft', data: model.toJson());
+  }
+}

+ 49 - 0
lib/features/vehicle/vehicle_apply_controller.dart

@@ -0,0 +1,49 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'vehicle_model.dart';
+import 'vehicle_api.dart';
+
+class VehicleApplyState {
+  final VehicleModel vehicle;
+  final bool isSubmitting;
+  const VehicleApplyState({required this.vehicle, this.isSubmitting = false});
+  VehicleApplyState copyWith({VehicleModel? vehicle, bool? isSubmitting}) =>
+      VehicleApplyState(vehicle: vehicle ?? this.vehicle, isSubmitting: isSubmitting ?? this.isSubmitting);
+}
+
+class VehicleApplyController extends StateNotifier<VehicleApplyState> {
+  final VehicleApi _api;
+  VehicleApplyController(this._api)
+      : super(VehicleApplyState(vehicle: VehicleModel(
+          id: '', applicationNo: '', applicantId: '', applicantName: '',
+          deptId: '', deptName: '', purpose: '',
+          startTime: DateTime.now(), endTime: DateTime.now().add(const Duration(hours: 2)),
+          origin: '', destination: '', reason: '',
+          createTime: DateTime.now(), updateTime: DateTime.now(),
+        )));
+
+  void updatePurpose(String v) => state = state.copyWith(vehicle: state.vehicle.copyWith(purpose: v));
+  void updateVehicleType(String v) => state = state.copyWith(vehicle: state.vehicle.copyWith(vehicleType: v));
+  void updateStartTime(DateTime t) => state = state.copyWith(vehicle: state.vehicle.copyWith(startTime: t));
+  void updateEndTime(DateTime t) => state = state.copyWith(vehicle: state.vehicle.copyWith(endTime: t));
+  void updateOrigin(String v) => state = state.copyWith(vehicle: state.vehicle.copyWith(origin: v));
+  void updateDestination(String v) => state = state.copyWith(vehicle: state.vehicle.copyWith(destination: v));
+  void updatePassengerCount(int v) => state = state.copyWith(vehicle: state.vehicle.copyWith(passengerCount: v));
+  void updateDriver(String v) => state = state.copyWith(vehicle: state.vehicle.copyWith(driver: v));
+  void updateReason(String v) => state = state.copyWith(vehicle: state.vehicle.copyWith(reason: v));
+  Future<bool> submit() async {
+    state = state.copyWith(isSubmitting: true);
+    try { await _api.submit(state.vehicle.copyWith(status: 'pending')); return true; }
+    catch (_) { return false; }
+    finally { state = state.copyWith(isSubmitting: false); }
+  }
+  Future<bool> saveDraft() async {
+    state = state.copyWith(isSubmitting: true);
+    try { await _api.saveDraft(state.vehicle); return true; }
+    catch (_) { return false; }
+    finally { state = state.copyWith(isSubmitting: false); }
+  }
+}
+
+final vehicleApplyProvider = StateNotifierProvider.autoDispose.family<VehicleApplyController, VehicleApplyState, String?>((ref, editId) {
+  return VehicleApplyController(ref.read(vehicleApiProvider));
+});

+ 128 - 0
lib/features/vehicle/vehicle_apply_page.dart

@@ -0,0 +1,128 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import '../../core/utils/date_utils.dart' as du;
+import '../../core/utils/responsive.dart';
+import '../../shared/widgets/form_section.dart';
+import '../../shared/widgets/form_field_row.dart';
+import 'vehicle_apply_controller.dart';
+
+class VehicleApplyPage extends ConsumerStatefulWidget {
+  final String? editId;
+  const VehicleApplyPage({super.key, this.editId});
+  @override
+  ConsumerState<VehicleApplyPage> createState() => _VehicleApplyPageState();
+}
+
+class _VehicleApplyPageState extends ConsumerState<VehicleApplyPage> {
+  static const _vehicleTypes = ['轿车', '商务车', '中巴', '大巴', '货车'];
+  static const _purposes = ['客户接待', '商务出行', '货物运输', '其他'];
+
+  @override
+  Widget build(BuildContext context) {
+    final ctrl = ref.watch(vehicleApplyProvider(widget.editId).notifier);
+    final state = ref.watch(vehicleApplyProvider(widget.editId));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(title: Text(widget.editId != null ? '编辑用车' : '用车申请')),
+      body: Column(
+        children: [
+          Expanded(
+            child: Center(
+              child: ConstrainedBox(
+                constraints: BoxConstraints(maxWidth: r.formMaxWidth),
+                child: SingleChildScrollView(
+                  padding: const EdgeInsets.symmetric(vertical: 8),
+                  child: FormSection(
+                    title: '用车信息',
+                    children: [
+                      FormFieldRow(label: '用车目的', value: state.vehicle.purpose, hint: '请选择', onTap: () => _showPicker(_purposes, ctrl.updatePurpose)),
+                      FormFieldRow(label: '车型', value: state.vehicle.vehicleType, onTap: () => _showPicker(_vehicleTypes, ctrl.updateVehicleType)),
+                      FormFieldRow(label: '开始时间', value: du.DateUtils.formatDateTime(state.vehicle.startTime), onTap: () => _pickDateTime(ctrl.updateStartTime, state.vehicle.startTime)),
+                      FormFieldRow(label: '结束时间', value: du.DateUtils.formatDateTime(state.vehicle.endTime), onTap: () => _pickDateTime(ctrl.updateEndTime, state.vehicle.endTime)),
+                      FormFieldRow(label: '出发地', value: state.vehicle.origin, hint: '请输入', onTap: () => _showInput('出发地', ctrl.updateOrigin)),
+                      FormFieldRow(label: '目的地', value: state.vehicle.destination, hint: '请输入', onTap: () => _showInput('目的地', ctrl.updateDestination)),
+                      FormFieldRow(label: '乘车人数', value: '${state.vehicle.passengerCount}人', onTap: () => _showNumberInput('乘车人数', ctrl.updatePassengerCount, state.vehicle.passengerCount)),
+                      FormFieldRow(label: '驾驶员', value: state.vehicle.driver, hint: '请输入', onTap: () => _showInput('驾驶员', ctrl.updateDriver)),
+                      FormFieldRow(label: '用车事由', value: state.vehicle.reason, hint: '请输入事由', onTap: () => _showInput('用车事由', ctrl.updateReason)),
+                    ],
+                  ),
+                ),
+              ),
+            ),
+          ),
+          _buildBottomButtons(ctrl, state),
+        ],
+      ),
+    );
+  }
+
+  void _showInput(String title, void Function(String) onSave) {
+    final ctrl = TextEditingController();
+    showDialog(
+      context: context,
+      builder: (_) => AlertDialog(
+        title: Text(title),
+        content: TextField(controller: ctrl, decoration: InputDecoration(hintText: '请输入$title', border: const OutlineInputBorder())),
+        actions: [
+          TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
+          TextButton(onPressed: () { onSave(ctrl.text); Navigator.pop(context); }, child: const Text('确定')),
+        ],
+      ),
+    );
+  }
+
+  void _showNumberInput(String title, void Function(int) onSave, int current) {
+    final ctrl = TextEditingController(text: '$current');
+    showDialog(
+      context: context,
+      builder: (_) => AlertDialog(
+        title: Text(title),
+        content: TextField(controller: ctrl, keyboardType: TextInputType.number, decoration: const InputDecoration(border: OutlineInputBorder())),
+        actions: [
+          TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
+          TextButton(onPressed: () { onSave(int.tryParse(ctrl.text) ?? 1); Navigator.pop(context); }, child: const Text('确定')),
+        ],
+      ),
+    );
+  }
+
+  void _showPicker(List<String> options, void Function(String) onPick) {
+    showModalBottomSheet(
+      context: context,
+      builder: (_) => Column(
+        mainAxisSize: MainAxisSize.min,
+        children: options.map((t) => ListTile(title: Text(t), onTap: () { onPick(t); Navigator.pop(context); })).toList(),
+      ),
+    );
+  }
+
+  Future<void> _pickDateTime(void Function(DateTime) onPicked, DateTime initial) async {
+    final date = await showDatePicker(context: context, initialDate: initial, firstDate: DateTime(2020), lastDate: DateTime(2030));
+    if (date == null || !context.mounted) return;
+    final time = await showTimePicker(context: context, initialTime: TimeOfDay.fromDateTime(initial));
+    if (time == null) return;
+    onPicked(DateTime(date.year, date.month, date.day, time.hour, time.minute));
+  }
+
+  Widget _buildBottomButtons(VehicleApplyController ctrl, VehicleApplyState state) {
+    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: Row(
+        children: [
+          Expanded(child: OutlinedButton(
+            onPressed: state.isSubmitting ? null : () async { await ctrl.saveDraft(); if (context.mounted) context.pop(); },
+            child: const Text('存草稿'),
+          )),
+          const SizedBox(width: 12),
+          Expanded(flex: 2, child: ElevatedButton(
+            onPressed: state.isSubmitting ? null : () async { final ok = await ctrl.submit(); if (context.mounted && ok) context.pop(); },
+            child: state.isSubmitting ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) : const Text('提交申请'),
+          )),
+        ],
+      ),
+    );
+  }
+}

+ 67 - 0
lib/features/vehicle/vehicle_detail_page.dart

@@ -0,0 +1,67 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/theme/app_colors.dart';
+import '../../core/utils/date_utils.dart' as du;
+import '../../core/utils/responsive.dart';
+import '../../shared/widgets/form_section.dart';
+import '../../shared/widgets/form_field_row.dart';
+import '../../shared/widgets/status_tag.dart';
+import 'vehicle_api.dart';
+import 'vehicle_model.dart';
+
+final vehicleDetailProvider = FutureProvider.autoDispose.family<VehicleModel, String>((ref, id) async {
+  return ref.read(vehicleApiProvider).fetchDetail(id);
+});
+
+class VehicleDetailPage extends ConsumerWidget {
+  final String id;
+  const VehicleDetailPage({super.key, required this.id});
+
+  @override
+  Widget build(BuildContext context, WidgetRef ref) {
+    final detailAsync = ref.watch(vehicleDetailProvider(id));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(title: const Text('用车详情')),
+      body: detailAsync.when(
+        loading: () => const Center(child: CircularProgressIndicator()),
+        error: (_, __) => const Center(child: Text('加载失败')),
+        data: (v) => Center(
+          child: ConstrainedBox(
+            constraints: BoxConstraints(maxWidth: r.detailTwoColumns ? 700 : double.infinity),
+            child: SingleChildScrollView(
+              padding: const EdgeInsets.symmetric(vertical: 8),
+              child: FormSection(
+                title: '用车信息',
+                children: [
+                  FormFieldRow(label: '申请单号', value: v.applicationNo, showArrow: false),
+                  FormFieldRow(label: '用车目的', value: v.purpose, showArrow: false),
+                  FormFieldRow(label: '车型', value: v.vehicleType, showArrow: false),
+                  FormFieldRow(label: '开始时间', value: du.DateUtils.formatDateTime(v.startTime), showArrow: false),
+                  FormFieldRow(label: '结束时间', value: du.DateUtils.formatDateTime(v.endTime), showArrow: false),
+                  FormFieldRow(label: '出发地', value: v.origin, showArrow: false),
+                  FormFieldRow(label: '目的地', value: v.destination, showArrow: false),
+                  FormFieldRow(label: '乘车人数', value: '${v.passengerCount}人', showArrow: false),
+                  FormFieldRow(label: '驾驶员', value: v.driver.isEmpty ? '-' : v.driver, showArrow: false),
+                  FormFieldRow(label: '部门', value: v.deptName, showArrow: false),
+                  FormFieldRow(label: '申请人', value: v.applicantName, showArrow: false),
+                  Padding(
+                    padding: const EdgeInsets.fromLTRB(14, 0, 14, 12),
+                    child: Row(
+                      children: [
+                        const SizedBox(width: 72, child: Text('状态', style: TextStyle(color: AppColors.textSecondary, fontSize: 13))),
+                        StatusTag(status: v.status),
+                      ],
+                    ),
+                  ),
+                  FormFieldRow(label: '用车事由', value: v.reason.isEmpty ? '-' : v.reason, showArrow: false),
+                ],
+              ),
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 14 - 0
lib/features/vehicle/vehicle_list_controller.dart

@@ -0,0 +1,14 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'vehicle_model.dart';
+import 'vehicle_api.dart';
+
+final vehicleStatusFilterProvider = StateProvider<String>((ref) => '');
+
+final vehicleListProvider =
+    FutureProvider.autoDispose.family<List<VehicleModel>, int>(
+        (ref, page) async {
+  final status = ref.watch(vehicleStatusFilterProvider);
+  final api = ref.watch(vehicleApiProvider);
+  final result = await api.fetchList(status: status, page: page);
+  return result.list;
+});

+ 147 - 0
lib/features/vehicle/vehicle_list_page.dart

@@ -0,0 +1,147 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+import 'package:flutter_slidable/flutter_slidable.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 'vehicle_list_controller.dart';
+import 'vehicle_model.dart';
+
+class VehicleListPage extends ConsumerStatefulWidget {
+  const VehicleListPage({super.key});
+  @override
+  ConsumerState<VehicleListPage> createState() => _VehicleListPageState();
+}
+
+class _VehicleListPageState extends ConsumerState<VehicleListPage> {
+  int _page = 1;
+
+  @override
+  Widget build(BuildContext context) {
+    final status = ref.watch(vehicleStatusFilterProvider);
+    final itemsAsync = ref.watch(vehicleListProvider(_page));
+    final r = ResponsiveHelper.of(context);
+
+    return Scaffold(
+      appBar: AppBar(
+        title: const Text('用车申请'),
+        actions: [
+          TextButton(
+            onPressed: () => context.go('/vehicle/apply'),
+            child: const Text('+ 新建', style: TextStyle(color: Colors.white, fontSize: 13)),
+          ),
+        ],
+      ),
+      body: Column(
+        children: [
+          _buildStatusFilter(status, r),
+          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: '暂无用车申请')
+                      : ListView.builder(
+                          padding: const EdgeInsets.symmetric(vertical: 4),
+                          itemCount: items.length,
+                          itemBuilder: (_, index) => _buildItem(items[index]),
+                        ),
+                ),
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildStatusFilter(String current, ResponsiveHelper r) {
+    final statuses = [
+      {'key': '', 'label': '全部'},
+      {'key': 'pending', 'label': '待审批'},
+      {'key': 'approved', 'label': '已通过'},
+      {'key': 'rejected', 'label': '已拒绝'},
+    ];
+    final filterBar = SingleChildScrollView(
+      scrollDirection: Axis.horizontal,
+      padding: const EdgeInsets.symmetric(horizontal: 12),
+      child: Row(
+        children: statuses.map((s) {
+          final isSelected = current == s['key'];
+          return Padding(
+            padding: const EdgeInsets.only(right: 8),
+            child: GestureDetector(
+              onTap: () {
+                ref.read(vehicleStatusFilterProvider.notifier).state = s['key']!;
+                _page = 1;
+              },
+              child: Container(
+                padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
+                decoration: BoxDecoration(
+                  color: isSelected ? AppColors.primaryLight : Colors.white,
+                  borderRadius: BorderRadius.circular(16),
+                  border: Border.all(color: isSelected ? AppColors.primary : const Color(0xFFDDDDDD)),
+                ),
+                child: Text(s['label']!, style: TextStyle(color: isSelected ? AppColors.primary : AppColors.textSecondary, fontSize: 12)),
+              ),
+            ),
+          );
+        }).toList(),
+      ),
+    );
+    return Container(
+      color: Colors.white,
+      padding: const EdgeInsets.symmetric(vertical: 8),
+      child: r.isWide ? Center(child: SizedBox(width: r.listMaxWidth, child: filterBar)) : filterBar,
+    );
+  }
+
+  Widget _buildItem(VehicleModel item) {
+    return Slidable(
+      endActionPane: ActionPane(
+        motion: const ScrollMotion(),
+        children: [
+          SlidableAction(
+            onPressed: (_) => context.go('/vehicle/apply?id=${item.id}'),
+            backgroundColor: AppColors.primary, foregroundColor: Colors.white,
+            icon: Icons.edit, label: '编辑',
+          ),
+        ],
+      ),
+      child: AppCard(
+        onTap: () => context.go('/vehicle/detail/${item.id}'),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                Text(item.applicationNo, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14, color: AppColors.textPrimary)),
+                StatusTag(status: item.status),
+              ],
+            ),
+            const SizedBox(height: 4),
+            Text('${item.purpose} · ${item.origin} → ${item.destination}',
+                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)),
+              ],
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 174 - 0
lib/features/vehicle/vehicle_model.dart

@@ -0,0 +1,174 @@
+import '../../shared/models/approval_status.dart';
+
+class VehicleModel {
+  final String id;
+  final String applicationNo;
+  final String applicantId;
+  final String applicantName;
+  final String deptId;
+  final String deptName;
+  final String vehicleType;
+  final String purpose;
+  final DateTime startTime;
+  final DateTime endTime;
+  final String origin;
+  final String destination;
+  final int passengerCount;
+  final String driver;
+  final String licensePlate;
+  final double estimatedMileage;
+  final double estimatedCost;
+  final String reason;
+  final String status;
+  final String currentApproverId;
+  final List<String> approvalChain;
+  final DateTime createTime;
+  final DateTime updateTime;
+  final List<ApprovalRecord> approvalRecords;
+
+  const VehicleModel({
+    required this.id,
+    required this.applicationNo,
+    required this.applicantId,
+    required this.applicantName,
+    required this.deptId,
+    required this.deptName,
+    this.vehicleType = '轿车',
+    required this.purpose,
+    required this.startTime,
+    required this.endTime,
+    required this.origin,
+    required this.destination,
+    this.passengerCount = 1,
+    this.driver = '',
+    this.licensePlate = '',
+    this.estimatedMileage = 0.0,
+    this.estimatedCost = 0.0,
+    required this.reason,
+    this.status = 'draft',
+    this.currentApproverId = '',
+    this.approvalChain = const [],
+    required this.createTime,
+    required this.updateTime,
+    this.approvalRecords = const [],
+  });
+
+  factory VehicleModel.fromJson(Map<String, dynamic> json) {
+    return VehicleModel(
+      id: json['id'] as String,
+      applicationNo: json['applicationNo'] as String? ?? '',
+      applicantId: json['applicantId'] as String? ?? '',
+      applicantName: json['applicantName'] as String? ?? '',
+      deptId: json['deptId'] as String? ?? '',
+      deptName: json['deptName'] as String? ?? '',
+      vehicleType: json['vehicleType'] as String? ?? '轿车',
+      purpose: json['purpose'] as String? ?? '',
+      startTime: DateTime.parse(json['startTime'] as String),
+      endTime: DateTime.parse(json['endTime'] as String),
+      origin: json['origin'] as String? ?? '',
+      destination: json['destination'] as String? ?? '',
+      passengerCount: json['passengerCount'] as int? ?? 1,
+      driver: json['driver'] as String? ?? '',
+      licensePlate: json['licensePlate'] as String? ?? '',
+      estimatedMileage:
+          (json['estimatedMileage'] as num?)?.toDouble() ?? 0.0,
+      estimatedCost: (json['estimatedCost'] as num?)?.toDouble() ?? 0.0,
+      reason: json['reason'] as String? ?? '',
+      status: json['status'] as String? ?? 'draft',
+      currentApproverId: json['currentApproverId'] as String? ?? '',
+      approvalChain:
+          (json['approvalChain'] as List<dynamic>?)
+              ?.map((e) => e as String)
+              .toList() ??
+          [],
+      createTime: DateTime.parse(json['createTime'] as String),
+      updateTime: DateTime.parse(json['updateTime'] as String),
+      approvalRecords:
+          (json['approvalRecords'] as List<dynamic>?)
+              ?.map((e) => ApprovalRecord.fromJson(e as Map<String, dynamic>))
+              .toList() ??
+          [],
+    );
+  }
+
+  Map<String, dynamic> toJson() => {
+        'id': id,
+        'applicationNo': applicationNo,
+        'applicantId': applicantId,
+        'applicantName': applicantName,
+        'deptId': deptId,
+        'deptName': deptName,
+        'vehicleType': vehicleType,
+        'purpose': purpose,
+        'startTime': startTime.toIso8601String(),
+        'endTime': endTime.toIso8601String(),
+        'origin': origin,
+        'destination': destination,
+        'passengerCount': passengerCount,
+        'driver': driver,
+        'licensePlate': licensePlate,
+        'estimatedMileage': estimatedMileage,
+        'estimatedCost': estimatedCost,
+        'reason': reason,
+        'status': status,
+        'currentApproverId': currentApproverId,
+        'approvalChain': approvalChain,
+        'createTime': createTime.toIso8601String(),
+        'updateTime': updateTime.toIso8601String(),
+        'approvalRecords': approvalRecords.map((r) => r.toJson()).toList(),
+      };
+
+  VehicleModel copyWith({
+    String? id,
+    String? applicationNo,
+    String? applicantId,
+    String? applicantName,
+    String? deptId,
+    String? deptName,
+    String? vehicleType,
+    String? purpose,
+    DateTime? startTime,
+    DateTime? endTime,
+    String? origin,
+    String? destination,
+    int? passengerCount,
+    String? driver,
+    String? licensePlate,
+    double? estimatedMileage,
+    double? estimatedCost,
+    String? reason,
+    String? status,
+    String? currentApproverId,
+    List<String>? approvalChain,
+    DateTime? createTime,
+    DateTime? updateTime,
+    List<ApprovalRecord>? approvalRecords,
+  }) {
+    return VehicleModel(
+      id: id ?? this.id,
+      applicationNo: applicationNo ?? this.applicationNo,
+      applicantId: applicantId ?? this.applicantId,
+      applicantName: applicantName ?? this.applicantName,
+      deptId: deptId ?? this.deptId,
+      deptName: deptName ?? this.deptName,
+      vehicleType: vehicleType ?? this.vehicleType,
+      purpose: purpose ?? this.purpose,
+      startTime: startTime ?? this.startTime,
+      endTime: endTime ?? this.endTime,
+      origin: origin ?? this.origin,
+      destination: destination ?? this.destination,
+      passengerCount: passengerCount ?? this.passengerCount,
+      driver: driver ?? this.driver,
+      licensePlate: licensePlate ?? this.licensePlate,
+      estimatedMileage: estimatedMileage ?? this.estimatedMileage,
+      estimatedCost: estimatedCost ?? this.estimatedCost,
+      reason: reason ?? this.reason,
+      status: status ?? this.status,
+      currentApproverId: currentApproverId ?? this.currentApproverId,
+      approvalChain: approvalChain ?? this.approvalChain,
+      createTime: createTime ?? this.createTime,
+      updateTime: updateTime ?? this.updateTime,
+      approvalRecords: approvalRecords ?? this.approvalRecords,
+    );
+  }
+}

+ 13 - 108
lib/main.dart

@@ -1,110 +1,15 @@
 import 'package:flutter/material.dart';
-
-void main() => runApp(const MyApp());
-
-class MyApp extends StatelessWidget {
-  const MyApp({super.key});
-
-  // This widget is the root of your application.
-  @override
-  Widget build(BuildContext context) {
-    return MaterialApp(
-      title: 'Flutter Demo',
-      theme: ThemeData(
-        // This is the theme of your application.
-        //
-        // Try running your application with "flutter run". You'll see the
-        // application has a blue toolbar. Then, without quitting the app, try
-        // changing the primarySwatch below to Colors.green and then invoke
-        // "hot reload" (press "r" in the console where you ran "flutter run",
-        // or press Run > Flutter Hot Reload in a Flutter IDE). Notice that the
-        // counter didn't reset back to zero; the application is not restarted.
-        primarySwatch: Colors.blue,
-      ),
-      home: const MyHomePage(title: 'Flutter Demo Home Page'),
-    );
-  }
-}
-
-class MyHomePage extends StatefulWidget {
-  const MyHomePage({super.key, required this.title});
-
-  // This widget is the home page of your application. It is stateful, meaning
-  // that it has a State object (defined below) that contains fields that affect
-  // how it looks.
-
-  // This class is the configuration for the state. It holds the values (in this
-  // case the title) provided by the parent (in this case the App widget) and
-  // used by the build method of the State. Fields in a Widget subclass are
-  // always marked "final".
-
-  final String title;
-
-  @override
-  State<MyHomePage> createState() => _MyHomePageState();
-}
-
-class _MyHomePageState extends State<MyHomePage> {
-  int _counter = 0;
-
-  void _incrementCounter() {
-    setState(() {
-      // This call to setState tells the Flutter framework that something has
-      // changed in this State, which causes it to rerun the build method below
-      // so that the display can reflect the updated values. If we changed
-      // _counter without calling setState(), then the build method would not be
-      // called again, and so nothing would appear to happen.
-      _counter++;
-    });
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    // This method is rerun every time setState is called, for instance as done
-    // by the _incrementCounter method above.
-    //
-    // The Flutter framework has been optimized to make rerunning build methods
-    // fast, so that you can just rebuild anything that needs updating rather
-    // than having to individually change instances of widgets.
-    return Scaffold(
-      appBar: AppBar(
-        // Here we take the value from the MyHomePage object that was created by
-        // the App.build method, and use it to set our appbar title.
-        title: Text(widget.title),
-      ),
-      body: Center(
-        // Center is a layout widget. It takes a single child and positions it
-        // in the middle of the parent.
-        child: Column(
-          // Column is also a layout widget. It takes a list of children and
-          // arranges them vertically. By default, it sizes itself to fit its
-          // children horizontally, and tries to be as tall as its parent.
-          //
-          // Invoke "debug painting" (press "p" in the console, choose the
-          // "Toggle Debug Paint" action from the Flutter Inspector in Android
-          // Studio, or the "Toggle Debug Paint" command in Visual Studio Code)
-          // to see the wireframe for each widget.
-          //
-          // Column has various properties to control how it sizes itself and
-          // how it positions its children. Here we use mainAxisAlignment to
-          // center the children vertically; the main axis here is the vertical
-          // axis because Columns are vertical (the cross axis would be
-          // horizontal).
-          mainAxisAlignment: .center,
-          children: [
-            const Text('You have pushed the button this many times:'),
-            Text(
-              '$_counter',
-              style: Theme.of(context).textTheme.headlineMedium,
-            ),
-          ],
-        ),
-      ),
-      floatingActionButton: FloatingActionButton(
-        onPressed: _incrementCounter,
-        tooltip: 'Increment',
-        child: const Icon(Icons.add),
-      ),
-    );
-  }
+import 'package:flutter/services.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'app.dart';
+
+void main() {
+  WidgetsFlutterBinding.ensureInitialized();
+  SystemChrome.setPreferredOrientations([
+    DeviceOrientation.portraitUp,
+    DeviceOrientation.portraitDown,
+    DeviceOrientation.landscapeLeft,
+    DeviceOrientation.landscapeRight,
+  ]);
+  runApp(const ProviderScope(child: App()));
 }

+ 0 - 0
lib/shared/models/.gitkeep


+ 72 - 0
lib/shared/models/approval_status.dart

@@ -0,0 +1,72 @@
+enum ApprovalStatus {
+  draft,
+  pending,
+  approved,
+  rejected,
+  withdrawn;
+
+  String get label {
+    switch (this) {
+      case ApprovalStatus.draft:
+        return '草稿';
+      case ApprovalStatus.pending:
+        return '待审批';
+      case ApprovalStatus.approved:
+        return '已通过';
+      case ApprovalStatus.rejected:
+        return '已拒绝';
+      case ApprovalStatus.withdrawn:
+        return '已撤回';
+    }
+  }
+}
+
+class ApprovalRecord {
+  final String id;
+  final String bizId;
+  final String bizType;
+  final String approverId;
+  final String approverName;
+  final int approvalLevel;
+  final String action;
+  final String opinion;
+  final DateTime approvalTime;
+
+  const ApprovalRecord({
+    required this.id,
+    required this.bizId,
+    required this.bizType,
+    required this.approverId,
+    required this.approverName,
+    required this.approvalLevel,
+    required this.action,
+    required this.opinion,
+    required this.approvalTime,
+  });
+
+  factory ApprovalRecord.fromJson(Map<String, dynamic> json) {
+    return ApprovalRecord(
+      id: json['id'] as String,
+      bizId: json['bizId'] as String,
+      bizType: json['bizType'] as String,
+      approverId: json['approverId'] as String,
+      approverName: json['approverName'] as String,
+      approvalLevel: json['approvalLevel'] as int,
+      action: json['action'] as String,
+      opinion: json['opinion'] as String? ?? '',
+      approvalTime: DateTime.parse(json['approvalTime'] as String),
+    );
+  }
+
+  Map<String, dynamic> toJson() => {
+    'id': id,
+    'bizId': bizId,
+    'bizType': bizType,
+    'approverId': approverId,
+    'approverName': approverName,
+    'approvalLevel': approvalLevel,
+    'action': action,
+    'opinion': opinion,
+    'approvalTime': approvalTime.toIso8601String(),
+  };
+}

+ 12 - 0
lib/shared/models/pagination_model.dart

@@ -0,0 +1,12 @@
+class PaginatedData<T> {
+  final List<T> list;
+  final int page;
+  final int size;
+  final int total;
+  const PaginatedData(
+      {required this.list,
+      required this.page,
+      required this.size,
+      required this.total});
+  bool get hasMore => page * size < total;
+}

+ 0 - 0
lib/shared/widgets/.gitkeep


+ 41 - 0
lib/shared/widgets/app_card.dart

@@ -0,0 +1,41 @@
+import 'package:flutter/material.dart';
+import '../../core/theme/app_colors.dart';
+
+class AppCard extends StatelessWidget {
+  final Widget child;
+  final VoidCallback? onTap;
+  final EdgeInsetsGeometry? padding;
+  final EdgeInsetsGeometry? margin;
+
+  const AppCard({
+    super.key,
+    required this.child,
+    this.onTap,
+    this.padding,
+    this.margin,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return GestureDetector(
+      onTap: onTap,
+      child: Container(
+        margin: margin ??
+            const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
+        padding: padding ?? const EdgeInsets.all(14),
+        decoration: BoxDecoration(
+          color: AppColors.cardWhite,
+          borderRadius: BorderRadius.circular(10),
+          boxShadow: [
+            BoxShadow(
+              color: Colors.black.withValues(alpha: 0.04),
+              blurRadius: 4,
+              offset: const Offset(0, 1),
+            ),
+          ],
+        ),
+        child: child,
+      ),
+    );
+  }
+}

+ 36 - 0
lib/shared/widgets/empty_state.dart

@@ -0,0 +1,36 @@
+import 'package:flutter/material.dart';
+import '../../core/theme/app_colors.dart';
+
+class EmptyState extends StatelessWidget {
+  final String message;
+  final IconData icon;
+
+  const EmptyState({
+    super.key,
+    this.message = '暂无数据',
+    this.icon = Icons.inbox_outlined,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return Center(
+      child: Padding(
+        padding: const EdgeInsets.all(48),
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Icon(icon, size: 64, color: AppColors.textHint),
+            const SizedBox(height: 16),
+            Text(
+              message,
+              style: const TextStyle(
+                color: AppColors.textSecondary,
+                fontSize: 14,
+              ),
+            ),
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 68 - 0
lib/shared/widgets/form_field_row.dart

@@ -0,0 +1,68 @@
+import 'package:flutter/material.dart';
+import '../../core/theme/app_colors.dart';
+
+class FormFieldRow extends StatelessWidget {
+  final String label;
+  final String? value;
+  final String? hint;
+  final VoidCallback? onTap;
+  final bool showArrow;
+
+  const FormFieldRow({
+    super.key,
+    required this.label,
+    this.value,
+    this.hint,
+    this.onTap,
+    this.showArrow = true,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return InkWell(
+      onTap: onTap,
+      child: Container(
+        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
+        decoration: const BoxDecoration(
+          border: Border(
+            bottom: BorderSide(color: Color(0xFFF9F9F9), width: 0.5),
+          ),
+        ),
+        child: Row(
+          children: [
+            SizedBox(
+              width: 72,
+              child: Text(
+                label,
+                style: const TextStyle(
+                  color: AppColors.textSecondary,
+                  fontSize: 13,
+                ),
+              ),
+            ),
+            Expanded(
+              child: Text(
+                value ?? hint ?? '',
+                textAlign: TextAlign.right,
+                style: TextStyle(
+                  color: value != null
+                      ? AppColors.textPrimary
+                      : AppColors.textHint,
+                  fontSize: 13,
+                ),
+              ),
+            ),
+            if (showArrow) ...[
+              const SizedBox(width: 4),
+              const Icon(
+                Icons.chevron_right,
+                size: 18,
+                color: AppColors.textHint,
+              ),
+            ],
+          ],
+        ),
+      ),
+    );
+  }
+}

+ 57 - 0
lib/shared/widgets/form_section.dart

@@ -0,0 +1,57 @@
+import 'package:flutter/material.dart';
+import '../../core/theme/app_colors.dart';
+
+class FormSection extends StatelessWidget {
+  final String? title;
+  final Widget? trailing;
+  final List<Widget> children;
+
+  const FormSection({
+    super.key,
+    this.title,
+    this.trailing,
+    required this.children,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
+      decoration: BoxDecoration(
+        color: AppColors.cardWhite,
+        borderRadius: BorderRadius.circular(10),
+        boxShadow: [
+          BoxShadow(
+            color: Colors.black.withValues(alpha: 0.04),
+            blurRadius: 4,
+            offset: const Offset(0, 1),
+          ),
+        ],
+      ),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          if (title != null)
+            Padding(
+              padding: const EdgeInsets.fromLTRB(14, 12, 14, 10),
+              child: Row(
+                mainAxisAlignment: MainAxisAlignment.spaceBetween,
+                children: [
+                  Text(
+                    title!,
+                    style: const TextStyle(
+                      fontSize: 13,
+                      fontWeight: FontWeight.w600,
+                      color: AppColors.textPrimary,
+                    ),
+                  ),
+                  if (trailing != null) trailing!,
+                ],
+              ),
+            ),
+          ...children,
+        ],
+      ),
+    );
+  }
+}

+ 26 - 0
lib/shared/widgets/loading_widget.dart

@@ -0,0 +1,26 @@
+import 'package:flutter/material.dart';
+import 'package:shimmer/shimmer.dart';
+
+class LoadingWidget extends StatelessWidget {
+  const LoadingWidget({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Shimmer.fromColors(
+      baseColor: Colors.grey[300]!,
+      highlightColor: Colors.grey[100]!,
+      child: ListView.builder(
+        padding: const EdgeInsets.all(12),
+        itemCount: 6,
+        itemBuilder: (_, __) => Container(
+          height: 80,
+          margin: const EdgeInsets.only(bottom: 10),
+          decoration: BoxDecoration(
+            color: Colors.white,
+            borderRadius: BorderRadius.circular(10),
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 51 - 0
lib/shared/widgets/status_tag.dart

@@ -0,0 +1,51 @@
+import 'package:flutter/material.dart';
+import '../../core/theme/app_colors.dart';
+
+class StatusTag extends StatelessWidget {
+  final String status;
+  const StatusTag({super.key, required this.status});
+
+  @override
+  Widget build(BuildContext context) {
+    final (bg, fg) = _colors(status);
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+      decoration: BoxDecoration(
+        color: bg,
+        borderRadius: BorderRadius.circular(4),
+      ),
+      child: Text(
+        _label(status),
+        style: TextStyle(color: fg, fontSize: 11),
+      ),
+    );
+  }
+
+  (Color, Color) _colors(String s) {
+    switch (s) {
+      case 'pending':
+        return (AppColors.warningBg, AppColors.warning);
+      case 'approved':
+        return (AppColors.successBg, AppColors.success);
+      case 'rejected':
+        return (AppColors.errorBg, AppColors.error);
+      default:
+        return (const Color(0xFFF5F5F5), AppColors.textSecondary);
+    }
+  }
+
+  String _label(String s) {
+    switch (s) {
+      case 'pending':
+        return '待审批';
+      case 'approved':
+        return '已通过';
+      case 'rejected':
+        return '已拒绝';
+      case 'draft':
+        return '草稿';
+      default:
+        return s;
+    }
+  }
+}

+ 526 - 1
pubspec.lock

@@ -1,6 +1,38 @@
 # Generated by pub
 # See https://dart.dev/tools/pub/glossary#lockfile
 packages:
+  _fe_analyzer_shared:
+    dependency: transitive
+    description:
+      name: _fe_analyzer_shared
+      sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "85.0.0"
+  analyzer:
+    dependency: transitive
+    description:
+      name: analyzer
+      sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "7.6.0"
+  analyzer_plugin:
+    dependency: transitive
+    description:
+      name: analyzer_plugin
+      sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.13.4"
+  args:
+    dependency: transitive
+    description:
+      name: args
+      sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.7.0"
   async:
     dependency: transitive
     description:
@@ -17,6 +49,70 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "2.1.2"
+  build:
+    dependency: transitive
+    description:
+      name: build
+      sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.5.4"
+  build_config:
+    dependency: transitive
+    description:
+      name: build_config
+      sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.1.2"
+  build_daemon:
+    dependency: transitive
+    description:
+      name: build_daemon
+      sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.1.1"
+  build_resolvers:
+    dependency: transitive
+    description:
+      name: build_resolvers
+      sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.5.4"
+  build_runner:
+    dependency: "direct dev"
+    description:
+      name: build_runner
+      sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.5.4"
+  build_runner_core:
+    dependency: transitive
+    description:
+      name: build_runner_core
+      sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "9.1.2"
+  built_collection:
+    dependency: transitive
+    description:
+      name: built_collection
+      sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "5.1.1"
+  built_value:
+    dependency: transitive
+    description:
+      name: built_value
+      sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "8.12.6"
   characters:
     dependency: transitive
     description:
@@ -25,6 +121,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.4.0"
+  checked_yaml:
+    dependency: transitive
+    description:
+      name: checked_yaml
+      sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.0.4"
   clock:
     dependency: transitive
     description:
@@ -33,6 +137,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.1.2"
+  code_builder:
+    dependency: transitive
+    description:
+      name: code_builder
+      sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.11.1"
   collection:
     dependency: transitive
     description:
@@ -41,6 +153,22 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.19.1"
+  convert:
+    dependency: transitive
+    description:
+      name: convert
+      sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.1.2"
+  crypto:
+    dependency: transitive
+    description:
+      name: crypto
+      sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.0.7"
   cupertino_icons:
     dependency: "direct main"
     description:
@@ -49,6 +177,54 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.0.9"
+  custom_lint_core:
+    dependency: transitive
+    description:
+      name: custom_lint_core
+      sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.7.5"
+  custom_lint_visitor:
+    dependency: transitive
+    description:
+      name: custom_lint_visitor
+      sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.0+7.7.0"
+  dart_style:
+    dependency: transitive
+    description:
+      name: dart_style
+      sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.1.1"
+  dio:
+    dependency: "direct main"
+    description:
+      name: dio
+      sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "5.9.2"
+  dio_web_adapter:
+    dependency: transitive
+    description:
+      name: dio_web_adapter
+      sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.1.2"
+  equatable:
+    dependency: transitive
+    description:
+      name: equatable
+      sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.0.8"
   fake_async:
     dependency: transitive
     description:
@@ -57,6 +233,30 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.3.3"
+  file:
+    dependency: transitive
+    description:
+      name: file
+      sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "7.0.1"
+  fixnum:
+    dependency: transitive
+    description:
+      name: fixnum
+      sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.1.1"
+  fl_chart:
+    dependency: "direct main"
+    description:
+      name: fl_chart
+      sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.69.2"
   flutter:
     dependency: "direct main"
     description: flutter
@@ -70,11 +270,136 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "6.0.0"
+  flutter_riverpod:
+    dependency: "direct main"
+    description:
+      name: flutter_riverpod
+      sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.6.1"
+  flutter_slidable:
+    dependency: "direct main"
+    description:
+      name: flutter_slidable
+      sha256: a857de7ea701f276fd6a6c4c67ae885b60729a3449e42766bb0e655171042801
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.1.2"
   flutter_test:
     dependency: "direct dev"
     description: flutter
     source: sdk
     version: "0.0.0"
+  flutter_web_plugins:
+    dependency: transitive
+    description: flutter
+    source: sdk
+    version: "0.0.0"
+  freezed_annotation:
+    dependency: transitive
+    description:
+      name: freezed_annotation
+      sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.1.0"
+  frontend_server_client:
+    dependency: transitive
+    description:
+      name: frontend_server_client
+      sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.0.0"
+  glob:
+    dependency: transitive
+    description:
+      name: glob
+      sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.1.3"
+  go_router:
+    dependency: "direct main"
+    description:
+      name: go_router
+      sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "14.8.1"
+  graphs:
+    dependency: transitive
+    description:
+      name: graphs
+      sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.3.2"
+  http:
+    dependency: transitive
+    description:
+      name: http
+      sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.6.0"
+  http_multi_server:
+    dependency: transitive
+    description:
+      name: http_multi_server
+      sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.2.2"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.1.2"
+  intl:
+    dependency: "direct main"
+    description:
+      name: intl
+      sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.19.0"
+  io:
+    dependency: transitive
+    description:
+      name: io
+      sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.5"
+  js:
+    dependency: transitive
+    description:
+      name: js
+      sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.7.2"
+  json_annotation:
+    dependency: "direct main"
+    description:
+      name: json_annotation
+      sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.9.0"
+  json_serializable:
+    dependency: "direct dev"
+    description:
+      name: json_serializable
+      sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "6.9.5"
   leak_tracker:
     dependency: transitive
     description:
@@ -107,6 +432,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "6.1.0"
+  logging:
+    dependency: transitive
+    description:
+      name: logging
+      sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.3.0"
   matcher:
     dependency: transitive
     description:
@@ -131,6 +464,22 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.17.0"
+  mime:
+    dependency: transitive
+    description:
+      name: mime
+      sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.0.0"
+  package_config:
+    dependency: transitive
+    description:
+      name: package_config
+      sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.2.0"
   path:
     dependency: transitive
     description:
@@ -139,11 +488,107 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.9.1"
+  pool:
+    dependency: transitive
+    description:
+      name: pool
+      sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.5.2"
+  pub_semver:
+    dependency: transitive
+    description:
+      name: pub_semver
+      sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.2.0"
+  pubspec_parse:
+    dependency: transitive
+    description:
+      name: pubspec_parse
+      sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.5.0"
+  riverpod:
+    dependency: transitive
+    description:
+      name: riverpod
+      sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.6.1"
+  riverpod_analyzer_utils:
+    dependency: transitive
+    description:
+      name: riverpod_analyzer_utils
+      sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.5.10"
+  riverpod_annotation:
+    dependency: "direct main"
+    description:
+      name: riverpod_annotation
+      sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.6.1"
+  riverpod_generator:
+    dependency: "direct dev"
+    description:
+      name: riverpod_generator
+      sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.6.5"
+  shelf:
+    dependency: transitive
+    description:
+      name: shelf
+      sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.4.2"
+  shelf_web_socket:
+    dependency: transitive
+    description:
+      name: shelf_web_socket
+      sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.0.0"
+  shimmer:
+    dependency: "direct main"
+    description:
+      name: shimmer
+      sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.0.0"
   sky_engine:
     dependency: transitive
     description: flutter
     source: sdk
     version: "0.0.0"
+  source_gen:
+    dependency: transitive
+    description:
+      name: source_gen
+      sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.0.0"
+  source_helper:
+    dependency: transitive
+    description:
+      name: source_helper
+      sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.3.7"
   source_span:
     dependency: transitive
     description:
@@ -160,6 +605,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.12.1"
+  state_notifier:
+    dependency: transitive
+    description:
+      name: state_notifier
+      sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.0"
   stream_channel:
     dependency: transitive
     description:
@@ -168,6 +621,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "2.1.4"
+  stream_transform:
+    dependency: transitive
+    description:
+      name: stream_transform
+      sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.1.1"
   string_scanner:
     dependency: transitive
     description:
@@ -192,6 +653,30 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "0.7.7"
+  timing:
+    dependency: transitive
+    description:
+      name: timing
+      sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.2"
+  typed_data:
+    dependency: transitive
+    description:
+      name: typed_data
+      sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.4.0"
+  uuid:
+    dependency: transitive
+    description:
+      name: uuid
+      sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.5.3"
   vector_math:
     dependency: transitive
     description:
@@ -208,6 +693,46 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "15.2.0"
+  watcher:
+    dependency: transitive
+    description:
+      name: watcher
+      sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.2.1"
+  web:
+    dependency: transitive
+    description:
+      name: web
+      sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.1.1"
+  web_socket:
+    dependency: transitive
+    description:
+      name: web_socket
+      sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.1"
+  web_socket_channel:
+    dependency: transitive
+    description:
+      name: web_socket_channel
+      sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.0.3"
+  yaml:
+    dependency: transitive
+    description:
+      name: yaml
+      sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.1.3"
 sdks:
   dart: ">=3.10.9 <4.0.0"
-  flutter: ">=3.18.0-18.0.pre.54"
+  flutter: ">=3.22.0"

+ 13 - 65
pubspec.yaml

@@ -1,20 +1,5 @@
 name: tboss_oa_module
-description: "A new Flutter module project."
-
-# The following defines the version and build number for your application.
-# A version number is three numbers separated by dots, like 1.2.43
-# followed by an optional build number separated by a +.
-# Both the version and the builder number may be overridden in flutter
-# build by specifying --build-name and --build-number, respectively.
-# In Android, build-name is used as versionName while build-number used as versionCode.
-# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
-# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
-# Read more about iOS versioning at
-# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
-#
-# This version is used _only_ for the Runner app, which is used if you just do
-# a `flutter run`. It has no impact on any other native host app that you embed
-# your Flutter project into.
+description: "TBOSS OA Flutter module."
 version: 1.0.0+1
 
 environment:
@@ -23,64 +8,27 @@ environment:
 dependencies:
   flutter:
     sdk: flutter
-
-  # The following adds the Cupertino Icons font to your application.
-  # Use with the CupertinoIcons class for iOS style icons.
   cupertino_icons: ^1.0.8
+  flutter_riverpod: ^2.6.1
+  riverpod_annotation: ^2.6.1
+  go_router: ^14.8.1
+  dio: ^5.7.0
+  json_annotation: ^4.9.0
+  intl: ^0.19.0
+  flutter_slidable: ^3.1.1
+  fl_chart: ^0.69.2
+  shimmer: ^3.0.0
 
 dev_dependencies:
   flutter_test:
     sdk: flutter
   flutter_lints: ^6.0.0
-
-# For information on the generic Dart part of this file, see the
-# following page: https://dart.dev/tools/pub/pubspec
+  build_runner: ^2.4.13
+  json_serializable: ^6.9.4
+  riverpod_generator: ^2.6.3
 
 flutter:
-  # The following line ensures that the Material Icons font is
-  # included with your application, so that you can use the icons in
-  # the material Icons class.
   uses-material-design: true
-
-  # To add Flutter specific assets to your application, add an assets section,
-  # like this:
-  # assets:
-  #   - images/a_dot_burr.jpeg
-  #   - images/a_dot_ham.jpeg
-
-  # An image asset can refer to one or more resolution-specific "variants", see
-  # https://flutter.dev/to/resolution-aware-images
-
-  # For details regarding adding assets from package dependencies, see
-  # https://flutter.dev/to/asset-from-package
-
-  # To add Flutter specific custom fonts to your application, add a fonts
-  # section here, in this "flutter" section. Each entry in this list should
-  # have a "family" key with the font family name, and a "fonts" key with a
-  # list giving the asset and other descriptors for the font. For
-  # example:
-  # fonts:
-  #   - family: Schyler
-  #     fonts:
-  #       - asset: fonts/Schyler-Regular.ttf
-  #       - asset: fonts/Schyler-Italic.ttf
-  #         style: italic
-  #   - family: Trajan Pro
-  #     fonts:
-  #       - asset: fonts/TrajanPro.ttf
-  #       - asset: fonts/TrajanPro_Bold.ttf
-  #         weight: 700
-  #
-  # For details regarding fonts from package dependencies,
-  # see https://flutter.dev/to/font-from-package
-
-
-  # This section identifies your Flutter project as a module meant for
-  # embedding in a native host app.  These identifiers should _not_ ordinarily
-  # be changed after generation - they are used to ensure that the tooling can
-  # maintain consistency when adding or modifying assets and plugins.
-  # They also do not have any bearing on your native host application's
-  # identifiers, which may be completely independent or the same as these.
   module:
     androidX: true
     androidPackage: com.amtxts.tboss_oa_module

+ 4 - 25
test/widget_test.dart

@@ -1,30 +1,9 @@
-// This is a basic Flutter widget test.
-//
-// To perform an interaction with a widget in your test, use the WidgetTester
-// utility in the flutter_test package. For example, you can send tap and scroll
-// gestures. You can also use WidgetTester to find child widgets in the widget
-// tree, read text, and verify that the values of widget properties are correct.
-
-import 'package:flutter/material.dart';
 import 'package:flutter_test/flutter_test.dart';
-
-import 'package:tboss_oa_module/main.dart';
+import 'package:tboss_oa_module/app.dart';
 
 void main() {
-  testWidgets('Counter increments smoke test', (WidgetTester tester) async {
-    // Build our app and trigger a frame.
-    await tester.pumpWidget(const MyApp());
-
-    // Verify that our counter starts at 0.
-    expect(find.text('0'), findsOneWidget);
-    expect(find.text('1'), findsNothing);
-
-    // Tap the '+' icon and trigger a frame.
-    await tester.tap(find.byIcon(Icons.add));
-    await tester.pump();
-
-    // Verify that our counter has incremented.
-    expect(find.text('0'), findsNothing);
-    expect(find.text('1'), findsOneWidget);
+  testWidgets('App renders home page', (WidgetTester tester) async {
+    await tester.pumpWidget(const App());
+    expect(find.text('TBOSS · 工作台'), findsOneWidget);
   });
 }