home_page.dart 21 KB

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