Jelajahi Sumber

feat: 列表页骨架屏、筛选条件、loading优化、Tab缓存等

- 新增骨架屏组件(SkeletonListCard/SkeletonVehicleCard等),TDSkeleton动画
- 新增筛选栏(FilterBar+TDDropdownMenu),日期范围+业务专属维度
- 列表页FutureProvider改为.family模式,每个Tab独立缓存
- 首次打开Tab显示骨架动画,再次切回直接用缓存
- 下拉刷新仅EasyRefresh头部loading,不显示骨架
- 关于页弹窗改用TDConfirmDialog + 文字溢出修复
- 工作台金刚区文字自动滚动移除,改为maxLines:2
- EmptyState增加ic_empty.png自定义图片
- TDTabBar底部分割线隐藏(dividerHeight:0)
- PRD/Design文档同步更新筛选栏说明
chengc 1 Minggu lalu
induk
melakukan
8f41780b45

TEMPAT SAMPAH
assets/img/ic_empty.png


+ 5 - 0
docs/superpowers/specs/tboss-oa-design.md

@@ -470,6 +470,7 @@
 | 区域 | 组件 | 说明 |
 |------|------|------|
 | 导航栏 | TDNavbar | 标题"申请记录" |
+| 筛选栏 | DateRangeFilter + FilterDropdown ×2 | TabBar 下方:日期范围选择器 + 费用类型(差旅/招待/办公/会议)+ 紧急程度(普通/紧急/特急)。筛选条件变化时重新拉取数据 |
 | 状态筛选 | TDTabBar | Tab:全部 / 草稿 / 审批中 / 已通过 / 已拒绝 / 已撤回。默认选中"全部" |
 | 列表 | TDListView | 下拉刷新(page=1)/ 上拉加载(page++)。数据来源 OA 本地 ExpenseApplication 表,审批状态通过 .NET→ERP 实时查询 |
 | 空状态 | TDEmpty | 文案随 Tab 变化:"暂无草稿""暂无审批中的单据"等 |
@@ -513,6 +514,7 @@
 | 区域 | 组件 | 说明 |
 |------|------|------|
 | 导航栏 | TDNavbar | 标题"报销记录" |
+| 筛选栏 | DateRangeFilter | TabBar 下方:日期范围选择器。筛选条件变化时重新拉取数据 |
 | 状态筛选 | TDTabBar | Tab:全部 / 草稿 / 审批中 / 已通过 / 待付款 / 已付款 / 已拒绝 / 已撤回 |
 | 列表 | TDListView | 同页面 6。数据来源 OA 本地 Expense 表 |
 | 空状态 | TDEmpty | 同页面 6 |
@@ -660,6 +662,7 @@
 | 区域 | 组件 | 说明 |
 |------|------|------|
 | 导航栏 | TDNavbar | 标题"加班记录" |
+| 筛选栏 | DateRangeFilter + FilterDropdown | TabBar 下方:日期范围选择器 + 加班类型(工作日/休息日/节假日) |
 | 状态筛选 | TDTabBar | 全部 / 草稿 / 审批中 / 已通过 / 已拒绝 / 已撤回 |
 | 列表 | TDListView | 下拉刷新/上拉加载,数据来源 OA 本地 Overtime 表,审批状态通过 ERP 实时查询 |
 
@@ -742,6 +745,7 @@
 | 区域 | 组件 | 说明 |
 |------|------|------|
 | 导航栏 | TDNavbar | 标题"用车记录" |
+| 筛选栏 | DateRangeFilter + FilterDropdown | TabBar 下方:日期范围选择器 + 用车目的(客户接待/商务出行/公务) |
 | 状态筛选 | TDTabBar | 全部 / 草稿 / 审批中 / 已通过 / 已拒绝 / 已撤回 / 已还车 |
 | 列表 | TDListView | 数据来源 OA 本地 Vehicle 表 |
 
@@ -830,6 +834,7 @@
 | 区域 | 组件 | 说明 |
 |------|------|------|
 | 导航栏 | TDNavbar | 标题"外勤记录" |
+| 筛选栏 | DateRangeFilter | TabBar 下方:日期范围选择器 |
 | 筛选 | TDTabBar | 全部 / 草稿 / 已完成 |
 | 列表 | TDListView | 按 CreateTime 倒序,数据来源 OA 本地 OutingLog 表 |
 

File diff ditekan karena terlalu besar
+ 1 - 1
docs/superpowers/specs/tboss-oa-prd.md


+ 7 - 3
lib/features/announcement/announcement_list_controller.dart

@@ -106,11 +106,15 @@ final mockAnnouncements = <AnnouncementModel>[
 final announcementTabProvider = StateProvider<int>((ref) => 0);
 final isAdminProvider = StateProvider<bool>((ref) => true);
 
-final filteredAnnouncementsProvider =
-    Provider.autoDispose<List<AnnouncementModel>>((ref) {
-  final tabIndex = ref.watch(announcementTabProvider);
+final announcementRefreshProvider = StateProvider<int>((ref) => 0);
+
+final filteredAnnouncementsProvider = FutureProvider.autoDispose.family<List<AnnouncementModel>, int>((ref, tabIndex) async {
+  ref.watch(announcementRefreshProvider);
   final isAdmin = ref.watch(isAdminProvider);
 
+  // 模拟网络延迟,使骨架屏可见
+  await Future.delayed(const Duration(milliseconds: 1000));
+
   // 管理员专属:我的草稿 Tab
   if (isAdmin && tabIndex == 4) {
     return mockAnnouncements.where((e) => e.status == 'draft').toList();

+ 54 - 18
lib/features/announcement/announcement_list_page.dart

@@ -8,6 +8,7 @@ import '../shell/nav_bar_config.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../core/utils/responsive.dart';
 import '../../shared/widgets/empty_state.dart';
+import '../../shared/widgets/skeleton_list_card.dart';
 import '../../core/i18n/app_localizations.dart';
 import 'announcement_list_controller.dart';
 import 'announcement_model.dart';
@@ -90,6 +91,7 @@ class _AnnouncementListPageState extends ConsumerState<AnnouncementListPage>
                   showIndicator: true,
                   indicatorColor: AppColors.primary,
                   indicatorHeight: 3,
+                  dividerHeight: 0,
                   labelPadding: const EdgeInsets.symmetric(horizontal: 12),
                 ),
               ),
@@ -134,8 +136,10 @@ class _AnnouncementListPageState extends ConsumerState<AnnouncementListPage>
                 showIndicator: true,
                 indicatorColor: AppColors.primary,
                 indicatorHeight: 3,
+                dividerHeight: 0,
                 labelPadding: const EdgeInsets.symmetric(horizontal: 12),
                 onTap: (index) {
+                  ref.invalidate(filteredAnnouncementsProvider);
                   ref.read(announcementTabProvider.notifier).state = index;
                 },
               ),
@@ -162,33 +166,65 @@ class _AnnouncementTabContent extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final items = ref.watch(filteredAnnouncementsProvider);
+    final itemsAsync = ref.watch(filteredAnnouncementsProvider(tabIndex));
 
-    if (items.isEmpty) {
-      return EasyRefresh(
-        header: TDRefreshHeader(),
-        onRefresh: () async {
-          ref.invalidate(filteredAnnouncementsProvider);
-        },
-        child: ListView(
-          children: const [
-            SizedBox(height: 120),
-            EmptyState(message: '暂无行政公告'),
-          ],
-        ),
+    if (itemsAsync.isLoading && !itemsAsync.hasValue) {
+      return SkeletonLoadingList(
+        cardBuilder: () => const SkeletonAnnouncementCard(),
       );
     }
 
     return EasyRefresh(
       header: TDRefreshHeader(),
       onRefresh: () async {
-        ref.invalidate(filteredAnnouncementsProvider);
+        ref.read(announcementRefreshProvider.notifier).state++;
       },
-      child: ListView.builder(
+      child: _buildContent(itemsAsync, context, ref),
+    );
+  }
+
+  Widget _buildContent(
+    AsyncValue<List<AnnouncementModel>> itemsAsync,
+    BuildContext context,
+    WidgetRef ref,
+  ) {
+    if (itemsAsync.isReloading) {
+      final oldItems = itemsAsync.valueOrNull ?? [];
+      if (oldItems.isEmpty) {
+        return SkeletonLoadingList(
+          cardBuilder: () => const SkeletonAnnouncementCard(),
+        );
+      }
+      return ListView.builder(
         padding: const EdgeInsets.symmetric(vertical: 8),
-        itemCount: items.length,
-        itemBuilder: (_, i) => _buildAnnouncementCard(context, items[i]),
-      ),
+        itemCount: oldItems.length,
+        itemBuilder: (_, i) => _buildAnnouncementCard(context, oldItems[i]),
+      );
+    }
+
+    if (itemsAsync.hasError) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '加载失败'),
+        ],
+      );
+    }
+
+    final items = itemsAsync.requireValue;
+    if (items.isEmpty) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '暂无行政公告'),
+        ],
+      );
+    }
+
+    return ListView.builder(
+      padding: const EdgeInsets.symmetric(vertical: 8),
+      itemCount: items.length,
+      itemBuilder: (_, i) => _buildAnnouncementCard(context, items[i]),
     );
   }
 

+ 27 - 5
lib/features/expense/expense_list_controller.dart

@@ -358,11 +358,33 @@ final mockExpenses = <ExpenseModel>[
 ];
 
 final expensePageProvider = StateProvider<int>((ref) => 1);
+final expenseDateStartProvider = StateProvider<DateTime?>((ref) => null);
+final expenseDateEndProvider = StateProvider<DateTime?>((ref) => null);
 
