feat: 新增勘察派单功能及拜访详情页操作
新增客户空间拓扑API、勘察明细模型,重构勘察接口,新增拜访详情页操作逻辑及新建勘察派单页面
This commit is contained in:
1 parent
7ad604ff8b
commit
83df770eaa
10 files changed
+2788
-529
No files matched your search
@@ -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),
|
||||
@@ -601,4 +689,293 @@ 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