Forráskód Böngészése

fix #19900 #19888 加班申请单、用车申请单

chengc 2 hete
szülő
commit
2983230753

+ 12 - 0
assets/i18n/en.json

@@ -130,8 +130,16 @@
     "draftSavedToast": "Draft saved",
     "saveFailed": "Save failed",
     "submitSuccess": "Submitted successfully",
+    "billCreated": "Bill created",
     "submitFailed": "Submit Failed",
+    "requestFailed": "Request Failed",
+    "unknownError": "Unknown error",
+    "networkTimeout": "Network connection timed out",
     "submitFailedRetry": "Submission failed, please try again later",
+    "auditFlowPrompt": "Submit to the approval workflow?",
+    "skipAudit": "No",
+    "submitToAudit": "Submit",
+    "returning": "Returning…",
     "noCostTypeData": "No cost type data. Please add cost types in ERP first.",
     "submittedAwaitingApproval": "Submitted, awaiting approval",
     "published": "Announcement published",
@@ -645,6 +653,10 @@
     "actualCost": "Actual cost (CNY)",
     "costRemarkLabel": "Cost remark (tolls/parking, etc.)",
     "returnCarSubmitted": "Return registration submitted",
+    "selectReturnTimeHint": "Please select return time",
+    "returnOdometer": "Return Odometer",
+    "enterReturnOdometer": "Please enter return odometer",
+    "returnOdometerCheck": "Return odometer must exceed departure odometer",
     "returnTimeMustLater": "Return time must be later than departure time",
     "departTime": "Est. Departure Time",
     "returnTime": "Est. Return Time",

+ 12 - 0
assets/i18n/zh_CN.json

@@ -127,8 +127,16 @@
     "draftSavedToast": "已保存为草稿",
     "saveFailed": "保存失败",
     "submitSuccess": "提交成功",
+    "billCreated": "单据已生成",
     "submitFailed": "提交失败",
+    "requestFailed": "请求失败",
+    "unknownError": "未知错误",
+    "networkTimeout": "网络连接超时",
     "submitFailedRetry": "提交失败,请稍后重试",
+    "auditFlowPrompt": "是否提交到审核流?",
+    "skipAudit": "否",
+    "submitToAudit": "提交",
+    "returning": "还车中…",
     "submittedAwaitingApproval": "已提交,等待审批",
     "published": "公告发布成功",
     "withdrawn": "已撤回",
@@ -645,6 +653,10 @@
     "actualCost": "实际费用金额(元)",
     "costRemarkLabel": "费用备注(路桥费/停车费等)",
     "returnCarSubmitted": "还车登记已提交",
+    "selectReturnTimeHint": "请选择归还时间",
+    "returnOdometer": "还车里程",
+    "enterReturnOdometer": "请输入还车里程",
+    "returnOdometerCheck": "还车里程必须大于出车里程",
     "returnTimeMustLater": "还车时间必须晚于出车时间",
     "departTime": "预计出车时间",
     "returnTime": "预计还车时间",

+ 12 - 0
assets/i18n/zh_TW.json

@@ -130,8 +130,16 @@
     "draftSavedToast": "已保存為草稿",
     "saveFailed": "保存失敗",
     "submitSuccess": "提交成功",
+    "billCreated": "單據已生成",
     "submitFailed": "提交失敗",
+    "requestFailed": "請求失敗",
+    "unknownError": "未知錯誤",
+    "networkTimeout": "網路連線逾時",
     "submitFailedRetry": "提交失敗,請稍后重試",
+    "auditFlowPrompt": "是否提交到審核流?",
+    "skipAudit": "否",
+    "submitToAudit": "提交",
+    "returning": "還車中…",
     "noCostTypeData": "暫無費用類型數據,請先前往ERP新增費用類型數據再嘗試",
     "submittedAwaitingApproval": "已提交,等待審批",
     "published": "公告發佈成功",
@@ -645,6 +653,10 @@
     "actualCost": "实際費用金額(元)",
     "costRemarkLabel": "費用備注(路桥費/停車費等)",
     "returnCarSubmitted": "還車登記已提交",
+    "selectReturnTimeHint": "請選擇歸還時間",
+    "returnOdometer": "還車里程",
+    "enterReturnOdometer": "請輸入還車里程",
+    "returnOdometerCheck": "還車里程必須大於出車里程",
     "returnTimeMustLater": "還車時間必須晚於出車時間",
     "departTime": "預計出車時間",
     "returnTime": "預計還車時間",

+ 5 - 1
lib/app.dart

