vehicle_apply_detail_page.dart 18 KB

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