Files
jiu/client/lib/screens/partners/partners_screen.dart
T
wangjia 480ce836bb
Deploy / build-linux-web (push) Successful in 53s
Deploy / build-windows (push) Successful in 1m48s
Deploy / build-macos (push) Successful in 1m17s
Deploy / build-android (push) Successful in 4m13s
Deploy / build-ios (push) Successful in 9s
Deploy / release-deploy (push) Successful in 1m37s
chore: release v1.0.18
移动端响应式适配(抽屉导航/列表卡片/弹窗自适应)、Android 正式签名与 APK 发布、
iOS(TestFlight) 工程与 CI、多平台构建流水线、相关文档同步。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 07:55:33 +08:00

554 lines
19 KiB
Dart

import '../../core/utils/dialog_util.dart';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/page_result.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart';
import '../../models/partner.dart';
import '../../providers/partner_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
class PartnersScreen extends ConsumerStatefulWidget {
const PartnersScreen({super.key});
@override
ConsumerState<PartnersScreen> createState() => _PartnersScreenState();
}
class _PartnersScreenState extends ConsumerState<PartnersScreen> {
final _supplierSearchCtrl = TextEditingController();
final _customerSearchCtrl = TextEditingController();
Timer? _supplierDebounce;
Timer? _customerDebounce;
@override
void dispose() {
_supplierSearchCtrl.dispose();
_customerSearchCtrl.dispose();
_supplierDebounce?.cancel();
_customerDebounce?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PageScaffold(
title: '往来单位',
tabs: const [
Tab(text: '供应商'),
Tab(text: '客户'),
],
tabViews: [
_buildSupplierTab(),
_buildCustomerTab(),
],
);
}
Widget _buildSupplierTab() {
final asyncPartners = ref.watch(supplierListProvider);
return asyncPartners.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用',
style: const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>
ref.read(supplierListProvider.notifier).reload(),
child: const Text('重试'),
),
],
),
),
data: (result) => _buildPartnerList(
result,
isSupplier: true,
searchCtrl: _supplierSearchCtrl,
onSearchChanged: (v) {
_supplierDebounce?.cancel();
_supplierDebounce = Timer(const Duration(milliseconds: 300), () {
ref.read(supplierListProvider.notifier).setKeyword(v);
});
},
onPageChanged: (p) =>
ref.read(supplierListProvider.notifier).setPage(p),
onPageSizeChanged: (s) =>
ref.read(supplierListProvider.notifier).setPageSize(s),
onAdd: () => _showPartnerDialog(context, isSupplier: true),
onEdit: (p) =>
_showPartnerDialog(context, isSupplier: true, partner: p),
onDelete: (p) => _confirmDelete(context, p, isSupplier: true),
),
);
}
Widget _buildCustomerTab() {
final asyncPartners = ref.watch(customerListProvider);
return asyncPartners.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.cloud_off, size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
const Text('暂无数据,网络不可用',
style: const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>
ref.read(customerListProvider.notifier).reload(),
child: const Text('重试'),
),
],
),
),
data: (result) => _buildPartnerList(
result,
isSupplier: false,
searchCtrl: _customerSearchCtrl,
onSearchChanged: (v) {
_customerDebounce?.cancel();
_customerDebounce = Timer(const Duration(milliseconds: 300), () {
ref.read(customerListProvider.notifier).setKeyword(v);
});
},
onPageChanged: (p) =>
ref.read(customerListProvider.notifier).setPage(p),
onPageSizeChanged: (s) =>
ref.read(customerListProvider.notifier).setPageSize(s),
onAdd: () => _showPartnerDialog(context, isSupplier: false),
onEdit: (p) =>
_showPartnerDialog(context, isSupplier: false, partner: p),
onDelete: (p) => _confirmDelete(context, p, isSupplier: false),
),
);
}
Widget _buildPartnerList(
PageResult<Partner> result, {
required bool isSupplier,
required TextEditingController searchCtrl,
required void Function(String) onSearchChanged,
required void Function(int) onPageChanged,
required void Function(int) onPageSizeChanged,
required VoidCallback onAdd,
required void Function(Partner) onEdit,
required void Function(Partner) onDelete,
}) {
final partners = result.data;
Widget partnerCard(Partner p) {
return MobileListCard(
title: Text(p.name),
subtitle: p.code != null ? Text(p.code!) : null,
trailing: Text(
p.status == 'enabled' ? '正常' : '禁用',
style: TextStyle(
fontSize: 12,
color: p.status == 'enabled'
? AppTheme.success
: AppTheme.textSecondary,
),
),
fields: [
if (p.contact?.isNotEmpty == true) MobileCardField('联系人', p.contact),
if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone),
if (p.address?.isNotEmpty == true) MobileCardField('地址', p.address),
],
actions: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑', style: TextStyle(fontSize: 13)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: const Text('删除',
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
),
],
);
}
return DataTableCard(
totalCount: result.total,
page: result.page,
pageSize: result.pageSize,
onPageChanged: onPageChanged,
onPageSizeChanged: onPageSizeChanged,
mobileCards: partners.map(partnerCard).toList(),
toolbar: Row(
children: [
ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: Text(isSupplier ? '新建' : '新建'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: isSupplier ? '供应商列表' : '客户列表',
headers: ['编码', '名称', '联系人', '电话', '地址', '备注'],
rows: partners.map((p) => [
p.code ?? '', p.name, p.contact ?? '',
p.phone ?? '', p.address ?? '', p.remark ?? '',
]).toList(),
),
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
const Spacer(),
SizedBox(
width: 200,
child: TextField(
controller: searchCtrl,
decoration: InputDecoration(
hintText: isSupplier ? '搜索供应商名称/编码' : '搜索客户名称/编码',
prefixIcon: const Icon(Icons.search, size: 16),
hintStyle: const TextStyle(fontSize: 13),
),
onChanged: onSearchChanged,
),
),
],
),
columns: [
const DataColumn(label: Text('编码')),
const DataColumn(label: Text('名称')),
const DataColumn(label: Text('联系人')),
const DataColumn(label: Text('联系电话')),
const DataColumn(label: Text('地址')),
const DataColumn(label: Text('状态')),
const DataColumn(label: Text('操作')),
],
rows: partners.isEmpty
? [
DataRow(cells: [
const DataCell(SizedBox()),
DataCell(Text(isSupplier ? '暂无供应商' : '暂无客户',
style:
const TextStyle(color: AppTheme.textSecondary))),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
])
]
: partners
.map((p) => DataRow(
cells: [
DataCell(Text(p.code ?? '-',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppTheme.textSecondary))),
DataCell(SizedBox(
width: 180,
child: Text(p.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500)),
)),
DataCell(Text(p.contact ?? '-')),
DataCell(Text(p.phone ?? '-')),
DataCell(SizedBox(
width: 160,
child: Text(p.address ?? '-',
overflow: TextOverflow.ellipsis),
)),
DataCell(Text(
p.status == 'enabled' ? '正常' : '禁用',
style: TextStyle(
fontSize: 13,
color: p.status == 'enabled'
? AppTheme.success
: AppTheme.textSecondary,
),
)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: const Text('删除',
style: TextStyle(
fontSize: 12,
color: AppTheme.danger)),
),
],
)),
],
))
.toList(),
);
}
void _showPartnerDialog(BuildContext context,
{required bool isSupplier, Partner? partner}) {
showAppDialog(
context: context,
builder: (ctx) => _PartnerFormDialog(
isSupplier: isSupplier,
partner: partner,
onSaved: () {
if (isSupplier) {
ref.read(supplierListProvider.notifier).reload();
} else {
ref.read(customerListProvider.notifier).reload();
}
},
),
);
}
Future<void> _confirmDelete(BuildContext context, Partner partner,
{required bool isSupplier}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: Text('确认删除「${partner.name}」?此操作不可恢复。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger,
foregroundColor: Colors.white),
child: const Text('删除'),
),
],
),
);
if (confirmed == true && mounted) {
try {
if (isSupplier) {
await ref
.read(supplierListProvider.notifier)
.deletePartner(partner.id);
} else {
await ref
.read(customerListProvider.notifier)
.deletePartner(partner.id);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('删除成功'),
backgroundColor: AppTheme.success));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('删除失败:$e'),
backgroundColor: AppTheme.danger));
}
}
}
}
}
class _PartnerFormDialog extends ConsumerStatefulWidget {
final bool isSupplier;
final Partner? partner;
final VoidCallback onSaved;
const _PartnerFormDialog({
required this.isSupplier,
this.partner,
required this.onSaved,
});
@override
ConsumerState<_PartnerFormDialog> createState() =>
_PartnerFormDialogState();
}
class _PartnerFormDialogState extends ConsumerState<_PartnerFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _codeCtrl;
late final TextEditingController _contactCtrl;
late final TextEditingController _phoneCtrl;
late final TextEditingController _addressCtrl;
late final TextEditingController _remarkCtrl;
bool _saving = false;
@override
void initState() {
super.initState();
final p = widget.partner;
_nameCtrl = TextEditingController(text: p?.name ?? '');
_codeCtrl = TextEditingController(text: p?.code ?? '');
_contactCtrl = TextEditingController(text: p?.contact ?? '');
_phoneCtrl = TextEditingController(text: p?.phone ?? '');
_addressCtrl = TextEditingController(text: p?.address ?? '');
_remarkCtrl = TextEditingController(text: p?.remark ?? '');
}
@override
void dispose() {
_nameCtrl.dispose();
_codeCtrl.dispose();
_contactCtrl.dispose();
_phoneCtrl.dispose();
_addressCtrl.dispose();
_remarkCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
final data = {
'name': _nameCtrl.text.trim(),
'type': widget.isSupplier ? 'supplier' : 'customer',
if (_codeCtrl.text.trim().isNotEmpty) 'code': _codeCtrl.text.trim(),
if (_contactCtrl.text.trim().isNotEmpty)
'contact': _contactCtrl.text.trim(),
if (_phoneCtrl.text.trim().isNotEmpty) 'phone': _phoneCtrl.text.trim(),
if (_addressCtrl.text.trim().isNotEmpty)
'address': _addressCtrl.text.trim(),
if (_remarkCtrl.text.trim().isNotEmpty) 'remark': _remarkCtrl.text.trim(),
};
try {
final notifier = widget.isSupplier
? ref.read(supplierListProvider.notifier)
: ref.read(customerListProvider.notifier);
if (widget.partner != null) {
await notifier.updatePartner(widget.partner!.id, data);
} else {
await notifier.createPartner(data);
}
if (mounted) {
Navigator.of(context).pop();
widget.onSaved();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(widget.partner != null ? '更新成功' : '创建成功'),
backgroundColor: AppTheme.success,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败:$e'),
backgroundColor: AppTheme.danger),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.partner != null;
final typeName = widget.isSupplier ? '供应商' : '客户';
return Dialog(
child: Container(
width: context.dialogWidth(520),
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(isEdit ? '编辑$typeName' : '新建$typeName',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 20),
TextFormField(
controller: _nameCtrl,
decoration: InputDecoration(labelText: '$typeName名称'),
validator: (v) =>
(v == null || v.isEmpty) ? '不能为空' : null,
),
const SizedBox(height: 12),
Row(children: [
if (isEdit) ...[
Expanded(
child: TextFormField(
controller: _codeCtrl,
decoration: const InputDecoration(labelText: '编码'),
),
),
const SizedBox(width: 12),
],
Expanded(
child: TextFormField(
controller: _contactCtrl,
decoration: const InputDecoration(labelText: '联系人'),
),
),
]),
const SizedBox(height: 12),
TextFormField(
controller: _phoneCtrl,
decoration: const InputDecoration(labelText: '联系电话'),
),
const SizedBox(height: 12),
TextFormField(
controller: _addressCtrl,
decoration: const InputDecoration(labelText: '地址'),
),
const SizedBox(height: 12),
TextFormField(
controller: _remarkCtrl,
decoration: const InputDecoration(labelText: '备注'),
maxLines: 2,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('取消'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Text('保存'),
),
],
),
],
),
),
),
);
}
}