@@ -18,7 +18,11 @@ import 'core/navigation/host_app_channel.dart';
 
 final apiClientProvider = Provider<ApiClient>((ref) {
   const useMock = false;
-  final client = ApiClient(baseUrl: HostAppChannel.baseUrl, useMock: useMock);
+  final client = ApiClient(
+    baseUrl: HostAppChannel.baseUrl,
+    useMock: useMock,
+    navigatorKey: rootNavigatorKey,
+  );
   final authService = ref.read(authServiceProvider);
   client.setToken(authService.token);
   return client;

+ 49 - 2
lib/core/network/api_client.dart

@@ -1,5 +1,9 @@
 import 'dart:typed_data';
 import 'package:dio/dio.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter/widgets.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../i18n/app_localizations.dart';
 import '../navigation/host_app_channel.dart';
 import 'api_exception.dart';
 import 'api_response.dart';
@@ -10,10 +14,12 @@ class ApiClient {
   String? _token;
   final String baseUrl;
   final bool useMock;
+  final GlobalKey<NavigatorState>? navigatorKey;
 
   ApiClient({
     required this.baseUrl,
     this.useMock = false,
+    this.navigatorKey,
     bool logEnabled = true,
   }) {
     _dio = Dio(
@@ -63,13 +69,15 @@ class ApiClient {
           if (body is Map<String, dynamic>) {
             final code = body['code'] as int? ?? -1;
             if (code != 0) {
+              final message = body['message'] as String?;
+              _showErrorDialog(message, fallbackKey: 'unknownError');
               handler.reject(
                 DioException(
                   requestOptions: response.requestOptions,
                   response: response,
                   error: ApiException(
                     code: code,
-                    message: body['message'] as String? ?? '未知错误',
+                    message: message ?? 'unknownError',
                   ),
                 ),
               );
@@ -81,10 +89,11 @@ class ApiClient {
         onError: (error, handler) {
           if (error.type == DioExceptionType.connectionTimeout ||
               error.type == DioExceptionType.receiveTimeout) {
+            _showErrorDialog(null, fallbackKey: 'networkTimeout');
             handler.next(
               DioException(
                 requestOptions: error.requestOptions,
-                error: const NetworkException('网络连接超时'),
+                error: const NetworkException('networkTimeout'),
               ),
             );
             return;
@@ -97,6 +106,44 @@ class ApiClient {
 
   void setToken(String? token) => _token = token;
 
+  bool _errorDialogShowing = false;
+
+  void _showErrorDialog(
+    String? message, {
+    String fallbackKey = 'unknownError',
+  }) {
+    final ctx = navigatorKey?.currentContext;
+    if (ctx == null) return;
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      final ctx2 = navigatorKey?.currentContext;
+      if (ctx2 == null) return;
+
+      // 如果已有错误弹窗在显示,先关闭旧的
+      if (_errorDialogShowing) {
+        Navigator.of(ctx2).pop();
+        _errorDialogShowing = false;
+      }
+
+      final l10n = AppLocalizations.of(ctx2);
+      final text = (message != null && message.isNotEmpty)
+          ? message
+          : l10n.get(fallbackKey);
+
+      _errorDialogShowing = true;
+      showGeneralDialog<dynamic>(
+        context: ctx2,
+        barrierDismissible: false,
+        pageBuilder: (_, _, _) => TDConfirmDialog(
+          title: l10n.get('requestFailed'),
+          content: text,
+          buttonStyle: TDDialogButtonStyle.text,
+        ),
+      ).then((_) {
+        _errorDialogShowing = false;
+      });
+    });
+  }
+
   Future<ApiResponse<T>> get<T>(
     String path, {
     Map<String, dynamic>? queryParameters,

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

@@ -57,7 +57,10 @@ void _smartBack(BuildContext context) {
   }
 }
 
+final rootNavigatorKey = GlobalKey<NavigatorState>();
+
 GoRouter createAppRouter() => GoRouter(
+  navigatorKey: rootNavigatorKey,
   observers: [routeObserver],
   initialLocation: '/',
   routes: [

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

@@ -11,6 +11,10 @@ class DateUtils {
     return DateFormat('yyyy-MM-dd').format(dt);
   }
 
+  static String formatDateTimeSec(DateTime dt) {
+    return DateFormat('yyyy-MM-dd HH:mm:ss').format(dt);
+  }
+
   static String formatTime(DateTime dt) {
     return DateFormat('HH:mm').format(dt);
   }

+ 1 - 30
lib/features/expense/expense_create_page.dart

@@ -5,9 +5,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
-import 'package:dio/dio.dart';
 import 'package:go_router/go_router.dart';
-import '../../core/network/api_exception.dart';
 import '../../core/utils/responsive.dart';
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
@@ -1151,39 +1149,12 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage> {
             GoRouter.of(context).go('/expense/list');
           }
         } catch (e) {
-          if (mounted) {
-            LoadingDialog.hide(context);
-            WidgetsBinding.instance.addPostFrameCallback((_) {
-              if (mounted) _showSubmitError(e, l10n);
-            });
-          }
+          if (mounted) LoadingDialog.hide(context);
         }
       },
     );
   }
 
-  void _showSubmitError(Object e, AppLocalizations l10n) {
-    final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry');
-    showGeneralDialog(
-      context: context,
-      pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog(
-        title: l10n.get('submitFailed'),
-        content: message,
-        buttonStyle: TDDialogButtonStyle.text,
-      ),
-    );
-  }
-
-  String? _extractErrorMessage(Object e) {
-    if (e is DioException) {
-      if (e.error is ApiException) return (e.error as ApiException).message;
-      if (e.error is NetworkException) {
-        return (e.error as NetworkException).message;
-      }
-    }
-    return null;
-  }
-
   Future<void> _showAddDetailDialog(
     ExpenseCreateController controller, {
     int? editIndex,

+ 1 - 30
lib/features/expense/expense_edit_page.dart

@@ -4,9 +4,7 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
-import 'package:dio/dio.dart';
 import 'package:go_router/go_router.dart';
-import '../../core/network/api_exception.dart';
 import '../../core/utils/responsive.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/form_section.dart';
@@ -1086,12 +1084,7 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
             });
           }
         } catch (e) {
-          if (mounted) {
-            LoadingDialog.hide(context);
-            WidgetsBinding.instance.addPostFrameCallback((_) {
-              if (mounted) _showSubmitError(e, l10n);
-            });
-          }
+          if (mounted) LoadingDialog.hide(context);
         } finally {
           _isSubmitting = false;
         }
@@ -1099,28 +1092,6 @@ class _ExpenseEditPageState extends ConsumerState<ExpenseEditPage> {
     );
   }
 
-  void _showSubmitError(Object e, AppLocalizations l10n) {
-    final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry');
-    showGeneralDialog(
-      context: context,
-      pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog(
-        title: l10n.get('submitFailed'),
-        content: message,
-        buttonStyle: TDDialogButtonStyle.text,
-      ),
-    );
-  }
-
-  String? _extractErrorMessage(Object e) {
-    if (e is DioException) {
-      if (e.error is ApiException) return (e.error as ApiException).message;
-      if (e.error is NetworkException) {
-        return (e.error as NetworkException).message;
-      }
-    }
-    return null;
-  }
-
   List<String> _validate(AppLocalizations l10n) {
     final e = <String>[];
     if (_purposeController.text.trim().isEmpty) {

+ 1 - 30
lib/features/expense_apply/expense_apply_create_page.dart

@@ -8,8 +8,6 @@ import 'package:tdesign_flutter/tdesign_flutter.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../core/navigation/host_app_channel.dart';
 import '../../core/storage/draft_storage.dart';
-import 'package:dio/dio.dart';
-import '../../core/network/api_exception.dart';
 import '../../shared/widgets/action_bar.dart';
 import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/searchable_picker_sheet.dart';
@@ -989,39 +987,12 @@ class _ExpenseApplyCreatePageState
             GoRouter.of(context).go('/expense-apply/list');
           }
         } catch (e) {
-          if (mounted) {
-            LoadingDialog.hide(context);
-            WidgetsBinding.instance.addPostFrameCallback((_) {
-              if (mounted) _showSubmitError(e, l10n);
-            });
-          }
+          if (mounted) LoadingDialog.hide(context);
         }
       },
     );
   }
 
-  void _showSubmitError(Object e, AppLocalizations l10n) {
-    final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry');
-    showGeneralDialog(
-      context: context,
-      pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog(
-        title: l10n.get('submitFailed'),
-        content: message,
-        buttonStyle: TDDialogButtonStyle.text,
-      ),
-    );
-  }
-
-  String? _extractErrorMessage(Object e) {
-    if (e is DioException) {
-      if (e.error is ApiException) return (e.error as ApiException).message;
-      if (e.error is NetworkException) {
-        return (e.error as NetworkException).message;
-      }
-    }
-    return null;
-  }
-
   Map<String, dynamic> _buildSubmitData() {
     // 紧急程度映射:normal→1, urgent→2, critical→3
     String priority;

+ 2 - 30
lib/features/expense_apply/expense_apply_edit_page.dart

@@ -7,8 +7,7 @@ import 'package:go_router/go_router.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../core/navigation/host_app_channel.dart';
-import 'package:dio/dio.dart';
-import '../../core/network/api_exception.dart';
+
 import '../../shared/widgets/action_bar.dart';
 import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/form_section.dart';
@@ -782,39 +781,12 @@ class _ExpenseApplyEditPageState extends ConsumerState<ExpenseApplyEditPage> {
             });
           }
         } catch (e) {
-          if (mounted) {
-            LoadingDialog.hide(context);
-            WidgetsBinding.instance.addPostFrameCallback((_) {
-              if (mounted) _showSubmitError(e, l10n);
-            });
-          }
+          if (mounted) LoadingDialog.hide(context);
         }
       },
     );
   }
 
