home_page.dart 20 KB

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