From 83df770eaac48ffdc3d3ba941ece0809e26b8634 Mon Sep 17 00:00:00 2001 From: andyesfly <35127884@qq.com> Date: Sat, 17 Oct 2026 10:10:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8B=98=E5=AF=9F?= =?UTF-8?q?=E6=B4=BE=E5=8D=95=E5=8A=9F=E8=83=BD=E5=8F=8A=E6=8B=9C=E8=AE=BF?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增客户空间拓扑API、勘察明细模型,重构勘察接口,新增拜访详情页操作逻辑及新建勘察派单页面 --- lib/app/router.dart | 7 + lib/features/customer/data/customer_api.dart | 8 + .../customer/data/customer_models.dart | 84 +- lib/features/survey/data/survey_api.dart | 44 +- lib/features/survey/data/survey_models.dart | 109 +- .../presentation/survey_create_page.dart | 613 +++++++++ .../survey/presentation/survey_form_page.dart | 711 ++++++---- lib/features/visit/data/visit_models.dart | 61 + .../visit/presentation/visit_detail_page.dart | 465 ++++++- .../visit/presentation/visit_form_page.dart | 1215 ++++++++++++++--- 10 files changed, 2788 insertions(+), 529 deletions(-) create mode 100644 lib/features/survey/presentation/survey_create_page.dart diff --git a/lib/app/router.dart b/lib/app/router.dart index e97b3b6..1a4fc38 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -9,6 +9,7 @@ import '../features/customer/presentation/customer_detail_page.dart'; import '../features/customer/presentation/customer_edit_page.dart'; import '../features/notice/presentation/notice_center_page.dart'; import '../features/project/presentation/project_detail_page.dart'; +import '../features/survey/presentation/survey_create_page.dart'; import '../features/survey/presentation/survey_form_page.dart'; import '../features/survey/presentation/survey_list_page.dart'; import '../features/visit/presentation/visit_batch_page.dart'; @@ -97,6 +98,12 @@ final routerProvider = Provider((ref) { path: '/survey', builder: (context, state) => const SurveyListPage(), ), + GoRoute( + path: '/survey/create', + builder: (context, state) => SurveyCreatePage( + customerId: int.tryParse(state.uri.queryParameters['customerId'] ?? ''), + ), + ), GoRoute( path: '/survey/form', builder: (context, state) => SurveyFormPage( diff --git a/lib/features/customer/data/customer_api.dart b/lib/features/customer/data/customer_api.dart index 98f1457..de1330e 100644 --- a/lib/features/customer/data/customer_api.dart +++ b/lib/features/customer/data/customer_api.dart @@ -74,4 +74,12 @@ class CustomerApi { .map((e) => CustomerOption.fromJson((e as Map).cast())) .toList(), ); + + /// 客户空间拓扑:一次返回 建筑 → 楼层 → 房间(勘察明细取建筑/楼层/房间用)。 + Future> topology(int customerId) => + _client.get( + '/customer-circuits/topology', + query: {'customerId': customerId}, + parse: (d) => CustomerTopology.fromJson((d as Map).cast()), + ); } \ No newline at end of file diff --git a/lib/features/customer/data/customer_models.dart b/lib/features/customer/data/customer_models.dart index 0a411c5..d973f90 100644 --- a/lib/features/customer/data/customer_models.dart +++ b/lib/features/customer/data/customer_models.dart @@ -140,4 +140,86 @@ Color customerStatusBarColor(int status) => switch (status) { 3 => AppColors.tertiaryContainer, 4 => AppColors.statusError, _ => AppColors.borderRule, -}; \ No newline at end of file +}; + +/// 客户空间拓扑(`/api/customer-circuits/topology`):建筑 → 楼层 → 房间。 +class CustomerTopology { + const CustomerTopology({required this.buildings}); + + final List buildings; + + factory CustomerTopology.fromJson(Map json) => CustomerTopology( + buildings: (json['buildings'] as List? ?? []) + .map((e) => CustomerTopologyBuilding.fromJson((e as Map).cast())) + .toList(), + ); +} + +class CustomerTopologyBuilding { + const CustomerTopologyBuilding({ + required this.id, + required this.buildingName, + required this.floorCount, + required this.floorDownCount, + required this.floors, + }); + + final int id; + final String buildingName; + + /// 地上楼层数(地上 1..N)。 + final int floorCount; + + /// 地下楼层数(地下 负1..负M)。 + final int floorDownCount; + final List floors; + + factory CustomerTopologyBuilding.fromJson(Map json) => + CustomerTopologyBuilding( + id: toIntOr(json['id'], 0), + buildingName: json['buildingName'] as String? ?? '', + floorCount: toIntOr(json['floorCount'], 0), + floorDownCount: toIntOr(json['floorDownCount'], 0), + floors: (json['floors'] as List? ?? []) + .map((e) => CustomerTopologyFloor.fromJson((e as Map).cast())) + .toList(), + ); +} + +class CustomerTopologyFloor { + const CustomerTopologyFloor({required this.floorNo, required this.rooms}); + + /// 楼层:正数为地上、负数为地下,如 `"1"` / `"-1"`。 + final String floorNo; + final List rooms; + + factory CustomerTopologyFloor.fromJson(Map json) => + CustomerTopologyFloor( + floorNo: json['floorNo'] == null ? '' : json['floorNo'].toString(), + rooms: (json['rooms'] as List? ?? []) + .map((e) => CustomerTopologyRoom.fromJson((e as Map).cast())) + .toList(), + ); +} + +class CustomerTopologyRoom { + const CustomerTopologyRoom({ + required this.id, + required this.roomName, + required this.buildingId, + required this.floorNo, + }); + + final int id; + final String roomName; + final int buildingId; + final String floorNo; + + factory CustomerTopologyRoom.fromJson(Map json) => + CustomerTopologyRoom( + id: toIntOr(json['id'], 0), + roomName: json['roomName'] as String? ?? '', + buildingId: toIntOr(json['buildingId'], 0), + floorNo: json['floorNo'] == null ? '' : json['floorNo'].toString(), + ); +} \ No newline at end of file diff --git a/lib/features/survey/data/survey_api.dart b/lib/features/survey/data/survey_api.dart index a65ffd8..7b07522 100644 --- a/lib/features/survey/data/survey_api.dart +++ b/lib/features/survey/data/survey_api.dart @@ -9,23 +9,6 @@ final surveyApiProvider = Provider( (ref) => SurveyApi(ref.watch(apiClientProvider)), ); -/// SOP 回执增量保存结果。 -class SopSaveResult { - const SopSaveResult({required this.updated, required this.skipped, required this.sopResults}); - - final int updated; - final List skipped; - final List sopResults; - - factory SopSaveResult.fromJson(Map json) => SopSaveResult( - updated: (json['updated'] as num?)?.toInt() ?? 0, - skipped: (json['skipped'] as List? ?? []).map((e) => (e as num).toInt()).toList(), - sopResults: (json['sopResults'] as List? ?? []) - .map((e) => SurveySopResult.fromJson((e as Map).cast())) - .toList(), - ); -} - /// 勘察工单接口,对应 uni-app 版 `api/survey.ts`。 class SurveyApi { SurveyApi(this._client); @@ -77,6 +60,14 @@ class SurveyApi { parse: (d) => SurveyItem.fromJson((d as Map).cast()), ); + /// 新建勘察派单:workerId 可省略,后端默认落「待指派(0)」。 + Future> create(Map body) => + _client.post( + '/survey-orders', + body: body, + parse: (d) => SurveyItem.fromJson((d as Map).cast()), + ); + Future> update(int id, Map body) => _client.put( '/survey-orders/$id', @@ -84,12 +75,15 @@ class SurveyApi { parse: (d) => SurveyItem.fromJson((d as Map).cast()), ); - /// 按 SOP 检查项增量保存现场回执; - /// 仅现场勘察中(status=1)且工单对当前用户可见时后端才放行。 - Future> saveSopResults(int id, List results) => - _client.put( - '/survey-orders/$id/sop-results', - body: {'results': results.map((e) => e.toJson()).toList()}, - parse: (d) => SopSaveResult.fromJson((d as Map).cast()), - ); + /// 全量替换工单勘察明细(新增/编辑/删除统一走此接口,返回最新明细)。 + Future>> saveDetails( + int id, + List details, + ) => _client.put>( + '/survey-orders/$id/details', + body: {'details': details.map((e) => e.toJson()).toList()}, + parse: (d) => (d as List? ?? []) + .map((e) => SurveyOrderDetail.fromJson((e as Map).cast())) + .toList(), + ); } \ No newline at end of file diff --git a/lib/features/survey/data/survey_models.dart b/lib/features/survey/data/survey_models.dart index 5d8eec7..5b7da7f 100644 --- a/lib/features/survey/data/survey_models.dart +++ b/lib/features/survey/data/survey_models.dart @@ -94,69 +94,62 @@ class SurveyWorker { ); } -class SurveySopResult { - const SurveySopResult({ - required this.id, - required this.surveyOrderId, - required this.sopItemId, - required this.checked, - required this.itemName, - this.itemDesc = '', - this.receiptType = '', - this.receiptRequire = '', - this.required = false, - this.receiptValue = const [], - this.receiptUrls = const [], +/// 勘察明细:一条 = 客户空间拓扑中的 建筑 + 楼层 + 房间(选填) + 现场照片 + 备注。 +class SurveyOrderDetail { + const SurveyOrderDetail({ + this.id, + this.surveyOrderId, + this.buildingId, + this.buildingName = '', + this.floorNo = '', + this.roomId, + this.roomName = '', + this.photos = const [], + this.photoUrls = const [], this.remark = '', }); - final int id; - final int surveyOrderId; - final int sopItemId; - final bool checked; - final String itemName; - final String itemDesc; - final String receiptType; - final String receiptRequire; - final bool required; + final int? id; + final int? surveyOrderId; + final int? buildingId; - /// 回执内容:photo/meter 类为照片对象 key 数组,form 类为单元素文本值数组。 - final List receiptValue; + /// 建筑名称(冗余存储,客户拓扑变更后仍可读)。 + final String buildingName; - /// 详情接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)。 - final List receiptUrls; + /// 楼层:正数为地上、负数为地下,如 `"1"` / `"-1"`。 + final String floorNo; + final int? roomId; + final String roomName; + + /// 现场照片对象 key。 + final List photos; + + /// 详情接口附带:照片预签名访问 URL(与 photos 顺序一致)。 + final List photoUrls; final String remark; - factory SurveySopResult.fromJson(Map json) => SurveySopResult( - id: toIntOr(json['id'], 0), - surveyOrderId: toIntOr(json['surveyOrderId'], 0), - sopItemId: toIntOr(json['sopItemId'], 0), - checked: json['checked'] == true, - itemName: json['itemName'] as String? ?? '', - itemDesc: json['itemDesc'] as String? ?? '', - receiptType: json['receiptType'] as String? ?? '', - receiptRequire: json['receiptRequire'] as String? ?? '', - required: json['required'] == true, - receiptValue: (json['receiptValue'] as List? ?? []).map((e) => e.toString()).toList(), - receiptUrls: (json['receiptUrls'] as List? ?? []).map((e) => e.toString()).toList(), + factory SurveyOrderDetail.fromJson(Map json) => SurveyOrderDetail( + id: toIntOrNull(json['id']), + surveyOrderId: toIntOrNull(json['surveyOrderId']), + buildingId: toIntOrNull(json['buildingId']), + buildingName: json['buildingName'] as String? ?? '', + floorNo: json['floorNo'] == null ? '' : json['floorNo'].toString(), + roomId: toIntOrNull(json['roomId']), + roomName: json['roomName'] as String? ?? '', + photos: (json['photos'] as List? ?? []).map((e) => e.toString()).toList(), + photoUrls: (json['photoUrls'] as List? ?? []).map((e) => e.toString()).toList(), remark: json['remark'] as String? ?? '', ); -} - -/// 逐项回执的增量提交体:只传需要变更的 SOP 项,未传项保持原值。 -class SopResultPayload { - const SopResultPayload({required this.sopItemId, this.checked, this.receiptValue, this.remark}); - - final int sopItemId; - final bool? checked; - final List? receiptValue; - final String? remark; + /// 提交体:只带定位要素与照片/备注,行 id 由后端全量重建。 Map toJson() => { - 'sopItemId': sopItemId, - if (checked != null) 'checked': checked, - if (receiptValue != null) 'receiptValue': receiptValue, - if (remark != null) 'remark': remark, + 'buildingId': buildingId, + 'buildingName': buildingName, + 'floorNo': floorNo, + 'roomId': roomId, + 'roomName': roomName, + 'photos': photos, + 'remark': remark, }; } @@ -204,7 +197,7 @@ class SurveyItem { this.reviewedAt, this.customer, this.worker, - this.sopResults, + this.details, }); final int id; @@ -251,8 +244,8 @@ class SurveyItem { final String salesmanName; final String salesmanPhone; - // 仅详情接口返回 - final List? sopResults; + // 仅详情接口返回:勘察明细(建筑/楼层/房间 + 现场照片) + final List? details; factory SurveyItem.fromJson(Map json) => SurveyItem( id: toIntOr(json['id'], 0), @@ -300,10 +293,10 @@ class SurveyItem { workerName: json['workerName'] as String? ?? '', salesmanName: json['salesmanName'] as String? ?? '', salesmanPhone: json['salesmanPhone'] as String? ?? '', - sopResults: json['sopResults'] == null + details: json['details'] == null ? null - : (json['sopResults'] as List) - .map((e) => SurveySopResult.fromJson((e as Map).cast())) + : (json['details'] as List) + .map((e) => SurveyOrderDetail.fromJson((e as Map).cast())) .toList(), ); diff --git a/lib/features/survey/presentation/survey_create_page.dart b/lib/features/survey/presentation/survey_create_page.dart new file mode 100644 index 0000000..82b177e --- /dev/null +++ b/lib/features/survey/presentation/survey_create_page.dart @@ -0,0 +1,613 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../core/utils/format_util.dart'; +import '../../../shared/widgets/app_feedback.dart'; +import '../../../shared/widgets/x_card.dart'; +import '../../../shared/widgets/x_common.dart'; +import '../../../shared/widgets/x_icon.dart'; +import '../../../shared/widgets/x_top_bar.dart'; +import '../../customer/data/customer_api.dart'; +import '../../customer/data/customer_models.dart'; +import '../data/survey_api.dart'; +import '../data/survey_models.dart'; + +/// 新建勘察派单页,对齐 Web 端 `views/project/survey/form.vue` 的新建模式。 +/// +/// 后端 `workerId` 非必填,省略时工单落「待指派(0)」,可由后台指派共享电工。 +class SurveyCreatePage extends ConsumerStatefulWidget { + const SurveyCreatePage({super.key, this.customerId}); + + /// 由客户拜访详情「一键发起勘察派单」带入的预填客户。 + final int? customerId; + + @override + ConsumerState createState() => _SurveyCreatePageState(); +} + +const _priorityLabels = ['特急 · P1', '加急 · P2', '常规 · P3']; + +const _slaOptions = [ + 'SLA-GRADE A (24h回传)', + 'SLA-GRADE B (48h回传)', + 'SLA-GRADE C (72h回传)', +]; + +const _defaultLoopCount = 40; +const _defaultExpectMinutes = 45; +const _baseFee = 350; +const _loopFee = 5; + +class _SurveyCreatePageState extends ConsumerState { + final TextEditingController _taskTitle = TextEditingController(); + final TextEditingController _siteLocation = TextEditingController(); + final TextEditingController _loopSpec = TextEditingController(); + final TextEditingController _loopCount = TextEditingController( + text: '$_defaultLoopCount', + ); + final TextEditingController _techContact = TextEditingController(); + final TextEditingController _expectMinutes = TextEditingController( + text: '$_defaultExpectMinutes', + ); + final TextEditingController _remark = TextEditingController(); + + List _customers = const []; + int? _customerId; + DateTime? _planStart; + DateTime? _planEnd; + int _priority = SurveyPriority.regular; + int _slaIndex = 0; + bool _saving = false; + + @override + void initState() { + super.initState(); + _customerId = widget.customerId; + _loadCustomers(); + } + + @override + void dispose() { + _taskTitle.dispose(); + _siteLocation.dispose(); + _loopSpec.dispose(); + _loopCount.dispose(); + _techContact.dispose(); + _expectMinutes.dispose(); + _remark.dispose(); + super.dispose(); + } + + Future _loadCustomers() async { + try { + final res = await ref + .read(customerApiProvider) + .list(page: 1, pageSize: 200); + if (!mounted || !res.isOk || res.data == null) return; + setState(() { + _customers = res.data!.list; + // 预填客户:带出勘查区域与技术联系人 + final preset = _customers.where((c) => c.id == _customerId).firstOrNull; + if (preset != null) _applyCustomer(preset); + }); + } catch (_) { + // 客户列表加载失败由提交前校验兜底 + } + } + + Customer? get _selectedCustomer => + _customers.where((c) => c.id == _customerId).firstOrNull; + + void _applyCustomer(Customer customer) { + if (_siteLocation.text.trim().isEmpty && customer.address.isNotEmpty) { + _siteLocation.text = customer.address; + } + if (_techContact.text.trim().isEmpty && customer.contactPerson.isNotEmpty) { + _techContact.text = customer.contactPhone.isNotEmpty + ? '${customer.contactPerson} - ${customer.contactPhone}' + : customer.contactPerson; + } + } + + void _pickCustomer() { + if (_customers.isEmpty) return; + final labels = _customers + .map( + (c) => + '${c.customerName}${c.customerNo.isNotEmpty ? ' · ${c.customerNo}' : ''}', + ) + .toList(); + final index = _customers.indexWhere((c) => c.id == _customerId); + _pickIndex(labels, index < 0 ? 0 : index, (i) { + setState(() { + _customerId = _customers[i].id; + _applyCustomer(_customers[i]); + }); + }); + } + + Future _pickIndex( + List labels, + int current, + ValueChanged onSelected, + ) async { + final index = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (ctx) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (var i = 0; i < labels.length; i++) + ListTile( + title: Text( + labels[i], + style: AppTypography.bodyMd.copyWith( + color: AppColors.onSurface, + ), + ), + trailing: i == current + ? const XIcon('check', size: 18, color: AppColors.primary) + : null, + onTap: () => Navigator.of(ctx).pop(i), + ), + ], + ), + ), + ); + if (index != null) onSelected(index); + } + + Future _pickPlan({required bool isStart}) async { + final base = (isStart ? _planStart : _planEnd) ?? DateTime.now(); + final date = await showDatePicker( + context: context, + initialDate: base, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (date == null) return; + setState(() { + if (isStart) { + if (_planEnd != null && _planEnd!.isBefore(date)) _planEnd = date; + _planStart = date; + } else { + _planEnd = date; + } + }); + } + + int get _loopCountValue => int.tryParse(_loopCount.text.trim()) ?? 0; + + num get _estimatedFee => _baseFee + _loopFee * _loopCountValue; + + String? _validateError() { + if (_customerId == null) return '请选择关联客户及工程单位'; + if (_taskTitle.text.trim().isEmpty) return '请填写勘察任务标题'; + if (_siteLocation.text.trim().isEmpty) return '请填写具体勘察物理区域'; + if (_loopCountValue <= 0) return '预估分支测绘回路数需大于 0'; + return null; + } + + Map _buildPayload(int isDraft) { + final payload = { + 'customerId': _customerId, + 'loopCount': _loopCountValue, + 'expectMinutes': + int.tryParse(_expectMinutes.text.trim()) ?? _defaultExpectMinutes, + 'slaLevel': _slaOptions[_slaIndex], + 'priority': _priority, + 'estimatedFee': _estimatedFee, + 'source': 'ledger', + 'isDraft': isDraft, + }; + final taskTitle = _taskTitle.text.trim(); + if (taskTitle.isNotEmpty) payload['taskTitle'] = taskTitle; + final siteLocation = _siteLocation.text.trim(); + if (siteLocation.isNotEmpty) payload['siteLocation'] = siteLocation; + final loopSpec = _loopSpec.text.trim(); + if (loopSpec.isNotEmpty) payload['loopSpec'] = loopSpec; + final techContact = _techContact.text.trim(); + if (techContact.isNotEmpty) payload['techContact'] = techContact; + final remark = _remark.text.trim(); + if (remark.isNotEmpty) payload['remark'] = remark; + if (_planStart != null) { + payload['planStart'] = '${formatDate(_planStart)}T00:00:00+08:00'; + } + if (_planEnd != null) { + payload['planEnd'] = '${formatDate(_planEnd)}T23:59:59+08:00'; + } + return payload; + } + + Future _onSave({required bool draft}) async { + if (_saving) return; + if (_customerId == null) { + showAppToast(draft ? '暂存草稿至少需要选择关联客户' : '请选择关联客户及工程单位'); + return; + } + if (!draft) { + final error = _validateError(); + if (error != null) { + showAppToast(error); + return; + } + } + + setState(() => _saving = true); + try { + final res = await ref + .read(surveyApiProvider) + .create(_buildPayload(draft ? 1 : 0)); + if (!mounted) return; + if (res.isOk && res.data != null) { + showAppToast(draft ? '已暂存草稿' : '勘察派单已推送', success: true); + await Future.delayed(const Duration(milliseconds: 500)); + if (mounted) context.pop(); + } else { + showAppToast(res.message.isNotEmpty ? res.message : '保存失败'); + } + } catch (_) { + if (mounted) showAppToast('保存失败,请重试'); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + return XPageShell( + topBar: XTopBar.nav(title: '新建勘察派单', onBack: () => context.pop()), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 60), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + XCard( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Column( + children: [ + _formItem( + label: '关联客户及工程单位', + isRequired: true, + child: _customerPicker(), + ), + _divider(), + _formItem( + label: '勘察任务标题', + isRequired: true, + child: _input(_taskTitle, '例如:厂区配电房增容现场勘察'), + ), + _divider(), + _formItem( + label: '具体勘察物理区域', + isRequired: true, + child: _input(_siteLocation, '客户地址 / 待勘察区域'), + ), + _divider(), + _formItem( + label: '回路规格', + child: _input(_loopSpec, '选填,如:YJV-4×120'), + ), + _divider(), + _formItem( + label: '预估分支测绘回路数', + isRequired: true, + child: _input(_loopCount, '请输入回路数', number: true), + ), + _divider(), + _formItem( + label: '技术联系人', + child: _input(_techContact, '选填,如:张工 - 13800000000'), + ), + _divider(), + _formItem( + label: '预计耗时', + hint: '分钟', + child: _input(_expectMinutes, '45', number: true), + ), + _divider(), + _formItem(label: '计划勘察时间', child: _planRangeField()), + _divider(), + _formItem( + label: '优先级', + child: _priorityPills(), + ), + _divider(), + _formItem( + label: 'SLA 等级', + child: _optionPicker( + _slaOptions, + _slaIndex, + (i) => setState(() => _slaIndex = i), + ), + ), + _divider(), + _formItem( + label: '备注', + child: _textarea(_remark, '选填,补充说明...'), + ), + ], + ), + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: Text( + '预估派单报酬:¥ ${_estimatedFee.toStringAsFixed(2)}', + style: AppTypography.dataMono.copyWith( + fontSize: 13, + color: AppColors.primary, + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 40), + child: Column( + children: [ + XPrimaryButton( + label: '推送勘察派单', + loading: _saving, + onPressed: () => _onSave(draft: false), + margin: const EdgeInsets.only(bottom: 12), + ), + XPrimaryButton( + label: '暂存草稿', + loading: _saving, + onPressed: () => _onSave(draft: true), + background: AppColors.surface, + foreground: AppColors.primary, + borderColor: AppColors.borderRule, + fontSize: 15, + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _divider() => + const Divider(height: 1, thickness: 1, color: AppColors.borderRule); + + Widget _formItem({ + required String label, + bool isRequired = false, + String? hint, + required Widget child, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: AppTypography.labelBold.copyWith( + color: AppColors.onSurfaceVariant, + ), + ), + if (isRequired) + Text( + ' *', + style: AppTypography.labelBold.copyWith( + color: AppColors.statusError, + ), + ), + const Spacer(), + if (hint != null) + Text( + hint, + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + const SizedBox(height: 8), + child, + ], + ), + ); + } + + Widget _customerPicker() { + final customer = _selectedCustomer; + final label = customer == null + ? (_customers.isNotEmpty ? '请选择客户' : '暂无客户可选') + : '${customer.customerName}' + '${customer.customerNo.isNotEmpty ? ' · ${customer.customerNo}' : ''}'; + return GestureDetector( + onTap: _customers.isEmpty ? null : _pickCustomer, + child: _pickerBox( + label, + customer == null, + const XIcon('forward', size: 16, color: AppColors.outline), + ), + ); + } + + Widget _planRangeField() { + return Row( + children: [ + Expanded( + child: _datePicker( + _planStart == null ? '' : formatDate(_planStart), + '开始日期', + () => _pickPlan(isStart: true), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('~', style: TextStyle(color: AppColors.textMuted)), + ), + Expanded( + child: _datePicker( + _planEnd == null ? '' : formatDate(_planEnd), + '结束日期', + () => _pickPlan(isStart: false), + ), + ), + ], + ); + } + + Widget _priorityPills() { + return Row( + children: [ + for (var i = 0; i < _priorityLabels.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded(child: _priorityPill(i + 1)), + ], + ], + ); + } + + Widget _priorityPill(int value) { + final active = _priority == value; + return GestureDetector( + onTap: () => setState(() => _priority = value), + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? AppColors.primary : AppColors.surfaceBright, + border: Border.all( + color: active ? AppColors.primary : AppColors.borderRule, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + _priorityLabels[value - 1], + style: TextStyle( + fontSize: 13, + fontWeight: active ? FontWeight.w700 : FontWeight.w400, + color: active ? Colors.white : AppColors.onSurfaceVariant, + ), + ), + ), + ); + } + + Widget _optionPicker( + List labels, + int index, + ValueChanged onSelected, + ) { + final safeIndex = index < 0 || index >= labels.length ? 0 : index; + return GestureDetector( + onTap: () => _pickIndex(labels, safeIndex, onSelected), + child: _pickerBox( + labels[safeIndex], + false, + const XIcon('forward', size: 16, color: AppColors.outline), + ), + ); + } + + Widget _datePicker(String value, String placeholder, VoidCallback onPick) { + return GestureDetector( + onTap: onPick, + child: _pickerBox( + value.isNotEmpty ? value : placeholder, + value.isEmpty, + const XIcon('calendar', size: 16, color: AppColors.outline), + ), + ); + } + + Widget _pickerBox(String text, bool placeholder, Widget? trailing) { + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: _fieldDecoration, + child: Row( + children: [ + Expanded( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + color: placeholder + ? AppColors.textPlaceholder + : AppColors.onSurface, + ), + ), + ), + ?trailing, + ], + ), + ); + } + + Widget _input( + TextEditingController controller, + String hint, { + bool number = false, + }) { + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: _fieldDecoration, + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + keyboardType: number ? TextInputType.number : TextInputType.text, + style: const TextStyle(fontSize: 15, color: AppColors.onSurface), + cursorColor: AppColors.primary, + decoration: _inputDecoration(hint), + ), + ), + ], + ), + ); + } + + Widget _textarea(TextEditingController controller, String hint) { + return Container( + height: 92, + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + decoration: _fieldDecoration, + child: TextField( + controller: controller, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + style: const TextStyle(fontSize: 15, color: AppColors.onSurface), + cursorColor: AppColors.primary, + decoration: _inputDecoration(hint), + ), + ); + } + + BoxDecoration get _fieldDecoration => BoxDecoration( + color: AppColors.surfaceBright, + border: Border.all(color: AppColors.borderRule), + borderRadius: BorderRadius.circular(10), + ); + + InputDecoration _inputDecoration(String hint) => InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + counterText: '', + hintText: hint, + hintStyle: const TextStyle(fontSize: 15, color: AppColors.textPlaceholder), + ); +} \ No newline at end of file diff --git a/lib/features/survey/presentation/survey_form_page.dart b/lib/features/survey/presentation/survey_form_page.dart index 665388d..d923a38 100644 --- a/lib/features/survey/presentation/survey_form_page.dart +++ b/lib/features/survey/presentation/survey_form_page.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; @@ -18,12 +17,14 @@ import '../../../shared/widgets/x_chip.dart'; import '../../../shared/widgets/x_common.dart'; import '../../../shared/widgets/x_icon.dart'; import '../../../shared/widgets/x_top_bar.dart'; +import '../../customer/data/customer_api.dart'; +import '../../customer/data/customer_models.dart'; import '../data/survey_api.dart'; import '../data/survey_models.dart'; const _monoFallback = ['JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas']; -/// 现场勘测(勘察单编辑 + SOP 逐项回执),对应 `pages/survey/form.vue`。 +/// 现场勘测(勘察单编辑 + 勘察明细),对应 `pages/survey/form.vue`。 class SurveyFormPage extends ConsumerStatefulWidget { const SurveyFormPage({super.key, this.id, this.customerId}); @@ -49,23 +50,20 @@ class _SurveyFormPageState extends ConsumerState { SurveyItem? _survey; int _surveyId = 0; bool _submitting = false; + bool _savingDetails = false; String _busyText = ''; - List<_SopRow> _sopRows = const []; + List<_DetailRow> _detailRows = const []; + List _buildings = const []; final List> _strokes = []; List? _drawing; bool get _busy => _busyText.isNotEmpty; - /// 回执仅「现场勘察中」可录入,其余状态只读展示(与 Web 端 isReadonly 判定一致)。 - bool get _canEditReceipt => _survey?.status == SurveyStatus.surveying; - - int get _sopDoneCount => _sopRows.where((r) => r.checked).length; - - List get _sopMissingRequired => _sopRows - .where((r) => r.required && !r.checked) - .map((r) => r.itemName) - .toList(); + /// 明细仅「待指派 / 现场勘察中」可维护,其余状态只读(与后端 saveDetails 校验一致)。 + bool get _canEditDetails => + _survey?.status == SurveyStatus.pendingAssign || + _survey?.status == SurveyStatus.surveying; bool get _hasSignature => _strokes.isNotEmpty || _drawing != null; @@ -87,7 +85,7 @@ class _SurveyFormPageState extends ConsumerState { _mainPanelLocation.dispose(); _loadCapacity.dispose(); _observation.dispose(); - for (final row in _sopRows) { + for (final row in _detailRows) { row.dispose(); } super.dispose(); @@ -103,10 +101,11 @@ class _SurveyFormPageState extends ConsumerState { final data = res.data!; setState(() { _survey = data; - _sopRows = (data.sopResults ?? const []) - .map(_buildSopRow) + _detailRows = (data.details ?? const []) + .map(_buildDetailRow) .toList(); }); + _loadTopology(data.customerId); } else { showAppToast(res.message.isEmpty ? '加载勘察单失败' : res.message); } @@ -115,70 +114,209 @@ class _SurveyFormPageState extends ConsumerState { } } - _SopRow _buildSopRow(SurveySopResult result) { - final keys = List.from(result.receiptValue); - final row = _SopRow( - sopItemId: result.sopItemId, - itemName: result.itemName.isEmpty ? '检查项 #${result.sopItemId}' : result.itemName, - itemDesc: result.itemDesc, - receiptType: result.receiptType.isEmpty ? 'photo' : result.receiptType, - receiptRequire: result.receiptRequire, - required: result.required, - checked: result.checked, - keys: keys, - urls: List.from(result.receiptUrls), - text: result.receiptType == 'form' ? (keys.isEmpty ? '' : keys.first) : '', - remark: result.remark, - ); - row.snap = _rowSnapshot(row); - return row; - } + _DetailRow _buildDetailRow(SurveyOrderDetail detail) => _DetailRow( + buildingId: detail.buildingId, + buildingName: detail.buildingName, + floorNo: detail.floorNo, + roomId: detail.roomId, + roomName: detail.roomName, + keys: detail.photos, + urls: detail.photoUrls, + remark: detail.remark, + ); - List _rowReceiptValue(_SopRow row) { - if (row.receiptType != 'form') return row.keys; - final text = row.textController.text; - return text.isEmpty ? const [] : [text]; - } - - String _rowSnapshot(_SopRow row) => - jsonEncode({'v': _rowReceiptValue(row), 'r': row.remarkController.text}); - - /// 增量保存单项回执:只提交当前项,未传项后端保持原值。 - Future _saveSopItem(_SopRow row) async { - if (!_canEditReceipt || row.saving) return; - final snap = _rowSnapshot(row); - if (snap == row.snap) return; - final receiptValue = _rowReceiptValue(row); - final checked = receiptValue.isNotEmpty; - setState(() => row.saving = true); + /// 客户空间拓扑:一次拉取建筑/楼层/房间,供明细下拉使用。 + Future _loadTopology(int customerId) async { + if (customerId <= 0) return; try { - final res = await ref.read(surveyApiProvider).saveSopResults(_surveyId, [ - SopResultPayload( - sopItemId: row.sopItemId, - checked: checked, - receiptValue: receiptValue, - remark: row.remarkController.text, - ), - ]); + final res = await ref.read(customerApiProvider).topology(customerId); if (!mounted) return; - if (res.isOk) { - setState(() { - row.snap = snap; - row.checked = checked; - }); - } else { - showAppToast(res.message.isEmpty ? '回执保存失败' : res.message); + if (res.isOk && res.data != null) { + setState(() => _buildings = res.data!.buildings); } - } catch (e) { - if (mounted) showAppToast(e is ApiException ? e.message : '回执保存失败'); - } finally { - if (mounted) setState(() => row.saving = false); + } catch (_) { + // 拓扑加载失败时建筑下拉为空,仍可只读展示既有明细 } } - /// 选中即上传即保存,电工中途退出不丢已传内容。 - Future _addSopPhotos(_SopRow row) async { - if (!_canEditReceipt) return; + /// 楼层选项:地上 1..floorCount,地下 负1..负floorDownCount。 + List<({String value, String label})> _floorOptions(int? buildingId) { + final building = _buildings.where((b) => b.id == buildingId).firstOrNull; + if (building == null) return const []; + return [ + for (var i = 1; i <= building.floorCount; i++) (value: '$i', label: '$i层'), + for (var i = 1; i <= building.floorDownCount; i++) + (value: '-$i', label: '负$i层'), + ]; + } + + /// 房间选项:按 所选建筑 + 楼层 过滤拓扑 floors[].rooms。 + List _roomOptions(_DetailRow row) { + if (row.buildingId == null || row.floorNo.isEmpty) return const []; + final building = _buildings.where((b) => b.id == row.buildingId).firstOrNull; + final floor = building?.floors + .where((f) => f.floorNo == row.floorNo) + .firstOrNull; + return floor?.rooms ?? const []; + } + + String _floorText(String floorNo) { + if (floorNo.isEmpty) return ''; + final n = int.tryParse(floorNo); + if (n == null) return floorNo; + return n < 0 ? '负${-n}层' : '$n层'; + } + + void _addDetailRow() { + setState(() => _detailRows = [..._detailRows, _DetailRow()]); + } + + void _removeDetailRow(int index) { + final row = _detailRows[index]; + setState(() => _detailRows = [..._detailRows]..removeAt(index)); + row.dispose(); + } + + void _pickBuilding(_DetailRow row) { + if (_buildings.isEmpty) { + showAppToast('该客户暂无建筑拓扑,请先在客户空间拓扑中维护'); + return; + } + final current = _buildings.indexWhere((b) => b.id == row.buildingId); + _pickSheet( + _buildings.map((b) => b.buildingName).toList(), + current, + (i) { + final building = _buildings[i]; + setState(() { + row.buildingId = building.id; + row.buildingName = building.buildingName; + // 建筑变更后原楼层/房间可能不再匹配,一并清空 + row.floorNo = ''; + row.roomId = null; + row.roomName = ''; + }); + }, + ); + } + + void _pickFloor(_DetailRow row) { + final options = _floorOptions(row.buildingId); + if (options.isEmpty) { + showAppToast('请先选择建筑'); + return; + } + final current = options.indexWhere((o) => o.value == row.floorNo); + _pickSheet( + options.map((o) => o.label).toList(), + current, + (i) => setState(() { + row.floorNo = options[i].value; + row.roomId = null; + row.roomName = ''; + }), + ); + } + + void _pickRoom(_DetailRow row) { + final rooms = _roomOptions(row); + if (rooms.isEmpty) { + showAppToast('该楼层暂无可选房间'); + return; + } + // 房间选填:首项为「不指定房间」 + final labels = ['不指定房间', ...rooms.map((r) => r.roomName)]; + final current = rooms.indexWhere((r) => r.id == row.roomId) + 1; + _pickSheet(labels, current, (i) { + setState(() { + if (i == 0) { + row.roomId = null; + row.roomName = ''; + } else { + final room = rooms[i - 1]; + row.roomId = room.id; + row.roomName = room.roomName; + } + }); + }); + } + + Future _pickSheet( + List labels, + int current, + ValueChanged onSelected, + ) async { + final index = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (ctx) => SafeArea( + child: ListView( + shrinkWrap: true, + children: [ + for (var i = 0; i < labels.length; i++) + ListTile( + title: Text( + labels[i], + style: AppTypography.bodyMd.copyWith( + color: AppColors.onSurface, + ), + ), + trailing: i == current + ? const XIcon('check', size: 18, color: AppColors.primary) + : null, + onTap: () => Navigator.of(ctx).pop(i), + ), + ], + ), + ), + ); + if (index != null) onSelected(index); + } + + /// 组装明细提交体:缺建筑或楼层的行按约定过滤。 + List _collectDetails() => [ + for (final row in _detailRows) + if (row.buildingId != null && row.floorNo.isNotEmpty) + SurveyOrderDetail( + buildingId: row.buildingId, + buildingName: row.buildingName, + floorNo: row.floorNo, + roomId: row.roomId, + roomName: row.roomName, + photos: List.of(row.keys), + remark: row.remarkController.text.trim(), + ), + ]; + + /// 全量替换保存勘察明细,返回是否成功。 + Future _saveDetails() async { + if (!_canEditDetails || _savingDetails) return false; + setState(() => _savingDetails = true); + try { + final res = await ref + .read(surveyApiProvider) + .saveDetails(_surveyId, _collectDetails()); + if (!mounted) return false; + if (res.isOk) { + showAppToast('勘察明细已保存', success: true); + return true; + } + showAppToast(res.message.isEmpty ? '勘察明细保存失败' : res.message); + return false; + } catch (e) { + if (mounted) showAppToast(e is ApiException ? e.message : '勘察明细保存失败'); + return false; + } finally { + if (mounted) setState(() => _savingDetails = false); + } + } + + /// 照片:选中即上传,只提交对象 key,预览用返回的预签名 URL。 + Future _addDetailPhotos(_DetailRow row) async { + if (!_canEditDetails) return; final picked = await ImagePicker().pickMultiImage(); if (picked.isEmpty || !mounted) return; setState(() => _busyText = '上传中...'); @@ -195,20 +333,18 @@ class _SurveyFormPageState extends ConsumerState { } if (!mounted) return; setState(() => _busyText = ''); - if (row.keys.isNotEmpty) await _saveSopItem(row); if (failed > 0) showAppToast('$failed 张上传失败'); } - void _removeSopPhoto(_SopRow row, int index) { - if (!_canEditReceipt) return; + void _removeDetailPhoto(_DetailRow row, int index) { + if (!_canEditDetails) return; setState(() { if (index < row.keys.length) row.keys.removeAt(index); if (index < row.urls.length) row.urls.removeAt(index); }); - _saveSopItem(row); } - void _previewSopPhotos(_SopRow row, int index) { + void _previewDetailPhotos(_DetailRow row, int index) { if (row.urls.isEmpty || index >= row.urls.length) return; final url = row.urls[index]; showDialog( @@ -327,6 +463,23 @@ class _SurveyFormPageState extends ConsumerState { showAppToast('无内容可提交'); return; } + // 勘察明细随报告一并落库(明细接口为全量替换) + if (_canEditDetails) { + final detailRes = await ref + .read(surveyApiProvider) + .saveDetails(_surveyId, _collectDetails()); + if (!mounted) return; + if (!detailRes.isOk) { + setState(() { + _busyText = ''; + _submitting = false; + }); + showAppToast( + detailRes.message.isEmpty ? '勘察明细保存失败' : detailRes.message, + ); + return; + } + } final res = await ref.read(surveyApiProvider).update(_surveyId, payload); if (!mounted) return; setState(() { @@ -367,7 +520,7 @@ class _SurveyFormPageState extends ConsumerState { if (survey != null) _buildTaskCard(survey), _buildTechSection(), _buildPhotoSection(), - if (_sopRows.isNotEmpty) _buildSopSection(), + if (survey != null) _buildDetailSection(), _buildSignSection(), _buildSubmitButton(), ], @@ -756,7 +909,7 @@ class _SurveyFormPageState extends ConsumerState { ); } - Widget _buildSopSection() { + Widget _buildDetailSection() { return Padding( padding: const EdgeInsets.only(bottom: 24), child: Column( @@ -765,141 +918,206 @@ class _SurveyFormPageState extends ConsumerState { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _buildSectionTitle('edit', AppColors.secondary, '勘察回执标准'), - Text( - '$_sopDoneCount/${_sopRows.length} 项已回执', - style: const TextStyle(fontSize: 12, color: AppColors.textMuted), - ), - ], - ), - if (_canEditReceipt && _sopMissingRequired.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - children: [ - const XIcon('schedule', size: 14, color: AppColors.statusWarn), - const SizedBox(width: 6), - Expanded( - child: Text( - '还有 ${_sopMissingRequired.length} 项必填回执未完成:' - '${_sopMissingRequired.join('、')}', + _buildSectionTitle('domain', AppColors.secondary, '勘察明细'), + if (_canEditDetails) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _savingDetails ? '保存中...' : '共 ${_detailRows.length} 条', style: const TextStyle( fontSize: 12, color: AppColors.textMuted, ), ), - ), - ], + const SizedBox(width: 12), + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _savingDetails ? null : _saveDetails, + child: const Text( + '保存', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ), + ], + ) + else + Text( + '共 ${_detailRows.length} 条', + style: const TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + ], + ), + if (_canEditDetails && _buildings.isEmpty) + const Padding( + padding: EdgeInsets.only(top: 8), + child: Text( + '该客户暂无建筑拓扑,请先在「客户空间拓扑」维护建筑与房间', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + ), + if (_detailRows.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + _canEditDetails ? '暂无明细,点击下方「新增明细」添加' : '暂无勘察明细', + style: const TextStyle(fontSize: 13, color: AppColors.textMuted), + ), + ), + for (var i = 0; i < _detailRows.length; i++) + _buildDetailCard(i, _detailRows[i]), + if (_canEditDetails) + Padding( + padding: const EdgeInsets.only(top: 12), + child: GestureDetector( + onTap: _addDetailRow, + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppColors.surface, + border: Border.all(color: AppColors.outlineVariant), + borderRadius: BorderRadius.circular(10), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + XIcon('add', size: 18, color: AppColors.primary), + SizedBox(width: 6), + Text( + '新增明细', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + ), ), ), - for (final row in _sopRows) _buildSopCard(row), ], ), ); } - Widget _buildSopCard(_SopRow row) { - final isForm = row.receiptType == 'form'; + Widget _buildDetailCard(int index, _DetailRow row) { + final editable = _canEditDetails; + final buildingLabel = row.buildingId == null + ? (editable ? '请选择建筑' : (row.buildingName.isEmpty ? '—' : row.buildingName)) + : (row.buildingName.isEmpty ? '建筑 #${row.buildingId}' : row.buildingName); + final floorLabel = row.floorNo.isEmpty + ? (editable ? '请选择楼层' : '—') + : _floorText(row.floorNo); + final roomLabel = row.roomId == null + ? (editable ? '不指定房间(选填)' : '—') + : (row.roomName.isEmpty ? '房间 #${row.roomId}' : row.roomName); return XCard( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), margin: const EdgeInsets.only(top: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Text( - row.itemName, - style: AppTypography.bodyMd.copyWith( - fontWeight: FontWeight.w700, - color: AppColors.onSurface, - ), + Text( + '明细 ${index + 1}', + style: AppTypography.bodyMd.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.onSurface, ), ), - const SizedBox(width: 8), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (row.required) - _buildSopTag('必填', const Color(0xFFC0392B), const Color(0x1AC0392B)), - if (row.required && row.checked) const SizedBox(width: 6), - if (row.checked) - _buildSopTag( - '已回执', - AppColors.tertiaryContainer, - const Color(0x1A008378), - ), - ], - ), + const Spacer(), + if (editable) + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _removeDetailRow(index), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + XIcon('delete', size: 16, color: AppColors.statusError), + SizedBox(width: 4), + Text( + '删除', + style: TextStyle(fontSize: 13, color: AppColors.statusError), + ), + ], + ), + ), ], ), - if (row.itemDesc.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - row.itemDesc, - style: const TextStyle( - fontSize: 13, - color: AppColors.onSurfaceVariant, - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 10), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: AppColors.surfaceBright, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _receiptTypeLabel(row.receiptType), - style: const TextStyle(fontSize: 12, color: AppColors.primary), - ), - Text( - row.receiptRequire.isEmpty ? '—' : row.receiptRequire, - style: const TextStyle(fontSize: 12, color: AppColors.primary), - ), - ], - ), + const SizedBox(height: 4), + _buildField( + label: '建筑', + input: _buildDetailPicker( + buildingLabel, + placeholder: row.buildingId == null, + enabled: editable, + onTap: () => _pickBuilding(row), ), ), - if (isForm) - Padding( - padding: const EdgeInsets.only(top: 10), - child: _buildTextarea( - controller: row.textController, - hint: '按清单格式填写回执内容', - enabled: _canEditReceipt, - onEditDone: () => _saveSopItem(row), - ), - ) - else - Padding( - padding: const EdgeInsets.only(top: 10), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - for (var i = 0; i < row.urls.length; i++) - _buildSopPhoto(row, i), - if (_canEditReceipt) _buildSopPhotoAdd(row), - ], - ), + _buildField( + label: '楼层', + divider: true, + input: _buildDetailPicker( + floorLabel, + placeholder: row.floorNo.isEmpty, + enabled: editable, + onTap: () => _pickFloor(row), ), - if (_canEditReceipt) + ), + _buildField( + label: '房间(选填)', + divider: true, + input: _buildDetailPicker( + roomLabel, + placeholder: row.roomId == null, + enabled: editable, + onTap: () => _pickRoom(row), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 13), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '现场照片', + style: AppTypography.labelBold.copyWith( + color: AppColors.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + if (row.urls.isEmpty && !editable) + const Text( + '无照片', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < row.urls.length; i++) + _buildDetailPhoto(row, i), + if (editable) _buildDetailPhotoAdd(row), + ], + ), + ], + ), + ), + if (editable) Padding( - padding: const EdgeInsets.only(top: 10), + padding: const EdgeInsets.only(top: 13), child: _buildInput( controller: row.remarkController, hint: '备注(选填)', height: 40, - onEditDone: () => _saveSopItem(row), ), ) else if (row.remarkController.text.isNotEmpty) @@ -910,31 +1128,51 @@ class _SurveyFormPageState extends ConsumerState { style: const TextStyle(fontSize: 12, color: AppColors.textMuted), ), ), - if (row.saving) - const Padding( - padding: EdgeInsets.only(top: 6), - child: Text( - '保存中...', - style: TextStyle(fontSize: 11, color: AppColors.textMuted), - ), - ), ], ), ); } - Widget _buildSopTag(String text, Color foreground, Color background) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(4), + Widget _buildDetailPicker( + String text, { + required bool placeholder, + required bool enabled, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: enabled ? onTap : null, + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: enabled ? AppColors.surfaceBright : AppColors.surface, + border: Border.all(color: AppColors.borderRule), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Expanded( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + color: placeholder + ? AppColors.textPlaceholder + : AppColors.onSurface, + ), + ), + ), + if (enabled) + const XIcon('forward', size: 16, color: AppColors.outline), + ], + ), ), - child: Text(text, style: TextStyle(fontSize: 11, color: foreground)), ); } - Widget _buildSopPhoto(_SopRow row, int index) { + Widget _buildDetailPhoto(_DetailRow row, int index) { return SizedBox( width: 84, height: 84, @@ -942,19 +1180,19 @@ class _SurveyFormPageState extends ConsumerState { children: [ Positioned.fill( child: GestureDetector( - onTap: () => _previewSopPhotos(row, index), + onTap: () => _previewDetailPhotos(row, index), child: ClipRRect( borderRadius: BorderRadius.circular(10), child: _buildImage(row.urls[index]), ), ), ), - if (_canEditReceipt) + if (_canEditDetails) Positioned( right: 2, top: 2, child: GestureDetector( - onTap: () => _removeSopPhoto(row, index), + onTap: () => _removeDetailPhoto(row, index), child: Container( width: 18, height: 18, @@ -972,9 +1210,9 @@ class _SurveyFormPageState extends ConsumerState { ); } - Widget _buildSopPhotoAdd(_SopRow row) { + Widget _buildDetailPhotoAdd(_DetailRow row) { return GestureDetector( - onTap: () => _addSopPhotos(row), + onTap: () => _addDetailPhotos(row), child: Container( width: 84, height: 84, @@ -1182,16 +1420,6 @@ class _SurveyFormPageState extends ConsumerState { ), ); } - - String _receiptTypeLabel(String type) { - if (type.isEmpty) return '—'; - return const { - 'photo': '现场照片', - 'form': '清单格式', - 'meter': '手持测仪', - }[type] ?? - type; - } } /// 工单级照片槽位(标签 + 本地临时路径)。 @@ -1202,50 +1430,35 @@ class _PhotoSlot { String? src; } -/// SOP 逐项回执行。 -class _SopRow { - _SopRow({ - required this.sopItemId, - required this.itemName, - required this.itemDesc, - required this.receiptType, - required this.receiptRequire, - required this.required, - required this.checked, - required this.keys, - required this.urls, - String text = '', +/// 勘察明细编辑行。 +class _DetailRow { + _DetailRow({ + this.buildingId, + this.buildingName = '', + this.floorNo = '', + this.roomId, + this.roomName = '', + List keys = const [], + List urls = const [], String remark = '', - }) : textController = TextEditingController(text: text), + }) : keys = List.of(keys), + urls = List.of(urls), remarkController = TextEditingController(text: remark); - final int sopItemId; - final String itemName; - final String itemDesc; - final String receiptType; - final String receiptRequire; - final bool required; - bool checked; + int? buildingId; + String buildingName; + String floorNo; + int? roomId; + String roomName; - /// 已上传照片的对象 key(form 类不用) + /// 已上传照片的对象 key。 final List keys; - /// 与 keys 顺序一致的预览地址(后端预签名 URL 或刚上传的临时地址) + /// 与 keys 顺序一致的预览地址(后端预签名 URL 或刚上传的临时地址)。 final List urls; - - /// form 类的表单文本值,photo/meter 类为备注 - final TextEditingController textController; final TextEditingController remarkController; - bool saving = false; - - /// 最近一次保存成功的快照,用于避免 blur 触发的无变化请求 - String snap = ''; - - void dispose() { - textController.dispose(); - remarkController.dispose(); - } + void dispose() => remarkController.dispose(); } /// 手写签名笔迹绘制,对应源页面 canvas 的 `#0054cd` / 3px / round 笔触。 diff --git a/lib/features/visit/data/visit_models.dart b/lib/features/visit/data/visit_models.dart index efec3c5..dc2d96c 100644 --- a/lib/features/visit/data/visit_models.dart +++ b/lib/features/visit/data/visit_models.dart @@ -37,6 +37,67 @@ const visitIntentOptions = [ (value: 3, label: 'C级 · 低意向'), ]; +/// 拜访方式图标,对应 Web 表单 mode-pill 的图标映射。 +const visitModeIcons = { + 1: 'location', + 2: 'groups', + 3: 'call', + 4: 'camera', +}; + +/// 意向评级卡片(Web 表单 INTENT_CARDS 的等价常量)。 +typedef IntentCard = ({int value, String letter, String title, String desc}); + +const visitIntentCards = [ + ( + value: 1, + letter: 'A', + title: '高意向 · 近期推进', + desc: '客户需求迫切,有明确预算意向或近期采购计划,拟安排方案深化对接。', + ), + ( + value: 2, + letter: 'B', + title: '中意向 · 方案比选', + desc: '认可智电升级方向,处于多家厂商对比及内部论证期,需递交产品方案。', + ), + ( + value: 3, + letter: 'C', + title: '低意向 · 储备客情', + desc: '日常商务拜访维系,短期内暂无实质性预算规划,列入潜在客户储备库。', + ), +]; + +/// 客户关注诉求与痛点(多选)。 +const visitDemandOptions = [ + '用电安全监管与隐患排查', + '节能降耗与能耗考核', + '配电室托管代运维', + '能耗可视化数字大屏', + '成本预算控制与分期投入', + '政府节能绿色补贴申报', +]; + +/// 下一步行动目标(单选)。 +const visitNextActionOptions = [ + '提报定制化初步解决方案与产品演示', + '安排技术团队现场勘察与需求复核', + '推进商务报价与合同条款沟通', + '组织分管领导方案汇报', + '定期客情维系与需求跟踪', + '暂不安排,列入储备客户跟进', +]; + +/// 拜访沟通纪要模板,对应 Web 表单 CONTENT_TEMPLATE。 +const visitContentTemplate = + '本次拜访与客户【接待人/职务】就【沟通主题】进行交流。\n' + '一、客户核心关注诉求与痛点:\n' + '1、\n' + '二、客户顾虑与异议:\n' + '1、\n' + '三、本次沟通结论与下一步计划:'; + /// 状态:0 待执行 1 已完成待复核 2 取证待技术复核 3 已立项转勘察 4 已归档 5 已取消 const visitStatusOptions = [ (value: 0, label: '待执行'), diff --git a/lib/features/visit/presentation/visit_detail_page.dart b/lib/features/visit/presentation/visit_detail_page.dart index 7c2693d..4dfaf7b 100644 --- a/lib/features/visit/presentation/visit_detail_page.dart +++ b/lib/features/visit/presentation/visit_detail_page.dart @@ -78,16 +78,83 @@ class _VisitDetailPageState extends ConsumerState { return '${visit.receptionPerson}$title$phone'; } - void _goEdit(bool canEdit) { - if (!canEdit) { - showAppToast('仅待执行的拜访可编辑'); - return; - } + void _goEdit() { context.push('/visit/form?id=${widget.id}').then((_) { if (mounted) _load(); }); } + /// 是否具备发起勘察派单的条件(对齐 Web `canSurvey`)。 + bool _canSurvey(VisitInfo visit) => + (visit.intentLevel == 1 || visit.intentLevel == 2) && + visit.status != VisitStatus.survey && + visit.status != VisitStatus.archived && + visit.status != VisitStatus.canceled; + + void _onSurvey(VisitInfo visit) { + if (visit.customerId <= 0) return; + context.push('/survey/create?customerId=${visit.customerId}'); + } + + void _onViewProject(VisitInfo visit) { + final no = visit.projectNo; + if (no != null && no.isNotEmpty) { + showAppToast('关联工程编号:$no'); + } else { + showAppToast('该项目暂未关联工程编号'); + } + } + + /// 报告归档:已立项转勘察(3) → 已归档(4)。 + Future _onArchive() async { + final confirmed = await showAppConfirm( + title: '确认报告归档?', + content: '该拜访记录将变更为「已归档」。', + ); + if (!confirmed) return; + try { + final res = await ref + .read(visitApiProvider) + .updateStatus(widget.id, {'status': VisitStatus.archived}); + if (res.isOk) { + showAppToast('归档成功', success: true); + _load(); + } else { + showAppToast(res.message.isNotEmpty ? res.message : '归档失败'); + } + } catch (_) { + showAppToast('归档失败'); + } + } + + /// 审核现场取证:取证待技术复核(2) → 转立项勘察(3) 或 通过并归档(4)。 + Future _showReviewSheet(VisitInfo visit) async { + final payload = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (_) => _ReviewSheet( + resultRating: visit.resultRating ?? '', + projectNo: visit.projectNo ?? '', + ), + ); + if (payload == null) return; + try { + final res = await ref.read(visitApiProvider).updateStatus(widget.id, payload); + if (res.isOk) { + showAppToast('审核完成', success: true); + _load(); + } else { + showAppToast(res.message.isNotEmpty ? res.message : '审核失败'); + } + } catch (_) { + showAppToast('审核失败'); + } + } + void _goExecute() { context.push('/visit/execute/${widget.id}').then((_) { if (mounted) _load(); @@ -187,20 +254,15 @@ class _VisitDetailPageState extends ConsumerState { Widget build(BuildContext context) { final visit = _visit; final isAdmin = ref.watch(sessionProvider).user?.isAdmin ?? false; - final canEdit = visit?.status == VisitStatus.pending; return XPageShell( topBar: XTopBar.nav( title: '拜访详情', onBack: () => context.pop(), trailing: GestureDetector( - onTap: () => _goEdit(canEdit), + onTap: _goEdit, behavior: HitTestBehavior.opaque, - child: XIcon( - 'edit', - size: 20, - color: canEdit ? AppColors.primary : AppColors.outlineVariant, - ), + child: const XIcon('edit', size: 20, color: AppColors.primary), ), ), child: visit == null @@ -214,12 +276,12 @@ class _VisitDetailPageState extends ConsumerState { )) : ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 60), - children: _buildContent(visit, canEdit, isAdmin), + children: _buildContent(visit, isAdmin), ), ); } - List _buildContent(VisitInfo visit, bool canEdit, bool isAdmin) { + List _buildContent(VisitInfo visit, bool isAdmin) { final customer = visit.customer; final photoList = visit.photoUrls.where((u) => u.isNotEmpty).toList(); final hasGps = @@ -459,12 +521,41 @@ class _VisitDetailPageState extends ConsumerState { ]), // 业务员操作 - _buildActions(canEdit, isAdmin), + _buildActions(visit, isAdmin), ]; } - Widget _buildActions(bool canEdit, bool isAdmin) { - if (!canEdit && !isAdmin) { + Widget _buildActions(VisitInfo visit, bool isAdmin) { + final canSurvey = _canSurvey(visit); + final pending = visit.status == VisitStatus.pending; + + // 主行动入口对齐 Web `primaryAction` + ({String label, VoidCallback onTap})? primary; + if (pending) { + primary = (label: '执行跟进', onTap: _goExecute); + } else if (visit.status == VisitStatus.evidence) { + primary = (label: '审核现场取证', onTap: () => _showReviewSheet(visit)); + } else if (visit.status == VisitStatus.survey) { + primary = (label: '查看工程', onTap: () => _onViewProject(visit)); + } else if (canSurvey) { + primary = (label: '发起派单', onTap: () => _onSurvey(visit)); + } + + final secondary = []; + if (canSurvey && primary?.label != '发起派单') { + secondary.add(_secondaryButton('一键发起勘察派单', () => _onSurvey(visit))); + } + if (visit.status == VisitStatus.survey) { + secondary.add(_secondaryButton('报告归档', _onArchive)); + } + if (pending) { + secondary.add(_secondaryButton('取消拜访', _onCancelVisit, danger: true)); + } + if (pending && isAdmin) { + secondary.add(_secondaryButton('改派业务员', _onReassign, danger: true)); + } + + if (primary == null && secondary.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 24), child: Container( @@ -476,54 +567,51 @@ class _VisitDetailPageState extends ConsumerState { borderRadius: BorderRadius.circular(AppRadius.lg), ), child: const Text( - '当前状态下无需业务员操作,取证复核 / 立项 / 归档请在 Web 后台办理', + '当前拜访已完成全部流程节点,暂无待办操作', textAlign: TextAlign.center, style: TextStyle(fontSize: 13, color: AppColors.textMuted), ), ), ); } + return Padding( padding: const EdgeInsets.only(top: 24), child: Column( children: [ - if (canEdit) + if (primary != null) XPrimaryButton( - label: '开始执行并完成', - onPressed: _goExecute, + label: primary.label, + onPressed: primary.onTap, pill: false, height: 48, fontSize: 15, margin: const EdgeInsets.only(bottom: 12), ), - if (canEdit) - XPrimaryButton( - label: '取消拜访', - onPressed: _onCancelVisit, - pill: false, - height: 48, - fontSize: 15, - background: AppColors.surface, - foreground: AppColors.statusError, - borderColor: AppColors.borderRule, - margin: const EdgeInsets.only(bottom: 12), - ), - if (isAdmin) - XPrimaryButton( - label: '改派业务员', - onPressed: _onReassign, - pill: false, - height: 48, - fontSize: 15, - background: AppColors.surface, - foreground: AppColors.statusError, - borderColor: AppColors.borderRule, - ), + ...secondary, ], ), ); } + Widget _secondaryButton( + String label, + VoidCallback onPressed, { + bool danger = false, + }) { + return XPrimaryButton( + label: label, + onPressed: onPressed, + pill: false, + height: 48, + fontSize: 15, + background: AppColors.surface, + foreground: danger ? AppColors.statusError : AppColors.primary, + borderColor: AppColors.borderRule, + margin: const EdgeInsets.only(bottom: 12), + ); + } + Widget _sectionTitle(String icon, Color iconColor, String text) { return Padding( padding: const EdgeInsets.only(top: 22, bottom: 10), @@ -601,4 +689,293 @@ class _VisitDetailPageState extends ConsumerState { ), ); } +} + +/// 审核现场取证弹层:取证待技术复核(2) → 转立项勘察(3) / 通过并归档(4)。 +/// 对应 Web `openReview` / `handleReviewSubmit`。 +class _ReviewSheet extends StatefulWidget { + const _ReviewSheet({required this.resultRating, required this.projectNo}); + + final String resultRating; + final String projectNo; + + @override + State<_ReviewSheet> createState() => _ReviewSheetState(); +} + +class _ReviewSheetState extends State<_ReviewSheet> { + late final TextEditingController _rating = TextEditingController( + text: widget.resultRating, + ); + late final TextEditingController _projectNo = TextEditingController( + text: widget.projectNo, + ); + int _target = VisitStatus.survey; + + @override + void dispose() { + _rating.dispose(); + _projectNo.dispose(); + super.dispose(); + } + + void _submit() { + final payload = {'status': _target}; + final rating = _rating.text.trim(); + if (rating.isNotEmpty) payload['resultRating'] = rating; + if (_target == VisitStatus.survey) { + final no = _projectNo.text.trim(); + if (no.isNotEmpty) payload['projectNo'] = no; + } + Navigator.of(context).pop(payload); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppColors.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 12, 8), + child: Row( + children: [ + const XIcon('verified', size: 18, color: AppColors.secondary), + const SizedBox(width: 8), + const Expanded( + child: Text( + '审核现场取证', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w700, + color: AppColors.onSurface, + ), + ), + ), + GestureDetector( + onTap: () => Navigator.of(context).pop(), + behavior: HitTestBehavior.opaque, + child: const Padding( + padding: EdgeInsets.all(6), + child: XIcon('close', size: 18, color: AppColors.textMuted), + ), + ), + ], + ), + ), + const Divider(height: 1, thickness: 1, color: AppColors.borderRule), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _label('审核评价'), + const SizedBox(height: 8), + _inputField(_rating, '填写技术复核意见,如:现场参数完整,可转立项', maxLength: 64), + const SizedBox(height: 18), + _label('审核结论', required: true), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _targetPill( + value: VisitStatus.survey, + title: '转立项勘察', + desc: '推送至勘察派单流程', + ), + ), + const SizedBox(width: 10), + Expanded( + child: _targetPill( + value: VisitStatus.archived, + title: '通过并归档', + desc: '直接结束本次拜访', + ), + ), + ], + ), + if (_target == VisitStatus.survey) ...[ + const SizedBox(height: 18), + _label('关联工程编号'), + const SizedBox(height: 8), + _inputField( + _projectNo, + '如:PRJ-20261006-001(可留空)', + maxLength: 64, + mono: true, + ), + ], + ], + ), + ), + ), + const Divider(height: 1, thickness: 1, color: AppColors.borderRule), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Row( + children: [ + Expanded( + child: XPrimaryButton( + label: '取消', + onPressed: () => Navigator.of(context).pop(), + height: 46, + fontSize: 15, + background: AppColors.surface, + foreground: AppColors.primary, + borderColor: AppColors.borderRule, + ), + ), + const SizedBox(width: 12), + Expanded( + child: XPrimaryButton( + label: '提交审核', + onPressed: _submit, + height: 46, + fontSize: 15, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _label(String text, {bool required = false}) { + return Row( + children: [ + Text( + text, + style: AppTypography.labelBold.copyWith( + color: AppColors.onSurfaceVariant, + ), + ), + if (required) + Text( + ' *', + style: AppTypography.labelBold.copyWith(color: AppColors.statusError), + ), + ], + ); + } + + Widget _inputField( + TextEditingController controller, + String hint, { + int? maxLength, + bool mono = false, + }) { + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: AppColors.surfaceBright, + border: Border.all(color: AppColors.borderRule), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: controller, + maxLength: maxLength, + style: mono + ? AppTypography.dataMono.copyWith( + fontSize: 15, + color: AppColors.onSurface, + ) + : const TextStyle(fontSize: 15, color: AppColors.onSurface), + cursorColor: AppColors.primary, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + contentPadding: EdgeInsets.zero, + counterText: '', + hintText: hint, + hintStyle: const TextStyle( + fontSize: 15, + color: AppColors.textPlaceholder, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _targetPill({ + required int value, + required String title, + required String desc, + }) { + final active = _target == value; + return GestureDetector( + onTap: () => setState(() => _target = value), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: active ? AppColors.surfaceVariant : AppColors.surfaceBright, + border: Border.all( + color: active ? AppColors.primary : AppColors.borderRule, + width: active ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: active ? AppColors.primary : AppColors.onSurface, + ), + ), + ), + Text( + active ? '●' : '○', + style: TextStyle( + fontSize: 16, + height: 1, + color: active ? AppColors.primary : AppColors.outline, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + desc, + style: const TextStyle( + fontSize: 12, + height: 1.4, + color: AppColors.textMuted, + ), + ), + ], + ), + ), + ); + } } \ No newline at end of file diff --git a/lib/features/visit/presentation/visit_form_page.dart b/lib/features/visit/presentation/visit_form_page.dart index 7a3f5e6..c782cd9 100644 --- a/lib/features/visit/presentation/visit_form_page.dart +++ b/lib/features/visit/presentation/visit_form_page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../../core/network/api_response.dart'; +import '../../../core/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_typography.dart'; @@ -18,7 +21,12 @@ import '../../customer/data/user_api.dart'; import '../data/visit_api.dart'; import '../data/visit_models.dart'; -/// 拜访新建 / 编辑页,对应 `pages/visit/form.vue`。 +/// 拜访新建 / 编辑页,对齐 Web 端 `views/customer/visit/form.vue`。 +/// +/// 字段与提交语义与 Web 保持一致: +/// - 新增 + 暂存草稿 → 待执行(0) +/// - 新增 + 保存并提交 → 已完成待复核(1) +/// - 编辑 → 保持原状态 class VisitFormPage extends ConsumerStatefulWidget { const VisitFormPage({super.key, this.id}); @@ -29,14 +37,21 @@ class VisitFormPage extends ConsumerStatefulWidget { ConsumerState createState() => _VisitFormPageState(); } +/// 现场照片槽位:key 为对象存储 key(落库),url 为预签名访问地址(预览)。 +class _PhotoSlot { + String key = ''; + String url = ''; +} + class _VisitFormPageState extends ConsumerState { final TextEditingController _subject = TextEditingController(); - final TextEditingController _visitPerson = TextEditingController(); - final TextEditingController _visitPhone = TextEditingController(); + final TextEditingController _companion = TextEditingController(); + final TextEditingController _receptionPerson = TextEditingController(); + final TextEditingController _receptionTitle = TextEditingController(); + final TextEditingController _receptionPhone = TextEditingController(); final TextEditingController _visitContent = TextEditingController(); final List _typeLabels = visitTypeOptions.map((o) => o.label).toList(); - final List _modeLabels = visitModeOptions.map((o) => o.label).toList(); final List _priorityLabels = visitPriorityOptions.map((o) => o.label).toList(); @@ -45,36 +60,55 @@ class _VisitFormPageState extends ConsumerState { late final bool _isEdit; late final bool _isAdmin; + int _currentUserId = 0; + String _currentUserName = ''; int? _customerId; + int _mode = 1; int _typeIndex = 0; - int _modeIndex = 0; int _priorityIndex = 2; int _salesmanIndex = -1; - String _planTime = ''; - String _planEndTime = ''; + DateTime? _planStart; + DateTime? _planEnd; + DateTime? _nextVisitTime; + int? _intentLevel; + final Set _demands = {}; + String? _nextAction; + int _visitStatus = VisitStatus.pending; + final List<_PhotoSlot> _photos = [_PhotoSlot(), _PhotoSlot()]; bool _saving = false; + NavigatorState? _uploadingNavigator; + @override void initState() { super.initState(); _isEdit = widget.id != null; final user = ref.read(sessionProvider).user; _isAdmin = user?.isAdmin ?? false; - _planTime = todayStr(); - _visitPerson.text = user?.realName ?? ''; + _currentUserId = user?.id ?? 0; + _currentUserName = user?.realName ?? ''; + _planStart = _defaultPlanStart(); _init(); } @override void dispose() { _subject.dispose(); - _visitPerson.dispose(); - _visitPhone.dispose(); + _companion.dispose(); + _receptionPerson.dispose(); + _receptionTitle.dispose(); + _receptionPhone.dispose(); _visitContent.dispose(); super.dispose(); } + /// 默认拜访时间:下一个整点,结束时间 +1 小时(与 Web 编辑态兜底一致)。 + DateTime _defaultPlanStart() { + final now = DateTime.now(); + return DateTime(now.year, now.month, now.day, now.hour + 1); + } + Future _init() async { await _loadCustomers(); if (_isAdmin) await _loadSalesmen(); @@ -112,28 +146,49 @@ class _VisitFormPageState extends ConsumerState { setState(() { _customerId = detail.customerId; _subject.text = detail.subject ?? ''; + _mode = detail.visitMode ?? 1; _typeIndex = _indexOf( visitTypeOptions, detail.visitType ?? visitTypeOptions[0].value, ); - _modeIndex = _indexOf( - visitModeOptions, - detail.visitMode ?? visitModeOptions[0].value, - ); _priorityIndex = _indexOf( visitPriorityOptions, detail.priority ?? visitPriorityOptions[2].value, ); - _planTime = detail.planTime != null && detail.planTime!.isNotEmpty - ? _dateOnly(detail.planTime!) - : todayStr(); - _planEndTime = _dateOnly(detail.planEndTime ?? ''); - _visitPerson.text = detail.visitPerson; - _visitPhone.text = detail.visitPhone ?? ''; + _visitStatus = detail.status; + _planStart = parseDate(detail.planTime) ?? _planStart; + _planEnd = parseDate(detail.planEndTime); + _nextVisitTime = parseDate(detail.nextVisitTime); + _intentLevel = detail.intentLevel; + _demands + ..clear() + ..addAll( + (detail.demands ?? '') + .split(',') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty), + ); + final action = detail.nextAction; + _nextAction = action != null && visitNextActionOptions.contains(action) + ? action + : null; + _companion.text = detail.companion ?? ''; + _receptionPerson.text = detail.receptionPerson ?? ''; + _receptionTitle.text = detail.receptionTitle ?? ''; + _receptionPhone.text = detail.receptionPhone ?? ''; _visitContent.text = detail.visitContent ?? ''; final index = _salesmen.indexWhere((s) => s.id == detail.salesmanId); _salesmanIndex = index >= 0 ? index + 1 : -1; + // 回填现场照片:优先使用预签名 URL 预览,兼容仅返回 key 的情况。 + final keys = detail.photos; + final urls = detail.photoUrls; + for (var i = 0; i < _photos.length; i++) { + _photos[i] + ..key = i < keys.length ? keys[i] : '' + ..url = i < urls.length ? urls[i] : ''; + } + // 编辑时若客户不在可选范围内(如已终止),补充一条占位以正确回显 if (detail.customerId != 0 && !_customers.any((c) => c.id == detail.customerId)) { @@ -155,9 +210,6 @@ class _VisitFormPageState extends ConsumerState { return index < 0 ? 0 : index; } - String _dateOnly(String value) => - value.length >= 10 ? value.substring(0, 10) : value; - CustomerOption? get _selectedCustomer => _customers.where((c) => c.id == _customerId).firstOrNull; @@ -175,6 +227,35 @@ class _VisitFormPageState extends ConsumerState { ) .toList(); + /// 业务负责人(非管理员锁定为本人,管理员需显式选择)。 + int? get _resolvedSalesmanId { + if (!_isAdmin) return _currentUserId == 0 ? null : _currentUserId; + return _salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null; + } + + String get _salesmanName { + if (_isAdmin && _salesmanIndex > 0) { + final s = _salesmen[_salesmanIndex - 1]; + return s.realName.isNotEmpty ? s.realName : s.username; + } + return _currentUserName; + } + + String? get _salesmanPhone { + if (_isAdmin && _salesmanIndex > 0) return _salesmen[_salesmanIndex - 1].phone; + return null; + } + + String get _durationText { + final start = _planStart; + final end = _planEnd; + if (start == null || end == null) return ''; + final minutes = end.difference(start).inMinutes; + if (minutes <= 0) return ''; + final hours = minutes / 60; + return '${hours == hours.roundToDouble() ? hours.toInt() : hours.toStringAsFixed(1)}h'; + } + void _pickCustomer() { final labels = _customerLabels; if (labels.isEmpty) return; @@ -217,65 +298,279 @@ class _VisitFormPageState extends ConsumerState { if (index != null) onSelected(index); } - Future _pickDate({required bool isStart}) async { - final initial = parseDate(isStart ? _planTime : _planEndTime) ?? DateTime.now(); - final picked = await showDatePicker( + /// 日期 + 时间选择(对应 Web 的 show-time range / date picker)。 + Future _pickDateTime(DateTime? initial) async { + final base = initial ?? DateTime.now(); + final date = await showDatePicker( context: context, - initialDate: initial, + initialDate: base, firstDate: DateTime(2000), lastDate: DateTime(2100), ); + if (date == null || !mounted) return null; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(base), + ); + if (time == null) return null; + return DateTime(date.year, date.month, date.day, time.hour, time.minute); + } + + Future _pickPlan({required bool isStart}) async { + final picked = await _pickDateTime(isStart ? _planStart : _planEnd); if (picked == null) return; setState(() { if (isStart) { - _planTime = formatDate(picked); + // 开始时间晚于结束时间时,结束时间自动后移 1 小时 + if (_planEnd != null && !_planEnd!.isAfter(picked)) { + _planEnd = picked.add(const Duration(hours: 1)); + } + _planStart = picked; } else { - _planEndTime = formatDate(picked); + _planEnd = picked; } }); } - Future _onSave() async { + Future _pickNextVisit() async { + final base = _nextVisitTime ?? DateTime.now(); + final date = await showDatePicker( + context: context, + initialDate: base, + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + if (date == null) return; + setState(() => _nextVisitTime = date); + } + + void _applyTemplate() { + final current = _visitContent.text.trim(); + _visitContent.text = current.isEmpty + ? visitContentTemplate + : '${_visitContent.text}\n\n$visitContentTemplate'; + setState(() {}); + } + + void _toggleDemand(String demand) { + setState(() { + if (!_demands.remove(demand)) _demands.add(demand); + }); + } + + void _onPhotoTap(int index) { + final slot = _photos[index]; + if (slot.url.isNotEmpty) { + _previewPhoto(slot.url); + } else { + _pickPhoto(index); + } + } + + Future _pickPhoto(int index) async { + final source = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + '拍照', + style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface), + ), + onTap: () => Navigator.of(ctx).pop(ImageSource.camera), + ), + ListTile( + title: Text( + '从相册选择', + style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface), + ), + onTap: () => Navigator.of(ctx).pop(ImageSource.gallery), + ), + ], + ), + ), + ); + if (source == null || !mounted) return; + + final file = await ImagePicker().pickImage(source: source, imageQuality: 80); + if (file == null || !mounted) return; + + _showUploading(); + try { + final up = await ref.read(apiClientProvider).uploadImage(file.path); + if (mounted) { + setState(() { + _photos[index] + ..key = up.key + ..url = up.url; + }); + showAppToast('上传成功', success: true); + } + } catch (e) { + final message = e is ApiException && e.message.isNotEmpty + ? e.message + : '照片上传失败'; + showAppToast(message); + } finally { + _hideUploading(); + } + } + + void _removePhoto(int index) { + setState(() { + _photos[index] + ..key = '' + ..url = ''; + }); + } + + void _previewPhoto(String url) { + showDialog( + context: context, + barrierColor: Colors.black, + builder: (_) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.zero, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Center(child: Image.network(url, fit: BoxFit.contain)), + ), + ), + ); + } + + void _showUploading() { + _uploadingNavigator = Navigator.of(context, rootNavigator: true); + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => const _UploadingDialog(), + ); + } + + void _hideUploading() { + _uploadingNavigator?.pop(); + _uploadingNavigator = null; + } + + String? _validateError() { + if (_customerId == null) return '请选择关联客户'; + if (_planStart == null) return '请选择拜访时间'; + if (_resolvedSalesmanId == null) return '请选择业务负责人'; + if (_receptionPerson.text.trim().isEmpty) return '请填写客户接待人姓名'; + if (_receptionTitle.text.trim().isEmpty) return '请填写接待人职务'; + final phone = _receptionPhone.text.trim(); + if (phone.isNotEmpty && !RegExp(r'^1[3-9]\d{9}$').hasMatch(phone)) { + return '接待人联系方式格式不正确'; + } + if (_intentLevel == null) return '请选择客户合作意向评级'; + if (_visitContent.text.trim().isEmpty) return '请填写拜访沟通纪要与关键反馈'; + if (_visitContent.text.length > 2000) return '沟通纪要不超过 2000 字'; + if (_nextVisitTime == null) return '请选择预计下次回访时间'; + if (_nextAction == null) return '请选择下一步行动目标'; + return null; + } + + Map _buildPayload(int status) { + final payload = { + 'customerId': _customerId, + 'visitPerson': _salesmanName, + 'visitContent': _visitContent.text.trim(), + 'visitMode': _mode, + 'status': status, + }; + + final start = _planStart; + if (start != null) { + payload['planTime'] = start.toIso8601String(); + payload['visitTime'] = formatDate(start); + } + final end = _planEnd; + if (end != null) payload['planEndTime'] = end.toIso8601String(); + + final salesmanId = _resolvedSalesmanId; + if (salesmanId != null) payload['salesmanId'] = salesmanId; + + final visitPhone = _salesmanPhone; + if (visitPhone != null && visitPhone.isNotEmpty) { + payload['visitPhone'] = visitPhone; + } + if (_intentLevel != null) payload['intentLevel'] = _intentLevel; + + final demands = _demands.join(','); + if (demands.isNotEmpty) payload['demands'] = demands; + + final companion = _companion.text.trim(); + if (companion.isNotEmpty) payload['companion'] = companion; + final receptionPerson = _receptionPerson.text.trim(); + if (receptionPerson.isNotEmpty) { + payload['receptionPerson'] = receptionPerson; + } + final receptionTitle = _receptionTitle.text.trim(); + if (receptionTitle.isNotEmpty) payload['receptionTitle'] = receptionTitle; + final receptionPhone = _receptionPhone.text.trim(); + if (receptionPhone.isNotEmpty) payload['receptionPhone'] = receptionPhone; + + final nextVisit = _nextVisitTime; + if (nextVisit != null) payload['nextVisitTime'] = formatDate(nextVisit); + if (_nextAction != null) payload['nextAction'] = _nextAction; + + final photos = _photos + .where((p) => p.key.isNotEmpty) + .map((p) => p.key) + .toList(); + if (photos.isNotEmpty) payload['photos'] = photos; + + // App 端保留的补充字段:主题 / 拜访类型 / 优先级(Web 台账支持按此筛选) + final subject = _subject.text.trim(); + if (subject.isNotEmpty) payload['subject'] = subject; + payload['visitType'] = visitTypeOptions[_typeIndex].value; + payload['priority'] = visitPriorityOptions[_priorityIndex].value; + return payload; + } + + Future _onSave({required bool draft}) async { + if (_saving) return; if (_customerId == null) { - showAppToast('请选择关联客户'); + showAppToast(draft ? '暂存草稿至少需要选择关联客户' : '请选择关联客户'); return; } - if (_planTime.isEmpty) { - showAppToast('请选择计划开始日期'); - return; + if (!draft) { + final error = _validateError(); + if (error != null) { + showAppToast(error); + return; + } } + + // 草稿=待执行(0);正式提交=已完成待复核(1);编辑态保持原状态 + final status = _isEdit + ? _visitStatus + : (draft ? VisitStatus.pending : VisitStatus.done); + setState(() => _saving = true); try { - final payload = { - 'customerId': _customerId, - 'subject': _subject.text, - 'visitType': visitTypeOptions[_typeIndex].value, - 'visitMode': visitModeOptions[_modeIndex].value, - 'priority': visitPriorityOptions[_priorityIndex].value, - 'planTime': _planTime, - 'planEndTime': _planEndTime, - 'visitPerson': _visitPerson.text, - 'visitPhone': _visitPhone.text, - 'visitContent': _visitContent.text, - }; - final salesmanId = - _salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null; - if (_isAdmin && salesmanId != null) payload['salesmanId'] = salesmanId; - + final payload = _buildPayload(status); final api = ref.read(visitApiProvider); final res = _isEdit ? await api.update(widget.id!, payload) : await api.create(payload); if (!mounted) return; if (res.isOk) { - showAppToast('保存成功', success: true); + showAppToast(draft ? '已暂存草稿' : '拜访记录提交成功', success: true); await Future.delayed(const Duration(milliseconds: 500)); if (mounted) context.pop(); } else { showAppToast(res.message.isNotEmpty ? res.message : '保存失败'); } } catch (_) { - showAppToast('保存失败,请重试'); + if (mounted) showAppToast('保存失败,请重试'); } finally { if (mounted) setState(() => _saving = false); } @@ -283,100 +578,296 @@ class _VisitFormPageState extends ConsumerState { @override Widget build(BuildContext context) { - final items = [ - _formItem(label: '关联客户', isRequired: true, child: _customerPicker()), - _formItem( - label: '拜访主题', - child: _input(_subject, '例如:配电房增容方案沟通'), - ), - _formItem( - label: '拜访类型', - child: _optionPicker( - _typeLabels, - _typeIndex, - (i) => setState(() => _typeIndex = i), - ), - ), - _formItem( - label: '拜访方式', - child: _optionPicker( - _modeLabels, - _modeIndex, - (i) => setState(() => _modeIndex = i), - ), - ), - _formItem( - label: '优先级', - child: _optionPicker( - _priorityLabels, - _priorityIndex, - (i) => setState(() => _priorityIndex = i), - ), - ), - _formItem( - label: '计划开始', - isRequired: true, - child: _datePicker( - _planTime, - '请选择计划开始日期', - () => _pickDate(isStart: true), - ), - ), - _formItem( - label: '计划结束', - child: _datePicker(_planEndTime, '选填', () => _pickDate(isStart: false)), - ), - _formItem(label: '拜访人', child: _input(_visitPerson, '默认当前登录用户')), - _formItem( - label: '联系电话', - child: _input(_visitPhone, '选填', number: true), - ), - if (_isAdmin) _formItem(label: '负责业务员', child: _salesmanPicker()), - _formItem( - label: '计划说明', - child: _textarea(_visitContent, '本次拜访的目标与重点事项...'), - ), - ]; - - final children = []; - for (var i = 0; i < items.length; i++) { - if (i > 0) { - children.add( - const Divider(height: 1, thickness: 1, color: AppColors.borderRule), - ); - } - children.add(items[i]); - } - return XPageShell( topBar: XTopBar.nav( - title: _isEdit ? '编辑拜访计划' : '新建拜访计划', + title: _isEdit ? '编辑拜访记录' : '新增拜访记录', onBack: () => context.pop(), ), child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 60), + padding: const EdgeInsets.fromLTRB(16, 4, 16, 60), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - XCard( - padding: const EdgeInsets.fromLTRB(16, 6, 16, 16), - child: Column(children: children), - ), - XPrimaryButton( - label: '保存计划', - loading: _saving, - onPressed: _onSave, - margin: const EdgeInsets.fromLTRB(16, 24, 16, 40), + _sectionHeader('拜访基本信息', AppColors.primary), + _buildBasicCard(), + _sectionHeader('沟通内容与意向评估', AppColors.secondary), + _buildContentCard(), + _sectionHeader('后续跟进计划', AppColors.tertiary), + _buildNextCard(), + _sectionHeader( + '现场交流照片 / 拜访资料', + AppColors.primary, + hint: '选填,上限 2 张', ), + _buildPhotoCard(), + _buildActions(), ], ), ), ); } + Widget _sectionHeader(String text, Color color, {String? hint}) { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 20, 0, 10), + child: Row( + children: [ + Container( + width: 4, + height: 16, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Text( + text, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.onSurface, + ), + ), + if (hint != null) ...[ + const SizedBox(width: 6), + Text( + hint, + style: const TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + ], + ], + ), + ); + } + + Widget _buildBasicCard() { + final customer = _selectedCustomer; + return XCard( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Column( + children: [ + _formItem( + label: '关联客户', + isRequired: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _customerPicker(), + if (customer != null && customer.customerNo.isNotEmpty) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: _miniChip(customer.customerNo, mono: true), + ), + ], + ], + ), + ), + _divider(), + _formItem(label: '拜访主题', child: _input(_subject, '例如:配电房增容方案沟通')), + _divider(), + _formItem( + label: '拜访方式', + isRequired: true, + child: _modePills(), + ), + _divider(), + _formItem( + label: '拜访时间', + isRequired: true, + child: _planRangeField(), + ), + _divider(), + _formItem( + label: '拜访类型', + child: _optionPicker( + _typeLabels, + _typeIndex, + (i) => setState(() => _typeIndex = i), + ), + ), + _divider(), + _formItem( + label: '优先级', + child: _optionPicker( + _priorityLabels, + _priorityIndex, + (i) => setState(() => _priorityIndex = i), + ), + ), + _divider(), + _formItem( + label: '业务负责人及陪同人员', + isRequired: true, + child: Column( + children: [ + _salesmanField(), + const SizedBox(height: 8), + _input(_companion, '陪同人员,如:王助理 (商务陪同)'), + ], + ), + ), + _divider(), + _formItem( + label: '客户接待人及职务', + isRequired: true, + child: Row( + children: [ + Expanded( + flex: 34, + child: _input(_receptionPerson, '接待人姓名'), + ), + const SizedBox(width: 8), + Expanded( + child: _input(_receptionTitle, '职务,如:后勤保卫处负责人'), + ), + ], + ), + ), + _divider(), + _formItem( + label: '接待人联系方式', + child: _input( + _receptionPhone, + '11 位手机号', + number: true, + maxLength: 11, + ), + ), + ], + ), + ); + } + + Widget _buildContentCard() { + return XCard( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Column( + children: [ + _formItem( + label: '客户合作意向评级', + isRequired: true, + child: _intentCards(), + ), + _divider(), + _formItem( + label: '客户关注诉求与痛点', + hint: '可多选', + child: _demandChips(), + ), + _divider(), + _formItem( + label: '拜访沟通纪要与关键反馈', + isRequired: true, + action: _templateButton(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _textarea( + _visitContent, + '详细记录本次拜访交流核心内容、客户真实顾虑...', + maxLength: 2000, + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 6), + Align( + alignment: Alignment.centerRight, + child: Text( + '${_visitContent.text.length} / 2000', + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildNextCard() { + return XCard( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: Column( + children: [ + _formItem( + label: '预计下次回访时间', + isRequired: true, + child: _datePicker( + _nextVisitTime == null ? '' : formatDate(_nextVisitTime), + '选择回访日期', + _pickNextVisit, + ), + ), + _divider(), + _formItem( + label: '下一步行动目标', + isRequired: true, + child: _optionPicker( + visitNextActionOptions, + visitNextActionOptions.indexOf(_nextAction ?? ''), + (i) => setState(() => _nextAction = visitNextActionOptions[i]), + placeholder: '选择下一步行动目标', + ), + ), + ], + ), + ); + } + + Widget _buildPhotoCard() { + return XCard( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + for (var i = 0; i < _photos.length; i++) ...[ + if (i > 0) const SizedBox(width: 10), + Expanded(child: _photoSlot(i)), + ], + ], + ), + ); + } + + Widget _buildActions() { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 40), + child: Column( + children: [ + XPrimaryButton( + label: '保存并提交拜访记录', + loading: _saving, + onPressed: () => _onSave(draft: false), + margin: const EdgeInsets.only(bottom: 12), + ), + XPrimaryButton( + label: '暂存草稿', + loading: _saving, + onPressed: () => _onSave(draft: true), + background: AppColors.surface, + foreground: AppColors.primary, + borderColor: AppColors.borderRule, + fontSize: 15, + ), + ], + ), + ); + } + + // ---------- 字段组件 ---------- + + Widget _divider() => + const Divider(height: 1, thickness: 1, color: AppColors.borderRule); + Widget _formItem({ required String label, bool isRequired = false, + String? hint, + Widget? action, required Widget child, }) { return Padding( @@ -385,7 +876,6 @@ class _VisitFormPageState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - mainAxisSize: MainAxisSize.min, children: [ Text( label, @@ -400,6 +890,16 @@ class _VisitFormPageState extends ConsumerState { color: AppColors.statusError, ), ), + const Spacer(), + ?action, + if (hint != null) + Text( + hint, + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ), ], ), const SizedBox(height: 8), @@ -409,6 +909,342 @@ class _VisitFormPageState extends ConsumerState { ); } + Widget _templateButton() { + return GestureDetector( + onTap: _applyTemplate, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const XIcon('edit', size: 13, color: AppColors.primary), + const SizedBox(width: 4), + const Text( + '常用纪要模板', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + ), + ); + } + + Widget _miniChip(String text, {bool mono = false}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: AppColors.surfaceVariant, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + text, + style: mono + ? AppTypography.dataMono.copyWith( + fontSize: 12, + color: AppColors.primary, + ) + : const TextStyle(fontSize: 12, color: AppColors.onSurfaceVariant), + ), + ); + } + + Widget _modePills() { + return Row( + children: [ + for (var i = 0; i < visitModeOptions.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + Expanded(child: _modePill(visitModeOptions[i])), + ], + ], + ); + } + + Widget _modePill(ValueOption option) { + final active = _mode == option.value; + final color = active ? Colors.white : AppColors.onSurfaceVariant; + return GestureDetector( + onTap: () => setState(() => _mode = option.value), + child: Container( + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? AppColors.primary : AppColors.surfaceBright, + border: Border.all( + color: active ? AppColors.primary : AppColors.borderRule, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + XIcon(visitModeIcons[option.value] ?? 'location', size: 14, color: color), + const SizedBox(height: 2), + Text( + option.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11, + fontWeight: active ? FontWeight.w700 : FontWeight.w400, + color: color, + ), + ), + ], + ), + ), + ); + } + + Widget _planRangeField() { + final duration = _durationText; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: _datePicker( + _planStart == null ? '' : formatDateTime(_planStart), + '开始时间', + () => _pickPlan(isStart: true), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 6), + child: Text('~', style: TextStyle(color: AppColors.textMuted)), + ), + Expanded( + child: _datePicker( + _planEnd == null ? '' : formatDateTime(_planEnd), + '结束时间', + () => _pickPlan(isStart: false), + ), + ), + ], + ), + if (duration.isNotEmpty) ...[ + const SizedBox(height: 8), + _miniChip(duration), + ], + ], + ); + } + + Widget _intentCards() { + return Column( + children: [ + for (var i = 0; i < visitIntentCards.length; i++) ...[ + if (i > 0) const SizedBox(height: 10), + _intentCard(visitIntentCards[i]), + ], + ], + ); + } + + Widget _intentCard(IntentCard card) { + final active = _intentLevel == card.value; + return GestureDetector( + onTap: () => setState(() => _intentLevel = card.value), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: active ? AppColors.surfaceVariant : AppColors.surfaceBright, + border: Border.all( + color: active ? AppColors.primary : AppColors.borderRule, + width: active ? 2 : 1, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 24, + height: 24, + alignment: Alignment.center, + decoration: BoxDecoration( + color: active ? AppColors.primary : AppColors.surfaceVariant, + shape: BoxShape.circle, + ), + child: Text( + card.letter, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: active ? Colors.white : AppColors.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + card.title, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: active ? AppColors.primary : AppColors.onSurface, + ), + ), + ), + Text( + active ? '●' : '○', + style: TextStyle( + fontSize: 16, + height: 1, + color: active ? AppColors.primary : AppColors.outline, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + card.desc, + style: const TextStyle( + fontSize: 12, + height: 1.6, + color: AppColors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + Widget _demandChips() { + return Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final demand in visitDemandOptions) _demandChip(demand), + ], + ); + } + + Widget _demandChip(String demand) { + final active = _demands.contains(demand); + return GestureDetector( + onTap: () => _toggleDemand(demand), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), + decoration: BoxDecoration( + color: active ? AppColors.surfaceVariant : AppColors.surfaceBright, + border: Border.all( + color: active ? AppColors.primary : AppColors.borderRule, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + XIcon( + active ? 'check' : 'add', + size: 13, + color: active ? AppColors.primary : AppColors.textMuted, + ), + const SizedBox(width: 4), + Text( + demand, + style: TextStyle( + fontSize: 13, + fontWeight: active ? FontWeight.w700 : FontWeight.w400, + color: active ? AppColors.primary : AppColors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + + Widget _photoSlot(int index) { + final slot = _photos[index]; + final filled = slot.url.isNotEmpty || slot.key.isNotEmpty; + return GestureDetector( + onTap: () => _onPhotoTap(index), + child: Container( + height: 116, + decoration: BoxDecoration( + color: AppColors.surfaceBright, + border: Border.all(color: AppColors.borderRule), + borderRadius: BorderRadius.circular(12), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Stack( + fit: StackFit.expand, + children: [ + if (slot.url.isNotEmpty) + Image.network(slot.url, fit: BoxFit.cover) + else if (filled) + const Center( + child: XIcon('photo', size: 26, color: AppColors.outline), + ) + else + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const XIcon('camera', size: 26, color: AppColors.outline), + const SizedBox(height: 6), + Text( + '拜访资料 ${index + 1}', + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + ), + ], + ), + if (filled) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + height: 26, + padding: const EdgeInsets.symmetric(horizontal: 8), + color: const Color(0x8C0D1C2C), + child: Row( + children: [ + Expanded( + child: Text( + '资料 ${index + 1}', + style: const TextStyle( + fontSize: 11, + color: Colors.white, + ), + ), + ), + GestureDetector( + onTap: () => _removePhoto(index), + child: const XIcon( + 'delete', + size: 15, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + Widget _customerPicker() { final label = _customerLabel; final disabled = _isEdit || _customers.isEmpty; @@ -426,15 +1262,58 @@ class _VisitFormPageState extends ConsumerState { ); } + Widget _salesmanField() { + if (!_isAdmin) { + return Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: _fieldDecoration, + child: Row( + children: [ + const XIcon('person', size: 16, color: AppColors.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + _currentUserName.isNotEmpty ? _currentUserName : '未指定', + style: const TextStyle( + fontSize: 15, + color: AppColors.onSurface, + ), + ), + ), + const Text( + '(负责人)', + style: TextStyle(fontSize: 12, color: AppColors.textMuted), + ), + ], + ), + ); + } + final labels = ['请选择业务负责人', ..._salesmen.map((s) => s.optionLabel)]; + final index = _salesmanIndex < 0 ? 0 : _salesmanIndex; + return GestureDetector( + onTap: () => _pickIndex(labels, index, (i) { + setState(() => _salesmanIndex = i); + }), + child: _pickerBox( + labels[index], + index == 0, + const XIcon('forward', size: 16, color: AppColors.outline), + ), + ); + } + Widget _optionPicker( List labels, int index, - ValueChanged onSelected, - ) { + ValueChanged onSelected, { + String? placeholder, + }) { + final safeIndex = index < 0 || index >= labels.length ? 0 : index; return GestureDetector( - onTap: () => _pickIndex(labels, index, onSelected), + onTap: () => _pickIndex(labels, safeIndex, onSelected), child: _pickerBox( - labels[index], + placeholder != null && safeIndex == 0 ? placeholder : labels[safeIndex], false, const XIcon('forward', size: 16, color: AppColors.outline), ), @@ -452,22 +1331,6 @@ class _VisitFormPageState extends ConsumerState { ); } - Widget _salesmanPicker() { - final labels = ['默认本人', ..._salesmen.map((s) => s.optionLabel)]; - final index = _salesmanIndex; - final text = index >= 0 && index < labels.length ? labels[index] : '默认本人'; - return GestureDetector( - onTap: () => _pickIndex(labels, index < 0 ? 0 : index, (i) { - setState(() => _salesmanIndex = i); - }), - child: _pickerBox( - text, - index < 0, - const XIcon('forward', size: 16, color: AppColors.outline), - ), - ); - } - Widget _pickerBox(String text, bool placeholder, Widget? trailing) { return Container( height: 44, @@ -478,6 +1341,8 @@ class _VisitFormPageState extends ConsumerState { Expanded( child: Text( text, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 15, color: placeholder @@ -496,6 +1361,7 @@ class _VisitFormPageState extends ConsumerState { TextEditingController controller, String hint, { bool number = false, + int? maxLength, }) { return Container( height: 44, @@ -506,6 +1372,7 @@ class _VisitFormPageState extends ConsumerState { Expanded( child: TextField( controller: controller, + maxLength: maxLength, keyboardType: number ? TextInputType.number : TextInputType.text, style: const TextStyle(fontSize: 15, color: AppColors.onSurface), cursorColor: AppColors.primary, @@ -517,15 +1384,22 @@ class _VisitFormPageState extends ConsumerState { ); } - Widget _textarea(TextEditingController controller, String hint) { + Widget _textarea( + TextEditingController controller, + String hint, { + int? maxLength, + ValueChanged? onChanged, + }) { return Container( - height: 92, + height: 132, padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), decoration: _fieldDecoration, child: TextField( controller: controller, maxLines: null, expands: true, + maxLength: maxLength, + onChanged: onChanged, textAlignVertical: TextAlignVertical.top, style: const TextStyle(fontSize: 15, color: AppColors.onSurface), cursorColor: AppColors.primary, @@ -544,7 +1418,44 @@ class _VisitFormPageState extends ConsumerState { isDense: true, border: InputBorder.none, contentPadding: EdgeInsets.zero, + counterText: '', hintText: hint, hintStyle: const TextStyle(fontSize: 15, color: AppColors.textPlaceholder), ); +} + +/// `uni.showLoading({ title: '上传中...' })` 的等价实现。 +class _UploadingDialog extends StatelessWidget { + const _UploadingDialog(); + + @override + Widget build(BuildContext context) { + return const Dialog( + backgroundColor: Color(0xE60D1C2C), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(10)), + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + SizedBox(width: 10), + Text( + '上传中...', + style: TextStyle(fontSize: 14, color: Colors.white), + ), + ], + ), + ), + ); + } } \ No newline at end of file