-  void _showSubmitError(Object e, AppLocalizations l10n) {
-    final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry');
-    showGeneralDialog(
-      context: context,
-      pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog(
-        title: l10n.get('submitFailed'),
-        content: message,
-        buttonStyle: TDDialogButtonStyle.text,
-      ),
-    );
-  }
-
-  String? _extractErrorMessage(Object e) {
-    if (e is DioException) {
-      if (e.error is ApiException) return (e.error as ApiException).message;
-      if (e.error is NetworkException) {
-        return (e.error as NetworkException).message;
-      }
-    }
-    return null;
-  }
-
   Map<String, dynamic> _buildSubmitData() {
     String priority;
     switch (_urgency) {

+ 9 - 0
lib/features/overtime/overtime_apply_api.dart

@@ -176,6 +176,15 @@ class OvertimeApplyApi {
     }
   }
 
+  /// 获取审核配置
+  Future<Map<String, dynamic>> getBillAuditConfig(String bilId) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetBillAuditConfig',
+      queryParameters: {'bilId': bilId},
+    );
+    return response.data!;
+  }
+
   /// 获取单据状态
   Future<Map<String, dynamic>> getBillStatus(String billNo) async {
     final response = await _client.get<Map<String, dynamic>>(

+ 15 - 39
lib/features/overtime/overtime_apply_create_page.dart

@@ -8,8 +8,6 @@ import 'package:tdesign_flutter/tdesign_flutter.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../core/navigation/host_app_channel.dart';
 import '../../core/storage/draft_storage.dart';
-import 'package:dio/dio.dart';
-import '../../core/network/api_exception.dart';
 import '../../shared/widgets/action_bar.dart';
 import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/searchable_picker_sheet.dart';
@@ -17,6 +15,7 @@ import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/app_skeletons.dart';
 import '../../shared/widgets/nav_bar_config.dart';
+import '../../shared/helpers/audit_flow_helper.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import 'overtime_apply_api.dart';
@@ -850,52 +849,29 @@ class _OvertimeApplyCreatePageState
           final api = ref.read(overtimeApplyApiProvider);
           final billNo = await api.submit(data);
           await DraftStorage.delete(_draftKey);
+          if (!mounted) return;
+          LoadingDialog.hide(context);
+
+          if (billNo != null) {
+            final dd = data['HeadData']['JB_DD']?.toString() ?? '';
+            await AuditFlowHelper.handle(
+              context: context,
+              l10n: l10n,
+              getConfig: () => api.getBillAuditConfig('JB'),
+              onShSubmit: () => api.shSubmit(bilNo: billNo, bilDd: dd),
+            );
+          }
           if (mounted) {
-            LoadingDialog.hide(context);
-            TDToast.showSuccess(l10n.get('submitSuccess'), context: context);
-            // BillSave 成功后异步提交审核流,不阻塞不弹窗
-            if (billNo != null) {
-              api.shSubmit(
-                bilNo: billNo,
-                bilDd: data['HeadData']['JB_DD']?.toString() ?? '',
-              );
-            }
+            TDToast.showSuccess(l10n.get('billCreated'), context: context);
             GoRouter.of(context).go('/overtime-apply/list');
           }
         } catch (e) {
-          if (mounted) {
-            LoadingDialog.hide(context);
-            WidgetsBinding.instance.addPostFrameCallback((_) {
-              if (mounted) _showSubmitError(e, l10n);
-            });
-          }
+          if (mounted) LoadingDialog.hide(context);
         }
       },
     );
   }
 
-  void _showSubmitError(Object e, AppLocalizations l10n) {
-    final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry');
-    showGeneralDialog(
-      context: context,
-      pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog(
-        title: l10n.get('submitFailed'),
-        content: message,
-        buttonStyle: TDDialogButtonStyle.text,
-      ),
-    );
-  }
-
-  String? _extractErrorMessage(Object e) {
-    if (e is DioException) {
-      if (e.error is ApiException) return (e.error as ApiException).message;
-      if (e.error is NetworkException) {
-        return (e.error as NetworkException).message;
-      }
-    }
-    return null;
-  }
-
   Map<String, dynamic> _buildSubmitData() {
     final now = DateTime.now();
     final jbDd =

+ 2 - 30
lib/features/overtime/overtime_apply_edit_page.dart

@@ -7,8 +7,7 @@ import 'package:go_router/go_router.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../core/navigation/host_app_channel.dart';
-import 'package:dio/dio.dart';
-import '../../core/network/api_exception.dart';
+
 import '../../shared/widgets/action_bar.dart';
 import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/form_section.dart';
@@ -822,39 +821,12 @@ class _OvertimeApplyEditPageState extends ConsumerState<OvertimeApplyEditPage> {
             GoRouter.of(context).go('/overtime-apply/list');
           }
         } catch (e) {
-          if (mounted) {
-            LoadingDialog.hide(context);
-            WidgetsBinding.instance.addPostFrameCallback((_) {
-              if (mounted) _showSubmitError(e, l10n);
-            });
-          }
+          if (mounted) LoadingDialog.hide(context);
         }
       },
     );
   }
 
