Files
jiu/client/lib/screens/partners/partners_screen.dart
T
wangjia 6238b86dcb feat(client): 登录/注册页照原型重建,ds 真相源组件族统一全部屏
- 登录/注册(login.html/register.html 1:1):两栏卡片+品牌渐变面板+主题小衣服
  pill(onSurface 变体)+记住我(记录并预填最近账号)+原型式 toast 校验;
  登录 fidelity 1.4–2.1% 三主题全绿;注册暂不入闸(少 门店编号/兑换券 字段,
  已记 CONTRACT,screens.mjs 留存根)
- ds 原子补齐:DsToast(.toast 单例)/DsCheck(.check/.agree)/DsButton lg 档/
  DsSelect 替换全部旧 DropdownButton/DsField label 在上/DsInput 后缀与密码形态
- 全屏统一:对话框按钮全 DsButton、盒式输入主题钉死(visualDensity.standard、
  h38、InputDecorationTheme 渗漏修复)、图标全 lucide、JetBrains Mono 三端同源、
  BrandMark 真相源 logo、只读模式写操作全量守卫(WriteGuard+DsToast)
- 出入库列表:版式对齐原型(卡片对齐+搜索框居中)、KPI 近30天滚动、结清后
  失效财务应收应付表;商品编辑抽屉介绍库改搜索下拉、图片双击全屏预览
- 删除旧 UI 死代码:DataTableCard/FormDialog/PageScaffold/SearchChip/
  SelectProductDialog/tabStateProvider
- golden/fidelity:stock-in/out 补注册(9%)、login 入册(8%)、goldens 全量重打;
  修复 pubspec flutter_web_plugins 非法声明(CI pub get 阻断)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-03 09:58:14 +08:00

599 lines
21 KiB
Dart

