feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建
This commit is contained in:
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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user