Compare commits

...

9 Commits

Author SHA1 Message Date
wangjia 7328d85912 chore: release client-v1.1.7
Design Source Checks / design-source (push) Successful in 18s
Deploy Client / build-client-web (push) Successful in 55s
Deploy Client / build-windows (push) Successful in 2m11s
Deploy Client / build-macos (push) Successful in 2m57s
Deploy Client / build-android (push) Successful in 1m40s
Deploy Client / build-ios (push) Successful in 3m9s
Deploy Client / release-deploy-client (push) Successful in 1m33s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 17:11:04 +08:00
wangjia d7782cc048 chore: release server-v1.1.11
Design Source Checks / design-source (push) Successful in 17s
Deploy Server / release-deploy-server (push) Successful in 1m2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 17:10:28 +08:00
wangjia dd447b8b32 chore(prototype): 库存原型搜索合一——编码并入商品名搜索框(与 Flutter 实现对齐)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 17:08:09 +08:00
wangjia e0d9b8ab09 feat(client): 库存筛选全部上移工具栏(参照出库管理),列头漏斗退役
- 工具栏:搜索 + 规格/系列/仓库 多选 chip(值/N 项 摘要 + × 单清)+ 状态
  单选 chip + 重置;列头只留 5 列排序箭头
- 原型 inventory.html 同步:toolbar 三 chip + openDimFilter、COLS 状态去
  filter、clearFilters/chip 值渲染跟进
- 桌面库存 golden 重录 ×3

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 17:01:54 +08:00
wangjia c7f36b2d72 feat(client): 库存表格列头排序 UI——DsSortHeader 点击循环 升/降/无
- 新组件 DsSortHeader(原型 thead .sh 对照,L1 已登记):未排序淡箭头暗示
  可排,激活 primary + 方向箭头(↑升/↓降)
- 库存屏 5 列(库存/成本价/总价/生产日期/入库时间)接线,排序走服务端
  全局生效(跨分页),点击循环 无→升→降→无
