Files
jiu/client/lib/screens/partners/partners_screen.dart
wangjia f994fbd031 feat(client): 往来单位屏设计语言重建(副标/状态徽章/两行名称/响应式 toolbar)
- 副标「共 N 个供应商/客户」(还原 .head .sub)
- 状态 → StatusPill(启用绿/停用灰,新 _PartnerStatusPill),桌面+移动一致
- 编码+名称合并两行(name 加粗 + code 等宽 muted)
- toolbar 响应式(窄屏搜索独占一行 + 操作行)+ token 间距,修预存窄屏溢出
- 整屏 golden ×三主题(桌面+移动)入回归闸
暂缓(数据缺口/有意偏差):应收/应付列(Partner 模型无该字段)、
类型 chips(现保留 供应商/客户 两 Tab)。

analyze 0 error · flutter test +181(往来桌面/移动各 3 golden)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
2026-06-25 13:05:12 +08:00

609 lines
21 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/config/app_constants.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
import '../../models/partner.dart';
import '../../providers/partner_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/kpi_card.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/page_scaffold.dart';
import '../../core/utils/export_util.dart';
import '../../widgets/write_guard.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: [
Icon(Icons.cloud_off, size: 40, color: context.tokens.muted),
const SizedBox(height: 12),
Text('暂无数据,网络不可用',
style: TextStyle(color: context.tokens.muted)),
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(AppConstants.searchDebounce, () {
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: [
Icon(Icons.cloud_off, size: 40, color: context.tokens.muted),
const SizedBox(height: 12),
Text('暂无数据,网络不可用',
style: TextStyle(color: context.tokens.muted)),
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(AppConstants.searchDebounce, () {
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: _PartnerStatusPill(p.status),
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: WriteGuard.isReadonly(ref)
? const []
: [
WriteGuard(
child: TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑', style: TextStyle(fontSize: 13)),
),
),
WriteGuard(
child: TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: Text('删除',
style: TextStyle(fontSize: 13, color: context.tokens.danger)),
),
),
],
);
}
return Column(
children: [
// 副标(还原原型 .head .sub
Container(
width: double.infinity,
color: context.tokens.bg,
padding: const EdgeInsets.fromLTRB(
AppDims.sp3, AppDims.sp3, AppDims.sp3, 0),
child: Text(
'${result.total}${isSupplier ? '供应商' : '客户'}',
style: TextStyle(
fontSize: AppDims.fsSm, color: context.tokens.muted),
),
),
Expanded(
child: DataTableCard(
totalCount: result.total,
page: result.page,
pageSize: result.pageSize,
onPageChanged: onPageChanged,
onPageSizeChanged: onPageSizeChanged,
mobileCards: partners.map(partnerCard).toList(),
toolbar: Builder(builder: (ctx) {
final searchField = TextField(
controller: searchCtrl,
decoration: InputDecoration(
hintText: isSupplier ? '搜索供应商名称/编码' : '搜索客户名称/编码',
prefixIcon: const Icon(Icons.search, size: 16),
hintStyle: const TextStyle(fontSize: 13),
),
onChanged: onSearchChanged,
);
final buttons = <Widget>[
if (!WriteGuard.isReadonly(ref)) ...[
WriteGuard(
child: ElevatedButton.icon(
onPressed: onAdd,
icon: const Icon(Icons.add, size: 16),
label: const Text('新建'),
),
),
const SizedBox(width: AppDims.sp2),
],
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('导出'),
),
];
if (ctx.isMobile) {
// 窄屏:搜索独占一行 + 操作按钮一行
return Column(
mainAxisSize: MainAxisSize.min,
children: [
searchField,
const SizedBox(height: AppDims.sp2),
Row(children: [...buttons, const Spacer()]),
],
);
}
return Row(
children: [
...buttons,
const Spacer(),
SizedBox(width: 240, child: searchField),
],
);
}),
columns: const [
DataColumn(label: Text('名称')),
DataColumn(label: Text('联系人')),
DataColumn(label: Text('联系电话')),
DataColumn(label: Text('地址')),
DataColumn(label: Text('状态')),
DataColumn(label: Text('操作')),
],
rows: partners.isEmpty
? [
DataRow(cells: [
DataCell(Text(isSupplier ? '暂无供应商' : '暂无客户',
style:
TextStyle(color: context.tokens.muted))),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
const DataCell(SizedBox()),
])
]
: partners
.map((p) => DataRow(
cells: [
DataCell(SizedBox(
width: 200,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(p.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w600)),
if ((p.code ?? '').isNotEmpty)
Text(p.code!,
style: TextStyle(
fontFamily: 'monospace',
fontSize: AppDims.fsXs,
color: context.tokens.muted)),
],
),
)),
DataCell(Text(p.contact ?? '-')),
DataCell(Text(p.phone ?? '-')),
DataCell(SizedBox(
width: 160,
child: Text(p.address ?? '-',
overflow: TextOverflow.ellipsis),
)),
DataCell(_PartnerStatusPill(p.status)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: WriteGuard.isReadonly(ref)
? const []
: [
WriteGuard(
child: TextButton(
key: Key('btn_edit_${p.id}'),
onPressed: () => onEdit(p),
child: const Text('编辑',
style: TextStyle(fontSize: 12)),
),
),
WriteGuard(
child: TextButton(
key: Key('btn_delete_${p.id}'),
onPressed: () => onDelete(p),
child: Text('删除',
style: TextStyle(
fontSize: 12,
color: context.tokens.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: context.tokens.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(SnackBar(
content: const Text('删除成功'),
backgroundColor: context.tokens.success));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('删除失败:$e'),
backgroundColor: context.tokens.danger));
}
}
}
}
}
// 往来单位状态徽章(启用/停用),还原原型 .badge 圆点软底。
class _PartnerStatusPill extends StatelessWidget {
final String status;
const _PartnerStatusPill(this.status);
@override
Widget build(BuildContext context) {
final t = context.tokens;
return status == 'enabled'
? StatusPill(label: '启用', color: t.success, background: t.okSoft)
: StatusPill(label: '停用', color: t.muted, background: t.borderSubtle);
}
}
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: context.tokens.success,
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败:$e'),
backgroundColor: context.tokens.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('保存'),
),
],
),
],
),
),
),
);
}
}