chengc 3 weeks ago
parent
commit
458babc5aa
42 changed files with 851 additions and 933 deletions
  1. 2 1
      assets/i18n/en.json
  2. 2 1
      assets/i18n/zh_CN.json
  3. 2 1
      assets/i18n/zh_TW.json
  4. 5 9
      lib/features/admin/admin_permissions_page.dart
  5. 16 20
      lib/features/announcement/announcement_create_page.dart
  6. 5 9
      lib/features/announcement/announcement_detail_page.dart
  7. 5 9
      lib/features/announcement/announcement_list_page.dart
  8. 60 16
      lib/features/expense/expense_api.dart
  9. 17 8
      lib/features/expense/expense_apply_import_page.dart
  10. 74 52
      lib/features/expense/expense_create_page.dart
  11. 40 9
      lib/features/expense/expense_detail_page.dart
  12. 1 0
      lib/features/expense/expense_list_controller.dart
  13. 22 76
      lib/features/expense/expense_list_page.dart
  14. 2 2
      lib/features/expense/expense_model.dart
  15. 31 27
      lib/features/expense/widgets/expense_detail_dialog.dart
  16. 36 15
      lib/features/expense_apply/expense_apply_api.dart
  17. 27 0
      lib/features/expense_apply/expense_apply_approval_api.dart
  18. 57 22
      lib/features/expense_apply/expense_apply_create_page.dart
  19. 96 176
      lib/features/expense_apply/expense_apply_detail_page.dart
  20. 1 0
      lib/features/expense_apply/expense_apply_list_controller.dart
  21. 55 72
      lib/features/expense_apply/expense_apply_list_page.dart
  22. 5 9
      lib/features/home/home_page.dart
  23. 33 37
      lib/features/messages/message_list_page.dart
  24. 17 21
      lib/features/outing_log/outing_log_create_page.dart
  25. 5 9
      lib/features/outing_log/outing_log_detail_page.dart
  26. 39 43
      lib/features/outing_log/outing_log_list_page.dart
  27. 17 21
      lib/features/overtime/overtime_create_page.dart
  28. 5 9
      lib/features/overtime/overtime_detail_page.dart
  29. 39 43
      lib/features/overtime/overtime_list_page.dart
  30. 5 9
      lib/features/profile/profile_page.dart
  31. 5 9
      lib/features/report/expense_apply_detail_report_page.dart
  32. 5 9
      lib/features/report/expense_detail_report_page.dart
  33. 5 9
      lib/features/report/outing_log_detail_report_page.dart
  34. 5 9
      lib/features/report/overtime_detail_report_page.dart
  35. 5 9
      lib/features/report/vehicle_detail_report_page.dart
  36. 17 21
      lib/features/vehicle/vehicle_create_page.dart
  37. 5 9
      lib/features/vehicle/vehicle_detail_page.dart
  38. 39 43
      lib/features/vehicle/vehicle_list_page.dart
  39. 11 87
      lib/shared/widgets/attachment_picker.dart
  40. 16 2
      lib/shared/widgets/nav_bar_config.dart
  41. 16 0
      pubspec.lock
  42. 1 0
      pubspec.yaml

+ 2 - 1
assets/i18n/en.json

@@ -942,6 +942,7 @@
     "draftRestorePrompt": "An unfinished form was found. Restore it?",
     "discard": "Discard",
     "restore": "Restore",
-    "noAttachment": "No attachments"
+    "noAttachment": "No attachments",
+    "attachServiceUnavailable": "Attachment service unavailable"
   }
 }

+ 2 - 1
assets/i18n/zh_CN.json

@@ -792,6 +792,7 @@
     "draftRestorePrompt": "检测到上次未完成的填写记录,是否恢复?",
     "discard": "丢弃",
     "restore": "恢复",
-    "noAttachment": "暂无附件"
+    "noAttachment": "暂无附件",
+    "attachServiceUnavailable": "附件服务暂不可用"
   }
 }

+ 2 - 1
assets/i18n/zh_TW.json

@@ -792,6 +792,7 @@
     "draftRestorePrompt": "檢測到上次未完成的填寫記錄,是否恢復?",
     "discard": "丟棄",
     "restore": "恢復",
-    "noAttachment": "暫無附件"
+    "noAttachment": "暫無附件",
+    "attachServiceUnavailable": "附件服務暫不可用"
   }
 }

+ 5 - 9
lib/features/admin/admin_permissions_page.dart

