feat: 新增勘察派单功能及拜访详情页操作

新增客户空间拓扑API、勘察明细模型,重构勘察接口,新增拜访详情页操作逻辑及新建勘察派单页面
This commit is contained in:
andyesfly committed 2026-10-17 10:10:02 +08:00
1 parent 7ad604ff8b
commit 83df770eaa
10 files changed
+2748 -489

No files matched your search

+7
View File
@@ -9,6 +9,7 @@ import '../features/customer/presentation/customer_detail_page.dart';
import '../features/customer/presentation/customer_edit_page.dart'; import '../features/customer/presentation/customer_edit_page.dart';
import '../features/notice/presentation/notice_center_page.dart'; import '../features/notice/presentation/notice_center_page.dart';
import '../features/project/presentation/project_detail_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_form_page.dart';
import '../features/survey/presentation/survey_list_page.dart'; import '../features/survey/presentation/survey_list_page.dart';
import '../features/visit/presentation/visit_batch_page.dart'; import '../features/visit/presentation/visit_batch_page.dart';
@@ -97,6 +98,12 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/survey', path: '/survey',
builder: (context, state) => const SurveyListPage(), builder: (context, state) => const SurveyListPage(),
), ),
GoRoute(
path: '/survey/create',
builder: (context, state) => SurveyCreatePage(
customerId: int.tryParse(state.uri.queryParameters['customerId'] ?? ''),
),
),
GoRoute( GoRoute(
path: '/survey/form', path: '/survey/form',
builder: (context, state) => SurveyFormPage( builder: (context, state) => SurveyFormPage(
@@ -74,4 +74,12 @@ class CustomerApi {
.map((e) => CustomerOption.fromJson((e as Map).cast<String, dynamic>())) .map((e) => CustomerOption.fromJson((e as Map).cast<String, dynamic>()))
.toList(), .toList(),
); );
/// 客户空间拓扑:一次返回 建筑 → 楼层 → 房间(勘察明细取建筑/楼层/房间用)。
Future<ApiResponse<CustomerTopology>> topology(int customerId) =>
_client.get<CustomerTopology>(
'/customer-circuits/topology',
query: {'customerId': customerId},
parse: (d) => CustomerTopology.fromJson((d as Map).cast<String, dynamic>()),
);
} }
@@ -141,3 +141,85 @@ Color customerStatusBarColor(int status) => switch (status) {
4 => AppColors.statusError, 4 => AppColors.statusError,
_ => AppColors.borderRule, _ => AppColors.borderRule,
}; };
/// 客户空间拓扑(`/api/customer-circuits/topology`):建筑 → 楼层 → 房间。
class CustomerTopology {
const CustomerTopology({required this.buildings});
final List<CustomerTopologyBuilding> buildings;
factory CustomerTopology.fromJson(Map<String, dynamic> json) => CustomerTopology(
buildings: (json['buildings'] as List? ?? [])
.map((e) => CustomerTopologyBuilding.fromJson((e as Map).cast<String, dynamic>()))
.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<CustomerTopologyFloor> floors;
factory CustomerTopologyBuilding.fromJson(Map<String, dynamic> 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<String, dynamic>()))
.toList(),
);
}
class CustomerTopologyFloor {
const CustomerTopologyFloor({required this.floorNo, required this.rooms});
/// 楼层:正数为地上、负数为地下,如 `"1"` / `"-1"`。
final String floorNo;
final List<CustomerTopologyRoom> rooms;
factory CustomerTopologyFloor.fromJson(Map<String, dynamic> json) =>
CustomerTopologyFloor(
floorNo: json['floorNo'] == null ? '' : json['floorNo'].toString(),
rooms: (json['rooms'] as List? ?? [])
.map((e) => CustomerTopologyRoom.fromJson((e as Map).cast<String, dynamic>()))
.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<String, dynamic> json) =>
CustomerTopologyRoom(
id: toIntOr(json['id'], 0),
roomName: json['roomName'] as String? ?? '',
buildingId: toIntOr(json['buildingId'], 0),
floorNo: json['floorNo'] == null ? '' : json['floorNo'].toString(),
);
}
+18 -24
View File
@@ -9,23 +9,6 @@ final surveyApiProvider = Provider<SurveyApi>(
(ref) => SurveyApi(ref.watch(apiClientProvider)), (ref) => SurveyApi(ref.watch(apiClientProvider)),
); );
/// SOP 回执增量保存结果。
class SopSaveResult {
const SopSaveResult({required this.updated, required this.skipped, required this.sopResults});
final int updated;
final List<int> skipped;
final List<SurveySopResult> sopResults;
factory SopSaveResult.fromJson(Map<String, dynamic> 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<String, dynamic>()))
.toList(),
);
}
/// 勘察工单接口,对应 uni-app 版 `api/survey.ts`。 /// 勘察工单接口,对应 uni-app 版 `api/survey.ts`。
class SurveyApi { class SurveyApi {
SurveyApi(this._client); SurveyApi(this._client);
@@ -77,6 +60,14 @@ class SurveyApi {
parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()), parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()),
); );
/// 新建勘察派单:workerId 可省略,后端默认落「待指派(0)」。
Future<ApiResponse<SurveyItem>> create(Map<String, dynamic> body) =>
_client.post<SurveyItem>(
'/survey-orders',
body: body,
parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<SurveyItem>> update(int id, Map<String, dynamic> body) => Future<ApiResponse<SurveyItem>> update(int id, Map<String, dynamic> body) =>
_client.put<SurveyItem>( _client.put<SurveyItem>(
'/survey-orders/$id', '/survey-orders/$id',
@@ -84,12 +75,15 @@ class SurveyApi {
parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()), parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()),
); );
/// 按 SOP 检查项增量保存现场回执; /// 全量替换工单勘察明细(新增/编辑/删除统一走此接口,返回最新明细)。
/// 仅现场勘察中(status=1)且工单对当前用户可见时后端才放行。 Future<ApiResponse<List<SurveyOrderDetail>>> saveDetails(
Future<ApiResponse<SopSaveResult>> saveSopResults(int id, List<SopResultPayload> results) => int id,
_client.put<SopSaveResult>( List<SurveyOrderDetail> details,
'/survey-orders/$id/sop-results', ) => _client.put<List<SurveyOrderDetail>>(
body: {'results': results.map((e) => e.toJson()).toList()}, '/survey-orders/$id/details',
parse: (d) => SopSaveResult.fromJson((d as Map).cast<String, dynamic>()), body: {'details': details.map((e) => e.toJson()).toList()},
parse: (d) => (d as List? ?? [])
.map((e) => SurveyOrderDetail.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
); );
} }
+51 -58
View File
@@ -94,69 +94,62 @@ class SurveyWorker {
); );
} }
class SurveySopResult { /// 勘察明细:一条 = 客户空间拓扑中的 建筑 + 楼层 + 房间(选填) + 现场照片 + 备注。
const SurveySopResult({ class SurveyOrderDetail {
required this.id, const SurveyOrderDetail({
required this.surveyOrderId, this.id,
required this.sopItemId, this.surveyOrderId,
required this.checked, this.buildingId,
required this.itemName, this.buildingName = '',
this.itemDesc = '', this.floorNo = '',
this.receiptType = '', this.roomId,
this.receiptRequire = '', this.roomName = '',
this.required = false, this.photos = const [],
this.receiptValue = const [], this.photoUrls = const [],
this.receiptUrls = const [],
this.remark = '', this.remark = '',
}); });
final int id; final int? id;
final int surveyOrderId; final int? surveyOrderId;
final int sopItemId; final int? buildingId;
final bool checked;
final String itemName;
final String itemDesc;
final String receiptType;
final String receiptRequire;
final bool required;
/// 回执内容:photo/meter 类为照片对象 key 数组,form 类为单元素文本值数组。 /// 建筑名称(冗余存储,客户拓扑变更后仍可读)。
final List<String> receiptValue; final String buildingName;
/// 详情接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)。 /// 楼层:正数为地上、负数为地下,如 `"1"` / `"-1"`。
final List<String> receiptUrls; final String floorNo;
final int? roomId;
final String roomName;
/// 现场照片对象 key。
final List<String> photos;
/// 详情接口附带:照片预签名访问 URL(与 photos 顺序一致)。
final List<String> photoUrls;
final String remark; final String remark;
factory SurveySopResult.fromJson(Map<String, dynamic> json) => SurveySopResult( factory SurveyOrderDetail.fromJson(Map<String, dynamic> json) => SurveyOrderDetail(
id: toIntOr(json['id'], 0), id: toIntOrNull(json['id']),
surveyOrderId: toIntOr(json['surveyOrderId'], 0), surveyOrderId: toIntOrNull(json['surveyOrderId']),
sopItemId: toIntOr(json['sopItemId'], 0), buildingId: toIntOrNull(json['buildingId']),
checked: json['checked'] == true, buildingName: json['buildingName'] as String? ?? '',
itemName: json['itemName'] as String? ?? '', floorNo: json['floorNo'] == null ? '' : json['floorNo'].toString(),
itemDesc: json['itemDesc'] as String? ?? '', roomId: toIntOrNull(json['roomId']),
receiptType: json['receiptType'] as String? ?? '', roomName: json['roomName'] as String? ?? '',
receiptRequire: json['receiptRequire'] as String? ?? '', photos: (json['photos'] as List? ?? []).map((e) => e.toString()).toList(),
required: json['required'] == true, photoUrls: (json['photoUrls'] as List? ?? []).map((e) => e.toString()).toList(),
receiptValue: (json['receiptValue'] as List? ?? []).map((e) => e.toString()).toList(),
receiptUrls: (json['receiptUrls'] as List? ?? []).map((e) => e.toString()).toList(),
remark: json['remark'] as String? ?? '', 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<String>? receiptValue;
final String? remark;
/// 提交体:只带定位要素与照片/备注,行 id 由后端全量重建。
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
'sopItemId': sopItemId, 'buildingId': buildingId,
if (checked != null) 'checked': checked, 'buildingName': buildingName,
if (receiptValue != null) 'receiptValue': receiptValue, 'floorNo': floorNo,
if (remark != null) 'remark': remark, 'roomId': roomId,
'roomName': roomName,
'photos': photos,
'remark': remark,
}; };
} }
@@ -204,7 +197,7 @@ class SurveyItem {
this.reviewedAt, this.reviewedAt,
this.customer, this.customer,
this.worker, this.worker,
this.sopResults, this.details,
}); });
final int id; final int id;
@@ -251,8 +244,8 @@ class SurveyItem {
final String salesmanName; final String salesmanName;
final String salesmanPhone; final String salesmanPhone;
// 仅详情接口返回 // 仅详情接口返回:勘察明细(建筑/楼层/房间 + 现场照片)
final List<SurveySopResult>? sopResults; final List<SurveyOrderDetail>? details;
factory SurveyItem.fromJson(Map<String, dynamic> json) => SurveyItem( factory SurveyItem.fromJson(Map<String, dynamic> json) => SurveyItem(
id: toIntOr(json['id'], 0), id: toIntOr(json['id'], 0),
@@ -300,10 +293,10 @@ class SurveyItem {
workerName: json['workerName'] as String? ?? '', workerName: json['workerName'] as String? ?? '',
salesmanName: json['salesmanName'] as String? ?? '', salesmanName: json['salesmanName'] as String? ?? '',
salesmanPhone: json['salesmanPhone'] as String? ?? '', salesmanPhone: json['salesmanPhone'] as String? ?? '',
sopResults: json['sopResults'] == null details: json['details'] == null
? null ? null
: (json['sopResults'] as List) : (json['details'] as List)
.map((e) => SurveySopResult.fromJson((e as Map).cast<String, dynamic>())) .map((e) => SurveyOrderDetail.fromJson((e as Map).cast<String, dynamic>()))
.toList(), .toList(),
); );
@@ -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<SurveyCreatePage> 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<SurveyCreatePage> {
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<Customer> _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<void> _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<void> _pickIndex(
List<String> labels,
int current,
ValueChanged<int> onSelected,
) async {
final index = await showModalBottomSheet<int>(
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<void> _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<String, dynamic> _buildPayload(int isDraft) {
final payload = <String, dynamic>{
'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<void> _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<String> labels,
int index,
ValueChanged<int> 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),
);
}
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; 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_common.dart';
import '../../../shared/widgets/x_icon.dart'; import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.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_api.dart';
import '../data/survey_models.dart'; import '../data/survey_models.dart';
const _monoFallback = <String>['JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas']; const _monoFallback = <String>['JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas'];
/// 现场勘测(勘察单编辑 + SOP 逐项回执),对应 `pages/survey/form.vue`。 /// 现场勘测(勘察单编辑 + 勘察明细),对应 `pages/survey/form.vue`。
class SurveyFormPage extends ConsumerStatefulWidget { class SurveyFormPage extends ConsumerStatefulWidget {
const SurveyFormPage({super.key, this.id, this.customerId}); const SurveyFormPage({super.key, this.id, this.customerId});
@@ -49,23 +50,20 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
SurveyItem? _survey; SurveyItem? _survey;
int _surveyId = 0; int _surveyId = 0;
bool _submitting = false; bool _submitting = false;
bool _savingDetails = false;
String _busyText = ''; String _busyText = '';
List<_SopRow> _sopRows = const []; List<_DetailRow> _detailRows = const [];
List<CustomerTopologyBuilding> _buildings = const [];
final List<List<Offset>> _strokes = []; final List<List<Offset>> _strokes = [];
List<Offset>? _drawing; List<Offset>? _drawing;
bool get _busy => _busyText.isNotEmpty; bool get _busy => _busyText.isNotEmpty;
/// 回执仅「现场勘察中」可录入,其余状态只读展示(与 Web 端 isReadonly 判定一致)。 /// 明细仅「待指派 / 现场勘察中」可维护,其余状态只读(与后端 saveDetails 校验一致)。
bool get _canEditReceipt => _survey?.status == SurveyStatus.surveying; bool get _canEditDetails =>
_survey?.status == SurveyStatus.pendingAssign ||
int get _sopDoneCount => _sopRows.where((r) => r.checked).length; _survey?.status == SurveyStatus.surveying;
List<String> get _sopMissingRequired => _sopRows
.where((r) => r.required && !r.checked)
.map((r) => r.itemName)
.toList();
bool get _hasSignature => _strokes.isNotEmpty || _drawing != null; bool get _hasSignature => _strokes.isNotEmpty || _drawing != null;
@@ -87,7 +85,7 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
_mainPanelLocation.dispose(); _mainPanelLocation.dispose();
_loadCapacity.dispose(); _loadCapacity.dispose();
_observation.dispose(); _observation.dispose();
for (final row in _sopRows) { for (final row in _detailRows) {
row.dispose(); row.dispose();
} }
super.dispose(); super.dispose();
@@ -103,10 +101,11 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
final data = res.data!; final data = res.data!;
setState(() { setState(() {
_survey = data; _survey = data;
_sopRows = (data.sopResults ?? const <SurveySopResult>[]) _detailRows = (data.details ?? const <SurveyOrderDetail>[])
.map(_buildSopRow) .map(_buildDetailRow)
.toList(); .toList();
}); });
_loadTopology(data.customerId);
} else { } else {
showAppToast(res.message.isEmpty ? '加载勘察单失败' : res.message); showAppToast(res.message.isEmpty ? '加载勘察单失败' : res.message);
} }
@@ -115,70 +114,209 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
} }
} }
_SopRow _buildSopRow(SurveySopResult result) { _DetailRow _buildDetailRow(SurveyOrderDetail detail) => _DetailRow(
final keys = List<String>.from(result.receiptValue); buildingId: detail.buildingId,
final row = _SopRow( buildingName: detail.buildingName,
sopItemId: result.sopItemId, floorNo: detail.floorNo,
itemName: result.itemName.isEmpty ? '检查项 #${result.sopItemId}' : result.itemName, roomId: detail.roomId,
itemDesc: result.itemDesc, roomName: detail.roomName,
receiptType: result.receiptType.isEmpty ? 'photo' : result.receiptType, keys: detail.photos,
receiptRequire: result.receiptRequire, urls: detail.photoUrls,
required: result.required, remark: detail.remark,
checked: result.checked,
keys: keys,
urls: List<String>.from(result.receiptUrls),
text: result.receiptType == 'form' ? (keys.isEmpty ? '' : keys.first) : '',
remark: result.remark,
); );
row.snap = _rowSnapshot(row);
return row;
}
List<String> _rowReceiptValue(_SopRow row) { /// 客户空间拓扑:一次拉取建筑/楼层/房间,供明细下拉使用。
if (row.receiptType != 'form') return row.keys; Future<void> _loadTopology(int customerId) async {
final text = row.textController.text; if (customerId <= 0) return;
return text.isEmpty ? const [] : [text];
}
String _rowSnapshot(_SopRow row) =>
jsonEncode({'v': _rowReceiptValue(row), 'r': row.remarkController.text});
/// 增量保存单项回执:只提交当前项,未传项后端保持原值。
Future<void> _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);
try { try {
final res = await ref.read(surveyApiProvider).saveSopResults(_surveyId, [ final res = await ref.read(customerApiProvider).topology(customerId);
SopResultPayload(
sopItemId: row.sopItemId,
checked: checked,
receiptValue: receiptValue,
remark: row.remarkController.text,
),
]);
if (!mounted) return; if (!mounted) return;
if (res.isOk) { if (res.isOk && res.data != null) {
setState(() { setState(() => _buildings = res.data!.buildings);
row.snap = snap;
row.checked = checked;
});
} else {
showAppToast(res.message.isEmpty ? '回执保存失败' : res.message);
} }
} catch (e) { } catch (_) {
if (mounted) showAppToast(e is ApiException ? e.message : '回执保存失败'); // 拓扑加载失败时建筑下拉为空,仍可只读展示既有明细
} finally {
if (mounted) setState(() => row.saving = false);
} }
} }
/// 选中即上传即保存,电工中途退出不丢已传内容。 /// 楼层选项:地上 1..floorCount,地下 负1..负floorDownCount。
Future<void> _addSopPhotos(_SopRow row) async { List<({String value, String label})> _floorOptions(int? buildingId) {
if (!_canEditReceipt) return; 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<CustomerTopologyRoom> _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<void> _pickSheet(
List<String> labels,
int current,
ValueChanged<int> onSelected,
) async {
final index = await showModalBottomSheet<int>(
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<SurveyOrderDetail> _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<String>.of(row.keys),
remark: row.remarkController.text.trim(),
),
];
/// 全量替换保存勘察明细,返回是否成功。
Future<bool> _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<void> _addDetailPhotos(_DetailRow row) async {
if (!_canEditDetails) return;
final picked = await ImagePicker().pickMultiImage(); final picked = await ImagePicker().pickMultiImage();
if (picked.isEmpty || !mounted) return; if (picked.isEmpty || !mounted) return;
setState(() => _busyText = '上传中...'); setState(() => _busyText = '上传中...');
@@ -195,20 +333,18 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
} }
if (!mounted) return; if (!mounted) return;
setState(() => _busyText = ''); setState(() => _busyText = '');
if (row.keys.isNotEmpty) await _saveSopItem(row);
if (failed > 0) showAppToast('$failed 张上传失败'); if (failed > 0) showAppToast('$failed 张上传失败');
} }
void _removeSopPhoto(_SopRow row, int index) { void _removeDetailPhoto(_DetailRow row, int index) {
if (!_canEditReceipt) return; if (!_canEditDetails) return;
setState(() { setState(() {
if (index < row.keys.length) row.keys.removeAt(index); if (index < row.keys.length) row.keys.removeAt(index);
if (index < row.urls.length) row.urls.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; if (row.urls.isEmpty || index >= row.urls.length) return;
final url = row.urls[index]; final url = row.urls[index];
showDialog<void>( showDialog<void>(
@@ -327,6 +463,23 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
showAppToast('无内容可提交'); showAppToast('无内容可提交');
return; 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); final res = await ref.read(surveyApiProvider).update(_surveyId, payload);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -367,7 +520,7 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
if (survey != null) _buildTaskCard(survey), if (survey != null) _buildTaskCard(survey),
_buildTechSection(), _buildTechSection(),
_buildPhotoSection(), _buildPhotoSection(),
if (_sopRows.isNotEmpty) _buildSopSection(), if (survey != null) _buildDetailSection(),
_buildSignSection(), _buildSignSection(),
_buildSubmitButton(), _buildSubmitButton(),
], ],
@@ -756,7 +909,7 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
); );
} }
Widget _buildSopSection() { Widget _buildDetailSection() {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.only(bottom: 24),
child: Column( child: Column(
@@ -765,141 +918,206 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_buildSectionTitle('edit', AppColors.secondary, '勘察回执标准'), _buildSectionTitle('domain', AppColors.secondary, '勘察明细'),
Text( if (_canEditDetails)
'$_sopDoneCount/${_sopRows.length} 项已回执', Row(
style: const TextStyle(fontSize: 12, color: AppColors.textMuted), mainAxisSize: MainAxisSize.min,
),
],
),
if (_canEditReceipt && _sopMissingRequired.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [ children: [
const XIcon('schedule', size: 14, color: AppColors.statusWarn), Text(
const SizedBox(width: 6), _savingDetails ? '保存中...' : '共 ${_detailRows.length} 条',
Expanded(
child: Text(
'还有 ${_sopMissingRequired.length} 项必填回执未完成:'
'${_sopMissingRequired.join('、')}',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.textMuted, 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) { Widget _buildDetailCard(int index, _DetailRow row) {
final isForm = row.receiptType == 'form'; 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( return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
margin: const EdgeInsets.only(top: 12), margin: const EdgeInsets.only(top: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Text(
child: Text( '明细 ${index + 1}',
row.itemName,
style: AppTypography.bodyMd.copyWith( style: AppTypography.bodyMd.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.onSurface, color: AppColors.onSurface,
), ),
), ),
), const Spacer(),
const SizedBox(width: 8), if (editable)
Row( GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _removeDetailRow(index),
child: const Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
if (row.required) XIcon('delete', size: 16, color: AppColors.statusError),
_buildSopTag('必填', const Color(0xFFC0392B), const Color(0x1AC0392B)), SizedBox(width: 4),
if (row.required && row.checked) const SizedBox(width: 6), Text(
if (row.checked) '删除',
_buildSopTag( style: TextStyle(fontSize: 13, color: AppColors.statusError),
'已回执',
AppColors.tertiaryContainer,
const Color(0x1A008378),
), ),
], ],
), ),
),
], ],
), ),
if (row.itemDesc.isNotEmpty) const SizedBox(height: 4),
_buildField(
label: '建筑',
input: _buildDetailPicker(
buildingLabel,
placeholder: row.buildingId == null,
enabled: editable,
onTap: () => _pickBuilding(row),
),
),
_buildField(
label: '楼层',
divider: true,
input: _buildDetailPicker(
floorLabel,
placeholder: row.floorNo.isEmpty,
enabled: editable,
onTap: () => _pickFloor(row),
),
),
_buildField(
label: '房间(选填)',
divider: true,
input: _buildDetailPicker(
roomLabel,
placeholder: row.roomId == null,
enabled: editable,
onTap: () => _pickRoom(row),
),
),
Padding( Padding(
padding: const EdgeInsets.only(top: 6), padding: const EdgeInsets.only(top: 13),
child: Text( child: Column(
row.itemDesc, crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle( children: [
fontSize: 13, Text(
'现场照片',
style: AppTypography.labelBold.copyWith(
color: AppColors.onSurfaceVariant, color: AppColors.onSurfaceVariant,
), ),
), ),
), const SizedBox(height: 8),
Padding( if (row.urls.isEmpty && !editable)
padding: const EdgeInsets.only(top: 10), const Text(
child: Container( '无照片',
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), style: TextStyle(fontSize: 12, color: AppColors.textMuted),
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),
),
],
),
),
),
if (isForm)
Padding(
padding: const EdgeInsets.only(top: 10),
child: _buildTextarea(
controller: row.textController,
hint: '按清单格式填写回执内容',
enabled: _canEditReceipt,
onEditDone: () => _saveSopItem(row),
),
) )
else else
Padding( Wrap(
padding: const EdgeInsets.only(top: 10),
child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
for (var i = 0; i < row.urls.length; i++) for (var i = 0; i < row.urls.length; i++)
_buildSopPhoto(row, i), _buildDetailPhoto(row, i),
if (_canEditReceipt) _buildSopPhotoAdd(row), if (editable) _buildDetailPhotoAdd(row),
],
),
], ],
), ),
), ),
if (_canEditReceipt) if (editable)
Padding( Padding(
padding: const EdgeInsets.only(top: 10), padding: const EdgeInsets.only(top: 13),
child: _buildInput( child: _buildInput(
controller: row.remarkController, controller: row.remarkController,
hint: '备注(选填)', hint: '备注(选填)',
height: 40, height: 40,
onEditDone: () => _saveSopItem(row),
), ),
) )
else if (row.remarkController.text.isNotEmpty) else if (row.remarkController.text.isNotEmpty)
@@ -910,31 +1128,51 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
style: const TextStyle(fontSize: 12, color: AppColors.textMuted), 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) { Widget _buildDetailPicker(
return Container( String text, {
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), 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( decoration: BoxDecoration(
color: background, color: enabled ? AppColors.surfaceBright : AppColors.surface,
borderRadius: BorderRadius.circular(4), 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( return SizedBox(
width: 84, width: 84,
height: 84, height: 84,
@@ -942,19 +1180,19 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
children: [ children: [
Positioned.fill( Positioned.fill(
child: GestureDetector( child: GestureDetector(
onTap: () => _previewSopPhotos(row, index), onTap: () => _previewDetailPhotos(row, index),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
child: _buildImage(row.urls[index]), child: _buildImage(row.urls[index]),
), ),
), ),
), ),
if (_canEditReceipt) if (_canEditDetails)
Positioned( Positioned(
right: 2, right: 2,
top: 2, top: 2,
child: GestureDetector( child: GestureDetector(
onTap: () => _removeSopPhoto(row, index), onTap: () => _removeDetailPhoto(row, index),
child: Container( child: Container(
width: 18, width: 18,
height: 18, height: 18,
@@ -972,9 +1210,9 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
); );
} }
Widget _buildSopPhotoAdd(_SopRow row) { Widget _buildDetailPhotoAdd(_DetailRow row) {
return GestureDetector( return GestureDetector(
onTap: () => _addSopPhotos(row), onTap: () => _addDetailPhotos(row),
child: Container( child: Container(
width: 84, width: 84,
height: 84, height: 84,
@@ -1182,16 +1420,6 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
), ),
); );
} }
String _receiptTypeLabel(String type) {
if (type.isEmpty) return '—';
return const {
'photo': '现场照片',
'form': '清单格式',
'meter': '手持测仪',
}[type] ??
type;
}
} }
/// 工单级照片槽位(标签 + 本地临时路径)。 /// 工单级照片槽位(标签 + 本地临时路径)。
@@ -1202,50 +1430,35 @@ class _PhotoSlot {
String? src; String? src;
} }
/// SOP 逐项回执行。 /// 勘察明细编辑行。
class _SopRow { class _DetailRow {
_SopRow({ _DetailRow({
required this.sopItemId, this.buildingId,
required this.itemName, this.buildingName = '',
required this.itemDesc, this.floorNo = '',
required this.receiptType, this.roomId,
required this.receiptRequire, this.roomName = '',
required this.required, List<String> keys = const [],
required this.checked, List<String> urls = const [],
required this.keys,
required this.urls,
String text = '',
String remark = '', String remark = '',
}) : textController = TextEditingController(text: text), }) : keys = List<String>.of(keys),
urls = List<String>.of(urls),
remarkController = TextEditingController(text: remark); remarkController = TextEditingController(text: remark);
final int sopItemId; int? buildingId;
final String itemName; String buildingName;
final String itemDesc; String floorNo;
final String receiptType; int? roomId;
final String receiptRequire; String roomName;
final bool required;
bool checked;
/// 已上传照片的对象 key(form 类不用) /// 已上传照片的对象 key。
final List<String> keys; final List<String> keys;
/// 与 keys 顺序一致的预览地址(后端预签名 URL 或刚上传的临时地址) /// 与 keys 顺序一致的预览地址(后端预签名 URL 或刚上传的临时地址)。
final List<String> urls; final List<String> urls;
/// form 类的表单文本值,photo/meter 类为备注
final TextEditingController textController;
final TextEditingController remarkController; final TextEditingController remarkController;
bool saving = false; void dispose() => remarkController.dispose();
/// 最近一次保存成功的快照,用于避免 blur 触发的无变化请求
String snap = '';
void dispose() {
textController.dispose();
remarkController.dispose();
}
} }
/// 手写签名笔迹绘制,对应源页面 canvas 的 `#0054cd` / 3px / round 笔触。 /// 手写签名笔迹绘制,对应源页面 canvas 的 `#0054cd` / 3px / round 笔触。
+61
View File
@@ -37,6 +37,67 @@ const visitIntentOptions = <ValueOption>[
(value: 3, label: 'C级 · 低意向'), (value: 3, label: 'C级 · 低意向'),
]; ];
/// 拜访方式图标,对应 Web 表单 mode-pill 的图标映射。
const visitModeIcons = <int, String>{
1: 'location',
2: 'groups',
3: 'call',
4: 'camera',
};
/// 意向评级卡片(Web 表单 INTENT_CARDS 的等价常量)。
typedef IntentCard = ({int value, String letter, String title, String desc});
const visitIntentCards = <IntentCard>[
(
value: 1,
letter: 'A',
title: '高意向 · 近期推进',
desc: '客户需求迫切,有明确预算意向或近期采购计划,拟安排方案深化对接。',
),
(
value: 2,
letter: 'B',
title: '中意向 · 方案比选',
desc: '认可智电升级方向,处于多家厂商对比及内部论证期,需递交产品方案。',
),
(
value: 3,
letter: 'C',
title: '低意向 · 储备客情',
desc: '日常商务拜访维系,短期内暂无实质性预算规划,列入潜在客户储备库。',
),
];
/// 客户关注诉求与痛点(多选)。
const visitDemandOptions = <String>[
'用电安全监管与隐患排查',
'节能降耗与能耗考核',
'配电室托管代运维',
'能耗可视化数字大屏',
'成本预算控制与分期投入',
'政府节能绿色补贴申报',
];
/// 下一步行动目标(单选)。
const visitNextActionOptions = <String>[
'提报定制化初步解决方案与产品演示',
'安排技术团队现场勘察与需求复核',
'推进商务报价与合同条款沟通',
'组织分管领导方案汇报',
'定期客情维系与需求跟踪',
'暂不安排,列入储备客户跟进',
];
/// 拜访沟通纪要模板,对应 Web 表单 CONTENT_TEMPLATE。
const visitContentTemplate =
'本次拜访与客户【接待人/职务】就【沟通主题】进行交流。\n'
'一、客户核心关注诉求与痛点:\n'
'1、\n'
'二、客户顾虑与异议:\n'
'1、\n'
'三、本次沟通结论与下一步计划:';
/// 状态:0 待执行 1 已完成待复核 2 取证待技术复核 3 已立项转勘察 4 已归档 5 已取消 /// 状态:0 待执行 1 已完成待复核 2 取证待技术复核 3 已立项转勘察 4 已归档 5 已取消
const visitStatusOptions = <ValueOption>[ const visitStatusOptions = <ValueOption>[
(value: 0, label: '待执行'), (value: 0, label: '待执行'),
@@ -78,16 +78,83 @@ class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
return '${visit.receptionPerson}$title$phone'; return '${visit.receptionPerson}$title$phone';
} }
void _goEdit(bool canEdit) { void _goEdit() {
if (!canEdit) {
showAppToast('仅待执行的拜访可编辑');
return;
}
context.push('/visit/form?id=${widget.id}').then((_) { context.push('/visit/form?id=${widget.id}').then((_) {
if (mounted) _load(); 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<void> _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<void> _showReviewSheet(VisitInfo visit) async {
final payload = await showModalBottomSheet<Map<String, dynamic>>(
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() { void _goExecute() {
context.push('/visit/execute/${widget.id}').then((_) { context.push('/visit/execute/${widget.id}').then((_) {
if (mounted) _load(); if (mounted) _load();
@@ -187,20 +254,15 @@ class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final visit = _visit; final visit = _visit;
final isAdmin = ref.watch(sessionProvider).user?.isAdmin ?? false; final isAdmin = ref.watch(sessionProvider).user?.isAdmin ?? false;
final canEdit = visit?.status == VisitStatus.pending;
return XPageShell( return XPageShell(
topBar: XTopBar.nav( topBar: XTopBar.nav(
title: '拜访详情', title: '拜访详情',
onBack: () => context.pop(), onBack: () => context.pop(),
trailing: GestureDetector( trailing: GestureDetector(
onTap: () => _goEdit(canEdit), onTap: _goEdit,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: XIcon( child: const XIcon('edit', size: 20, color: AppColors.primary),
'edit',
size: 20,
color: canEdit ? AppColors.primary : AppColors.outlineVariant,
),
), ),
), ),
child: visit == null child: visit == null
@@ -214,12 +276,12 @@ class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
)) ))
: ListView( : ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60), padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
children: _buildContent(visit, canEdit, isAdmin), children: _buildContent(visit, isAdmin),
), ),
); );
} }
List<Widget> _buildContent(VisitInfo visit, bool canEdit, bool isAdmin) { List<Widget> _buildContent(VisitInfo visit, bool isAdmin) {
final customer = visit.customer; final customer = visit.customer;
final photoList = visit.photoUrls.where((u) => u.isNotEmpty).toList(); final photoList = visit.photoUrls.where((u) => u.isNotEmpty).toList();
final hasGps = final hasGps =
@@ -459,12 +521,41 @@ class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
]), ]),
// 业务员操作 // 业务员操作
_buildActions(canEdit, isAdmin), _buildActions(visit, isAdmin),
]; ];
} }
Widget _buildActions(bool canEdit, bool isAdmin) { Widget _buildActions(VisitInfo visit, bool isAdmin) {
if (!canEdit && !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 = <Widget>[];
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( return Padding(
padding: const EdgeInsets.only(top: 24), padding: const EdgeInsets.only(top: 24),
child: Container( child: Container(
@@ -476,54 +567,51 @@ class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
borderRadius: BorderRadius.circular(AppRadius.lg), borderRadius: BorderRadius.circular(AppRadius.lg),
), ),
child: const Text( child: const Text(
'当前状态下无需业务员操作,取证复核 / 立项 / 归档请在 Web 后台办理', '当前拜访已完成全部流程节点,暂无待办操作',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: AppColors.textMuted), style: TextStyle(fontSize: 13, color: AppColors.textMuted),
), ),
), ),
); );
} }
return Padding( return Padding(
padding: const EdgeInsets.only(top: 24), padding: const EdgeInsets.only(top: 24),
child: Column( child: Column(
children: [ children: [
if (canEdit) if (primary != null)
XPrimaryButton( XPrimaryButton(
label: '开始执行并完成', label: primary.label,
onPressed: _goExecute, onPressed: primary.onTap,
pill: false, pill: false,
height: 48, height: 48,
fontSize: 15, fontSize: 15,
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
), ),
if (canEdit) ...secondary,
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,
),
], ],
), ),
); );
} }
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) { Widget _sectionTitle(String icon, Color iconColor, String text) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10), padding: const EdgeInsets.only(top: 22, bottom: 10),
@@ -602,3 +690,292 @@ class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
); );
} }
} }
/// 审核现场取证弹层:取证待技术复核(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 = <String, dynamic>{'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,
),
),
],
),
),
);
}
}
File diff suppressed because it is too large. Load diff