// screens/partners/partners_screen.dart — 往来单位(照原型 partners.html 1:1 重建)。
// 版式:.head(标题+副标+刷新/新增) + .toolbar(搜索 260 + 类型 chips + 重置)
// + .table 7 列(名称双行/类型徽章/联系人/电话/应收/应付/操作) + .pager 仅文案。
// 行点击 → 详情抽屉(partner_detail_drawer);新增/编辑 → 弹窗(原型 frow 布局)。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/config/app_constants.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/utils/dialog_util.dart';
import '../../core/utils/export_util.dart';
import '../../models/partner.dart';
import '../../providers/partner_provider.dart';
import '../../widgets/ds/ds_atoms.dart';
import '../../widgets/ds/ds_table.dart';
import '../../widgets/mobile_list_card.dart';
import '../../widgets/partner_detail_drawer.dart';
import '../../widgets/write_guard.dart';
import '../../core/theme/app_fonts.dart';
import '../../widgets/ds/ds_toast.dart';
class PartnersScreen extends ConsumerStatefulWidget {
const PartnersScreen({super.key});
@override
ConsumerState<PartnersScreen> createState() => _PartnersScreenState();
}
class _PartnersScreenState extends ConsumerState<PartnersScreen> {
final _searchCtrl = TextEditingController();
Timer? _debounce;
// 类型 chips(原型 TYPES 筛选):全部 / 供应商 / 客户;「两者」在两侧均出现
String _typeChip = '全部';
static const _typeChips = ['全部', '供应商', '客户'];
@override
void dispose() {
_debounce?.cancel();
_searchCtrl.dispose();
super.dispose();
}
void _setType(String chip) {
setState(() => _typeChip = chip);
ref.read(partnersScreenProvider.notifier).setType(switch (chip) {
'供应商' => 'supplier',
'客户' => 'customer',
_ => null,
});
}
void _clearFilters() {
_searchCtrl.clear();
setState(() => _typeChip = '全部');
final n = ref.read(partnersScreenProvider.notifier);
n.setKeyword('');
n.setType(null);
}
void _reload() {
ref.read(partnersScreenProvider.notifier).reload();
ref.invalidate(partnerBalancesProvider);
}
@override
Widget build(BuildContext context) {
final t = context.tokens;
final async = ref.watch(partnersScreenProvider);
final balances = ref.watch(partnerBalancesProvider).valueOrNull ?? const {};
return async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(LucideIcons.cloudOff, size: 40, color: t.muted),
const SizedBox(height: 12),
Text('暂无数据,网络不可用', style: TextStyle(color: t.muted)),
const SizedBox(height: 12),
DsButton('重试', variant: DsBtnVariant.primary, onPressed: _reload),
],
),
),
data: (result) {
final partners = result.data;
final mobile = context.isMobile;
// 原型 .main{padding:22px 26px};窄屏保持紧凑
return Container(
color: t.bg,
padding: mobile
? EdgeInsets.zero
: const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Column(
children: [
// ── 头部(原型 .head{margin-bottom:18px})──
Container(
width: double.infinity,
color: t.bg,
padding: mobile
? const EdgeInsets.fromLTRB(
AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2)
: const EdgeInsets.only(bottom: 18),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('往来单位',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: t.heading)),
const SizedBox(width: AppDims.sp3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text('供应商与客户档案 · 共 ${result.total}',
style: TextStyle(
fontSize: AppDims.fsSm, color: t.muted)),
),
const Spacer(),
if (!mobile) ...[
DsButton('刷新',
icon: LucideIcons.refreshCw, onPressed: _reload),
const SizedBox(width: 10),
WriteGuard(
child: DsButton('新增往来单位',
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: () => _openForm()),
),
],
],
),
),
Expanded(
child: DsTable(
pagerInfoText: '${result.total} 个往来单位',
emptyText: '没有匹配的往来单位 · 试试调整筛选或搜索',
toolbar: _toolbar(mobile, partners),
mobileCards: partners.map((p) => _card(p, balances)).toList(),
columns: const [
DsColumn('name', '名称'),
DsColumn('type', '类型'),
DsColumn('contact', '联系人'),
DsColumn('phone', '电话'),
DsColumn('recv', '应收', numeric: true),
DsColumn('pay', '应付', numeric: true),
DsColumn('actions', '操作', action: true),
],
rows: partners
.map((p) => DsRow(
onTap: () => _openDetail(p, balances),
cells: _cells(p, balances),
))
.toList(),
),
),
],
),
);
},
);
}
// ── toolbar(原型:searchbox 260 + chips + sp + 重置)──
Widget _toolbar(bool mobile, List<Partner> partners) {
final search = DsSearchBox(
controller: _searchCtrl,
hint: '名称 / 拼音',
width: mobile ? null : 260,
onChanged: (v) {
_debounce?.cancel();
_debounce = Timer(AppConstants.searchDebounce, () {
ref.read(partnersScreenProvider.notifier).setKeyword(v.trim());
});
},
);
final chips = [
for (final c in _typeChips) ...[
DsChip(
label: c,
selected: _typeChip == c,
caret: false,
onTap: () => _setType(c)),
const SizedBox(width: 10),
],
];
final reset = DsButton('重置', small: true, onPressed: _clearFilters);
final export =
DsButton('导出', small: true, icon: LucideIcons.download, onPressed: () {
exportExcel(
filename: '往来单位',
headers: ['编码', '名称', '类型', '联系人', '电话', '地址', '备注'],
rows: partners
.map((p) => [
p.code ?? '',
p.name,
p.typeLabel,
p.contact ?? '',
p.phone ?? '',
p.address ?? '',
p.remark ?? '',
])
.toList(),
);
});
if (mobile) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
search,
const SizedBox(height: AppDims.sp2),
Row(children: [
...chips,
const Spacer(),
WriteGuard(
child: DsButton('新增',
small: true,
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: () => _openForm()),
),
]),
],
);
}
return Row(children: [
search,
const SizedBox(width: 10),
...chips,
const Spacer(),
export,
const SizedBox(width: 10),
reset
]);
}
// ── 表格单元格(原型 7 列)──
List<Widget> _cells(Partner p, Map<int, ({double recv, double pay})> bal) {
final t = context.tokens;
final b = bal[p.id];
return [
// 名称双行:.pname + .pcode(地址前 12 字…)
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(p.name,
style: TextStyle(fontWeight: FontWeight.w600, color: t.heading)),
// 地址为空则不渲染副行(不占位、不显示「—」),行高回落单行
if (p.address?.isNotEmpty == true)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(_addrBrief(p.address!),
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.faint,
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
),
],
),
partnerTypeBadge(p),
Text((p.contact?.isNotEmpty == true) ? p.contact! : ''),
Text((p.phone?.isNotEmpty == true) ? p.phone! : '',
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
Text(fmtYuan(b?.recv ?? 0),
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
Text(fmtYuan(b?.pay ?? 0),
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback)),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_iconBtn(LucideIcons.eye, '详情', () => _openDetail(p, bal)),
const SizedBox(width: 8),
WriteGuard(
child: _iconBtn(LucideIcons.pencil, '编辑', () => _openForm(p)),
),
],
),
];
}
/// 原型 .pcode = addr.slice(0,12)…(地址前 12 字 + 省略号)
String _addrBrief(String addr) =>
addr.length > 12 ? '${addr.substring(0, 12)}' : addr;
Widget _iconBtn(IconData icon, String tip, VoidCallback onTap) {
final t = context.tokens;
return Tooltip(
message: tip,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppDims.rSm),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(icon, size: 16, color: t.muted),
),
),
);
}
// ── 窄屏卡片(m-partners:右侧类型徽章 + 应收/应付)──
Widget _card(Partner p, Map<int, ({double recv, double pay})> bal) {
final b = bal[p.id];
return MobileListCard(
title: Text(p.name),
subtitle: (p.code?.isNotEmpty == true) ? Text(p.code!) : null,
trailing: partnerTypeBadge(p),
fields: [
if (p.contact?.isNotEmpty == true) MobileCardField('联系人', p.contact),
if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone),
MobileCardField('应收', fmtYuan(b?.recv ?? 0)),
MobileCardField('应付', fmtYuan(b?.pay ?? 0)),
],
onTap: () => _openDetail(p, bal),
);
}
void _openDetail(Partner p, Map<int, ({double recv, double pay})> bal) {
showPartnerDetailDrawer(
context,
partner: p,
balance: bal[p.id],
onEdit: WriteGuard.isReadonly(ref) ? null : () => _openForm(p),
);
}
void _openForm([Partner? partner]) {
showAppDialog(
context: context,
builder: (ctx) => _PartnerFormDialog(
partner: partner,
onSaved: _reload,
onDelete: partner == null ? null : () => _confirmDelete(partner),
),
);
}
Future<void> _confirmDelete(Partner partner) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: Text('确认删除「${partner.name}」?此操作不可恢复。'),
actions: [
DsButton('取消', onPressed: () => Navigator.of(ctx).pop(false)),
DsButton('删除',
variant: DsBtnVariant.danger,
onPressed: () => Navigator.of(ctx).pop(true)),
],
),
);
if (confirmed == true && mounted) {
try {
await ref
.read(partnersScreenProvider.notifier)
.deletePartner(partner.id);
if (mounted) {
showDsToast(context, '删除成功', bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '删除失败:$e', bg: context.tokens.danger);
}
}
}
}
}
// ── 新增/编辑弹窗(原型 openAdd 的 frow 布局:名称*+类型 / 联系人+电话 / 地址 /
// 期初余额 + 备注(备注为既有能力保留))──
class _PartnerFormDialog extends ConsumerStatefulWidget {
final Partner? partner;
final VoidCallback onSaved;
final VoidCallback? onDelete;
const _PartnerFormDialog(
{this.partner, required this.onSaved, this.onDelete});
@override
ConsumerState<_PartnerFormDialog> createState() => _PartnerFormDialogState();
}
class _PartnerFormDialogState extends ConsumerState<_PartnerFormDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _nameCtrl;
late final TextEditingController _contactCtrl;
late final TextEditingController _phoneCtrl;
late final TextEditingController _addressCtrl;
late final TextEditingController _balanceCtrl;
late final TextEditingController _remarkCtrl;
late String _type; // supplier / customer / supplier,customer
bool _saving = false;
static const _typeOptions = {
'supplier': '供应商',
'customer': '客户',
'supplier,customer': '两者',
};
@override
void initState() {
super.initState();
final p = widget.partner;
_nameCtrl = TextEditingController(text: p?.name ?? '');
_contactCtrl = TextEditingController(text: p?.contact ?? '');
_phoneCtrl = TextEditingController(text: p?.phone ?? '');
_addressCtrl = TextEditingController(text: p?.address ?? '');
_balanceCtrl = TextEditingController(
text: (p?.balance ?? 0) == 0 ? '' : '${p!.balance}');
_remarkCtrl = TextEditingController(text: p?.remark ?? '');
// SET 值可能乱序(customer,supplier);归一化到选项 key
final raw = p?.type ?? 'supplier';
final isS = raw.contains('supplier'), isC = raw.contains('customer');
_type = isS && isC ? 'supplier,customer' : (isC ? 'customer' : 'supplier');
}
@override
void dispose() {
_nameCtrl.dispose();
_contactCtrl.dispose();
_phoneCtrl.dispose();
_addressCtrl.dispose();
_balanceCtrl.dispose();
_remarkCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _saving = true);
final data = {
'name': _nameCtrl.text.trim(),
'type': _type,
'contact': _contactCtrl.text.trim(),
'phone': _phoneCtrl.text.trim(),
'address': _addressCtrl.text.trim(),
'balance': double.tryParse(_balanceCtrl.text.trim()) ?? 0,
'remark': _remarkCtrl.text.trim(),
if (widget.partner?.code != null) 'code': widget.partner!.code,
};
try {
final notifier = ref.read(partnersScreenProvider.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();
showDsToast(context, widget.partner != null ? '更新成功' : '创建成功',
bg: context.tokens.success);
}
} catch (e) {
if (mounted) {
showDsToast(context, '保存失败:$e', bg: context.tokens.danger);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
final isEdit = widget.partner != null;
return Dialog(
child: Container(
width: context.dialogWidth(560),
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(isEdit ? '编辑往来单位' : '新增往来单位',
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 20),
// frow1:名称* + 类型*
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expanded(
child: DsField('名称',
required: true,
input: TextFormField(
controller: _nameCtrl,
decoration:
const InputDecoration(hintText: '如:茅台华东总代'),
validator: (v) =>
(v == null || v.trim().isEmpty) ? '不能为空' : null,
)),
),
const SizedBox(width: 14),
Expanded(
child: DsField('类型',
required: true,
input: DsSelect<String>(
value: _type,
options: [
for (final e in _typeOptions.entries)
(e.key, e.value),
],
onChanged: (v) => setState(() => _type = v),
)),
),
]),
const SizedBox(height: 14),
// frow2:联系人 + 电话
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expanded(
child: DsField('联系人',
input: TextFormField(
controller: _contactCtrl,
decoration: const InputDecoration(hintText: '姓名'),
)),
),
const SizedBox(width: 14),
Expanded(
child: DsField('电话',
input: TextFormField(
controller: _phoneCtrl,
decoration:
const InputDecoration(hintText: '手机 / 座机'),
)),
),
]),
const SizedBox(height: 14),
// frow3:地址(单列占满)
DsField('地址',
input: TextFormField(
controller: _addressCtrl,
decoration:
const InputDecoration(hintText: '省 / 市 / 详细地址'),
)),
const SizedBox(height: 14),
// frow4:期初余额 + 备注
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Expanded(
child: DsField('期初余额',
input: TextFormField(
controller: _balanceCtrl,
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
decoration: const InputDecoration(hintText: '0.00'),
)),
),
const SizedBox(width: 14),
Expanded(
child: DsField('备注',
input: TextFormField(
controller: _remarkCtrl,
decoration: const InputDecoration(hintText: '备注说明'),
)),
),
]),
const SizedBox(height: 24),
Row(
children: [
if (widget.onDelete != null)
WriteGuard(
child: DsButton('删除', variant: DsBtnVariant.danger,
onPressed: () {
Navigator.of(context).pop();
widget.onDelete!();
}),
),
const Spacer(),
DsButton('取消',
onPressed: () => Navigator.of(context).pop()),
const SizedBox(width: 10),
DsButton(_saving ? '保存中…' : '保存',
variant: DsBtnVariant.primary,
onPressed: _saving ? null : _save),
],
),
],
),
),
),
),
);
}
}