@@ -115,15 +115,11 @@ class _AdminPermissionsPageState extends ConsumerState<AdminPermissionsPage> {
   Widget build(BuildContext context) {
     //final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('permissionManagement'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('permissionManagement'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
     return Column(
       children: [
         _buildSearchBar(),

+ 16 - 20
lib/features/announcement/announcement_create_page.dart

@@ -445,27 +445,23 @@ class _AnnouncementCreatePageState
     final r = ResponsiveHelper.of(context);
     final l10n = AppLocalizations.of(context);
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('announcementCreate'),
-            showBack: true,
-            showRight: true,
-            rightWidget: GestureDetector(
-              onTap: _showPreview,
-              child: Text(
-                l10n.get('preview'),
-                style: TextStyle(
-                  fontSize: 14,
-                  color: colors.primary,
-                  fontWeight: FontWeight.w500,
-                ),
-              ),
-            ),
-            onBack: () => context.pop(),
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('announcementCreate'),
+      showBack: true,
+      showRight: true,
+      rightWidget: GestureDetector(
+        onTap: _showPreview,
+        child: Text(
+          l10n.get('preview'),
+          style: TextStyle(
+            fontSize: 14,
+            color: colors.primary,
+            fontWeight: FontWeight.w500,
           ),
-        );
+        ),
+      ),
+      onBack: () => context.pop(),
+    ));
 
     return Column(
       children: [

+ 5 - 9
lib/features/announcement/announcement_detail_page.dart

@@ -51,15 +51,11 @@ class _AnnouncementDetailPageState
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('announcementDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('announcementDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
 
     _item = mockAnnouncements.firstWhere(
       (e) => e.id == widget.id,

+ 5 - 9
lib/features/announcement/announcement_list_page.dart

@@ -121,15 +121,11 @@ class _AnnouncementListPageState extends ConsumerState<AnnouncementListPage>
       });
     }
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('announcementList'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('announcementList'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
     return Center(
       child: ConstrainedBox(
         constraints: BoxConstraints(maxWidth: r.listMaxWidth),

+ 60 - 16
lib/features/expense/expense_api.dart

@@ -6,6 +6,7 @@ import '../../core/network/api_response.dart';
 import '../../app.dart';
 import '../../shared/models/pagination_model.dart';
 import '../../shared/models/bill_attachment.dart';
+import '../../core/navigation/host_app_channel.dart';
 import '../../core/utils/date_utils.dart' as du;
 import 'expense_model.dart';
 import 'expense_list_controller.dart';
@@ -103,6 +104,7 @@ class ExpenseApi {
     String startDate = '',
     String endDate = '',
     String usr = '',
+    String sortDir = 'DESC',
     int page = 1,
     int size = 20,
   }) async {
@@ -114,6 +116,7 @@ class ExpenseApi {
         'startDate': startDate,
         'endDate': endDate,
         'usr': usr,
+        'sortDir': sortDir,
         'page': page,
         'size': size,
       },
@@ -130,8 +133,9 @@ class ExpenseApi {
     return ExpenseModel.fromJson(response.data!);
   }
 
-  /// 提交审批,返回报销单号
-  Future<String> submit(Map<String, dynamic> data) async {
+  /// 提交审批,返回报销单号(提取失败时返回 null,不影响主流程)
+  /// BillSave 返回格式: { callok:true, resultData:{ BIL_NO:"BX20267020005", ... } }
+  Future<String?> submit(Map<String, dynamic> data) async {
     final response = await _client.post<Map<String, dynamic>>('/OA/BillSave', data: {
       'erpCategory': 'MasterService',
       'billId': 'BX',
@@ -139,19 +143,34 @@ class ExpenseApi {
       'data': data,
     });
     final resData = response.data;
-    if (resData != null) {
-      final resultData = resData['resultData'] as Map<String, dynamic>?;
-      if (resultData != null) {
-        final bxNo = resultData['BX_NO'] as String?;
-        if (bxNo != null && bxNo.isNotEmpty) return bxNo;
-        final headData = resultData['HeadData'] as Map<String, dynamic>?;
-        if (headData != null) {
-          final hdBxNo = headData['BX_NO'] as String?;
-          if (hdBxNo != null && hdBxNo.isNotEmpty) return hdBxNo;
-        }
-      }
+    if (resData == null) return null;
+
+    final resultData = resData['resultData'];
+    if (resultData is Map<String, dynamic>) {
+      final bilNo = resultData['BIL_NO'] as String?;
+      if (bilNo != null && bilNo.isNotEmpty) return bilNo;
+    }
+    if (resultData is String && resultData.isNotEmpty) {
+      try {
+        final parsed = json.decode(resultData) as Map<String, dynamic>;
+        final bilNo = parsed['BIL_NO'] as String?;
+        if (bilNo != null && bilNo.isNotEmpty) return bilNo;
+      } catch (_) {}
+    }
+    final rootBilNo = resData['BIL_NO'] as String?;
+    if (rootBilNo != null && rootBilNo.isNotEmpty) return rootBilNo;
+
+    return null;
+  }
+
+  /// 检测附件服务是否可用
+  Future<bool> checkAttachHealth() async {
+    try {
+      final response = await _client.get<Map<String, dynamic>>('/OA/CheckAttachHealth');
+      return response.data?['available'] as bool? ?? false;
+    } catch (_) {
+      return false;
     }
-    throw Exception('BillSave succeeded but could not extract bill number');
   }
 
   /// 财务核销
@@ -211,11 +230,11 @@ class ExpenseApi {
 
   /// 可转入报销单的费用申请明细(keyword 模糊匹配单号/事由/部门/申请人/费用类型)
   Future<Map<String, dynamic>> getImportableExpenseApplies({
-    String keyword = '', String startDate = '', String endDate = '', int page = 1, int size = 20,
+    String keyword = '', String startDate = '', String endDate = '', int page = 1, int size = 20, String sortDir = 'DESC',
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
       '/OA/GetImportableExpenseApplies',
-      queryParameters: {'keyword': keyword, 'startDate': startDate, 'endDate': endDate, 'page': page, 'size': size},
+      queryParameters: {'keyword': keyword, 'startDate': startDate, 'endDate': endDate, 'page': page, 'size': size, 'sortDir': sortDir},
     );
     return response.data!;
   }
@@ -405,3 +424,28 @@ final expenseApprovalListProvider =
     return (response['list'] as List<dynamic>?)?.map((e) => ExpenseApprovalListItem.fromJson(e as Map<String, dynamic>).toExpenseModel()).toList() ?? [];
   },
 );
+
+/// "我的报销单" 列表(制单人=当前用户,非审批流)
+final expenseMyListProvider =
+    FutureProvider.autoDispose.family<List<ExpenseModel>, String>(
+  (ref, status) async {
+    ref.watch(expenseRefreshProvider);
+    ref.watch(expenseDateStartProvider);
+    ref.watch(expenseDateEndProvider);
+    ref.watch(expenseKeywordProvider);
+
+    final api = ref.read(expenseApiProvider);
+    final dateStart = ref.read(expenseDateStartProvider);
+    final dateEnd = ref.read(expenseDateEndProvider);
+    final keyword = ref.read(expenseKeywordProvider);
+
+    final result = await api.fetchList(
+      keyword: keyword,
+      startDate: dateStart != null ? du.DateUtils.formatDate(dateStart) : '',
+      endDate: dateEnd != null ? du.DateUtils.formatDate(dateEnd) : '',
+      usr: HostAppChannel.usr,
+      sortDir: ref.watch(expenseSortDirProvider),
+    );
+    return result.list;
+  },
+);

+ 17 - 8
lib/features/expense/expense_apply_import_page.dart

@@ -97,6 +97,7 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
   int _page = 1;
   late final ScrollController _scrollCtrl;
   late final EasyRefreshController _refreshCtrl;
+  bool _sortDesc = true;
 
   @override
   void initState() {
@@ -150,6 +151,7 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
         startDate: _startDateCtrl.text,
         endDate: _endDateCtrl.text,
         page: append ? _page : 1,
+        sortDir: _sortDesc ? 'DESC' : 'ASC',
       );
       if (!mounted) return;
       if (gen != _generation) return;
@@ -314,9 +316,18 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
               ),
               const SizedBox(width: 8),
               GestureDetector(
+                onTap: () { setState(() => _sortDesc = !_sortDesc); _search(); },
+                child: Container(
+                  width: 40, height: 40,
+                  decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)),
+                  child: Center(child: Icon(_sortDesc ? Icons.arrow_downward : Icons.arrow_upward, color: Colors.white, size: 20)),
+                ),
+              ),
+              const SizedBox(width: 8),
+              GestureDetector(
               onTap: _search,
               child: Container(
-                width: 64, height: 40,
+                width: 40, height: 40,
                 decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)),
                 child: const Icon(Icons.search, color: Colors.white, size: 22),
               ),
@@ -557,13 +568,11 @@ class _ExpenseApplyImportPageState extends ConsumerState<ExpenseApplyImportPage>
   Widget build(BuildContext context) {
     final l10n = AppLocalizations.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    ref.read(navBarConfigProvider.notifier).update(
-      NavBarConfig(
-        title: l10n.get('importExpenseApply'),
-        showBack: true,
-        onBack: () => context.pop(),
-      ),
-    );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('importExpenseApply'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
 
     final grouped = <String, List<ImportableItem>>{};
     for (final item in _items) {

+ 74 - 52
lib/features/expense/expense_create_page.dart

@@ -40,6 +40,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
   final _scrollCtrl = ScrollController();
   final _detailsSectionKey = GlobalKey();
   late final AttachmentPickerController _attachmentController;
+  bool _attachAvailable = false;
   late Future<bool> _draftFuture;
   bool _draftHandled = false;
   bool _isPoppingToNative = false;
@@ -70,6 +71,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
     );
     _attachmentController = AttachmentPickerController(maxCount: 9)
       ..addListener(() => setState(() {}));
+    _checkAttachHealth();
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
     _costTypes = [];
@@ -173,24 +175,32 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
     if (state == AppLifecycleState.resumed && _isPoppingToNative) {
       _isPoppingToNative = false;
       HostAppChannel.refresh();
+      // 重置表单数据
+      _purposeController.clear();
+      _remarkController.clear();
+      _selectedDeptId = '';
+      _selectedDeptName = '';
+      _attachmentController.clear();
+      _attachAvailable = false;
+      _addingDetail = false;
+      // 重置参考数据
       _costTypes = [];
       _projects = [];
       _departments = [];
       _customers = [];
       _employees = [];
+      _currencies = [];
       _refDataFuture = null;
       _refDataLoading = true;
+      // 重置草稿和控制器状态
       _draftHandled = false;
-      _purposeController.clear();
-      _remarkController.clear();
-      _selectedDeptId = '';
-      _selectedDeptName = '';
-      ref.read(expenseCreateProvider(widget.editId).notifier).reset();
-      _attachmentController.clear();
       _draftFuture = widget.editId == null
           ? ExpenseCreateController.hasDraft()
           : Future.value(false);
+      ref.read(expenseCreateProvider(widget.editId).notifier).reset();
+      // 重新加载
       _loadRefData();
+      _checkAttachHealth();
     }
   }
 
@@ -201,17 +211,13 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
     final r = ResponsiveHelper.of(context);
     final l10n = AppLocalizations.of(context);
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: widget.editId != null
-                ? l10n.get('editExpense')
-                : l10n.get('expenseApply'),
-            showBack: true,
-            onBack: () => _doPop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: widget.editId != null
+          ? l10n.get('editExpense')
+          : l10n.get('expenseApply'),
+      showBack: true,
+      onBack: () => _doPop(),
+    ));
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final bottomInset = MediaQuery.of(context).padding.bottom;
 
@@ -418,14 +424,13 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
           'ITM': i + 1,
           'BX_DD': _today(),
           'ACC_NO': d.acctSubjectId,
-          'AMT': d.amount,
-          'AMTN': d.totalAmount,
+          'AMT': d.totalAmount,
+          'AMTN': d.amount,
           'AMTN_SH': d.approvedAmount > 0 ? d.approvedAmount : d.totalAmount,
           'REM': d.remark,
           'CUST': d.customerVendorId,
           'IDX_NO': d.expenseCategory,
           'OBJ_NO': d.projectId.isNotEmpty ? d.projectId : '',
-          'AMTN_BT': d.offsetAmount,
           'TAX': d.taxAmount,
           'TAX_RTO': d.taxRate,
           'DEP': d.costDeptId,
@@ -488,7 +493,6 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
               approvedAmount: item.amtnYj,
               customerVendorId: '',
               customerVendorName: '',
-              offsetAmount: 0,
               bankName: '',
               bankAccountName: '',
               bankAccount: '',
@@ -915,42 +919,58 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
     );
   }
 
+  Future<void> _checkAttachHealth() async {
+    if (mounted) setState(() => _attachAvailable = false);
+    try {
+      final api = ref.read(expenseApiProvider);
+      final ok = await api.checkAttachHealth();
+      if (mounted) setState(() => _attachAvailable = ok);
+    } catch (_) {
+      if (mounted) setState(() => _attachAvailable = false);
+    }
+  }
+
   Widget _buildAttachmentSection(
     ExpenseCreateController controller,
     ExpenseCreateState state,
   ) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
+    final children = <Widget>[];
+    if (!_attachAvailable) {
+      children.add(Text(l10n.get('attachServiceUnavailable'),
+          style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)));
+    } else {
+      children.addAll([
+        Text(l10n.get('maxAttachment'),
+            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
+        const SizedBox(height: 8),
+      ]);
+    }
     return FormSection(
       title: l10n.get('attachmentUpload'),
       leadingIcon: Icons.attach_file_outlined,
       children: [
-        Text(
-          l10n.get('maxAttachment'),
-          style: TextStyle(
-            fontSize: AppFontSizes.caption,
-            color: colors.textPlaceholder,
+        ...children,
+        if (_attachAvailable)
+          AttachmentPicker(
+            controller: _attachmentController,
+            maxImageSizeMB: 10,
+            maxFileSizeMB: 20,
+            allowedExtensions: const [
+              'pdf',
+              'doc',
+              'docx',
+              'xls',
+              'xlsx',
+              'ppt',
+              'pptx',
+              'txt',
+            ],
+            onFileRejected: (file, reason) {
+              if (context.mounted) TDToast.showText(reason, context: context);
+            },
           ),
-        ),
-        const SizedBox(height: 8),
-        AttachmentPicker(
-          controller: _attachmentController,
-          maxImageSizeMB: 10,
-          maxFileSizeMB: 20,
-          allowedExtensions: const [
-            'pdf',
-            'doc',
-            'docx',
-            'xls',
-            'xlsx',
-            'ppt',
-            'pptx',
-            'txt',
-          ],
-          onFileRejected: (file, reason) {
-            if (context.mounted) TDToast.showText(reason, context: context);
-          },
-        ),
       ],
     );
   }
@@ -990,9 +1010,12 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
           final data = _buildSubmitData(state);
           final api = ref.read(expenseApiProvider);
           final billNo = await api.submit(data);
-          // 上传附件(表头 + 表身)
-          if (_attachmentController.files.isNotEmpty) {
-            final today = _today();
+          // 上传附件(billNo 提取失败则跳过,不影响主流程)
+          if (billNo != null && _attachmentController.files.isNotEmpty) {
+            final now = DateTime.now();
+            final effDd = '${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')}.'
+                '${now.millisecond.toString().padLeft(3, '0')}';
             final usr = HostAppChannel.usr;
             for (var i = 0; i < _attachmentController.files.length; i++) {
               final file = _attachmentController.files[i];
@@ -1003,7 +1026,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
                   'SRCITM': 0,
                   'ITM': i + 1,
                   'TAG': 1,
-                  'EFF_DD': today,
+                  'EFF_DD': effDd,
                   'USR': usr,
                   'FILENAME': file.name,
                   'EXT': file.name.split('.').last,
@@ -1063,7 +1086,6 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
         customerVendorId: d.customerVendorId,
         customerVendorName: d.customerVendorName,
         approvedAmount: d.approvedAmount,
-        offsetAmount: d.offsetAmount,
         bankName: d.bankName,
         bankAccountName: d.bankAccountName,
         bankAccount: d.bankAccount,
@@ -1084,6 +1106,7 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
       employees: _dialogEmployees,
       l10n: l10n,
       initialData: initialData,
+      checkAttachHealth: () => ref.read(expenseApiProvider).checkAttachHealth(),
     );
     if (result != null && mounted) {
       final now = DateTime.now();
@@ -1112,7 +1135,6 @@ class _ExpenseCreatePageState extends ConsumerState<ExpenseCreatePage>
         customerVendorId: result.customerVendorId,
         customerVendorName: result.customerVendorName,
         approvedAmount: result.approvedAmount,
-        offsetAmount: result.offsetAmount,
         bankName: result.bankName,
         bankAccountName: result.bankAccountName,
         bankAccount: result.bankAccount,

+ 40 - 9
lib/features/expense/expense_detail_page.dart

@@ -26,9 +26,11 @@ class ExpenseDetailPage extends ConsumerStatefulWidget {
   ConsumerState<ExpenseDetailPage> createState() => _ExpenseDetailPageState();
 }
 
-class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
+class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage>
+    with WidgetsBindingObserver {
   ExpenseModel? _expense;
   List<BillAttachment> _attachments = [];
+  bool _attachAvailable = false;
   List<ApprovalRecord> _timelineRecords = [];
   List<String> _timelineChain = [];
   String _timelineCurrentApproverId = '';
@@ -38,9 +40,25 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
   @override
   void initState() {
     super.initState();
+    WidgetsBinding.instance.addObserver(this);
     _loadData();
   }
 
+  @override
+  void dispose() {
+    WidgetsBinding.instance.removeObserver(this);
+    super.dispose();
+  }
+
+  @override
+  void didChangeAppLifecycleState(AppLifecycleState state) {
+    if (state == AppLifecycleState.resumed) {
+      _attachments = [];
+      _attachAvailable = false;
+      _loadData();
+    }
+  }
+
   Future<void> _loadData() async {
     setState(() {
       _isLoading = true;
@@ -78,8 +96,13 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
 
       // 3. 加载附件(非致命)
       try {
-        _attachments = await api.getAttachments('BX', widget.billNo);
+        if (mounted) setState(() => _attachAvailable = false);
+        _attachAvailable = await api.checkAttachHealth();
+        if (_attachAvailable) {
+          _attachments = await api.getAttachments('BX', widget.billNo);
+        }
       } catch (_) {
+        _attachAvailable = false;
         _attachments = [];
       }
     } catch (e) {
@@ -94,13 +117,11 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
 
-    ref.read(navBarConfigProvider.notifier).update(
-          NavBarConfig(
-            title: l10n.get('expenseDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('expenseDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
 
     if (_isLoading) {
       return const Center(
@@ -140,6 +161,7 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
       children: [
         Expanded(
           child: SingleChildScrollView(
+            physics: const AlwaysScrollableScrollPhysics(),
             padding: const EdgeInsets.all(16),
             child: Column(
               children: [
@@ -456,6 +478,15 @@ class _ExpenseDetailPageState extends ConsumerState<ExpenseDetailPage> {
 
   // ═══ 附件 ═══
   Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
+    if (!_attachAvailable) {
+      return FormSection(
+        title: l10n.get('attachments'),
+        leadingIcon: Icons.attach_file_outlined,
+        children: [Text(l10n.get('attachServiceUnavailable'),
+            style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))],
+      );
+    }
+
     final headerAtts = _attachments.where((a) => a.isHeader).toList();
     final bodyGroups = <int, List<BillAttachment>>{};
     for (final a in _attachments.where((a) => a.isBody)) {

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

@@ -7,6 +7,7 @@ final expenseDateStartProvider = StateProvider<DateTime?>((ref) => null);
 final expenseDateEndProvider = StateProvider<DateTime?>((ref) => null);
 final expenseKeywordProvider = StateProvider<String>((ref) => '');
 final expenseRefreshProvider = StateProvider<int>((ref) => 0);
+final expenseSortDirProvider = StateProvider<String>((ref) => 'DESC');
 
 final now = DateTime.now();
 final mockExpenses = <ExpenseModel>[

+ 22 - 76
lib/features/expense/expense_list_page.dart

@@ -8,7 +8,6 @@ import '../../shared/widgets/nav_bar_config.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../core/utils/responsive.dart';
-import '../../core/auth/role_provider.dart';
 import '../../shared/widgets/list_card.dart';
 import '../../shared/widgets/status_tag.dart';
 import '../../shared/widgets/empty_state.dart';
@@ -19,7 +18,6 @@ import 'expense_list_controller.dart';
 import 'expense_model.dart';
 import 'expense_api.dart';
 
-final _scopeProvider = StateProvider<String>((ref) => 'my');
 
 class ExpenseListPage extends ConsumerStatefulWidget {
   const ExpenseListPage({super.key});
@@ -28,13 +26,7 @@ class ExpenseListPage extends ConsumerStatefulWidget {
 }
 
 class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
-    with TickerProviderStateMixin, WidgetsBindingObserver {
-  List<String> _getTabLabels(AppLocalizations l10n) => [
-    l10n.get('statusWaitApprove'),
-    l10n.get('statusApproved'),
-  ];
-  static const _tabKeys = ['pending', 'approved'];
-  late final TabController _tabCtrl;
+    with WidgetsBindingObserver {
   final _keywordCtrl = TextEditingController();
   final _startDateCtrl = TextEditingController();
   final _endDateCtrl = TextEditingController();
@@ -43,12 +35,6 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
   @override
   void initState() {
     super.initState();
-    _tabCtrl = TabController(length: _tabKeys.length, vsync: this);
-    _tabCtrl.addListener(() {
-      if (!_tabCtrl.indexIsChanging) {
-        ref.read(expenseStatusFilterProvider.notifier).state = _tabKeys[_tabCtrl.index];
-      }
-    });
     final now = DateTime.now();
     _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
     _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${_daysInMonth(now.year, now.month).toString().padLeft(2, '0')}';
@@ -77,7 +63,6 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
   @override
   void dispose() {
     WidgetsBinding.instance.removeObserver(this);
-    _tabCtrl.dispose();
     _keywordCtrl.dispose();
     _startDateCtrl.dispose();
     _endDateCtrl.dispose();
@@ -127,20 +112,12 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
 
   @override
   Widget build(BuildContext context) {
-    final status = ref.watch(expenseStatusFilterProvider);
     final l10n = AppLocalizations.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final isManager = ref.watch(isManagerProvider);
     final tdTheme = TDTheme.of(context);
 
-    final targetIdx = _tabKeys.indexOf(status);
-    if (targetIdx >= 0 && _tabCtrl.index != targetIdx && !_tabCtrl.indexIsChanging) {
-      WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _tabCtrl.animateTo(targetIdx); });
-    }
-
-    ref.read(navBarConfigProvider.notifier).update(NavBarConfig(title: l10n.get('expenseList'), showBack: true, onBack: () => SystemNavigator.pop()));
+    setNavBarTitle(context, ref, NavBarConfig(title: l10n.get('expenseList'), showBack: true, onBack: () => SystemNavigator.pop()));
     return Column(children: [
-      if (isManager) Container(color: colors.bgCard, padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: _buildScopeChip(colors)),
       Container(
         color: colors.bgCard,
         child: Column(mainAxisSize: MainAxisSize.min, children: [
@@ -162,62 +139,43 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
               ),
             ),
             const SizedBox(width: 8),
-            GestureDetector(onTap: _applyFilter, child: Container(width: 64, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22))),
+            GestureDetector(
+              onTap: () {
+                final dir = ref.read(expenseSortDirProvider);
+                ref.read(expenseSortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC';
+                _applyFilter();
+              },
+              child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: Center(child: Icon(ref.watch(expenseSortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20))),
+            ),
+            const SizedBox(width: 8),
+            GestureDetector(onTap: _applyFilter, child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22))),
           ])),
         ]),
       ),
-      Container(
-        color: colors.bgCard, padding: const EdgeInsets.symmetric(horizontal: 8),
-        child: TDTabBar(
-          tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(), controller: _tabCtrl,
-          isScrollable: true, labelColor: colors.primary, unselectedLabelColor: colors.textSecondary,
-          outlineType: TDTabBarOutlineType.filled, showIndicator: true, indicatorColor: colors.primary, indicatorHeight: 3,
-          dividerHeight: 0, labelPadding: const EdgeInsets.symmetric(horizontal: 12),
-          onTap: (index) => ref.read(expenseStatusFilterProvider.notifier).state = _tabKeys[index],
-        ),
-      ),
-      Expanded(child: Container(color: colors.bgPage, child: TabBarView(controller: _tabCtrl, children: List.generate(_tabKeys.length, (tabIdx) => _buildTabContent(tabIdx))))),
+      Expanded(child: Container(color: colors.bgPage, child: _ExpenseListContent(refreshCtrl: _refreshCtrl))),
     ]);
   }
-
-  Widget _buildScopeChip(AppColorsExtension colors) {
-    final scope = ref.watch(_scopeProvider);
-    final l10n = AppLocalizations.of(context);
-    return Row(children: [
-      GestureDetector(onTap: () => ref.read(_scopeProvider.notifier).state = 'my', child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration(color: scope == 'my' ? colors.primary : colors.bgPage, borderRadius: BorderRadius.circular(16), border: scope == 'my' ? null : Border.all(color: colors.border)), child: Text(l10n.get('scopeMyApplications'), style: TextStyle(fontSize: 13, color: scope == 'my' ? colors.bgCard : colors.textSecondary)))),
-      const SizedBox(width: 8),
-      GestureDetector(onTap: () => ref.read(_scopeProvider.notifier).state = 'sub', child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration(color: scope == 'sub' ? colors.primary : colors.bgPage, borderRadius: BorderRadius.circular(16), border: scope == 'sub' ? null : Border.all(color: colors.border)), child: Text(l10n.get('scopeSubordinates'), style: TextStyle(fontSize: 13, color: scope == 'sub' ? colors.bgCard : colors.textSecondary)))),
-    ]);
-  }
-
-  Widget _buildTabContent(int tabIdx) {
-    final r = ResponsiveHelper.of(context);
-    return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: _ExpenseTabContent(statusKey: _tabKeys[tabIdx], refreshCtrl: _refreshCtrl)));
-  }
 }
 
-class _ExpenseTabContent extends ConsumerWidget {
-  final String statusKey;
+class _ExpenseListContent extends ConsumerWidget {
   final EasyRefreshController refreshCtrl;
-  const _ExpenseTabContent({required this.statusKey, required this.refreshCtrl});
+  const _ExpenseListContent({required this.refreshCtrl});
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final itemsAsync = ref.watch(expenseApprovalListProvider(statusKey));
-    final scope = ref.watch(_scopeProvider);
-    if (itemsAsync.isLoading && !itemsAsync.hasValue) return const SkeletonLoadingList();
-    return EasyRefresh(controller: refreshCtrl, header: TDRefreshHeader(), onRefresh: () async { ref.read(expenseRefreshProvider.notifier).state++; }, child: _buildContent(itemsAsync, context, ref, scope));
+    final r = ResponsiveHelper.of(context);
+    final itemsAsync = ref.watch(expenseMyListProvider(''));
+    if (itemsAsync.isLoading && !itemsAsync.hasValue) return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: const SkeletonLoadingList()));
+    return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: EasyRefresh(controller: refreshCtrl, header: TDRefreshHeader(), onRefresh: () async { ref.read(expenseRefreshProvider.notifier).state++; }, child: _buildContent(itemsAsync, context, ref))));
   }
 
-  Widget _buildContent(AsyncValue<List<ExpenseModel>> itemsAsync, BuildContext context, WidgetRef ref, String scope) {
+  Widget _buildContent(AsyncValue<List<ExpenseModel>> itemsAsync, BuildContext context, WidgetRef ref) {
     final l10n = AppLocalizations.of(context);
-    final isSub = scope == 'sub';
     if (itemsAsync.isReloading) {
       final oldItems = itemsAsync.valueOrNull ?? [];
       if (oldItems.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenses'))]);
       return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) {
-        final card = ListCard(cardNo: oldItems[i].expenseNo, amount: '¥${oldItems[i].totalAmount.toStringAsFixed(2)}', applicant: isSub ? '${oldItems[i].applicantName} · ${oldItems[i].deptName}' : oldItems[i].applicantName, description: oldItems[i].purpose, date: du.DateUtils.formatDate(oldItems[i].createTime), statusTag: StatusTag.fromStatus(oldItems[i].status, l10n), onTap: () { final queryId = ref.read(expenseStatusFilterProvider) != 'approved' ? 1 : 4; context.push('/expense/detail/${oldItems[i].expenseNo}?queryId=$queryId'); });
-        if (isSub && oldItems[i].status == 'pending') return Padding(padding: const EdgeInsets.only(bottom: 16), child: _buildSwipeApprove(card, oldItems[i].id));
+        final card = ListCard(cardNo: oldItems[i].expenseNo, amount: '¥${oldItems[i].totalAmount.toStringAsFixed(2)}', applicant: oldItems[i].applicantName, description: oldItems[i].purpose, date: du.DateUtils.formatDate(oldItems[i].createTime), statusTag: StatusTag.fromStatus(oldItems[i].status, l10n), onTap: () { context.push('/expense/detail/${oldItems[i].expenseNo}'); });
         return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
       });
     }
@@ -226,21 +184,9 @@ class _ExpenseTabContent extends ConsumerWidget {
     if (items.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenses'))]);
     return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: items.length + 1, itemBuilder: (_, i) {
       if (i == items.length) return ListFooter(itemCount: items.length);
-      final card = ListCard(cardNo: items[i].expenseNo, amount: '¥${items[i].totalAmount.toStringAsFixed(2)}', applicant: isSub ? '${items[i].applicantName} · ${items[i].deptName}' : items[i].applicantName, description: items[i].purpose, date: du.DateUtils.formatDate(items[i].createTime), statusTag: StatusTag.fromStatus(items[i].status, l10n), onTap: () { final queryId = ref.read(expenseStatusFilterProvider) != 'approved' ? 1 : 4; context.push('/expense/detail/${items[i].expenseNo}?queryId=$queryId'); });
-      if (isSub && items[i].status == 'pending') return Padding(padding: const EdgeInsets.only(bottom: 16), child: _buildSwipeApprove(card, items[i].id));
+      final card = ListCard(cardNo: items[i].expenseNo, amount: '¥${items[i].totalAmount.toStringAsFixed(2)}', applicant: items[i].applicantName, description: items[i].purpose, date: du.DateUtils.formatDate(items[i].createTime), statusTag: StatusTag.fromStatus(items[i].status, l10n), onTap: () { context.push('/expense/detail/${items[i].expenseNo}'); });
       return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
     });
   }
 
-  Widget _buildSwipeApprove(Widget card, String itemId) {
-    return Builder(builder: (ctx) {
-      final screenWidth = MediaQuery.of(ctx).size.width;
-      return TDSwipeCell(groupTag: 'expense_approve', right: TDSwipeCellPanel(extentRatio: 100 / screenWidth, children: [
-        TDSwipeCellAction(label: '', backgroundColor: Colors.transparent, builder: (_) => Container(margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), decoration: BoxDecoration(color: Colors.green, borderRadius: BorderRadius.circular(8)), alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 12), child: const Text('一键同意', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600))), onPressed: (_) async {
-          final confirmed = await showDialog<bool>(context: ctx, builder: (dCtx) => TDAlertDialog(title: '确认审批', content: '确认同意该报销?', leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.of(dCtx).pop(false)), rightBtn: TDDialogButtonOptions(title: '确认', action: () => Navigator.of(dCtx).pop(true))));
-          if (confirmed == true) { if (ctx.mounted) TDToast.showSuccess('已审批通过', context: ctx); }
-        }),
-      ]), cell: card);
-    });
-  }
 }

+ 2 - 2
lib/features/expense/expense_model.dart

@@ -347,8 +347,8 @@ class ExpenseDetailModel {
       expenseApplyDate: json['aeDd'] != null
           ? DateTime.parse(json['aeDd'] as String)
           : null,
-      expenseCategory: json['expenseCategory'] as String? ?? '',
-      categoryName: json['categoryName'] as String? ?? '',
+      expenseCategory: json['idxNo'] as String? ?? json['expenseCategory'] as String? ?? '',
+      categoryName: json['idxName'] as String? ?? json['categoryName'] as String? ?? '',
       priority: json['priority'] as String? ?? '1',
       purpose: json['purpose'] as String? ?? '',
       projectId: json['objNo'] as String? ?? '',

+ 31 - 27
lib/features/expense/widgets/expense_detail_dialog.dart

@@ -75,6 +75,7 @@ class ExpenseDetailDialog extends StatefulWidget {
   final List<EmployeeItem> employees;
   final AppLocalizations l10n;
   final ExpenseDetailInputData? initialData;
+  final Future<bool> Function()? checkAttachHealth;
 
   const ExpenseDetailDialog({
     super.key,
@@ -85,6 +86,7 @@ class ExpenseDetailDialog extends StatefulWidget {
     required this.employees,
     required this.l10n,
     this.initialData,
+    this.checkAttachHealth,
   });
 
   /// 显示弹窗,返回 [ExpenseDetailInputData] 或 `null`(取消时)。
@@ -97,6 +99,7 @@ class ExpenseDetailDialog extends StatefulWidget {
     required List<EmployeeItem> employees,
     required AppLocalizations l10n,
     ExpenseDetailInputData? initialData,
+    Future<bool> Function()? checkAttachHealth,
   }) {
     FocusScope.of(context).unfocus();
     return Navigator.push<ExpenseDetailInputData>(
@@ -112,6 +115,7 @@ class ExpenseDetailDialog extends StatefulWidget {
           employees: employees,
           l10n: l10n,
           initialData: initialData,
+          checkAttachHealth: checkAttachHealth,
         ),
       ),
     );
@@ -128,7 +132,6 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
   late TextEditingController _amountCtrl;
   CustomerVendor? _selCustomer;
   late TextEditingController _approvedAmountCtrl;
-  late TextEditingController _offsetCtrl;
   late TextEditingController _remarkCtrl;
   late TextEditingController _bankNameCtrl;
   late TextEditingController _bankAccountNameCtrl;
@@ -139,6 +142,7 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
   EmployeeItem? _selEmployee;
   late final AttachmentPickerController _attachmentCtrl;
   final ScrollController _scrollCtrl = ScrollController();
+  bool _attachAvailable = false;
 
   static const _taxOptions = [0.0, 0.06, 0.09, 0.13];
   static const _taxLabels = ['0%', '6%', '9%', '13%'];
@@ -163,7 +167,6 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
       _selCustomer = CustomerVendor(id: '', name: d.customerVendorName);
     }
     _approvedAmountCtrl = TextEditingController(text: d != null && d.approvedAmount > 0 ? d.approvedAmount.toStringAsFixed(2) : '');
-    _offsetCtrl = TextEditingController(text: d != null && d.offsetAmount > 0 ? d.offsetAmount.toStringAsFixed(2) : '');
     _remarkCtrl = TextEditingController(text: d?.remark ?? '');
     _bankNameCtrl = TextEditingController(text: d?.bankName ?? '');
     _bankAccountNameCtrl = TextEditingController(text: d?.bankAccountName ?? '');
@@ -189,6 +192,18 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     }
     _attachmentCtrl = AttachmentPickerController(maxCount: 9)
       ..addListener(() => setState(() {}));
+    _checkAttachHealth();
+  }
+
+  Future<void> _checkAttachHealth() async {
+    if (widget.checkAttachHealth == null) return;
+    if (mounted) setState(() => _attachAvailable = false);
+    try {
+      final ok = await widget.checkAttachHealth!();
+      if (mounted) setState(() => _attachAvailable = ok);
+    } catch (_) {
+      if (mounted) setState(() => _attachAvailable = false);
+    }
   }
 
   @override
@@ -196,7 +211,6 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     _descCtrl.dispose();
     _amountCtrl.dispose();
     _approvedAmountCtrl.dispose();
-    _offsetCtrl.dispose();
     _remarkCtrl.dispose();
     _bankNameCtrl.dispose();
     _bankAccountNameCtrl.dispose();
@@ -229,7 +243,6 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
         customerVendorId: _selCustomer?.id ?? '',
         customerVendorName: _selCustomer?.name ?? '',
         approvedAmount: double.tryParse(_approvedAmountCtrl.text) ?? 0,
-        offsetAmount: double.tryParse(_offsetCtrl.text) ?? 0,
         bankName: _bankNameCtrl.text.trim(),
         bankAccountName: _bankAccountNameCtrl.text.trim(),
         bankAccount: _bankAccountCtrl.text.trim(),
@@ -309,8 +322,6 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
                       const SizedBox(height: 12),
                       _buildBankInfoCard(colors),
                       const SizedBox(height: 12),
-                      _buildOffsetCard(),
-                      const SizedBox(height: 12),
                       _buildRemarkInput(colors),
                       const SizedBox(height: 12),
                       _buildAttachmentCard(colors),
@@ -669,18 +680,6 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
     );
   }
 