-  void _showSubmitError(Object e, AppLocalizations l10n) {
-    final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry');
-    showGeneralDialog(
-      context: context,
-      pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog(
-        title: l10n.get('submitFailed'),
-        content: message,
-        buttonStyle: TDDialogButtonStyle.text,
-      ),
-    );
-  }
-
-  String? _extractErrorMessage(Object e) {
-    if (e is DioException) {
-      if (e.error is ApiException) return (e.error as ApiException).message;
-      if (e.error is NetworkException) {
-        return (e.error as NetworkException).message;
-      }
-    }
-    return null;
-  }
-
   Map<String, dynamic> _buildSubmitData() {
     return {
       'HeadData': {

+ 2 - 0
lib/features/overtime/overtime_apply_list_controller.dart

@@ -1,5 +1,6 @@
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 
+import '../../core/navigation/host_app_channel.dart';
 import 'overtime_apply_api.dart';
 import 'overtime_apply_model.dart';
 
@@ -233,6 +234,7 @@ final overtimeApplyListProvider =
         endDate: dateEnd != null
             ? '${dateEnd.year}-${dateEnd.month.toString().padLeft(2, '0')}-${dateEnd.day.toString().padLeft(2, '0')}'
             : '',
+        usr: HostAppChannel.usr,
         sortDir: sortDir,
       );
 

+ 11 - 7
lib/features/overtime/overtime_apply_model.dart

@@ -56,8 +56,11 @@ class OvertimeApplyModel {
       clsDate: json['CLS_DATE'] != null
           ? DateTime.tryParse(json['CLS_DATE'] as String)
           : null,
-      isAuditApproved: (json['isAuditApproved'] as int?) == 1,
-      totalJbHours: (json['TotalJbHours'] as num?)?.toDouble() ??
+      isAuditApproved:
+          json['IsAuditApproved'] == true ||
+          (json['isAuditApproved'] as int?) == 1,
+      totalJbHours:
+          (json['TotalJbHours'] as num?)?.toDouble() ??
           (json['totalJbHours'] as num?)?.toDouble() ??
           0,
       details: _parseDetails(json['Details'] ?? json['details']),
@@ -85,8 +88,9 @@ class OvertimeApplyModel {
     if (data == null) return [];
     if (data is List) {
       return data
-          .map((e) =>
-              OvertimeApplyDetailModel.fromJson(e as Map<String, dynamic>))
+          .map(
+            (e) => OvertimeApplyDetailModel.fromJson(e as Map<String, dynamic>),
+          )
           .toList();
     }
     return [];
@@ -203,9 +207,9 @@ class OvertimeApplyDetailModel {
       dayOfWeek: json['DAY_OF_WEEK'] is int
           ? json['DAY_OF_WEEK'] as int
           : int.tryParse(
-                  (json['DAY_OF_WEEK'] ?? json['dayOfWeek'])?.toString() ??
-                      '') ??
-              0,
+                  (json['DAY_OF_WEEK'] ?? json['dayOfWeek'])?.toString() ?? '',
+                ) ??
+                0,
     );
   }
 

+ 22 - 0
lib/features/vehicle/vehicle_apply_api.dart

@@ -169,6 +169,28 @@ class VehicleApplyApi {
     }
   }
 
+  /// 获取用户对指定程序的权限
+  Future<Map<String, dynamic>> getUserRights(String pgm) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetUserRights',
+      queryParameters: {
+        'compNo': HostAppChannel.compNo,
+        'usrId': HostAppChannel.usr,
+        'pgm': pgm,
+      },
+    );
+    return response.data!;
+  }
+
+  /// 获取审核配置
+  Future<Map<String, dynamic>> getBillAuditConfig(String bilId) async {
+    final response = await _client.get<Map<String, dynamic>>(
+      '/OA/GetBillAuditConfig',
+      queryParameters: {'bilId': bilId},
+    );
+    return response.data!;
+  }
+
   /// 获取单据状态(billId="YC")
   Future<Map<String, dynamic>> getBillStatus(String billNo) async {
     final response = await _client.get<Map<String, dynamic>>(

+ 17 - 42
lib/features/vehicle/vehicle_apply_create_page.dart

@@ -5,10 +5,8 @@ import 'package:flutter/services.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:go_router/go_router.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
-import 'package:dio/dio.dart';
 
 import '../../core/i18n/app_localizations.dart';
-import '../../core/network/api_exception.dart';
 import '../../core/navigation/host_app_channel.dart';
 import '../../core/storage/draft_storage.dart';
 import '../../core/theme/app_colors.dart';
@@ -21,6 +19,7 @@ import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/widgets/nav_bar_config.dart';
 import '../../shared/widgets/app_input_dialog.dart';
 import '../../shared/widgets/searchable_picker_sheet.dart';
+import '../../shared/helpers/audit_flow_helper.dart';
 import 'vehicle_apply_api.dart';
 
 class VehicleApplyCreatePage extends ConsumerStatefulWidget {
@@ -613,54 +612,30 @@ class _VehicleApplyCreatePageState
           final api = ref.read(vehicleApplyApiProvider);
           final billNo = await api.submit(data);
           await DraftStorage.delete(_draftKey);
+          if (!mounted) return;
+          LoadingDialog.hide(context);
+
+          if (billNo != null) {
+            final dd = data['HeadData']['YC_DD']?.toString() ?? '';
+            await AuditFlowHelper.handle(
+              context: context,
+              l10n: l10n,
+              getConfig: () => api.getBillAuditConfig('YC'),
+              onShSubmit: () =>
+                  api.shSubmit(bilId: 'YC', bilNo: billNo, bilDd: dd),
+            );
+          }
           if (mounted) {
-            LoadingDialog.hide(context);
-            TDToast.showSuccess(l10n.get('submitSuccess'), context: context);
+            TDToast.showSuccess(l10n.get('billCreated'), context: context);
             GoRouter.of(context).go('/vehicle-apply/list');
-            // BillSave 成功后异步提交审核流,不阻塞不弹窗
-            if (billNo != null) {
-              api.shSubmit(
-                bilId: 'YC',
-                bilNo: billNo,
-                bilDd: data['HeadData']['YC_DD']?.toString() ?? '',
-              );
-            }
           }
         } catch (e) {
-          if (mounted) {
-            LoadingDialog.hide(context);
-            // 等一帧确保 loading dialog 完全关闭后再弹出错误对话框
-            WidgetsBinding.instance.addPostFrameCallback((_) {
-              if (mounted) _showSubmitError(e, l10n);
-            });
-          }
+          if (mounted) LoadingDialog.hide(context);
         }
       },
     );
   }
 
