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

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

No files matched your search

+61
View File
@@ -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),
@@ -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