home_page.dart 20 KB

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