home_page.dart 21 KB

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