-final expenseListProvider = FutureProvider<List<ExpenseModel>>((ref) async {
-  ref.watch(expenseStatusFilterProvider);
+final expenseRefreshProvider = StateProvider<int>((ref) => 0);
+
+final expenseListProvider = FutureProvider.autoDispose.family<List<ExpenseModel>, String>((ref, status) async {
   ref.watch(expensePageProvider);
-  final status = ref.read(expenseStatusFilterProvider);
-  if (status.isEmpty) return mockExpenses;
-  return mockExpenses.where((e) => e.status == status).toList();
+  ref.watch(expenseDateStartProvider);
+  ref.watch(expenseDateEndProvider);
+  ref.watch(expenseRefreshProvider);
+  // 模拟网络延迟,使骨架屏可见
+  await Future.delayed(const Duration(milliseconds: 1000));
+
+  var list = status.isEmpty
+      ? mockExpenses
+      : mockExpenses.where((e) => e.status == status).toList();
+
+  final dateStart = ref.read(expenseDateStartProvider);
+  final dateEnd = ref.read(expenseDateEndProvider);
+  if (dateStart != null) {
+    list = list.where((e) =>
+        e.createTime.isAfter(dateStart.subtract(const Duration(days: 1)))).toList();
+  }
+  if (dateEnd != null) {
+    list = list.where((e) =>
+        e.createTime.isBefore(dateEnd.add(const Duration(days: 1)))).toList();
+  }
+
+  return list;
 });

+ 112 - 48
lib/features/expense/expense_list_page.dart

@@ -10,9 +10,11 @@ import '../../core/utils/responsive.dart';
 import '../../shared/widgets/list_card.dart';
 import '../../shared/widgets/status_tag.dart';
 import '../../shared/widgets/empty_state.dart';
-import '../../shared/widgets/loading_widget.dart';
+import '../../shared/widgets/skeleton_list_card.dart';
+import '../../shared/widgets/filter_bar.dart';
 import '../../core/i18n/app_localizations.dart';
 import 'expense_list_controller.dart';
+import 'expense_model.dart';
 
 class ExpenseListPage extends ConsumerStatefulWidget {
   const ExpenseListPage({super.key});
@@ -55,6 +57,8 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
   @override
   Widget build(BuildContext context) {
     final status = ref.watch(expenseStatusFilterProvider);
+    final dateStart = ref.watch(expenseDateStartProvider);
+    final dateEnd = ref.watch(expenseDateEndProvider);
     final l10n = AppLocalizations.of(context);
 
     // Sync TabController with external filter changes
@@ -91,13 +95,47 @@ class _ExpenseListPageState extends ConsumerState<ExpenseListPage>
             showIndicator: true,
             indicatorColor: AppColors.primary,
             indicatorHeight: 3,
+            dividerHeight: 0,
             labelPadding: const EdgeInsets.symmetric(horizontal: 12),
             onTap: (index) {
+              ref.invalidate(expenseListProvider);
               ref.read(expenseStatusFilterProvider.notifier).state =
                   _tabKeys[index];
             },
           ),
         ),
+        // 筛选栏(TDesign 组件)
+        FilterBar(
+          groups: [
+            FilterGroup(title: '日期范围', type: FilterGroupType.dateRange, sections: [
+              FilterSection(
+                label: '起始日期',
+                type: FilterSectionType.dateRange,
+                startDate: dateStart,
+                endDate: dateEnd,
+                onStartChanged: (v) =>
+                    ref.read(expenseDateStartProvider.notifier).state = v,
+                onEndChanged: (v) =>
+                    ref.read(expenseDateEndProvider.notifier).state = v,
+              ),
+              FilterSection(
+                label: '结束日期',
+                type: FilterSectionType.dateRange,
+                startDate: dateStart,
+                endDate: dateEnd,
+                onStartChanged: (v) =>
+                    ref.read(expenseDateStartProvider.notifier).state = v,
+                onEndChanged: (v) =>
+                    ref.read(expenseDateEndProvider.notifier).state = v,
+              ),
+            ]),
+          ],
+          onReset: () {
+            ref.read(expenseDateStartProvider.notifier).state = null;
+            ref.read(expenseDateEndProvider.notifier).state = null;
+          },
+          onConfirm: () {},
+        ),
         Expanded(
           child: TabBarView(
             controller: _tabCtrl,
@@ -128,54 +166,80 @@ class _ExpenseTabContent extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final itemsAsync = ref.watch(expenseListProvider);
-
-    return itemsAsync.when(
-      loading: () => const LoadingWidget(),
-      error: (_, _) => const EmptyState(message: '加载失败'),
-      data: (items) {
-        if (items.isEmpty) {
-          return EasyRefresh(
-            header: TDRefreshHeader(),
-            onRefresh: () async {
-              ref.invalidate(expenseListProvider);
-            },
-            child: ListView(
-              children: const [
-                SizedBox(height: 120),
-                EmptyState(message: '暂无报销单'),
-              ],
-            ),
-          );
-        }
-
-        return EasyRefresh(
-          header: TDRefreshHeader(),
-          onRefresh: () async {
-            ref.invalidate(expenseListProvider);
-          },
-          child: ListView.builder(
-            padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
-            itemCount: items.length,
-            itemBuilder: (_, i) => Padding(
-              padding: const EdgeInsets.only(bottom: 16),
-              child: ListCard(
-                cardNo: items[i].reportNo,
-                amount: '¥${items[i].totalAmount.toStringAsFixed(2)}',
-                description:
-                    '${items[i].expenseType} — ${items[i].applicantName}',
-                date: du.DateUtils.formatDate(
-                  items[i].createTime,
-                ),
-                statusTag: StatusTag.fromStatus(items[i].status),
-                onTap: () => context.push(
-                  '/expense/detail/${items[i].id}',
-                ),
-              ),
-            ),
-          ),
-        );
+    final itemsAsync = ref.watch(expenseListProvider(statusKey));
+
+    if (itemsAsync.isLoading && !itemsAsync.hasValue) {
+      return const SkeletonLoadingList();
+    }
+
+    return EasyRefresh(
+      header: TDRefreshHeader(),
+      onRefresh: () async {
+        ref.read(expenseRefreshProvider.notifier).state++;
       },
+      child: _buildContent(itemsAsync, context, ref),
+    );
+  }
+
+  Widget _buildContent(
+    AsyncValue<List<ExpenseModel>> itemsAsync,
+    
+    BuildContext context,
+    WidgetRef ref,
+  ) {
+    if (itemsAsync.isReloading) {
+      final oldItems = itemsAsync.valueOrNull ?? [];
+      if (oldItems.isEmpty) return const SkeletonLoadingList();
+      return ListView.builder(
+        padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+        itemCount: oldItems.length,
+        itemBuilder: (_, i) => Padding(
+          padding: const EdgeInsets.only(bottom: 16),
+          child: ListCard(
+            cardNo: oldItems[i].reportNo,
+            amount: '¥${oldItems[i].totalAmount.toStringAsFixed(2)}',
+            description: '${oldItems[i].expenseType} — ${oldItems[i].applicantName}',
+            date: du.DateUtils.formatDate(oldItems[i].createTime),
+            statusTag: StatusTag.fromStatus(oldItems[i].status),
+            onTap: () => context.push('/expense/detail/${oldItems[i].id}'),
+          ),
+        ),
+      );
+    }
+
+    if (itemsAsync.hasError) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '加载失败'),
+        ],
+      );
+    }
+
+    final items = itemsAsync.requireValue;
+    if (items.isEmpty) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '暂无报销单'),
+        ],
+      );
+    }
+
+    return ListView.builder(
+      padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+      itemCount: items.length,
+      itemBuilder: (_, i) => Padding(
+        padding: const EdgeInsets.only(bottom: 16),
+        child: ListCard(
+          cardNo: items[i].reportNo,
+          amount: '¥${items[i].totalAmount.toStringAsFixed(2)}',
+          description: '${items[i].expenseType} — ${items[i].applicantName}',
+          date: du.DateUtils.formatDate(items[i].createTime),
+          statusTag: StatusTag.fromStatus(items[i].status),
+          onTap: () => context.push('/expense/detail/${items[i].id}'),
+        ),
+      ),
     );
   }
 }

+ 47 - 8
lib/features/expense_application/expense_application_list_controller.dart

@@ -252,11 +252,50 @@ final mockExpenseApplications = <ExpenseApplicationModel>[
   ),
 ];
 
-final expenseApplicationListProvider =
-    FutureProvider.autoDispose<List<ExpenseApplicationModel>>((ref) async {
-      ref.watch(expenseApplyStatusFilterProvider);
-      ref.watch(expenseAppPageProvider);
-      final status = ref.read(expenseApplyStatusFilterProvider);
-      if (status.isEmpty) return mockExpenseApplications;
-      return mockExpenseApplications.where((e) => e.status == status).toList();
-    });
+final expenseApplyDateStartProvider = StateProvider<DateTime?>((ref) => null);
+final expenseApplyDateEndProvider = StateProvider<DateTime?>((ref) => null);
+final expenseApplyTypeFilterProvider = StateProvider<String?>((ref) => null);
+final expenseApplyUrgencyFilterProvider = StateProvider<String?>((ref) => null);
+final expenseApplyRefreshProvider = StateProvider<int>((ref) => 0);
+
+final expenseApplicationListProvider = FutureProvider.autoDispose
+    .family<List<ExpenseApplicationModel>, String>((ref, status) async {
+  ref.watch(expenseAppPageProvider);
+  ref.watch(expenseApplyRefreshProvider);
+  ref.watch(expenseApplyDateStartProvider);
+  ref.watch(expenseApplyDateEndProvider);
+  ref.watch(expenseApplyTypeFilterProvider);
+  ref.watch(expenseApplyUrgencyFilterProvider);
+  // 模拟网络延迟
+  await Future.delayed(const Duration(milliseconds: 1000));
+
+  var list = status.isEmpty
+      ? mockExpenseApplications
+      : mockExpenseApplications.where((e) => e.status == status).toList();
+
+  // 日期范围筛选
+  final dateStart = ref.read(expenseApplyDateStartProvider);
+  final dateEnd = ref.read(expenseApplyDateEndProvider);
+  if (dateStart != null) {
+    list = list.where((e) =>
+        e.createTime.isAfter(dateStart.subtract(const Duration(days: 1)))).toList();
+  }
+  if (dateEnd != null) {
+    list = list.where((e) =>
+        e.createTime.isBefore(dateEnd.add(const Duration(days: 1)))).toList();
+  }
+
+  // 费用类型筛选
+  final typeFilter = ref.read(expenseApplyTypeFilterProvider);
+  if (typeFilter != null && typeFilter.isNotEmpty) {
+    list = list.where((e) => e.expenseTypes.contains(typeFilter)).toList();
+  }
+
+  // 紧急程度筛选
+  final urgencyFilter = ref.read(expenseApplyUrgencyFilterProvider);
+  if (urgencyFilter != null && urgencyFilter.isNotEmpty) {
+    list = list.where((e) => e.urgency == urgencyFilter).toList();
+  }
+
+  return list;
+});

+ 143 - 48
lib/features/expense_application/expense_application_list_page.dart