- 原型 inventory.html COLS sort:true + toggleSort 交互 + atoms .sh 样式登记
- 桌面库存 golden 重录 ×3(列头带排序箭头)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 16:45:39 +08:00
wangjia e012f6e02b feat(backend): 库存列表列头排序——库存/成本价/总价/生产日期/入库时间全局排序
sort_by 白名单列映射 SQL 表达式(cost/price/prod_date 与 SELECT 同 COALESCE
口径,排序与显示一致)+ sort_dir,非法值忽略回默认;id 兜底保证分页稳定。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 16:45:38 +08:00
wangjia b682b7ea88 ci: iOS 临时钥匙串补导 WWDR 中间证书——修独占搜索列表后 unable to build chain
Design Source Checks / design-source (push) Successful in 20s
Deploy Client / build-client-web (push) Successful in 49s
Deploy Client / build-windows (push) Successful in 2m10s
Deploy Client / build-macos (push) Successful in 2m38s
Deploy Client / build-android (push) Successful in 1m17s
Deploy Client / build-ios (push) Successful in 3m41s
Deploy Client / release-deploy-client (push) Successful in 1m30s
p12 只含叶子证书;独占后 login 里的 Apple WWDR 中间证书不可见,
codesign 报 errSecInternalComponent。建 keychain 时从本机 login/System
导出 WWDR(纯公钥)一并导入。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 14:28:18 +08:00
wangjia 3810876395 ci: iOS 构建期临时钥匙串独占搜索列表——隔离本机其他项目的同名分发证书
Design Source Checks / design-source (push) Successful in 2m23s
Deploy Client / build-client-web (push) Successful in 3m4s
Deploy Client / build-windows (push) Successful in 3m57s
Deploy Client / build-macos (push) Successful in 4m16s
Deploy Client / build-android (push) Successful in 2m29s
Deploy Client / build-ios (push) Failing after 1m22s
Deploy Client / release-deploy-client (push) Has been skipped
根因:mac runner 即本机,login 钥匙串出现第二张「Apple Distribution」证书
(另一项目 2026-07-07 新建)后,xcodebuild 优先选中新证书,与 Jiu App Store
profile(嵌 6/16 正牌证书)不配对 → archive 失败。
修复:构建期 list-keychains 只留临时 keychain(fastlane 同款),trap 恢复 login。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 14:04:49 +08:00
wangjia 43576d6248 chore: release client-v1.1.6
Design Source Checks / design-source (push) Successful in 30s
Deploy Client / build-client-web (push) Successful in 58s
Deploy Client / build-macos (push) Successful in 3m1s
Deploy Client / build-android (push) Successful in 1m50s
Deploy Client / build-ios (push) Failing after 55s
Deploy Client / build-windows (push) Successful in 2m5s
Deploy Client / release-deploy-client (push) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 13:39:38 +08:00
16 changed files with 283 additions and 50 deletions
+6
View File
@@ -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);}
+18 -7
View File
@@ -57,8 +57,12 @@
<div class="kpi alert click" onclick="filterStatus('已售')"><div class="ic ic-alert"><svg viewBox="0 0 24 24"><use href="#i-check"/></svg></div><div class="t">已售 · 点击筛选</div><div class="v">17</div><div class="d down">需补货 6 项</div></div>
</div>
<div class="toolbar">
<div class="searchbox"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input id="searchInput" placeholder="商品名 / 拼音" oninput="state.q=this.value; state.page=1; render()"></div>
<div class="searchbox code"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input id="codeInput" placeholder="商品编码" oninput="state.code=this.value; state.page=1; render()"></div>
<!-- 搜索合一(2026-07-07 用户口径):名称/拼音/编码同框,与 Flutter 实现一致 -->
<div class="searchbox"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input id="searchInput" placeholder="商品名 / 拼音 / 编码" oninput="state.q=this.value; state.page=1; render()"></div>
<!-- 2026-07-07 用户口径:筛选全部上移工具栏(参照出库管理),列头漏斗退役、只留排序 -->
<div class="chip" id="chipSpec" onclick="openDimFilter('spec',this)">规格 <span class="cv" id="specVal"></span><svg viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></div>
<div class="chip" id="chipSeries" onclick="openDimFilter('series',this)">系列 <span class="cv" id="seriesVal"></span><svg viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></div>
<div class="chip" id="chipWh" onclick="openDimFilter('wh',this)">仓库 <span class="cv" id="whVal"></span><svg viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></div>
<div class="chip" id="chipStatus">状态 <span class="cv" id="statusVal"></span><svg viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></div>
<div class="sp"></div>
<div class="btn ghost sm" onclick="clearFilters()">重置</div>
@@ -113,19 +117,20 @@ 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:'状态'},{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.spec&&it.spec!==state.spec)return false; if(state.series&&it.series!==state.series)return false; if(state.wh&&it.supplier!==state.wh)return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q)&&!it.code.toLowerCase().includes(q))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 (x<y?-1:x>y?1:0)*(state.sortAsc?1:-1);});} return rows; }
function render(){
document.getElementById('statusVal').textContent=state.status==='全部'?'':state.status;
[['spec','chipSpec','specVal'],['series','chipSeries','seriesVal'],['wh','chipWh','whVal']].forEach(([k,cid,vid])=>{ document.getElementById(vid).textContent=state[k]||''; document.getElementById(cid).classList.toggle('on',!!state[k]); });
document.getElementById('chipStatus').classList.toggle('on',state.status!=='全部');
const cols=visibleCols();
document.getElementById('thead').innerHTML='<tr>'+cols.map(c=>{ const cls=(c.num?'num ':'')+(c.key==='act'?'act ':''); if(c.filter){const on=state.status!=='全部'; return `<th class="${cls}"><span class="fh ${on?'filtered':''}" onclick="openFilter('${c.filter}',this)">${c.label} <svg class="funnel" viewBox="0 0 24 24"><use href="#i-ic22"/></svg></span></th>`;} return `<th class="${cls}">${c.label}</th>`; }).join('')+'</tr>';
document.getElementById('thead').innerHTML='<tr>'+cols.map(c=>{ const cls=(c.num?'num ':'')+(c.key==='act'?'act ':''); if(c.filter){const on=state.status!=='全部'; return `<th class="${cls}"><span class="fh ${on?'filtered':''}" onclick="openFilter('${c.filter}',this)">${c.label} <svg class="funnel" viewBox="0 0 24 24"><use href="#i-ic22"/></svg></span></th>`;} if(c.sort){const on=state.sortBy===c.key; const ic=!on?'i-chevron-down':(state.sortAsc?'i-ic51':'i-chevron-down'); return `<th class="${cls}"><span class="sh ${on?'on':''}" onclick="toggleSort('${c.key}')">${c.label} <svg class="sort-ic" viewBox="0 0 24 24"><use href="#${ic}"/></svg></span></th>`;} return `<th class="${cls}">${c.label}</th>`; }).join('')+'</tr>';
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=`<tr><td colspan="${cols.length}"><div class="empty">没有匹配的商品 · 试试调整筛选或搜索</div></td></tr>`; }
else { tb.innerHTML=pageRows.map(it=>{ const idx=ITEMS.indexOf(it); return '<tr class="click" onclick="openProduct('+idx+')">'+cols.map(c=>{ if(c.key==='product')return `<td><div class="pname">${it.name}</div><div class="pcode">${it.code}</div></td>`; if(c.key==='qty')return `<td class="num"><span class="qty ${it.qty<=10?'low':''}">${it.qty}</span></td>`; if(c.key==='prodDate')return `<td style="font-family:var(--font-mono);color:var(--text)">${it.prodDate}</td>`; if(c.key==='inDate')return `<td style="font-family:var(--font-mono);color:var(--muted)">${it.inDate}</td>`; if(c.key==='status')return `<td><span class="badge b-${it.status}">${it.status}</span></td>`; if(c.key==='act')return `<td class="act"><div class="row-act" onclick="event.stopPropagation()"><svg viewBox="0 0 24 24" onclick="openProduct(${idx})"><use href="#i-eye"/></svg></div></td>`; return `<td class="${c.num?'num':''}">${it[c.key]}</td>`; }).join('')+'</tr>'; }).join(''); }
@@ -138,9 +143,15 @@ 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 clearFilters(){ state.q='';state.code='';state.status='全部';state.page=1; document.getElementById('searchInput').value=''; document.getElementById('codeInput').value=''; 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.status='全部';state.spec=null;state.series=null;state.wh=null;state.page=1; document.getElementById('searchInput').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(); }); }
// 维度筛选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移;真实实现为多选)
const DIM_KEY={spec:'spec',series:'series',wh:'supplier'};
function openDimFilter(dim,t){ const key=DIM_KEY[dim]; const opts=['全部',...new Set(ITEMS.map(it=>it[key]).filter(Boolean))]; openMenu(t,opts.map(v=>({v,label:v,sel:(state[dim]||'全部')===v})),v=>{ state[dim]=v==='全部'?null:v; state.page=1; closeMenus(); render(); }); }
document.getElementById('chipStatus').onclick=e=>openFilter('status',e.currentTarget);
const COL_KEY='jiu-inv-hidden';
function saveHidden(){ try{ localStorage.setItem(COL_KEY, JSON.stringify([...state.hidden])); }catch(e){} }
+22
View File
@@ -5,6 +5,28 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.7] - 2026-07-07
### 新功能
- 库存列表列头排序:点击 库存 / 成本价 / 总价 / 生产日期 / 入库时间 列头
即可全局排序(升序 ↑ / 降序 ↓ / 再点恢复默认)。
### 改进
- 库存筛选全部移到工具栏(与出库管理同一形式):规格 / 系列 / 仓库 / 状态
改为工具栏下拉标签,可单独清除;列头不再放筛选漏斗。
## [1.1.6] - 2026-07-07
### 新功能
- 库存三态管理:状态列改为「在售 / 库存 / 已售」。商品详情抽屉的状态徽章旁
新增开关,一键把商品挂上/撤下公开酒单(桌面与手机都支持);只有挂「在售」
的商品才会出现在店铺公开页。
- 卖光的商品以「已售」留痕:点库存页「已售」统计卡查看,平时列表不显示。
### 改进
- 库存筛选改为「全部 / 在售 / 库存 / 已售」;低库存提示改由数量颜色标注
(红=缺货、橙=低于安全库存)。
## [1.1.5] - 2026-07-06
### 新功能
+6
View File
@@ -5,6 +5,12 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.11] - 2026-07-07
### 新功能
- 库存列表支持按 库存 / 成本价 / 总价 / 生产日期 / 入库时间 排序,
全局生效(跨分页),成本类按显示口径排序结果一致。
## [1.1.10] - 2026-07-07
### 新功能
+18 -1
View File
@@ -158,6 +158,23 @@ func (h *InventoryHandler) List(c *gin.Context) {
return
}
// 排序(2026-07-07 列头排序):白名单列 → SQL 表达式,防注入;带 id 兜底保证分页稳定。
// cost/price/prod_date 用与 SELECT 相同的 COALESCE 口径,排序结果与显示一致。
orderExpr := "inv.id DESC"
if sortCol, ok := map[string]string{
"qty": "inv.quantity",
"cost": "COALESCE(sii.cost_price, inv.unit_price, p.purchase_price)",
"price": "inv.quantity * COALESCE(sii.cost_price, inv.unit_price, p.purchase_price)",
"prod_date": "COALESCE(DATE(sii.production_date), DATE(inv.production_date))",
"in_time": "inv.created_at",
}[c.Query("sort_by")]; ok {
dir := "ASC"
if c.Query("sort_dir") == "desc" {
dir = "DESC"
}
orderExpr = sortCol + " " + dir + ", inv.id DESC"
}
// Data query
dataSQL := `
SELECT
@@ -183,7 +200,7 @@ func (h *InventoryHandler) List(c *gin.Context) {
LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL
LEFT JOIN warehouses w ON w.id = inv.warehouse_id
WHERE ` + baseWhere + `
ORDER BY inv.id DESC
ORDER BY ` + orderExpr + `
LIMIT ? OFFSET ?`
dataArgs := append(args, pageSize, (page-1)*pageSize)
@@ -3,6 +3,7 @@ package handler
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
@@ -525,3 +526,44 @@ func TestInventoryHandler_StatusTenantIsolation(t *testing.T) {
require.NoError(t, db.Where("id = ?", invB.ID).First(&got).Error)
assert.Equal(t, "stock", got.Status)
}
// 列头排序(2026-07-07):白名单列全局排序,非法列忽略回默认
func TestInventoryHandler_ListSort(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_SORT")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
for i, qty := range []float64{5, 20, 10} {
p := testutil.CreateTestProduct(db, shop.ID, fmt.Sprintf("酒%d", i))
price := float64(100 * (i + 1))
require.NoError(t, db.Create(&model.Inventory{
ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p.ID,
Quantity: qty, UnitPrice: &price,
}).Error)
}
qtys := func(w *httptest.ResponseRecorder) []float64 {
rows := parseResponse(w)["data"].([]interface{})
out := make([]float64, len(rows))
for i, r := range rows {
out[i] = r.(map[string]interface{})["quantity"].(float64)
}
return out
}
// qty 升序
w := makeRequest(r, "GET", "/api/v1/inventory?sort_by=qty&sort_dir=asc", token, nil)
assert.Equal(t, []float64{5, 10, 20}, qtys(w))
// qty 降序
w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=qty&sort_dir=desc", token, nil)
assert.Equal(t, []float64{20, 10, 5}, qtys(w))
// 总价 = qty×进价:5×100=500, 20×200=4000, 10×300=3000 → 升序 5,10,20
w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=price&sort_dir=asc", token, nil)
assert.Equal(t, []float64{5, 10, 20}, qtys(w))
// 非法列忽略 → 默认 id 倒序(最后插入的 qty=10 在前)
w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=evil;drop", token, nil)
assert.Equal(t, []float64{10, 20, 5}, qtys(w))
}
@@ -31,6 +31,8 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
String _keyword = '';
String _code = ''; // 商品编码独立筛选(原型 codeInput)
String _status = ''; // 三态筛选(空=后端默认排除已售)
String _sortBy = ''; // 列头排序(空=默认 id 倒序)
bool _sortAsc = true;
List<String> _series = [];
List<String> _spec = [];
PageResult<Inventory>? _cache;
@@ -55,6 +57,8 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
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<PageResult<Inventory>> {
reload();
}
void setSort(String sortBy, bool asc) {
_sortBy = sortBy;
_sortAsc = asc;
_page = 1;
reload();
}
void setSeries(List<String> series) {
_series = series;
_page = 1;
@@ -17,6 +17,8 @@ class InventoryRepository {
List<String>? series,
List<String>? 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(
@@ -141,6 +141,34 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
.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) =>
@@ -178,7 +206,25 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
if (v != null && mounted) _applyStatusFilter(v);
}
// ── 多选漏斗(规格/系列/仓库 ──
// ── 工具栏多选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移 ──
Widget _multiChip(String label, List<String> options, Set<String> selected,
ValueChanged<Set<String>> onChanged) {
return Builder(
builder: (chipCtx) => DsChip(
label: label,
value: selected.isEmpty
? null
: selected.length == 1
? selected.first
: '${selected.length}',
onTap: () => _openMultiFilter(chipCtx,
options: options, selected: selected, onChanged: onChanged),
onClear: () => onChanged({}),
),
);
}
// ── 多选菜单(规格/系列/仓库,chip 锚定) ──
void _openMultiFilter(
BuildContext anchorContext, {
required List<String> options,
@@ -880,11 +926,40 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
);
}
// 桌面 toolbar合一搜索框 + 重置
// 状态筛选 chip 已按用户口径删除,状态列漏斗保留)
// 桌面 toolbar搜索 + 筛选 chips + 重置
// 2026-07-07 用户口径反转:筛选全部上移工具栏,形式
// 参照出库管理;列头只留排序,漏斗退役)
return Row(
children: [
SizedBox(width: 260, child: searchField),
const SizedBox(width: AppDims.sp2),
_multiChip('规格', specOptions, _filterSpec, (v) {
setState(() => _filterSpec = v);
ref
.read(inventoryListProvider.notifier)
.setSpec(v.toList());
}),
const SizedBox(width: AppDims.sp2),
_multiChip('系列', seriesOptions, _filterSeries, (v) {
setState(() => _filterSeries = v);
ref
.read(inventoryListProvider.notifier)
.setSeries(v.toList());
}),
const SizedBox(width: AppDims.sp2),
_multiChip('仓库', warehouseOptions, _filterWarehouse,
(v) => setState(() => _filterWarehouse = v)),
const SizedBox(width: AppDims.sp2),
Builder(
builder: (chipCtx) => DsChip(
label: '状态',
value: _statusFilter == '全部'
? null
: _statusFilter,
onTap: () => _openStatusMenu(chipCtx),
onClear: () => _applyStatusFilter('全部'),
),
),
const Spacer(),
DsButton('重置', small: true, onPressed: _clearFilters),
],
@@ -899,45 +974,18 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
},
emptyText: '没有匹配的商品 · 试试调整筛选或搜索',
columns: visibleCols.map((c) {
// 筛选已全部上移工具栏 chips(2026-07-07),列头只留排序。
final Widget? header = switch (c.key) {
'spec' => DsFilterHeader(
// 可排序列(原型 COLS sort:true,服务端全局排序)
'qty' ||
'cost' ||
'price' ||
'prodDate' ||
'inTime' =>
DsSortHeader(
label: c.label,
filtered: _filterSpec.isNotEmpty,
onOpen: (bctx) => _openMultiFilter(bctx,
options: specOptions,
selected: _filterSpec, onChanged: (v) {
setState(() => _filterSpec = v);
ref
.read(inventoryListProvider.notifier)
.setSpec(v.toList());
}),
),
'series' => DsFilterHeader(
label: c.label,
filtered: _filterSeries.isNotEmpty,
onOpen: (bctx) => _openMultiFilter(bctx,
options: seriesOptions,
selected: _filterSeries, onChanged: (v) {
setState(() => _filterSeries = v);
ref
.read(inventoryListProvider.notifier)
.setSeries(v.toList());
}),
),
'warehouse' => DsFilterHeader(
label: c.label,
filtered: _filterWarehouse.isNotEmpty,
onOpen: (bctx) => _openMultiFilter(bctx,
options: warehouseOptions,
selected: _filterWarehouse,
onChanged: (v) =>
setState(() => _filterWarehouse = v)),
),
// 原型 COLS 里带 filter 的列只有状态(单选)。
'status' => DsFilterHeader(
label: c.label,
filtered: _statusFilter != '全部',
onOpen: _openStatusMenu,
ascending: _sortBy == c.key ? _sortAsc : null,
onTap: () => _toggleSort(c.key),
),
_ => null,
};
+44
View File
@@ -479,6 +479,50 @@ class _DsTableState extends State<DsTable> {
}
}
/// 原型 thead th .sh 排序列头(2026-07-07 登记):label + 方向箭头。
/// 未排序 = 淡 chevron-down 暗示可排;激活整体 primaryasc=↑ / 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 {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

After

Width:  |  Height:  |  Size: 422 KiB

@@ -52,6 +52,8 @@ class _FakeInvRepo implements InventoryRepository {
List<String>? series,
List<String>? spec,
String? status,
String? sortBy,
bool sortAsc = true,
int page = 1,
int pageSize = 50,
}) async =>
@@ -100,6 +100,8 @@ class _FakeInventoryRepository extends InventoryRepository {
List<String>? series,
List<String>? spec,
String? status,
String? sortBy,
bool sortAsc = true,
int page = 1,
int pageSize = 50,
}) async {
+18 -2
View File
@@ -37,6 +37,8 @@ fi
WORK="$(mktemp -d)"
cleanup() {
# 恢复默认搜索列表(构建期临时独占,见下)——失败路径也必须恢复
security list-keychains -d user -s login.keychain-db 2>/dev/null || true
security delete-keychain "$KEYCHAIN" 2>/dev/null || true
rm -rf "$WORK"
rm -f "$HOME/Library/MobileDevice/Provisioning Profiles/jiu-ci.mobileprovision" 2>/dev/null || true
@@ -53,10 +55,24 @@ security unlock-keychain -p "$KEYCHAIN_PWD" "$KEYCHAIN"
echo "${IOS_DIST_CERT_P12_BASE64}" | base64 --decode > "$WORK/dist.p12"
security import "$WORK/dist.p12" -k "$KEYCHAIN" \
-P "${IOS_DIST_CERT_PASSWORD:-}" -T /usr/bin/codesign -T /usr/bin/security
# 补齐证书链:p12 只含叶子证书,独占搜索列表后 login 里的 Apple WWDR 中间证书
# 不可见会报 errSecInternalComponent(unable to build chain)——把本机已有的
# WWDR 中间证书(纯公钥)一并导入临时 keychain。
security find-certificate -a -c "Apple Worldwide Developer Relations" -p \
~/Library/Keychains/login.keychain-db /Library/Keychains/System.keychain \
> "$WORK/wwdr.pem" 2>/dev/null || true
if [ -s "$WORK/wwdr.pem" ]; then
security import "$WORK/wwdr.pem" -k "$KEYCHAIN" -T /usr/bin/codesign 2>/dev/null || true
else
echo "WARN: 本机未找到 Apple WWDR 中间证书,签名可能报 unable to build chain" >&2
fi
# 允许 codesign 非交互访问私钥
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PWD" "$KEYCHAIN" >/dev/null
# 把临时 keychain 加入搜索列表(保留默认 login keychain
security list-keychains -d user -s "$KEYCHAIN" login.keychain-db
# 构建期独占搜索列表(fastlane 同款):只见临时 keychain,隔离本机其他项目的
# 同名「Apple Distribution」证书(2026-07-07 事故:login 里另一项目的新证书被
# xcodebuild 优先选中,与 Jiu App Store profile 不配对 → archive 失败)。
# trap cleanup 恢复 login,失败路径也不遗留。
security list-keychains -d user -s "$KEYCHAIN"
# --- 2. 安装 provisioning profile ---
PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles"