diff --git a/.superpowers/prototype/atoms.css b/.superpowers/prototype/atoms.css index b0aa5d2..6683b91 100644 --- a/.superpowers/prototype/atoms.css +++ b/.superpowers/prototype/atoms.css @@ -135,6 +135,12 @@ th.act,td.act{width:78px;} thead th .fh{display:inline-flex; align-items:center; gap:5px; cursor:pointer;} thead th .fh.filtered{color:var(--primary);} .funnel{width:12px; height:12px; stroke-width:1.6;} +/* 列头排序(2026-07-07):label+方向箭头;未排序淡 chevron-down 暗示可排,激活整体 primary。 + 点击循环 无→升→降→无(服务端全局排序)。 */ +thead th .sh{display:inline-flex; align-items:center; gap:4px; cursor:pointer; user-select:none;} +thead th .sh.on{color:var(--primary);} +.sort-ic{width:12px; height:12px; stroke-width:2;} +thead th .sh:not(.on) .sort-ic{opacity:.45;} tbody td{padding:12px 14px; font-size:var(--fs-body); border-bottom:1px solid var(--border-subtle); color:var(--text); white-space:nowrap;} tbody tr:last-child td{border-bottom:none;} tbody tr.click{cursor:pointer;} tbody tr:hover{background:var(--row-hover);} diff --git a/.superpowers/prototype/screens/inventory.html b/.superpowers/prototype/screens/inventory.html index 5984f84..0a932a3 100644 --- a/.superpowers/prototype/screens/inventory.html +++ b/.superpowers/prototype/screens/inventory.html @@ -113,19 +113,19 @@ const ITEMS=[ {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 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()}; +const COLS=[{key:'product',label:'商品',fixed:true},{key:'spec',label:'规格'},{key:'series',label:'系列'},{key:'batch',label:'批次号'},{key:'qty',label:'库存',num:true,sort:true},{key:'cost',label:'成本价',num:true,sort:true},{key:'price',label:'总价',num:true,sort:true},{key:'prodDate',label:'生产日期',sort:true},{key:'inDate',label:'入库时间',sort:true},{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(),sortBy:null,sortAsc:true}; function setActive(k){ document.querySelectorAll('.nav[data-k]').forEach(e=>e.classList.toggle('active',e.dataset.k===k)); } function navTo(k){ setActive(k); const inv=document.querySelector('[data-view="inventory"]'), ph=document.querySelector('[data-view="placeholder"]'); if(k==='inventory'){inv.classList.add('active');ph.classList.remove('active');return;} inv.classList.remove('active');ph.classList.add('active'); const nav=NAVS.find(n=>n.k===k); document.getElementById('phTitle').textContent=nav.label; document.getElementById('phIcon').innerHTML=navSvg(nav.d); } function visibleCols(){ return COLS.filter(c=>!state.hidden.has(c.key)); } -function filtered(){ return ITEMS.filter(it=>{ if(state.status!=='全部'&&it.status!==state.status)return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q))return false;} if(state.code){const c=state.code.toLowerCase(); if(!it.code.toLowerCase().includes(c))return false;} return true; }); } +function filtered(){ const rows=ITEMS.filter(it=>{ if(state.status!=='全部'&&it.status!==state.status)return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q))return false;} if(state.code){const c=state.code.toLowerCase(); if(!it.code.toLowerCase().includes(c))return false;} return true; }); if(state.sortBy&&SORT_VAL[state.sortBy]){const v=SORT_VAL[state.sortBy];rows.sort((a,b)=>{const x=v(a),y=v(b);return (xy?1:0)*(state.sortAsc?1:-1);});} return rows; } function render(){ document.getElementById('statusVal').textContent=state.status==='全部'?'':state.status; document.getElementById('chipStatus').classList.toggle('on',state.status!=='全部'); const cols=visibleCols(); - document.getElementById('thead').innerHTML=''+cols.map(c=>{ const cls=(c.num?'num ':'')+(c.key==='act'?'act ':''); if(c.filter){const on=state.status!=='全部'; return `${c.label} `;} return `${c.label}`; }).join('')+''; + document.getElementById('thead').innerHTML=''+cols.map(c=>{ const cls=(c.num?'num ':'')+(c.key==='act'?'act ':''); if(c.filter){const on=state.status!=='全部'; return `${c.label} `;} if(c.sort){const on=state.sortBy===c.key; const ic=!on?'i-chevron-down':(state.sortAsc?'i-ic51':'i-chevron-down'); return `${c.label} `;} return `${c.label}`; }).join('')+''; const rows=filtered(); const start=(state.page-1)*state.perPage; const pageRows=rows.slice(start,start+state.perPage); const tb=document.getElementById('tbody'); if(rows.length===0){ tb.innerHTML=`
没有匹配的商品 · 试试调整筛选或搜索
`; } else { tb.innerHTML=pageRows.map(it=>{ const idx=ITEMS.indexOf(it); return ''+cols.map(c=>{ if(c.key==='product')return `
${it.name}
${it.code}
`; if(c.key==='qty')return `${it.qty}`; if(c.key==='prodDate')return `${it.prodDate}`; if(c.key==='inDate')return `${it.inDate}`; if(c.key==='status')return `${it.status}`; if(c.key==='act')return `
`; return `${it[c.key]}`; }).join('')+''; }).join(''); } @@ -138,6 +138,9 @@ const PAGE_SIZES=[10,20,50,100]; function openPageSize(e){ openMenu(e.currentTarget, PAGE_SIZES.map(n=>({v:String(n),label:String(n),sel:n===state.perPage})), v=>{ closeMenus(); setPerPage(v); }, 'pgsize-menu', 88); } function setPerPage(v){ state.perPage=+v; state.page=1; const el=document.getElementById('pageSizeVal'); if(el)el.textContent=v; render(); } function filterStatus(s){ state.status=s; state.page=1; navTo('inventory'); render(); } +// 列头排序:无→升→降→无 循环(真实实现为服务端全局排序) +function toggleSort(k){ if(state.sortBy!==k){state.sortBy=k;state.sortAsc=true;} else if(state.sortAsc){state.sortAsc=false;} else {state.sortBy=null;state.sortAsc=true;} state.page=1; render(); } +const SORT_VAL={qty:it=>it.qty,cost:it=>parseFloat(String(it.cost).replace(/[¥,]/g,''))||0,price:it=>parseFloat(String(it.price).replace(/[¥,]/g,''))||0,prodDate:it=>it.prodDate||'',inDate:it=>it.inDate||''}; function clearFilters(){ state.q='';state.code='';state.status='全部';state.page=1; document.getElementById('searchInput').value=''; document.getElementById('codeInput').value=''; render(); } function openFilter(kind,t){ openMenu(t,STATUS.map(v=>({v,label:v,sel:v===state.status})),v=>{ state.status=v; state.page=1; closeMenus(); render(); }); } diff --git a/client/lib/providers/inventory_provider.dart b/client/lib/providers/inventory_provider.dart index 2c393c6..090db55 100644 --- a/client/lib/providers/inventory_provider.dart +++ b/client/lib/providers/inventory_provider.dart @@ -31,6 +31,8 @@ class InventoryListNotifier extends AsyncNotifier> { String _keyword = ''; String _code = ''; // 商品编码独立筛选(原型 codeInput) String _status = ''; // 三态筛选(空=后端默认排除已售) + String _sortBy = ''; // 列头排序(空=默认 id 倒序) + bool _sortAsc = true; List _series = []; List _spec = []; PageResult? _cache; @@ -55,6 +57,8 @@ class InventoryListNotifier extends AsyncNotifier> { keyword: _keyword.isEmpty ? null : _keyword, code: _code.isEmpty ? null : _code, status: _status.isEmpty ? null : _status, + sortBy: _sortBy.isEmpty ? null : _sortBy, + sortAsc: _sortAsc, series: _series.isEmpty ? null : _series, spec: _spec.isEmpty ? null : _spec, page: _page, @@ -97,6 +101,13 @@ class InventoryListNotifier extends AsyncNotifier> { reload(); } + void setSort(String sortBy, bool asc) { + _sortBy = sortBy; + _sortAsc = asc; + _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 825b241..bea2cf3 100644 --- a/client/lib/repositories/inventory_repository.dart +++ b/client/lib/repositories/inventory_repository.dart @@ -17,6 +17,8 @@ class InventoryRepository { List? series, List? spec, String? status, // 三态筛选:stock/on_sale/sold(逗号可多值);空=后端默认(排除已售) + String? sortBy, // 列头排序:qty/cost/price/prod_date/in_time(后端白名单) + bool sortAsc = true, int page = 1, int pageSize = 50, }) async { @@ -31,6 +33,10 @@ class InventoryRepository { if (series != null && series.isNotEmpty) 'series': series.join(','), if (spec != null && spec.isNotEmpty) 'spec': spec.join(','), if (status != null && status.isNotEmpty) 'status': status, + if (sortBy != null && sortBy.isNotEmpty) ...{ + 'sort_by': sortBy, + 'sort_dir': sortAsc ? 'asc' : 'desc', + }, }; final resp = await _client.get('/inventory', params: params); return PageResult.fromJson( diff --git a/client/lib/screens/inventory/inventory_list_screen.dart b/client/lib/screens/inventory/inventory_list_screen.dart index 09763ad..e71cc92 100644 --- a/client/lib/screens/inventory/inventory_list_screen.dart +++ b/client/lib/screens/inventory/inventory_list_screen.dart @@ -141,6 +141,34 @@ class _InventoryListScreenState extends ConsumerState { .setStatus(_statusParam[label] ?? ''); } + // ── 列头排序(点击循环 无→升→降→无,服务端全局排序) ── + String _sortBy = ''; // 列 key(屏内),空=未排序 + bool _sortAsc = true; + static const _sortParam = { + 'qty': 'qty', + 'cost': 'cost', + 'price': 'price', + 'prodDate': 'prod_date', + 'inTime': 'in_time', + }; + + void _toggleSort(String key) { + setState(() { + if (_sortBy != key) { + _sortBy = key; + _sortAsc = true; + } else if (_sortAsc) { + _sortAsc = false; + } else { + _sortBy = ''; + _sortAsc = true; + } + }); + ref + .read(inventoryListProvider.notifier) + .setSort(_sortBy.isEmpty ? '' : _sortParam[_sortBy]!, _sortAsc); + } + // 成本仅管理员可见(与出库同口径):非管理员隐藏成本价/总价列、卡片单价、货值 KPI。 bool get _isAdmin => ref.watch(isAdminProvider); bool _colAllowed(ColDef c) => @@ -939,6 +967,17 @@ class _InventoryListScreenState extends ConsumerState { filtered: _statusFilter != '全部', onOpen: _openStatusMenu, ), + // 可排序列(原型 COLS sort:true,服务端全局排序) + 'qty' || + 'cost' || + 'price' || + 'prodDate' || + 'inTime' => + DsSortHeader( + label: c.label, + ascending: _sortBy == c.key ? _sortAsc : null, + onTap: () => _toggleSort(c.key), + ), _ => null, }; return DsColumn(c.key, c.label, diff --git a/client/lib/widgets/ds/ds_table.dart b/client/lib/widgets/ds/ds_table.dart index 24ce13b..375f90f 100644 --- a/client/lib/widgets/ds/ds_table.dart +++ b/client/lib/widgets/ds/ds_table.dart @@ -479,6 +479,50 @@ class _DsTableState extends State { } } +/// 原型 thead th .sh 排序列头(2026-07-07 登记):label + 方向箭头。 +/// 未排序 = 淡 chevron-down 暗示可排;激活整体 primary,asc=↑ / desc=↓。 +/// 点击由调用方循环 无→升→降→无(服务端全局排序)。 +class DsSortHeader extends StatelessWidget { + final String label; + + /// null = 未按本列排序;true = 升序;false = 降序 + final bool? ascending; + final VoidCallback onTap; + const DsSortHeader( + {super.key, required this.label, this.ascending, required this.onTap}); + + @override + Widget build(BuildContext context) { + final t = context.tokens; + final on = ascending != null; + final color = on ? t.primary : t.muted; + return InkWell( + onTap: onTap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label, + style: TextStyle( + fontSize: AppDims.fsSm, + fontWeight: FontWeight.w600, + color: color)), + const SizedBox(width: 4), + Opacity( + opacity: on ? 1 : .45, + child: Icon( + ascending == true + ? LucideIcons.chevronUp + : LucideIcons.chevronDown, + size: 12, + color: color, + ), + ), + ], + ), + ); + } +} + /// 原型 thead th .fh 漏斗列头:label + funnel(12, sw1.6),有筛选值时整体 primary。 /// 点击回调携带列头自身 BuildContext(供 showDsMenu 锚定)。 class DsFilterHeader extends StatelessWidget { diff --git a/client/test/golden/goldens/inventory_list_a.png b/client/test/golden/goldens/inventory_list_a.png index 908b160..3b5a336 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 b38c05c..d9f1879 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 998ec24..e6f88fa 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/product_detail_golden_test.dart b/client/test/golden/product_detail_golden_test.dart index 87356df..66d2f7b 100644 --- a/client/test/golden/product_detail_golden_test.dart +++ b/client/test/golden/product_detail_golden_test.dart @@ -52,6 +52,8 @@ class _FakeInvRepo implements InventoryRepository { List? series, List? spec, String? status, + String? sortBy, + bool sortAsc = true, 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 46d1c23..7ccb3c8 100644 --- a/client/test/stock_out_insufficient_stock_flow_test.dart +++ b/client/test/stock_out_insufficient_stock_flow_test.dart @@ -100,6 +100,8 @@ class _FakeInventoryRepository extends InventoryRepository { List? series, List? spec, String? status, + String? sortBy, + bool sortAsc = true, int page = 1, int pageSize = 50, }) async {