diff --git a/.superpowers/prototype/atoms.css b/.superpowers/prototype/atoms.css index dbd79f7..b0aa5d2 100644 --- a/.superpowers/prototype/atoms.css +++ b/.superpowers/prototype/atoms.css @@ -32,6 +32,13 @@ svg{fill:none; stroke:currentColor;} .searchbox svg{width:14px; height:14px; stroke-width:2; color:var(--faint); flex:none;} .searchbox input{border:none; background:transparent; outline:none; font-size:var(--fs-body); color:var(--text); width:100%; font-family:var(--font);} .searchbox input::placeholder{color:var(--faint);} +/* ---- Switch 开关(2026-07-07 新增:库存⇄在售 挂售切换等布尔即时生效场景) ---- + 38×22 pill 轨道 + 18px 圆点滑块;on=primary,off=border;disabled 半透明。 */ +.switch{position:relative; width:38px; height:22px; border-radius:var(--r-pill); background:var(--border); border:none; padding:0; cursor:pointer; transition:background-color .18s; flex:none;} +.switch::after{content:""; position:absolute; top:2px; left:2px; width:18px; height:18px; border-radius:50%; background:var(--surface); box-shadow:var(--sh-1); transition:transform .18s;} +.switch.on{background:var(--primary);} +.switch.on::after{transform:translateX(16px);} +.switch:disabled{opacity:.45; cursor:default;} /* ---- 日期字段(触发器)+ 滚轮日期选择器(年月日,单据/生产/明细共用) ---- */ .datefield{height:38px; width:100%; box-sizing:border-box; background:var(--surface); border:1px solid var(--border); border-radius:var(--r-md); padding:0 11px; display:flex; align-items:center; gap:8px; cursor:pointer; outline:none; transition:border-color .15s,box-shadow .15s;} @@ -79,6 +86,9 @@ svg{fill:none; stroke:currentColor;} .b-在售{background:var(--success-bg); color:var(--success);} .b-缺货{background:var(--danger-bg); color:var(--danger);} .b-预警{background:var(--warn-bg); color:var(--warn);} +/* 库存三态(2026-07-07 落库):库存=在库不公开 / 已售=卖光留痕 */ +.b-库存{background:var(--info-soft); color:var(--primary);} +.b-已售{background:var(--border-subtle); color:var(--muted);} /* 单据状态 */ .b-草稿{background:var(--info-soft); color:var(--muted);} .b-待审核{background:var(--warn-bg); color:var(--warn);} diff --git a/.superpowers/prototype/index.html b/.superpowers/prototype/index.html index b9e7b26..f0eea3f 100644 --- a/.superpowers/prototype/index.html +++ b/.superpowers/prototype/index.html @@ -324,6 +324,12 @@
选择往来单位…
+

Switch 开关

.switch .on · 布尔即时生效(库存⇄在售 挂售切换)· 2026-07-07
+ + + + on / off / disabled(已售行无 switch) +

分段切换 / 可搜索选择器

.seg · .combo(录单网格用)
@@ -369,7 +375,8 @@

状态徽章 · 全部

