feat: 新增勘察派单功能及拜访详情页操作
新增客户空间拓扑API、勘察明细模型,重构勘察接口,新增拜访详情页操作逻辑及新建勘察派单页面
This commit is contained in:
1 parent
7ad604ff8b
commit
83df770eaa
10 files changed
+2748
-489
No files matched your search
@@ -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<GoRouter>((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(
|
||||
|
||||
@@ -74,4 +74,12 @@ class CustomerApi {
|
||||
.map((e) => CustomerOption.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.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,
|
||||
_ => 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(),
|
||||
);
|
||||
}
|
||||
@@ -9,23 +9,6 @@ final surveyApiProvider = Provider<SurveyApi>(
|
||||
(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`。
|
||||
class SurveyApi {
|
||||
SurveyApi(this._client);
|
||||
@@ -77,6 +60,14 @@ class SurveyApi {
|
||||
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) =>
|
||||
_client.put<SurveyItem>(
|
||||
'/survey-orders/$id',
|
||||
@@ -84,12 +75,15 @@ class SurveyApi {
|
||||
parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
/// 按 SOP 检查项增量保存现场回执;
|
||||
/// 仅现场勘察中(status=1)且工单对当前用户可见时后端才放行。
|
||||
Future<ApiResponse<SopSaveResult>> saveSopResults(int id, List<SopResultPayload> results) =>
|
||||
_client.put<SopSaveResult>(
|
||||
'/survey-orders/$id/sop-results',
|
||||
body: {'results': results.map((e) => e.toJson()).toList()},
|
||||
parse: (d) => SopSaveResult.fromJson((d as Map).cast<String, dynamic>()),
|
||||
/// 全量替换工单勘察明细(新增/编辑/删除统一走此接口,返回最新明细)。
|
||||
Future<ApiResponse<List<SurveyOrderDetail>>> saveDetails(
|
||||
int id,
|
||||
List<SurveyOrderDetail> details,
|
||||
) => _client.put<List<SurveyOrderDetail>>(
|
||||
'/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<String, dynamic>()))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
@@ -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<String> receiptValue;
|
||||
/// 建筑名称(冗余存储,客户拓扑变更后仍可读)。
|
||||
final String buildingName;
|
||||
|
||||
/// 详情接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)。
|
||||
final List<String> receiptUrls;
|
||||
/// 楼层:正数为地上、负数为地下,如 `"1"` / `"-1"`。
|
||||
final String floorNo;
|
||||
final int? roomId;
|
||||
final String roomName;
|
||||
|
||||
/// 现场照片对象 key。
|
||||
final List<String> photos;
|
||||
|
||||
/// 详情接口附带:照片预签名访问 URL(与 photos 顺序一致)。
|
||||
final List<String> photoUrls;
|
||||
final String remark;
|
||||
|
||||
factory SurveySopResult.fromJson(Map<String, dynamic> 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<String, dynamic> 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<String>? receiptValue;
|
||||
final String? remark;
|
||||
|
||||
/// 提交体:只带定位要素与照片/备注,行 id 由后端全量重建。
|
||||
Map<String, dynamic> 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<SurveySopResult>? sopResults;
|
||||
// 仅详情接口返回:勘察明细(建筑/楼层/房间 + 现场照片)
|
||||
final List<SurveyOrderDetail>? details;
|
||||
|
||||
factory SurveyItem.fromJson(Map<String, dynamic> 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<String, dynamic>()))
|
||||
: (json['details'] as List)
|
||||
.map((e) => SurveyOrderDetail.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.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: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 = <String>['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<SurveyFormPage> {
|
||||
SurveyItem? _survey;
|
||||
int _surveyId = 0;
|
||||
bool _submitting = false;
|
||||
bool _savingDetails = false;
|
||||
String _busyText = '';
|
||||
|
||||
List<_SopRow> _sopRows = const [];
|
||||
List<_DetailRow> _detailRows = const [];
|
||||
List<CustomerTopologyBuilding> _buildings = const [];
|
||||
final List<List<Offset>> _strokes = [];
|
||||
List<Offset>? _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<String> 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<SurveyFormPage> {
|
||||
_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<SurveyFormPage> {
|
||||
final data = res.data!;
|
||||
setState(() {
|
||||
_survey = data;
|
||||
_sopRows = (data.sopResults ?? const <SurveySopResult>[])
|
||||
.map(_buildSopRow)
|
||||
_detailRows = (data.details ?? const <SurveyOrderDetail>[])
|
||||
.map(_buildDetailRow)
|
||||
.toList();
|
||||
});
|
||||
_loadTopology(data.customerId);
|
||||
} else {
|
||||
showAppToast(res.message.isEmpty ? '加载勘察单失败' : res.message);
|
||||
}
|
||||
@@ -115,70 +114,209 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
|
||||
}
|
||||
}
|
||||
|
||||
_SopRow _buildSopRow(SurveySopResult result) {
|
||||
final keys = List<String>.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<String>.from(result.receiptUrls),
|
||||
text: result.receiptType == 'form' ? (keys.isEmpty ? '' : keys.first) : '',
|
||||
remark: result.remark,
|
||||
_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,
|
||||
);
|
||||
row.snap = _rowSnapshot(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
List<String> _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<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);
|
||||
/// 客户空间拓扑:一次拉取建筑/楼层/房间,供明细下拉使用。
|
||||
Future<void> _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<void> _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<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();
|
||||
if (picked.isEmpty || !mounted) return;
|
||||
setState(() => _busyText = '上传中...');
|
||||
@@ -195,20 +333,18 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
|
||||
}
|
||||
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<void>(
|
||||
@@ -327,6 +463,23 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
|
||||
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<SurveyFormPage> {
|
||||
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<SurveyFormPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSopSection() {
|
||||
Widget _buildDetailSection() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Column(
|
||||
@@ -765,141 +918,206 @@ class _SurveyFormPageState extends ConsumerState<SurveyFormPage> {
|
||||
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(
|
||||
_buildSectionTitle('domain', AppColors.secondary, '勘察明细'),
|
||||
if (_canEditDetails)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const XIcon('schedule', size: 14, color: AppColors.statusWarn),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'还有 ${_sopMissingRequired.length} 项必填回执未完成:'
|
||||
'${_sopMissingRequired.join('、')}',
|
||||
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,
|
||||
Text(
|
||||
'明细 ${index + 1}',
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
const Spacer(),
|
||||
if (editable)
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _removeDetailRow(index),
|
||||
child: const 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),
|
||||
XIcon('delete', size: 16, color: AppColors.statusError),
|
||||
SizedBox(width: 4),
|
||||
Text(
|
||||
'删除',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.statusError),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
row.itemDesc,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
padding: const EdgeInsets.only(top: 13),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'现场照片',
|
||||
style: AppTypography.labelBold.copyWith(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isForm)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: _buildTextarea(
|
||||
controller: row.textController,
|
||||
hint: '按清单格式填写回执内容',
|
||||
enabled: _canEditReceipt,
|
||||
onEditDone: () => _saveSopItem(row),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (row.urls.isEmpty && !editable)
|
||||
const Text(
|
||||
'无照片',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Wrap(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (var i = 0; i < row.urls.length; i++)
|
||||
_buildSopPhoto(row, i),
|
||||
if (_canEditReceipt) _buildSopPhotoAdd(row),
|
||||
_buildDetailPhoto(row, i),
|
||||
if (editable) _buildDetailPhotoAdd(row),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_canEditReceipt)
|
||||
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<SurveyFormPage> {
|
||||
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),
|
||||
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: background,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
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<SurveyFormPage> {
|
||||
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<SurveyFormPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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<SurveyFormPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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<String> keys = const [],
|
||||
List<String> urls = const [],
|
||||
String remark = '',
|
||||
}) : textController = TextEditingController(text: text),
|
||||
}) : keys = List<String>.of(keys),
|
||||
urls = List<String>.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<String> keys;
|
||||
|
||||
/// 与 keys 顺序一致的预览地址(后端预签名 URL 或刚上传的临时地址)
|
||||
/// 与 keys 顺序一致的预览地址(后端预签名 URL 或刚上传的临时地址)。
|
||||
final List<String> 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 笔触。
|
||||
|
||||
@@ -37,6 +37,67 @@ const visitIntentOptions = <ValueOption>[
|
||||
(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 已取消
|
||||
const visitStatusOptions = <ValueOption>[
|
||||
(value: 0, label: '待执行'),
|
||||
|
||||
@@ -78,16 +78,83 @@ class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
|
||||
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<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() {
|
||||
context.push('/visit/execute/${widget.id}').then((_) {
|
||||
if (mounted) _load();
|
||||
@@ -187,20 +254,15 @@ class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
|
||||
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<VisitDetailPage> {
|
||||
))
|
||||
: ListView(
|
||||
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 photoList = visit.photoUrls.where((u) => u.isNotEmpty).toList();
|
||||
final hasGps =
|
||||
@@ -459,12 +521,41 @@ class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
|
||||
]),
|
||||
|
||||
// 业务员操作
|
||||
_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 = <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(
|
||||
padding: const EdgeInsets.only(top: 24),
|
||||
child: Container(
|
||||
@@ -476,54 +567,51 @@ class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
|
||||
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),
|
||||
@@ -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
Reference in new issue
Block a user