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
This commit is contained in:
wangjia
2026-06-25 13:05:12 +08:00
parent a661afe0d0
commit 07d3559d0a
8 changed files with 190 additions and 57 deletions
@@ -6,9 +6,11 @@ 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';
@@ -154,15 +156,7 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
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'
? context.tokens.success
: context.tokens.muted,
),
),
trailing: _PartnerStatusPill(p.status),
fields: [
if (p.contact?.isNotEmpty == true) MobileCardField('联系人', p.contact),
if (p.phone?.isNotEmpty == true) MobileCardField('电话', p.phone),
@@ -190,24 +184,48 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
);
}
return DataTableCard(
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: Row(
children: [
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: Text(isSupplier ? '新建' : '新建'),
label: const Text('新建'),
),
),
const SizedBox(width: 8),
const SizedBox(width: AppDims.sp2),
],
OutlinedButton.icon(
onPressed: () => exportExcel(
@@ -221,34 +239,37 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
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('操作')),
];
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: [
const DataCell(SizedBox()),
DataCell(Text(isSupplier ? '暂无供应商' : '暂无客户',
style:
TextStyle(color: context.tokens.muted))),
@@ -262,17 +283,24 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
: partners
.map((p) => DataRow(
cells: [
DataCell(Text(p.code ?? '-',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: context.tokens.muted))),
DataCell(SizedBox(
width: 180,
child: Text(p.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500)),
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 ?? '-')),
@@ -281,15 +309,7 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
child: Text(p.address ?? '-',
overflow: TextOverflow.ellipsis),
)),
DataCell(Text(
p.status == 'enabled' ? '正常' : '禁用',
style: TextStyle(
fontSize: 13,
color: p.status == 'enabled'
? context.tokens.success
: context.tokens.muted,
),
)),
DataCell(_PartnerStatusPill(p.status)),
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: WriteGuard.isReadonly(ref)
@@ -318,6 +338,9 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
],
))
.toList(),
),
),
],
);
}
@@ -388,6 +411,20 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
}
}
// 往来单位状态徽章(启用/停用),还原原型 .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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:jiu_client/core/models/page_result.dart';
import 'package:jiu_client/core/auth/auth_state.dart';
import 'package:jiu_client/models/partner.dart';
import 'package:jiu_client/providers/partner_provider.dart';
import 'package:jiu_client/screens/partners/partners_screen.dart';
import '../support/golden_harness.dart';
/// design-distill 阶段4:往来单位整屏 golden × 三主题(桌面 + 移动,回归闸)。
/// 保真目检:shoot partners.html + montage 与 goldens/partners_*.png 并排。
/// 更新基准:flutter test --update-goldens test/golden/partners_golden_test.dart
const _suppliers = [
Partner(
id: 1, code: 'GYS-001', name: '茅台华东总代', type: 'supplier',
contact: '张伟', phone: '138-0011-2233', address: '上海市黄浦区中山东路 8 号',
status: 'enabled'),
Partner(
id: 2, code: 'GYS-002', name: '老窖酒水批发', type: 'supplier',
contact: '李建国', phone: '139-2200-7788', address: '江苏省南京市建邺区江东中路',
status: 'enabled'),
Partner(
id: 3, code: 'GYS-003', name: '五粮液川南运营中心', type: 'supplier',
contact: '刘海涛', phone: '135-8899-0011', address: '四川省宜宾市翠屏区岷江路',
status: 'disabled'),
];
const _customers = [
Partner(
id: 11, code: 'KH-001', name: '鼎丰超市', type: 'customer',
contact: '王芳', phone: '137-6655-4321', address: '浙江省杭州市西湖区文三路',
status: 'enabled'),
Partner(
id: 12, code: 'KH-002', name: '金樽酒楼', type: 'customer',
contact: '陈静', phone: '136-2211-9900', address: '江苏省苏州市姑苏区干将路',
status: 'enabled'),
];
class _FakeSupplierNotifier extends PartnerListNotifier {
@override
Future<PageResult<Partner>> build() async => const PageResult(
data: _suppliers, total: 3, page: 1, pageSize: 20);
@override
void setPage(int page) {}
@override
void setPageSize(int pageSize) {}
@override
void setKeyword(String keyword) {}
@override
void reload() {}
}
class _FakeCustomerNotifier extends PartnerListNotifier {
@override
Future<PageResult<Partner>> build() async => const PageResult(
data: _customers, total: 2, page: 1, pageSize: 20);
@override
void setPage(int page) {}
@override
void setPageSize(int pageSize) {}
@override
void setKeyword(String keyword) {}
@override
void reload() {}
}
List<Override> _overrides() => [
supplierListProvider.overrideWith(() => _FakeSupplierNotifier()),
customerListProvider.overrideWith(() => _FakeCustomerNotifier()),
isReadonlyProvider.overrideWithValue(false),
];
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
goldenAcrossThemes(
'partners 整屏(桌面)',
goldenPrefix: 'partners',
child: () => const Scaffold(body: PartnersScreen()),
overrides: _overrides,
logical: const Size(1280, 900),
);
goldenAcrossThemes(
'partners 整屏(移动)',
goldenPrefix: 'partners_mobile',
child: () => const Scaffold(body: PartnersScreen()),
overrides: _overrides,
logical: const Size(390, 1200),
);
}