home_page.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:flutter_swiper_null_safety/flutter_swiper_null_safety.dart';
  4. import 'package:go_router/go_router.dart';
  5. import 'package:tdesign_flutter/tdesign_flutter.dart';
  6. import '../../core/theme/app_colors.dart';
  7. import '../../shared/widgets/section_card.dart';
  8. import '../../core/i18n/app_localizations.dart';
  9. import '../shell/nav_bar_config.dart';
  10. import 'home_controller.dart';
  11. /// 工作台首页
  12. ///
  13. /// 按角色展示不同区块:
  14. /// - 员工:轮播图 + 金刚区(发起/记录/报表)+ 个人快捷看板
  15. /// - 经理:员工版 + 待审批红色角标卡片 + 部门快捷看板(3 卡片)
  16. /// - 财务:员工版 + 财务看盘(已支付/待付款/异常退回)
  17. /// - 管理员:员工版 + 金刚区"发布公告" + 财务看盘
  18. class HomePage extends ConsumerWidget {
  19. const HomePage({super.key});
  20. @override
  21. Widget build(BuildContext context, WidgetRef ref) {
  22. final summaryAsync = ref.watch(homeSummaryProvider);
  23. final location = GoRouterState.of(context).uri.toString();
  24. final l10n = AppLocalizations.of(context);
  25. if (location == '/') {
  26. ref
  27. .read(navBarConfigProvider.notifier)
  28. .update(
  29. NavBarConfig(
  30. title: l10n.get('appName'),
  31. showBack: true,
  32. leadingIcon: Icons.close,
  33. ),
  34. );
  35. }
  36. return summaryAsync.when(
  37. loading: () => const Center(child: CircularProgressIndicator()),
  38. error: (_, _) => Center(child: Text(l10n.get('loadFailed'))),
  39. data: (summary) => SingleChildScrollView(
  40. physics: const AlwaysScrollableScrollPhysics(),
  41. padding: const EdgeInsets.fromLTRB(
  42. AppSpacing.md,
  43. AppSpacing.md,
  44. AppSpacing.md,
  45. AppSpacing.lg,
  46. ),
  47. child: Column(
  48. children: [
  49. _buildBanner(ref, l10n),
  50. const SizedBox(height: AppSpacing.md),
  51. _buildInitiateGrid(context, summary, l10n),
  52. const SizedBox(height: AppSpacing.md),
  53. _buildRecordsGrid(context, l10n),
  54. const SizedBox(height: AppSpacing.md),
  55. _buildReportGrid(context, l10n),
  56. const SizedBox(height: AppSpacing.md),
  57. _buildDashboard(context, summary, l10n),
  58. ],
  59. ),
  60. ),
  61. );
  62. }
  63. // ===================================================================
  64. // 轮播图
  65. // ===================================================================
  66. Widget _buildBanner(WidgetRef ref, AppLocalizations l10n) {
  67. final banners = ref.watch(bannerProvider);
  68. return ClipRRect(
  69. borderRadius: BorderRadius.circular(8),
  70. child: SizedBox(
  71. height: 160,
  72. child: Swiper(
  73. autoplay: true,
  74. autoplayDelay: 3000,
  75. itemCount: banners.length,
  76. loop: true,
  77. pagination: const SwiperPagination(
  78. alignment: Alignment.bottomCenter,
  79. builder: TDSwiperPagination.dotsBar,
  80. ),
  81. onTap: (index) {
  82. final banner = banners[index];
  83. if (banner.linkUrl != null) {
  84. // 有外链可跳转
  85. }
  86. },
  87. itemBuilder: (BuildContext context, int index) {
  88. return TDImage(
  89. assetUrl: banners[index].imageUrl,
  90. fit: BoxFit.cover,
  91. );
  92. },
  93. ),
  94. ),
  95. );
  96. }
  97. // ===================================================================
  98. // 金刚区 — 发起
  99. // ===================================================================
  100. Widget _buildInitiateGrid(
  101. BuildContext context,
  102. HomeSummary summary,
  103. AppLocalizations l10n,
  104. ) {
  105. final items = <_GridItem>[
  106. _GridItem(
  107. icon: Icons.add_card_outlined,
  108. label: l10n.get('preApplication'),
  109. onTap: () => context.push('/expense-apply/apply'),
  110. ),
  111. _GridItem(
  112. icon: Icons.receipt_long_outlined,
  113. label: l10n.get('expenseReimbursement'),
  114. onTap: () => context.push('/expense/apply'),
  115. ),
  116. _GridItem(
  117. icon: Icons.directions_car_outlined,
  118. label: l10n.get('vehicleApplication'),
  119. onTap: () => context.push('/vehicle/apply'),
  120. ),
  121. _GridItem(
  122. icon: Icons.access_time_outlined,
  123. label: l10n.get('overtimeApplication'),
  124. onTap: () => context.push('/overtime/apply'),
  125. ),
  126. // 管理员额外显示"发布公告"
  127. if (summary.userRole == 'admin')
  128. _GridItem(
  129. icon: Icons.campaign_outlined,
  130. label: l10n.get('publishAnnouncement'),
  131. onTap: () => context.push('/announcement/create'),
  132. ),
  133. ];
  134. return SectionCard(
  135. title: l10n.get('initiate'),
  136. actionText: l10n.get('more'),
  137. actionIcon: Icons.chevron_right,
  138. onActionTap: () => context.push('/expense-apply/list'),
  139. children: [_buildGrid(items)],
  140. );
  141. }
  142. // ===================================================================
  143. // 金刚区 — 记录
  144. // ===================================================================
  145. Widget _buildRecordsGrid(BuildContext context, AppLocalizations l10n) {
  146. final items = <_GridItem>[
  147. _GridItem(
  148. icon: Icons.description_outlined,
  149. label: l10n.get('applicationRecords'),
  150. onTap: () => context.push('/expense-apply/list'),
  151. ),
  152. _GridItem(
  153. icon: Icons.receipt_outlined,
  154. label: l10n.get('expenseRecords'),
  155. onTap: () => context.push('/expense/list'),
  156. ),
  157. _GridItem(
  158. icon: Icons.access_time_outlined,
  159. label: l10n.get('overtimeRecords'),
  160. onTap: () => context.push('/overtime/list'),
  161. ),
  162. _GridItem(
  163. icon: Icons.directions_car_outlined,
  164. label: l10n.get('vehicleRecords'),
  165. onTap: () => context.push('/vehicle/list'),
  166. ),
  167. _GridItem(
  168. icon: Icons.edit_note_outlined,
  169. label: l10n.get('outingLogs'),
  170. onTap: () => context.push('/outing-log/list'),
  171. ),
  172. _GridItem(
  173. icon: Icons.campaign_outlined,
  174. label: l10n.get('companyAnnouncements'),
  175. onTap: () => context.push('/announcement/list'),
  176. ),
  177. ];
  178. return SectionCard(
  179. title: l10n.get('records'),
  180. actionText: l10n.get('more'),
  181. actionIcon: Icons.chevron_right,
  182. onActionTap: () => context.push('/expense-apply/list'),
  183. children: [_buildGrid(items)],
  184. );
  185. }
  186. // ===================================================================
  187. // 金刚区 — 报表
  188. // ===================================================================
  189. Widget _buildReportGrid(BuildContext context, AppLocalizations l10n) {
  190. final items = <_GridItem>[
  191. _GridItem(
  192. icon: Icons.bar_chart_outlined,
  193. label: l10n.get('reportExpenseApply'),
  194. onTap: () => context.push('/report/expense-apply-detail'),
  195. ),
  196. _GridItem(
  197. icon: Icons.pie_chart_outline,
  198. label: l10n.get('reportExpense'),
  199. onTap: () => context.push('/report/expense-detail'),
  200. ),
  201. _GridItem(
  202. icon: Icons.query_stats_outlined,
  203. label: l10n.get('reportOvertime'),
  204. onTap: () => context.push('/report/overtime-detail'),
  205. ),
  206. _GridItem(
  207. icon: Icons.map_outlined,
  208. label: l10n.get('reportVehicle'),
  209. onTap: () => context.push('/report/vehicle-detail'),
  210. ),
  211. _GridItem(
  212. icon: Icons.explore_outlined,
  213. label: l10n.get('reportOutingLog'),
  214. onTap: () => context.push('/report/outing-log-detail'),
  215. ),
  216. ];
  217. return SectionCard(
  218. title: l10n.get('reports'),
  219. showAction: false,
  220. children: [_buildGrid(items)],
  221. );
  222. }
  223. // ===================================================================
  224. // 宫格构建器(4 列,自动换行)
  225. // ===================================================================
  226. Widget _buildGrid(List<_GridItem> items) {
  227. return LayoutBuilder(
  228. builder: (context, constraints) {
  229. final itemWidth = constraints.maxWidth / 4;
  230. final runSpacing = AppSpacing.md;
  231. final spacing = 0.0;
  232. return Wrap(
  233. runSpacing: runSpacing,
  234. spacing: spacing,
  235. children: items.map((item) {
  236. return SizedBox(
  237. width: itemWidth,
  238. child: _buildGridItem(item),
  239. );
  240. }).toList(),
  241. );
  242. },
  243. );
  244. }
  245. Widget _buildGridItem(_GridItem item) {
  246. return GestureDetector(
  247. onTap: item.onTap,
  248. child: Column(
  249. mainAxisSize: MainAxisSize.min,
  250. children: [
  251. Container(
  252. width: 36,
  253. height: 36,
  254. decoration: BoxDecoration(
  255. color: AppColors.primaryLight,
  256. borderRadius: BorderRadius.circular(10),
  257. ),
  258. child: Icon(item.icon, size: 22, color: AppColors.primary),
  259. ),
  260. const SizedBox(height: AppSpacing.xs),
  261. Text(
  262. item.label,
  263. style: const TextStyle(
  264. fontSize: AppFontSizes.caption,
  265. color: AppColors.textSecondary,
  266. ),
  267. textAlign: TextAlign.center,
  268. maxLines: 1,
  269. overflow: TextOverflow.ellipsis,
  270. ),
  271. ],
  272. ),
  273. );
  274. }
  275. // ===================================================================
  276. // 角色判断 & 看板分发
  277. // ===================================================================
  278. Widget _buildDashboard(
  279. BuildContext context,
  280. HomeSummary summary,
  281. AppLocalizations l10n,
  282. ) {
  283. switch (summary.userRole) {
  284. case 'admin':
  285. return _buildFinanceDashboard(context, summary, l10n);
  286. case 'finance':
  287. return _buildFinanceDashboard(context, summary, l10n);
  288. case 'manager':
  289. return _buildManagerDashboard(context, summary, l10n);
  290. default:
  291. return _buildEmployeeDashboard(context, summary, l10n);
  292. }
  293. }
  294. // ===================================================================
  295. // 员工版:个人快捷看板(2 卡片)
  296. // ===================================================================
  297. Widget _buildEmployeeDashboard(
  298. BuildContext context,
  299. HomeSummary summary,
  300. AppLocalizations l10n,
  301. ) {
  302. return SectionCard(
  303. title: l10n.get('myDashboard'),
  304. showAction: false,
  305. children: [
  306. Row(
  307. children: [
  308. Expanded(
  309. child: _buildStatCard(
  310. title: l10n.get('monthlyTotalExpense'),
  311. value: '¥${_formatAmount(summary.monthlyReimbursement)}',
  312. valueColor: AppColors.amountPrimary,
  313. onTap: () => context.push('/expense/list'),
  314. ),
  315. ),
  316. const SizedBox(width: AppSpacing.md),
  317. Expanded(
  318. child: _buildStatCard(
  319. title: l10n.get('monthlySubmitted'),
  320. value: '${summary.monthlySubmittedCount} 笔',
  321. valueColor: AppColors.textPrimary,
  322. onTap: () => context.push('/expense-apply/list'),
  323. ),
  324. ),
  325. ],
  326. ),
  327. ],
  328. );
  329. }
  330. // ===================================================================
  331. // 经理版:待审批角标 + 部门快捷看板(3 卡片)
  332. // ===================================================================
  333. Widget _buildManagerDashboard(
  334. BuildContext context,
  335. HomeSummary summary,
  336. AppLocalizations l10n,
  337. ) {
  338. return Column(
  339. children: [
  340. // 待审批卡片(红色角标)
  341. GestureDetector(
  342. onTap: () => context.push('/messages'),
  343. child: Container(
  344. width: double.infinity,
  345. padding: const EdgeInsets.all(AppSpacing.md),
  346. decoration: BoxDecoration(
  347. color: AppColors.bgCard,
  348. borderRadius: BorderRadius.circular(8),
  349. ),
  350. child: Row(
  351. children: [
  352. Container(
  353. width: 40,
  354. height: 40,
  355. decoration: BoxDecoration(
  356. color: AppColors.dangerBg,
  357. borderRadius: BorderRadius.circular(8),
  358. ),
  359. child: const Icon(
  360. Icons.task_alt,
  361. color: AppColors.danger,
  362. size: 24,
  363. ),
  364. ),
  365. const SizedBox(width: AppSpacing.sm),
  366. Expanded(
  367. child: Text(
  368. l10n.get('pendingApproval'),
  369. style: const TextStyle(
  370. fontSize: AppFontSizes.subtitle,
  371. fontWeight: FontWeight.w600,
  372. color: AppColors.textPrimary,
  373. ),
  374. ),
  375. ),
  376. if (summary.pendingApprovalCount > 0)
  377. Container(
  378. padding: const EdgeInsets.symmetric(
  379. horizontal: 8,
  380. vertical: 2,
  381. ),
  382. decoration: BoxDecoration(
  383. color: AppColors.danger,
  384. borderRadius: BorderRadius.circular(10),
  385. ),
  386. child: Text(
  387. '${summary.pendingApprovalCount}',
  388. style: const TextStyle(
  389. fontSize: AppFontSizes.caption,
  390. color: Colors.white,
  391. fontWeight: FontWeight.w600,
  392. ),
  393. ),
  394. ),
  395. const SizedBox(width: AppSpacing.xs),
  396. const Icon(
  397. Icons.chevron_right,
  398. color: AppColors.textPlaceholder,
  399. size: 20,
  400. ),
  401. ],
  402. ),
  403. ),
  404. ),
  405. const SizedBox(height: AppSpacing.md),
  406. // 部门快捷看板(3 卡片)
  407. SectionCard(
  408. title: l10n.get('deptDashboard'),
  409. showAction: false,
  410. children: [
  411. Row(
  412. children: [
  413. Expanded(
  414. child: _buildStatCard(
  415. title: l10n.get('deptMonthlyReimbursement'),
  416. value: '¥${_formatAmount(summary.deptMonthlyReimbursement)}',
  417. valueColor: AppColors.amountPrimary,
  418. onTap: () => context.push('/expense/list'),
  419. ),
  420. ),
  421. const SizedBox(width: AppSpacing.sm),
  422. Expanded(
  423. child: _buildStatCard(
  424. title: l10n.get('deptMonthlySubmitted'),
  425. value: '${summary.deptMonthlySubmittedCount} 笔',
  426. valueColor: AppColors.textPrimary,
  427. onTap: () => context.push('/expense-apply/list'),
  428. ),
  429. ),
  430. const SizedBox(width: AppSpacing.sm),
  431. Expanded(
  432. child: _buildStatCard(
  433. title: l10n.get('deptPendingDocuments'),
  434. value: '${summary.deptPendingDocuments} 笔',
  435. valueColor: AppColors.warning,
  436. onTap: () => context.push('/expense-apply/list'),
  437. ),
  438. ),
  439. ],
  440. ),
  441. ],
  442. ),
  443. ],
  444. );
  445. }
  446. // ===================================================================
  447. // 财务/管理员版:财务看盘(3 大数字卡片)
  448. // ===================================================================
  449. Widget _buildFinanceDashboard(
  450. BuildContext context,
  451. HomeSummary summary,
  452. AppLocalizations l10n,
  453. ) {
  454. return SectionCard(
  455. title: l10n.get('financeDashboard'),
  456. showAction: false,
  457. children: [
  458. Row(
  459. children: [
  460. Expanded(
  461. child: _buildStatCard(
  462. title: l10n.get('paidTotal'),
  463. value: '¥${_formatAmount(summary.paidTotal)}',
  464. valueColor: AppColors.success,
  465. valueFontSize: 18,
  466. onTap: () => context.push('/expense/list'),
  467. ),
  468. ),
  469. const SizedBox(width: AppSpacing.sm),
  470. Expanded(
  471. child: _buildStatCard(
  472. title: l10n.get('pendingPaymentTotal'),
  473. value: '¥${_formatAmount(summary.pendingPaymentTotal)}',
  474. valueColor: AppColors.warning,
  475. valueFontSize: 18,
  476. onTap: () => context.push('/expense/list'),
  477. ),
  478. ),
  479. const SizedBox(width: AppSpacing.sm),
  480. Expanded(
  481. child: _buildStatCard(
  482. title: l10n.get('abnormalReturns'),
  483. value: '¥${_formatAmount(summary.abnormalReturns)}',
  484. valueColor: AppColors.danger,
  485. valueFontSize: 18,
  486. onTap: () => context.push('/expense/list'),
  487. ),
  488. ),
  489. ],
  490. ),
  491. ],
  492. );
  493. }
  494. // ===================================================================
  495. // 统计数值卡片
  496. // ===================================================================
  497. Widget _buildStatCard({
  498. required String title,
  499. required String value,
  500. required Color valueColor,
  501. double valueFontSize = 22,
  502. VoidCallback? onTap,
  503. }) {
  504. return GestureDetector(
  505. onTap: onTap,
  506. child: Container(
  507. padding: const EdgeInsets.symmetric(
  508. vertical: AppSpacing.sm,
  509. horizontal: AppSpacing.sm,
  510. ),
  511. decoration: BoxDecoration(
  512. color: AppColors.bgPage,
  513. borderRadius: BorderRadius.circular(8),
  514. ),
  515. child: Column(
  516. mainAxisSize: MainAxisSize.min,
  517. children: [
  518. Text(
  519. value,
  520. style: TextStyle(
  521. fontSize: valueFontSize,
  522. fontWeight: FontWeight.w700,
  523. color: valueColor,
  524. ),
  525. textAlign: TextAlign.center,
  526. maxLines: 1,
  527. overflow: TextOverflow.ellipsis,
  528. ),
  529. const SizedBox(height: AppSpacing.xs),
  530. Text(
  531. title,
  532. style: const TextStyle(
  533. fontSize: AppFontSizes.caption,
  534. color: AppColors.textSecondary,
  535. ),
  536. textAlign: TextAlign.center,
  537. ),
  538. ],
  539. ),
  540. ),
  541. );
  542. }
  543. /// 格式化金额:保留两位小数,去除末尾多余的 0
  544. String _formatAmount(double amount) {
  545. final str = amount.toStringAsFixed(2);
  546. // 保留两位小数的标准格式
  547. return str;
  548. }
  549. }
  550. /// 宫格项数据类
  551. class _GridItem {
  552. final IconData icon;
  553. final String label;
  554. final VoidCallback onTap;
  555. const _GridItem({
  556. required this.icon,
  557. required this.label,
  558. required this.onTap,
  559. });
  560. }