-  Widget _buildOffsetCard() {
-    return _inputCard(
-      label: _l10n.get('offsetAmount'),
-      required: false,
-      controller: _offsetCtrl,
-      hintText: '0',
-      colors: Theme.of(context).extension<AppColorsExtension>()!,
-      keyboardType: const TextInputType.numberWithOptions(decimal: true),
-      inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}$'))],
-    );
-  }
-
   // ── 备注 ──
   Widget _buildRemarkInput(AppColorsExtension colors) {
     final tdTheme = TDTheme.of(context);
@@ -717,15 +716,20 @@ class _ExpenseDetailDialogState extends State<ExpenseDetailDialog> {
           ),
         ),
         const SizedBox(height: 4),
-        AttachmentPicker(
-          controller: _attachmentCtrl,
-          maxImageSizeMB: 10,
-          maxFileSizeMB: 20,
-          allowedExtensions: const ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
-          onFileRejected: (file, reason) {
-            if (context.mounted) TDToast.showText(reason, context: context);
-          },
-        ),
+        if (!_attachAvailable)
+          TDText(_l10n.get('attachServiceUnavailable'),
+              font: tdTheme.fontBodyMedium, fontWeight: FontWeight.w400,
+              textColor: tdTheme.textColorPlaceholder)
+        else
+          AttachmentPicker(
+            controller: _attachmentCtrl,
+            maxImageSizeMB: 10,
+            maxFileSizeMB: 20,
+            allowedExtensions: const ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
+            onFileRejected: (file, reason) {
+              if (context.mounted) TDToast.showText(reason, context: context);
+            },
+          ),
       ],
     );
   }

