home_page.dart 19 KB

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