@@ -10,9 +10,11 @@ import '../../core/utils/responsive.dart';
 import '../../shared/widgets/list_card.dart';
 import '../../shared/widgets/status_tag.dart';
 import '../../shared/widgets/empty_state.dart';
-import '../../shared/widgets/loading_widget.dart';
+import '../../shared/widgets/skeleton_list_card.dart';
+import '../../shared/widgets/filter_bar.dart';
 import '../../core/i18n/app_localizations.dart';
 import 'expense_application_list_controller.dart';
+import 'expense_application_model.dart';
 
 class ExpenseApplicationListPage extends ConsumerStatefulWidget {
   const ExpenseApplicationListPage({super.key});
@@ -50,6 +52,10 @@ class _ExpenseApplicationListPageState
   @override
   Widget build(BuildContext context) {
     final status = ref.watch(expenseApplyStatusFilterProvider);
+    final dateStart = ref.watch(expenseApplyDateStartProvider);
+    final dateEnd = ref.watch(expenseApplyDateEndProvider);
+    ref.watch(expenseApplyTypeFilterProvider);
+    ref.watch(expenseApplyUrgencyFilterProvider);
     final l10n = AppLocalizations.of(context);
 
     ref.read(navBarConfigProvider.notifier).update(
@@ -72,7 +78,7 @@ class _ExpenseApplicationListPageState
       children: [
         Container(
           color: AppColors.bgCard,
-          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
+          padding: const EdgeInsets.symmetric(horizontal: 8),
           child: TDTabBar(
             tabs: _tabLabels.map((l) => TDTab(text: l)).toList(),
             controller: _tabCtrl,
@@ -82,14 +88,76 @@ class _ExpenseApplicationListPageState
             showIndicator: true,
             indicatorColor: AppColors.primary,
             indicatorHeight: 3,
+            dividerHeight: 0,
             isScrollable: true,
             labelPadding: const EdgeInsets.symmetric(horizontal: 12),
             onTap: (index) {
+              ref.invalidate(expenseApplicationListProvider);
               ref.read(expenseApplyStatusFilterProvider.notifier).state =
                   _tabKeys[index];
             },
           ),
         ),
+        // 筛选栏(TDesign 组件)
+        FilterBar(
+          groups: [
+            FilterGroup(title: '日期范围', type: FilterGroupType.dateRange, sections: [
+              FilterSection(
+                label: '起始日期',
+                type: FilterSectionType.dateRange,
+                startDate: dateStart,
+                endDate: dateEnd,
+                onStartChanged: (v) =>
+                    ref.read(expenseApplyDateStartProvider.notifier).state = v,
+                onEndChanged: (v) =>
+                    ref.read(expenseApplyDateEndProvider.notifier).state = v,
+              ),
+              FilterSection(
+                label: '结束日期',
+                type: FilterSectionType.dateRange,
+                startDate: dateStart,
+                endDate: dateEnd,
+                onStartChanged: (v) =>
+                    ref.read(expenseApplyDateStartProvider.notifier).state = v,
+                onEndChanged: (v) =>
+                    ref.read(expenseApplyDateEndProvider.notifier).state = v,
+              ),
+            ]),
+            FilterGroup(title: '其它', type: FilterGroupType.other, sections: [
+              FilterSection(
+                label: '费用类型',
+                type: FilterSectionType.multiSelect,
+                options: const [
+                  FilterOption(value: 'travel', label: '差旅费'),
+                  FilterOption(value: 'entertainment', label: '业务招待费'),
+                  FilterOption(value: 'office', label: '办公费'),
+                  FilterOption(value: 'meeting', label: '会议费'),
+                ],
+                onMultiChanged: (v) =>
+                    ref.read(expenseApplyTypeFilterProvider.notifier).state =
+                        v.isNotEmpty ? v.first : null,
+              ),
+              FilterSection(
+                label: '紧急程度',
+                type: FilterSectionType.singleSelect,
+                options: const [
+                  FilterOption(value: 'normal', label: '普通'),
+                  FilterOption(value: 'urgent', label: '紧急'),
+                  FilterOption(value: 'critical', label: '特急'),
+                ],
+                onChanged: (v) =>
+                    ref.read(expenseApplyUrgencyFilterProvider.notifier).state = v,
+              ),
+            ]),
+          ],
+          onReset: () {
+            ref.read(expenseApplyDateStartProvider.notifier).state = null;
+            ref.read(expenseApplyDateEndProvider.notifier).state = null;
+            ref.read(expenseApplyTypeFilterProvider.notifier).state = null;
+            ref.read(expenseApplyUrgencyFilterProvider.notifier).state = null;
+          },
+          onConfirm: () {},
+        ),
         Expanded(
           child: TabBarView(
             controller: _tabCtrl,
@@ -129,55 +197,82 @@ class _ExpenseApplyList extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    // Use a key that depends on status to force rebuild when switching tabs
-    final itemsAsync = ref.watch(
-      expenseApplicationListProvider.select((value) => value),
+    final itemsAsync = ref.watch(expenseApplicationListProvider(statusKey));
+
+    // 首次加载该 tab:骨架
+    if (itemsAsync.isLoading && !itemsAsync.hasValue) {
+      return const SkeletonLoadingList();
+    }
+
+    // 其他情况统一 EasyRefresh
+    return EasyRefresh(
+      header: TDRefreshHeader(),
+      onRefresh: () async {
+        ref.read(expenseApplyRefreshProvider.notifier).state++;
+      },
+      child: _buildContent(itemsAsync, context, ref),
     );
+  }
 
-    return itemsAsync.when(
-      loading: () => const LoadingWidget(),
-      error: (_, _) => const EmptyState(message: '加载失败'),
-      data: (items) {
-        if (items.isEmpty) {
-          return EasyRefresh(
-            header: TDRefreshHeader(),
-            onRefresh: () async {
-              ref.invalidate(expenseApplicationListProvider);
-            },
-            child: ListView(
-              children: const [
-                SizedBox(height: 120),
-                EmptyState(message: '暂无报销申请'),
-              ],
-            ),
-          );
-        }
-
-        return EasyRefresh(
-          header: TDRefreshHeader(),
-          onRefresh: () async {
-            ref.invalidate(expenseApplicationListProvider);
-          },
-          child: ListView.builder(
-            padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
-            itemCount: items.length,
-            itemBuilder: (_, i) => Padding(
-              padding: const EdgeInsets.only(bottom: 16),
-              child: ListCard(
-                cardNo: items[i].applicationNo,
-                amount: '¥${items[i].estimatedAmount.toStringAsFixed(2)}',
-                description:
-                    '${items[i].expenseType} — ${items[i].purpose}',
-                date: du.DateUtils.formatDate(items[i].createTime),
-                statusTag: StatusTag.fromStatus(items[i].status),
-                onTap: () => context.push(
-                  '/expense-apply/detail/${items[i].id}',
-                ),
-              ),
-            ),
+  Widget _buildContent(
+    AsyncValue<List<ExpenseApplicationModel>> itemsAsync,
+    BuildContext context,
+    WidgetRef ref,
+  ) {
+    // 重新加载中(下拉刷新/筛选变化):保留旧数据
+    if (itemsAsync.isReloading) {
+      final oldItems = itemsAsync.valueOrNull ?? [];
+      if (oldItems.isEmpty) return const SkeletonLoadingList();
+      return ListView.builder(
+        padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+        itemCount: oldItems.length,
+        itemBuilder: (_, i) => Padding(
+          padding: const EdgeInsets.only(bottom: 16),
+          child: ListCard(
+            cardNo: oldItems[i].applicationNo,
+            amount: '¥${oldItems[i].estimatedAmount.toStringAsFixed(2)}',
+            description: '${oldItems[i].expenseType} — ${oldItems[i].purpose}',
+            date: du.DateUtils.formatDate(oldItems[i].createTime),
+            statusTag: StatusTag.fromStatus(oldItems[i].status),
+            onTap: () => context.push('/expense-apply/detail/${oldItems[i].id}'),
           ),
-        );
-      },
+        ),
+      );
+    }
+
+    if (itemsAsync.hasError) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '加载失败'),
+        ],
+      );
+    }
+
+    final items = itemsAsync.requireValue;
+    if (items.isEmpty) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '暂无报销申请'),
+        ],
+      );
+    }
+
+    return ListView.builder(
+      padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+      itemCount: items.length,
+      itemBuilder: (_, i) => Padding(
+        padding: const EdgeInsets.only(bottom: 16),
+        child: ListCard(
+          cardNo: items[i].applicationNo,
+          amount: '¥${items[i].estimatedAmount.toStringAsFixed(2)}',
+          description: '${items[i].expenseType} — ${items[i].purpose}',
+          date: du.DateUtils.formatDate(items[i].createTime),
+          statusTag: StatusTag.fromStatus(items[i].status),
+          onTap: () => context.push('/expense-apply/detail/${items[i].id}'),
+        ),
+      ),
     );
   }
 }

+ 41 - 36
lib/features/home/home_page.dart

@@ -142,9 +142,7 @@ class HomePage extends ConsumerWidget {
 
     return SectionCard(
       title: l10n.get('initiate'),
-      actionText: l10n.get('more'),
-      actionIcon: Icons.chevron_right,
-      onActionTap: () => context.push('/expense-apply/list'),
+      showAction: false,
       children: [_buildGrid(items)],
     );
   }
@@ -189,9 +187,7 @@ class HomePage extends ConsumerWidget {
 
     return SectionCard(
       title: l10n.get('records'),
-      actionText: l10n.get('more'),
-      actionIcon: Icons.chevron_right,
-      onActionTap: () => context.push('/expense-apply/list'),
+      showAction: false,
       children: [_buildGrid(items)],
     );
   }
