feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建

This commit is contained in:
andyesfly committed 2026-10-02 17:21:53 +08:00
commit f61b7bf90b
120 files changed
+15229

No files matched your search

@@ -0,0 +1,695 @@
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 '../../auth/application/session_controller.dart';
import '../../customer/data/customer_api.dart';
import '../../customer/data/customer_models.dart';
import '../../customer/data/user_api.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 批量安排拜访页,对应 `pages/visit/batch.vue`。
class VisitBatchPage extends ConsumerStatefulWidget {
const VisitBatchPage({super.key});
@override
ConsumerState<VisitBatchPage> createState() => _VisitBatchPageState();
}
class _VisitBatchPageState extends ConsumerState<VisitBatchPage> {
final _keyword = TextEditingController();
final _subject = TextEditingController();
final List<CustomerOption> _customers = [];
final List<int> _selectedIds = [];
List<SysUser> _salesmen = const [];
bool _customerLoading = false;
bool _saving = false;
bool _initialized = false;
String _planTime = todayStr();
String _planEndTime = '';
int _typeIndex = 0;
int _modeIndex = 0;
int _priorityIndex = 2;
int _salesmanIndex = -1;
late final List<String> _typeLabels = visitTypeOptions
.map((o) => o.label)
.toList();
late final List<String> _modeLabels = visitModeOptions
.map((o) => o.label)
.toList();
late final List<String> _priorityLabels = visitPriorityOptions
.map((o) => o.label)
.toList();
@override
void dispose() {
_keyword.dispose();
_subject.dispose();
super.dispose();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialized) return;
_initialized = true;
final preset = int.tryParse(
GoRouterState.of(context).uri.queryParameters['customerId'] ?? '',
);
if (preset != null && preset > 0) _selectedIds.add(preset);
_loadCustomers();
if (_isAdmin) _loadSalesmen();
}
bool get _isAdmin => ref.read(sessionProvider).user?.isAdmin ?? false;
int get _visitType => visitTypeOptions[_typeIndex].value;
int get _visitMode => visitModeOptions[_modeIndex].value;
int get _priority => visitPriorityOptions[_priorityIndex].value;
int? get _salesmanId =>
_salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null;
List<String> get _salesmanLabels => [
'默认本人',
..._salesmen.map((s) => s.optionLabel),
];
List<CustomerOption> get _filteredCustomers {
final kw = _keyword.text.trim().toLowerCase();
if (kw.isEmpty) return _customers;
return _customers
.where(
(c) =>
c.customerName.toLowerCase().contains(kw) ||
c.customerNo.toLowerCase().contains(kw),
)
.toList();
}
Future<void> _loadCustomers() async {
_customerLoading = true;
try {
final res = await ref.read(customerApiProvider).options();
if (!mounted) return;
if (res.isOk && res.data != null) {
_customers
..clear()
..addAll(res.data!);
}
} catch (_) {
showAppToast('客户列表加载失败');
} finally {
if (mounted) setState(() => _customerLoading = false);
}
}
Future<void> _loadSalesmen() async {
try {
final list = await ref.read(userApiProvider).salesmanOptions();
if (mounted) setState(() => _salesmen = list);
} catch (_) {
if (mounted) setState(() => _salesmen = const []);
}
}
void _toggle(int id) {
setState(() {
if (_selectedIds.contains(id)) {
_selectedIds.remove(id);
} else {
_selectedIds.add(id);
}
});
}
void _selectAll() {
setState(() {
for (final c in _filteredCustomers) {
if (!_selectedIds.contains(c.id)) _selectedIds.add(c.id);
}
});
}
void _clearAll() => setState(_selectedIds.clear);
Future<void> _pickPlanStart() async {
final picked = await _pickDate(_planTime);
if (picked == null) return;
if (!mounted) return;
setState(() => _planTime = formatDate(picked));
}
Future<void> _pickPlanEnd() async {
final picked = await _pickDate(_planEndTime);
if (picked == null) return;
if (!mounted) return;
setState(() => _planEndTime = formatDate(picked));
}
Future<DateTime?> _pickDate(String current) {
return showDatePicker(
context: context,
initialDate: parseDate(current) ?? DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
}
Future<void> _pickOption({
required List<String> labels,
required int selectedIndex,
required 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 == selectedIndex
? const XIcon('check', size: 18, color: AppColors.primary)
: null,
onTap: () => Navigator.of(ctx).pop(i),
),
],
),
),
);
if (index == null) return;
if (!mounted) return;
setState(() => onSelected(index));
}
Future<void> _onSave() async {
if (_selectedIds.isEmpty) {
showAppToast('请至少选择一个客户');
return;
}
if (_planTime.isEmpty) {
showAppToast('请选择计划日期');
return;
}
setState(() => _saving = true);
try {
final payload = <String, dynamic>{
'customerIds': List<int>.from(_selectedIds),
'planTime': _planTime,
'subject': _subject.text,
'visitType': _visitType,
'visitMode': _visitMode,
'priority': _priority,
};
if (_planEndTime.isNotEmpty) payload['planEndTime'] = _planEndTime;
final salesmanId = _salesmanId;
if (_isAdmin && salesmanId != null) payload['salesmanId'] = salesmanId;
final res = await ref.read(visitApiProvider).batchCreate(payload);
if (!mounted) return;
final data = res.data;
if (res.isOk && data != null) {
showAppToast('成功创建 ${data.total} 条拜访计划');
Future.delayed(const Duration(milliseconds: 800), () {
if (mounted) context.pop();
});
} else {
showAppToast(res.message.isEmpty ? '创建失败' : 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: [
_buildPlanCard(),
_buildCustomerCard(),
Container(
margin: const EdgeInsets.fromLTRB(0, 24, 0, 40),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(9999),
boxShadow: const [
BoxShadow(
color: Color(0x4D0054CD),
blurRadius: 16,
offset: Offset(0, 6),
),
],
),
child: XPrimaryButton(
label: '创建 ${_selectedIds.length} 条拜访计划',
loading: _saving,
onPressed: _onSave,
),
),
],
),
),
);
}
Widget _buildPlanCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
margin: const EdgeInsets.only(bottom: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(0, 14, 0, 6),
child: Text(
'计划信息',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
_formItem(
label: '计划日期',
isRequired: true,
child: _pickerValue(
text: _planTime.isEmpty ? '请选择计划日期' : _planTime,
placeholder: _planTime.isEmpty,
trailingIcon: 'calendar',
onTap: _pickPlanStart,
),
),
_formDivider(),
_formItem(
label: '计划结束',
child: _pickerValue(
text: _planEndTime.isEmpty ? '选填' : _planEndTime,
placeholder: _planEndTime.isEmpty,
trailingIcon: 'calendar',
onTap: _pickPlanEnd,
),
),
_formDivider(),
_formItem(
label: '拜访主题',
child: _textInput(_subject, '默认:批量安排的拜访计划'),
),
_formDivider(),
_formItem(
label: '拜访类型',
child: _pickerValue(
text: _typeLabels[_typeIndex],
placeholder: false,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _typeLabels,
selectedIndex: _typeIndex,
onSelected: (i) => _typeIndex = i,
),
),
),
_formDivider(),
_formItem(
label: '拜访方式',
child: _pickerValue(
text: _modeLabels[_modeIndex],
placeholder: false,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _modeLabels,
selectedIndex: _modeIndex,
onSelected: (i) => _modeIndex = i,
),
),
),
_formDivider(),
_formItem(
label: '优先级',
child: _pickerValue(
text: _priorityLabels[_priorityIndex],
placeholder: false,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _priorityLabels,
selectedIndex: _priorityIndex,
onSelected: (i) => _priorityIndex = i,
),
),
),
if (_isAdmin) ...[
_formDivider(),
_formItem(
label: '负责业务员',
child: _pickerValue(
text: _salesmanIndex >= 0
? _salesmanLabels[_salesmanIndex]
: '默认本人',
placeholder: _salesmanIndex < 0,
trailingIcon: 'forward',
onTap: () => _pickOption(
labels: _salesmanLabels,
selectedIndex: _salesmanIndex,
onSelected: (i) => _salesmanIndex = i,
),
),
),
],
],
),
);
}
Widget _buildCustomerCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(0, 14, 0, 6),
child: Row(
children: [
const Text(
'选择客户',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
const Spacer(),
GestureDetector(
onTap: _selectAll,
child: const Text(
'全选',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
const SizedBox(width: 16),
GestureDetector(
onTap: _clearAll,
child: const Text(
'清空',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
],
),
),
_searchBar(),
Padding(
padding: const EdgeInsets.fromLTRB(0, 4, 0, 8),
child: Row(
children: [
const Text(
'已选 ',
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
Text(
'${_selectedIds.length}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const Text(
' 个客户',
style: TextStyle(fontSize: 13, color: AppColors.textMuted),
),
],
),
),
if (_customerLoading)
_centerText('加载中...')
else if (_filteredCustomers.isEmpty)
_centerText('暂无可选客户')
else
for (final c in _filteredCustomers) _customerItem(c),
],
),
);
}
Widget _formItem({
required String label,
required Widget child,
bool isRequired = false,
}) {
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)
const Text(
' *',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.statusError,
),
),
],
),
const SizedBox(height: 8),
child,
],
),
);
}
Widget _formDivider() =>
const Divider(height: 1, thickness: 1, color: AppColors.borderRule);
Widget _pickerValue({
required String text,
required bool placeholder,
required String trailingIcon,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: 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: Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
color: placeholder
? AppColors.textPlaceholder
: AppColors.onSurface,
),
),
),
XIcon(trailingIcon, size: 16, color: AppColors.outline),
],
),
),
);
}
Widget _textInput(TextEditingController controller, String hint) {
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: TextField(
controller: controller,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: hint,
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
);
}
Widget _searchBar() {
return Container(
height: 42,
margin: const EdgeInsets.fromLTRB(0, 6, 0, 8),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const XIcon('search', size: 18, color: AppColors.textMuted),
const SizedBox(width: 8),
Expanded(
child: TextField(
controller: _keyword,
onChanged: (_) => setState(() {}),
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: '按客户名称、编号筛选...',
hintStyle: TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
),
);
}
Widget _centerText(String text) => Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
),
);
Widget _customerItem(CustomerOption c) {
final selected = _selectedIds.contains(c.id);
return GestureDetector(
onTap: () => _toggle(c.id),
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
decoration: BoxDecoration(
color: selected ? AppColors.surfaceContainerLow : AppColors.surfaceBright,
border: Border.all(
color: selected ? AppColors.primaryContainer : AppColors.borderRule,
),
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Container(
width: 22,
height: 22,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? AppColors.primaryContainer : Colors.white,
border: Border.all(
color: selected
? AppColors.primaryContainer
: AppColors.borderRule,
width: 1.5,
),
borderRadius: BorderRadius.circular(6),
),
child: selected
? const XIcon('check', size: 14, color: Colors.white)
: null,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.customerName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
color: AppColors.onSurface,
),
),
const SizedBox(height: 2),
Text(
c.customerNo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTypography.dataMono.copyWith(
fontSize: 11,
color: AppColors.textMuted,
),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,604 @@
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_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../../auth/application/session_controller.dart';
import '../../customer/data/user_api.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访详情页,对应 `pages/visit/detail.vue`。
class VisitDetailPage extends ConsumerStatefulWidget {
const VisitDetailPage({super.key, required this.id});
final int id;
@override
ConsumerState<VisitDetailPage> createState() => _VisitDetailPageState();
}
class _VisitDetailPageState extends ConsumerState<VisitDetailPage> {
VisitInfo? _visit;
bool _loading = false;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final res = await ref.read(visitApiProvider).detail(widget.id);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _visit = res.data);
} else {
showAppToast(res.message.isNotEmpty ? res.message : '拜访记录不存在');
}
} catch (_) {
showAppToast('加载失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
String _findLabel(List<ValueOption> options, int? value) {
if (value == null) return '';
return options.where((o) => o.value == value).map((o) => o.label).firstOrNull ??
'';
}
String _planRange(VisitInfo visit) {
if (visit.planTime == null || visit.planTime!.isEmpty) return '未排期';
final start = formatDate(visit.planTime);
if (visit.planEndTime == null || visit.planEndTime!.isEmpty) return start;
return '$start ~ ${formatDate(visit.planEndTime)}';
}
String _receptionText(VisitInfo visit) {
if (visit.receptionPerson == null || visit.receptionPerson!.isEmpty) return '-';
final title = visit.receptionTitle != null && visit.receptionTitle!.isNotEmpty
? '(${visit.receptionTitle})'
: '';
final phone = visit.receptionPhone != null && visit.receptionPhone!.isNotEmpty
? ' · ${visit.receptionPhone}'
: '';
return '${visit.receptionPerson}$title$phone';
}
void _goEdit(bool canEdit) {
if (!canEdit) {
showAppToast('仅待执行的拜访可编辑');
return;
}
context.push('/visit/form?id=${widget.id}').then((_) {
if (mounted) _load();
});
}
void _goExecute() {
context.push('/visit/execute/${widget.id}').then((_) {
if (mounted) _load();
});
}
Future<void> _onCancelVisit() async {
final confirmed = await showAppConfirm(
title: '取消拜访',
content: '确定取消该拜访计划吗?取消后不可再执行。',
);
if (!confirmed) return;
try {
final res = await ref
.read(visitApiProvider)
.updateStatus(widget.id, {'status': VisitStatus.canceled});
if (res.isOk) {
showAppToast('已取消', success: true);
_load();
} else {
showAppToast(res.message.isNotEmpty ? res.message : '操作失败');
}
} catch (_) {
showAppToast('操作失败');
}
}
Future<void> _onReassign() async {
try {
final salesmen = await ref.read(userApiProvider).salesmanOptions();
if (!mounted) return;
if (salesmen.isEmpty) {
showAppToast('暂无可选业务员');
return;
}
final index = await _showSalesmanSheet(
salesmen.map((s) => s.optionLabel).toList(),
);
if (index == null || !mounted) return;
final res = await ref
.read(visitApiProvider)
.reassign(widget.id, {'salesmanId': salesmen[index].id});
if (res.isOk) {
showAppToast('改派成功', success: true);
_load();
} else {
showAppToast(res.message.isNotEmpty ? res.message : '改派失败');
}
} catch (_) {
showAppToast('获取业务员失败');
}
}
Future<int?> _showSalesmanSheet(List<String> labels) {
return 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),
),
onTap: () => Navigator.of(ctx).pop(i),
),
],
),
),
);
}
void _previewPhoto(List<String> urls, int index) {
showDialog<void>(
context: context,
barrierColor: Colors.black,
builder: (_) => Dialog(
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.zero,
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: Center(
child: Image.network(urls[index], fit: BoxFit.contain),
),
),
),
);
}
@override
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),
behavior: HitTestBehavior.opaque,
child: XIcon(
'edit',
size: 20,
color: canEdit ? AppColors.primary : AppColors.outlineVariant,
),
),
),
child: visit == null
? (Center(
child: _loading
? const Text(
'加载中...',
style: TextStyle(fontSize: 15, color: AppColors.textMuted),
)
: const SizedBox.shrink(),
))
: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
children: _buildContent(visit, canEdit, isAdmin),
),
);
}
List<Widget> _buildContent(VisitInfo visit, bool canEdit, bool isAdmin) {
final customer = visit.customer;
final photoList = visit.photoUrls.where((u) => u.isNotEmpty).toList();
final hasGps =
(visit.checkinLng ?? 0) != 0 && (visit.checkinLat ?? 0) != 0;
final hasExecution =
(visit.checkinTime != null && visit.checkinTime!.isNotEmpty) ||
(visit.checkinStatus ?? 0) > 0 ||
hasGps ||
(visit.spaceDesc != null && visit.spaceDesc!.isNotEmpty);
final hasResult =
(visit.visitTime != null && visit.visitTime!.isNotEmpty) ||
(visit.intentLevel ?? 0) > 0 ||
(visit.intentDesc != null && visit.intentDesc!.isNotEmpty) ||
(visit.demands != null && visit.demands!.isNotEmpty) ||
(visit.visitContent != null && visit.visitContent!.isNotEmpty) ||
(visit.companion != null && visit.companion!.isNotEmpty) ||
(visit.receptionPerson != null && visit.receptionPerson!.isNotEmpty) ||
(visit.resultRating != null && visit.resultRating!.isNotEmpty);
final hasNext =
(visit.nextVisitTime != null && visit.nextVisitTime!.isNotEmpty) ||
(visit.nextAction != null && visit.nextAction!.isNotEmpty);
final priority = visit.priority ?? 0;
final typeText = _findLabel(visitTypeOptions, visit.visitType);
final modeText = _findLabel(visitModeOptions, visit.visitMode);
return [
// 头部
XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
visit.subject?.isNotEmpty == true ? visit.subject! : '客户拜访',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 8),
XChip(
visitStatusText(visit.status),
tone: visitStatusTone(visit.status),
),
],
),
if (priority > 0 || typeText.isNotEmpty || modeText.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (priority > 0)
XChip(
visitPriorityText(visit.priority),
tone: visitPriorityTone(visit.priority),
),
if (typeText.isNotEmpty) XChip(typeText, tone: ChipTone.muted),
if (modeText.isNotEmpty) XChip(modeText, tone: ChipTone.muted),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
const Text(
'拜访单号',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
const SizedBox(width: 8),
Text(
visit.visitNo,
style: AppTypography.dataMono.copyWith(
fontSize: 12,
color: AppColors.onSurface,
),
),
],
),
),
],
),
),
// 客户信息
_sectionTitle('groups', AppColors.primary, '客户信息'),
XCard(
onTap: visit.customerId > 0
? () => context.push('/customer/detail/${visit.customerId}')
: null,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: _infoColumn([
_infoItem(
'business',
'客户名称',
customer?.customerName?.isNotEmpty == true
? customer!.customerName!
: '-',
link: true,
),
if (customer?.customerNo?.isNotEmpty == true)
_infoItem('category', '客户编号', customer!.customerNo!, mono: true),
if (customer?.contactPerson?.isNotEmpty == true)
_infoItem('person', '联系人', customer!.contactPerson!),
if (customer?.contactPhone?.isNotEmpty == true)
_infoItem('call', '联系电话', customer!.contactPhone!, mono: true),
if (customer?.address?.isNotEmpty == true)
_infoItem('location', '客户地址', customer!.address!),
]),
),
// 计划安排
_sectionTitle('schedule', AppColors.secondary, '计划安排'),
_plainInfoCard([
_infoItem('calendar', '计划时间', _planRange(visit)),
_infoItem(
'person',
'拜访人',
'${visit.visitPerson.isNotEmpty ? visit.visitPerson : '-'}'
'${visit.visitPhone != null && visit.visitPhone!.isNotEmpty ? ' · ${visit.visitPhone}' : ''}',
),
if (visit.salesmanName != null && visit.salesmanName!.isNotEmpty)
_infoItem(
'verified',
'负责业务员',
'${visit.salesmanName}'
'${visit.salesmanPhone != null && visit.salesmanPhone!.isNotEmpty ? ' · ${visit.salesmanPhone}' : ''}',
),
]),
// 现场执行
if (hasExecution) ...[
_sectionTitle('location', AppColors.tertiary, '现场执行'),
_plainInfoCard([
if (visit.checkinTime != null && visit.checkinTime!.isNotEmpty)
_infoItem('clock', '到场签到', formatDateTime(visit.checkinTime)),
if (hasGps)
_infoItem(
'location',
'GPS 坐标',
'${visit.checkinLat}, ${visit.checkinLng}',
mono: true,
),
if (visit.spaceDesc != null && visit.spaceDesc!.isNotEmpty)
_infoItem('business', '现场情况', visit.spaceDesc!),
]),
],
// 拜访结果
if (hasResult) ...[
_sectionTitle('check-circle', AppColors.primary, '拜访结果'),
_plainInfoCard([
if (visit.visitTime != null && visit.visitTime!.isNotEmpty)
_infoItem('clock', '实际拜访时间', formatDate(visit.visitTime)),
if ((visit.intentLevel ?? 0) > 0)
_infoItem(
'trend-up',
'意向评级',
_findLabel(visitIntentOptions, visit.intentLevel),
),
if (visit.intentDesc != null && visit.intentDesc!.isNotEmpty)
_infoItem('chart', '意向说明', visit.intentDesc!),
if (visit.demands != null && visit.demands!.isNotEmpty)
_infoItem('bolt', '客户需求', visit.demands!),
if (visit.visitContent != null && visit.visitContent!.isNotEmpty)
_infoItem('edit', '拜访内容', visit.visitContent!),
if (visit.companion != null && visit.companion!.isNotEmpty)
_infoItem('person', '随行人员', visit.companion!),
if (visit.receptionPerson != null && visit.receptionPerson!.isNotEmpty)
_infoItem('person', '接待人', _receptionText(visit)),
if (visit.resultRating != null && visit.resultRating!.isNotEmpty)
_infoItem('verified', '结果评价', visit.resultRating!),
]),
],
// 后续计划
if (hasNext) ...[
_sectionTitle('forward', AppColors.secondary, '后续计划'),
_plainInfoCard([
if (visit.nextVisitTime != null && visit.nextVisitTime!.isNotEmpty)
_infoItem('calendar', '下次拜访', formatDate(visit.nextVisitTime)),
if (visit.nextAction != null && visit.nextAction!.isNotEmpty)
_infoItem('forward', '下一步动作', visit.nextAction!),
]),
],
// 现场照片
if (photoList.isNotEmpty) ...[
_sectionTitle('camera', AppColors.primary, '现场照片'),
LayoutBuilder(
builder: (context, constraints) {
final width = (constraints.maxWidth - 10) / 2;
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
for (var i = 0; i < photoList.length; i++)
GestureDetector(
onTap: () => _previewPhoto(photoList, i),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Container(
width: width,
height: 140,
color: AppColors.surfaceVariant,
child: Image.network(photoList[i], fit: BoxFit.cover),
),
),
),
],
);
},
),
],
// 流程信息
_sectionTitle('verified', AppColors.tertiary, '流程信息'),
_plainInfoCard([
if ((visit.surveyOrderId ?? 0) > 0 ||
(visit.projectNo != null && visit.projectNo!.isNotEmpty))
_infoItem(
'construction',
'关联立项',
visit.projectNo != null && visit.projectNo!.isNotEmpty
? visit.projectNo!
: '勘察单 #${visit.surveyOrderId}',
mono: true,
),
_infoItem('clock', '创建时间', formatDateTime(visit.createdAt)),
]),
// 业务员操作
_buildActions(canEdit, isAdmin),
];
}
Widget _buildActions(bool canEdit, bool isAdmin) {
if (!canEdit && !isAdmin) {
return Padding(
padding: const EdgeInsets.only(top: 24),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
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)
XPrimaryButton(
label: '开始执行并完成',
onPressed: _goExecute,
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,
),
],
),
);
}
Widget _sectionTitle(String icon, Color iconColor, String text) {
return Padding(
padding: const EdgeInsets.only(top: 22, bottom: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon(icon, size: 17, color: iconColor),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
],
),
);
}
Widget _plainInfoCard(List<Widget> items) {
return XCard(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: _infoColumn(items),
);
}
Widget _infoColumn(List<Widget> items) {
final children = <Widget>[];
for (var i = 0; i < items.length; i++) {
if (i > 0) {
children.add(
const Divider(height: 1, thickness: 1, color: AppColors.borderRule),
);
}
children.add(items[i]);
}
return Column(children: children);
}
Widget _infoItem(
String icon,
String label,
String value, {
bool mono = false,
bool link = false,
}) {
final color = link ? AppColors.primary : AppColors.onSurface;
final style = mono
? AppTypography.dataMono.copyWith(fontSize: 15, color: color)
: TextStyle(fontSize: 15, color: color);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 13),
child: Row(
children: [
XIcon(icon, size: 18, color: AppColors.textMuted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
Text(value, style: style),
],
),
),
],
),
);
}
}
@@ -0,0 +1,947 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/format_util.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
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 '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访执行页,对应 `pages/visit/execute.vue`。
class VisitExecutePage extends ConsumerStatefulWidget {
const VisitExecutePage({super.key, required this.id});
final int id;
@override
ConsumerState<VisitExecutePage> createState() => _VisitExecutePageState();
}
/// 现场照片槽位:key 为对象存储 key(落库),url 为预签名访问地址(预览)。
class _PhotoSlot {
String key = '';
String url = '';
}
class _VisitExecutePageState extends ConsumerState<VisitExecutePage> {
VisitInfo? _visit;
bool _loading = false;
bool _checking = false;
bool _submitting = false;
final _spaceDesc = TextEditingController();
final _visitContent = TextEditingController();
final _demands = TextEditingController();
final _intentDesc = TextEditingController();
int? _intentLevel;
final List<_PhotoSlot> _photoSlots = [_PhotoSlot(), _PhotoSlot()];
NavigatorState? _uploadingNavigator;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_spaceDesc.dispose();
_visitContent.dispose();
_demands.dispose();
_intentDesc.dispose();
super.dispose();
}
VisitApi get _api => ref.read(visitApiProvider);
bool get _checkedIn => _visit?.checkinStatus == 1;
bool get _hasGps =>
(_visit?.checkinLng ?? 0) != 0 && (_visit?.checkinLat ?? 0) != 0;
String get _gpsText => '${_visit?.checkinLat}, ${_visit?.checkinLng}';
String get _planRange {
final v = _visit;
final planTime = v?.planTime;
if (planTime == null || planTime.isEmpty) return '未排期';
final start = formatDate(planTime);
final endTime = v?.planEndTime;
if (endTime == null || endTime.isEmpty) return start;
return '$start ~ ${formatDate(endTime)}';
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final res = await _api.detail(widget.id);
if (!mounted) return;
final d = res.data;
if (!res.isOk || d == null) {
showAppToast(res.message.isEmpty ? '拜访记录不存在' : res.message);
return;
}
if (d.status != VisitStatus.pending) {
showAppToast('该拜访已处理,无需执行');
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) context.pop();
});
return;
}
_spaceDesc.text = d.spaceDesc ?? '';
_visitContent.text = d.visitContent ?? '';
_demands.text = d.demands ?? '';
_intentDesc.text = d.intentDesc ?? '';
setState(() {
_visit = d;
_intentLevel = d.intentLevel;
});
} catch (_) {
if (mounted) showAppToast('加载失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
/// 到场签到:获取 GPS 后落库(后端 update 透传 checkinLng/checkinLat/checkinStatus)。
Future<void> _onCheckin() async {
setState(() => _checking = true);
try {
Position? loc;
try {
final permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
loc = null;
} else {
loc = await Geolocator.getCurrentPosition();
}
} catch (_) {
loc = null;
}
if (loc == null) {
showAppToast('定位失败,请检查定位权限');
return;
}
final res = await _api.update(widget.id, {
'checkinLng': double.parse(loc.longitude.toStringAsFixed(6)),
'checkinLat': double.parse(loc.latitude.toStringAsFixed(6)),
'checkinStatus': 1,
'checkinTime': nowDateTimeStr(),
});
if (!mounted) return;
if (res.isOk) {
showAppToast('签到成功', success: true);
await _load();
} else {
showAppToast(res.message.isEmpty ? '签到失败' : res.message);
}
} catch (_) {
if (mounted) showAppToast('签到失败,请重试');
} finally {
if (mounted) setState(() => _checking = false);
}
}
void _onPhotoTap(int index) {
final slot = _photoSlots[index];
if (slot.url.isNotEmpty) {
_previewPhotos(index);
} else {
_pickPhoto(index);
}
}
Future<void> _pickPhoto(int index) async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
backgroundColor: AppColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.lg)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: Text(
'拍照',
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
),
onTap: () => Navigator.of(ctx).pop(ImageSource.camera),
),
ListTile(
title: Text(
'从相册选择',
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
),
onTap: () => Navigator.of(ctx).pop(ImageSource.gallery),
),
],
),
),
);
if (source == null) return;
if (!mounted) return;
final file = await ImagePicker().pickImage(
source: source,
imageQuality: 80,
);
if (file == null) return;
if (!mounted) return;
_showUploading();
try {
// 严格依赖对象存储:上传失败直接抛出,不写入本地槽位。
final up = await ref.read(apiClientProvider).uploadImage(file.path);
if (mounted) {
setState(() {
_photoSlots[index]
..key = up.key
..url = up.url;
});
showAppToast('上传成功', success: true);
}
} catch (e) {
final message = e is ApiException && e.message.isNotEmpty
? e.message
: '照片上传失败';
showAppToast(message);
} finally {
_hideUploading();
}
}
void _removePhoto(int index) {
setState(() {
_photoSlots[index]
..key = ''
..url = '';
});
}
void _previewPhotos(int index) {
final urls = _photoSlots
.where((s) => s.url.isNotEmpty)
.map((s) => s.url)
.toList();
if (urls.isEmpty) return;
final current = urls.indexOf(_photoSlots[index].url);
showDialog<void>(
context: context,
barrierColor: Colors.black,
builder: (ctx) => Dialog.fullscreen(
backgroundColor: Colors.black,
child: Stack(
children: [
GestureDetector(
onTap: () => Navigator.of(ctx).pop(),
child: PageView.builder(
controller: PageController(
initialPage: current < 0 ? 0 : current,
),
itemCount: urls.length,
itemBuilder: (_, i) => Center(
child: Image.network(urls[i], fit: BoxFit.contain),
),
),
),
Positioned(
right: 12,
top: MediaQuery.paddingOf(ctx).top + 12,
child: GestureDetector(
onTap: () => Navigator.of(ctx).pop(),
child: const XIcon('close', size: 24, color: Colors.white),
),
),
],
),
),
);
}
void _showUploading() {
// 提前抓取根导航器,页面被销毁时也能正常关闭上传遮罩。
_uploadingNavigator = Navigator.of(context, rootNavigator: true);
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const _UploadingDialog(),
);
}
void _hideUploading() {
_uploadingNavigator?.pop();
_uploadingNavigator = null;
}
Future<void> _onSubmit() async {
if (!_checkedIn) {
final go = await showAppConfirm(
title: '尚未签到',
content: '还未完成到场签到,确定直接提交吗?',
);
if (!go) return;
}
final photos = _photoSlots
.where((s) => s.key.isNotEmpty)
.map((s) => s.key)
.toList();
setState(() => _submitting = true);
try {
final payload = <String, dynamic>{
'spaceDesc': _spaceDesc.text,
'visitContent': _visitContent.text,
'demands': _demands.text,
'intentDesc': _intentDesc.text,
};
final intentLevel = _intentLevel;
if (intentLevel != null && intentLevel != 0) {
payload['intentLevel'] = intentLevel;
}
if (photos.isNotEmpty) payload['photos'] = photos;
final upd = await _api.update(widget.id, payload);
if (!mounted) return;
if (!upd.isOk) {
showAppToast(upd.message.isEmpty ? '保存失败' : upd.message);
return;
}
final res = await _api.updateStatus(widget.id, {
'status': VisitStatus.done,
});
if (!mounted) return;
if (res.isOk) {
showAppToast('拜访已完成', success: true);
Future.delayed(const Duration(milliseconds: 600), () {
if (mounted) context.pop();
});
} else {
showAppToast(res.message.isEmpty ? '状态更新失败' : res.message);
}
} catch (_) {
if (mounted) showAppToast('提交失败,请重试');
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final visit = _visit;
return XPageShell(
topBar: XTopBar.nav(title: '拜访执行', onBack: () => context.pop()),
child: visit == null
? (_loading
? const Center(child: XPlaceholder(text: '加载中...'))
: const SizedBox.shrink())
: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildTaskCard(visit),
_sectionTitle(
'到场签到',
icon: 'location',
iconColor: AppColors.tertiary,
),
_buildCheckinCard(),
_sectionTitle(
'现场记录',
icon: 'edit',
iconColor: AppColors.secondary,
),
_buildRecordCard(),
_sectionTitle(
'意向评级',
icon: 'trend-up',
iconColor: AppColors.primary,
),
_buildIntentCard(),
_sectionTitle(
'现场照片',
icon: 'camera',
iconColor: AppColors.primary,
hint: '最多 2 张',
),
_buildPhotoGrid(),
_pillButton(
label: '提交并标记完成',
icon: 'check',
background: AppColors.primary,
fontSize: 16,
shadow: true,
loading: _submitting,
margin: const EdgeInsets.fromLTRB(16, 24, 16, 40),
onPressed: _onSubmit,
),
],
),
),
);
}
Widget _sectionTitle(
String text, {
required String icon,
required Color iconColor,
String? hint,
}) {
return Padding(
padding: const EdgeInsets.fromLTRB(0, 22, 0, 10),
child: Row(
children: [
XIcon(icon, size: 17, color: iconColor),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
if (hint != null) ...[
const SizedBox(width: 6),
Text(
hint,
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
],
),
);
}
Widget _buildTaskCard(VisitInfo visit) {
final customer = visit.customer;
final address = customer?.address;
final subject = visit.subject;
return XCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subject == null || subject.isEmpty ? '客户拜访' : subject,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
const SizedBox(height: 4),
Text(
visit.visitNo,
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
const SizedBox(width: 10),
XChip(
visitStatusText(visit.status),
tone: visitStatusTone(visit.status),
),
],
),
const SizedBox(height: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_taskLine(
'business',
AppColors.primary,
customer?.customerName ?? '未知客户',
),
if (address != null && address.isNotEmpty) ...[
const SizedBox(height: 8),
_taskLine('location', AppColors.textMuted, address),
],
const SizedBox(height: 8),
_taskLine(
'schedule',
AppColors.statusWarn,
'计划时间:$_planRange',
),
],
),
],
),
);
}
Widget _taskLine(String icon, Color color, String text) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
XIcon(icon, size: 18, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 14,
color: AppColors.onSurfaceVariant,
),
),
),
],
);
}
Widget _buildCheckinCard() {
return XCard(
padding: const EdgeInsets.all(16),
child: _checkedIn ? _buildCheckinDone() : _buildCheckinAction(),
);
}
Widget _buildCheckinDone() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
XIcon('check-circle', size: 20, color: AppColors.tertiary),
SizedBox(width: 6),
Text(
'已签到',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.tertiary,
),
),
],
),
const SizedBox(height: 6),
Text(
'签到时间:${formatDateTime(_visit?.checkinTime)}',
style: const TextStyle(
fontSize: 13,
color: AppColors.onSurfaceVariant,
),
),
if (_hasGps) ...[
const SizedBox(height: 6),
Text(
'坐标:$_gpsText',
style: AppTypography.dataMono.copyWith(
fontSize: 13,
color: AppColors.onSurfaceVariant,
),
),
],
],
);
}
Widget _buildCheckinAction() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_pillButton(
label: '获取定位并签到',
icon: 'location',
background: AppColors.tertiary,
height: 46,
radius: 10,
loading: _checking,
onPressed: _onCheckin,
),
const SizedBox(height: 10),
const Text(
'签到将记录当前时间与 GPS 坐标,请到达客户现场后操作',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
);
}
Widget _buildRecordCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
children: [
_textAreaField('现场情况', _spaceDesc, '记录配电房、设备与现场的实际情况...'),
_fieldDivider(),
_textAreaField('沟通纪要', _visitContent, '本次与客户沟通的主要内容...'),
_fieldDivider(),
_textAreaField('客户需求', _demands, '客户提出的需求与关注点...'),
],
),
);
}
Widget _fieldDivider() =>
const Divider(height: 1, thickness: 1, color: AppColors.borderRule);
Widget _textAreaField(
String label,
TextEditingController controller,
String hint,
) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_fieldLabel(label),
const SizedBox(height: 8),
Container(
height: 84,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: controller,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(fontSize: 15, color: AppColors.onSurface),
cursorColor: AppColors.primary,
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: hint,
hintStyle: const TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
),
);
}
Widget _fieldLabel(String label) => Text(
label,
style: AppTypography.labelBold.copyWith(color: AppColors.onSurfaceVariant),
);
Widget _buildIntentCard() {
return XCard(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 14),
child: Row(
children: [
for (var i = 0; i < visitIntentOptions.length; i++) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(child: _intentChip(visitIntentOptions[i])),
],
],
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_fieldLabel('意向说明'),
const SizedBox(height: 8),
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: TextField(
controller: _intentDesc,
style: const TextStyle(
fontSize: 15,
color: AppColors.onSurface,
),
cursorColor: AppColors.primary,
decoration: const InputDecoration(
isDense: true,
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
hintText: '选填,补充判断依据',
hintStyle: TextStyle(
fontSize: 15,
color: AppColors.textPlaceholder,
),
),
),
),
],
),
),
],
),
);
}
Widget _intentChip(ValueOption option) {
final active = _intentLevel == option.value;
return GestureDetector(
onTap: () => setState(() => _intentLevel = option.value),
child: Container(
height: 44,
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? AppColors.surfaceVariant : AppColors.surfaceBright,
border: Border.all(
color: active ? AppColors.primary : AppColors.borderRule,
),
borderRadius: BorderRadius.circular(10),
),
child: Text(
option.label,
style: TextStyle(
fontSize: 13,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
color: active ? AppColors.primary : AppColors.onSurfaceVariant,
),
),
),
);
}
Widget _buildPhotoGrid() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
for (var i = 0; i < _photoSlots.length; i++) ...[
if (i > 0) const SizedBox(width: 10),
Expanded(child: _photoSlot(i)),
],
],
),
const SizedBox(height: 10),
const Row(
children: [
XIcon('warning', size: 14, color: AppColors.outline),
SizedBox(width: 6),
Expanded(
child: Text(
'照片上传依赖对象存储服务,上传失败将无法提交',
style: TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
],
),
],
);
}
Widget _photoSlot(int index) {
final slot = _photoSlots[index];
final filled = slot.url.isNotEmpty;
return GestureDetector(
onTap: () => _onPhotoTap(index),
child: Container(
height: 120,
decoration: BoxDecoration(
color: AppColors.surfaceBright,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
fit: StackFit.expand,
children: [
if (filled)
Image.network(slot.url, fit: BoxFit.cover)
else
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const XIcon('camera', size: 28, color: AppColors.outline),
const SizedBox(height: 6),
Text(
'现场照片 ${index + 1}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textMuted,
),
),
],
),
if (filled)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 10),
color: const Color(0x8C0D1C2C),
child: Row(
children: [
Text(
'现场照片 ${index + 1}',
style: const TextStyle(
fontSize: 11,
color: Colors.white,
),
),
const Spacer(),
GestureDetector(
onTap: () => _removePhoto(index),
child: const XIcon(
'delete',
size: 15,
color: Colors.white,
),
),
],
),
),
),
],
),
),
),
);
}
Widget _pillButton({
required String label,
required String icon,
required Color background,
required VoidCallback onPressed,
bool loading = false,
double height = 48,
double radius = 9999,
double fontSize = 15,
bool shadow = false,
EdgeInsetsGeometry? margin,
}) {
return Padding(
padding: margin ?? EdgeInsets.zero,
child: Container(
height: height,
width: double.infinity,
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(radius),
boxShadow: shadow
? const [
BoxShadow(
color: Color(0x4D0054CD),
blurRadius: 16,
offset: Offset(0, 6),
),
]
: null,
),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(radius),
child: InkWell(
onTap: loading ? null : onPressed,
borderRadius: BorderRadius.circular(radius),
child: Center(
child: loading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon(icon, size: 18, color: Colors.white),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
),
),
),
);
}
}
/// `uni.showLoading({ title: '上传中...' })` 的等价实现。
class _UploadingDialog extends StatelessWidget {
const _UploadingDialog();
@override
Widget build(BuildContext context) {
return const Dialog(
backgroundColor: Color(0xE60D1C2C),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(10)),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
),
SizedBox(width: 10),
Text(
'上传中...',
style: TextStyle(fontSize: 14, color: Colors.white),
),
],
),
),
);
}
}
@@ -0,0 +1,550 @@
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 '../../auth/application/session_controller.dart';
import '../../customer/data/customer_api.dart';
import '../../customer/data/customer_models.dart';
import '../../customer/data/user_api.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访新建 / 编辑页,对应 `pages/visit/form.vue`。
class VisitFormPage extends ConsumerStatefulWidget {
const VisitFormPage({super.key, this.id});
/// 为空表示新增,否则为编辑。
final int? id;
@override
ConsumerState<VisitFormPage> createState() => _VisitFormPageState();
}
class _VisitFormPageState extends ConsumerState<VisitFormPage> {
final TextEditingController _subject = TextEditingController();
final TextEditingController _visitPerson = TextEditingController();
final TextEditingController _visitPhone = TextEditingController();
final TextEditingController _visitContent = TextEditingController();
final List<String> _typeLabels = visitTypeOptions.map((o) => o.label).toList();
final List<String> _modeLabels = visitModeOptions.map((o) => o.label).toList();
final List<String> _priorityLabels =
visitPriorityOptions.map((o) => o.label).toList();
List<CustomerOption> _customers = const [];
List<SysUser> _salesmen = const [];
late final bool _isEdit;
late final bool _isAdmin;
int? _customerId;
int _typeIndex = 0;
int _modeIndex = 0;
int _priorityIndex = 2;
int _salesmanIndex = -1;
String _planTime = '';
String _planEndTime = '';
bool _saving = false;
@override
void initState() {
super.initState();
_isEdit = widget.id != null;
final user = ref.read(sessionProvider).user;
_isAdmin = user?.isAdmin ?? false;
_planTime = todayStr();
_visitPerson.text = user?.realName ?? '';
_init();
}
@override
void dispose() {
_subject.dispose();
_visitPerson.dispose();
_visitPhone.dispose();
_visitContent.dispose();
super.dispose();
}
Future<void> _init() async {
await _loadCustomers();
if (_isAdmin) await _loadSalesmen();
if (widget.id != null) await _loadDetail();
}
Future<void> _loadCustomers() async {
try {
final res = await ref.read(customerApiProvider).options();
if (res.isOk && res.data != null && mounted) {
setState(() => _customers = res.data!);
}
} catch (_) {
// 客户列表加载失败由保存前的校验兜底
}
}
Future<void> _loadSalesmen() async {
try {
final list = await ref.read(userApiProvider).salesmanOptions();
if (mounted) setState(() => _salesmen = list);
} catch (_) {
if (mounted) setState(() => _salesmen = const []);
}
}
Future<void> _loadDetail() async {
final res = await ref.read(visitApiProvider).detail(widget.id!);
if (!res.isOk || res.data == null) {
showAppToast(res.message.isNotEmpty ? res.message : '拜访记录不存在');
return;
}
if (!mounted) return;
final detail = res.data!;
setState(() {
_customerId = detail.customerId;
_subject.text = detail.subject ?? '';
_typeIndex = _indexOf(
visitTypeOptions,
detail.visitType ?? visitTypeOptions[0].value,
);
_modeIndex = _indexOf(
visitModeOptions,
detail.visitMode ?? visitModeOptions[0].value,
);
_priorityIndex = _indexOf(
visitPriorityOptions,
detail.priority ?? visitPriorityOptions[2].value,
);
_planTime = detail.planTime != null && detail.planTime!.isNotEmpty
? _dateOnly(detail.planTime!)
: todayStr();
_planEndTime = _dateOnly(detail.planEndTime ?? '');
_visitPerson.text = detail.visitPerson;
_visitPhone.text = detail.visitPhone ?? '';
_visitContent.text = detail.visitContent ?? '';
final index = _salesmen.indexWhere((s) => s.id == detail.salesmanId);
_salesmanIndex = index >= 0 ? index + 1 : -1;
// 编辑时若客户不在可选范围内(如已终止),补充一条占位以正确回显
if (detail.customerId != 0 &&
!_customers.any((c) => c.id == detail.customerId)) {
_customers = [
CustomerOption(
id: detail.customerId,
customerNo: detail.customer?.customerNo ?? '',
customerName: detail.customer?.customerName ?? '当前客户',
status: 0,
),
..._customers,
];
}
});
}
int _indexOf(List<ValueOption> options, int value) {
final index = options.indexWhere((o) => o.value == value);
return index < 0 ? 0 : index;
}
String _dateOnly(String value) =>
value.length >= 10 ? value.substring(0, 10) : value;
CustomerOption? get _selectedCustomer =>
_customers.where((c) => c.id == _customerId).firstOrNull;
String get _customerLabel {
final customer = _selectedCustomer;
if (customer == null) return '';
return '${customer.customerName}'
'${customer.customerNo.isNotEmpty ? ' · ${customer.customerNo}' : ''}';
}
List<String> get _customerLabels => _customers
.map(
(c) =>
'${c.customerName}${c.customerNo.isNotEmpty ? ' · ${c.customerNo}' : ''}',
)
.toList();
void _pickCustomer() {
final labels = _customerLabels;
if (labels.isEmpty) return;
final index = _customers.indexWhere((c) => c.id == _customerId);
_pickIndex(labels, index < 0 ? 0 : index, (i) {
setState(() => _customerId = _customers[i].id);
});
}
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> _pickDate({required bool isStart}) async {
final initial = parseDate(isStart ? _planTime : _planEndTime) ?? DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: initial,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked == null) return;
setState(() {
if (isStart) {
_planTime = formatDate(picked);
} else {
_planEndTime = formatDate(picked);
}
});
}
Future<void> _onSave() async {
if (_customerId == null) {
showAppToast('请选择关联客户');
return;
}
if (_planTime.isEmpty) {
showAppToast('请选择计划开始日期');
return;
}
setState(() => _saving = true);
try {
final payload = <String, dynamic>{
'customerId': _customerId,
'subject': _subject.text,
'visitType': visitTypeOptions[_typeIndex].value,
'visitMode': visitModeOptions[_modeIndex].value,
'priority': visitPriorityOptions[_priorityIndex].value,
'planTime': _planTime,
'planEndTime': _planEndTime,
'visitPerson': _visitPerson.text,
'visitPhone': _visitPhone.text,
'visitContent': _visitContent.text,
};
final salesmanId =
_salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null;
if (_isAdmin && salesmanId != null) payload['salesmanId'] = salesmanId;
final api = ref.read(visitApiProvider);
final res = _isEdit
? await api.update(widget.id!, payload)
: await api.create(payload);
if (!mounted) return;
if (res.isOk) {
showAppToast('保存成功', success: true);
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) context.pop();
} else {
showAppToast(res.message.isNotEmpty ? res.message : '保存失败');
}
} catch (_) {
showAppToast('保存失败,请重试');
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final items = <Widget>[
_formItem(label: '关联客户', isRequired: true, child: _customerPicker()),
_formItem(
label: '拜访主题',
child: _input(_subject, '例如:配电房增容方案沟通'),
),
_formItem(
label: '拜访类型',
child: _optionPicker(
_typeLabels,
_typeIndex,
(i) => setState(() => _typeIndex = i),
),
),
_formItem(
label: '拜访方式',
child: _optionPicker(
_modeLabels,
_modeIndex,
(i) => setState(() => _modeIndex = i),
),
),
_formItem(
label: '优先级',
child: _optionPicker(
_priorityLabels,
_priorityIndex,
(i) => setState(() => _priorityIndex = i),
),
),
_formItem(
label: '计划开始',
isRequired: true,
child: _datePicker(
_planTime,
'请选择计划开始日期',
() => _pickDate(isStart: true),
),
),
_formItem(
label: '计划结束',
child: _datePicker(_planEndTime, '选填', () => _pickDate(isStart: false)),
),
_formItem(label: '拜访人', child: _input(_visitPerson, '默认当前登录用户')),
_formItem(
label: '联系电话',
child: _input(_visitPhone, '选填', number: true),
),
if (_isAdmin) _formItem(label: '负责业务员', child: _salesmanPicker()),
_formItem(
label: '计划说明',
child: _textarea(_visitContent, '本次拜访的目标与重点事项...'),
),
];
final children = <Widget>[];
for (var i = 0; i < items.length; i++) {
if (i > 0) {
children.add(
const Divider(height: 1, thickness: 1, color: AppColors.borderRule),
);
}
children.add(items[i]);
}
return XPageShell(
topBar: XTopBar.nav(
title: _isEdit ? '编辑拜访计划' : '新建拜访计划',
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, 6, 16, 16),
child: Column(children: children),
),
XPrimaryButton(
label: '保存计划',
loading: _saving,
onPressed: _onSave,
margin: const EdgeInsets.fromLTRB(16, 24, 16, 40),
),
],
),
),
);
}
Widget _formItem({
required String label,
bool isRequired = false,
required Widget child,
}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: AppTypography.labelBold.copyWith(
color: AppColors.onSurfaceVariant,
),
),
if (isRequired)
Text(
' *',
style: AppTypography.labelBold.copyWith(
color: AppColors.statusError,
),
),
],
),
const SizedBox(height: 8),
child,
],
),
);
}
Widget _customerPicker() {
final label = _customerLabel;
final disabled = _isEdit || _customers.isEmpty;
return GestureDetector(
onTap: disabled ? null : _pickCustomer,
child: _pickerBox(
label.isNotEmpty
? label
: (_customers.isNotEmpty ? '请选择客户' : '暂无客户可选'),
label.isEmpty,
_isEdit
? null
: const XIcon('forward', size: 16, color: AppColors.outline),
),
);
}
Widget _optionPicker(
List<String> labels,
int index,
ValueChanged<int> onSelected,
) {
return GestureDetector(
onTap: () => _pickIndex(labels, index, onSelected),
child: _pickerBox(
labels[index],
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 _salesmanPicker() {
final labels = ['默认本人', ..._salesmen.map((s) => s.optionLabel)];
final index = _salesmanIndex;
final text = index >= 0 && index < labels.length ? labels[index] : '默认本人';
return GestureDetector(
onTap: () => _pickIndex(labels, index < 0 ? 0 : index, (i) {
setState(() => _salesmanIndex = i);
}),
child: _pickerBox(
text,
index < 0,
const XIcon('forward', size: 16, color: AppColors.outline),
),
);
}
Widget _pickerBox(String text, bool placeholder, Widget? trailing) {
return Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: _fieldDecoration,
child: Row(
children: [
Expanded(
child: Text(
text,
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,
hintText: hint,
hintStyle: const TextStyle(fontSize: 15, color: AppColors.textPlaceholder),
);
}
@@ -0,0 +1,553 @@
import 'dart:async';
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_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../data/visit_api.dart';
import '../data/visit_models.dart';
/// 拜访列表页,对应 `pages/visit/list.vue`。
class VisitListPage extends ConsumerStatefulWidget {
const VisitListPage({super.key});
@override
ConsumerState<VisitListPage> createState() => _VisitListPageState();
}
class _VisitListPageState extends ConsumerState<VisitListPage> {
static const int _pageSize = 10;
final ScrollController _scrollController = ScrollController();
final TextEditingController _keywordController = TextEditingController();
List<VisitInfo> _list = const [];
VisitStats _stats = VisitStats.empty;
int _total = 0;
int _page = 1;
bool _hasMore = true;
bool _loading = false;
String _keyword = '';
int? _filterStatus;
int? _filterPriority;
String _filterPlanDate = '';
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_reload();
}
@override
void dispose() {
_searchDebounce?.cancel();
_scrollController.dispose();
_keywordController.dispose();
super.dispose();
}
void _onScroll() {
final position = _scrollController.position;
if (position.pixels >= position.maxScrollExtent - 200) _loadMore();
}
Future<void> _loadStats() async {
try {
final res = await ref.read(visitApiProvider).stats();
if (res.isOk && res.data != null && mounted) {
setState(() => _stats = res.data!);
}
} catch (_) {
// 统计失败不阻塞列表
}
}
Future<void> _load({bool reset = false}) async {
if (_loading) return;
setState(() => _loading = true);
if (reset) {
_page = 1;
_hasMore = true;
}
try {
final res = await ref
.read(visitApiProvider)
.list(
page: _page,
pageSize: _pageSize,
keyword: _keyword.isEmpty ? null : _keyword,
status: _filterStatus,
priority: _filterPriority,
planStartDate: _filterPlanDate.isEmpty ? null : _filterPlanDate,
planEndDate: _filterPlanDate.isEmpty ? null : _filterPlanDate,
);
if (res.isOk && res.data != null && mounted) {
final data = res.data!;
setState(() {
_list = reset ? data.list : [..._list, ...data.list];
_total = data.total;
_hasMore = _list.length < data.total;
if (_hasMore) _page += 1;
});
}
} catch (_) {
showAppToast('加载拜访记录失败');
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _reload() {
_loadStats();
_load(reset: true);
}
void _loadMore() {
if (_hasMore && !_loading) _load();
}
Future<void> _refresh() async {
await _loadStats();
await _load(reset: true);
}
void _onSearchChanged(String value) {
_keyword = value;
_searchDebounce?.cancel();
_searchDebounce = Timer(
const Duration(milliseconds: 350),
() => _load(reset: true),
);
}
void _setStatus(int? value) {
if (_filterStatus == value) return;
setState(() {
_filterStatus = value;
_filterPlanDate = '';
});
_load(reset: true);
}
void _setPriority(int? value) {
if (_filterPriority == value) return;
setState(() => _filterPriority = value);
_load(reset: true);
}
void _quickStatus(int status) {
setState(() {
_filterPlanDate = '';
_filterPriority = null;
_filterStatus = status;
});
_load(reset: true);
}
void _quickToday() {
setState(() {
_filterStatus = null;
_filterPriority = null;
_filterPlanDate = todayStr();
});
_load(reset: true);
}
void _quickPriority() {
setState(() {
_filterPlanDate = '';
_filterStatus = VisitStatus.pending;
_filterPriority = 1;
});
_load(reset: true);
}
String _findLabel(List<ValueOption> options, int? value) {
if (value == null) return '';
return options.where((o) => o.value == value).map((o) => o.label).firstOrNull ??
'';
}
@override
Widget build(BuildContext context) {
return XPageShell(
topBar: XTopBar.nav(
title: '客户拜访',
onBack: () => context.pop(),
trailing: GestureDetector(
onTap: () => context.push('/visit/batch'),
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const XIcon('calendar', size: 20, color: AppColors.primary),
const SizedBox(width: 4),
Text(
'批量安排',
style: AppTypography.labelBold.copyWith(
color: AppColors.primary,
),
),
],
),
),
),
child: Stack(
children: [
RefreshIndicator(
onRefresh: _refresh,
color: AppColors.primary,
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 140),
children: [
_buildKpiGrid(),
const SizedBox(height: 14),
XSearchField(
controller: _keywordController,
hintText: '按单号、主题、拜访人搜索...',
onChanged: _onSearchChanged,
),
const SizedBox(height: 8),
_buildFilters(),
..._buildList(),
],
),
),
Positioned(right: 20, bottom: 40, child: _buildFab()),
],
),
);
}
Widget _buildKpiGrid() {
return LayoutBuilder(
builder: (context, constraints) {
final width = (constraints.maxWidth - 10) / 2;
return Wrap(
spacing: 10,
runSpacing: 10,
children: [
_kpiTile(
width,
'${_stats.pending}',
'待执行',
AppColors.primary,
() => _quickStatus(VisitStatus.pending),
),
_kpiTile(
width,
'${_stats.todayPlan}',
'今日计划',
AppColors.tertiary,
_quickToday,
),
_kpiTile(
width,
'${_stats.urgentPending}',
'加急待办',
AppColors.statusError,
_quickPriority,
),
_kpiTile(
width,
'${_stats.conversionRate}%',
'转化率',
AppColors.secondary,
null,
),
],
);
},
);
}
Widget _kpiTile(
double width,
String value,
String label,
Color valueColor,
VoidCallback? onTap,
) {
return GestureDetector(
onTap: onTap,
child: Container(
width: width,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border.all(color: AppColors.borderRule),
borderRadius: BorderRadius.circular(AppRadius.lg),
boxShadow: const [
BoxShadow(color: Color(0x0A000000), blurRadius: 8, offset: Offset(0, 2)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: AppTypography.dataMono.copyWith(
fontSize: 22,
fontWeight: FontWeight.w700,
color: valueColor,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
],
),
),
);
}
Widget _buildFilters() {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_filterChip('全部', _filterStatus == null, () => _setStatus(null)),
for (final option in visitStatusOptions)
_filterChip(
option.label,
_filterStatus == option.value,
() => _setStatus(option.value),
),
],
),
),
),
Padding(
padding: const EdgeInsets.only(top: 6, bottom: 2),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_filterChip(
'全部优先级',
_filterPriority == null,
() => _setPriority(null),
),
for (final option in visitPriorityOptions)
_filterChip(
option.label,
_filterPriority == option.value,
() => _setPriority(option.value),
),
],
),
),
),
],
),
);
}
Widget _filterChip(String label, bool selected, VoidCallback onTap) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: XFilterChip(label: label, selected: selected, onTap: onTap),
);
}
List<Widget> _buildList() {
if (_loading && _list.isEmpty) {
return const [XPlaceholder(text: '加载中...')];
}
if (_list.isEmpty) {
return const [XPlaceholder(text: '暂无拜访记录')];
}
return [
for (final visit in _list) _buildCard(visit),
_buildFooter(),
];
}
Widget _buildCard(VisitInfo visit) {
final subject = visit.subject?.isNotEmpty == true
? visit.subject!
: (visit.customer?.customerName?.isNotEmpty == true
? visit.customer!.customerName!
: '未命名拜访');
final customerName = visit.customer?.customerName?.isNotEmpty == true
? visit.customer!.customerName!
: '未知客户';
final typeText = _findLabel(visitTypeOptions, visit.visitType);
return XCard(
clip: true,
margin: const EdgeInsets.only(top: 12),
onTap: () => context.push('/visit/detail/${visit.id}'),
child: Stack(
children: [
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(width: 4, color: visitStatusBarColor(visit.status)),
),
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Text(
subject,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
),
const SizedBox(width: 8),
XChip(
visitStatusText(visit.status),
tone: visitStatusTone(visit.status),
),
],
),
const SizedBox(height: 8),
Row(
children: [
const XIcon('groups', size: 15, color: AppColors.textMuted),
const SizedBox(width: 6),
Text(
customerName,
style: const TextStyle(
fontSize: 14,
color: AppColors.onSurfaceVariant,
),
),
if (typeText.isNotEmpty) ...[
const SizedBox(width: 6),
XChip(typeText, tone: ChipTone.muted),
],
],
),
const SizedBox(height: 8),
Row(
children: [
_metaItem(
'schedule',
visit.planTime != null && visit.planTime!.isNotEmpty
? formatDateLabel(visit.planTime)
: '未排期',
),
const SizedBox(width: 14),
_metaItem(
'person',
visit.visitPerson.isNotEmpty ? visit.visitPerson : '-',
),
],
),
],
),
),
),
Container(
width: 92,
color: AppColors.surfaceBright,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
XChip(
visit.priority == null ? '' : visitPriorityText(visit.priority),
tone: visitPriorityTone(visit.priority),
),
const SizedBox(height: 8),
Text(
visit.visitNo,
style: AppTypography.dataMono.copyWith(
fontSize: 10,
color: AppColors.textMuted,
),
),
],
),
),
],
),
],
),
);
}
Widget _metaItem(String icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
XIcon(icon, size: 15, color: AppColors.textMuted),
const SizedBox(width: 4),
Text(
text,
style: const TextStyle(fontSize: 13, color: AppColors.textMuted),
),
],
);
}
Widget _buildFooter() {
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 4),
child: Center(
child: Text(
_hasMore ? '加载中...' : '共 $_total 条',
style: const TextStyle(fontSize: 12, color: AppColors.textMuted),
),
),
);
}
Widget _buildFab() {
return GestureDetector(
onTap: () => context.push('/visit/form'),
child: Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(14),
boxShadow: const [
BoxShadow(color: Color(0x4D0054CD), blurRadius: 16, offset: Offset(0, 6)),
],
),
child: const Center(child: XIcon('add', size: 24, color: Colors.white)),
),
);
}
}