feat(client): Phase 2 库存屏照原型重建(去tab/KPI货值/库存·成本价·单价列)

照原型 inventory.html 把库存屏从「3 tab」重排为单视图:
- 去 3 tab(库存查询/预警/流水)→ 单视图;预警并入 KPI 缺货卡点击筛选;流水移商品详情
- 头部:库存管理 + 共 N 个 SKU·数据实时 + 列设置/导出(盘点/新增入库去掉,走专门菜单)
- KPI 4 卡走全店汇总接口:SKU总数/库存货值(Σqty×进价)/在库数量/缺货预警(可点筛选)
- 表格 12 列:加 库存(低库存染色)/成本价(进价)/单价(售价);操作列只「查看详情」;状态圆点
- 仓库/备注默认隐藏;工具栏 搜索+状态筛选+重置
- 后端 GET /inventory/summary 全店汇总(守 shop_id);前端 model/repository/provider 配套
- golden_harness:monospace 指向 Noto(消等宽数字黑块,全 golden 受益);库存屏存量 grey 清零

后端编译/库存测试通过;前端 analyze 0 error,flutter test 242 通过;代码端颜色闸 0 违规。

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 20:40:54 +08:00
parent ff45100eed
commit 8d3f902f3b
42 changed files with 286 additions and 113 deletions
+23
View File
@@ -72,6 +72,29 @@ class Inventory {
);
}
/// 全店库存 KPI 汇总(GET /inventory/summary,不随分页)。
class InventorySummary {
final int skuCount;
final double stockValue;
final double inStockQty;
final int shortageCount;
final int warningCount;
const InventorySummary({
this.skuCount = 0,
this.stockValue = 0,
this.inStockQty = 0,
this.shortageCount = 0,
this.warningCount = 0,
});
factory InventorySummary.fromJson(Map<String, dynamic> j) => InventorySummary(
skuCount: (j['sku_count'] as num?)?.toInt() ?? 0,
stockValue: (j['stock_value'] as num?)?.toDouble() ?? 0,
inStockQty: (j['in_stock_qty'] as num?)?.toDouble() ?? 0,
shortageCount: (j['shortage_count'] as num?)?.toInt() ?? 0,
warningCount: (j['warning_count'] as num?)?.toInt() ?? 0,
);
}
class InventoryLog {
final int warehouseId;
final String? warehouseName;
@@ -16,6 +16,14 @@ final inventoryListProvider =
InventoryListNotifier.new,
);
/// 全店库存 KPI 汇总(不随分页;换店/网络恢复自动刷新)。
final inventorySummaryProvider =
FutureProvider.autoDispose<InventorySummary>((ref) {
ref.watch(authStateProvider.select((s) => s.user?.shopId));
ref.watch(networkRecoveryCountProvider);
return ref.read(inventoryRepositoryProvider).getSummary();
});
class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
int _page = 1;
int _pageSize = AppConstants.defaultPageSize;
@@ -41,6 +41,18 @@ class InventoryRepository {
}
}
Future<InventorySummary> getSummary() async {
try {
final resp = await _client.get('/inventory/summary');
return InventorySummary.fromJson(resp.data as Map<String, dynamic>);
} on DioException catch (e) {
throw AppException(
e.response?.data?['error'] as String? ?? '获取库存汇总失败',
statusCode: e.response?.statusCode,
);
}
}
Future<Map<String, dynamic>> createCheck(Map<String, dynamic> data) async {
try {
final resp = await _client.post('/inventory/checks', data: data);
@@ -42,18 +42,23 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
Set<String> _filterWarehouse = {};
Set<String> _filterSpec = {};
Set<String> _filterSeries = {};
// 库存状态前端筛选(派生自 qty vs 安全库存):全部/在售/预警/缺货
String _statusFilter = '全部';
Set<String>? _hiddenCols; // null = 尚未载入本地存档
bool _mobileTableView = false; // 移动端:false=卡片列表,true=表格(横向滚动)
static const _screenId = 'inventory_list';
// 列序照原型重排;商品 = name+code 合并两行
// 库存量/成本价列按项目铁律不展示(装箱数脏数据 + 进价敏感);仓库/备注作可隐藏列
// 列序照原型 inventory.html:商品/规格/系列/批次/库存/成本价/单价/生产/入库/供应商/状态/操作
// 成本价=入库进价(unitPrice),单价=售价(salePrice);库存列照原型显示(脏数据另清)
// 仓库/备注原型没有 → 默认隐藏的可选列(列设置可开)。
static const _colDefs = [
ColDef('product', '商品', required: true),
ColDef('spec', '规格'),
ColDef('series', '系列'),
ColDef('batch', '批次号'),
ColDef('qty', '库存'),
ColDef('cost', '成本价'),
ColDef('price', '单价'),
ColDef('prodDate', '生产日期'),
ColDef('inTime', '入库时间'),
@@ -64,10 +69,15 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ColDef('actions', '操作', required: true),
];
// 仓库/备注默认隐藏(原型无此两列);用户在列设置里可开。
static const _defaultHidden = {'warehouse', 'remark'};
@override
void initState() {
super.initState();
// 默认隐藏仓库/备注(原型无此两列);本地有存档则覆盖。
_hiddenCols = Set.of(_defaultHidden);
ColumnPrefs.load(_screenId).then((saved) {
if (saved != null && mounted) setState(() => _hiddenCols = saved);
});
@@ -285,6 +295,40 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
}
/// 商品列:name(可点进详情)+ code 两行(还原原型 .pname/.pcode)。
void _exportInventory(List<Inventory> items) {
exportExcel(
filename: '库存查询',
headers: const [
'商品编码', '商品名称', '规格', '系列', '批次号', '库存量',
'成本价', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'
],
rows: items.map((item) {
final status = item.quantity == 0
? '缺货'
: (item.minStock != null && item.quantity < item.minStock!)
? '库存不足'
: '正常';
return [
item.productCode,
item.productName,
item.spec,
item.series,
item.batchNo,
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
item.unitPrice != null ? item.unitPrice!.toStringAsFixed(2) : '',
item.salePrice != null ? item.salePrice!.toStringAsFixed(2) : '',
item.productionDate ?? '',
item.createdAt != null && item.createdAt!.length >= 10
? item.createdAt!.substring(0, 10)
: '',
item.supplierName,
item.minStock ?? '',
status,
];
}).toList(),
);
}
Widget _productCell(Inventory item, BuildContext context) {
final linked = item.productId != null;
final body = Column(
@@ -327,11 +371,23 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
'batch' => DataCell(Text(item.batchNo.isEmpty ? '-' : item.batchNo,
style: const TextStyle(fontFamily: 'monospace', fontSize: 12))),
'warehouse' => DataCell(Text(item.warehouseName.isEmpty ? '-' : item.warehouseName)),
'qty' => DataCell(Text(
item.quantity.toStringAsFixed(0),
style: TextStyle(
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
// 低库存染色(qty ≤ 安全库存):原型 .qty.low → accent
color: (item.minStock != null && item.quantity <= item.minStock!)
? context.tokens.accent
: context.tokens.text))),
'cost' => DataCell(Text(
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '-',
style: const TextStyle(fontFamily: 'monospace'))),
'price' => DataCell(Text(
item.unitPrice != null ? '¥${item.unitPrice!.toStringAsFixed(2)}' : '待定价',
style: item.unitPrice == null
item.salePrice != null ? '¥${item.salePrice!.toStringAsFixed(2)}' : '待定价',
style: item.salePrice == null
? TextStyle(color: context.tokens.warn)
: null)),
: const TextStyle(fontFamily: 'monospace'))),
'prodDate' => DataCell(Text(item.productionDate ?? '-')),
'inTime' => DataCell(Text(
item.createdAt != null && item.createdAt!.length >= 10
@@ -353,10 +409,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
))),
'status' => DataCell(_InventoryStatusBadge(item)),
'actions' => DataCell(item.productId != null
? TextButton(
onPressed: () => _printLabel(context, item),
child: Text('打标签',
style: TextStyle(fontSize: 12, color: context.tokens.primary)),
? IconButton(
icon: const Icon(Icons.visibility_outlined, size: 18),
tooltip: '查看详情',
onPressed: () => context.push('/products/${item.productId}'),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
)
: const SizedBox()),
_ => const DataCell(SizedBox()),
@@ -458,24 +516,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
@override
Widget build(BuildContext context) {
return PageScaffold(
title: '库存管理',
initialTab: ref.read(inventoryTabProvider),
onTabChanged: (i) => ref.read(inventoryTabProvider.notifier).state = i,
tabs: const [
Tab(text: '库存查询'),
Tab(text: '库存预警'),
Tab(text: '流水记录'),
],
tabViews: [
_buildInventoryTab(),
_buildWarningTab(),
_buildLogTab(),
],
);
// 照原型 inventory.html:单视图(无 tab)。库存预警并入 KPI 缺货卡点击筛选,
// 流水记录移到商品详情页。
return _buildInventoryView();
}
Widget _buildInventoryTab() {
Widget _buildInventoryView() {
final asyncInventory = ref.watch(inventoryListProvider);
return asyncInventory.when(
@@ -500,9 +546,6 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
),
data: (result) {
final items = result.data;
final warningCount =
items.where((i) => i.minStock != null && i.quantity < i.minStock!).length;
final emptyCount = items.where((i) => i.quantity == 0).length;
// 仓库选项从当前页派生(客户端筛选),系列/规格从 product-options API 加载(服务端筛选)
final warehouseOptions = items
@@ -519,9 +562,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
?.map((s) => s.name)
.toList() ??
[];
final filteredItems = _filterWarehouse.isEmpty
? items
: items.where((i) => _filterWarehouse.contains(i.warehouseName)).toList();
String statusOf(Inventory i) => i.quantity == 0
? '缺货'
: (i.minStock != null && i.quantity < i.minStock!)
? '预警'
: '在售';
final filteredItems = items
.where((i) =>
(_filterWarehouse.isEmpty ||
_filterWarehouse.contains(i.warehouseName)) &&
(_statusFilter == '全部' || statusOf(i) == _statusFilter))
.toList();
final hidden = _hiddenCols ?? <String>{};
final visibleCols =
@@ -529,34 +580,70 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
return Column(
children: [
// 副标(还原原型 .head .sub):商品总数 + 实时提示
// 头部(原型 .head):标题 + SKU 数 + 列设置/导出
Container(
width: double.infinity,
color: context.tokens.bg,
padding: const EdgeInsets.fromLTRB(
AppDims.sp3, AppDims.sp3, AppDims.sp3, 0),
child: Text(
'${NumberFormat.decimalPattern().format(result.total)} 个商品 · 数据实时',
style:
TextStyle(fontSize: AppDims.fsSm, color: context.tokens.muted),
AppDims.sp4, AppDims.sp4, AppDims.sp4, AppDims.sp2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('库存管理',
style: TextStyle(
fontSize: AppDims.fsH1,
fontWeight: FontWeight.w700,
color: context.tokens.heading)),
const SizedBox(width: AppDims.sp3),
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text(
'${NumberFormat.decimalPattern().format(result.total)} 个 SKU · 数据实时',
style: TextStyle(
fontSize: AppDims.fsSm,
color: context.tokens.muted)),
),
const Spacer(),
if (!context.isMobile) ...[
ColumnToggleButton(
columns: _colDefs,
hidden: hidden,
onChanged: (v) {
setState(() => _hiddenCols = v);
ColumnPrefs.save(_screenId, v);
},
),
const SizedBox(width: AppDims.sp2),
OutlinedButton.icon(
onPressed: () => _exportInventory(filteredItems),
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
],
],
),
),
// KPI 卡(还原原型 .kpi):宽屏 4 等分,窄屏横向滚动
Builder(builder: (context) {
final inStockQty =
items.fold<num>(0, (s, i) => s + i.quantity);
// KPI 走全店汇总接口(不随分页)。
final summary = ref.watch(inventorySummaryProvider).valueOrNull;
String yuanWan(double v) => v >= 10000
? '¥${(v / 10000).toStringAsFixed(v >= 1000000 ? 0 : 1)}'
: '¥${v.toStringAsFixed(0)}';
final cards = <Widget>[
KpiCard(
title: '商品总数',
value: '${result.total}',
title: 'SKU 总数',
value: NumberFormat.decimalPattern()
.format(summary?.skuCount ?? result.total),
icon: Icons.inventory_2,
tone: KpiTone.info,
delta: '种 · 点击清筛选',
delta: '点击清筛选',
onTap: () {
setState(() {
_filterWarehouse = {};
_filterSpec = {};
_filterSeries = {};
_statusFilter = '全部';
_searchCtrl.clear();
});
final n = ref.read(inventoryListProvider.notifier);
@@ -565,25 +652,30 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
n.setSeries([]);
},
),
KpiCard(
title: '库存货值',
value: summary != null ? yuanWan(summary.stockValue) : '',
icon: Icons.savings_outlined,
tone: KpiTone.ok,
delta: '按进价',
),
KpiCard(
title: '在库数量',
value: inStockQty.toStringAsFixed(0),
value: NumberFormat.decimalPattern()
.format((summary?.inStockQty ?? 0).round()),
icon: Icons.warehouse_outlined,
tone: KpiTone.blue,
delta: '',
),
KpiCard(
title: '库存预警',
value: '$warningCount',
icon: Icons.warning_amber,
tone: KpiTone.warn,
delta: warningCount > 0 ? '需补货' : '正常',
),
KpiCard(
title: '缺货商品',
value: '$emptyCount',
icon: Icons.remove_shopping_cart,
title: '缺货预警',
value: '${summary?.shortageCount ?? 0}',
icon: Icons.error_outline,
tone: KpiTone.alert,
delta: (summary?.warningCount ?? 0) > 0
? '需补货 ${summary!.warningCount}'
: '点击筛选',
onTap: () => setState(() => _statusFilter = '缺货'),
),
];
final mobile = context.isMobile;
@@ -622,32 +714,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
ref.read(inventoryListProvider.notifier).setPageSize(s),
toolbar: Builder(builder: (ctx) {
final isMobile = ctx.isMobile;
void doExport() => exportExcel(
filename: '库存查询',
headers: ['商品编码', '商品名称', '规格', '系列', '批次号', '仓库', '库存量', '单价', '生产日期', '入库时间', '供应商', '安全库存', '状态'],
rows: filteredItems.map((item) {
final status = item.quantity == 0
? '缺货'
: (item.minStock != null && item.quantity < item.minStock!)
? '库存不足'
: '正常';
return [
item.productCode.isEmpty ? '' : item.productCode,
item.productName.isEmpty ? '' : item.productName,
item.spec.isEmpty ? '' : item.spec,
item.series.isEmpty ? '' : item.series,
item.batchNo.isEmpty ? '' : item.batchNo,
item.warehouseName.isEmpty ? '' : item.warehouseName,
'${item.quantity.toStringAsFixed(0)} ${item.unit}'.trim(),
item.unitPrice != null ? item.unitPrice!.toStringAsFixed(2) : '',
item.productionDate ?? '',
item.createdAt != null && item.createdAt!.length >= 10 ? item.createdAt!.substring(0, 10) : '',
item.supplierName.isEmpty ? '' : item.supplierName,
item.minStock ?? '',
status,
];
}).toList(),
);
void doExport() => _exportInventory(filteredItems);
final searchField = TextField(
controller: _searchCtrl,
@@ -729,43 +796,63 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
);
}
// 桌面:搜索置于最前(原型 searchbox 240),按钮成组靠左
// 桌面(原型 .toolbar):搜索 + 状态筛选 + 重置(导出/列设置在头部)
return Row(
children: [
SizedBox(width: 240, child: searchField),
const SizedBox(width: AppDims.sp3),
if (canCheck) ...[
WriteGuard(
child: OutlinedButton.icon(
onPressed: () => context.go('/inventory/check'),
icon: const Icon(Icons.fact_check, size: 16),
label: const Text('发起盘点'),
PopupMenuButton<String>(
initialValue: _statusFilter,
onSelected: (v) => setState(() => _statusFilter = v),
itemBuilder: (_) => const ['全部', '在售', '预警', '缺货']
.map((s) =>
PopupMenuItem(value: s, child: Text(s)))
.toList(),
child: Container(
height: 38,
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
border: Border.all(color: ctx.tokens.border),
borderRadius: BorderRadius.circular(AppDims.rMd),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('状态',
style: TextStyle(
fontSize: 13, color: ctx.tokens.text)),
if (_statusFilter != '全部') ...[
const SizedBox(width: 6),
Text(_statusFilter,
style: TextStyle(
fontSize: 13,
color: ctx.tokens.primary,
fontWeight: FontWeight.w600)),
],
const SizedBox(width: 4),
Icon(Icons.keyboard_arrow_down,
size: 16, color: ctx.tokens.muted),
],
),
),
const SizedBox(width: AppDims.sp2),
],
OutlinedButton.icon(
onPressed: doExport,
icon: const Icon(Icons.download, size: 16),
label: const Text('导出'),
),
const SizedBox(width: AppDims.sp2),
ColumnToggleButton(
columns: _colDefs,
hidden: hidden,
onChanged: (v) {
setState(() => _hiddenCols = v);
ColumnPrefs.save(_screenId, v);
},
),
const SizedBox(width: AppDims.sp2),
OutlinedButton.icon(
onPressed: () =>
ref.read(inventoryListProvider.notifier).reload(),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('刷新'),
),
const Spacer(),
TextButton(
onPressed: () {
setState(() {
_filterWarehouse = {};
_filterSpec = {};
_filterSeries = {};
_statusFilter = '全部';
_searchCtrl.clear();
});
final n = ref.read(inventoryListProvider.notifier);
n.setKeyword('');
n.setSpec([]);
n.setSeries([]);
},
child: const Text('重置'),
),
],
);
}),
@@ -802,7 +889,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
};
return DataColumn(
label: label,
numeric: c.key == 'price',
numeric: const {'qty', 'cost', 'price'}.contains(c.key),
);
}).toList(),
rows: items.isEmpty
@@ -1208,12 +1295,12 @@ class _ImportProgressDialog extends StatelessWidget {
const SizedBox(height: 12),
LinearProgressIndicator(
value: state.uploadPercent / 100,
backgroundColor: Colors.grey[200],
backgroundColor: context.tokens.borderSubtle,
valueColor: AlwaysStoppedAnimation<Color>(context.tokens.primary),
),
const SizedBox(height: 6),
Text('${state.uploadPercent}%',
style: TextStyle(fontSize: 12, color: Colors.grey[600])),
style: TextStyle(fontSize: 12, color: context.tokens.muted)),
],
);
@@ -1238,7 +1325,7 @@ class _ImportProgressDialog extends StatelessWidget {
]),
const SizedBox(height: 8),
LinearProgressIndicator(
backgroundColor: Colors.grey[200],
backgroundColor: context.tokens.borderSubtle,
valueColor: AlwaysStoppedAnimation<Color>(context.tokens.primary),
),
],
@@ -1266,7 +1353,7 @@ class _ImportProgressDialog extends StatelessWidget {
Text('${state.total}', style: const TextStyle(fontSize: 13)),
Text('成功插入:${state.imported}', style: const TextStyle(fontSize: 13)),
Text('重复更新:${state.updated}',
style: TextStyle(fontSize: 13, color: Colors.grey[600])),
style: TextStyle(fontSize: 13, color: context.tokens.muted)),
if (state.errors.isNotEmpty)
Text('失败:${state.errors.length}',
style: TextStyle(fontSize: 13, color: context.tokens.danger)),
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 175 KiB

@@ -76,6 +76,13 @@ List<Override> _overrides() => [
productSeriesListProvider.overrideWith(() => _FakeSeriesNotifier()),
productSpecListProvider.overrideWith(() => _FakeSpecNotifier()),
isReadonlyProvider.overrideWithValue(false),
inventorySummaryProvider.overrideWith((ref) => const InventorySummary(
skuCount: 1284,
stockValue: 2640000,
inStockQty: 18920,
shortageCount: 17,
warningCount: 6,
)),
];
void main() {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

After

Width:  |  Height:  |  Size: 431 KiB

+4
View File
@@ -12,6 +12,10 @@ Future<void> ensureGoldenFonts() async {
final loader = FontLoader('NotoSansSC')
..addFont(rootBundle.load('assets/fonts/NotoSansSC.ttf'));
await loader.load();
// 把内置 'monospace' family 也指向 Noto(否则等宽数字/批次号在 golden 渲染成黑块)。
final mono = FontLoader('monospace')
..addFont(rootBundle.load('assets/fonts/NotoSansSC.ttf'));
await mono.load();
// 真实 Material 图标字体(否则 golden 里图标渲染成豆腐块 □)。
for (final key in const [
'fonts/MaterialIcons-Regular.otf',