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
This commit is contained in:
wangjia
2026-07-03 09:58:14 +08:00
parent ca7595b113
commit 6238b86dcb
286 changed files with 16831 additions and 11136 deletions
+96 -229
View File
@@ -5,14 +5,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/core/models/page_result.dart';
import 'package:jiu_client/core/theme/themes.dart';
import 'package:jiu_client/models/license.dart';
import 'package:jiu_client/models/partner.dart';
import 'package:jiu_client/providers/license_provider.dart';
import 'package:jiu_client/providers/partner_provider.dart';
import 'package:jiu_client/screens/partners/partners_screen.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
/// WriteGuard 内层会 watch licenseProvider;测试里覆写成同步返回 normal 授权,
/// 避免触发真实网络请求(否则留下 pending timer 导致测试失败)
/// 往来单位屏(Phase2 重建版:单表 + 类型 chips + 应收应付 + 详情抽屉 + frow 弹窗)。
/// WriteGuard 内层会 watch licenseProvider;覆写成同步返回 normal 授权,避免真实网络请求
class _FakeLicenseNotifier extends LicenseNotifier {
@override
Future<LicenseInfo?> build() async => const LicenseInfo(
@@ -24,14 +24,10 @@ class _FakeLicenseNotifier extends LicenseNotifier {
);
}
// ---------------------------------------------------------------------------
// Fake notifier
// ---------------------------------------------------------------------------
class _FakePartnerNotifier extends PartnerListNotifier {
final AsyncValue<PageResult<Partner>> _fixed;
_FakePartnerNotifier(String type, this._fixed) : super(type: type);
_FakePartnerNotifier(this._fixed);
@override
Future<PageResult<Partner>> build() async {
@@ -42,69 +38,52 @@ class _FakePartnerNotifier extends PartnerListNotifier {
@override
void reload() {}
@override
void setKeyword(String keyword) {}
@override
void setType(String? t) {}
@override
void setPage(int page) {}
@override
Future<void> createPartner(Map<String, dynamic> data) async {}
@override
Future<void> updatePartner(int id, Map<String, dynamic> data) async {}
@override
Future<void> deletePartner(int id) async {}
}
/// A notifier that stays permanently in loading state (build never completes).
/// 永久 loadingbuild 永不完成)。
class _FakeLoadingPartnerNotifier extends PartnerListNotifier {
_FakeLoadingPartnerNotifier(String type) : super(type: type);
@override
Future<PageResult<Partner>> build() {
// Never completes — keeps the widget in CircularProgressIndicator state.
return Completer<PageResult<Partner>>().future;
}
Future<PageResult<Partner>> build() =>
Completer<PageResult<Partner>>().future;
@override
void reload() {}
@override
void setKeyword(String keyword) {}
@override
void setType(String? t) {}
@override
void setPage(int page) {}
@override
Future<void> createPartner(Map<String, dynamic> data) async {}
@override
Future<void> updatePartner(int id, Map<String, dynamic> data) async {}
@override
Future<void> deletePartner(int id) async {}
}
Widget _buildApp({
AsyncValue<PageResult<Partner>> supplierState =
const AsyncValue.data(PageResult(data: [], total: 0, page: 1, pageSize: 20)),
AsyncValue<PageResult<Partner>> customerState =
const AsyncValue.data(PageResult(data: [], total: 0, page: 1, pageSize: 20)),
AsyncValue<PageResult<Partner>> state = const AsyncValue.data(
PageResult(data: [], total: 0, page: 1, pageSize: 1000)),
}) {
return ProviderScope(
overrides: [
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
supplierListProvider.overrideWith(
() => _FakePartnerNotifier('supplier', supplierState),
),
customerListProvider.overrideWith(
() => _FakePartnerNotifier('customer', customerState),
),
partnersScreenProvider.overrideWith(() => _FakePartnerNotifier(state)),
partnerBalancesProvider.overrideWith((ref) async => {
1: (recv: 126800.0, pay: 0.0),
}),
// 抽屉「近期单据」:测试环境不发真实请求
partnerRecentDocsProvider.overrideWith((ref, id) async => const []),
],
child: MaterialApp(theme: buildTheme('a'), home: const Scaffold(body: PartnersScreen())),
child: MaterialApp(
theme: buildTheme('a'), home: const Scaffold(body: PartnersScreen())),
);
}
@@ -112,242 +91,130 @@ Widget _buildLoadingApp() {
return ProviderScope(
overrides: [
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
supplierListProvider.overrideWith(
() => _FakeLoadingPartnerNotifier('supplier'),
),
customerListProvider.overrideWith(
() => _FakeLoadingPartnerNotifier('customer'),
),
partnersScreenProvider.overrideWith(() => _FakeLoadingPartnerNotifier()),
partnerBalancesProvider.overrideWith((ref) async => {}),
],
child: MaterialApp(theme: buildTheme('a'), home: const Scaffold(body: PartnersScreen())),
child: MaterialApp(
theme: buildTheme('a'), home: const Scaffold(body: PartnersScreen())),
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
void main() {
group('PartnersScreen - Supplier tab', () {
testWidgets('shows CircularProgressIndicator while loading', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const _partners = [
Partner(
id: 1,
code: 'S001',
name: '张家酒水供应商',
type: 'supplier',
contact: '张三',
phone: '13800138000',
address: '北京市朝阳区',
),
];
void main() {
void desktop(WidgetTester tester) {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
}
group('PartnersScreen', () {
testWidgets('loading 态显示 CircularProgressIndicator', (tester) async {
desktop(tester);
await tester.pumpWidget(_buildLoadingApp());
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('shows error message on failure', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
testWidgets('错误态显示提示与重试', (tester) async {
desktop(tester);
await tester.pumpWidget(
_buildApp(
supplierState: AsyncValue.error('网络错误', StackTrace.empty),
),
);
_buildApp(state: AsyncValue.error('网络错误', StackTrace.empty)));
await tester.pump();
expect(find.text('暂无数据,网络不可用'), findsOneWidget);
expect(find.text('重试'), findsOneWidget);
});
testWidgets('shows 暂无供应商 when supplier list is empty', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
testWidgets('空列表显示原型空态文案', (tester) async {
desktop(tester);
await tester.pumpWidget(_buildApp());
await tester.pump();
expect(find.text('暂无供应商'), findsOneWidget);
expect(find.text('没有匹配的往来单位 · 试试调整筛选或搜索'), findsOneWidget);
});
testWidgets('shows supplier list with name and contact', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const partners = [
Partner(
id: 1,
code: 'S001',
name: '张家酒水供应商',
type: 'supplier',
contact: '张三',
phone: '13800138000',
),
];
testWidgets('列表显示 名称/联系人/电话/类型徽章/应收应付', (tester) async {
desktop(tester);
await tester.pumpWidget(_buildApp(
supplierState: AsyncValue.data(
const PageResult(data: partners, total: 1, page: 1, pageSize: 20),
),
state: const AsyncValue.data(
PageResult(data: _partners, total: 1, page: 1, pageSize: 1000)),
));
await tester.pump();
await tester.pumpAndSettle();
expect(find.text('张家酒水供应商'), findsOneWidget);
expect(find.text('张三'), findsOneWidget);
expect(find.text('13800138000'), findsOneWidget);
expect(find.text('供应商'), findsWidgets); // chips + 徽章
expect(find.text('¥126,800'), findsOneWidget); // 应收(balances 假数据)
});
testWidgets('shows 新建 button in toolbar', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
testWidgets('类型 chips 与重置在 toolbar', (tester) async {
desktop(tester);
await tester.pumpWidget(_buildApp());
await tester.pump();
expect(find.text('全部'), findsOneWidget);
expect(find.text('供应商'), findsOneWidget);
expect(find.text('客户'), findsOneWidget);
expect(find.text('重置'), findsOneWidget);
});
testWidgets('点「新增往来单位」打开 frow 弹窗,空名保存校验', (tester) async {
desktop(tester);
await tester.pumpWidget(_buildApp());
await tester.pump();
expect(find.text(''), findsOneWidget);
});
testWidgets('tapping 新建 opens 新建供应商 dialog', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(_buildApp());
await tester.pump();
await tester.tap(find.text('新建'));
await tester.pumpAndSettle();
expect(find.text('新建供应商'), findsOneWidget);
expect(find.text('供应商名称'), findsAtLeastNWidgets(1));
});
testWidgets('supplier form shows 不能为空 when saving with empty name',
(tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(_buildApp());
await tester.pump();
await tester.tap(find.text('新建'));
await tester.tap(find.text('增往来单位').first);
await tester.pumpAndSettle();
// DsFieldlabel 在上(「名称」+ 独立红星),非浮动 labelText
expect(find.text('名称'), findsOneWidget);
expect(find.text('期初余额'), findsOneWidget);
await tester.tap(find.text('保存'));
await tester.pump();
expect(find.text('不能为空'), findsAtLeastNWidgets(1));
});
testWidgets('shows delete confirmation when delete button tapped', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const partners = [
Partner(id: 1, name: '张家酒水供应商', type: 'supplier'),
];
testWidgets('铅笔打开预填编辑弹窗,弹窗内可发起删除确认', (tester) async {
desktop(tester);
await tester.pumpWidget(_buildApp(
supplierState: AsyncValue.data(
const PageResult(data: partners, total: 1, page: 1, pageSize: 20),
),
state: const AsyncValue.data(
PageResult(data: _partners, total: 1, page: 1, pageSize: 1000)),
));
await tester.pump();
await tester.tap(find.byKey(const Key('btn_delete_1')));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.pencil).first);
await tester.pumpAndSettle();
expect(find.text('编辑往来单位'), findsOneWidget);
expect(find.widgetWithText(TextFormField, '张家酒水供应商'), findsOneWidget);
await tester.tap(find.text('删除'));
await tester.pumpAndSettle();
expect(find.text('确认删除'), findsOneWidget);
expect(find.textContaining('张家酒水供应商'), findsAtLeastNWidgets(1));
expect(find.text('取消'), findsOneWidget);
// 删除 appears in both the list row action button and the dialog confirm button.
expect(find.text('删除'), findsAtLeastNWidgets(1));
});
testWidgets('tapping edit button opens prefilled edit dialog', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const partners = [
Partner(
id: 1,
code: 'S001',
name: '张家酒水供应商',
type: 'supplier',
contact: '张三',
),
];
testWidgets('行点击打开详情抽屉(联系信息 + 应收/应付 + 近期单据)', (tester) async {
desktop(tester);
await tester.pumpWidget(_buildApp(
supplierState: AsyncValue.data(
const PageResult(data: partners, total: 1, page: 1, pageSize: 20),
),
state: const AsyncValue.data(
PageResult(data: _partners, total: 1, page: 1, pageSize: 1000)),
));
await tester.pump();
await tester.tap(find.byKey(const Key('btn_edit_1')));
await tester.pumpAndSettle();
expect(find.text('编辑供应商'), findsOneWidget);
expect(find.widgetWithText(TextFormField, '张家酒水供应商'), findsOneWidget);
});
});
group('PartnersScreen - Customer tab', () {
testWidgets('shows 暂无客户 when customer list is empty', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(_buildApp());
await tester.pump();
// Switch to 客户 tab
await tester.tap(find.text('客户'));
await tester.pumpAndSettle();
expect(find.text('暂无客户'), findsOneWidget);
});
testWidgets('shows customer names in list', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
const customers = [
Partner(id: 10, name: '王记饭店', type: 'customer', phone: '13900139000'),
];
await tester.pumpWidget(_buildApp(
customerState: AsyncValue.data(
const PageResult(data: customers, total: 1, page: 1, pageSize: 20),
),
));
await tester.pump();
// Switch to customer tab
await tester.tap(find.text('客户'));
await tester.pumpAndSettle();
expect(find.text('王记饭店'), findsOneWidget);
});
testWidgets('opens 新建客户 dialog from customer tab', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(_buildApp());
await tester.pump();
await tester.tap(find.text('客户'));
await tester.pumpAndSettle();
await tester.tap(find.text('新建'));
await tester.pumpAndSettle();
expect(find.text('新建客户'), findsOneWidget);
expect(find.text('客户名称'), findsAtLeastNWidgets(1));
await tester.tap(find.text('张家酒水供应商'));
await tester.pump(const Duration(milliseconds: 300));
expect(find.text('联系信息'), findsOneWidget);
expect(find.text('应收账款'), findsOneWidget);
expect(find.text('应付账款'), findsOneWidget);
expect(find.text('近期单据'), findsOneWidget);
});
});
}