feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建
This commit is contained in:
commit
f61b7bf90b
120 files changed
+15229
No files matched your search
@@ -0,0 +1,443 @@
|
||||
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_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 '../../visit/data/visit_api.dart';
|
||||
import '../../visit/data/visit_models.dart';
|
||||
import '../data/customer_api.dart';
|
||||
import '../data/customer_models.dart';
|
||||
|
||||
/// 信息卡里的等宽数值(联系电话 / 传真 / 邮编)。
|
||||
/// 源页面 `.info-value.font-data-mono`:字号 15 覆盖全局 12,字重 500、行高 16、等宽字体。
|
||||
const _monoFallback = <String>['JetBrains Mono', 'SF Mono', 'Menlo', 'Consolas'];
|
||||
const _infoMonoStyle = TextStyle(
|
||||
fontSize: 15,
|
||||
height: 16 / 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFamilyFallback: _monoFallback,
|
||||
);
|
||||
|
||||
/// 客户详情页,对应 `pages/customer/detail.vue`。
|
||||
class CustomerDetailPage extends ConsumerStatefulWidget {
|
||||
const CustomerDetailPage({super.key, required this.id});
|
||||
|
||||
final int id;
|
||||
|
||||
@override
|
||||
ConsumerState<CustomerDetailPage> createState() => _CustomerDetailPageState();
|
||||
}
|
||||
|
||||
class _CustomerDetailPageState extends ConsumerState<CustomerDetailPage> {
|
||||
Customer? _customer;
|
||||
bool _loading = true;
|
||||
List<VisitInfo> _visits = const [];
|
||||
int _visitTotal = 0;
|
||||
bool _visitLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
await Future.wait([_loadCustomer(), _loadVisits()]);
|
||||
}
|
||||
|
||||
Future<void> _loadCustomer() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final res = await ref.read(customerApiProvider).detail(widget.id);
|
||||
if (!mounted) return;
|
||||
if (res.isOk && res.data != null) {
|
||||
setState(() => _customer = res.data);
|
||||
} else {
|
||||
showAppToast(res.message.isEmpty ? '客户不存在' : res.message);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) showAppToast('加载失败');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadVisits() async {
|
||||
setState(() => _visitLoading = true);
|
||||
try {
|
||||
final res = await ref
|
||||
.read(visitApiProvider)
|
||||
.list(customerId: widget.id, page: 1, pageSize: 5);
|
||||
if (!mounted) return;
|
||||
if (res.isOk && res.data != null) {
|
||||
setState(() {
|
||||
_visits = res.data!.list;
|
||||
_visitTotal = res.data!.total;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 拜访记录加载失败不影响客户详情展示
|
||||
} finally {
|
||||
if (mounted) setState(() => _visitLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _goEdit() async {
|
||||
await context.push('/customer/edit?id=${widget.id}');
|
||||
if (!mounted) return;
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _onDelete() async {
|
||||
final confirmed = await showAppConfirm(
|
||||
title: '删除客户',
|
||||
content: '确定删除该客户吗?此操作不可恢复。',
|
||||
destructive: true,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
try {
|
||||
final res = await ref.read(customerApiProvider).delete(widget.id);
|
||||
if (res.isOk) {
|
||||
showAppToast('已删除', success: true);
|
||||
if (mounted) context.pop();
|
||||
} else {
|
||||
showAppToast(res.message.isEmpty ? '删除失败' : res.message);
|
||||
}
|
||||
} catch (_) {
|
||||
showAppToast('删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XPageShell(
|
||||
topBar: XTopBar.nav(
|
||||
title: '客户详情',
|
||||
onBack: () => context.pop(),
|
||||
trailing: GestureDetector(
|
||||
onTap: _goEdit,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const XIcon('edit', size: 20, color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
child: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
final c = _customer;
|
||||
if (c == null) {
|
||||
if (_loading) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'加载中...',
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.textMuted),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildHeaderCard(c),
|
||||
_buildInfoCard(c),
|
||||
_buildSectionHead(),
|
||||
if (_visits.isNotEmpty) _buildVisitCard() else _buildEmptyBlock(),
|
||||
XPrimaryButton(
|
||||
label: '删除客户',
|
||||
onPressed: _onDelete,
|
||||
height: 46,
|
||||
margin: const EdgeInsets.fromLTRB(16, 28, 16, 0),
|
||||
background: AppColors.surface,
|
||||
foreground: AppColors.statusError,
|
||||
borderColor: AppColors.statusError,
|
||||
fontSize: 15,
|
||||
pill: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderCard(Customer c) {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
c.customerName,
|
||||
style: AppTypography.headlineMdMobile.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
XChip(
|
||||
customerStatusText(c.status),
|
||||
tone: customerStatusTone(c.status),
|
||||
),
|
||||
if (c.customerLevel.isNotEmpty)
|
||||
XChip(c.customerLevel, tone: ChipTone.muted),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'客户编号',
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
c.customerNo,
|
||||
style: AppTypography.dataMono.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(Customer c) {
|
||||
final region = [c.province, c.city, c.district]
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
final items = <Widget>[
|
||||
_infoItem('location', '地区', region.isEmpty ? '-' : region.join(' ')),
|
||||
_infoItem('business', '详细地址', c.address.isEmpty ? '-' : c.address),
|
||||
_infoItem('person', '联系人', c.contactPerson.isEmpty ? '-' : c.contactPerson),
|
||||
_infoItem(
|
||||
'call',
|
||||
'联系电话',
|
||||
c.contactPhone.isEmpty ? '-' : c.contactPhone,
|
||||
mono: true,
|
||||
),
|
||||
_infoItem('category', '传真', c.fax.isEmpty ? '-' : c.fax, mono: true),
|
||||
_infoItem('category', '邮编', c.postalCode.isEmpty ? '-' : c.postalCode, mono: true),
|
||||
_infoItem('clock', '创建时间', formatDateTime(c.createdAt)),
|
||||
];
|
||||
|
||||
return XCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0) Container(height: 1, color: AppColors.borderRule),
|
||||
items[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoItem(String icon, String label, String value, {bool mono = false}) {
|
||||
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: AppTypography.bodyMd.copyWith(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: mono
|
||||
? _infoMonoStyle.copyWith(color: AppColors.onSurface)
|
||||
: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionHead() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 22, bottom: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const XIcon('schedule', size: 17, color: AppColors.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'拜访记录',
|
||||
style: AppTypography.titleSm.copyWith(
|
||||
fontSize: 16,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => context.push('/visit/form?customerId=${widget.id}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
children: [
|
||||
const XIcon('add', size: 15, color: AppColors.primary),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
'发起拜访',
|
||||
style: AppTypography.labelBold.copyWith(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVisitCard() {
|
||||
final items = <Widget>[];
|
||||
for (var i = 0; i < _visits.length; i++) {
|
||||
if (i > 0) items.add(Container(height: 1, color: AppColors.borderRule));
|
||||
items.add(_visitItem(_visits[i]));
|
||||
}
|
||||
if (_visitTotal > _visits.length) {
|
||||
items.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Text(
|
||||
'共 $_visitTotal 条拜访,仅显示最近 ${_visits.length} 条',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return XCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Column(children: items),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _visitItem(VisitInfo v) {
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/visit/detail/${v.id}'),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 13, 0, 13),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: -10,
|
||||
top: 1,
|
||||
bottom: 1,
|
||||
child: Container(
|
||||
width: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: visitStatusBarColor(v.status),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
(v.subject == null || v.subject!.isEmpty)
|
||||
? '客户拜访'
|
||||
: v.subject!,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
XChip(
|
||||
visitStatusText(v.status),
|
||||
tone: visitStatusTone(v.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${v.visitNo} · ${formatDate(v.planTime)}',
|
||||
style: AppTypography.dataMono.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const XIcon('forward', size: 16, color: AppColors.textPlaceholder),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyBlock() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_visitLoading ? '加载中...' : '暂无拜访记录',
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontSize: 13,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/network/api_response.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.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 '../data/customer_api.dart';
|
||||
import '../data/customer_models.dart';
|
||||
import '../data/user_api.dart';
|
||||
|
||||
/// 设施类型下拉项,照抄源页面 `levelOptions`。
|
||||
const _levelOptions = ['医院', '学校', '企业', '其他'];
|
||||
|
||||
/// 状态下拉的可选值,照抄源页面 `statusOptions`。
|
||||
const _statusValues = [1, 2, 3, 4];
|
||||
|
||||
/// 省市区三级选择器的内置示例数据(源页面用 uni 的 region picker)。
|
||||
const _regionData = <String, Map<String, List<String>>>{
|
||||
'北京市': {
|
||||
'北京市': ['东城区', '西城区', '朝阳区', '海淀区', '丰台区', '石景山区', '通州区'],
|
||||
},
|
||||
'上海市': {
|
||||
'上海市': ['黄浦区', '徐汇区', '长宁区', '静安区', '普陀区', '浦东新区', '闵行区'],
|
||||
},
|
||||
'广东省': {
|
||||
'广州市': ['越秀区', '天河区', '海珠区', '白云区', '番禺区'],
|
||||
'深圳市': ['福田区', '南山区', '罗湖区', '宝安区', '龙岗区'],
|
||||
'珠海市': ['香洲区', '金湾区', '斗门区'],
|
||||
},
|
||||
'浙江省': {
|
||||
'杭州市': ['上城区', '拱墅区', '西湖区', '滨江区', '余杭区'],
|
||||
'宁波市': ['海曙区', '江北区', '鄞州区', '北仑区'],
|
||||
'温州市': ['鹿城区', '龙湾区', '瓯海区'],
|
||||
},
|
||||
'江苏省': {
|
||||
'南京市': ['玄武区', '秦淮区', '鼓楼区', '江宁区'],
|
||||
'苏州市': ['姑苏区', '吴中区', '相城区', '工业园区'],
|
||||
'无锡市': ['梁溪区', '滨湖区', '新吴区'],
|
||||
},
|
||||
'四川省': {
|
||||
'成都市': ['锦江区', '青羊区', '武侯区', '成华区', '高新区'],
|
||||
'绵阳市': ['涪城区', '游仙区', '安州区'],
|
||||
},
|
||||
};
|
||||
|
||||
/// 客户编辑页(新增 / 编辑),对应 `pages/customer/edit.vue`。
|
||||
class CustomerEditPage extends ConsumerStatefulWidget {
|
||||
const CustomerEditPage({super.key, this.id});
|
||||
|
||||
/// 为 null 表示新增。
|
||||
final int? id;
|
||||
|
||||
@override
|
||||
ConsumerState<CustomerEditPage> createState() => _CustomerEditPageState();
|
||||
}
|
||||
|
||||
class _CustomerEditPageState extends ConsumerState<CustomerEditPage> {
|
||||
final _customerName = TextEditingController();
|
||||
final _customerNo = TextEditingController();
|
||||
final _address = TextEditingController();
|
||||
final _contactPerson = TextEditingController();
|
||||
final _contactPhone = TextEditingController();
|
||||
final _postalCode = TextEditingController();
|
||||
final _fax = TextEditingController();
|
||||
|
||||
late final bool _isEdit = widget.id != null;
|
||||
bool _isAdmin = false;
|
||||
bool _saving = false;
|
||||
|
||||
String _province = '';
|
||||
String _city = '';
|
||||
String _district = '';
|
||||
String _customerLevel = '';
|
||||
int _status = 1;
|
||||
List<SysUser> _salesmen = const [];
|
||||
int _salesmanIndex = 0;
|
||||
|
||||
List<String> get _salesmanLabels =>
|
||||
['未指定', ..._salesmen.map((s) => s.optionLabel)];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isAdmin = ref.read(sessionProvider).user?.isAdmin ?? false;
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_customerName.dispose();
|
||||
_customerNo.dispose();
|
||||
_address.dispose();
|
||||
_contactPerson.dispose();
|
||||
_contactPhone.dispose();
|
||||
_postalCode.dispose();
|
||||
_fax.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (_isAdmin) {
|
||||
try {
|
||||
final list = await ref.read(userApiProvider).salesmanOptions();
|
||||
if (mounted) setState(() => _salesmen = list);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _salesmen = const []);
|
||||
}
|
||||
}
|
||||
|
||||
final id = widget.id;
|
||||
if (id == null) return;
|
||||
try {
|
||||
final res = await ref.read(customerApiProvider).detail(id);
|
||||
if (!mounted) return;
|
||||
final d = res.data;
|
||||
if (res.isOk && d != null) {
|
||||
setState(() {
|
||||
_customerName.text = d.customerName;
|
||||
_customerNo.text = d.customerNo;
|
||||
_province = d.province;
|
||||
_city = d.city;
|
||||
_district = d.district;
|
||||
_address.text = d.address;
|
||||
_contactPerson.text = d.contactPerson;
|
||||
_contactPhone.text = d.contactPhone;
|
||||
_postalCode.text = d.postalCode;
|
||||
_fax.text = d.fax;
|
||||
_customerLevel = d.customerLevel;
|
||||
_status = d.status;
|
||||
final si = _salesmen.indexWhere((s) => s.id == d.salesmanId);
|
||||
_salesmanIndex = si >= 0 ? si + 1 : 0;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
// 详情加载失败保持空表单
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSave() async {
|
||||
if (_customerName.text.trim().isEmpty) {
|
||||
showAppToast('请输入客户名称');
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
'customerName': _customerName.text,
|
||||
if (_isEdit) 'customerNo': _customerNo.text,
|
||||
'province': _province,
|
||||
'city': _city,
|
||||
'district': _district,
|
||||
'address': _address.text,
|
||||
'contactPerson': _contactPerson.text,
|
||||
'contactPhone': _contactPhone.text,
|
||||
'postalCode': _postalCode.text,
|
||||
'fax': _fax.text,
|
||||
'customerLevel': _customerLevel,
|
||||
'status': _status,
|
||||
};
|
||||
// ADMIN 可指定负责业务员;未指定时传 null 以清空。
|
||||
if (_isAdmin) {
|
||||
payload['salesmanId'] =
|
||||
_salesmanIndex > 0 ? _salesmen[_salesmanIndex - 1].id : null;
|
||||
}
|
||||
|
||||
try {
|
||||
final api = ref.read(customerApiProvider);
|
||||
final ApiResponse<Customer> res;
|
||||
if (_isEdit) {
|
||||
res = await api.update(widget.id!, payload);
|
||||
} else {
|
||||
payload.remove('customerNo');
|
||||
res = await api.create(payload);
|
||||
}
|
||||
if (res.isOk) {
|
||||
showAppToast('保存成功', success: true);
|
||||
if (mounted) context.pop();
|
||||
} else {
|
||||
showAppToast(res.message.isEmpty ? '保存失败' : res.message);
|
||||
}
|
||||
} catch (_) {
|
||||
showAppToast('保存失败,请重试');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
String get _regionText => _province.isEmpty
|
||||
? '请选择省市区'
|
||||
: [_province, _city, _district].where((e) => e.isNotEmpty).join(' ');
|
||||
|
||||
Future<void> _pickRegion() async {
|
||||
final res = await showModalBottomSheet<({String province, String city, String district})>(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => const _RegionPickerSheet(),
|
||||
);
|
||||
if (res == null) return;
|
||||
setState(() {
|
||||
_province = res.province;
|
||||
_city = res.city;
|
||||
_district = res.district;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _pickLevel() async {
|
||||
final index = await _pickIndex(
|
||||
'选择设施类型',
|
||||
_levelOptions,
|
||||
selected: _levelOptions.indexOf(_customerLevel),
|
||||
);
|
||||
if (index == null) return;
|
||||
setState(() => _customerLevel = _levelOptions[index]);
|
||||
}
|
||||
|
||||
Future<void> _pickStatus() async {
|
||||
final index = await _pickIndex(
|
||||
'选择状态',
|
||||
_statusValues.map(customerStatusText).toList(),
|
||||
selected: _statusValues.indexOf(_status),
|
||||
);
|
||||
if (index == null) return;
|
||||
setState(() => _status = _statusValues[index]);
|
||||
}
|
||||
|
||||
Future<void> _pickSalesman() async {
|
||||
final index = await _pickIndex(
|
||||
'选择负责业务员',
|
||||
_salesmanLabels,
|
||||
selected: _salesmanIndex,
|
||||
);
|
||||
if (index == null) return;
|
||||
setState(() => _salesmanIndex = index);
|
||||
}
|
||||
|
||||
Future<int?> _pickIndex(
|
||||
String title,
|
||||
List<String> options, {
|
||||
int? selected,
|
||||
}) {
|
||||
return showModalBottomSheet<int>(
|
||||
context: context,
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: AppTypography.titleSm.copyWith(fontSize: 16),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(ctx).pop(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const XIcon('close', size: 20, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(height: 1, color: AppColors.borderRule),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (_, i) {
|
||||
final sel = selected == i;
|
||||
return InkWell(
|
||||
onTap: () => Navigator.of(ctx).pop(i),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: AppColors.borderRule),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
options[i],
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: sel
|
||||
? AppColors.primary
|
||||
: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (sel)
|
||||
const XIcon('check', size: 18, color: AppColors.primary),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return XPageShell(
|
||||
topBar: XTopBar.nav(
|
||||
title: _isEdit ? '编辑客户' : '新增客户',
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 60),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildForm(),
|
||||
XPrimaryButton(
|
||||
label: '保存',
|
||||
loading: _saving,
|
||||
onPressed: _onSave,
|
||||
margin: const EdgeInsets.fromLTRB(16, 24, 16, 40),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm() {
|
||||
final fields = <Widget>[
|
||||
_field(
|
||||
'客户名称',
|
||||
required: true,
|
||||
control: _input(_customerName, '请输入客户名称'),
|
||||
),
|
||||
_field(
|
||||
'客户编号',
|
||||
control: _input(_customerNo, '保存后自动生成', enabled: !_isEdit),
|
||||
),
|
||||
_field(
|
||||
'省市区',
|
||||
control: _picker(
|
||||
text: _regionText,
|
||||
placeholder: _province.isEmpty,
|
||||
onTap: _pickRegion,
|
||||
),
|
||||
),
|
||||
_field('详细地址', control: _input(_address, '请输入详细地址')),
|
||||
_field('联系人', control: _input(_contactPerson, '请输入联系人')),
|
||||
_field(
|
||||
'联系电话',
|
||||
control: _input(_contactPhone, '请输入联系电话', number: true),
|
||||
),
|
||||
_field('邮编', control: _input(_postalCode, '选填', number: true)),
|
||||
_field('传真', control: _input(_fax, '选填')),
|
||||
_field(
|
||||
'设施类型',
|
||||
control: _picker(
|
||||
text: _customerLevel.isEmpty ? '请选择设施类型' : _customerLevel,
|
||||
placeholder: _customerLevel.isEmpty,
|
||||
onTap: _pickLevel,
|
||||
),
|
||||
),
|
||||
_field(
|
||||
'状态',
|
||||
control: _picker(
|
||||
text: customerStatusText(_status),
|
||||
placeholder: false,
|
||||
onTap: _pickStatus,
|
||||
),
|
||||
),
|
||||
if (_isAdmin)
|
||||
_field(
|
||||
'负责业务员',
|
||||
control: _picker(
|
||||
text: _salesmanIndex > 0 ? _salesmanLabels[_salesmanIndex] : '未指定',
|
||||
placeholder: _salesmanIndex <= 0,
|
||||
onTap: _pickSalesman,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return XCard(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < fields.length; i++) ...[
|
||||
if (i > 0) Container(height: 1, color: AppColors.borderRule),
|
||||
fields[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field(String label, {bool required = false, required Widget control}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: label),
|
||||
if (required)
|
||||
const TextSpan(
|
||||
text: ' *',
|
||||
style: TextStyle(color: AppColors.statusError),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: AppTypography.labelBold.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
control,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _input(
|
||||
TextEditingController controller,
|
||||
String hint, {
|
||||
bool enabled = true,
|
||||
bool number = false,
|
||||
}) {
|
||||
return Container(
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
enabled: enabled,
|
||||
keyboardType: number ? TextInputType.number : TextInputType.text,
|
||||
style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
hintText: hint,
|
||||
hintStyle: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _picker({
|
||||
required String text,
|
||||
required bool placeholder,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
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,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: placeholder
|
||||
? AppColors.textPlaceholder
|
||||
: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const XIcon('forward', size: 16, color: AppColors.outline),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 省 / 市 / 区三级选择器(自建,替代 uni 的 region picker)。
|
||||
class _RegionPickerSheet extends StatefulWidget {
|
||||
const _RegionPickerSheet();
|
||||
|
||||
@override
|
||||
State<_RegionPickerSheet> createState() => _RegionPickerSheetState();
|
||||
}
|
||||
|
||||
class _RegionPickerSheetState extends State<_RegionPickerSheet> {
|
||||
int _level = 0;
|
||||
String? _province;
|
||||
String? _city;
|
||||
|
||||
void _back() {
|
||||
setState(() {
|
||||
if (_level == 2) {
|
||||
_city = null;
|
||||
} else {
|
||||
_province = null;
|
||||
}
|
||||
_level--;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final List<String> items;
|
||||
if (_level == 0) {
|
||||
items = _regionData.keys.toList();
|
||||
} else if (_level == 1) {
|
||||
items = _regionData[_province]!.keys.toList();
|
||||
} else {
|
||||
items = _regionData[_province!]![_city!]!;
|
||||
}
|
||||
final crumbs = [_province, _city].whereType<String>().join(' ');
|
||||
|
||||
return SafeArea(
|
||||
top: false,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_level > 0)
|
||||
GestureDetector(
|
||||
onTap: _back,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const XIcon('back', size: 20, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'选择省市区',
|
||||
style: AppTypography.titleSm.copyWith(fontSize: 16),
|
||||
),
|
||||
if (crumbs.isNotEmpty)
|
||||
Text(
|
||||
crumbs,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: const XIcon('close', size: 20, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(height: 1, color: AppColors.borderRule),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final name = items[i];
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (_level == 0) {
|
||||
setState(() {
|
||||
_province = name;
|
||||
_level = 1;
|
||||
});
|
||||
} else if (_level == 1) {
|
||||
setState(() {
|
||||
_city = name;
|
||||
_level = 2;
|
||||
});
|
||||
} else {
|
||||
Navigator.of(context).pop(
|
||||
(
|
||||
province: _province!,
|
||||
city: _city!,
|
||||
district: name,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: AppColors.borderRule),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
style: AppTypography.bodyMd.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/tab_index.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.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/customer_api.dart';
|
||||
import '../data/customer_models.dart';
|
||||
|
||||
/// 客户 Tab,对应 `components/tabs/CustomerTab.vue`。
|
||||
class CustomerTab extends ConsumerStatefulWidget {
|
||||
const CustomerTab({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CustomerTab> createState() => _CustomerTabState();
|
||||
}
|
||||
|
||||
class _CustomerTabState extends ConsumerState<CustomerTab> {
|
||||
static const _statusOptions = <({String label, int? value})>[
|
||||
(label: '全部', value: null),
|
||||
(label: '意向', value: 1),
|
||||
(label: '签约', value: 2),
|
||||
(label: '运维中', value: 3),
|
||||
(label: '终止', value: 4),
|
||||
];
|
||||
|
||||
static const _levelOptions = <({String label, String? value})>[
|
||||
(label: '全部', value: null),
|
||||
(label: '医院', value: '医院'),
|
||||
(label: '学校', value: '学校'),
|
||||
(label: '企业', value: '企业'),
|
||||
];
|
||||
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
Timer? _searchTimer;
|
||||
List<Customer> _list = const [];
|
||||
bool _loading = false;
|
||||
int? _filterStatus;
|
||||
String? _filterLevel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchTimer?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
// 列表由后端按 keyword / status 过滤;设施类型后端未提供查询条件,前端过滤
|
||||
final keyword = _searchController.text.trim();
|
||||
final res = await ref
|
||||
.read(customerApiProvider)
|
||||
.list(
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
keyword: keyword.isEmpty ? null : keyword,
|
||||
status: _filterStatus,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (res.isOk && res.data != null) {
|
||||
setState(() => _list = res.data!.list);
|
||||
}
|
||||
} catch (_) {
|
||||
showAppToast('加载客户失败');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _onSearchChanged(String _) {
|
||||
_searchTimer?.cancel();
|
||||
_searchTimer = Timer(const Duration(milliseconds: 350), _load);
|
||||
}
|
||||
|
||||
void _onStatusChip(int? value) {
|
||||
if (_filterStatus == value) return;
|
||||
setState(() => _filterStatus = value);
|
||||
_load();
|
||||
}
|
||||
|
||||
String? _statusAction(int status) {
|
||||
if (status == 1) return '跟进展';
|
||||
if (status == 2) return '安排';
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _goDetail(Customer c) async {
|
||||
await context.push('/customer/detail/${c.id}');
|
||||
if (mounted) _load();
|
||||
}
|
||||
|
||||
Future<void> _goEdit(Customer c) async {
|
||||
await context.push('/customer/edit?id=${c.id}');
|
||||
if (mounted) _load();
|
||||
}
|
||||
|
||||
Future<void> _goCreate() async {
|
||||
await context.push('/customer/edit');
|
||||
if (mounted) _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen(tabIndexProvider, (previous, next) {
|
||||
if (next == AppTab.customer) _load();
|
||||
});
|
||||
|
||||
final visible = _filterLevel == null
|
||||
? _list
|
||||
: _list.where((c) => c.customerLevel == _filterLevel).toList();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
XPageShell(
|
||||
topBar: XTopBar.brand(
|
||||
title: '鑫潼智电',
|
||||
trailingColor: AppColors.primary,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildPageHead(),
|
||||
_buildSearchBar(),
|
||||
_buildFilters(),
|
||||
Expanded(child: _buildList(visible)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 20,
|
||||
bottom: 110,
|
||||
child: GestureDetector(
|
||||
onTap: _goCreate,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x4D0054CD),
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const XIcon('add', size: 24, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPageHead() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('客户', style: AppTypography.headlineMd),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: const Text(
|
||||
'管理和跟踪您的运营客户组合',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||
child: XSearchField(
|
||||
controller: _searchController,
|
||||
hintText: '按客户名称、联系人或电话搜索...',
|
||||
onChanged: _onSearchChanged,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilters() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildFilterGroup(
|
||||
'生命周期状态',
|
||||
Row(
|
||||
children: [
|
||||
for (final option in _statusOptions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: XFilterChip(
|
||||
label: option.label,
|
||||
selected: _filterStatus == option.value,
|
||||
onTap: () => _onStatusChip(option.value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildFilterGroup(
|
||||
'设施类型',
|
||||
Row(
|
||||
children: [
|
||||
for (final option in _levelOptions)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: XFilterChip(
|
||||
label: option.label,
|
||||
selected: _filterLevel == option.value,
|
||||
onTap: () => setState(() => _filterLevel = option.value),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterGroup(String label, Widget chips) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 6),
|
||||
child: Text(
|
||||
label,
|
||||
style: AppTypography.captionSm.copyWith(
|
||||
color: AppColors.textMuted,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: chips,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildList(List<Customer> visible) {
|
||||
if (_loading && _list.isEmpty) {
|
||||
return const XPlaceholder(text: '加载中...');
|
||||
}
|
||||
if (visible.isEmpty) {
|
||||
return const XPlaceholder(text: '暂无客户');
|
||||
}
|
||||
return RefreshIndicator(
|
||||
color: AppColors.primary,
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 140),
|
||||
itemCount: visible.length,
|
||||
itemBuilder: (context, index) => _customerCard(visible[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _customerCard(Customer c) {
|
||||
final action = _statusAction(c.status);
|
||||
|
||||
return XCard(
|
||||
padding: EdgeInsets.zero,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
onTap: () => _goDetail(c),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 左侧状态色条 / 右侧编号面板均贴边,圆角按卡片圆角裁出
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: customerStatusBarColor(c.status),
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
left: Radius.circular(AppRadius.lg),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 86,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
borderRadius: BorderRadius.horizontal(
|
||||
right: Radius.circular(AppRadius.lg),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, right: 86),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
c.customerName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
XChip(
|
||||
customerStatusText(c.status),
|
||||
tone: customerStatusTone(c.status),
|
||||
),
|
||||
if (c.customerLevel.isNotEmpty)
|
||||
XChip(c.customerLevel, tone: ChipTone.muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const XIcon('more', size: 20, color: AppColors.textMuted),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
const XIcon(
|
||||
'location',
|
||||
size: 16,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
c.locationText,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
const XIcon('person', size: 16, color: AppColors.textMuted),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
c.contactPerson.isEmpty ? '-' : c.contactPerson,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 86,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'客户编号',
|
||||
style: TextStyle(fontSize: 11, color: AppColors.textMuted),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
c.customerNo,
|
||||
style: AppTypography.dataMono.copyWith(
|
||||
fontSize: 11,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(
|
||||
onTap: () => _goEdit(c),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1A712AE2),
|
||||
borderRadius: BorderRadius.circular(AppRadius.regular),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const XIcon(
|
||||
'calendar',
|
||||
size: 14,
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Text(
|
||||
action,
|
||||
style: AppTypography.captionSm.copyWith(
|
||||
color: AppColors.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user