home_page.dart 20 KB

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