+ 36 - 15
lib/features/expense_apply/expense_apply_api.dart

@@ -58,6 +58,7 @@ class ExpenseApplyApi {
     String startDate = '',
     String endDate = '',
     String usr = '',
+    String sortDir = 'DESC',
     int page = 1,
     int size = 20,
   }) async {
@@ -69,6 +70,7 @@ class ExpenseApplyApi {
         'startDate': startDate,
         'endDate': endDate,
         'usr': usr,
+        'sortDir': sortDir,
         'page': page,
         'size': size,
       },
@@ -115,8 +117,9 @@ class ExpenseApplyApi {
     return list.map((e) => DepartmentItem.fromJson(e as Map<String, dynamic>)).toList();
   }
 
-  /// 提交审批,返回申请单号
-  Future<String> submit(Map<String, dynamic> data) async {
+  /// 提交审批,返回申请单号(提取失败时返回 null,不影响主流程)
+  /// BillSave 返回格式: { callok:true, resultData:{ BIL_NO:"AE20267020005", ... } }
+  Future<String?> submit(Map<String, dynamic> data) async {
     final response = await _client.post<Map<String, dynamic>>('/OA/BillSave', data: {
       'erpCategory': 'MasterService',
       'billId': 'AE',
@@ -124,20 +127,38 @@ class ExpenseApplyApi {
       'data': data,
     });
     final resData = response.data;
-    // 尝试从 resultData 或 HeadData 中提取 AE_NO
-    if (resData != null) {
-      final resultData = resData['resultData'] as Map<String, dynamic>?;
-      if (resultData != null) {
-        final aeNo = resultData['AE_NO'] as String?;
-        if (aeNo != null && aeNo.isNotEmpty) return aeNo;
-        final headData = resultData['HeadData'] as Map<String, dynamic>?;
-        if (headData != null) {
-          final hdAeNo = headData['AE_NO'] as String?;
-          if (hdAeNo != null && hdAeNo.isNotEmpty) return hdAeNo;
-        }
-      }
+    if (resData == null) return null;
+
+    // 从 resultData 中取(BillSave 实际返回的嵌套格式)
+    final resultData = resData['resultData'];
+    if (resultData is Map<String, dynamic>) {
+      // BIL_NO 是 BillSave 返回的通用单号字段(不区分 AE/BX)
+      final bilNo = resultData['BIL_NO'] as String?;
+      if (bilNo != null && bilNo.isNotEmpty) return bilNo;
+    }
+    // resultData 可能是 JSON 字符串
+    if (resultData is String && resultData.isNotEmpty) {
+      try {
+        final parsed = json.decode(resultData) as Map<String, dynamic>;
+        final bilNo = parsed['BIL_NO'] as String?;
+        if (bilNo != null && bilNo.isNotEmpty) return bilNo;
+      } catch (_) {}
+    }
+    // 兜底: resData 根级 BIL_NO
+    final rootBilNo = resData['BIL_NO'] as String?;
+    if (rootBilNo != null && rootBilNo.isNotEmpty) return rootBilNo;
+
+    return null;
+  }
+
+  /// 检测附件服务是否可用
+  Future<bool> checkAttachHealth() async {
+    try {
+      final response = await _client.get<Map<String, dynamic>>('/OA/CheckAttachHealth');
+      return response.data?['available'] as bool? ?? false;
+    } catch (_) {
+      return false;
     }
-    throw Exception('BillSave succeeded but could not extract bill number');
   }
 
   /// 审批进度

+ 27 - 0
lib/features/expense_apply/expense_apply_approval_api.dart

@@ -1,8 +1,10 @@
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import '../../core/network/api_client.dart';
+import '../../core/navigation/host_app_channel.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../app.dart';
 import 'expense_apply_model.dart';
+import 'expense_apply_api.dart';
 import 'expense_apply_list_controller.dart';
 
 final expenseApplyApprovalApiProvider = Provider<ExpenseApplyApprovalApi>(
@@ -120,3 +122,28 @@ final expenseApplyApprovalListProvider =
         .toList();
   },
 );
+
+/// "我的申请单" 列表(制单人=当前用户,非审批流)
+final expenseApplyMyListProvider =
+    FutureProvider.autoDispose.family<List<ExpenseApplyModel>, String>(
+  (ref, status) async {
+    ref.watch(expenseApplyRefreshProvider);
+    ref.watch(expenseApplyDateStartProvider);
+    ref.watch(expenseApplyDateEndProvider);
+    ref.watch(expenseApplyKeywordProvider);
+
+    final api = ref.read(expenseApplyApiProvider);
+    final dateStart = ref.read(expenseApplyDateStartProvider);
+    final dateEnd = ref.read(expenseApplyDateEndProvider);
+    final keyword = ref.read(expenseApplyKeywordProvider);
+
+    final result = await api.fetchList(
+      keyword: keyword,
+      startDate: dateStart != null ? du.DateUtils.formatDate(dateStart) : '',
+      endDate: dateEnd != null ? du.DateUtils.formatDate(dateEnd) : '',
+      usr: HostAppChannel.usr,
+      sortDir: ref.watch(expenseApplySortDirProvider),
+    );
+    return result.list;
+  },
+);

+ 57 - 22
lib/features/expense_apply/expense_apply_create_page.dart

@@ -51,6 +51,7 @@ class _ExpenseApplyCreatePageState
 
   // ── 附件 ──
   late final AttachmentPickerController _attachmentController;
+  bool _attachAvailable = false;
 
   // ── 草稿 ──
   late Future<bool> _draftFuture;
@@ -81,6 +82,7 @@ class _ExpenseApplyCreatePageState
     );
     _attachmentController = AttachmentPickerController(maxCount: 9)
       ..addListener(() => setState(() {}));
+    _checkAttachHealth();
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
     _costTypes = []; _projects = []; _departments = [];
@@ -170,27 +172,40 @@ class _ExpenseApplyCreatePageState
     if (state == AppLifecycleState.resumed && _isPoppingToNative) {
       _isPoppingToNative = false;
       HostAppChannel.refresh();
+      // 重置表单数据
+      _urgency = Urgency.normal.value;
+      _purposeController.clear();
+      _validUntil = '';
+      _referenceNoController.clear();
+      _remarkController.clear();
+      _details.clear();
+      _detailIdCounter = 1;
+      _attachmentController.clear();
+      _attachAvailable = false;
+      _addingDetail = false;
+      _selectedDeptId = '';
+      _selectedDeptName = '';
+      // 重置参考数据
       _costTypes = []; _projects = []; _departments = [];
       _refDataFuture = null;
       _refDataLoading = true;
+      // 重置草稿状态
       _draftHandled = false;
       _draftFuture = DraftStorage.has(_draftKey);
+      // 重新加载
       _loadRefData();
+      _checkAttachHealth();
     }
   }
 
   @override
   Widget build(BuildContext context) {
     final l10n = AppLocalizations.of(context);
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('expenseApplyRequest'),
-            showBack: true,
-            onBack: () => _doPop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('expenseApplyRequest'),
+      showBack: true,
+      onBack: () => _doPop(),
+    ));
 
     return FutureBuilder<bool>(
       future: _draftFuture,
@@ -761,21 +776,38 @@ class _ExpenseApplyCreatePageState
 
   // ═══ 3. 附件上传 ═══
 
+  Future<void> _checkAttachHealth() async {
+    // 立即设为 false,等待 API 返回后再更新,避免缓存旧值
+    if (mounted) setState(() => _attachAvailable = false);
+    try {
+      final api = ref.read(expenseApplyApiProvider);
+      final ok = await api.checkAttachHealth();
+      if (mounted) setState(() => _attachAvailable = ok);
+    } catch (_) {
+      if (mounted) setState(() => _attachAvailable = false);
+    }
+  }
+
   Widget _buildAttachmentSection(AppLocalizations l10n) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final children = <Widget>[];
+    if (!_attachAvailable) {
+      children.add(Text(l10n.get('attachServiceUnavailable'),
+          style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)));
+    } else {
+      children.addAll([
+        Text(l10n.get('maxAttachment'),
+            style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
+        const SizedBox(height: 8),
+      ]);
+    }
     return FormSection(
       title: l10n.get('attachmentUpload'),
       leadingIcon: Icons.attach_file_outlined,
       children: [
-        Text(
-          l10n.get('maxAttachment'),
-          style: TextStyle(
-            fontSize: AppFontSizes.caption,
-            color: colors.textPlaceholder,
-          ),
-        ),
-        const SizedBox(height: 8),
-        AttachmentPicker(
+        ...children,
+        if (_attachAvailable)
+          AttachmentPicker(
           controller: _attachmentController,
           maxImageSizeMB: 10,
           maxFileSizeMB: 20,
@@ -867,9 +899,12 @@ class _ExpenseApplyCreatePageState
           final data = _buildSubmitData();
           final api = ref.read(expenseApplyApiProvider);
           final billNo = await api.submit(data);
-          // 上传表头附件
-          if (_attachmentController.files.isNotEmpty) {
-            final today = _today();
+          // 上传表头附件(billNo 提取失败则跳过,不影响主流程)
+          if (billNo != null && _attachmentController.files.isNotEmpty) {
+            final now = DateTime.now();
+            final effDd = '${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')}.'
+                '${now.millisecond.toString().padLeft(3, '0')}';
             final usr = HostAppChannel.usr;
             for (var i = 0; i < _attachmentController.files.length; i++) {
               final file = _attachmentController.files[i];
@@ -880,7 +915,7 @@ class _ExpenseApplyCreatePageState
                   'SRCITM': 0,
                   'ITM': i + 1,
                   'TAG': 1,
-                  'EFF_DD': today,
+                  'EFF_DD': effDd,
                   'USR': usr,
                   'FILENAME': file.name,
                   'EXT': file.name.split('.').last,

+ 96 - 176
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -6,12 +6,8 @@ import '../../shared/widgets/nav_bar_config.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
-import '../../shared/widgets/approval_actions.dart';
-import '../../shared/widgets/approval_timeline.dart';
-import '../../shared/models/approval_status.dart';
 import 'expense_apply_model.dart';
 import '../../core/i18n/app_localizations.dart';
-import '../../shared/widgets/loading_dialog.dart';
 import '../../shared/models/bill_attachment.dart';
 import 'expense_apply_api.dart';
 import '../../core/theme/app_colors.dart';
@@ -26,62 +22,60 @@ class ExpenseApplyDetailPage extends ConsumerStatefulWidget {
   ConsumerState<ExpenseApplyDetailPage> createState() => _ExpenseApplyDetailPageState();
 }
 
-class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage> {
+class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
+    with WidgetsBindingObserver {
   bool _loading = true;
   String? _error;
   ExpenseApplyModel? _data;
   List<BillAttachment> _attachments = [];
-
-  List<ApprovalRecord> _approvalRecords = [];
-  List<String> _approvalChain = [];
-  String _currentApproverId = '';
+  bool _attachAvailable = false;
 
   @override
   void initState() {
     super.initState();
+    WidgetsBinding.instance.addObserver(this);
     _loadData();
   }
 
+  @override
+  void dispose() {
+    WidgetsBinding.instance.removeObserver(this);
+    super.dispose();
+  }
+
+  @override
+  void didChangeAppLifecycleState(AppLifecycleState state) {
+    if (state == AppLifecycleState.resumed) {
+      _attachments = [];
+      _attachAvailable = false;
+      _loadData();
+    }
+  }
+
   Future<void> _loadData() async {
     setState(() { _loading = true; _error = null; });
     try {
       final api = ref.read(expenseApplyApiProvider);
       final detail = await api.fetchDetail(widget.billNo);
 
-      // Load approval timeline (non-critical, best-effort)
-      List<ApprovalRecord> records = [];
-      List<String> chain = [];
-      String currentId = '';
-      try {
-        final timeline = await api.fetchApprovalTimeline('AE', widget.billNo);
-        records = (timeline['records'] as List<dynamic>?)
-                ?.map((e) => ApprovalRecord.fromJson(e as Map<String, dynamic>))
-                .toList() ??
-            [];
-        chain = (timeline['chain'] as List<dynamic>?)
-                ?.map((e) => e as String)
-                .toList() ??
-            [];
-        currentId = timeline['currentApproverId'] as String? ?? '';
-      } catch (_) {
-        // Timeline load failure is non-fatal
-      }
-
       // Load attachments (non-critical, best-effort)
       List<BillAttachment> attachments = [];
+      bool attachAvailable = false;
       try {
-        attachments = await api.getAttachments('AE', widget.billNo);
+        if (mounted) setState(() => _attachAvailable = false);
+        attachAvailable = await api.checkAttachHealth();
+        if (attachAvailable) {
+          attachments = await api.getAttachments('AE', widget.billNo);
+        }
       } catch (_) {
-        // Attachment load failure is non-fatal
+        attachAvailable = false;
       }
 
       if (mounted) {
         setState(() {
           _data = detail;
-          _approvalRecords = records;
-          _approvalChain = chain;
-          _currentApproverId = currentId;
           _attachments = attachments;
+          _attachAvailable = attachAvailable;
           _loading = false;
         });
       }
@@ -92,101 +86,16 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
     }
   }
 
-  void _handleApprove() async {
-    final l10n = AppLocalizations.of(context);
-    final rem = await _showOpinionDialog(
-      title: l10n.get('confirmApprove'),
-      hint: l10n.get('approvalComment'),
-    );
-    if (rem == null || !mounted) return;
-    await _doAudit('approve', rem: rem);
-  }
-
-  void _handleReject() async {
-    final l10n = AppLocalizations.of(context);
-    final rem = await _showOpinionDialog(
-      title: l10n.get('confirmReject'),
-      hint: l10n.get('rejectReason'),
-    );
-    if (rem == null || !mounted) return;
-    await _doAudit('reject', rem: rem);
-  }
-
-  void _handleReverseAudit() async {
-    final l10n = AppLocalizations.of(context);
-    final confirmed = await showDialog<bool>(
-      context: context,
-      builder: (ctx) => TDAlertDialog(
-        title: l10n.get('withdrawConfirm'),
-        content: l10n.get('withdrawConfirmTip'),
-        leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx, false)),
-        rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () => Navigator.pop(ctx, true)),
-      ),
-    );
-    if (confirmed != true || !mounted) return;
-    final rem = await _showOpinionDialog(
-      title: l10n.get('withdrawConfirm'),
-      hint: l10n.get('approvalComment'),
-    );
-    if (rem == null || !mounted) return;
-    await _doAudit('reverseAudit', rem: rem);
-  }
-
-  /// 弹出审批意见输入框,返回 null 表示取消
-  Future<String?> _showOpinionDialog({
-    required String title,
-    required String hint,
-  }) async {
-    final ctrl = TextEditingController();
-    return showDialog<String>(
-      context: context,
-      builder: (ctx) => TDAlertDialog(
-        title: title,
-        content: hint,
-        leftBtn: TDDialogButtonOptions(
-          title: AppLocalizations.of(context).get('cancel'),
-          action: () => Navigator.pop(ctx),
-        ),
-        rightBtn: TDDialogButtonOptions(
-          title: AppLocalizations.of(context).get('confirm'),
-          action: () => Navigator.pop(ctx, ctrl.text),
-        ),
-      ),
-    );
-  }
-
-  Future<void> _doAudit(String action, {String rem = ''}) async {
-    try {
-      LoadingDialog.show(context);
-      final api = ref.read(expenseApplyApiProvider);
-      await api.executeApproval(bilId: 'AE', bilNo: widget.billNo, action: action, rem: rem);
-      if (mounted) {
-        LoadingDialog.hide(context);
-        TDToast.showSuccess(AppLocalizations.of(context).get('submitSuccess'), context: context);
-        if (mounted) context.pop();
-      }
-    } catch (e) {
-      if (mounted) {
-        LoadingDialog.hide(context);
-        TDToast.showFail(e.toString(), context: context);
-      }
-    }
-  }
-
   @override
   Widget build(BuildContext context) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('expenseApplyDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('expenseApplyDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
 
     if (_loading) {
       return const Center(
@@ -222,43 +131,22 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
 
     final app = _data!;
 
-    return Column(
-      children: [
-        Expanded(
-          child: SingleChildScrollView(
-            padding: const EdgeInsets.all(16),
-            child: Column(
-              children: [
-                _buildBasicInfoSection(app, l10n, colors),
-                const SizedBox(height: 16),
-                _buildExpenseDetailSection(app, l10n, colors),
-                const SizedBox(height: 16),
-                _buildAttachmentSection(l10n, colors),
-                const SizedBox(height: 16),
-                if (_approvalChain.isNotEmpty)
-                  Container(
-                    padding: const EdgeInsets.all(16),
-                    decoration: BoxDecoration(
-                      color: colors.bgCard,
-                      borderRadius: BorderRadius.circular(8),
-                    ),
-                    child: ApprovalTimeline(
-                      records: _approvalRecords,
-                      chain: _approvalChain,
-                      currentApproverId: _currentApproverId,
-                    ),
-                  ),
-              ],
-            ),
-          ),
-        ),
-        ApprovalActions(
-          queryId: widget.queryId,
-          onApprove: _handleApprove,
-          onReject: _handleReject,
-          onReverseAudit: _handleReverseAudit,
+    return Expanded(
+      child: SingleChildScrollView(
+        physics: const AlwaysScrollableScrollPhysics(),
+        padding: const EdgeInsets.all(16),
+        child: Column(
+          children: [
+            _buildBasicInfoSection(app, l10n, colors),
+            const SizedBox(height: 16),
+            _buildExpenseDetailSection(app, l10n, colors),
+            const SizedBox(height: 16),
+            _buildAttachmentSection(l10n, colors),
+            const SizedBox(height: 24),
+            _buildPageFooter(colors),
+          ],
         ),
-      ],
+      ),
     );
   }
 
@@ -268,12 +156,6 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
     AppLocalizations l10n,
     AppColorsExtension colors,
   ) {
-    String urgencyLabel = switch (app.urgency) {
-      'urgent' => l10n.get('urgent'),
-      'normal' => l10n.get('normal'),
-      'critical' => l10n.get('critical'),
-      _ => app.urgency,
-    };
     return FormSection(
       title: l10n.get('basicInfo'),
       leadingIcon: Icons.info_outline,
@@ -284,16 +166,13 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
         const SizedBox(height: 16),
         FormFieldRow(label: l10n.get('department'), value: app.deptName, readOnly: true, showArrow: false),
         const SizedBox(height: 16),
-        FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDateTime(app.createTime), readOnly: true, showArrow: false),
+        FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDate(app.createTime), readOnly: true, showArrow: false),
         const SizedBox(height: 16),
         SizedBox(
           height: 24,
           child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
             Text(l10n.get('emergencyLevel'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
-            Text(urgencyLabel, style: TextStyle(
-              fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600,
-              color: app.urgency == 'urgent' || app.urgency == 'critical' ? colors.danger : colors.textPrimary,
-            )),
+            _buildUrgencyChip(app.urgency, l10n, colors),
           ]),
         ),
         const SizedBox(height: 16),
@@ -420,15 +299,20 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
 
   // ═══ 附件 ═══
   Widget _buildAttachmentSection(AppLocalizations l10n, AppColorsExtension colors) {
+    final children = <Widget>[];
+    if (!_attachAvailable) {
+      children.add(Text(l10n.get('attachServiceUnavailable'),
+          style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
+    } else if (_attachments.isEmpty) {
+      children.add(Text(l10n.get('noAttachment'),
+          style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)));
+    } else {
+      children.addAll(_attachments.map((a) => _buildAttachmentRow(a, colors)));
+    }
     return FormSection(
       title: l10n.get('attachments'),
       leadingIcon: Icons.attach_file_outlined,
-      children: [
-        if (_attachments.isEmpty)
-          Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))
-        else
-          ..._attachments.map((a) => _buildAttachmentRow(a, colors)),
-      ],
+      children: children,
     );
   }
 
@@ -461,4 +345,40 @@ class _ExpenseApplyDetailPageState extends ConsumerState<ExpenseApplyDetailPage>
       default: return Icons.insert_drive_file;
     }
   }
+
+  Widget _buildPageFooter(AppColorsExtension colors) {
+    final l10n = AppLocalizations.of(context);
+    return Center(
+      child: Padding(
+        padding: const EdgeInsets.only(bottom: 16),
+        child: Row(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            Icon(Icons.rocket_launch_outlined, size: 16, color: colors.textPlaceholder),
+            const SizedBox(width: 6),
+            Text(l10n.get('pageFooter'),
+                style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget _buildUrgencyChip(String urgency, AppLocalizations l10n, AppColorsExtension colors) {
+    final (label, color) = switch (urgency) {
+      '3' || 'critical' => (l10n.get('critical'), colors.danger),
+      '2' || 'urgent' => (l10n.get('urgent'), colors.warning),
+      '1' || 'normal' => (l10n.get('normal'), colors.primary),
+      _ => (l10n.get('normal'), colors.primary),
+    };
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
+      decoration: BoxDecoration(
+        color: color.withValues(alpha: 0.1),
+        borderRadius: BorderRadius.circular(4),
+        border: Border.all(color: color, width: 0.5),
+      ),
+      child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: color)),
+    );
+  }
 }

+ 1 - 0
lib/features/expense_apply/expense_apply_list_controller.dart

@@ -10,6 +10,7 @@ final expenseApplyKeywordProvider = StateProvider<String>((ref) => '');
 final expenseApplyTypeFilterProvider = StateProvider<String?>((ref) => null);
 final expenseApplyUrgencyFilterProvider = StateProvider<String?>((ref) => null);
 final expenseApplyRefreshProvider = StateProvider<int>((ref) => 0);
+final expenseApplySortDirProvider = StateProvider<String>((ref) => 'DESC');
 
 final now = DateTime.now();
 final mockExpenseApplies = <ExpenseApplyModel>[

+ 55 - 72
lib/features/expense_apply/expense_apply_list_page.dart

@@ -8,9 +8,7 @@ import '../../shared/widgets/nav_bar_config.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../core/utils/responsive.dart';
-import '../../core/auth/role_provider.dart';
 import '../../shared/widgets/list_card.dart';
-import '../../shared/widgets/status_tag.dart';
 import '../../shared/widgets/empty_state.dart';
 import '../../shared/widgets/skeleton_list_card.dart';
 import '../../shared/widgets/list_footer.dart';
@@ -18,8 +16,6 @@ import '../../core/i18n/app_localizations.dart';
 import 'expense_apply_list_controller.dart';
 import 'expense_apply_model.dart';
 import 'expense_apply_approval_api.dart';
-final _scopeProvider = StateProvider<String>((ref) => 'my');
-
 class ExpenseApplyListPage extends ConsumerStatefulWidget {
   const ExpenseApplyListPage({super.key});
   @override
@@ -27,10 +23,7 @@ class ExpenseApplyListPage extends ConsumerStatefulWidget {
 }
 
 class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage>
-    with TickerProviderStateMixin, WidgetsBindingObserver {
-  List<String> _getTabLabels(AppLocalizations l10n) => [l10n.get('statusWaitApprove'), l10n.get('statusApproved')];
-  static const _tabKeys = ['pending', 'approved'];
-  late final TabController _tabCtrl;
+    with WidgetsBindingObserver {
   final _keywordCtrl = TextEditingController();
   final _startDateCtrl = TextEditingController();
   final _endDateCtrl = TextEditingController();
@@ -39,10 +32,6 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage>
   @override
   void initState() {
     super.initState();
-    _tabCtrl = TabController(length: _tabKeys.length, vsync: this);
-    _tabCtrl.addListener(() {
-      if (!_tabCtrl.indexIsChanging) ref.read(expenseApplyStatusFilterProvider.notifier).state = _tabKeys[_tabCtrl.index];
-    });
     final now = DateTime.now();
     _startDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-01';
     _endDateCtrl.text = '${now.year}-${now.month.toString().padLeft(2, '0')}-${DateTime(now.year, now.month + 1, 0).day.toString().padLeft(2, '0')}';
@@ -71,7 +60,6 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage>
   @override
   void dispose() {
     WidgetsBinding.instance.removeObserver(this);
-    _tabCtrl.dispose();
     _keywordCtrl.dispose();
     _startDateCtrl.dispose();
     _endDateCtrl.dispose();
@@ -117,20 +105,12 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage>
 
   @override
   Widget build(BuildContext context) {
-    final status = ref.watch(expenseApplyStatusFilterProvider);
     final l10n = AppLocalizations.of(context);
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
-    final isManager = ref.watch(isManagerProvider);
     final tdTheme = TDTheme.of(context);
 
-    final targetIdx = _tabKeys.indexOf(status);
-    if (targetIdx >= 0 && _tabCtrl.index != targetIdx && !_tabCtrl.indexIsChanging) {
-      WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _tabCtrl.animateTo(targetIdx); });
-    }
-
-    ref.read(navBarConfigProvider.notifier).update(NavBarConfig(title: l10n.get('expenseApplyList'), showBack: true, onBack: () => SystemNavigator.pop()));
+    setNavBarTitle(context, ref, NavBarConfig(title: l10n.get('expenseApplyList'), showBack: true, onBack: () => SystemNavigator.pop()));
     return Column(children: [
-      if (isManager) Container(color: colors.bgCard, padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: _buildScopeChip(colors)),
       Container(
         color: colors.bgCard,
         child: Column(mainAxisSize: MainAxisSize.min, children: [
@@ -152,62 +132,52 @@ class _ExpenseApplyListPageState extends ConsumerState<ExpenseApplyListPage>
               ),
             ),
             const SizedBox(width: 8),
-            GestureDetector(onTap: _applyFilter, child: Container(width: 64, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22))),
+            GestureDetector(
+              onTap: () {
+                final dir = ref.read(expenseApplySortDirProvider);
+                ref.read(expenseApplySortDirProvider.notifier).state = dir == 'ASC' ? 'DESC' : 'ASC';
+                _applyFilter();
+              },
+              child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: Center(child: Icon(ref.watch(expenseApplySortDirProvider) == 'ASC' ? Icons.arrow_upward : Icons.arrow_downward, color: Colors.white, size: 20))),
+            ),
+            const SizedBox(width: 8),
+            GestureDetector(onTap: _applyFilter, child: Container(width: 40, height: 40, decoration: BoxDecoration(color: colors.primary, borderRadius: BorderRadius.circular(20)), child: const Icon(Icons.search, color: Colors.white, size: 22))),
           ])),
         ]),
       ),
-      Container(
-        color: colors.bgCard, padding: const EdgeInsets.symmetric(horizontal: 8),
-        child: TDTabBar(
-          tabs: _getTabLabels(l10n).map((l) => TDTab(text: l)).toList(), controller: _tabCtrl,
-          isScrollable: true, labelColor: colors.primary, unselectedLabelColor: colors.textSecondary,
-          outlineType: TDTabBarOutlineType.filled, showIndicator: true, indicatorColor: colors.primary, indicatorHeight: 3,
-          dividerHeight: 0, labelPadding: const EdgeInsets.symmetric(horizontal: 12),
-          onTap: (index) => ref.read(expenseApplyStatusFilterProvider.notifier).state = _tabKeys[index],
-        ),
-      ),
-      Expanded(child: Container(color: colors.bgPage, child: TabBarView(controller: _tabCtrl, children: List.generate(_tabKeys.length, (tabIdx) => _buildTabContent(tabIdx))))),
+      Expanded(child: Container(color: colors.bgPage, child: _ExpenseApplyListContent(refreshCtrl: _refreshCtrl))),
     ]);
   }
-
-  Widget _buildScopeChip(AppColorsExtension colors) {
-    final scope = ref.watch(_scopeProvider);
-    final l10n = AppLocalizations.of(context);
-    return Row(children: [
-      GestureDetector(onTap: () => ref.read(_scopeProvider.notifier).state = 'my', child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration(color: scope == 'my' ? colors.primary : colors.bgPage, borderRadius: BorderRadius.circular(16), border: scope == 'my' ? null : Border.all(color: colors.border)), child: Text(l10n.get('scopeMyApplications'), style: TextStyle(fontSize: 13, color: scope == 'my' ? colors.bgCard : colors.textSecondary)))),
-      const SizedBox(width: 8),
-      GestureDetector(onTap: () => ref.read(_scopeProvider.notifier).state = 'sub', child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), decoration: BoxDecoration(color: scope == 'sub' ? colors.primary : colors.bgPage, borderRadius: BorderRadius.circular(16), border: scope == 'sub' ? null : Border.all(color: colors.border)), child: Text(l10n.get('scopeSubordinates'), style: TextStyle(fontSize: 13, color: scope == 'sub' ? colors.bgCard : colors.textSecondary)))),
-    ]);
-  }
-
-  Widget _buildTabContent(int tabIdx) {
-    final r = ResponsiveHelper.of(context);
-    return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: _ExpenseApplyTabContent(statusKey: _tabKeys[tabIdx], refreshCtrl: _refreshCtrl)));
-  }
 }
 