-  void _showSubmitError(Object e, AppLocalizations l10n) {
-    final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry');
-    showGeneralDialog(
-      context: context,
-      pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog(
-        title: l10n.get('submitFailed'),
-        content: message,
-        buttonStyle: TDDialogButtonStyle.text,
-      ),
-    );
-  }
-
-  String? _extractErrorMessage(Object e) {
-    if (e is DioException) {
-      if (e.error is ApiException) return (e.error as ApiException).message;
-      if (e.error is NetworkException) {
-        return (e.error as NetworkException).message;
-      }
-    }
-    return null;
-  }
-
   Map<String, dynamic> _buildSubmitData() {
     return {
       'HeadData': {
@@ -867,7 +842,7 @@ class _VehicleApplyCreatePageState
       useDay: true,
       useHour: true,
       useMinute: true,
-      dateStart: [now.year - 1, now.month, now.day, now.hour, now.minute],
+      dateStart: [now.year - 1, 1, 1, 0, 0],
       dateEnd: [now.year + 10, 12, 31, 23, 59],
       initialDate: [d.year, d.month, d.day, d.hour, d.minute],
       onConfirm: (selected) {

+ 81 - 54
lib/features/vehicle/vehicle_apply_detail_page.dart

@@ -33,7 +33,6 @@ class _VehicleApplyDetailPageState
   String? _error;
   VehicleApplyModel? _data;
   BillStatusBar? _billStatusBar;
-  Map<String, dynamic>? _billStatus;
 
   @override
   void initState() {
@@ -59,62 +58,91 @@ class _VehicleApplyDetailPageState
       } else {
         final api = ref.read(vehicleApplyApiProvider);
         final detail = await api.fetchDetail(widget.billNo);
+        if (!mounted) return;
+
+        // 获取单据状态 + 还车权限(非致命),与详情合并到一个 setState 避免中间闪烁
+        Map<String, dynamic>? status;
+        Map<String, dynamic>? rights;
+        try {
+          final results = await Future.wait([
+            api.getBillStatus(widget.billNo),
+            api.getUserRights('RHB'),
+          ]);
+          final Map<String, dynamic> s = results[0];
+          final Map<String, dynamic> r = results[1];
+          status = s;
+          rights = r;
+        } catch (_) {}
+
+        final canReturn =
+            rights?['canAdd'] == true || rights?['canModify'] == true;
         if (mounted) {
           setState(() {
             _data = detail;
+            _billStatusBar = (status != null)
+                ? BillStatusBar(
+                    billStatus: status,
+                    returnedText: AppLocalizations.of(
+                      context,
+                    ).get('vehicleReturned'),
+                    onEdit: () {
+                      GoRouter.of(context)
+                          .push('/vehicle-apply/edit/${widget.billNo}')
+                          .then((result) {
+                            if (result == true && mounted) _loadData();
+                          });
+                    },
+                    onSubmit: () {
+                      final dd = _data?.ycDd != null
+                          ? du.DateUtils.formatDate(_data!.ycDd!)
+                          : '';
+                      api.shSubmit(
+                        bilId: 'YC',
+                        bilNo: widget.billNo,
+                        bilDd: dd,
+                      );
+                    },
+                    onCancelSubmit: () {
+                      final dd = _data?.ycDd != null
+                          ? du.DateUtils.formatDate(_data!.ycDd!)
+                          : '';
+                      api.shSubmit(
+                        bilId: 'YC',
+                        bilNo: widget.billNo,
+                        bilDd: dd,
+                        isCancel: true,
+                      );
+                    },
+                    onTapStatusTag: () => _showAuditTrail('YC'),
+                    onReturn: canReturn
+                        ? () {
+                            ReturnCarDialog.show(
+                              context,
+                              billNo: widget.billNo,
+                              odometerBegin: _data?.odometerBegin ?? 0,
+                              recordDd: _data?.recordDd,
+                              onSubmit: (returnData) async {
+                                final api = ref.read(vehicleApplyApiProvider);
+                                returnData['usrCheck'] = HostAppChannel.usr;
+                                try {
+                                  await api.submitReturn(
+                                    widget.billNo,
+                                    returnData,
+                                  );
+                                  if (mounted) _loadData();
+                                  return true;
+                                } catch (_) {
+                                  return false;
+                                }
+                              },
+                            );
+                          }
+                        : null,
+                  )
+                : null;
             _loading = false;
           });
         }
-        // 获取单据状态(非致命)
-        try {
-          final status = await api.getBillStatus(widget.billNo);
-          if (mounted) {
-            setState(() {
-              _billStatus = status;
-              _billStatusBar = BillStatusBar(
-                billStatus: _billStatus,
-                onEdit: () {
-                  GoRouter.of(
-                    context,
-                  ).push('/vehicle-apply/edit/${widget.billNo}').then((result) {
-                    if (result == true && mounted) _loadData();
-                  });
-                },
-                onSubmit: () {
-                  final dd = _data?.ycDd != null
-                      ? du.DateUtils.formatDate(_data!.ycDd!)
-                      : '';
-                  api.shSubmit(bilId: 'YC', bilNo: widget.billNo, bilDd: dd);
-                },
-                onCancelSubmit: () {
-                  final dd = _data?.ycDd != null
-                      ? du.DateUtils.formatDate(_data!.ycDd!)
-                      : '';
-                  api.shSubmit(
-                    bilId: 'YC',
-                    bilNo: widget.billNo,
-                    bilDd: dd,
-                    isCancel: true,
-                  );
-                },
-                onTapStatusTag: () => _showAuditTrail('YC'),
-                onReturn: () {
-                  ReturnCarDialog.show(
-                    context,
-                    billNo: widget.billNo,
-                    odometerBegin: _data?.odometerBegin ?? 0,
-                    onSubmit: (returnData) async {
-                      final api = ref.read(vehicleApplyApiProvider);
-                      returnData['usrCheck'] = HostAppChannel.usr;
-                      await api.submitReturn(widget.billNo, returnData);
-                      if (mounted) _loadData();
-                    },
-                  );
-                },
-              );
-            });
-          }
-        } catch (_) {}
       }
     } catch (e) {
       if (mounted) {
@@ -452,7 +480,6 @@ class _VehicleApplyDetailPageState
     await HostAppChannel.showAuditTrail(billId, widget.billNo);
   }
 
-  // ═══ 工具方法 ═══
   // ═══ 还车信息 ═══
   Widget _buildReturnInfoSection(
     VehicleApplyModel app,
@@ -466,7 +493,7 @@ class _VehicleApplyDetailPageState
         FormFieldRow(
           label: l10n.get('actualReturnTime'),
           value: app.returnTime != null
-              ? du.DateUtils.formatDateTime(app.returnTime!)
+              ? du.DateUtils.formatDateTimeSec(app.returnTime!)
               : '-',
           readOnly: true,
           showArrow: false,
@@ -505,7 +532,7 @@ class _VehicleApplyDetailPageState
         FormFieldRow(
           label: l10n.get('checkDd'),
           value: app.checkDd != null
-              ? du.DateUtils.formatDateTime(app.checkDd!)
+              ? du.DateUtils.formatDateTimeSec(app.checkDd!)
               : '-',
           readOnly: true,
           showArrow: false,

+ 2 - 32
lib/features/vehicle/vehicle_apply_edit_page.dart

@@ -5,10 +5,7 @@ import 'package:flutter/services.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:go_router/go_router.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
-import 'package:dio/dio.dart';
-
 import '../../core/i18n/app_localizations.dart';
-import '../../core/network/api_exception.dart';
 import '../../core/navigation/host_app_channel.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
@@ -781,39 +778,12 @@ class _VehicleApplyEditPageState extends ConsumerState<VehicleApplyEditPage> {
             });
           }
         } catch (e) {
-          if (mounted) {
-            LoadingDialog.hide(context);
-            WidgetsBinding.instance.addPostFrameCallback((_) {
-              if (mounted) _showSubmitError(e, l10n);
-            });
-          }
+          if (mounted) LoadingDialog.hide(context);
         }
       },
     );
   }
 
-  void _showSubmitError(Object e, AppLocalizations l10n) {
-    final message = _extractErrorMessage(e) ?? l10n.get('submitFailedRetry');
-    showGeneralDialog(
-      context: context,
-      pageBuilder: (ctx, animation, secondaryAnimation) => TDConfirmDialog(
-        title: l10n.get('submitFailed'),
-        content: message,
-        buttonStyle: TDDialogButtonStyle.text,
-      ),
-    );
-  }
-
-  String? _extractErrorMessage(Object e) {
-    if (e is DioException) {
-      if (e.error is ApiException) return (e.error as ApiException).message;
-      if (e.error is NetworkException) {
-        return (e.error as NetworkException).message;
-      }
-    }
-    return null;
-  }
-
   Map<String, dynamic> _buildSubmitData() {
     return {
       'HeadData': {
@@ -914,7 +884,7 @@ class _VehicleApplyEditPageState extends ConsumerState<VehicleApplyEditPage> {
       useDay: true,
       useHour: true,
       useMinute: true,
-      dateStart: [now.year - 1, now.month, now.day, now.hour, now.minute],
+      dateStart: [now.year - 1, 1, 1, 0, 0],
       dateEnd: [now.year + 10, 12, 31, 23, 59],
       initialDate: [d.year, d.month, d.day, d.hour, d.minute],
       onConfirm: (selected) {

+ 2 - 0
lib/features/vehicle/vehicle_apply_list_controller.dart

@@ -1,5 +1,6 @@
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 
+import '../../core/navigation/host_app_channel.dart';
 import 'vehicle_apply_api.dart';
 import 'vehicle_apply_model.dart';
 
@@ -213,6 +214,7 @@ final vehicleApplyListProvider =
         endDate: dateEnd != null
             ? '${dateEnd.year}-${dateEnd.month.toString().padLeft(2, '0')}-${dateEnd.day.toString().padLeft(2, '0')}'
             : '',
+        usr: HostAppChannel.usr,
         sortDir: sortDir,
       );
 

+ 19 - 5
lib/features/vehicle/vehicle_apply_list_page.dart

@@ -366,11 +366,8 @@ class _VehicleApplyListContent extends ConsumerWidget {
         ? m.salName
         : (m.applicantName.isNotEmpty ? m.applicantName : m.salNo);
     final deptLabel = m.deptName.isNotEmpty ? m.deptName : m.dep;
-    final hasRoute =
-        m.originAdr.isNotEmpty && m.destAdr.isNotEmpty;
-    final routeText = hasRoute
-        ? '${m.originAdr} → ${m.destAdr}'
-        : '';
+    final hasRoute = m.originAdr.isNotEmpty && m.destAdr.isNotEmpty;
+    final routeText = hasRoute ? '${m.originAdr} → ${m.destAdr}' : '';
     final descParts = <String>[];
     if (m.reason.isNotEmpty) descParts.add(m.reason);
     if (routeText.isNotEmpty) descParts.add(routeText);
@@ -391,6 +388,23 @@ class _VehicleApplyListContent extends ConsumerWidget {
     AppLocalizations l10n,
     AppColorsExtension colors,
   ) {
+    if (item.isReturn) {
+      return Container(
+        padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
+        decoration: BoxDecoration(
+          color: Colors.deepPurple.withValues(alpha: 0.1),
+          borderRadius: BorderRadius.circular(4),
+        ),
+        child: Text(
+          l10n.get('vehicleReturned'),
+          style: const TextStyle(
+            fontSize: 11,
+            fontWeight: FontWeight.w500,
+            color: Colors.deepPurple,
+          ),
+        ),
+      );
+    }
     if (item.isAuditApproved) {
       return Container(
         padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),

+ 4 - 1
lib/features/vehicle/vehicle_apply_model.dart

@@ -120,7 +120,9 @@ class VehicleApplyModel {
       clsDate: json['CLS_DATE'] != null
           ? DateTime.tryParse(json['CLS_DATE'] as String)
           : null,
-      isReturn: json['IS_RETURN'] == 'T',
+      isReturn:
+          (json['IsReturn'] ?? json['IS_RETURN']) == true ||
+          json['IS_RETURN'] == 'T',
       returnTime: json['RETURN_TIME'] != null
           ? DateTime.tryParse(json['RETURN_TIME'] as String)
           : null,
@@ -143,6 +145,7 @@ class VehicleApplyModel {
           json['DEP'] as String? ??
           '',
       isAuditApproved:
+          json['IsAuditApproved'] == true ||
           (json['clsDate'] != null && (json['clsDate'] as String).isNotEmpty) ||
           (json['CHK_MAN'] as String?)?.isNotEmpty == true,
     );

+ 63 - 22
lib/features/vehicle/widgets/return_car_dialog.dart

@@ -3,6 +3,8 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../../../core/i18n/app_localizations.dart';
+import '../../../shared/widgets/loading_dialog.dart';
 import '../../../core/theme/app_colors.dart';
 import '../../../core/theme/app_colors_extension.dart';
 
@@ -12,12 +14,14 @@ import '../../../core/theme/app_colors_extension.dart';
 class ReturnCarDialog extends StatefulWidget {
   final String billNo;
   final int odometerBegin; // 出车里程,用于对比
-  final Function(Map<String, dynamic>) onSubmit; // 提交回调
+  final DateTime? recordDd; // 录入日期,归还时间必须大于此值
+  final Future<bool> Function(Map<String, dynamic>) onSubmit; // 提交回调,返回 true=成功
 
   const ReturnCarDialog({
     super.key,
     required this.billNo,
     required this.odometerBegin,
+    this.recordDd,
     required this.onSubmit,
   });
 
@@ -26,7 +30,8 @@ class ReturnCarDialog extends StatefulWidget {
     BuildContext context, {
     required String billNo,
     required int odometerBegin,
-    required Function(Map<String, dynamic>) onSubmit,
+    DateTime? recordDd,
+    required Future<bool> Function(Map<String, dynamic>) onSubmit,
   }) {
     FocusScope.of(context).unfocus();
     Navigator.push(
@@ -37,6 +42,7 @@ class ReturnCarDialog extends StatefulWidget {
         builder: (_) => ReturnCarDialog(
           billNo: billNo,
           odometerBegin: odometerBegin,
+          recordDd: recordDd,
           onSubmit: onSubmit,
         ),
       ),
@@ -64,13 +70,17 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
   // 提交中
   bool _submitting = false;
 
+  AppLocalizations get l10n => AppLocalizations.of(context);
+
   @override
   void initState() {
     super.initState();
     final now = DateTime.now();
+    final minDt = widget.recordDd ?? now;
+    final initDt = now.isAfter(minDt) ? now : minDt;
     _returnTime =
-        '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')} '
-        '${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
+        '${initDt.year}-${initDt.month.toString().padLeft(2, '0')}-${initDt.day.toString().padLeft(2, '0')} '
+        '${initDt.hour.toString().padLeft(2, '0')}:${initDt.minute.toString().padLeft(2, '0')}:${initDt.second.toString().padLeft(2, '0')}';
     _odometerFocus.addListener(() => _ensureVisible(_odometerFocus));
     _amtnFocus.addListener(() => _ensureVisible(_amtnFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
@@ -115,16 +125,17 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
     super.dispose();
   }
 
-  void _confirm() {
+  Future<void> _confirm() async {
+    FocusScope.of(context).unfocus();
     final odometerText = _odometerCtrl.text.trim();
     if (odometerText.isEmpty) {
-      TDToast.showText('请输入还车里程', context: context);
+      TDToast.showText(l10n.get('enterReturnOdometer'), context: context);
       return;
     }
     final odometerEnd = int.tryParse(odometerText) ?? 0;
     if (odometerEnd <= widget.odometerBegin) {
       TDToast.showText(
-        '还车里程必须大于出车里程(${widget.odometerBegin} km)',
+        '${l10n.get('returnOdometerCheck')}(${widget.odometerBegin} km)',
         context: context,
       );
       return;
@@ -134,13 +145,26 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
     final amtn = double.tryParse(amtnText) ?? 0;
 
     setState(() => _submitting = true);
+    LoadingDialog.show(context, text: l10n.get('returning'));
 
-    widget.onSubmit({
+    final ok = await widget.onSubmit({
       'returnTime': _returnTime,
       'odometerEnd': odometerEnd,
       'amtn': amtn,
       'remark': _remarkCtrl.text.trim(),
     });
+
+    if (!mounted) return;
+    LoadingDialog.hide(context);
+    if (ok) {
+      // 延迟一帧关闭弹窗,等待焦点从 loading 遮罩归还完毕,
+      // 避免 Navigator.pop 被键盘/焦点恢复打断
+      WidgetsBinding.instance.addPostFrameCallback((_) {
+        if (mounted) Navigator.pop(context);
+      });
+    } else {
+      setState(() => _submitting = false);
+    }
   }
 
   @override
@@ -236,7 +260,7 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
               Expanded(
                 child: Center(
                   child: Text(
-                    '还车登记',
+                    l10n.get('returnCarRegister'),
                     style: TextStyle(
                       fontSize: AppFontSizes.title,
                       fontWeight: FontWeight.w600,
@@ -266,9 +290,9 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
   // ── 实际归还时间 ──
   Widget _buildReturnTimePicker(AppColorsExtension colors) {
     return _datePickerCard(
-      label: '实际归还时间',
+      label: l10n.get('actualReturnTime'),
       value: _returnTime,
-      hint: '请选择归还时间',
+      hint: l10n.get('selectReturnTimeHint'),
       colors: colors,
       required: true,
       onPick: (d) => setState(() => _returnTime = d),
@@ -278,10 +302,10 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
   // ── 还车里程 ──
   Widget _buildOdometerInput(AppColorsExtension colors) {
     return _inputCard(
-      label: '还车里程',
+      label: l10n.get('returnOdometer'),
       required: true,
       controller: _odometerCtrl,
-      hintText: '> ${widget.odometerBegin} km',
+      hintText: '> ${widget.odometerBegin}',
       suffixText: 'km',
       keyboardType: TextInputType.number,
       inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*$'))],
@@ -292,11 +316,10 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
   // ── 实际费用 ──
   Widget _buildAmtnInput(AppColorsExtension colors) {
     return _inputCard(
-      label: '实际费用',
+      label: l10n.get('amtn'),
       required: false,
       controller: _amtnCtrl,
-      hintText: '路桥费/停车费等',
-      suffixText: '元',
+      hintText: l10n.get('enterActualCost'),
       keyboardType: const TextInputType.numberWithOptions(decimal: true),
       inputFormatters: [
         FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$')),
@@ -311,8 +334,8 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
     return TDTextarea(
       controller: _remarkCtrl,
       focusNode: _remarkFocus,
-      label: '费用明细备注',
-      hintText: '请输入备注',
+      label: l10n.get('costRemark'),
+      hintText: l10n.get('enterRemark'),
       maxLines: 3,
       minLines: 1,
       maxLength: 500,
@@ -332,7 +355,7 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
       children: [
         Expanded(
           child: TDButton(
-            text: '取消',
+            text: l10n.get('cancel'),
             size: TDButtonSize.large,
             type: TDButtonType.outline,
             shape: TDButtonShape.rectangle,
@@ -343,7 +366,7 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
         const SizedBox(width: 12),
         Expanded(
           child: TDButton(
-            text: '确认还车',
+            text: l10n.get('confirmReturnCar'),
             size: TDButtonSize.large,
             type: TDButtonType.fill,
             shape: TDButtonShape.rectangle,
@@ -445,6 +468,7 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
   }) {
     final now = DateTime.now();
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final minDt = widget.recordDd ?? DateTime(2020);
 
     int initYear = now.year;
     int initMonth = now.month;
@@ -463,10 +487,20 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
       }
     }
 
+    // 确保初始值不早于 RECORD_DD
+    final initDt = DateTime(initYear, initMonth, initDay, initHour, initMinute);
+    if (initDt.isBefore(minDt)) {
+      initYear = minDt.year;
+      initMonth = minDt.month;
+      initDay = minDt.day;
+      initHour = minDt.hour;
+      initMinute = minDt.minute;
+    }
+
     FocusManager.instance.primaryFocus?.unfocus();
     TDPicker.showDatePicker(
       context,
-      title: '选择日期',
+      title: l10n.get('selectDate'),
       backgroundColor: colors.bgCard,
       useYear: true,
       useMonth: true,
@@ -475,7 +509,14 @@ class _ReturnCarDialogState extends State<ReturnCarDialog> {
       useMinute: true,
       useSecond: true,
       useWeekDay: false,
-      dateStart: const [2020, 1, 1, 0, 0, 0],
+      dateStart: [
+        minDt.year,
+        minDt.month,
+        minDt.day,
+        minDt.hour,
+        minDt.minute,
+        minDt.second,
+      ],
       dateEnd: [now.year + 1, 12, 31, 23, 59, 59],
       initialDate: [initYear, initMonth, initDay, initHour, initMinute, 0],
       onConfirm: (selected) {

+ 70 - 0
lib/shared/helpers/audit_flow_helper.dart

@@ -0,0 +1,70 @@
+import 'package:flutter/material.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../../core/i18n/app_localizations.dart';
+import '../../core/theme/app_colors_extension.dart';
+
+/// 审核流处理工具。
+///
+/// BillSave 成功后调用,根据 [getBillAuditConfig] 返回值决定:
+/// - 无审核流 → 直接 return
+/// - 有审核流 + 自动提交 → 直接调 [onShSubmit]
+/// - 有审核流 + 不自动提交 → 弹窗询问,用户确认后调 [onShSubmit]
+/// - getBillAuditConfig 出错 → 直接 return
+class AuditFlowHelper {
+  AuditFlowHelper._();
+
+  /// [getConfig] 调用 getBillAuditConfig,[onShSubmit] 调用 SHSubmit。
+  /// 调用方需确保 [context] 来自当前 mounted 的 widget。
+  static Future<void> handle({
+    required BuildContext context,
+    required AppLocalizations l10n,
+    required Future<Map<String, dynamic>> Function() getConfig,
+    required Future<void> Function() onShSubmit,
+  }) async {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    try {
+      final config = await getConfig();
+      final hasFlow = config['hasAuditFlow'] == true;
+      final autoSubmit = config['autoSubmit'] == true;
+
+      if (!hasFlow) return;
+
+      if (autoSubmit) {
+        await onShSubmit();
+        return;
+      }
+
+      final shouldSubmit = await _showDialog(colors, context, l10n);
+      if (shouldSubmit == true) {
+        await onShSubmit();
+      }
+    } catch (_) {
+      // getBillAuditConfig 或 SHSubmit 失败均不阻塞流程
+    }
+  }
+
+  static Future<bool?> _showDialog(
+    AppColorsExtension colors,
+    BuildContext context,
+    AppLocalizations l10n,
+  ) {
+    return showDialog<bool>(
+      context: context,
+      builder: (ctx) => TDAlertDialog(
+        title: l10n.get('submit'),
+        content: l10n.get('auditFlowPrompt'),
+        buttonStyle: TDDialogButtonStyle.text,
+        leftBtn: TDDialogButtonOptions(
+          title: l10n.get('skipAudit'),
+          titleColor: colors.textSecondary,
+          action: () => Navigator.pop(ctx, false),
+        ),
+        rightBtn: TDDialogButtonOptions(
+          title: l10n.get('submitToAudit'),
+          titleColor: colors.primary,
+          action: () => Navigator.pop(ctx, true),
+        ),
+      ),
+    );
+  }
+}

+ 19 - 2
lib/shared/widgets/bill_status_bar.dart

@@ -43,6 +43,9 @@ class BillStatusBar {
   /// 已转单状态文本(如"已转为报销单",expense/expense_apply 使用)
   final String? transferredText;
 
+  /// 已还车状态文本(如"已还车",仅用车申请单使用,优先级最高)
+  final String? returnedText;
+
   const BillStatusBar({
     this.billStatus,
     this.onEdit,
@@ -52,12 +55,18 @@ class BillStatusBar {
     this.onTapStatusTag,
     this.closedText,
     this.transferredText,
+    this.returnedText,
   });
 
   // ── 派生状态 ──
 
   bool get _isClosed => billStatus?['isClosed'] == true;
   bool get _isTransferred => billStatus?['isTransferred'] == true;
+  bool get _isReturned {
+    final extras = billStatus?['extras'] as Map<String, dynamic>?;
+    return extras?['isReturned'] == true;
+  }
+
   String? get _approvalStatus => billStatus?['approvalStatus'] as String?;
   String? get _approvalText => billStatus?['approvalText'] as String?;
   bool get _canEdit => billStatus?['canEdit'] == true;
@@ -80,15 +89,22 @@ class BillStatusBar {
 
   bool get showReturn {
     if (onReturn == null) return false;
+    if (_isReturned) return false;
     final s = _approvalStatus;
-    return (s == 'SHCOUNT4' || s == 'SHCOUNT6') && !_isClosed && !_isTransferred;
+    return (s == 'SHCOUNT4' || s == 'SHCOUNT6') &&
+        !_isClosed &&
+        !_isTransferred;
   }
 
-  bool get _showButtons => showEdit || showSubmit || showCancelSubmit || showReturn;
+  bool get _showButtons =>
+      showEdit || showSubmit || showCancelSubmit || showReturn;
 
   // ── 状态标签数据 ──
 
   ({IconData icon, Color color}) _statusStyle() {
+    if (_isReturned) {
+      return (icon: Icons.task_alt, color: Colors.deepPurple);
+    }
     if (_isClosed) {
       return (icon: Icons.lock_outline, color: Colors.blueGrey);
     }
@@ -120,6 +136,7 @@ class BillStatusBar {
   }
 
   String? get _displayText {
+    if (_isReturned) return returnedText;
     if (_isClosed) return closedText;
     if (_isTransferred) return transferredText;
     return _approvalText;

+ 5 - 3
lib/shared/widgets/loading_dialog.dart

@@ -28,8 +28,9 @@ class LoadingDialog {
   }
 
   /// 关闭 loading 弹窗。即使弹窗已被关闭也不会抛异常。
+  /// 使用 rootNavigator 匹配 showDialog 默认的 useRootNavigator: true。
   static void hide(BuildContext context) {
-    Navigator.of(context).maybePop();
+    Navigator.of(context, rootNavigator: true).maybePop();
   }
 }
 
@@ -43,7 +44,7 @@ class _LoadingContent extends StatelessWidget {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     return Center(
       child: Container(
-        width: 160,
+        constraints: const BoxConstraints(minWidth: 120, maxWidth: 220),
         padding: const EdgeInsets.fromLTRB(16, 24, 16, 20),
         decoration: BoxDecoration(
           color: colors.loadingCard,
@@ -62,7 +63,8 @@ class _LoadingContent extends StatelessWidget {
             const SizedBox(height: 14),
             Text(
               text ?? l10n.get('loading'),
-              softWrap: false,
+              textAlign: TextAlign.center,
+              softWrap: true,
               style: const TextStyle(
                 fontSize: 14,
                 fontWeight: FontWeight.w500,