home_page.dart 21 KB

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