-class _ExpenseApplyTabContent extends ConsumerWidget {
-  final String statusKey;
+class _ExpenseApplyListContent extends ConsumerWidget {
   final EasyRefreshController refreshCtrl;
-  const _ExpenseApplyTabContent({required this.statusKey, required this.refreshCtrl});
+  const _ExpenseApplyListContent({required this.refreshCtrl});
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final itemsAsync = ref.watch(expenseApplyApprovalListProvider(statusKey));
-    final scope = ref.watch(_scopeProvider);
-    if (itemsAsync.isLoading && !itemsAsync.hasValue) return const SkeletonLoadingList();
-    return EasyRefresh(controller: refreshCtrl, header: TDRefreshHeader(), onRefresh: () async { ref.read(expenseApplyRefreshProvider.notifier).state++; }, child: _buildContent(itemsAsync, context, ref, scope));
+    final r = ResponsiveHelper.of(context);
+    final itemsAsync = ref.watch(expenseApplyMyListProvider(''));
+    if (itemsAsync.isLoading && !itemsAsync.hasValue) return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: const SkeletonLoadingList()));
+    return Center(child: ConstrainedBox(constraints: BoxConstraints(maxWidth: r.listMaxWidth), child: EasyRefresh(controller: refreshCtrl, header: TDRefreshHeader(), onRefresh: () async { ref.read(expenseApplyRefreshProvider.notifier).state++; }, child: _buildContent(itemsAsync, context, ref))));
   }
 
