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
@@ -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)),
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 211 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 162 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 176 KiB |
|
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() {
|
||||
|
||||
|
Before Width: | Height: | Size: 431 KiB After Width: | Height: | Size: 431 KiB |
@@ -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',
|
||||
|
||||