feat(client): 基础数据加商品档案全量表 + 原型 5 tab 置前

非破坏式向原型靠拢:
- 新增「商品档案」tab(productListProvider 全量商品表:商品 name+code/系列/规格/
  分类/单位/默认成本价/查看,行点进详情),原型头牌功能(此前基础数据页只管字典)
- tab 重排:原型 5 个(商品档案/商品介绍/系列/规格/香型)置前;描述文档→商品介绍
- 保留 商品名称/产地/保质期/储存/仓库 字典 tab 在后(不删管理入口、不孤立数据)
整屏 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 16:22:47 +08:00
parent 5f5a51ee19
commit 9ef5ae7059
5 changed files with 205 additions and 4 deletions
@@ -1,10 +1,14 @@
import '../../core/utils/dialog_util.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
import '../../models/warehouse.dart';
import '../../models/product.dart';
import '../../providers/product_option_provider.dart';
import '../../providers/product_provider.dart';
import '../../models/product_option.dart' show ProductCategoryOption;
import '../../providers/warehouse_provider.dart';
import '../../providers/shop_provider.dart';
@@ -25,6 +29,7 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
// 「设为默认」的乐观覆盖:点一下立刻变色,不等后端回包(key→选项 id)。
final Map<String, int> _optDefaults = {};
final _archiveSearchCtrl = TextEditingController();
final _nameSearchCtrl = TextEditingController();
final _seriesSearchCtrl = TextEditingController();
final _specSearchCtrl = TextEditingController();
@@ -61,6 +66,7 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
_storageSearchCtrl.dispose();
_descDocSearchCtrl.dispose();
_categorySearchCtrl.dispose();
_archiveSearchCtrl.dispose();
super.dispose();
}
@@ -68,31 +74,167 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
Widget build(BuildContext context) {
return PageScaffold(
title: '基础数据',
// 原型 5 个 tab(商品档案/商品介绍/系列/规格/香型)置前;
// 其余字典(商品名称/产地/保质期/储存/仓库)保留在后,不删管理入口。
tabs: const [
Tab(text: '商品名称'),
Tab(text: '商品档案'),
Tab(text: '商品介绍'),
Tab(text: '系列'),
Tab(text: '规格'),
Tab(text: '香型'),
Tab(text: '商品名称'),
Tab(text: '产地'),
Tab(text: '保质期'),
Tab(text: '储存方式'),
Tab(text: '描述文档'),
Tab(text: '仓库'),
],
tabViews: [
_buildNameTab(),
_buildArchiveTab(),
_buildDescDocTab(),
_buildSeriesTab(),
_buildSpecTab(),
_buildCategoryTab(),
_buildNameTab(),
_buildOriginTab(),
_buildShelfLifeTab(),
_buildStorageTab(),
_buildDescDocTab(),
_buildWarehousesTab(),
],
);
}
// ── 商品档案 tab(全量商品表,还原原型头牌) ────────────────────
Widget _buildArchiveTab() {
final async = ref.watch(productListProvider);
return async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) =>
_buildError(() => ref.read(productListProvider.notifier).reload()),
data: (result) {
final items = result.data;
final notifier = ref.read(productListProvider.notifier);
return DataTableCard(
totalCount: result.total,
page: result.page,
pageSize: result.pageSize,
onPageChanged: (p) => notifier.setPage(p),
onPageSizeChanged: (s) => notifier.setPageSize(s),
toolbar: Row(
children: [
SizedBox(
width: 240,
child: TextField(
controller: _archiveSearchCtrl,
decoration: const InputDecoration(
hintText: '商品名 / 编码 / 拼音,回车搜索',
prefixIcon: Icon(Icons.search, size: 16),
hintStyle: TextStyle(fontSize: 13),
),
onSubmitted: (v) => notifier.setKeyword(v.trim()),
),
),
const SizedBox(width: AppDims.sp2),
OutlinedButton.icon(
onPressed: () => exportExcel(
filename: '商品档案',
headers: ['商品编码', '商品名称', '系列', '规格', '分类', '单位', '默认成本价'],
rows: items
.map((p) => [
p.code,
p.name,
p.series ?? '',
p.spec ?? '',
p.categoryName ?? '',
p.unit,
p.purchasePrice ?? '',
])
.toList(),
),
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
const Spacer(),
],
),
mobileCards: items
.map((p) => MobileListCard(
onTap: () => context.push('/products/${p.id}'),
title: Text(p.name),
subtitle: p.code.isEmpty ? null : Text(p.code),
fields: [
if ((p.series ?? '').isNotEmpty)
MobileCardField('系列', p.series),
if ((p.spec ?? '').isNotEmpty)
MobileCardField('规格', p.spec),
if ((p.categoryName ?? '').isNotEmpty)
MobileCardField('分类', p.categoryName),
MobileCardField('单位', p.unit.isEmpty ? '-' : p.unit),
],
))
.toList(),
columns: const [
DataColumn(label: Text('商品')),
DataColumn(label: Text('系列')),
DataColumn(label: Text('规格')),
DataColumn(label: Text('分类')),
DataColumn(label: Text('单位')),
DataColumn(label: Text('默认成本价')),
DataColumn(label: Text('操作')),
],
rows: items.isEmpty
? [
DataRow(cells: [
DataCell(Text('暂无商品(入库录单后自动建档)',
style: TextStyle(color: context.tokens.muted))),
for (int i = 1; i < 7; i++) const DataCell(SizedBox()),
])
]
: items
.map((p) => DataRow(
onSelectChanged: (_) => context.push('/products/${p.id}'),
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.series ?? '').isEmpty ? '-' : p.series!)),
DataCell(Text((p.spec ?? '').isEmpty ? '-' : p.spec!)),
DataCell(Text((p.categoryName ?? '').isEmpty
? '-'
: p.categoryName!)),
DataCell(Text(p.unit.isEmpty ? '-' : p.unit)),
DataCell(Text(
p.purchasePrice != null && p.purchasePrice! > 0
? '¥${p.purchasePrice!.toStringAsFixed(2)}'
: '-')),
DataCell(TextButton(
onPressed: () => context.push('/products/${p.id}'),
child: const Text('查看',
style: TextStyle(fontSize: 12)),
)),
],
))
.toList(),
);
},
);
}
// ── 商品名称 tab ───────────────────────────────────────────
Widget _buildNameTab() {
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,59 @@
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/product.dart';
import 'package:jiu_client/providers/product_provider.dart';
import 'package:jiu_client/screens/products/products_screen.dart';
import '../support/golden_harness.dart';
/// design-distill 阶段4:基础数据「商品档案」全量商品表 golden × 三主题。
/// 锁住原型头牌 tab(商品 name+code / 系列 / 规格 / 分类 / 单位 / 默认成本价)。
/// 更新基准:flutter test --update-goldens test/golden/products_archive_golden_test.dart
const _products = [
Product(
id: 1, code: 'MT-FT-500', name: '茅台 飞天 53°', unit: '',
series: '飞天', spec: '500ml×6', categoryName: '酱香', purchasePrice: 2680),
Product(
id: 2, code: 'WLY-PW-500', name: '五粮液 普五 52°', unit: '',
series: '普五', spec: '500ml×6', categoryName: '浓香', purchasePrice: 1050),
Product(
id: 3, code: 'JNC-SJ-500', name: '剑南春 水晶剑', unit: '',
series: '水晶剑', spec: '500ml×6', categoryName: '浓香', purchasePrice: 438),
];
class _FakeProductList extends ProductListNotifier {
@override
Future<PageResult<Product>> build() async =>
const PageResult(data: _products, total: 3, page: 1, pageSize: 20);
@override
void setPage(int page) {}
@override
void setPageSize(int pageSize) {}
@override
void setKeyword(String keyword) {}
@override
void setCategoryId(int? categoryId) {}
@override
void reload() {}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
goldenAcrossThemes(
'products 商品档案 tab',
goldenPrefix: 'products_archive',
child: () => const Scaffold(body: ProductsScreen()),
overrides: () => [
productListProvider.overrideWith(() => _FakeProductList()),
isReadonlyProvider.overrideWithValue(false),
],
logical: const Size(1280, 760),
);
}