.badge .b-* · 对齐 schema 真实枚举 · 圆点已全局图标化(icons.js BADGE_ICON 单源映射自动注入,屏内无需手写 svg)
-
库存 · 按 min_stock 派生在售预警缺货
+
库存三态 · status 落库在售库存已售(在售=公开酒单展示;已售=卖光留痕;预警/缺货退出状态列,由数量染色承担)
+
库存 · 数量派生提示预警缺货
单据 · 入库/出库草稿待审核已通过已驳回
往来单位 · 启用停用启用停用供应商客户两者
用户角色管理员普通只读
diff --git a/.superpowers/prototype/screens/icons.js b/.superpowers/prototype/screens/icons.js index 2a3a4e3..0b7b93a 100644 --- a/.superpowers/prototype/screens/icons.js +++ b/.superpowers/prototype/screens/icons.js @@ -9,7 +9,7 @@ // 单一真相源映射:任何屏渲染出的 .badge.b-*(无 .bi/.ico 者)自动注入图标并隐藏圆点。 // 屏内无需手写 svg;新增状态在此登记即可全局生效。 var BADGE_ICON = { - '在售':'i-cart','预警':'i-alert','缺货':'i-close', + '在售':'i-cart','预警':'i-alert','缺货':'i-close','库存':'i-box','已售':'i-check', '草稿':'i-ic06','待审核':'i-ic04','已审核':'i-check','已拒绝':'i-close','已通过':'i-check','已驳回':'i-close', '部分退单':'i-undo','已退单':'i-undo','待定价':'i-yen', '启用':'i-check','停用':'i-close','供应商':'i-box','客户':'i-ic37','两者':'i-refresh', diff --git a/.superpowers/prototype/screens/inventory.html b/.superpowers/prototype/screens/inventory.html index c845517..5984f84 100644 --- a/.superpowers/prototype/screens/inventory.html +++ b/.superpowers/prototype/screens/inventory.html @@ -54,7 +54,7 @@
SKU 总数
1,284
▲ 2.3% 较上月
库存货值
¥264万
▲ 1.1% 较上月
在库数量
18,920
▼ 0.6% 较上月
-
缺货预警 · 点击筛选
17
需补货 6 项
+
已售 · 点击筛选
17
需补货 6 项
@@ -101,18 +101,18 @@ const ITEMS=[ {name:'茅台 飞天 53°',code:'MT-FT-500',spec:'500ml×6',series:'飞天',batch:'PZ240711',cat:'酱香',qty:128,unit:'件',cost:'¥2,680',price:'¥343,040',prodDate:'2024-07-11',inDate:'2026-06-14',supplier:'贵州茅台经销',value:'¥34.3万',status:'在售'}, {name:'五粮液 普五 52°',code:'WLY-PW-500',spec:'500ml×6',series:'普五',batch:'PZ240428',cat:'浓香',qty:64,unit:'件',cost:'¥1,050',price:'¥67,200',prodDate:'2024-04-28',inDate:'2026-06-14',supplier:'宜宾五粮液',value:'¥6.7万',status:'在售'}, - {name:'剑南春 水晶剑',code:'JNC-SJ-500',spec:'500ml×6',series:'水晶剑',batch:'PZ231115',cat:'浓香',qty:8,unit:'件',cost:'¥438',price:'¥3,504',prodDate:'2023-11-15',inDate:'2026-06-12',supplier:'绵竹剑南春',value:'¥3,500',status:'缺货'}, + {name:'剑南春 水晶剑',code:'JNC-SJ-500',spec:'500ml×6',series:'水晶剑',batch:'PZ231115',cat:'浓香',qty:8,unit:'件',cost:'¥438',price:'¥3,504',prodDate:'2023-11-15',inDate:'2026-06-12',supplier:'绵竹剑南春',value:'¥3,500',status:'已售'}, {name:'国窖 1573',code:'GJ-1573-500',spec:'500ml×6',series:'1573',batch:'PZ240208',cat:'浓香',qty:42,unit:'件',cost:'¥980',price:'¥41,160',prodDate:'2024-02-08',inDate:'2026-06-10',supplier:'泸州老窖',value:'¥4.1万',status:'在售'}, - {name:'洋河 梦之蓝 M6',code:'YH-M6-500',spec:'500ml×6',series:'梦之蓝M6',batch:'PZ240520',cat:'绵柔',qty:23,unit:'件',cost:'¥620',price:'¥14,260',prodDate:'2024-05-20',inDate:'2026-06-09',supplier:'洋河股份',value:'¥1.4万',status:'预警'}, + {name:'洋河 梦之蓝 M6',code:'YH-M6-500',spec:'500ml×6',series:'梦之蓝M6',batch:'PZ240520',cat:'绵柔',qty:23,unit:'件',cost:'¥620',price:'¥14,260',prodDate:'2024-05-20',inDate:'2026-06-09',supplier:'洋河股份',value:'¥1.4万',status:'库存'}, {name:'泸州老窖 特曲',code:'LZLJ-TQ-500',spec:'500ml×6',series:'特曲',batch:'PZ240630',cat:'浓香',qty:156,unit:'件',cost:'¥320',price:'¥49,920',prodDate:'2024-06-30',inDate:'2026-06-08',supplier:'泸州老窖',value:'¥5万',status:'在售'}, {name:'习酒 窖藏 1988',code:'XJ-JC1988',spec:'500ml×6',series:'窖藏1988',batch:'PZ230918',cat:'酱香',qty:31,unit:'件',cost:'¥760',price:'¥23,560',prodDate:'2023-09-18',inDate:'2026-06-05',supplier:'贵州习酒',value:'¥2.4万',status:'在售'}, - {name:'汾酒 青花 20',code:'FJ-QH20-500',spec:'500ml×6',series:'青花20',batch:'PZ240112',cat:'清香',qty:0,unit:'件',cost:'¥420',price:'¥0',prodDate:'2024-01-12',inDate:'2026-06-03',supplier:'山西杏花村',value:'¥0',status:'缺货'}, + {name:'汾酒 青花 20',code:'FJ-QH20-500',spec:'500ml×6',series:'青花20',batch:'PZ240112',cat:'清香',qty:0,unit:'件',cost:'¥420',price:'¥0',prodDate:'2024-01-12',inDate:'2026-06-03',supplier:'山西杏花村',value:'¥0',status:'已售'}, {name:'郎酒 红花郎 15',code:'LJ-HHL15',spec:'500ml×6',series:'红花郎15',batch:'PZ240325',cat:'酱香',qty:54,unit:'件',cost:'¥560',price:'¥30,240',prodDate:'2024-03-25',inDate:'2026-06-01',supplier:'四川郎酒',value:'¥3万',status:'在售'}, - {name:'古井贡 年份原浆',code:'GJG-NF-500',spec:'500ml×6',series:'年份原浆',batch:'PZ240802',cat:'浓香',qty:12,unit:'件',cost:'¥390',price:'¥4,680',prodDate:'2024-08-02',inDate:'2026-05-28',supplier:'古井贡酒',value:'¥4,700',status:'预警'}, + {name:'古井贡 年份原浆',code:'GJG-NF-500',spec:'500ml×6',series:'年份原浆',batch:'PZ240802',cat:'浓香',qty:12,unit:'件',cost:'¥390',price:'¥4,680',prodDate:'2024-08-02',inDate:'2026-05-28',supplier:'古井贡酒',value:'¥4,700',status:'库存'}, {name:'西凤酒 旗舰版',code:'XF-QJ-500',spec:'500ml×6',series:'旗舰版',batch:'PZ240614',cat:'凤香',qty:88,unit:'件',cost:'¥280',price:'¥24,640',prodDate:'2024-06-14',inDate:'2026-05-25',supplier:'陕西西凤',value:'¥2.5万',status:'在售'}, - {name:'水井坊 井台',code:'SJF-JT-500',spec:'500ml×6',series:'井台',batch:'PZ231209',cat:'浓香',qty:6,unit:'件',cost:'¥680',price:'¥4,080',prodDate:'2023-12-09',inDate:'2026-05-20',supplier:'四川水井坊',value:'¥4,100',status:'缺货'}, + {name:'水井坊 井台',code:'SJF-JT-500',spec:'500ml×6',series:'井台',batch:'PZ231209',cat:'浓香',qty:6,unit:'件',cost:'¥680',price:'¥4,080',prodDate:'2023-12-09',inDate:'2026-05-20',supplier:'四川水井坊',value:'¥4,100',status:'已售'}, ]; -const STATUS=['全部','在售','预警','缺货']; +const STATUS=['全部','在售','库存','已售']; const COLS=[{key:'product',label:'商品',fixed:true},{key:'spec',label:'规格'},{key:'series',label:'系列'},{key:'batch',label:'批次号'},{key:'qty',label:'库存',num:true},{key:'cost',label:'成本价',num:true},{key:'price',label:'总价',num:true},{key:'prodDate',label:'生产日期'},{key:'inDate',label:'入库时间'},{key:'supplier',label:'供应商'},{key:'status',label:'状态',filter:'status'},{key:'act',label:'操作',fixed:true}]; const state={q:'',code:'',status:'全部',page:1,perPage:10,hidden:new Set()}; diff --git a/.superpowers/prototype/screens/m-inventory.html b/.superpowers/prototype/screens/m-inventory.html index 4a30f41..bc9908e 100644 --- a/.superpowers/prototype/screens/m-inventory.html +++ b/.superpowers/prototype/screens/m-inventory.html @@ -20,7 +20,7 @@
SKU 总数
1,284
▲ 2.3% 较上月
库存货值
¥264万
▲ 1.5% 较上月
在库数量
18,920
件 · 实时
-
缺货预警
17
需补货 6 项
+
已售
17
需补货 6 项
@@ -38,12 +38,12 @@ const ITEMS=[ {name:'茅台 飞天 53°',code:'MT-FT-500',spec:'500ml×6',series:'飞天',batch:'PZ240711',qty:128,unit:'件',cost:'¥2,680',supplier:'贵州茅台经销',status:'在售'}, {name:'五粮液 普五 52°',code:'WLY-PW-500',spec:'500ml×6',series:'普五',batch:'PZ240428',qty:64,unit:'件',cost:'¥1,050',supplier:'宜宾五粮液',status:'在售'}, - {name:'剑南春 水晶剑',code:'JNC-SJ-500',spec:'500ml×6',series:'水晶剑',batch:'PZ231115',qty:8,unit:'件',cost:'¥438',supplier:'绵竹剑南春',status:'缺货'}, + {name:'剑南春 水晶剑',code:'JNC-SJ-500',spec:'500ml×6',series:'水晶剑',batch:'PZ231115',qty:8,unit:'件',cost:'¥438',supplier:'绵竹剑南春',status:'已售'}, {name:'国窖 1573',code:'GJ-1573-500',spec:'500ml×6',series:'1573',batch:'PZ240208',qty:42,unit:'件',cost:'¥980',supplier:'泸州老窖',status:'在售'}, - {name:'洋河 梦之蓝 M6',code:'YH-M6-500',spec:'550ml×6',series:'梦之蓝M6',batch:'PZ240520',qty:23,unit:'件',cost:'¥620',supplier:'洋河股份',status:'预警'}, + {name:'洋河 梦之蓝 M6',code:'YH-M6-500',spec:'550ml×6',series:'梦之蓝M6',batch:'PZ240520',qty:23,unit:'件',cost:'¥620',supplier:'洋河股份',status:'库存'}, {name:'泸州老窖 特曲',code:'LZLJ-TQ-500',spec:'500ml×6',series:'特曲',batch:'PZ240630',qty:156,unit:'件',cost:'¥320',supplier:'泸州老窖',status:'在售'}, {name:'习酒 窖藏 1988',code:'XJ-JC1988',spec:'500ml×6',series:'窖藏1988',batch:'PZ230918',qty:31,unit:'件',cost:'¥760',supplier:'贵州习酒',status:'在售'}, - {name:'汾酒 青花 20',code:'FJ-QH20-500',spec:'500ml×6',series:'青花20',batch:'PZ240112',qty:0,unit:'件',cost:'¥420',supplier:'山西杏花村',status:'缺货'}, + {name:'汾酒 青花 20',code:'FJ-QH20-500',spec:'500ml×6',series:'青花20',batch:'PZ240112',qty:0,unit:'件',cost:'¥420',supplier:'山西杏花村',status:'已售'}, {name:'郎酒 红花郎 15',code:'LJ-HHL15',spec:'500ml×6',series:'红花郎15',batch:'PZ240325',qty:54,unit:'件',cost:'¥560',supplier:'四川郎酒',status:'在售'}, {name:'西凤酒 旗舰版',code:'XF-QJ-500',spec:'500ml×6',series:'旗舰版',batch:'PZ240614',qty:88,unit:'件',cost:'¥280',supplier:'陕西西凤',status:'在售'}, ]; @@ -66,7 +66,7 @@ function render(){ function openItem(idx){ const it=ITEMS[idx]; if(!peEdit||peEdit.idx!==idx) peEdit={idx,imgs:[PE_SAMPLE[0],'',''],introName:'',introBody:''}; const hasIntro=!!peEdit.introName; - openSheet(it.name, `
编码${it.code}
规格${it.spec}
系列${it.series}
批次号${it.batch}
当前库存${it.qty} ${it.unit}
成本价${it.cost}
供应商${it.supplier}
状态${it.status}
+ openSheet(it.name, `
编码${it.code}
规格${it.spec}
系列${it.series}
批次号${it.batch}
当前库存${it.qty} ${it.unit}
成本价${it.cost}
供应商${it.supplier}
状态${it.status}${it.status==='已售'?'':``}
商品图片
建议主图为白底产品图,可再加细节图(瓶身/防伪)与场景图。
商品介绍 / 卖点
diff --git a/.superpowers/prototype/screens/product-editor.js b/.superpowers/prototype/screens/product-editor.js index 56dcfbd..cbf2ac8 100644 --- a/.superpowers/prototype/screens/product-editor.js +++ b/.superpowers/prototype/screens/product-editor.js @@ -64,7 +64,8 @@ if (it.qty != null) r.push(['当前库存', `${it.qty} ${it.unit || ''}`]); if (it.cost) r.push(['成本价', `${it.cost}`]); if (it.value) r.push(['库存货值', `${it.value}`]); - if (it.status) r.push(['状态', `${it.status}`]); + // 状态行:三态徽章 + 挂售 switch(在售⇄库存 即时生效;已售只读无 switch) + if (it.status) r.push(['状态', `${it.status}${it.status === '已售' ? '' : ``}`]); return r.map((x, i) => `
${x[0]}${x[1]}
`).join(''); } diff --git a/client/lib/models/inventory.dart b/client/lib/models/inventory.dart index a0d2918..a367de9 100644 --- a/client/lib/models/inventory.dart +++ b/client/lib/models/inventory.dart @@ -9,6 +9,9 @@ class Inventory { final int? productId; final int? stockInItemId; final double quantity; + + /// 库存三态(2026-07-07):stock=库存 / on_sale=在售(公开酒单) / sold=已售(卖光留痕) + final String status; final String productCode; final String productName; final String series; @@ -31,6 +34,7 @@ class Inventory { this.productId, this.stockInItemId, required this.quantity, + this.status = 'stock', this.productCode = '', this.productName = '', this.series = '', @@ -54,6 +58,7 @@ class Inventory { productId: (json['product_id'] as num?)?.toInt(), stockInItemId: (json['stock_in_item_id'] as num?)?.toInt(), quantity: (json['quantity'] as num).toDouble(), + status: json['status'] as String? ?? 'stock', productCode: json['product_code'] as String? ?? '', productName: json['product_name'] as String? ?? '', series: json['series'] as String? ?? '', @@ -77,6 +82,7 @@ class InventorySummary { final int skuCount; final double stockValue; final double inStockQty; + final int soldCount; // 已售留痕行数(status=sold) final int shortageCount; final int warningCount; // 月初快照(KPI 环比):由 inventory_logs 反推 @@ -87,6 +93,7 @@ class InventorySummary { this.skuCount = 0, this.stockValue = 0, this.inStockQty = 0, + this.soldCount = 0, this.shortageCount = 0, this.warningCount = 0, this.lastMonthSku = 0, @@ -97,6 +104,7 @@ class 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, + soldCount: (j['sold_count'] as num?)?.toInt() ?? 0, shortageCount: (j['shortage_count'] as num?)?.toInt() ?? 0, warningCount: (j['warning_count'] as num?)?.toInt() ?? 0, lastMonthSku: (j['last_month_sku'] as num?)?.toInt() ?? 0, diff --git a/client/lib/providers/inventory_provider.dart b/client/lib/providers/inventory_provider.dart index 0ee7299..2c393c6 100644 --- a/client/lib/providers/inventory_provider.dart +++ b/client/lib/providers/inventory_provider.dart @@ -30,6 +30,7 @@ class InventoryListNotifier extends AsyncNotifier> { int? _warehouseId; String _keyword = ''; String _code = ''; // 商品编码独立筛选(原型 codeInput) + String _status = ''; // 三态筛选(空=后端默认排除已售) List _series = []; List _spec = []; PageResult? _cache; @@ -53,6 +54,7 @@ class InventoryListNotifier extends AsyncNotifier> { warehouseId: _warehouseId, keyword: _keyword.isEmpty ? null : _keyword, code: _code.isEmpty ? null : _code, + status: _status.isEmpty ? null : _status, series: _series.isEmpty ? null : _series, spec: _spec.isEmpty ? null : _spec, page: _page, @@ -89,6 +91,12 @@ class InventoryListNotifier extends AsyncNotifier> { reload(); } + void setStatus(String status) { + _status = status; + _page = 1; + reload(); + } + void setSeries(List series) { _series = series; _page = 1; diff --git a/client/lib/repositories/inventory_repository.dart b/client/lib/repositories/inventory_repository.dart index 21adf90..825b241 100644 --- a/client/lib/repositories/inventory_repository.dart +++ b/client/lib/repositories/inventory_repository.dart @@ -16,6 +16,7 @@ class InventoryRepository { String? code, List? series, List? spec, + String? status, // 三态筛选:stock/on_sale/sold(逗号可多值);空=后端默认(排除已售) int page = 1, int pageSize = 50, }) async { @@ -29,6 +30,7 @@ class InventoryRepository { if (code != null && code.isNotEmpty) 'code': code, if (series != null && series.isNotEmpty) 'series': series.join(','), if (spec != null && spec.isNotEmpty) 'spec': spec.join(','), + if (status != null && status.isNotEmpty) 'status': status, }; final resp = await _client.get('/inventory', params: params); return PageResult.fromJson( @@ -67,6 +69,18 @@ class InventoryRepository { } } + /// 库存⇄在售 挂售切换(status 仅收 stock/on_sale;sold 由出库流程专属) + Future updateStatus(int id, String status) async { + try { + await _client.put('/inventory/$id/status', data: {'status': status}); + } on DioException catch (e) { + throw AppException( + e.response?.data?['error'] as String? ?? '状态更新失败', + statusCode: e.response?.statusCode, + ); + } + } + Future updateRemark(int id, String remark) async { try { await _client.put('/inventory/$id/remark', data: {'remark': remark}); diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index a6d8990..09763ad 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -38,12 +38,19 @@ class _InventoryListScreenState extends ConsumerState { Set _filterWarehouse = {}; Set _filterSpec = {}; Set _filterSeries = {}; - // 库存状态前端筛选(派生自 qty vs 安全库存):全部/在售/预警/缺货 + // 库存三态筛选(2026-07-07 status 落库,服务端过滤):全部/在售/库存/已售; + // 默认「全部」= 后端排除已售(已售仅点已售卡/筛选时显示)。预警缺货退出状态维度。 String _statusFilter = '全部'; + static const _statusParam = { + '全部': '', + '在售': 'on_sale', + '库存': 'stock', + '已售': 'sold', + }; Set? _hiddenCols; // null = 尚未载入本地存档 static const _screenId = 'inventory_list'; - static const _statusOptions = ['全部', '在售', '预警', '缺货']; + static const _statusOptions = ['全部', '在售', '库存', '已售']; // 列序照原型 inventory.html COLS:商品/规格/系列/批次/库存/成本价/单价/生产/入库/供应商/状态/操作。 // 成本价=入库进价(unitPrice),总价=库存×成本价(2026-07-03 用户拍板:原「单价/参考售价」列换掉)。 @@ -95,13 +102,21 @@ class _InventoryListScreenState extends ConsumerState { n.setKeyword(''); n.setSpec([]); n.setSeries([]); + n.setStatus(''); } - String _statusOf(Inventory i) => i.quantity == 0 - ? '缺货' - : (i.minStock != null && i.quantity < i.minStock!) - ? '预警' - : '在售'; + // 三态落库后的显示标签(status 列真相源) + String _statusOf(Inventory i) => switch (i.status) { + 'on_sale' => '在售', + 'sold' => '已售', + _ => '库存', + }; + + // 低库存提示(预警/缺货退出状态列后由数量染色承担) + bool _isLowStock(Inventory i) => + i.status != 'sold' && + (i.quantity == 0 || + (i.minStock != null && i.quantity < i.minStock!)); void _openEditor(Inventory item) { if (item.productId == null) return; @@ -112,9 +127,20 @@ class _InventoryListScreenState extends ConsumerState { unit: item.unit, cost: item.unitPrice, status: _statusOf(item), + inventoryId: item.id, + inventoryStatus: item.status, + onStatusChanged: () => + ref.read(inventoryListProvider.notifier).reload(), ); } + void _applyStatusFilter(String label) { + setState(() => _statusFilter = label); + ref + .read(inventoryListProvider.notifier) + .setStatus(_statusParam[label] ?? ''); + } + // 成本仅管理员可见(与出库同口径):非管理员隐藏成本价/总价列、卡片单价、货值 KPI。 bool get _isAdmin => ref.watch(isAdminProvider); bool _colAllowed(ColDef c) => @@ -149,7 +175,7 @@ class _InventoryListScreenState extends ConsumerState { DsMenuItem(value: s, label: s, selected: s == _statusFilter), ], ); - if (v != null && mounted) setState(() => _statusFilter = v); + if (v != null && mounted) _applyStatusFilter(v); } // ── 多选漏斗(规格/系列/仓库) ── @@ -223,7 +249,7 @@ class _InventoryListScreenState extends ConsumerState { selected: _statusFilter == s, caret: false, onTap: () { - setState(() => _statusFilter = s); + _applyStatusFilter(s); setSheet(() {}); }), ]), @@ -512,12 +538,12 @@ class _InventoryListScreenState extends ConsumerState { return '¥$buf${parts.length > 1 ? '.${parts[1]}' : ''}'; } - /// 状态徽章(DsBadge):派生 qty vs 安全库存 → 在售/预警/缺货。 + /// 状态徽章(DsBadge):三态落库 status 真相源。 Widget _dsStatusBadge(Inventory item) { - return switch (_statusOf(item)) { - '缺货' => const DsBadge('缺货', tone: DsBadgeTone.danger), - '预警' => const DsBadge('预警', tone: DsBadgeTone.warn), - _ => const DsBadge('在售', tone: DsBadgeTone.ok), + return switch (item.status) { + 'on_sale' => const DsBadge('在售', tone: DsBadgeTone.ok), + 'sold' => const DsBadge('已售', tone: DsBadgeTone.muted), + _ => const DsBadge('库存', tone: DsBadgeTone.info), }; } @@ -526,11 +552,12 @@ class _InventoryListScreenState extends ConsumerState { Widget _inventoryCard(Inventory item) { final t = context.tokens; final status = _statusOf(item); - final qtyColor = switch (status) { - '缺货' => t.danger, - '预警' => t.warn, - _ => t.heading, - }; + // 低库存数量染色(预警/缺货退出状态维度后由数量承担) + final qtyColor = !_isLowStock(item) + ? t.heading + : item.quantity == 0 + ? t.danger + : t.warn; final sub = [ if (item.productCode.isNotEmpty) item.productCode, if (item.spec.isNotEmpty) item.spec, @@ -626,11 +653,11 @@ class _InventoryListScreenState extends ConsumerState { ?.map((s) => s.name) .toList() ?? []; + // 状态筛选已走服务端(setStatus);仓库仍为当前页客户端筛选 final filteredItems = items .where((i) => - (_filterWarehouse.isEmpty || - _filterWarehouse.contains(i.warehouseName)) && - (_statusFilter == '全部' || _statusOf(i) == _statusFilter)) + _filterWarehouse.isEmpty || + _filterWarehouse.contains(i.warehouseName)) .toList(); final hidden = _hiddenCols ?? {}; @@ -739,12 +766,12 @@ class _InventoryListScreenState extends ConsumerState { deltaTone: _mTone(qtyTone), ), MKpiItem( - label: '缺货预警', - value: '${summary?.shortageCount ?? 0}', + label: '已售', + value: '${summary?.soldCount ?? 0}', delta: '需补货 ${summary?.warningCount ?? 0} 项', deltaTone: MKpiDeltaTone.warn, - selected: _statusFilter == '缺货', - onTap: () => setState(() => _statusFilter = '缺货'), + selected: _statusFilter == '已售', + onTap: () => _applyStatusFilter('已售'), ), ]), ); @@ -780,15 +807,15 @@ class _InventoryListScreenState extends ConsumerState { deltaTone: qtyTone, ), DsKpi( - title: '缺货预警 · 点击筛选', - value: '${summary?.shortageCount ?? 0}', - icon: LucideIcons.triangleAlert, + title: '已售 · 点击筛选', + value: '${summary?.soldCount ?? 0}', + icon: LucideIcons.check, tone: DsKpiTone.alert, delta: '需补货 ${summary?.warningCount ?? 0} 项', deltaTone: (summary?.warningCount ?? 0) > 0 ? DsKpiDelta.down : DsKpiDelta.neutral, - onTap: () => setState(() => _statusFilter = '缺货'), + onTap: () => _applyStatusFilter('已售'), ), ]; final row = []; diff --git a/client/lib/widgets/ds/ds_switch.dart b/client/lib/widgets/ds/ds_switch.dart new file mode 100644 index 0000000..5def4e3 --- /dev/null +++ b/client/lib/widgets/ds/ds_switch.dart @@ -0,0 +1,55 @@ +// widgets/ds/ds_switch.dart — 原型 atoms.css .switch 的 Flutter 对照(2026-07-07 登记)。 +// 38×22 pill 轨道 + 18px 圆点滑块:on=primary / off=border / disabled 45% 透明。 +// 用于布尔即时生效场景(库存⇄在售挂售切换);不同于 Material Switch 的尺寸与配色。 +import 'package:flutter/material.dart'; + +import '../../core/theme/context_tokens.dart'; + +class DsSwitch extends StatelessWidget { + final bool value; + final ValueChanged? onChanged; // null = disabled + const DsSwitch({super.key, required this.value, this.onChanged}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final enabled = onChanged != null; + return Semantics( + toggled: value, + button: true, + child: GestureDetector( + onTap: enabled ? () => onChanged!(!value) : null, + child: Opacity( + opacity: enabled ? 1 : .45, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: 38, + height: 22, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: value ? t.primary : t.border, + borderRadius: BorderRadius.circular(999), + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + alignment: value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: t.surface, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: t.shadow, blurRadius: 3, offset: const Offset(0, 1)), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/client/lib/widgets/ds/status_icon_map.dart b/client/lib/widgets/ds/status_icon_map.dart index 0493c79..7c316ac 100644 --- a/client/lib/widgets/ds/status_icon_map.dart +++ b/client/lib/widgets/ds/status_icon_map.dart @@ -5,8 +5,10 @@ import 'package:flutter/widgets.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; const Map kStatusIcons = { - // 库存状态 + // 库存状态(三态落库 2026-07-07:在售/库存/已售;预警/缺货为数量派生提示) '在售': LucideIcons.shoppingCart, // i-cart + '库存': LucideIcons.box, // i-box + '已售': LucideIcons.check, // i-check '预警': LucideIcons.triangleAlert, // i-alert '缺货': LucideIcons.x, // i-close // 单据状态 diff --git a/client/lib/widgets/product_editor_drawer.dart b/client/lib/widgets/product_editor_drawer.dart index 57010b0..a8fc1eb 100644 --- a/client/lib/widgets/product_editor_drawer.dart +++ b/client/lib/widgets/product_editor_drawer.dart @@ -20,10 +20,12 @@ import '../core/theme/app_dims.g.dart'; import '../core/theme/context_tokens.dart'; import '../models/product.dart'; import '../models/product_image.dart'; +import '../providers/inventory_provider.dart' show inventoryRepositoryProvider; import '../providers/product_option_provider.dart'; import '../providers/product_provider.dart'; import '../providers/shop_provider.dart' show shopInfoProvider; import 'ds/ds_atoms.dart'; +import 'ds/ds_switch.dart'; import 'ds/grid_combo_cell.dart'; import 'ds/m_sheet.dart'; import 'searchable_option_field.dart'; @@ -41,7 +43,10 @@ Future showProductEditorDrawer( double? qty, String? unit, double? cost, - String? status, // 在售 / 预警 / 缺货 + String? status, // 在售 / 库存 / 已售(三态显示标签) + int? inventoryId, // 库存行 id:非空时状态行显示挂售 switch + String? inventoryStatus, // stock / on_sale / sold(switch 初始态) + VoidCallback? onStatusChanged, // switch 切换成功后回调(列表刷新) }) { // 窄屏:底部 sheet 形态(原型 m-inventory openItem 内嵌公开页编辑) if (context.isMobile) { @@ -56,7 +61,10 @@ Future showProductEditorDrawer( qty: qty, unit: unit, cost: cost, - status: status), + status: status, + inventoryId: inventoryId, + inventoryStatus: inventoryStatus, + onStatusChanged: onStatusChanged), ), ); } @@ -83,7 +91,10 @@ Future showProductEditorDrawer( qty: qty, unit: unit, cost: cost, - status: status), + status: status, + inventoryId: inventoryId, + inventoryStatus: inventoryStatus, + onStatusChanged: onStatusChanged), ), ), ), @@ -106,12 +117,18 @@ class _ProductEditorDrawer extends ConsumerStatefulWidget { final String? unit; final double? cost; final String? status; + final int? inventoryId; + final String? inventoryStatus; + final VoidCallback? onStatusChanged; const _ProductEditorDrawer({ required this.productId, this.qty, this.unit, this.cost, this.status, + this.inventoryId, + this.inventoryStatus, + this.onStatusChanged, }); @override @@ -125,6 +142,32 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> { bool _uploading = false; bool _saving = false; + // 库存三态挂售 switch(stock⇄on_sale;sold 只读无 switch) + late String? _invStatus = widget.inventoryStatus; + bool _statusSaving = false; + + Future _toggleOnSale(bool on) async { + final id = widget.inventoryId; + if (id == null || _statusSaving) return; + final next = on ? 'on_sale' : 'stock'; + final prev = _invStatus; + setState(() { + _invStatus = next; + _statusSaving = true; + }); + try { + await ref.read(inventoryRepositoryProvider).updateStatus(id, next); + widget.onStatusChanged?.call(); + } catch (e) { + if (mounted) { + setState(() => _invStatus = prev); + showDsToast(context, '状态更新失败:$e', bg: context.tokens.danger); + } + } finally { + if (mounted) setState(() => _statusSaving = false); + } + } + // 介绍选择态(.pe-pick / .pe-text) String? _introTitle; // 已选介绍库标题;null → 占位「从介绍库选择…」 int? _introDocId; @@ -366,15 +409,40 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> { rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost)))); } } - if ((widget.status ?? '').isNotEmpty) { + // 状态行:三态徽章 + 挂售 switch(在售⇄库存 即时生效;已售只读;readonly 禁用) + final invStatus = _invStatus; + final statusLabel = switch (invStatus) { + 'on_sale' => '在售', + 'sold' => '已售', + 'stock' => '库存', + _ => widget.status ?? '', + }; + if (statusLabel.isNotEmpty) { + final badge = DsBadge(statusLabel, + tone: switch (statusLabel) { + '在售' => DsBadgeTone.ok, + '已售' => DsBadgeTone.muted, + '库存' => DsBadgeTone.info, + '缺货' => DsBadgeTone.danger, + '预警' => DsBadgeTone.warn, + _ => DsBadgeTone.ok, + }); + final canToggle = widget.inventoryId != null && + invStatus != null && + invStatus != 'sold' && + !ref.watch(isReadonlyProvider); rows.add(( '状态', - DsBadge(widget.status!, - tone: switch (widget.status) { - '缺货' => DsBadgeTone.danger, - '预警' => DsBadgeTone.warn, - _ => DsBadgeTone.ok, - }) + Row(mainAxisSize: MainAxisSize.min, children: [ + badge, + if (widget.inventoryId != null && invStatus != null && invStatus != 'sold') ...[ + const SizedBox(width: 10), + DsSwitch( + value: invStatus == 'on_sale', + onChanged: canToggle && !_statusSaving ? _toggleOnSale : null, + ), + ], + ]) )); } return Column( diff --git a/client/test/golden/goldens/inventory_list_a.png b/client/test/golden/goldens/inventory_list_a.png index 8ebc29c..908b160 100644 Binary files a/client/test/golden/goldens/inventory_list_a.png and b/client/test/golden/goldens/inventory_list_a.png differ diff --git a/client/test/golden/goldens/inventory_list_b.png b/client/test/golden/goldens/inventory_list_b.png index 2f629f7..b38c05c 100644 Binary files a/client/test/golden/goldens/inventory_list_b.png and b/client/test/golden/goldens/inventory_list_b.png differ diff --git a/client/test/golden/goldens/inventory_list_c.png b/client/test/golden/goldens/inventory_list_c.png index bf5e4d0..998ec24 100644 Binary files a/client/test/golden/goldens/inventory_list_c.png and b/client/test/golden/goldens/inventory_list_c.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_a.png b/client/test/golden/goldens/inventory_list_mobile_a.png index 6cdb47c..6b227a1 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_a.png and b/client/test/golden/goldens/inventory_list_mobile_a.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_b.png b/client/test/golden/goldens/inventory_list_mobile_b.png index 24dd9ef..4e9b5f2 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_b.png and b/client/test/golden/goldens/inventory_list_mobile_b.png differ diff --git a/client/test/golden/goldens/inventory_list_mobile_c.png b/client/test/golden/goldens/inventory_list_mobile_c.png index b323169..de587bc 100644 Binary files a/client/test/golden/goldens/inventory_list_mobile_c.png and b/client/test/golden/goldens/inventory_list_mobile_c.png differ diff --git a/client/test/golden/goldens/m_inventory_a.png b/client/test/golden/goldens/m_inventory_a.png index dc2de1a..226af52 100644 Binary files a/client/test/golden/goldens/m_inventory_a.png and b/client/test/golden/goldens/m_inventory_a.png differ diff --git a/client/test/golden/goldens/m_inventory_b.png b/client/test/golden/goldens/m_inventory_b.png index d07ea28..cff175d 100644 Binary files a/client/test/golden/goldens/m_inventory_b.png and b/client/test/golden/goldens/m_inventory_b.png differ diff --git a/client/test/golden/goldens/m_inventory_c.png b/client/test/golden/goldens/m_inventory_c.png index 91c8d82..ecb4dea 100644 Binary files a/client/test/golden/goldens/m_inventory_c.png and b/client/test/golden/goldens/m_inventory_c.png differ diff --git a/client/test/golden/inventory_list_golden_test.dart b/client/test/golden/inventory_list_golden_test.dart index 01c0233..70f1469 100644 --- a/client/test/golden/inventory_list_golden_test.dart +++ b/client/test/golden/inventory_list_golden_test.dart @@ -27,6 +27,7 @@ const _items = [ id: 1, productId: 1, quantity: 128, + status: 'on_sale', productCode: 'MT-FT-500', productName: '茅台 飞天 53°', series: '飞天', @@ -153,6 +154,7 @@ const _items = [ id: 8, productId: 8, quantity: 0, + status: 'sold', productCode: 'FJ-QH20-500', productName: '汾酒 青花 20', series: '青花20', @@ -249,6 +251,7 @@ List _overrides() => [ stockValue: 2640000, inStockQty: 18920, shortageCount: 17, + soldCount: 17, warningCount: 6, // 月初快照 → 环比 ▲2.3% / ▲1.1% / ▼0.6%(对齐原型示例文案) lastMonthSku: 1255, diff --git a/client/test/golden/inventory_list_mobile_golden_test.dart b/client/test/golden/inventory_list_mobile_golden_test.dart index bbd3634..30829e7 100644 --- a/client/test/golden/inventory_list_mobile_golden_test.dart +++ b/client/test/golden/inventory_list_mobile_golden_test.dart @@ -22,6 +22,7 @@ const _items = [ id: 1, productId: 1, quantity: 128, + status: 'on_sale', productCode: 'MT-FT-500', productName: '茅台 飞天 53°', series: '飞天', @@ -76,6 +77,7 @@ const _items = [ id: 8, productId: 8, quantity: 0, + status: 'sold', productCode: 'FJ-QH20-500', productName: '汾酒 青花 20', series: '青花20', @@ -172,6 +174,7 @@ List _overrides() => [ stockValue: 2640000, inStockQty: 18920, shortageCount: 17, + soldCount: 17, warningCount: 6, lastMonthSku: 1255, lastMonthValue: 2611276, diff --git a/client/test/golden/product_detail_golden_test.dart b/client/test/golden/product_detail_golden_test.dart index 8fba836..87356df 100644 --- a/client/test/golden/product_detail_golden_test.dart +++ b/client/test/golden/product_detail_golden_test.dart @@ -51,6 +51,7 @@ class _FakeInvRepo implements InventoryRepository { String? code, List? series, List? spec, + String? status, int page = 1, int pageSize = 50, }) async => diff --git a/client/test/stock_out_insufficient_stock_flow_test.dart b/client/test/stock_out_insufficient_stock_flow_test.dart index 5f4b96a..46d1c23 100644 --- a/client/test/stock_out_insufficient_stock_flow_test.dart +++ b/client/test/stock_out_insufficient_stock_flow_test.dart @@ -99,6 +99,7 @@ class _FakeInventoryRepository extends InventoryRepository { String? code, List? series, List? spec, + String? status, int page = 1, int pageSize = 50, }) async {