-  Widget _buildContent(AsyncValue<List<ExpenseApplyModel>> itemsAsync, BuildContext context, WidgetRef ref, String scope) {
+  Widget _buildContent(AsyncValue<List<ExpenseApplyModel>> itemsAsync, BuildContext context, WidgetRef ref) {
     final l10n = AppLocalizations.of(context);
-    final isSub = scope == 'sub';
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
     if (itemsAsync.isReloading) {
       final oldItems = itemsAsync.valueOrNull ?? [];
       if (oldItems.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenseApplications'))]);
       return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: oldItems.length, itemBuilder: (_, i) {
-        final card = ListCard(cardNo: oldItems[i].expenseApplyNo, amount: '¥${oldItems[i].estimatedAmount.toStringAsFixed(2)}', applicant: isSub ? '${oldItems[i].applicantName} · ${oldItems[i].deptName}' : oldItems[i].applicantName, description: oldItems[i].purpose, date: du.DateUtils.formatDate(oldItems[i].createTime), statusTag: StatusTag.fromStatus(oldItems[i].status, l10n), onTap: () { final queryId = ref.read(expenseApplyStatusFilterProvider) != 'approved' ? 1 : 4; context.push('/expense-apply/detail/${oldItems[i].expenseApplyNo}?queryId=$queryId'); });
-        if (isSub && oldItems[i].status == 'pending') return Padding(padding: const EdgeInsets.only(bottom: 16), child: _buildSwipeApprove(card, oldItems[i].id));
+        final m = oldItems[i];
+        final card = ListCard(
+          cardNo: m.expenseApplyNo, amount: '¥${m.estimatedAmount.toStringAsFixed(2)}',
+          applicant: m.deptName.isNotEmpty ? '${m.applicantName} · ${m.deptName}' : m.applicantName,
+          description: m.remark.isNotEmpty ? '${m.purpose}\n${l10n.get('remark')}: ${m.remark}' : m.purpose,
+          date: du.DateUtils.formatDate(m.createTime),
+          statusTag: _buildUrgencyChip(m.urgency, l10n, colors),
+          onTap: () { context.push('/expense-apply/detail/${m.expenseApplyNo}'); },
+        );
         return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
       });
     }
@@ -216,21 +186,34 @@ class _ExpenseApplyTabContent extends ConsumerWidget {
     if (items.isEmpty) return ListView(children: [const SizedBox(height: 120), EmptyState(message: l10n.get('noExpenseApplications'))]);
     return ListView.builder(padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), itemCount: items.length + 1, itemBuilder: (_, i) {
       if (i == items.length) return ListFooter(itemCount: items.length);
-      final card = ListCard(cardNo: items[i].expenseApplyNo, amount: '¥${items[i].estimatedAmount.toStringAsFixed(2)}', applicant: isSub ? '${items[i].applicantName} · ${items[i].deptName}' : items[i].applicantName, description: items[i].purpose, date: du.DateUtils.formatDate(items[i].createTime), statusTag: StatusTag.fromStatus(items[i].status, l10n), onTap: () { final queryId = ref.read(expenseApplyStatusFilterProvider) != 'approved' ? 1 : 4; context.push('/expense-apply/detail/${items[i].expenseApplyNo}?queryId=$queryId'); });
-      if (isSub && items[i].status == 'pending') return Padding(padding: const EdgeInsets.only(bottom: 16), child: _buildSwipeApprove(card, items[i].id));
+      final m = items[i];
+      final card = ListCard(
+        cardNo: m.expenseApplyNo, amount: '¥${m.estimatedAmount.toStringAsFixed(2)}',
+        applicant: m.deptName.isNotEmpty ? '${m.applicantName} · ${m.deptName}' : m.applicantName,
+        description: m.remark.isNotEmpty ? '${m.purpose}\n${l10n.get('remark')}: ${m.remark}' : m.purpose,
+        date: du.DateUtils.formatDate(m.createTime),
+        statusTag: _buildUrgencyChip(m.urgency, l10n, colors),
+        onTap: () { context.push('/expense-apply/detail/${m.expenseApplyNo}'); },
+      );
       return Padding(padding: const EdgeInsets.only(bottom: 16), child: card);
     });
   }
 
-  Widget _buildSwipeApprove(Widget card, String itemId) {
-    return Builder(builder: (ctx) {
-      final screenWidth = MediaQuery.of(ctx).size.width;
-      return TDSwipeCell(groupTag: 'expense_apply_approve', right: TDSwipeCellPanel(extentRatio: 100 / screenWidth, children: [
-        TDSwipeCellAction(label: '', backgroundColor: Colors.transparent, builder: (_) => Container(margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), decoration: BoxDecoration(color: Colors.green, borderRadius: BorderRadius.circular(8)), alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 12), child: const Text('一键同意', style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w600))), onPressed: (_) async {
-          final confirmed = await showDialog<bool>(context: ctx, builder: (dCtx) => TDAlertDialog(title: '确认审批', content: '确认同意该申请?', leftBtn: TDDialogButtonOptions(title: '取消', action: () => Navigator.of(dCtx).pop(false)), rightBtn: TDDialogButtonOptions(title: '确认', action: () => Navigator.of(dCtx).pop(true))));
-          if (confirmed == true) { if (ctx.mounted) TDToast.showSuccess('已审批通过', context: ctx); }
-        }),
-      ]), cell: card);
-    });
+  Widget _buildUrgencyChip(String urgency, AppLocalizations l10n, AppColorsExtension colors) {
+    // 与费用申请单创建页保持一致: critical→danger, urgent→warning, normal→primary
+    final (label, color) = switch (urgency) {
+      '3' || 'critical' => (l10n.get('critical'), colors.danger),
+      '2' || 'urgent' => (l10n.get('urgent'), colors.warning),
+      _ => (l10n.get('normal'), colors.primary),
+    };
+    return Container(
+      padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
+      decoration: BoxDecoration(
+        color: color.withValues(alpha: 0.1),
+        borderRadius: BorderRadius.circular(4),
+        border: Border.all(color: color, width: 0.5),
+      ),
+      child: Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: color)),
+    );
   }
 }

