vehicle_apply_detail_page.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter_riverpod/flutter_riverpod.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:tdesign_flutter/tdesign_flutter.dart';
  5. import '../../core/i18n/app_localizations.dart';
  6. import '../../core/navigation/host_app_channel.dart';
  7. import '../../core/theme/app_colors.dart';
  8. import '../../core/theme/app_colors_extension.dart';
  9. import '../../core/utils/date_utils.dart' as du;
  10. import '../../core/utils/amount_utils.dart';
  11. import '../../shared/widgets/app_skeletons.dart';
  12. import '../../shared/widgets/bill_status_bar.dart';
  13. import '../../shared/widgets/loading_dialog.dart';
  14. import '../../shared/widgets/form_field_row.dart';
  15. import '../../shared/widgets/form_section.dart';
  16. import 'vehicle_apply_api.dart';
  17. import 'vehicle_apply_list_controller.dart';
  18. import 'vehicle_apply_model.dart';
  19. import 'widgets/return_car_dialog.dart';
  20. class VehicleApplyDetailPage extends ConsumerStatefulWidget {
  21. final String billNo;
  22. const VehicleApplyDetailPage({super.key, required this.billNo});
  23. @override
  24. ConsumerState<VehicleApplyDetailPage> createState() =>
  25. _VehicleApplyDetailPageState();
  26. }
  27. class _VehicleApplyDetailPageState
  28. extends ConsumerState<VehicleApplyDetailPage> {
  29. bool _loading = true;
  30. String? _error;
  31. VehicleApplyModel? _data;
  32. BillStatusBar? _billStatusBar;
  33. @override
  34. void initState() {
  35. super.initState();
  36. _loadData();
  37. }
  38. Future<void> _loadData() async {
  39. setState(() {
  40. _loading = true;
  41. _error = null;
  42. });
  43. try {
  44. // 先用 mock 数据匹配 billNo
  45. final match = mockVehicles.where((e) => e.ycNo == widget.billNo);
  46. if (match.isNotEmpty) {
  47. if (mounted) {
  48. setState(() {
  49. _data = match.first;
  50. _loading = false;
  51. });
  52. }
  53. } else {
  54. final api = ref.read(vehicleApplyApiProvider);
  55. final detail = await api.fetchDetail(widget.billNo);
  56. if (!mounted) return;
  57. // 获取单据状态 + 还车权限(非致命),与详情合并到一个 setState 避免中间闪烁
  58. Map<String, dynamic>? status;
  59. Map<String, dynamic>? rights;
  60. try {
  61. final results = await Future.wait([
  62. api.getBillStatus(widget.billNo),
  63. api.getUserRights('RHB'),
  64. ]);
  65. final Map<String, dynamic> s = results[0];
  66. final Map<String, dynamic> r = results[1];
  67. status = s;
  68. rights = r;
  69. } catch (_) {}
  70. final canReturn =
  71. rights?['canAdd'] == true || rights?['canModify'] == true;
  72. if (mounted) {
  73. setState(() {
  74. _data = detail;
  75. _billStatusBar = (status != null)
  76. ? BillStatusBar(
  77. billStatus: status,
  78. returnedText: AppLocalizations.of(
  79. context,
  80. ).get('vehicleReturned'),
  81. onEdit: () {
  82. GoRouter.of(context)
  83. .push('/vehicle-apply/edit/${widget.billNo}')
  84. .then((result) {
  85. if (result == true && mounted) _loadData();
  86. });
  87. },
  88. onSubmit: () async {
  89. final l10n = AppLocalizations.of(context);
  90. final dd = _data?.ycDd != null
  91. ? du.DateUtils.formatDate(_data!.ycDd!)
  92. : '';
  93. LoadingDialog.show(
  94. context,
  95. text: l10n.get('submitting'),
  96. );
  97. try {
  98. await api.shSubmit(
  99. bilId: 'YC',
  100. bilNo: widget.billNo,
  101. bilDd: dd,
  102. );
  103. } finally {
  104. if (mounted) LoadingDialog.hide(context);
  105. }
  106. if (mounted) _loadData();
  107. },
  108. onCancelSubmit: () async {
  109. final l10n = AppLocalizations.of(context);
  110. final dd = _data?.ycDd != null
  111. ? du.DateUtils.formatDate(_data!.ycDd!)
  112. : '';
  113. LoadingDialog.show(
  114. context,
  115. text: l10n.get('submitting'),
  116. );
  117. try {
  118. await api.shSubmit(
  119. bilId: 'YC',
  120. bilNo: widget.billNo,
  121. bilDd: dd,
  122. isCancel: true,
  123. );
  124. } finally {
  125. if (mounted) LoadingDialog.hide(context);
  126. }
  127. if (mounted) _loadData();
  128. },
  129. onTapStatusTag: () => _showAuditTrail('YC'),
  130. onReturn: canReturn
  131. ? () {
  132. ReturnCarDialog.show(
  133. context,
  134. billNo: widget.billNo,
  135. odometerBegin: _data?.odometerBegin ?? 0,
  136. recordDd: _data?.recordDd,
  137. onSubmit: (returnData) async {
  138. final api = ref.read(vehicleApplyApiProvider);
  139. returnData['usrCheck'] = HostAppChannel.usr;
  140. try {
  141. await api.submitReturn(
  142. widget.billNo,
  143. returnData,
  144. );
  145. if (mounted) _loadData();
  146. return true;
  147. } catch (_) {
  148. return false;
  149. }
  150. },
  151. );
  152. }
  153. : null,
  154. )
  155. : null;
  156. _loading = false;
  157. });
  158. }
  159. }
  160. } catch (e) {
  161. if (mounted) {
  162. setState(() {
  163. _error = e.toString();
  164. _loading = false;
  165. });
  166. }
  167. }
  168. }
  169. @override
  170. Widget build(BuildContext context) {
  171. final colors = Theme.of(context).extension<AppColorsExtension>()!;
  172. final l10n = AppLocalizations.of(context);
  173. if (_loading) return const SkeletonDetailPage(sectionRows: [5, 6, 4, 1]);
  174. if (_error != null) {
  175. return Center(
  176. child: Column(
  177. mainAxisSize: MainAxisSize.min,
  178. children: [
  179. Icon(Icons.error_outline, size: 48, color: colors.danger),
  180. const SizedBox(height: 16),
  181. Padding(
  182. padding: const EdgeInsets.symmetric(horizontal: 32),
  183. child: Text(
  184. _error!,
  185. textAlign: TextAlign.center,
  186. style: TextStyle(
  187. fontSize: AppFontSizes.body,
  188. color: colors.textSecondary,
  189. ),
  190. ),
  191. ),
  192. const SizedBox(height: 16),
  193. TDButton(
  194. text: l10n.get('retry'),
  195. size: TDButtonSize.medium,
  196. onTap: _loadData,
  197. ),
  198. ],
  199. ),
  200. );
  201. }
  202. final app = _data!;
  203. return Column(
  204. children: [
  205. Expanded(
  206. child: SingleChildScrollView(
  207. physics: const AlwaysScrollableScrollPhysics(),
  208. padding: const EdgeInsets.all(16),
  209. child: Column(
  210. children: [
  211. _buildBasicInfoSection(app, l10n, colors),
  212. const SizedBox(height: 16),
  213. _buildVehicleInfoSection(app, l10n, colors),
  214. const SizedBox(height: 16),
  215. _buildTripInfoSection(app, l10n, colors),
  216. const SizedBox(height: 16),
  217. _buildPassengerInfoSection(app, l10n, colors),
  218. if (app.isReturn) ...[
  219. const SizedBox(height: 16),
  220. _buildReturnInfoSection(app, l10n, colors),
  221. ],
  222. const SizedBox(height: 24),
  223. _buildPageFooter(colors),
  224. ],
  225. ),
  226. ),
  227. ),
  228. _billStatusBar?.buildActions(context) ?? const SizedBox.shrink(),
  229. ],
  230. );
  231. }
  232. // ═══ 基本信息 ═══
  233. Widget _buildBasicInfoSection(
  234. VehicleApplyModel app,
  235. AppLocalizations l10n,
  236. AppColorsExtension colors,
  237. ) {
  238. return FormSection(
  239. title: l10n.get('basicInfo'),
  240. leadingIcon: Icons.info_outline,
  241. trailing:
  242. _billStatusBar?.buildStatusTag(context) ?? const SizedBox.shrink(),
  243. children: [
  244. FormFieldRow(
  245. label: l10n.get('vehicleApplyNo'),
  246. value: app.ycNo,
  247. readOnly: true,
  248. showArrow: false,
  249. ),
  250. const SizedBox(height: 16),
  251. FormFieldRow(
  252. label: l10n.get('date'),
  253. value: app.ycDd != null ? du.DateUtils.formatDate(app.ycDd!) : '-',
  254. readOnly: true,
  255. showArrow: false,
  256. ),
  257. const SizedBox(height: 16),
  258. FormFieldRow(
  259. label: l10n.get('applicant'),
  260. value: app.applicantName.isNotEmpty ? app.applicantName : app.salNo,
  261. readOnly: true,
  262. showArrow: false,
  263. ),
  264. const SizedBox(height: 16),
  265. FormFieldRow(
  266. label: l10n.get('dep'),
  267. value: app.dep.isNotEmpty
  268. ? '${app.dep}${app.deptName.isNotEmpty ? '/${app.deptName}' : ''}'
  269. : '-',
  270. readOnly: true,
  271. showArrow: false,
  272. ),
  273. const SizedBox(height: 16),
  274. FormFieldRow(
  275. label: l10n.get('vehicleReason'),
  276. value: app.reason.isNotEmpty ? app.reason : '-',
  277. readOnly: true,
  278. showArrow: false,
  279. bold: true,
  280. showMoreOnOverflow: true,
  281. ),
  282. ],
  283. );
  284. }
  285. // ═══ 车辆信息 ═══
  286. Widget _buildVehicleInfoSection(
  287. VehicleApplyModel app,
  288. AppLocalizations l10n,
  289. AppColorsExtension colors,
  290. ) {
  291. return FormSection(
  292. title: l10n.get('vehicleInfo'),
  293. leadingIcon: Icons.directions_car_outlined,
  294. children: [
  295. FormFieldRow(
  296. label: l10n.get('licensePlate'),
  297. value: app.licensePlate.isNotEmpty ? app.licensePlate : '-',
  298. readOnly: true,
  299. showArrow: false,
  300. bold: true,
  301. valueColor: colors.platePrimary,
  302. ),
  303. const SizedBox(height: 16),
  304. FormFieldRow(
  305. label: l10n.get('vehicleType'),
  306. value: app.vehicleType.isNotEmpty
  307. ? _vehicleTypeLabel(app.vehicleType, l10n)
  308. : '-',
  309. readOnly: true,
  310. showArrow: false,
  311. ),
  312. const SizedBox(height: 16),
  313. FormFieldRow(
  314. label: l10n.get('brand'),
  315. value: app.brand.isNotEmpty ? app.brand : '-',
  316. readOnly: true,
  317. showArrow: false,
  318. ),
  319. const SizedBox(height: 16),
  320. FormFieldRow(
  321. label: l10n.get('seats'),
  322. value: app.seats > 0 ? '${app.seats}' : '-',
  323. readOnly: true,
  324. showArrow: false,
  325. ),
  326. const SizedBox(height: 16),
  327. FormFieldRow(
  328. label: l10n.get('odometerBegin'),
  329. value: app.odometerBegin > 0 ? '${app.odometerBegin} km' : '-',
  330. readOnly: true,
  331. showArrow: false,
  332. ),
  333. const SizedBox(height: 16),
  334. FormFieldRow(
  335. label: l10n.get('driverName'),
  336. value: app.driverName.isNotEmpty ? app.driverName : '-',
  337. readOnly: true,
  338. showArrow: false,
  339. ),
  340. ],
  341. );
  342. }
  343. // ═══ 行程信息 ═══
  344. Widget _buildTripInfoSection(
  345. VehicleApplyModel app,
  346. AppLocalizations l10n,
  347. AppColorsExtension colors,
  348. ) {
  349. return FormSection(
  350. title: l10n.get('tripInfo'),
  351. leadingIcon: Icons.route_outlined,
  352. children: [
  353. FormFieldRow(
  354. label: l10n.get('departTime'),
  355. value: app.startTime != null
  356. ? du.DateUtils.formatDateTime(app.startTime!)
  357. : '-',
  358. readOnly: true,
  359. showArrow: false,
  360. ),
  361. const SizedBox(height: 16),
  362. FormFieldRow(
  363. label: l10n.get('returnTime'),
  364. value: app.endTime != null
  365. ? du.DateUtils.formatDateTime(app.endTime!)
  366. : '-',
  367. readOnly: true,
  368. showArrow: false,
  369. ),
  370. const SizedBox(height: 16),
  371. FormFieldRow(
  372. label: l10n.get('origin'),
  373. value: app.originAdr.isNotEmpty ? app.originAdr : '-',
  374. readOnly: true,
  375. showArrow: false,
  376. showMoreOnOverflow: true,
  377. ),
  378. const SizedBox(height: 16),
  379. FormFieldRow(
  380. label: l10n.get('destination'),
  381. value: app.destAdr.isNotEmpty ? app.destAdr : '-',
  382. readOnly: true,
  383. showArrow: false,
  384. showMoreOnOverflow: true,
  385. ),
  386. ],
  387. );
  388. }
  389. // ═══ 同行信息 ═══
  390. Widget _buildPassengerInfoSection(
  391. VehicleApplyModel app,
  392. AppLocalizations l10n,
  393. AppColorsExtension colors,
  394. ) {
  395. final names = app.passengerName.isNotEmpty
  396. ? app.passengerName
  397. .split(';')
  398. .where((n) => n.trim().isNotEmpty)
  399. .toList()
  400. : <String>[];
  401. return FormSection(
  402. title: l10n.get('companionInfo'),
  403. leadingIcon: Icons.people_outline,
  404. children: [
  405. if (names.isEmpty)
  406. Padding(
  407. padding: const EdgeInsets.symmetric(vertical: 8),
  408. child: Text(
  409. '-',
  410. style: TextStyle(
  411. fontSize: AppFontSizes.subtitle,
  412. color: colors.textPlaceholder,
  413. ),
  414. ),
  415. )
  416. else
  417. Wrap(
  418. spacing: 10,
  419. runSpacing: 10,
  420. children: names.map((name) {
  421. return Container(
  422. padding: const EdgeInsets.symmetric(
  423. horizontal: 14,
  424. vertical: 10,
  425. ),
  426. decoration: BoxDecoration(
  427. color: colors.bgCard,
  428. borderRadius: BorderRadius.circular(20),
  429. border: Border.all(color: colors.border, width: 0.5),
  430. boxShadow: [
  431. BoxShadow(
  432. color: Colors.black.withValues(alpha: 0.04),
  433. blurRadius: 4,
  434. offset: const Offset(0, 1),
  435. ),
  436. ],
  437. ),
  438. child: Row(
  439. mainAxisSize: MainAxisSize.min,
  440. children: [
  441. Icon(Icons.person_outline, size: 16, color: colors.primary),
  442. const SizedBox(width: 8),
  443. Text(
  444. name.trim(),
  445. style: TextStyle(
  446. fontSize: AppFontSizes.body,
  447. color: colors.textPrimary,
  448. ),
  449. ),
  450. ],
  451. ),
  452. );
  453. }).toList(),
  454. ),
  455. const SizedBox(height: 16),
  456. Row(
  457. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  458. children: [
  459. Text(
  460. l10n.get('passengerCount'),
  461. style: TextStyle(
  462. fontSize: AppFontSizes.body,
  463. fontWeight: FontWeight.w600,
  464. color: colors.textPrimary,
  465. ),
  466. ),
  467. Text(
  468. '${app.passengerCount}',
  469. style: TextStyle(
  470. fontSize: AppFontSizes.subtitle,
  471. fontWeight: FontWeight.w700,
  472. color: colors.textPrimary,
  473. ),
  474. ),
  475. ],
  476. ),
  477. ],
  478. );
  479. }
  480. // ═══ 审批轨迹 ═══
  481. Future<void> _showAuditTrail(String billId) async {
  482. await HostAppChannel.showAuditTrail(billId, widget.billNo);
  483. }
  484. // ═══ 还车信息 ═══
  485. Widget _buildReturnInfoSection(
  486. VehicleApplyModel app,
  487. AppLocalizations l10n,
  488. AppColorsExtension colors,
  489. ) {
  490. return FormSection(
  491. title: l10n.get('returnCarInfo'),
  492. leadingIcon: Icons.assignment_return_outlined,
  493. children: [
  494. FormFieldRow(
  495. label: l10n.get('actualReturnTime'),
  496. value: app.returnTime != null
  497. ? du.DateUtils.formatDateTimeSec(app.returnTime!)
  498. : '-',
  499. readOnly: true,
  500. showArrow: false,
  501. ),
  502. const SizedBox(height: 16),
  503. FormFieldRow(
  504. label: l10n.get('odometerEnd'),
  505. value: app.odometerEnd > 0 ? '${app.odometerEnd} km' : '-',
  506. readOnly: true,
  507. showArrow: false,
  508. ),
  509. const SizedBox(height: 16),
  510. FormFieldRow(
  511. label: l10n.get('amtn'),
  512. value: app.amtn > 0 ? formatAmount(app.amtn) : '-',
  513. readOnly: true,
  514. showArrow: false,
  515. valueColor: colors.amountPrimary,
  516. ),
  517. const SizedBox(height: 16),
  518. FormFieldRow(
  519. label: l10n.get('remark'),
  520. value: app.remark.isNotEmpty ? app.remark : '-',
  521. readOnly: true,
  522. showArrow: false,
  523. showMoreOnOverflow: true,
  524. ),
  525. const SizedBox(height: 16),
  526. FormFieldRow(
  527. label: l10n.get('usrCheck'),
  528. value: app.usrCheck.isNotEmpty ? app.usrCheck : '-',
  529. readOnly: true,
  530. showArrow: false,
  531. ),
  532. const SizedBox(height: 16),
  533. FormFieldRow(
  534. label: l10n.get('checkDd'),
  535. value: app.checkDd != null
  536. ? du.DateUtils.formatDateTimeSec(app.checkDd!)
  537. : '-',
  538. readOnly: true,
  539. showArrow: false,
  540. ),
  541. ],
  542. );
  543. }
  544. Widget _buildPageFooter(AppColorsExtension colors) {
  545. final l10n = AppLocalizations.of(context);
  546. return Center(
  547. child: Padding(
  548. padding: const EdgeInsets.only(bottom: 16),
  549. child: Row(
  550. mainAxisSize: MainAxisSize.min,
  551. children: [
  552. Icon(
  553. Icons.rocket_launch_outlined,
  554. size: 16,
  555. color: colors.textPlaceholder,
  556. ),
  557. const SizedBox(width: 6),
  558. Text(
  559. l10n.get('pageFooter'),
  560. style: TextStyle(
  561. fontSize: AppFontSizes.caption,
  562. color: colors.textPlaceholder,
  563. ),
  564. ),
  565. ],
  566. ),
  567. ),
  568. );
  569. }
  570. String _vehicleTypeLabel(String key, AppLocalizations l10n) {
  571. switch (key) {
  572. case 'sedan':
  573. return l10n.get('sedan');
  574. case 'suv':
  575. return 'SUV';
  576. case 'mpv':
  577. return l10n.get('businessVan');
  578. case 'van':
  579. return l10n.get('van');
  580. case 'truck':
  581. return l10n.get('truck');
  582. case 'pickup':
  583. return l10n.get('pickup');
  584. case 'minibus':
  585. return l10n.get('minibus');
  586. case 'bus':
  587. return l10n.get('bus');
  588. default:
  589. return key;
  590. }
  591. }
  592. }