Files
jiu/client/lib/screens/partners/partners_screen.dart
T
wangjia 4287f5a876 feat(client): 往来/基础数据/商品详情窄屏对齐移动原型(卡片流/图标徽章/sheet 表单)
- 往来单位:窄屏隐藏页内大标题(标题在壳顶栏);MSearchRow 搜索行;
  类型 pills 全部/供应商/客户/两者(两者=客户端过滤,服务端 FIND_IN_SET 无独立值);
  卡片流类型+状态徽章换图标变体、mc-sub mono 编码
- 基础数据:窄屏隐藏大标题;各 tab 搜索行换 MSearchRow(清空 × 并入,重置钮撤);
  档案/字典卡片副行 mono 编码;系列/规格/仓库 新增编辑窄屏走 showMSheet 底部 sheet 轻表单
- 商品详情:窄屏 padding 收紧 sp4;信息卡 Wrap→单列;图片缩略图行窄屏横滚防溢出;
  二维码弹窗宽度走 context.dialogWidth
- golden:新增 m_partners / m_products / m_products_series(390×844 @2x 三主题);
  重生成既有 partners_mobile / products_archive_mobile 基准(桌面 golden 零漂移)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:13:38 +08:00

653 lines
24 KiB
Dart
Raw 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.
// 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/ds/m_search_row.dart';
import '../../widgets/ds/status_icon_map.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 = ['全部', '供应商', '客户'];
// 窄屏对齐 m-partners 原型 F=['全部','供应商','客户','两者']
// 「两者」后端无独立 type 值(FIND_IN_SET 语义),列表本就一页取全 → 客户端过滤。
static const _typeChipsMobile = ['全部', '供应商', '客户', '两者'];
@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 mobile = context.isMobile;
// 「两者」为窄屏专属 chip:服务端 FIND_IN_SET 无独立值,客户端过滤
final partners = _typeChip == '两者'
? result.data.where((p) => p.typeLabel == '两者').toList()
: result.data;
// 原型 .main{padding:22px 26px};窄屏对齐 m-partners .m-scroll{padding:14px}
return Container(
color: t.bg,
padding: mobile
? const EdgeInsets.fromLTRB(AppDims.sp3, AppDims.sp3, AppDims.sp3, 0)
: const EdgeInsets.fromLTRB(26, 22, 26, 22),
child: Column(
children: [
// ── 头部(原型 .head{margin-bottom:18px});窄屏隐藏——
// 标题由壳顶栏 .m-top data-title 提供(对齐 m-partners)──
if (!mobile)
Container(
width: double.infinity,
color: t.bg,
padding: 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(),
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 + 重置;
// 窄屏对齐 m-partners.m-search 搜索行 + .m-pill 类型横排)──
Widget _toolbar(bool mobile, List<Partner> partners) {
void onSearch(String v) {
_debounce?.cancel();
_debounce = Timer(AppConstants.searchDebounce, () {
ref.read(partnersScreenProvider.notifier).setKeyword(v.trim());
});
}
if (mobile) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
MSearchRow(
controller: _searchCtrl,
hint: '名称 / 拼音',
onChanged: onSearch,
),
const SizedBox(height: AppDims.sp2),
Row(children: [
// 类型 pills(原型 m-pill 的既有对应物 DsChipselected 态)
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: [
for (final c in _typeChipsMobile) ...[
DsChip(
label: c,
selected: _typeChip == c,
caret: false,
onTap: () => _setType(c)),
const SizedBox(width: 8),
],
]),
),
),
WriteGuard(
child: DsButton('新增',
small: true,
icon: LucideIcons.plus,
variant: DsBtnVariant.primary,
onPressed: () => _openForm()),
),
]),
],
);
}
final search = DsSearchBox(
controller: _searchCtrl,
hint: '名称 / 拼音',
width: 260,
onChanged: onSearch,
);
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(),
);
});
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-partnersbadge b-供应商/客户/两者 + BADGE_ICON)。
/// 色调与 partnerTypeBadgepartner_detail_drawer)一致,仅多前导图标。
DsBadge _typeBadgeIcon(Partner p) => DsBadge(p.typeLabel,
tone: switch (p.typeLabel) {
'两者' => DsBadgeTone.warn,
'客户' => DsBadgeTone.accent,
_ => DsBadgeTone.info,
},
icon: statusIcon(p.typeLabel));
/// 状态徽章图标变体(原型 badge b-启用/b-停用)。
DsBadge _statusBadgeIcon(Partner p) {
final label = p.status == 'disabled' ? '停用' : '启用';
return DsBadge(label,
tone: label == '启用' ? DsBadgeTone.ok : DsBadgeTone.muted,
icon: statusIcon(label));
}
// ── 窄屏卡片(m-partnersmc-sub mono 编码 + 右侧类型/状态图标徽章)──
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!,
style: const TextStyle(
fontFamily: AppFonts.mono,
fontFamilyFallback: AppFonts.monoFallback))
: null,
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
_typeBadgeIcon(p),
const SizedBox(width: 6),
_statusBadgeIcon(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),
],
),
],
),
),
),
),
);
}
}