+ 5 - 9
lib/features/home/home_page.dart

@@ -27,15 +27,11 @@ class HomePage extends ConsumerWidget {
     final l10n = AppLocalizations.of(context);
 
     if (location == '/') {
-      ref
-          .read(navBarConfigProvider.notifier)
-          .update(
-            NavBarConfig(
-              title: l10n.get('appName'),
-              showBack: true,
-              leadingIcon: Icons.close,
-            ),
-          );
+      setNavBarTitle(context, ref, NavBarConfig(
+        title: l10n.get('appName'),
+        showBack: true,
+        leadingIcon: Icons.close,
+      ));
     }
 
     return summaryAsync.when(

+ 33 - 37
lib/features/messages/message_list_page.dart

@@ -35,43 +35,39 @@ class MessageListPage extends ConsumerWidget {
     final l10n = AppLocalizations.of(context);
 
     if (location.startsWith('/messages')) {
-      ref
-          .read(navBarConfigProvider.notifier)
-          .update(
-            NavBarConfig(
-              title: l10n.get('messageNotifications'),
-              showBack: true,
-              leadingIcon: Icons.close,
-              // 仅在有未读时显示"全部已读"按钮
-              showRight: unreadCount > 0,
-              rightWidget: unreadCount > 0
-                  ? GestureDetector(
-                      onTap: () {
-                        TDMessage.showMessage(
-                          context: context,
-                          content: l10n.get('markAllRead'),
-                          theme: MessageTheme.success,
-                          icon: true,
-                          duration: 2000,
-                        );
-                      },
-                      child: Padding(
-                        padding: const EdgeInsets.symmetric(
-                          horizontal: 12,
-                          vertical: 8,
-                        ),
-                        child: Text(
-                          l10n.get('markAllRead'),
-                          style: TextStyle(
-                            fontSize: AppFontSizes.body,
-                            color: colors.primary,
-                          ),
-                        ),
-                      ),
-                    )
-                  : null,
-            ),
-          );
+      setNavBarTitle(context, ref, NavBarConfig(
+        title: l10n.get('messageNotifications'),
+        showBack: true,
+        leadingIcon: Icons.close,
+        // 仅在有未读时显示"全部已读"按钮
+        showRight: unreadCount > 0,
+        rightWidget: unreadCount > 0
+            ? GestureDetector(
+                onTap: () {
+                  TDMessage.showMessage(
+                    context: context,
+                    content: l10n.get('markAllRead'),
+                    theme: MessageTheme.success,
+                    icon: true,
+                    duration: 2000,
+                  );
+                },
+                child: Padding(
+                  padding: const EdgeInsets.symmetric(
+                    horizontal: 12,
+                    vertical: 8,
+                  ),
+                  child: Text(
+                    l10n.get('markAllRead'),
+                    style: TextStyle(
+                      fontSize: AppFontSizes.body,
+                      color: colors.primary,
+                    ),
+                  ),
+                ),
+              )
+            : null,
+      ));
     }
 
     // 首次加载:展示骨架屏,不包裹 EasyRefresh

+ 17 - 21
lib/features/outing_log/outing_log_create_page.dart

@@ -329,27 +329,23 @@ class _OutingLogCreatePageState extends ConsumerState<OutingLogCreatePage> {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('outingLogCreate'),
-            showBack: true,
-            onBack: () {
-              if (_hasUnsaved()) {
-                _showConfirmDialog(
-                  l10n.get('confirmExit'),
-                  l10n.get('unsavedContentWarning'),
-                  l10n.get('continueEditing'),
-                  l10n.get('discardAndExit'),
-                  () => context.pop(),
-                );
-              } else {
-                context.pop();
-              }
-            },
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('outingLogCreate'),
+      showBack: true,
+      onBack: () {
+        if (_hasUnsaved()) {
+          _showConfirmDialog(
+            l10n.get('confirmExit'),
+            l10n.get('unsavedContentWarning'),
+            l10n.get('continueEditing'),
+            l10n.get('discardAndExit'),
+            () => context.pop(),
+          );
+        } else {
+          context.pop();
+        }
+      },
+    ));
 
     return PopScope(
       canPop: false,

+ 5 - 9
lib/features/outing_log/outing_log_detail_page.dart

@@ -98,15 +98,11 @@ class _OutingLogDetailPageState extends ConsumerState<OutingLogDetailPage> {
     //final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('outingLogDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('outingLogDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
 
     return Column(
       children: [

+ 39 - 43
lib/features/outing_log/outing_log_list_page.dart

@@ -96,51 +96,47 @@ class _OutingLogListPageState extends ConsumerState<OutingLogListPage>
       ref.read(outingLogDateEndProvider.notifier).state = null;
     }
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('outingLogList'),
-            showBack: true,
-            showRight: true,
-            rightWidget: GestureDetector(
-              onTap: () => ListFilterPanel.show(
-                context,
-                groups: filterGroups,
-                onReset: onFilterReset,
-                onConfirm: () {},
-                defaultStartDate: DateTime(now.year, now.month, 1),
-                defaultEndDate: DateTime(now.year, now.month, now.day),
-              ),
-              child: Stack(
-                clipBehavior: Clip.none,
-                children: [
-                  Icon(
-                    TDIcons.filter,
-                    size: 22,
-                    color: hasFilter ? colors.primary : colors.textPrimary,
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('outingLogList'),
+      showBack: true,
+      showRight: true,
+      rightWidget: GestureDetector(
+        onTap: () => ListFilterPanel.show(
+          context,
+          groups: filterGroups,
+          onReset: onFilterReset,
+          onConfirm: () {},
+          defaultStartDate: DateTime(now.year, now.month, 1),
+          defaultEndDate: DateTime(now.year, now.month, now.day),
+        ),
+        child: Stack(
+          clipBehavior: Clip.none,
+          children: [
+            Icon(
+              TDIcons.filter,
+              size: 22,
+              color: hasFilter ? colors.primary : colors.textPrimary,
+            ),
+            if (hasFilter)
+              Positioned(
+                right: -2,
+                top: -2,
+                child: Container(
+                  width: 6,
+                  height: 6,
+                  decoration: BoxDecoration(
+                    color: colors.danger,
+                    shape: BoxShape.circle,
                   ),
-                  if (hasFilter)
-                    Positioned(
-                      right: -2,
-                      top: -2,
-                      child: Container(
-                        width: 6,
-                        height: 6,
-                        decoration: BoxDecoration(
-                          color: colors.danger,
-                          shape: BoxShape.circle,
-                        ),
-                      ),
-                    ),
-                ],
+                ),
               ),
-            ),
-            hasFilter: hasFilter,
-            filterVersion: filterVersion,
-            onBack: () => context.pop(),
-          ),
-        );
+          ],
+        ),
+      ),
+      hasFilter: hasFilter,
+      filterVersion: filterVersion,
+      onBack: () => context.pop(),
+    ));
     return Center(
       child: ConstrainedBox(
         constraints: BoxConstraints(maxWidth: r.listMaxWidth),

+ 17 - 21
lib/features/overtime/overtime_create_page.dart

@@ -55,27 +55,23 @@ class _OvertimeCreatePageState extends ConsumerState<OvertimeCreatePage> {
     final state = ref.watch(overtimeCreateProvider(widget.editId));
     final l10n = AppLocalizations.of(context);
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('overtimeApply'),
-            showBack: true,
-            onBack: () {
-              if (_hasUnsaved()) {
-                _showConfirmDialog(
-                  l10n.get('confirmExit'),
-                  l10n.get('unsavedContentWarning'),
-                  l10n.get('continueEditing'),
-                  l10n.get('discardAndExit'),
-                  () => context.pop(),
-                );
-              } else {
-                context.pop();
-              }
-            },
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('overtimeApply'),
+      showBack: true,
+      onBack: () {
+        if (_hasUnsaved()) {
+          _showConfirmDialog(
+            l10n.get('confirmExit'),
+            l10n.get('unsavedContentWarning'),
+            l10n.get('continueEditing'),
+            l10n.get('discardAndExit'),
+            () => context.pop(),
+          );
+        } else {
+          context.pop();
+        }
+      },
+    ));
 
     return PopScope(
       canPop: false,

+ 5 - 9
lib/features/overtime/overtime_detail_page.dart

@@ -29,15 +29,11 @@ class OvertimeDetailPage extends ConsumerWidget {
       colors,
     );
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('overtimeDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('overtimeDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
     return Column(
       children: [
         Expanded(

+ 39 - 43
lib/features/overtime/overtime_list_page.dart

@@ -138,51 +138,47 @@ class _OvertimeListPageState extends ConsumerState<OvertimeListPage>
       ref.read(overtimeTypeFilterProvider.notifier).state = null;
     }
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('overtimeList'),
-            showBack: true,
-            showRight: true,
-            rightWidget: GestureDetector(
-              onTap: () => ListFilterPanel.show(
-                context,
-                groups: filterGroups,
-                onReset: onFilterReset,
-                onConfirm: () {},
-                defaultStartDate: DateTime(now.year, now.month, 1),
-                defaultEndDate: DateTime(now.year, now.month, now.day),
-              ),
-              child: Stack(
-                clipBehavior: Clip.none,
-                children: [
-                  Icon(
-                    TDIcons.filter,
-                    size: 22,
-                    color: hasFilter ? colors.primary : colors.textPrimary,
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('overtimeList'),
+      showBack: true,
+      showRight: true,
+      rightWidget: GestureDetector(
+        onTap: () => ListFilterPanel.show(
+          context,
+          groups: filterGroups,
+          onReset: onFilterReset,
+          onConfirm: () {},
+          defaultStartDate: DateTime(now.year, now.month, 1),
+          defaultEndDate: DateTime(now.year, now.month, now.day),
+        ),
+        child: Stack(
+          clipBehavior: Clip.none,
+          children: [
+            Icon(
+              TDIcons.filter,
+              size: 22,
+              color: hasFilter ? colors.primary : colors.textPrimary,
+            ),
+            if (hasFilter)
+              Positioned(
+                right: -2,
+                top: -2,
+                child: Container(
+                  width: 6,
+                  height: 6,
+                  decoration: BoxDecoration(
+                    color: colors.danger,
+                    shape: BoxShape.circle,
                   ),
-                  if (hasFilter)
-                    Positioned(
-                      right: -2,
-                      top: -2,
-                      child: Container(
-                        width: 6,
-                        height: 6,
-                        decoration: BoxDecoration(
-                          color: colors.danger,
-                          shape: BoxShape.circle,
-                        ),
-                      ),
-                    ),
-                ],
+                ),
               ),
-            ),
-            hasFilter: hasFilter,
-            filterVersion: filterVersion,
-            onBack: () => context.pop(),
-          ),
-        );
+          ],
+        ),
+      ),
+      hasFilter: hasFilter,
+      filterVersion: filterVersion,
+      onBack: () => context.pop(),
+    ));
     return Column(
       children: [
         if (isManager)

+ 5 - 9
lib/features/profile/profile_page.dart

@@ -25,15 +25,11 @@ class ProfilePage extends ConsumerWidget {
     final isAdmin = currentRole == 'admin';
 
     if (location.startsWith('/profile')) {
-      ref
-          .read(navBarConfigProvider.notifier)
-          .update(
-            NavBarConfig(
-              title: l10n.get('tabProfile'),
-              showBack: true,
-              leadingIcon: Icons.close,
-            ),
-          );
+      setNavBarTitle(context, ref, NavBarConfig(
+        title: l10n.get('tabProfile'),
+        showBack: true,
+        leadingIcon: Icons.close,
+      ));
     }
     return SingleChildScrollView(
       physics: const AlwaysScrollableScrollPhysics(),

+ 5 - 9
lib/features/report/expense_apply_detail_report_page.dart

@@ -81,15 +81,11 @@ class _ExpenseApplyDetailReportPageState
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
     final role = ref.watch(currentRoleProvider);
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('reportExpenseApplyDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('reportExpenseApplyDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
     final showDetail = role != 'employee';
     final showExport = role == 'finance' || role == 'admin';
     return Scaffold(

+ 5 - 9
lib/features/report/expense_detail_report_page.dart

@@ -80,15 +80,11 @@ class _ExpenseDetailReportPageState
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
     final role = ref.watch(currentRoleProvider);
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('reportExpenseDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('reportExpenseDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
     final showDetail = role != 'employee';
     final showExport = role == 'finance' || role == 'admin';
     return Scaffold(

+ 5 - 9
lib/features/report/outing_log_detail_report_page.dart

@@ -86,15 +86,11 @@ class _OutingLogDetailReportPageState extends ConsumerState<OutingLogDetailRepor
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
     final role = ref.watch(currentRoleProvider);
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('reportOutingLogDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('reportOutingLogDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
     final showDetail = role != 'employee';
     final showExport = role == 'finance' || role == 'admin';
     return Scaffold(

+ 5 - 9
lib/features/report/overtime_detail_report_page.dart

@@ -94,15 +94,11 @@ class _OvertimeDetailReportPageState
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
     final role = ref.watch(currentRoleProvider);
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('reportOvertimeDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('reportOvertimeDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
     final showDetail = role != 'employee';
     final showExport = role == 'finance' || role == 'admin';
     return Scaffold(

+ 5 - 9
lib/features/report/vehicle_detail_report_page.dart

@@ -84,15 +84,11 @@ class _VehicleDetailReportPageState
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);
     final role = ref.watch(currentRoleProvider);
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('reportVehicleDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('reportVehicleDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
     final showDetail = role != 'employee';
     final showExport = role == 'finance' || role == 'admin';
     return Scaffold(

+ 17 - 21
lib/features/vehicle/vehicle_create_page.dart

@@ -75,27 +75,23 @@ class _VehicleCreatePageState extends ConsumerState<VehicleCreatePage> {
     final l10n = AppLocalizations.of(context);
     final v = state.vehicle;
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('vehicleApply'),
-            showBack: true,
-            onBack: () {
-              if (_hasUnsaved()) {
-                _showConfirmDialog(
-                  l10n.get('confirmExit'),
-                  l10n.get('unsavedContentWarning'),
-                  l10n.get('continueEditing'),
-                  l10n.get('discardAndExit'),
-                  () => context.pop(),
-                );
-              } else {
-                context.pop();
-              }
-            },
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('vehicleApply'),
+      showBack: true,
+      onBack: () {
+        if (_hasUnsaved()) {
+          _showConfirmDialog(
+            l10n.get('confirmExit'),
+            l10n.get('unsavedContentWarning'),
+            l10n.get('continueEditing'),
+            l10n.get('discardAndExit'),
+            () => context.pop(),
+          );
+        } else {
+          context.pop();
+        }
+      },
+    ));
 
     return PopScope(
       canPop: false,

+ 5 - 9
lib/features/vehicle/vehicle_detail_page.dart

@@ -61,15 +61,11 @@ class _VehicleDetailPageState extends ConsumerState<VehicleDetailPage> {
     );
     final l10n = AppLocalizations.of(context);
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('vehicleDetail'),
-            showBack: true,
-            onBack: () => context.pop(),
-          ),
-        );
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('vehicleDetail'),
+      showBack: true,
+      onBack: () => context.pop(),
+    ));
 
     final (icon, color, statusText) = _statusProps(vehicle.status);
 

+ 39 - 43
lib/features/vehicle/vehicle_list_page.dart

@@ -134,51 +134,47 @@ class _VehicleListPageState extends ConsumerState<VehicleListPage>
       ref.read(vehiclePurposeFilterProvider.notifier).state = null;
     }
 
-    ref
-        .read(navBarConfigProvider.notifier)
-        .update(
-          NavBarConfig(
-            title: l10n.get('vehicleList'),
-            showBack: true,
-            showRight: true,
-            rightWidget: GestureDetector(
-              onTap: () => ListFilterPanel.show(
-                context,
-                groups: filterGroups,
-                onReset: onFilterReset,
-                onConfirm: () {},
-                defaultStartDate: DateTime(now.year, now.month, 1),
-                defaultEndDate: DateTime(now.year, now.month, now.day),
-              ),
-              child: Stack(
-                clipBehavior: Clip.none,
-                children: [
-                  Icon(
-                    TDIcons.filter,
-                    size: 22,
-                    color: hasFilter ? colors.primary : colors.textPrimary,
+    setNavBarTitle(context, ref, NavBarConfig(
+      title: l10n.get('vehicleList'),
+      showBack: true,
+      showRight: true,
+      rightWidget: GestureDetector(
+        onTap: () => ListFilterPanel.show(
+          context,
+          groups: filterGroups,
+          onReset: onFilterReset,
+          onConfirm: () {},
+          defaultStartDate: DateTime(now.year, now.month, 1),
+          defaultEndDate: DateTime(now.year, now.month, now.day),
+        ),
+        child: Stack(
+          clipBehavior: Clip.none,
+          children: [
+            Icon(
+              TDIcons.filter,
+              size: 22,
+              color: hasFilter ? colors.primary : colors.textPrimary,
+            ),
+            if (hasFilter)
+              Positioned(
+                right: -2,
+                top: -2,
+                child: Container(
+                  width: 6,
+                  height: 6,
+                  decoration: BoxDecoration(
+                    color: colors.danger,
+                    shape: BoxShape.circle,
                   ),
-                  if (hasFilter)
-                    Positioned(
-                      right: -2,
-                      top: -2,
-                      child: Container(
-                        width: 6,
-                        height: 6,
-                        decoration: BoxDecoration(
-                          color: colors.danger,
-                          shape: BoxShape.circle,
-                        ),
-                      ),
-                    ),
-                ],
+                ),
               ),
-            ),
-            hasFilter: hasFilter,
-            filterVersion: filterVersion,
-            onBack: () => context.pop(),
-          ),
-        );
+          ],
+        ),
+      ),
+      hasFilter: hasFilter,
+      filterVersion: filterVersion,
+      onBack: () => context.pop(),
+    ));
     return Column(
       children: [
         if (isManager)

+ 11 - 87
lib/shared/widgets/attachment_picker.dart

@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
 import 'package:image_picker/image_picker.dart';
 import 'package:file_picker/file_picker.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
+import 'package:marquee/marquee.dart';
 import '../models/attachment_file.dart';
 import '../../core/i18n/app_localizations.dart';
 import '../../core/theme/app_colors.dart';
@@ -395,12 +396,21 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
           SizedBox(
             width: size,
             height: 16,
-            child: _MarqueeText(
+            child: Marquee(
               text: file.name,
               style: TextStyle(
                 fontSize: AppFontSizes.caption,
                 color: colors.textSecondary,
               ),
+              scrollAxis: Axis.horizontal,
+              blankSpace: 40,
+              velocity: 30,
+              pauseAfterRound: const Duration(seconds: 1),
+              startPadding: 0,
+              accelerationDuration: const Duration(milliseconds: 500),
+              accelerationCurve: Curves.linear,
+              decelerationDuration: const Duration(milliseconds: 500),
+              decelerationCurve: Curves.easeOut,
             ),
           ),
         ],
@@ -427,89 +437,3 @@ class _AttachmentPickerState extends State<AttachmentPicker> {
   }
 }
 
-// ═══════════════════════════════════════════════════════════════
-// Marquee Text(跑马灯循环滚动)
-// 技术方案: 文本复制两份 "text    text",AnimationController
-// 单向 repeat 0→1,每帧 jumpTo(value * singleTextWidth),
-// 滚动完一份文本宽度后自动重置从 0 开始,视觉上无缝循环。
-// ═══════════════════════════════════════════════════════════════
-
-class _MarqueeText extends StatefulWidget {
-  final String text;
-  final TextStyle style;
-  const _MarqueeText({required this.text, required this.style});
-
-  @override
-  State<_MarqueeText> createState() => _MarqueeTextState();
-}
-
-class _MarqueeTextState extends State<_MarqueeText>
-    with SingleTickerProviderStateMixin {
-  late final AnimationController _controller;
-  final ScrollController _scrollController = ScrollController();
-  bool _needsScroll = false;
-  double _singleWidth = 0;
-
-  /// 每个字符的滚动时间(毫秒),控制滚动速度
-  static const int _msPerChar = 180;
-
-  @override
-  void initState() {
-    super.initState();
-    _controller = AnimationController(
-      vsync: this,
-      duration: Duration(
-        milliseconds: (widget.text.length * _msPerChar).clamp(3000, 12000),
-      ),
-    );
-    WidgetsBinding.instance.addPostFrameCallback(_measure);
-  }
-
-  void _measure(_) {
-    if (!mounted || !_scrollController.hasClients) return;
-    final max = _scrollController.position.maxScrollExtent;
-    if (max > 0) {
-      // max 是两份文本的总可滚动宽度,单份 = max/2
-      _singleWidth = max / 2;
-      setState(() => _needsScroll = true);
-      _controller.addListener(_onScroll);
-      // 单向 repeat: 0→1→0(跳)→1→...  视觉上文本持续右移然后无缝重置
-      _controller.repeat();
-      // 从中间开始,避免初始就看到右边的重复文本
-      _scrollController.jumpTo(0);
-    }
-  }
-
-  void _onScroll() {
-    if (!_scrollController.hasClients || _singleWidth <= 0) return;
-    // value 从 0→1 线性变化,对应滚动 0 → singleWidth
-    _scrollController.jumpTo(_controller.value * _singleWidth);
-  }
-
-  @override
-  void dispose() {
-    _controller.dispose();
-    _scrollController.dispose();
-    super.dispose();
-  }
-
-  @override
-  Widget build(BuildContext context) {
-    // 复制文本两份,中间用空格隔开,保证无缝循环
-    const spacer = '       '; // 两份文本之间的视觉间距
-    return SingleChildScrollView(
-      controller: _scrollController,
-      scrollDirection: Axis.horizontal,
-      physics: _needsScroll
-          ? const NeverScrollableScrollPhysics()
-          : const ClampingScrollPhysics(),
-      child: Row(
-        mainAxisSize: MainAxisSize.min,
-        children: [
-          Text(widget.text, style: widget.style, maxLines: 1),
-          Text(spacer + widget.text, style: widget.style, maxLines: 1),
-        ],
-      ),
-    );
-  }
-}

+ 16 - 2
lib/shared/widgets/nav_bar_config.dart

@@ -1,4 +1,3 @@
-import 'dart:async';
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 
@@ -66,7 +65,7 @@ class NavBarConfig {
 
 /// NavBar 配置变更器,内部做相等判断避免无限重建
 ///
-/// 通过 [Timer.run] 将状态更新推迟到事件循环下一个 tick,
+/// 通过 [Future.microtask] 将状态更新推迟到事件循环下一个 tick,
 /// 避免子页面在 build 中更新 provider 时与 AppShell watch 冲突。
 class NavBarConfigNotifier extends StateNotifier<NavBarConfig> {
   NavBarConfigNotifier() : super(NavBarConfig.home);
@@ -85,3 +84,18 @@ final navBarConfigProvider =
     StateNotifierProvider<NavBarConfigNotifier, NavBarConfig>(
       (ref) => NavBarConfigNotifier(),
     );
+
+/// 仅在当前路由可见时更新导航栏标题,避免被覆盖的页面因 GoRouter
+/// rebuild 级联效应竞争标题(如 showDatePicker 导致的路由栈全量重建)。
+///
+/// [context] 为当前页面 build 的 context,
+/// [config] 为要设置的 NavBarConfig。
+void setNavBarTitle(BuildContext context, WidgetRef ref, NavBarConfig config) {
+  final route = ModalRoute.of(context);
+  // null = 首次 build 路由尚未完全插入,仍需更新
+  // isCurrent = 当前可见路由
+  // false = 已被覆盖的旧页面,跳过
+  if (route == null || route.isCurrent) {
+    ref.read(navBarConfigProvider.notifier).update(config);
+  }
+}

+ 16 - 0
pubspec.lock

@@ -241,6 +241,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "2.0.8"
+  fading_edge_scrollview:
+    dependency: transitive
+    description:
+      name: fading_edge_scrollview
+      sha256: "1f84fe3ea8e251d00d5735e27502a6a250e4aa3d3b330d3fdcb475af741464ef"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.1.1"
   fake_async:
     dependency: transitive
     description:
@@ -589,6 +597,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.3.0"
+  marquee:
+    dependency: "direct main"
+    description:
+      name: marquee
+      sha256: a87e7e80c5d21434f90ad92add9f820cf68be374b226404fe881d2bba7be0862
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.3.0"
   matcher:
     dependency: transitive
     description:

+ 1 - 0
pubspec.yaml

@@ -22,6 +22,7 @@ dependencies:
   tdesign_flutter: ^0.2.7
   image_picker: ^1.1.2
   file_picker: ^11.0.2
+  marquee: ^2.3.0
 
 dev_dependencies:
   flutter_test: