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

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

No files matched your search

@@ -0,0 +1,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,
),
),
],
),
),
),
],
],
),
),
),
],
),
);
}
}