vehicle_apply_detail_page.dart 20 KB

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