@@ -243,12 +239,14 @@ class HomePage extends ConsumerWidget {
   Widget _buildGrid(List<_GridItem> items) {
     return LayoutBuilder(
       builder: (context, constraints) {
-        final itemWidth = constraints.maxWidth / 4;
-        final runSpacing = AppSpacing.md;
-        final spacing = 0.0;
+        const crossAxisCount = 4;
+        const horizontalSpacing = 8.0;
+        final itemWidth =
+            (constraints.maxWidth - (crossAxisCount - 1) * horizontalSpacing) /
+                crossAxisCount;
         return Wrap(
-          runSpacing: runSpacing,
-          spacing: spacing,
+          runSpacing: AppSpacing.md,
+          spacing: horizontalSpacing,
           children: items.map((item) {
             return SizedBox(
               width: itemWidth,
@@ -261,32 +259,39 @@ class HomePage extends ConsumerWidget {
   }
 
   Widget _buildGridItem(_GridItem item) {
-    return GestureDetector(
-      onTap: item.onTap,
-      child: Column(
-        mainAxisSize: MainAxisSize.min,
-        children: [
-          Container(
-            width: 36,
-            height: 36,
-            decoration: BoxDecoration(
-              color: AppColors.primaryLight,
-              borderRadius: BorderRadius.circular(10),
-            ),
-            child: Icon(item.icon, size: 22, color: AppColors.primary),
-          ),
-          const SizedBox(height: AppSpacing.xs),
-          Text(
-            item.label,
-            style: const TextStyle(
-              fontSize: AppFontSizes.caption,
-              color: AppColors.textSecondary,
-            ),
-            textAlign: TextAlign.center,
-            maxLines: 1,
-            overflow: TextOverflow.ellipsis,
+    return Material(
+      color: Colors.transparent,
+      child: InkWell(
+        onTap: item.onTap,
+        borderRadius: BorderRadius.circular(8),
+        child: Padding(
+          padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs),
+          child: Column(
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              Container(
+                width: 36,
+                height: 36,
+                decoration: BoxDecoration(
+                  color: AppColors.primaryLight,
+                  borderRadius: BorderRadius.circular(10),
+                ),
+                child: Icon(item.icon, size: 22, color: AppColors.primary),
+              ),
+              const SizedBox(height: AppSpacing.xs),
+              Text(
+                item.label,
+                style: const TextStyle(
+                  fontSize: AppFontSizes.caption,
+                  color: AppColors.textSecondary,
+                ),
+                textAlign: TextAlign.center,
+                maxLines: 2,
+                overflow: TextOverflow.ellipsis,
+              ),
+            ],
           ),
-        ],
+        ),
       ),
     );
   }

+ 23 - 3
lib/features/outing_log/outing_log_list_controller.dart

@@ -155,14 +155,34 @@ final mockOutingLogs = <OutingLogModel>[
 final outingLogTabProvider = StateProvider<int>((ref) => 0);
 final outingLogScopeProvider = StateProvider<int>((ref) => 0);
 
-final filteredOutingLogsProvider =
-    Provider.autoDispose<List<OutingLogModel>>((ref) {
-  final tabIndex = ref.watch(outingLogTabProvider);
+final outingLogDateStartProvider = StateProvider<DateTime?>((ref) => null);
+final outingLogDateEndProvider = StateProvider<DateTime?>((ref) => null);
+
+final outingLogRefreshProvider = StateProvider<int>((ref) => 0);
+
+final filteredOutingLogsProvider = FutureProvider.autoDispose.family<List<OutingLogModel>, int>((ref, tabIndex) async {
+  ref.watch(outingLogDateStartProvider);
+  ref.watch(outingLogDateEndProvider);
+  ref.watch(outingLogRefreshProvider);
+  // 模拟网络延迟,使骨架屏可见
+  await Future.delayed(const Duration(milliseconds: 1000));
   var list = mockOutingLogs;
   if (tabIndex == 1) {
     list = list.where((e) => e.status == 'draft').toList();
   } else if (tabIndex == 2) {
     list = list.where((e) => e.status == 'completed').toList();
   }
+
+  final dateStart = ref.read(outingLogDateStartProvider);
+  final dateEnd = ref.read(outingLogDateEndProvider);
+  if (dateStart != null) {
+    list = list.where((e) =>
+        e.createTime.isAfter(dateStart.subtract(const Duration(days: 1)))).toList();
+  }
+  if (dateEnd != null) {
+    list = list.where((e) =>
+        e.createTime.isBefore(dateEnd.add(const Duration(days: 1)))).toList();
+  }
+
   return list;
 });

+ 92 - 22
lib/features/outing_log/outing_log_list_page.dart

@@ -9,6 +9,8 @@ import '../../core/utils/date_utils.dart' as du;
 import '../../core/utils/responsive.dart';
 import '../../shared/widgets/status_tag.dart';
 import '../../shared/widgets/empty_state.dart';
+import '../../shared/widgets/skeleton_list_card.dart';
+import '../../shared/widgets/filter_bar.dart';
 import '../../core/i18n/app_localizations.dart';
 import 'outing_log_list_controller.dart';
 import 'outing_log_model.dart';
@@ -46,6 +48,8 @@ class _OutingLogListPageState extends ConsumerState<OutingLogListPage>
   @override
   Widget build(BuildContext context) {
     final tabIndex = ref.watch(outingLogTabProvider);
+    final dateStart = ref.watch(outingLogDateStartProvider);
+    final dateEnd = ref.watch(outingLogDateEndProvider);
     final scopeIndex = ref.watch(outingLogScopeProvider);
     final r = ResponsiveHelper.of(context);
     final l10n = AppLocalizations.of(context);
@@ -85,12 +89,46 @@ class _OutingLogListPageState extends ConsumerState<OutingLogListPage>
                 showIndicator: true,
                 indicatorColor: AppColors.primary,
                 indicatorHeight: 3,
+                dividerHeight: 0,
                 labelPadding: const EdgeInsets.symmetric(horizontal: 12),
                 onTap: (index) {
+                  ref.invalidate(filteredOutingLogsProvider);
                   ref.read(outingLogTabProvider.notifier).state = index;
                 },
               ),
             ),
+            // 筛选栏(TDesign 组件)
+            FilterBar(
+              groups: [
+                FilterGroup(title: '日期范围', type: FilterGroupType.dateRange, sections: [
+                  FilterSection(
+                    label: '起始日期',
+                    type: FilterSectionType.dateRange,
+                    startDate: dateStart,
+                    endDate: dateEnd,
+                    onStartChanged: (v) =>
+                        ref.read(outingLogDateStartProvider.notifier).state = v,
+                    onEndChanged: (v) =>
+                        ref.read(outingLogDateEndProvider.notifier).state = v,
+                  ),
+                  FilterSection(
+                    label: '结束日期',
+                    type: FilterSectionType.dateRange,
+                    startDate: dateStart,
+                    endDate: dateEnd,
+                    onStartChanged: (v) =>
+                        ref.read(outingLogDateStartProvider.notifier).state = v,
+                    onEndChanged: (v) =>
+                        ref.read(outingLogDateEndProvider.notifier).state = v,
+                  ),
+                ]),
+              ],
+              onReset: () {
+                ref.read(outingLogDateStartProvider.notifier).state = null;
+                ref.read(outingLogDateEndProvider.notifier).state = null;
+              },
+              onConfirm: () {},
+            ),
             Expanded(
               child: TabBarView(
                 controller: _tabCtrl,
@@ -171,38 +209,70 @@ class _OutingLogTabContent extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final items = ref.watch(filteredOutingLogsProvider);
+    final itemsAsync = ref.watch(filteredOutingLogsProvider(tabIndex));
+
+    if (itemsAsync.isLoading && !itemsAsync.hasValue) {
+      return SkeletonLoadingList(
+        cardBuilder: () => const SkeletonOutingLogCard(),
+      );
+    }
+
+    return EasyRefresh(
+      header: TDRefreshHeader(),
+      onRefresh: () async {
+        ref.read(outingLogRefreshProvider.notifier).state++;
+      },
+      child: _buildContent(itemsAsync, context, ref),
+    );
+  }
+
+  Widget _buildContent(
+    AsyncValue<List<OutingLogModel>> itemsAsync,
+    BuildContext context,
+    WidgetRef ref,
+  ) {
+    if (itemsAsync.isReloading) {
+      final oldItems = itemsAsync.valueOrNull ?? [];
+      if (oldItems.isEmpty) {
+        return SkeletonLoadingList(
+          cardBuilder: () => const SkeletonOutingLogCard(),
+        );
+      }
+      return ListView.builder(
+        padding: const EdgeInsets.symmetric(vertical: 8),
+        itemCount: oldItems.length,
+        itemBuilder: (_, i) => _buildOutingLogItem(context, ref, oldItems[i]),
+      );
+    }
 
+    if (itemsAsync.hasError) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '加载失败'),
+        ],
+      );
+    }
+
+    final items = itemsAsync.requireValue;
     if (items.isEmpty) {
       final message = tabIndex == 1
           ? '暂无草稿'
           : tabIndex == 2
               ? '暂无已完成记录'
               : '暂无外勤日志';
-      return EasyRefresh(
-        header: TDRefreshHeader(),
-        onRefresh: () async {
-          ref.invalidate(filteredOutingLogsProvider);
-        },
-        child: ListView(
-          children: [
-            const SizedBox(height: 120),
-            EmptyState(message: message),
-          ],
-        ),
+      return ListView(
+        children: [
+          const SizedBox(height: 120),
+          EmptyState(message: message),
+        ],
       );
     }
 
-    return EasyRefresh(
-      header: TDRefreshHeader(),
-      onRefresh: () async {
-        ref.invalidate(filteredOutingLogsProvider);
-      },
-      child: ListView.builder(
-        padding: const EdgeInsets.symmetric(vertical: 8),
-        itemCount: items.length,
-        itemBuilder: (_, i) => _buildOutingLogItem(context, ref, items[i]),
-      ),
+    return ListView.builder(
+      padding: const EdgeInsets.symmetric(vertical: 8),
+      itemCount: items.length,
+      itemBuilder: (_, i) => _buildOutingLogItem(context, ref, items[i]),
     );
   }
 

+ 34 - 5
lib/features/overtime/overtime_list_controller.dart

@@ -116,11 +116,40 @@ final mockOvertimes = <OvertimeModel>[
 ];
 
 final overtimePageProvider = StateProvider<int>((ref) => 1);
+final overtimeDateStartProvider = StateProvider<DateTime?>((ref) => null);
+final overtimeDateEndProvider = StateProvider<DateTime?>((ref) => null);
+final overtimeTypeFilterProvider = StateProvider<String?>((ref) => null);
 
-final overtimeListProvider = FutureProvider<List<OvertimeModel>>((ref) async {
-  ref.watch(overtimeStatusFilterProvider);
+final overtimeRefreshProvider = StateProvider<int>((ref) => 0);
+
+final overtimeListProvider = FutureProvider.autoDispose.family<List<OvertimeModel>, String>((ref, status) async {
   ref.watch(overtimePageProvider);
-  final status = ref.read(overtimeStatusFilterProvider);
-  if (status.isEmpty) return mockOvertimes;
-  return mockOvertimes.where((e) => e.status == status).toList();
+  ref.watch(overtimeDateStartProvider);
+  ref.watch(overtimeDateEndProvider);
+  ref.watch(overtimeTypeFilterProvider);
+  ref.watch(overtimeRefreshProvider);
+  // 模拟网络延迟,使骨架屏可见
+  await Future.delayed(const Duration(milliseconds: 1000));
+
+  var list = status.isEmpty
+      ? mockOvertimes
+      : mockOvertimes.where((e) => e.status == status).toList();
+
+  final dateStart = ref.read(overtimeDateStartProvider);
+  final dateEnd = ref.read(overtimeDateEndProvider);
+  if (dateStart != null) {
+    list = list.where((e) =>
+        e.createTime.isAfter(dateStart.subtract(const Duration(days: 1)))).toList();
+  }
+  if (dateEnd != null) {
+    list = list.where((e) =>
+        e.createTime.isBefore(dateEnd.add(const Duration(days: 1)))).toList();
+  }
+
+  final typeFilter = ref.read(overtimeTypeFilterProvider);
+  if (typeFilter != null && typeFilter.isNotEmpty) {
+    list = list.where((e) => e.otType == typeFilter).toList();
+  }
+
+  return list;
 });

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

@@ -9,9 +9,11 @@ import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/list_card.dart';
 import '../../shared/widgets/status_tag.dart';
 import '../../shared/widgets/empty_state.dart';
-import '../../shared/widgets/loading_widget.dart';
+import '../../shared/widgets/skeleton_list_card.dart';
+import '../../shared/widgets/filter_bar.dart';
 import '../../core/i18n/app_localizations.dart';
 import 'overtime_list_controller.dart';
+import 'overtime_model.dart';
 
 class OvertimeListPage extends ConsumerStatefulWidget {
   const OvertimeListPage({super.key});
@@ -53,6 +55,9 @@ class _OvertimeListPageState extends ConsumerState<OvertimeListPage>
   @override
   Widget build(BuildContext context) {
     final status = ref.watch(overtimeStatusFilterProvider);
+    final dateStart = ref.watch(overtimeDateStartProvider);
+    final dateEnd = ref.watch(overtimeDateEndProvider);
+    ref.watch(overtimeTypeFilterProvider);
     final l10n = AppLocalizations.of(context);
 
     // Sync TabController with external filter changes
@@ -89,13 +94,61 @@ class _OvertimeListPageState extends ConsumerState<OvertimeListPage>
             showIndicator: true,
             indicatorColor: AppColors.primary,
             indicatorHeight: 3,
+            dividerHeight: 0,
             labelPadding: const EdgeInsets.symmetric(horizontal: 12),
             onTap: (index) {
+              ref.invalidate(overtimeListProvider);
               ref.read(overtimeStatusFilterProvider.notifier).state =
                   _tabKeys[index];
             },
           ),
         ),
+        // 筛选栏(TDesign 组件)
+        FilterBar(
+          groups: [
+            FilterGroup(title: '日期范围', type: FilterGroupType.dateRange, sections: [
+              FilterSection(
+                label: '起始日期',
+                type: FilterSectionType.dateRange,
+                startDate: dateStart,
+                endDate: dateEnd,
+                onStartChanged: (v) =>
+                    ref.read(overtimeDateStartProvider.notifier).state = v,
+                onEndChanged: (v) =>
+                    ref.read(overtimeDateEndProvider.notifier).state = v,
+              ),
+              FilterSection(
+                label: '结束日期',
+                type: FilterSectionType.dateRange,
+                startDate: dateStart,
+                endDate: dateEnd,
+                onStartChanged: (v) =>
+                    ref.read(overtimeDateStartProvider.notifier).state = v,
+                onEndChanged: (v) =>
+                    ref.read(overtimeDateEndProvider.notifier).state = v,
+              ),
+            ]),
+            FilterGroup(title: '其它', type: FilterGroupType.other, sections: [
+              FilterSection(
+                label: '加班类型',
+                type: FilterSectionType.singleSelect,
+                options: const [
+                  FilterOption(value: 'workday', label: '工作日加班'),
+                  FilterOption(value: 'weekend', label: '休息日加班'),
+                  FilterOption(value: 'holiday', label: '节假日加班'),
+                ],
+                onChanged: (v) =>
+                    ref.read(overtimeTypeFilterProvider.notifier).state = v,
+              ),
+            ]),
+          ],
+          onReset: () {
+            ref.read(overtimeDateStartProvider.notifier).state = null;
+            ref.read(overtimeDateEndProvider.notifier).state = null;
+            ref.read(overtimeTypeFilterProvider.notifier).state = null;
+          },
+          onConfirm: () {},
+        ),
         Expanded(
           child: TabBarView(
             controller: _tabCtrl,
@@ -120,49 +173,80 @@ class _OvertimeTabContent extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final itemsAsync = ref.watch(overtimeListProvider);
-
-    return itemsAsync.when(
-      loading: () => const LoadingWidget(),
-      error: (_, _) => const EmptyState(message: '加载失败'),
-      data: (items) {
-        if (items.isEmpty) {
-          return EasyRefresh(
-            header: TDRefreshHeader(),
-            onRefresh: () async {
-              ref.invalidate(overtimeListProvider);
-            },
-            child: ListView(
-              children: const [
-                SizedBox(height: 120),
-                EmptyState(message: '暂无加班记录'),
-              ],
-            ),
-          );
-        }
-
-        return EasyRefresh(
-          header: TDRefreshHeader(),
-          onRefresh: () async {
-            ref.invalidate(overtimeListProvider);
-          },
-          child: ListView.builder(
-            padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
-            itemCount: items.length,
-            itemBuilder: (_, i) => Padding(
-              padding: const EdgeInsets.only(bottom: 16),
-              child: ListCard(
-                cardNo: items[i].applicationNo,
-                description: '${items[i].otType} · ${items[i].compensationType}',
-                amount: '${items[i].otHours.toStringAsFixed(1)}小时',
-                date: du.DateUtils.formatDate(items[i].otDate),
-                statusTag: StatusTag.fromStatus(items[i].status),
-                onTap: () => context.push('/overtime/detail/${items[i].id}'),
-              ),
-            ),
-          ),
-        );
+    final itemsAsync = ref.watch(overtimeListProvider(statusKey));
+
+    if (itemsAsync.isLoading && !itemsAsync.hasValue) {
+      return const SkeletonLoadingList();
+    }
+
+    return EasyRefresh(
+      header: TDRefreshHeader(),
+      onRefresh: () async {
+        ref.read(overtimeRefreshProvider.notifier).state++;
       },
+      child: _buildContent(itemsAsync, context, ref),
+    );
+  }
+
+  Widget _buildContent(
+    AsyncValue<List<OvertimeModel>> itemsAsync,
+    
+    BuildContext context,
+    WidgetRef ref,
+  ) {
+    if (itemsAsync.isReloading) {
+      final oldItems = itemsAsync.valueOrNull ?? [];
+      if (oldItems.isEmpty) return const SkeletonLoadingList();
+      return ListView.builder(
+        padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+        itemCount: oldItems.length,
+        itemBuilder: (_, i) => Padding(
+          padding: const EdgeInsets.only(bottom: 16),
+          child: ListCard(
+            cardNo: oldItems[i].applicationNo,
+            description: '${oldItems[i].otType} · ${oldItems[i].compensationType}',
+            amount: '${oldItems[i].otHours.toStringAsFixed(1)}小时',
+            date: du.DateUtils.formatDate(oldItems[i].otDate),
+            statusTag: StatusTag.fromStatus(oldItems[i].status),
+            onTap: () => context.push('/overtime/detail/${oldItems[i].id}'),
+          ),
+        ),
+      );
+    }
+
+    if (itemsAsync.hasError) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '加载失败'),
+        ],
+      );
+    }
+
+    final items = itemsAsync.requireValue;
+    if (items.isEmpty) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '暂无加班记录'),
+        ],
+      );
+    }
+
+    return ListView.builder(
+      padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+      itemCount: items.length,
+      itemBuilder: (_, i) => Padding(
+        padding: const EdgeInsets.only(bottom: 16),
+        child: ListCard(
+          cardNo: items[i].applicationNo,
+          description: '${items[i].otType} · ${items[i].compensationType}',
+          amount: '${items[i].otHours.toStringAsFixed(1)}小时',
+          date: du.DateUtils.formatDate(items[i].otDate),
+          statusTag: StatusTag.fromStatus(items[i].status),
+          onTap: () => context.push('/overtime/detail/${items[i].id}'),
+        ),
+      ),
     );
   }
 }

+ 48 - 42
lib/features/profile/profile_page.dart

@@ -188,46 +188,48 @@ class ProfilePage extends ConsumerWidget {
   }
 
   void _showAboutDialog(BuildContext context, AppLocalizations l10n) {
-    showDialog(
+    showGeneralDialog(
       context: context,
-      builder: (ctx) => TDAlertDialog(
-        title: l10n.get('about'),
-        titleColor: AppColors.textPrimary,
-        contentWidget: Column(
-          mainAxisSize: MainAxisSize.min,
-          crossAxisAlignment: CrossAxisAlignment.start,
-          children: [
-            _aboutRow(l10n.get('version'), 'v1.0.0 (Build 20260601)'),
-            const Divider(height: 24),
-            GestureDetector(
-              onTap: () {
-                Navigator.of(ctx).pop();
-                ScaffoldMessenger.of(context).showSnackBar(
-                  const SnackBar(content: Text('用户协议(占位)'), duration: Duration(seconds: 2)),
-                );
-              },
-              child: const Text('用户协议',
-                  style: TextStyle(fontSize: 14, color: AppColors.primary)),
-            ),
-            const SizedBox(height: 12),
-            GestureDetector(
-              onTap: () {
-                Navigator.of(ctx).pop();
-                ScaffoldMessenger.of(context).showSnackBar(
-                  const SnackBar(content: Text('隐私政策(占位)'), duration: Duration(seconds: 2)),
-                );
-              },
-              child: const Text('隐私政策',
-                  style: TextStyle(fontSize: 14, color: AppColors.primary)),
-            ),
-          ],
-        ),
-        leftBtn: TDDialogButtonOptions(
-          title: l10n.get('close'),
-          titleColor: AppColors.textSecondary,
-          action: () => Navigator.of(ctx).pop(),
-        ),
-      ),
+      pageBuilder: (BuildContext buildContext, Animation<double> animation,
+          Animation<double> secondaryAnimation) {
+        return TDConfirmDialog(
+          title: l10n.get('about'),
+          titleColor: AppColors.textPrimary,
+          contentWidget: Column(
+            mainAxisSize: MainAxisSize.min,
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              _aboutRow(l10n.get('version'), 'v1.0.0 (Build 20260601)'),
+              const Divider(height: 24),
+              GestureDetector(
+                onTap: () {
+                  Navigator.of(buildContext).pop();
+                  ScaffoldMessenger.of(context).showSnackBar(
+                    const SnackBar(content: Text('用户协议(占位)'), duration: Duration(seconds: 2)),
+                  );
+                },
+                child: const Text('用户协议',
+                    style: TextStyle(fontSize: 14, color: AppColors.primary)),
+              ),
+              const SizedBox(height: 12),
+              GestureDetector(
+                onTap: () {
+                  Navigator.of(buildContext).pop();
+                  ScaffoldMessenger.of(context).showSnackBar(
+                    const SnackBar(content: Text('隐私政策(占位)'), duration: Duration(seconds: 2)),
+                  );
+                },
+                child: const Text('隐私政策',
+                    style: TextStyle(fontSize: 14, color: AppColors.primary)),
+              ),
+            ],
+          ),
+          buttonStyle: TDDialogButtonStyle.text,
+          buttonText: l10n.get('close'),
+          buttonTextColor: AppColors.primary,
+          action: () => Navigator.of(buildContext).pop(),
+        );
+      },
     );
   }
 
@@ -238,9 +240,13 @@ class ProfilePage extends ConsumerWidget {
         Text(label,
             style: const TextStyle(
                 fontSize: 14, color: AppColors.textSecondary)),
-        Text(value,
-            style: const TextStyle(
-                fontSize: 14, color: AppColors.textPrimary)),
+        Flexible(
+          child: Text(value,
+              textAlign: TextAlign.right,
+              softWrap: true,
+              style: const TextStyle(
+                  fontSize: 14, color: AppColors.textPrimary)),
+        ),
       ],
     );
   }

+ 35 - 5
lib/features/vehicle/vehicle_list_controller.dart

@@ -133,10 +133,40 @@ final mockVehicles = <VehicleModel>[
   ),
 ];
 
-final vehicleListProvider = FutureProvider<List<VehicleModel>>((ref) async {
-  ref.watch(vehicleStatusFilterProvider);
+final vehicleDateStartProvider = StateProvider<DateTime?>((ref) => null);
+final vehicleDateEndProvider = StateProvider<DateTime?>((ref) => null);
+final vehiclePurposeFilterProvider = StateProvider<String?>((ref) => null);
+
+final vehicleRefreshProvider = StateProvider<int>((ref) => 0);
+
+final vehicleListProvider = FutureProvider.autoDispose.family<List<VehicleModel>, String>((ref, status) async {
   ref.watch(vehiclePageProvider);
-  final status = ref.read(vehicleStatusFilterProvider);
-  if (status.isEmpty) return mockVehicles;
-  return mockVehicles.where((e) => e.status == status).toList();
+  ref.watch(vehicleDateStartProvider);
+  ref.watch(vehicleDateEndProvider);
+  ref.watch(vehiclePurposeFilterProvider);
+  ref.watch(vehicleRefreshProvider);
+  // 模拟网络延迟,使骨架屏可见
+  await Future.delayed(const Duration(milliseconds: 1000));
+
+  var list = status.isEmpty
+      ? mockVehicles
+      : mockVehicles.where((e) => e.status == status).toList();
+
+  final dateStart = ref.read(vehicleDateStartProvider);
+  final dateEnd = ref.read(vehicleDateEndProvider);
+  if (dateStart != null) {
+    list = list.where((e) =>
+        e.createTime.isAfter(dateStart.subtract(const Duration(days: 1)))).toList();
+  }
+  if (dateEnd != null) {
+    list = list.where((e) =>
+        e.createTime.isBefore(dateEnd.add(const Duration(days: 1)))).toList();
+  }
+
+  final purposeFilter = ref.read(vehiclePurposeFilterProvider);
+  if (purposeFilter != null && purposeFilter.isNotEmpty) {
+    list = list.where((e) => e.purpose == purposeFilter).toList();
+  }
+
+  return list;
 });

+ 116 - 34
lib/features/vehicle/vehicle_list_page.dart

@@ -7,7 +7,8 @@ import '../../core/theme/app_colors.dart';
 import '../shell/nav_bar_config.dart';
 import '../../core/utils/date_utils.dart' as du;
 import '../../shared/widgets/empty_state.dart';
-import '../../shared/widgets/loading_widget.dart';
+import '../../shared/widgets/skeleton_list_card.dart';
+import '../../shared/widgets/filter_bar.dart';
 import '../../core/i18n/app_localizations.dart';
 import 'vehicle_list_controller.dart';
 import 'vehicle_model.dart';
@@ -53,6 +54,9 @@ class _VehicleListPageState extends ConsumerState<VehicleListPage>
   @override
   Widget build(BuildContext context) {
     final status = ref.watch(vehicleStatusFilterProvider);
+    final dateStart = ref.watch(vehicleDateStartProvider);
+    final dateEnd = ref.watch(vehicleDateEndProvider);
+    ref.watch(vehiclePurposeFilterProvider);
     final l10n = AppLocalizations.of(context);
 
     // Sync TabController with external filter changes
@@ -89,13 +93,61 @@ class _VehicleListPageState extends ConsumerState<VehicleListPage>
             showIndicator: true,
             indicatorColor: AppColors.primary,
             indicatorHeight: 3,
+            dividerHeight: 0,
             labelPadding: const EdgeInsets.symmetric(horizontal: 12),
             onTap: (index) {
+              ref.invalidate(vehicleListProvider);
               ref.read(vehicleStatusFilterProvider.notifier).state =
                   _tabKeys[index];
             },
           ),
         ),
+        // 筛选栏(TDesign 组件)
+        FilterBar(
+          groups: [
+            FilterGroup(title: '日期范围', type: FilterGroupType.dateRange, sections: [
+              FilterSection(
+                label: '起始日期',
+                type: FilterSectionType.dateRange,
+                startDate: dateStart,
+                endDate: dateEnd,
+                onStartChanged: (v) =>
+                    ref.read(vehicleDateStartProvider.notifier).state = v,
+                onEndChanged: (v) =>
+                    ref.read(vehicleDateEndProvider.notifier).state = v,
+              ),
+              FilterSection(
+                label: '结束日期',
+                type: FilterSectionType.dateRange,
+                startDate: dateStart,
+                endDate: dateEnd,
+                onStartChanged: (v) =>
+                    ref.read(vehicleDateStartProvider.notifier).state = v,
+                onEndChanged: (v) =>
+                    ref.read(vehicleDateEndProvider.notifier).state = v,
+              ),
+            ]),
+            FilterGroup(title: '其它', type: FilterGroupType.other, sections: [
+              FilterSection(
+                label: '用车目的',
+                type: FilterSectionType.singleSelect,
+                options: const [
+                  FilterOption(value: 'reception', label: '客户接待'),
+                  FilterOption(value: 'business', label: '商务出行'),
+                  FilterOption(value: 'official', label: '公务'),
+                ],
+                onChanged: (v) =>
+                    ref.read(vehiclePurposeFilterProvider.notifier).state = v,
+              ),
+            ]),
+          ],
+          onReset: () {
+            ref.read(vehicleDateStartProvider.notifier).state = null;
+            ref.read(vehicleDateEndProvider.notifier).state = null;
+            ref.read(vehiclePurposeFilterProvider.notifier).state = null;
+          },
+          onConfirm: () {},
+        ),
         Expanded(
           child: TabBarView(
             controller: _tabCtrl,
@@ -120,42 +172,72 @@ class _VehicleTabContent extends ConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    final itemsAsync = ref.watch(vehicleListProvider);
+    final itemsAsync = ref.watch(vehicleListProvider(statusKey));
 
-    return itemsAsync.when(
-      loading: () => const LoadingWidget(),
-      error: (_, _) => const EmptyState(message: '加载失败'),
-      data: (items) {
-        if (items.isEmpty) {
-          return EasyRefresh(
-            header: TDRefreshHeader(),
-            onRefresh: () async {
-              ref.invalidate(vehicleListProvider);
-            },
-            child: ListView(
-              children: const [
-                SizedBox(height: 120),
-                EmptyState(message: '暂无用车记录'),
-              ],
-            ),
-          );
-        }
+    if (itemsAsync.isLoading && !itemsAsync.hasValue) {
+      return SkeletonLoadingList(
+        cardBuilder: () => const SkeletonVehicleCard(),
+      );
+    }
 
-        return EasyRefresh(
-          header: TDRefreshHeader(),
-          onRefresh: () async {
-            ref.invalidate(vehicleListProvider);
-          },
-          child: ListView.builder(
-            padding: const EdgeInsets.all(16),
-            itemCount: items.length,
-            itemBuilder: (_, i) => Padding(
-              padding: const EdgeInsets.only(bottom: 16),
-              child: _buildVehicleListItem(context, items[i]),
-            ),
-          ),
-        );
+    return EasyRefresh(
+      header: TDRefreshHeader(),
+      onRefresh: () async {
+        ref.read(vehicleRefreshProvider.notifier).state++;
       },
+      child: _buildContent(itemsAsync, context, ref),
+    );
+  }
+
+  Widget _buildContent(
+    AsyncValue<List<VehicleModel>> itemsAsync,
+    
+    BuildContext context,
+    WidgetRef ref,
+  ) {
+    if (itemsAsync.isReloading) {
+      final oldItems = itemsAsync.valueOrNull ?? [];
+      if (oldItems.isEmpty) {
+        return SkeletonLoadingList(
+          cardBuilder: () => const SkeletonVehicleCard(),
+        );
+      }
+      return ListView.builder(
+        padding: const EdgeInsets.all(16),
+        itemCount: oldItems.length,
+        itemBuilder: (_, i) => Padding(
+          padding: const EdgeInsets.only(bottom: 16),
+          child: _buildVehicleListItem(context, oldItems[i]),
+        ),
+      );
+    }
+
+    if (itemsAsync.hasError) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '加载失败'),
+        ],
+      );
+    }
+
+    final items = itemsAsync.requireValue;
+    if (items.isEmpty) {
+      return ListView(
+        children: const [
+          SizedBox(height: 120),
+          EmptyState(message: '暂无用车记录'),
+        ],
+      );
+    }
+
+    return ListView.builder(
+      padding: const EdgeInsets.all(16),
+      itemCount: items.length,
+      itemBuilder: (_, i) => Padding(
+        padding: const EdgeInsets.only(bottom: 16),
+        child: _buildVehicleListItem(context, items[i]),
+      ),
     );
   }
 

+ 14 - 1
lib/shared/widgets/empty_state.dart

@@ -5,7 +5,12 @@ class EmptyState extends StatelessWidget {
   final String message;
   final String? operationText;
   final VoidCallback? onTap;
-  const EmptyState({super.key, required this.message, this.operationText, this.onTap});
+  const EmptyState({
+    super.key,
+    required this.message,
+    this.operationText,
+    this.onTap,
+  });
 
   @override
   Widget build(BuildContext context) {
@@ -15,6 +20,14 @@ class EmptyState extends StatelessWidget {
         emptyText: message,
         operationText: operationText,
         onTapEvent: onTap,
+        image: Container(
+          margin: const EdgeInsets.only(bottom: 16),
+          child: const TDImage(
+            width: 120,
+            assetUrl: 'assets/img/ic_empty.png',
+            type: TDImageType.fitWidth,
+          ),
+        ),
       ),
     );
   }

+ 447 - 0
lib/shared/widgets/filter_bar.dart

@@ -0,0 +1,447 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../../core/theme/app_colors.dart';
+
+/// 筛选栏 — 单一下拉菜单,内部分组
+///
+/// [FilterGroup] 列表定义各组筛选维度。日期范围组内自动展示「起始日期」「结束日期」两行,
+/// 其余组每行对应一个 [FilterSection],点击弹出 [TDPicker.showMultiPicker] 选择。
+class FilterBar extends StatelessWidget {
+  final List<FilterGroup> groups;
+  final VoidCallback onReset;
+  final VoidCallback onConfirm;
+
+  const FilterBar({
+    super.key,
+    required this.groups,
+    required this.onReset,
+    required this.onConfirm,
+  });
+
+  String get _label {
+    final parts = <String>[];
+    for (final g in groups) {
+      final summary = g.summary;
+      if (summary.isNotEmpty) parts.add(summary);
+    }
+    return parts.isEmpty ? '过滤条件' : parts.join(' · ');
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final hasFilter = groups.any((g) => g.summary.isNotEmpty);
+    return Container(
+      padding: const EdgeInsets.only(left: 16),
+      decoration: const BoxDecoration(
+        color: AppColors.bgCard,
+        border: Border(
+          bottom: BorderSide(color: AppColors.border, width: 0.5),
+        ),
+      ),
+      child: TDDropdownMenu(
+        closeOnClickOverlay: true,
+        isScrollable: true,
+        arrowIcon: TDIcons.caret_down_small,
+        decoration: const BoxDecoration(color: Colors.transparent),
+        labelBuilder: (context, label, isOpened, index) {
+          return TDText(
+            label,
+            font: TDTheme.of(context).fontBodyMedium,
+            textColor: hasFilter
+                ? AppColors.primary
+                : TDTheme.of(context).textColorPrimary,
+            maxLines: 1,
+            overflow: TextOverflow.ellipsis,
+          );
+        },
+        builder: (ctx) {
+          return [
+            TDDropdownItem(
+              label: _label,
+              multiple: true,
+              builder: (itemCtx, itemState, popupState) =>
+                  _FilterPanel(
+                    groups: groups,
+                    onReset: () {
+                      onReset();
+                      itemState.reset();
+                      Navigator.maybePop(itemCtx);
+                    },
+                    onConfirm: () {
+                      onConfirm();
+                      Navigator.maybePop(itemCtx);
+                    },
+                  ),
+              onReset: onReset,
+            ),
+          ];
+        },
+      ),
+    );
+  }
+}
+
+/// 筛选面板
+class _FilterPanel extends StatelessWidget {
+  final List<FilterGroup> groups;
+  final VoidCallback onReset;
+  final VoidCallback onConfirm;
+
+  const _FilterPanel({
+    required this.groups,
+    required this.onReset,
+    required this.onConfirm,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      color: Colors.white,
+      child: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Flexible(
+            child: SingleChildScrollView(
+              child: Column(
+                mainAxisSize: MainAxisSize.min,
+                children: groups.map((g) => _GroupSection(group: g)).toList(),
+              ),
+            ),
+          ),
+          const Divider(height: 1),
+          Padding(
+            padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+            child: Row(
+              mainAxisAlignment: MainAxisAlignment.spaceBetween,
+              children: [
+                TDButton(
+                  text: '重置',
+                  size: TDButtonSize.medium,
+                  type: TDButtonType.text,
+                  theme: TDButtonTheme.defaultTheme,
+                  onTap: onReset,
+                ),
+                TDButton(
+                  text: '确定',
+                  size: TDButtonSize.medium,
+                  type: TDButtonType.text,
+                  theme: TDButtonTheme.primary,
+                  onTap: onConfirm,
+                ),
+              ],
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+/// 分组
+class _GroupSection extends StatelessWidget {
+  final FilterGroup group;
+
+  const _GroupSection({required this.group});
+
+  @override
+  Widget build(BuildContext context) {
+    final isDateGroup = group.type == FilterGroupType.dateRange;
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Padding(
+          padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
+          child: Text(
+            group.title,
+            style: const TextStyle(fontSize: 13, color: AppColors.textPlaceholder),
+          ),
+        ),
+        if (isDateGroup)
+          ...group.sections.map((s) => _DateSectionRow(section: s))
+        else
+          ...group.sections.map((s) => _SectionRow(section: s)),
+        const Divider(height: 1),
+      ],
+    );
+  }
+}
+
+/// 日期范围行(起始日期 / 结束日期)
+class _DateSectionRow extends StatelessWidget {
+  final FilterSection section;
+
+  const _DateSectionRow({required this.section});
+
+  Future<void> _pick(BuildContext context, bool isStart) async {
+    // 先关闭下拉面板
+    Navigator.maybePop(context);
+    await Future.delayed(const Duration(milliseconds: 150));
+
+    final initial = isStart
+        ? (section.startDate ?? DateTime.now().subtract(const Duration(days: 30)))
+        : (section.endDate ?? DateTime.now());
+    final now = DateTime.now();
+    DateTime? picked;
+
+    if (!context.mounted) return;
+    await showModalBottomSheet<void>(
+      context: context,
+      backgroundColor: Colors.white,
+      shape: const RoundedRectangleBorder(
+        borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
+      ),
+      builder: (ctx) {
+        return TDDatePicker(
+          title: isStart ? '选择起始日期' : '选择结束日期',
+          backgroundColor: Colors.white,
+          model: DatePickerModel(
+            useYear: true, useMonth: true, useDay: true,
+            useHour: false, useMinute: false, useSecond: false, useWeekDay: false,
+            dateStart: [2020, 1, 1],
+            dateEnd: [now.year + 1, 12, 31],
+            dateInitial: [initial.year, initial.month, initial.day],
+          ),
+          onConfirm: (selected) {
+            picked = DateTime(
+              selected['year'] ?? initial.year,
+              selected['month'] ?? initial.month,
+              selected['day'] ?? initial.day,
+            );
+            Navigator.of(ctx).pop();
+          },
+          onCancel: (_) => Navigator.of(ctx).pop(),
+        );
+      },
+    );
+
+    if (picked != null && context.mounted) {
+      if (isStart) {
+        section.onStartChanged?.call(picked!);
+      } else {
+        section.onEndChanged?.call(picked!);
+      }
+    }
+  }
+
+  String _fmt(DateTime? dt) {
+    if (dt == null) return '全部';
+    return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final label = section.label; // "起始日期" / "结束日期"
+    final date = label == '起始日期' ? section.startDate : section.endDate;
+    return GestureDetector(
+      onTap: () => _pick(context, label == '起始日期'),
+      behavior: HitTestBehavior.opaque,
+      child: Padding(
+        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+        child: Row(
+          children: [
+            SizedBox(
+              width: 72,
+              child: Text(label,
+                  style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
+            ),
+            const SizedBox(width: 8),
+            Expanded(
+              child: Text(
+                _fmt(date),
+                style: TextStyle(
+                  fontSize: 14,
+                  color: date != null ? AppColors.primary : AppColors.textPlaceholder,
+                ),
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+              ),
+            ),
+            const Icon(Icons.chevron_right, size: 16, color: AppColors.textPlaceholder),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+/// 非日期行(单选/多选),点击弹出 [TDPicker.showMultiPicker]
+class _SectionRow extends StatelessWidget {
+  final FilterSection section;
+
+  const _SectionRow({required this.section});
+
+  Future<void> _handleTap(BuildContext context) async {
+    // 先关闭下拉面板
+    Navigator.maybePop(context);
+    await Future.delayed(const Duration(milliseconds: 150));
+    if (!context.mounted) return;
+
+    final options = section.options;
+    if (options.isEmpty) return;
+
+    final labels = options.map((o) => o.label).toList();
+    final isMulti = section.type == FilterSectionType.multiSelect;
+
+    List<int> initialIndexes = [];
+    if (isMulti) {
+      final selectedSet = Set<String>.from(section.selectedValues ?? []);
+      for (int i = 0; i < options.length; i++) {
+        if (selectedSet.contains(options[i].value)) initialIndexes.add(i);
+      }
+    } else {
+      final idx = section.selectedValue != null
+          ? options.indexWhere((o) => o.value == section.selectedValue)
+          : -1;
+      if (idx >= 0) initialIndexes = [idx];
+    }
+
+    TDPicker.showMultiPicker(
+      context,
+      title: section.label,
+      data: [labels],
+      initialIndexes: initialIndexes.isNotEmpty ? initialIndexes : null,
+      onConfirm: (selected) {
+        if (isMulti) {
+          final values = <String>[];
+          for (final s in selected) {
+            if (s is int && s >= 0 && s < options.length) {
+              values.add(options[s].value);
+            }
+          }
+          section.onMultiChanged?.call(values);
+        } else {
+          if (selected.isNotEmpty && selected[0] is int) {
+            final idx = selected[0] as int;
+            if (idx >= 0 && idx < options.length) {
+              section.onChanged?.call(options[idx].value);
+            }
+          }
+        }
+      },
+    );
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final summary = section.summary;
+    return GestureDetector(
+      onTap: () => _handleTap(context),
+      behavior: HitTestBehavior.opaque,
+      child: Padding(
+        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
+        child: Row(
+          children: [
+            SizedBox(
+              width: 72,
+              child: Text(section.label,
+                  style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
+            ),
+            const SizedBox(width: 8),
+            Expanded(
+              child: Text(
+                summary.isNotEmpty ? summary : '全部',
+                style: TextStyle(
+                  fontSize: 14,
+                  color: summary.isNotEmpty ? AppColors.primary : AppColors.textPlaceholder,
+                ),
+                maxLines: 1,
+                overflow: TextOverflow.ellipsis,
+              ),
+            ),
+            const Icon(Icons.chevron_right, size: 16, color: AppColors.textPlaceholder),
+          ],
+        ),
+      ),
+    );
+  }
+}
+
+// ============================================
+//  数据模型
+// ============================================
+
+enum FilterSectionType { dateRange, singleSelect, multiSelect }
+enum FilterGroupType { dateRange, other }
+
+class FilterSection {
+  final String label;
+  final FilterSectionType type;
+  final List<FilterOption> options;
+  final String? selectedValue;
+  final List<String>? selectedValues;
+  final ValueChanged<String>? onChanged;
+  final ValueChanged<List<String>>? onMultiChanged;
+  final DateTime? startDate;
+  final DateTime? endDate;
+  final ValueChanged<DateTime>? onStartChanged;
+  final ValueChanged<DateTime>? onEndChanged;
+
+  const FilterSection({
+    required this.label,
+    required this.type,
+    this.options = const [],
+    this.selectedValue,
+    this.selectedValues,
+    this.onChanged,
+    this.onMultiChanged,
+    this.startDate,
+    this.endDate,
+    this.onStartChanged,
+    this.onEndChanged,
+  });
+
+  String get summary {
+    switch (type) {
+      case FilterSectionType.dateRange:
+        // dateRange sections are rendered as two separate rows, each has its own summary
+        if (startDate != null && endDate != null) return '';
+        return '';
+      case FilterSectionType.singleSelect:
+        if (selectedValue == null) return '';
+        final opt = options.where((o) => o.value == selectedValue);
+        return opt.isNotEmpty ? opt.first.label : '';
+      case FilterSectionType.multiSelect:
+        if (selectedValues == null || selectedValues!.isEmpty) return '';
+        final labels = options
+            .where((o) => selectedValues!.contains(o.value))
+            .map((o) => o.label);
+        return labels.join('、');
+    }
+  }
+}
+
+class FilterOption {
+  final String value;
+  final String label;
+
+  const FilterOption({required this.value, required this.label});
+}
+
+class FilterGroup {
+  final String title;
+  final FilterGroupType type;
+  final List<FilterSection> sections;
+
+  const FilterGroup({
+    required this.title,
+    required this.type,
+    required this.sections,
+  });
+
+  String get summary {
+    final parts = <String>[];
+    for (final s in sections) {
+      if (s.type == FilterSectionType.dateRange) {
+        if (s.startDate != null) {
+          parts.add(
+              '${s.startDate!.month}/${s.startDate!.day} ~ ${s.endDate != null ? "${s.endDate!.month}/${s.endDate!.day}" : "..."}');
+        }
+      } else {
+        final sSum = s.summary;
+        if (sSum.isNotEmpty) parts.add(sSum);
+      }
+    }
+    return parts.join(' ');
+  }
+}

+ 213 - 0
lib/shared/widgets/skeleton_list_card.dart

@@ -0,0 +1,213 @@
+import 'package:flutter/material.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../../core/theme/app_colors.dart';
+
+/// Skeleton 占位卡片,匹配 [ListCard] 布局:
+/// ┌────────────────────────┐
+/// │ cardNo          amount │  ← Row: 单号(14/600) + 金额(16/700)
+/// │                        │
+/// │ description            │  ← 描述(14)
+/// │                        │
+/// │ date        statusTag  │  ← Row: 日期(12) + 状态标签
+/// └────────────────────────┘
+class SkeletonListCard extends StatelessWidget {
+  const SkeletonListCard({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      padding: const EdgeInsets.all(12),
+      decoration: BoxDecoration(
+        color: AppColors.bgCard,
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: TDSkeleton.fromRowCol(
+        animation: TDSkeletonAnimation.flashed,
+        rowCol: TDSkeletonRowCol(
+          objects: const [
+            // R1: cardNo + spacer + amount
+            [
+              TDSkeletonRowColObj.text(width: 120, height: 14),
+              TDSkeletonRowColObj.spacer(),
+              TDSkeletonRowColObj.text(width: 80, height: 16),
+            ],
+            // R2: description
+            [
+              TDSkeletonRowColObj.text(height: 14),
+            ],
+            // R3: date + spacer + statusTag
+            [
+              TDSkeletonRowColObj.text(width: 100, height: 12),
+              TDSkeletonRowColObj.spacer(),
+              TDSkeletonRowColObj.rect(width: 48, height: 20),
+            ],
+          ],
+          style: TDSkeletonRowColStyle(
+            rowSpacing: (_) => 8,
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+/// Skeleton 占位卡片,匹配车辆列表卡片布局:
+/// ┌────────────────────────┐
+/// │ 车牌号         状态徽章 │  ← Row: 车牌(16/700) + 徽章(12)
+/// │                        │
+/// │ 申请单号       用途标签 │  ← Row: 单号(12) + 标签(10)
+/// │                        │
+/// │ 路线 ...        时间    │  ← Row: 路线(13/ellipsis) + 时间(12)
+/// └────────────────────────┘
+class SkeletonVehicleCard extends StatelessWidget {
+  const SkeletonVehicleCard({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      padding: const EdgeInsets.all(12),
+      decoration: BoxDecoration(
+        color: AppColors.bgCard,
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: TDSkeleton.fromRowCol(
+        animation: TDSkeletonAnimation.flashed,
+        rowCol: TDSkeletonRowCol(
+          objects: const [
+            // R1: licensePlate + spacer + status badge
+            [
+              TDSkeletonRowColObj.text(width: 100, height: 16),
+              TDSkeletonRowColObj.spacer(),
+              TDSkeletonRowColObj.rect(width: 48, height: 20),
+            ],
+            // R2: applicationNo + spacer + purpose tag
+            [
+              TDSkeletonRowColObj.text(width: 140, height: 12),
+              TDSkeletonRowColObj.spacer(),
+              TDSkeletonRowColObj.rect(width: 40, height: 16),
+            ],
+            // R3: route (flex) + spacer + date
+            [
+              TDSkeletonRowColObj.text(flex: 2, height: 13),
+              TDSkeletonRowColObj.spacer(flex: 1),
+              TDSkeletonRowColObj.text(width: 120, height: 12),
+            ],
+          ],
+          style: TDSkeletonRowColStyle(
+            rowSpacing: (_) => 6,
+          ),
+        ),
+      ),
+    );
+  }
+}
+
+/// 外勤日志 Skeleton 占位卡片
+///
+/// 匹配外勤日志卡片布局:visitNo → customerName+状态 → address → summary+date
+class SkeletonOutingLogCard extends StatelessWidget {
+  const SkeletonOutingLogCard({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      padding: const EdgeInsets.all(12),
+      decoration: BoxDecoration(
+        color: AppColors.bgCard,
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: TDSkeleton.fromRowCol(
+        animation: TDSkeletonAnimation.flashed,
+        rowCol: TDSkeletonRowCol(
+          objects: const [
+            // R1: visitNo
+            [TDSkeletonRowColObj.text(width: 100, height: 11)],
+            // R2: customerName + spacer + statusTag
+            [
+              TDSkeletonRowColObj.text(flex: 3, height: 15),
+              TDSkeletonRowColObj.spacer(flex: 1),
+              TDSkeletonRowColObj.rect(width: 48, height: 20),
+            ],
+            // R3: checkInAddress
+            [TDSkeletonRowColObj.text(height: 12)],
+            // R4: summary + spacer + date
+            [
+              TDSkeletonRowColObj.text(flex: 2, height: 12),
+              TDSkeletonRowColObj.spacer(flex: 1),
+              TDSkeletonRowColObj.text(width: 80, height: 11),
+            ],
+          ],
+          style: TDSkeletonRowColStyle(rowSpacing: (_) => 4),
+        ),
+      ),
+    );
+  }
+}
+
+/// 公告 Skeleton 占位卡片
+///
+/// 匹配公告卡片布局:title 行 + typeTag/publisher/date 行
+class SkeletonAnnouncementCard extends StatelessWidget {
+  const SkeletonAnnouncementCard({super.key});
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      padding: const EdgeInsets.all(12),
+      decoration: BoxDecoration(
+        color: AppColors.bgCard,
+        borderRadius: BorderRadius.circular(8),
+      ),
+      child: TDSkeleton.fromRowCol(
+        animation: TDSkeletonAnimation.flashed,
+        rowCol: TDSkeletonRowCol(
+          objects: const [
+            // R1: title (full width)
+            [TDSkeletonRowColObj.text(height: 15)],
+            // R2: typeTag + spacer + date
+            [
+              TDSkeletonRowColObj.rect(width: 56, height: 20),
+              TDSkeletonRowColObj.spacer(width: 8),
+              TDSkeletonRowColObj.text(width: 60, height: 12),
+              TDSkeletonRowColObj.spacer(),
+              TDSkeletonRowColObj.text(width: 120, height: 12),
+            ],
+          ],
+          style: TDSkeletonRowColStyle(rowSpacing: (_) => 8),
+        ),
+      ),
+    );
+  }
+}
+
+/// 列表加载态:骨架卡片占位
+///
+/// [cardCount] 骨架卡片数量,默认 5
+/// [cardBuilder] 骨架卡片构建器,默认 [SkeletonListCard]
+class SkeletonLoadingList extends StatelessWidget {
+  final int cardCount;
+  final Widget Function() cardBuilder;
+
+  const SkeletonLoadingList({
+    super.key,
+    this.cardCount = 5,
+    this.cardBuilder = _defaultBuilder,
+  });
+
+  static Widget _defaultBuilder() => const SkeletonListCard();
+
+  @override
+  Widget build(BuildContext context) {
+    return ListView(
+      padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
+      physics: const NeverScrollableScrollPhysics(),
+      children: List.generate(
+        cardCount,
+        (_) => Padding(
+          padding: const EdgeInsets.only(bottom: 16),
+          child: cardBuilder(),
+        ),
+      ),
+    );
+  }
+}