vehicle_apply_detail_page.dart 20 KB

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