Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b682b7ea88 | |||
| 3810876395 | |||
| 43576d6248 | |||
| abe8f8e669 | |||
| 65c672b4ad | |||
| 73955a139c | |||
| b0b38db39f | |||
| 7b14eb426b | |||
| 0de9ba7c21 | |||
| 47d6490bf2 | |||
| 8000f00e9e | |||
| d8f7fd903e | |||
| db9afb952c | |||
| f3efda3691 | |||
| d19e88e3f4 |
@@ -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);}
|
||||
|
||||
@@ -324,6 +324,12 @@
|
||||
<div class="searchbox" style="width:240px"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input placeholder="搜索"></div>
|
||||
<div class="ctrl" style="max-width:200px"><span class="ph">选择往来单位…</span><svg width="14" height="14" viewBox="0 0 24 24" stroke-width="2"><use href="#i-chevron-down"/></svg></div>
|
||||
</div></div>
|
||||
<div class="pcard"><div class="pcard-h"><h3>Switch 开关</h3><span class="tk">.switch .on · 布尔即时生效(库存⇄在售 挂售切换)· 2026-07-07</span></div><div class="pcard-b">
|
||||
<button class="switch on" onclick="this.classList.toggle('on')" aria-label="switch demo"></button>
|
||||
<button class="switch" onclick="this.classList.toggle('on')" aria-label="switch demo"></button>
|
||||
<button class="switch" disabled aria-label="switch disabled"></button>
|
||||
<span style="font-size:var(--fs-xs);color:var(--faint)">on / off / disabled(已售行无 switch)</span>
|
||||
</div></div>
|
||||
<div class="pcard"><div class="pcard-h"><h3>分段切换 / 可搜索选择器</h3><span class="tk">.seg · .combo(录单网格用)</span></div><div class="pcard-b">
|
||||
<div class="seg"><button class="on">入库</button><button>出库</button></div>
|
||||
<div class="combo has" style="width:240px; flex:none"><input value="茅台 飞天 53°" readonly><span class="caf"><svg viewBox="0 0 24 24" stroke-width="2"><use href="#i-close"/></svg></span></div>
|
||||
@@ -369,7 +375,8 @@
|
||||
</div></div>
|
||||
<div class="pcard"><div class="pcard-h"><h3>状态徽章 · 全部</h3><span class="tk">.badge .b-* · 对齐 schema 真实枚举 · 圆点已全局图标化(icons.js BADGE_ICON 单源映射自动注入,屏内无需手写 svg)</span></div><div class="pcard-b" style="display:block">
|
||||
<div style="display:flex;flex-direction:column;gap:13px">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="width:160px;flex:none;font-size:var(--fs-xs);color:var(--muted);font-weight:600">库存 · 按 min_stock 派生</span><span class="badge b-在售">在售</span><span class="badge b-预警">预警</span><span class="badge b-缺货">缺货</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="width:160px;flex:none;font-size:var(--fs-xs);color:var(--muted);font-weight:600">库存三态 · status 落库</span><span class="badge b-在售">在售</span><span class="badge b-库存">库存</span><span class="badge b-已售">已售</span><span style="font-size:var(--fs-xs);color:var(--faint)">(在售=公开酒单展示;已售=卖光留痕;预警/缺货退出状态列,由数量染色承担)</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="width:160px;flex:none;font-size:var(--fs-xs);color:var(--muted);font-weight:600">库存 · 数量派生提示</span><span class="badge b-预警">预警</span><span class="badge b-缺货">缺货</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="width:160px;flex:none;font-size:var(--fs-xs);color:var(--muted);font-weight:600">单据 · 入库/出库</span><span class="badge b-草稿">草稿</span><span class="badge b-待审核">待审核</span><span class="badge b-已通过">已通过</span><span class="badge b-已驳回">已驳回</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="width:160px;flex:none;font-size:var(--fs-xs);color:var(--muted);font-weight:600">往来单位 · 启用停用</span><span class="badge b-启用">启用</span><span class="badge b-停用">停用</span><span class="badge b-供应商">供应商</span><span class="badge b-客户">客户</span><span class="badge b-两者">两者</span></div>
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap"><span style="width:160px;flex:none;font-size:var(--fs-xs);color:var(--muted);font-weight:600">用户角色</span><span class="badge b-管理员">管理员</span><span class="badge b-普通">普通</span><span class="badge b-只读">只读</span></div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<div class="kpi click" onclick="clearFilters()"><div class="ic ic-info"><svg viewBox="0 0 24 24"><use href="#i-box"/></svg></div><div class="t">SKU 总数</div><div class="v">1,284</div><div class="d up">▲ 2.3% 较上月</div></div>
|
||||
<div class="kpi"><div class="ic ic-ok"><svg viewBox="0 0 24 24"><use href="#i-ic17"/></svg></div><div class="t">库存货值</div><div class="v">¥264万</div><div class="d up">▲ 1.1% 较上月</div></div>
|
||||
<div class="kpi"><div class="ic ic-blue"><svg viewBox="0 0 24 24"><use href="#i-box"/></svg></div><div class="t">在库数量</div><div class="v">18,920</div><div class="d down">▼ 0.6% 较上月</div></div>
|
||||
<div class="kpi alert click" onclick="filterStatus('缺货')"><div class="ic ic-alert"><svg viewBox="0 0 24 24"><use href="#i-ic18"/></svg></div><div class="t">缺货预警 · 点击筛选</div><div class="v">17</div><div class="d down">需补货 6 项</div></div>
|
||||
<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>
|
||||
@@ -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()};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="k"><div class="kl">SKU 总数</div><div class="kv">1,284</div><div class="kd up">▲ 2.3% 较上月</div></div>
|
||||
<div class="k"><div class="kl">库存货值</div><div class="kv">¥264万</div><div class="kd up">▲ 1.5% 较上月</div></div>
|
||||
<div class="k"><div class="kl">在库数量</div><div class="kv">18,920</div><div class="kd">件 · 实时</div></div>
|
||||
<div class="k"><div class="kl">缺货预警</div><div class="kv">17</div><div class="kd warn">需补货 6 项</div></div>
|
||||
<div class="k"><div class="kl">已售</div><div class="kv">17</div><div class="kd warn">需补货 6 项</div></div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:14px" class="m-search"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input id="q" placeholder="商品名 / 拼音 / 编码" oninput="state.q=this.value;render()"></div>
|
||||
@@ -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, `<div class="drow"><span>编码</span><b style="font-family:var(--font-mono)">${it.code}</b></div><div class="drow"><span>规格</span><b>${it.spec}</b></div><div class="drow"><span>系列</span><b>${it.series}</b></div><div class="drow"><span>批次号</span><b style="font-family:var(--font-mono)">${it.batch}</b></div><div class="drow"><span>当前库存</span><b>${it.qty} ${it.unit}</b></div><div class="drow"><span>成本价</span><b>${it.cost}</b></div><div class="drow"><span>供应商</span><b>${it.supplier}</b></div><div class="drow" style="border-bottom:none"><span>状态</span><b><span class="badge b-${it.status}">${it.status}</span></b></div>
|
||||
openSheet(it.name, `<div class="drow"><span>编码</span><b style="font-family:var(--font-mono)">${it.code}</b></div><div class="drow"><span>规格</span><b>${it.spec}</b></div><div class="drow"><span>系列</span><b>${it.series}</b></div><div class="drow"><span>批次号</span><b style="font-family:var(--font-mono)">${it.batch}</b></div><div class="drow"><span>当前库存</span><b>${it.qty} ${it.unit}</b></div><div class="drow"><span>成本价</span><b>${it.cost}</b></div><div class="drow"><span>供应商</span><b>${it.supplier}</b></div><div class="drow" style="border-bottom:none"><span>状态</span><b style="display:flex;align-items:center;gap:10px"><span class="badge b-${it.status}">${it.status}</span>${it.status==='已售'?'':`<button class="switch ${it.status==='在售'?'on':''}" aria-label="挂售切换" onclick="this.classList.toggle('on');const b=this.previousElementSibling;const on=this.classList.contains('on');b.textContent=on?'在售':'库存';b.className='badge '+(on?'b-在售':'b-库存');"></button>`}</b></div>
|
||||
<div class="pe-card" style="margin-top:14px"><div class="pe-h"><span class="pe-ic"><svg viewBox="0 0 24 24"><use href="#i-ic27"/></svg></span>商品图片</div>
|
||||
<div class="pe-b"><div class="pe-gallery" id="mGallery"></div><div class="pe-note">建议主图为白底产品图,可再加细节图(瓶身/防伪)与场景图。</div></div></div>
|
||||
<div class="pe-card"><div class="pe-h"><span class="pe-ic"><svg viewBox="0 0 24 24"><use href="#i-ic28"/></svg></span>商品介绍 / 卖点</div>
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
if (it.qty != null) r.push(['当前库存', `<b>${it.qty} ${it.unit || ''}</b>`]);
|
||||
if (it.cost) r.push(['成本价', `<b>${it.cost}</b>`]);
|
||||
if (it.value) r.push(['库存货值', `<b>${it.value}</b>`]);
|
||||
if (it.status) r.push(['状态', `<b><span class="badge b-${it.status}">${it.status}</span></b>`]);
|
||||
// 状态行:三态徽章 + 挂售 switch(在售⇄库存 即时生效;已售只读无 switch)
|
||||
if (it.status) r.push(['状态', `<b style="display:flex;align-items:center;gap:10px"><span class="badge b-${it.status}">${it.status}</span>${it.status === '已售' ? '' : `<button class="switch ${it.status === '在售' ? 'on' : ''}" aria-label="挂售切换" onclick="this.classList.toggle('on');const b=this.previousElementSibling;const on=this.classList.contains('on');b.textContent=on?'在售':'库存';b.className='badge '+(on?'b-在售':'b-库存');"></button>`}</b>`]);
|
||||
return r.map((x, i) => `<div class="drow"${i === r.length - 1 ? ' style="border-bottom:none"' : ''}><span>${x[0]}</span>${x[1]}</div>`).join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,18 @@ 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.6] - 2026-07-07
|
||||
|
||||
### 新功能
|
||||
- 库存三态管理:状态列改为「在售 / 库存 / 已售」。商品详情抽屉的状态徽章旁
|
||||
新增开关,一键把商品挂上/撤下公开酒单(桌面与手机都支持);只有挂「在售」
|
||||
的商品才会出现在店铺公开页。
|
||||
- 卖光的商品以「已售」留痕:点库存页「已售」统计卡查看,平时列表不显示。
|
||||
|
||||
### 改进
|
||||
- 库存筛选改为「全部 / 在售 / 库存 / 已售」;低库存提示改由数量颜色标注
|
||||
(红=缺货、橙=低于安全库存)。
|
||||
|
||||
## [1.1.5] - 2026-07-06
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -5,6 +5,30 @@
|
||||
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.10] - 2026-07-07
|
||||
|
||||
### 新功能
|
||||
- 库存三态管理:每条库存新增「库存 / 在售 / 已售」状态。商品卖光后不再从
|
||||
列表消失,而是以「已售」留痕(默认隐藏,点「已售」统计卡查看);出库退单
|
||||
自动恢复为「库存」。
|
||||
- 公开店铺酒单改为人工精选:只展示手动挂「在售」的商品。升级后酒单为空,
|
||||
需在库存详情里将要公开的商品切换为「在售」。
|
||||
- 店铺分享卡片标题不再带「岩美酒库」尾缀。
|
||||
|
||||
### 改进
|
||||
- 库存统计口径:SKU 总数、在库数量、库存货值不再计入已售留痕,新增已售数。
|
||||
|
||||
## [1.1.9] - 2026-07-07
|
||||
|
||||
### 改进
|
||||
- 商品/店铺公开页全新视觉:配色与 App 设计系统同源,支持三主题
|
||||
(经典蓝 / 琥珀深色 / 酒窖暖色),右上角衬衫图标下拉切换并记忆选择,
|
||||
首次打开自动跟随手机深浅色模式。
|
||||
- 商品图集升级:多图滑动带指示点,点击图片全屏查看、全屏内可滑动切换;
|
||||
无图商品保留图片占位区不再空缺。
|
||||
- 页面自适应满屏:页脚固定在屏幕底部,底部不再出现大片空白。
|
||||
- 页脚展示「技术支持:岩美科技」与 ICP 备案号,移除「在 App 中查看」入口。
|
||||
|
||||
## [1.1.8] - 2026-07-07
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -229,6 +229,7 @@ cd client && flutter test
|
||||
1. 更新 `inventories` 表数量
|
||||
2. 写入 `inventory_logs` 流水记录
|
||||
- 出库前必须校验库存充足,不足时返回错误并回滚
|
||||
- **库存三态**(2026-07-07 落库 `inventories.status`):`stock` 库存(默认,不公开)⇄ `on_sale` 在售(公开酒单展示,抽屉 switch 手动挂售,接口 `PUT /inventory/:id/status` 仅收这两值)→ `sold` 已售(出库审核扣光自动置入,**留痕不再软删**;退单恢复置回 stock)。列表默认排除已售(显式 `status=sold` 才返回);Summary 的 SKU/数量/货值排除已售、另出 `sold_count`;公开店铺页只列 on_sale。盘亏归零仍软删(灭失≠售出)。预警/缺货退出状态维度,由数量 vs 安全库存染色承担
|
||||
- 审核中(pending)单据支持**撤回**(`withdraw`)回 draft:管理员/超管任意单、操作员限本人单(`OperatorID==本人`,handler 内判权,非 AdminOnly 中间件)
|
||||
|
||||
### Schema 管理
|
||||
|
||||
@@ -30,6 +30,7 @@ type inventoryRow struct {
|
||||
ProductID *uint64 `json:"product_id"`
|
||||
StockInItemID *uint64 `json:"stock_in_item_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Status string `json:"status"` // stock=库存 / on_sale=在售 / sold=已售
|
||||
ProductCode string `json:"product_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
Series string `json:"series"`
|
||||
@@ -77,6 +78,26 @@ func (h *InventoryHandler) List(c *gin.Context) {
|
||||
baseWhere := "inv.shop_id = ? AND inv.deleted_at IS NULL"
|
||||
args := []interface{}{shopID}
|
||||
|
||||
// 状态筛选(2026-07-07 三态):默认排除已售——仅显式传 status(含 sold)才显示已售行
|
||||
if statusStr := c.Query("status"); statusStr != "" {
|
||||
parts := make([]string, 0, 3)
|
||||
for _, s := range strings.Split(statusStr, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "stock" || s == "on_sale" || s == "sold" {
|
||||
parts = append(parts, s)
|
||||
}
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
placeholders := strings.Repeat("?,", len(parts))
|
||||
baseWhere += " AND inv.status IN (" + placeholders[:len(placeholders)-1] + ")"
|
||||
for _, s := range parts {
|
||||
args = append(args, s)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
baseWhere += " AND inv.status <> 'sold'"
|
||||
}
|
||||
|
||||
keyword := c.Query("keyword")
|
||||
warehouseIDStr := c.Query("warehouse_id")
|
||||
inStock := c.Query("in_stock")
|
||||
@@ -141,7 +162,7 @@ func (h *InventoryHandler) List(c *gin.Context) {
|
||||
dataSQL := `
|
||||
SELECT
|
||||
inv.id, inv.shop_id, inv.warehouse_id, inv.product_id, inv.stock_in_item_id,
|
||||
inv.quantity,
|
||||
inv.quantity, inv.status,
|
||||
COALESCE(NULLIF(p.code,''), NULLIF(sii.product_code,''), inv.product_code, '') AS product_code,
|
||||
COALESCE(NULLIF(p.name,''), NULLIF(sii.product_name,''), inv.product_name, '') AS product_name,
|
||||
COALESCE(NULLIF(p.series,''), NULLIF(sii.series,''), inv.series, '') AS series,
|
||||
@@ -179,10 +200,11 @@ func (h *InventoryHandler) List(c *gin.Context) {
|
||||
|
||||
// inventorySummary 全店库存 KPI 汇总(不随分页,供库存屏 KPI 卡)
|
||||
type inventorySummary struct {
|
||||
SkuCount int64 `json:"sku_count"` // SKU 总数(库存行数)
|
||||
SkuCount int64 `json:"sku_count"` // SKU 总数(未售库存行数)
|
||||
StockValue float64 `json:"stock_value"` // 库存货值 Σ(qty×进价)
|
||||
InStockQty float64 `json:"in_stock_qty"` // 在库总数量
|
||||
ShortageCount int64 `json:"shortage_count"` // 缺货数(qty<=0)
|
||||
SoldCount int64 `json:"sold_count"` // 已售数(status=sold 留痕行)
|
||||
ShortageCount int64 `json:"shortage_count"` // 缺货数(qty<=0 且未售,预期归零)
|
||||
WarningCount int64 `json:"warning_count"` // 预警数(0<qty<安全库存)
|
||||
// 月初快照(供 KPI 环比):本月净变动由 inventory_logs 反推
|
||||
LastMonthSku int64 `json:"last_month_sku"` // 月初存在的库存行数
|
||||
@@ -198,10 +220,11 @@ func (h *InventoryHandler) Summary(c *gin.Context) {
|
||||
// 月初数量 = 当前数量 − 本月净变动(product=序列号模型下 log 与库存行按 product_id+warehouse_id 一一对应)
|
||||
const sql = `
|
||||
SELECT
|
||||
COUNT(*) AS sku_count,
|
||||
COALESCE(SUM(CASE WHEN inv.status <> 'sold' THEN 1 ELSE 0 END), 0) AS sku_count,
|
||||
COALESCE(SUM(inv.quantity * COALESCE(sii.cost_price, inv.unit_price, p.purchase_price)), 0) AS stock_value,
|
||||
COALESCE(SUM(inv.quantity), 0) AS in_stock_qty,
|
||||
COALESCE(SUM(CASE WHEN inv.quantity <= 0 THEN 1 ELSE 0 END), 0) AS shortage_count,
|
||||
COALESCE(SUM(CASE WHEN inv.status = 'sold' THEN 1 ELSE 0 END), 0) AS sold_count,
|
||||
COALESCE(SUM(CASE WHEN inv.quantity <= 0 AND inv.status <> 'sold' THEN 1 ELSE 0 END), 0) AS shortage_count,
|
||||
COALESCE(SUM(CASE WHEN inv.quantity > 0 AND p.min_stock IS NOT NULL AND inv.quantity < p.min_stock THEN 1 ELSE 0 END), 0) AS warning_count,
|
||||
COALESCE(SUM(CASE WHEN inv.created_at < ? THEN 1 ELSE 0 END), 0) AS last_month_sku,
|
||||
COALESCE(SUM(CASE WHEN inv.created_at < ? THEN inv.quantity - COALESCE(lg.net, 0) ELSE 0 END), 0) AS last_month_qty,
|
||||
@@ -403,6 +426,40 @@ func (h *InventoryHandler) CompleteCheck(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "盘点完成"})
|
||||
}
|
||||
|
||||
// UpdateStatus PUT /api/v1/inventory/:id/status —— 库存⇄在售 手动切换(抽屉 switch)。
|
||||
// sold 由出库审核流程专属写入,本接口拒收;已售行不可改(WHERE 兜底)。
|
||||
func (h *InventoryHandler) UpdateStatus(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Status != "stock" && req.Status != "on_sale" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "状态仅支持 stock / on_sale"})
|
||||
return
|
||||
}
|
||||
result := h.db.Model(&model.Inventory{}).
|
||||
Where("id = ? AND shop_id = ? AND deleted_at IS NULL AND status <> 'sold'", id, shopID).
|
||||
Update("status", req.Status)
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
|
||||
return
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "库存记录不存在或已售出"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "status": req.Status})
|
||||
}
|
||||
|
||||
// UpdateRemark PUT /api/v1/inventory/:id/remark
|
||||
func (h *InventoryHandler) UpdateRemark(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
|
||||
@@ -442,3 +442,86 @@ func TestInventoryHandler_CostVisibility(t *testing.T) {
|
||||
assert.Equal(t, float64(10), resp["in_stock_qty"].(float64))
|
||||
}
|
||||
}
|
||||
|
||||
// 库存三态(2026-07-07):List 默认排除已售、status 筛选、切换接口、Summary sold_count。
|
||||
func TestInventoryHandler_StatusFlow(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "INV_ST")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
|
||||
p1 := testutil.CreateTestProduct(db, shop.ID, "库存酒")
|
||||
p2 := testutil.CreateTestProduct(db, shop.ID, "在售酒")
|
||||
p3 := testutil.CreateTestProduct(db, shop.ID, "已售酒")
|
||||
token := getAuthToken(user.ID, shop.ID, "admin")
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
price := 50.0
|
||||
invStock := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p1.ID, Quantity: 3, UnitPrice: &price, Status: "stock"}
|
||||
invSale := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p2.ID, Quantity: 2, UnitPrice: &price, Status: "on_sale"}
|
||||
invSold := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p3.ID, Quantity: 0, UnitPrice: &price, Status: "sold"}
|
||||
require.NoError(t, db.Create(&invStock).Error)
|
||||
require.NoError(t, db.Create(&invSale).Error)
|
||||
require.NoError(t, db.Create(&invSold).Error)
|
||||
|
||||
// List 默认:不含已售(2 行)
|
||||
w := makeRequest(r, "GET", "/api/v1/inventory", token, nil)
|
||||
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
|
||||
|
||||
// status=sold:只看已售
|
||||
w = makeRequest(r, "GET", "/api/v1/inventory?status=sold", token, nil)
|
||||
resp := parseResponse(w)
|
||||
assert.Equal(t, float64(1), resp["total"].(float64))
|
||||
row := resp["data"].([]interface{})[0].(map[string]interface{})
|
||||
assert.Equal(t, "sold", row["status"])
|
||||
|
||||
// status 多值
|
||||
w = makeRequest(r, "GET", "/api/v1/inventory?status=stock,on_sale,sold", token, nil)
|
||||
assert.Equal(t, float64(3), parseResponse(w)["total"].(float64))
|
||||
|
||||
// 切换:stock → on_sale
|
||||
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invStock.ID), token,
|
||||
map[string]interface{}{"status": "on_sale"})
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var got model.Inventory
|
||||
require.NoError(t, db.Where("id = ?", invStock.ID).First(&got).Error)
|
||||
assert.Equal(t, "on_sale", got.Status)
|
||||
|
||||
// 拒收 sold 值
|
||||
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invSale.ID), token,
|
||||
map[string]interface{}{"status": "sold"})
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
// 已售行不可改
|
||||
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invSold.ID), token,
|
||||
map[string]interface{}{"status": "stock"})
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
|
||||
// Summary:sku_count 排除已售、sold_count=1、货值只算未售
|
||||
w = makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil)
|
||||
sm := parseResponse(w)
|
||||
assert.Equal(t, float64(2), sm["sku_count"].(float64))
|
||||
assert.Equal(t, float64(1), sm["sold_count"].(float64))
|
||||
assert.Equal(t, float64(3*50+2*50), sm["stock_value"].(float64))
|
||||
assert.Equal(t, float64(0), sm["shortage_count"].(float64), "sold 不计入缺货")
|
||||
}
|
||||
|
||||
// 跨租户:不能改他店库存状态
|
||||
func TestInventoryHandler_StatusTenantIsolation(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shopA := testutil.CreateTestShop(db, "INV_STA")
|
||||
shopB := testutil.CreateTestShop(db, "INV_STB")
|
||||
userA := testutil.CreateTestUser(db, shopA.ID, "admin", "pass", "admin")
|
||||
whB := testutil.CreateTestWarehouse(db, shopB.ID, "仓B")
|
||||
pB := testutil.CreateTestProduct(db, shopB.ID, "他店酒")
|
||||
invB := model.Inventory{ShopID: shopB.ID, WarehouseID: &whB.ID, ProductID: &pB.ID, Quantity: 1, Status: "stock"}
|
||||
require.NoError(t, db.Create(&invB).Error)
|
||||
r := setupProtectedRouter(db)
|
||||
|
||||
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
|
||||
w := makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invB.ID), tokenA,
|
||||
map[string]interface{}{"status": "on_sale"})
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
var got model.Inventory
|
||||
require.NoError(t, db.Where("id = ?", invB.ID).First(&got).Error)
|
||||
assert.Equal(t, "stock", got.Status)
|
||||
}
|
||||
|
||||
@@ -87,10 +87,10 @@ func (h *PublicHandler) loadPublicProduct(publicID string) (*publicProductData,
|
||||
// queryShopProducts 店铺公开商品列表查询——ListShopProducts(JSON API)与 ShopPage(SSR)共用。
|
||||
// keyword 为空时不过滤(API 现状);非空时按 名称/编号/全拼/首字母 模糊匹配(SSR 搜索框)。
|
||||
func (h *PublicHandler) queryShopProducts(shopID uint64, page, pageSize int, keyword string) ([]publicProductResp, int64, error) {
|
||||
// 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。
|
||||
// 仅列「在售且有库存」的商品(2026-07-07 三态:公开酒单只挂 status=on_sale)。
|
||||
stockSub := h.db.Model(&model.Inventory{}).
|
||||
Select("product_id, SUM(quantity) AS qty").
|
||||
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0", shopID).
|
||||
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND status = 'on_sale'", shopID).
|
||||
Group("product_id")
|
||||
|
||||
query := h.db.Model(&model.Product{}).
|
||||
@@ -192,7 +192,6 @@ type productPageVM struct {
|
||||
ShopAddr string
|
||||
ShopHours string
|
||||
ShopWx string
|
||||
AppURL string
|
||||
}
|
||||
|
||||
// ProductPage GET /product/:public_id
|
||||
@@ -235,7 +234,6 @@ func (h *PublicHandler) ProductPage(c *gin.Context) {
|
||||
Origin: d.Origin,
|
||||
ShelfLife: d.ShelfLife,
|
||||
Storage: d.Storage,
|
||||
AppURL: "/app/product/" + p.PublicID,
|
||||
}
|
||||
for _, img := range p.Images {
|
||||
if img.URL != "" {
|
||||
@@ -312,7 +310,7 @@ func (h *PublicHandler) ShopPage(c *gin.Context) {
|
||||
}
|
||||
|
||||
vm := shopPageVM{
|
||||
PageTitle: shop.Name + " · 在售商品 · 岩美酒库",
|
||||
PageTitle: shop.Name + " · 在售商品",
|
||||
OGTags: template.HTML(buildShopOG(shop, total, config.C.Storage.PublicURL)), // #nosec G203 -- buildShopOG 内部对所有值 html.EscapeString
|
||||
Found: true,
|
||||
ShopName: shop.Name,
|
||||
|
||||
@@ -57,9 +57,9 @@ func TestProductPage_SSR(t *testing.T) {
|
||||
// OG 标签(og:url 指向 SSR 短链)
|
||||
assert.Contains(t, body, `property="og:title"`)
|
||||
assert.Contains(t, body, "/product/pub-ssr-001")
|
||||
// 门店互跳 + App 兜底链接
|
||||
// 门店互跳;「在 App 中查看」已移除(2026-07-07 用户拍板)
|
||||
assert.Contains(t, body, "/shop/"+shop.Code)
|
||||
assert.Contains(t, body, "/app/product/pub-ssr-001")
|
||||
assert.NotContains(t, body, "/app/product/")
|
||||
// 敏感字段零暴露(成本/进价永不出现在公开页)
|
||||
for _, s := range []string{"cost", "purchase_price", "profit"} {
|
||||
assert.NotContains(t, body, s, "公开页不得出现敏感字段名 %s", s)
|
||||
|
||||
@@ -41,6 +41,7 @@ func addInventory(db *gorm.DB, shopID, warehouseID, productID uint64, qty float6
|
||||
WarehouseID: &wid,
|
||||
ProductID: &pid,
|
||||
Quantity: qty,
|
||||
Status: "on_sale", // 公开页 fixtures:三态改造后酒单只挂在售
|
||||
}).Error; err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
@@ -5,97 +5,320 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.PageTitle}}</title>{{.OGTags}}
|
||||
<style>
|
||||
:root{--primary:#2563AC;--primary-dark:#154072;--ink:#232934;--muted:#6E7888;
|
||||
--border:#DCE2EB;--paper:#F5F7FA;--head:#F0F4FF;--ok:#2E8B57;--ok-bg:#E6F3EC;}
|
||||
*{box-sizing:border-box;margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
|
||||
body{background:var(--paper);color:var(--ink);line-height:1.65;font-size:15px;}
|
||||
.wrap{max-width:640px;margin:0 auto;padding:14px 14px 40px;}
|
||||
.card{background:#fff;border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:12px;}
|
||||
.gallery{display:flex;gap:8px;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;border-radius:12px;margin-bottom:12px;}
|
||||
.gallery img{width:100%;max-width:640px;flex:0 0 100%;scroll-snap-align:center;border-radius:12px;object-fit:cover;background:#fff;border:1px solid var(--border);}
|
||||
h1{font-size:20px;line-height:1.4;margin-bottom:6px;}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 2px;}
|
||||
.chip{font-size:12px;padding:2px 10px;border-radius:10px;background:var(--head);color:var(--primary-dark);}
|
||||
.price{font-size:22px;font-weight:700;color:var(--primary);margin-top:6px;}
|
||||
.price small{font-size:12px;font-weight:400;color:var(--muted);}
|
||||
.row{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #EEF1F5;font-size:14px;}
|
||||
.row:last-child{border-bottom:none;}
|
||||
.row span{color:var(--muted);flex:none;}
|
||||
.row b{font-weight:600;text-align:right;word-break:break-all;}
|
||||
.sec-t{font-size:13px;font-weight:600;color:var(--primary-dark);margin-bottom:8px;}
|
||||
.desc{font-size:14px;color:var(--ink);white-space:pre-wrap;}
|
||||
.verify{display:flex;align-items:center;gap:8px;background:var(--ok-bg);color:var(--ok);border-radius:10px;padding:10px 14px;font-size:13px;font-weight:600;margin-bottom:12px;}
|
||||
/* 设计系统单一真源:变量名与三主题(a 经典蓝浅 / b 琥珀深 / c 酒窖暖浅)色值
|
||||
逐字取自 .superpowers/prototype/tokens.css。SSR 页自包含无法直接引 tokens.css,
|
||||
改配色须先改原型令牌再同步此处。--glow/--seal-bg 为本页派生变量(主色低透明度/表面高透明度)。 */
|
||||
:root{
|
||||
--r-md:6px; --r-lg:10px; --r-xl:14px; --r-pill:999px;
|
||||
--fs-display:28px; --fs-h1:21px; --fs-title:15px; --fs-body:13px; --fs-sm:12px; --fs-xs:11px;
|
||||
--font:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei","Inter",sans-serif;
|
||||
--font-mono:"JetBrains Mono","SF Mono",ui-monospace,monospace;
|
||||
--scrim:rgba(15,20,30,.42);
|
||||
/* 主题 A(默认) */
|
||||
--bg:#F5F7FA; --surface:#FFFFFF; --border:#DCE2EB; --border-subtle:#ECEFF4;
|
||||
--text:#232934; --heading:#1B2430; --muted:#6E7888; --faint:#99A3B3;
|
||||
--primary:#2563AC; --primary-dark:#154072; --on-primary:#FFFFFF; --brand50:#EEF4FB; --accent:#8B2331;
|
||||
--shadow:rgba(20,40,70,.20); --sh-1:0 1px 3px var(--shadow);
|
||||
--glow1:rgba(37,99,172,.05); --glow2:rgba(21,64,114,.04); --seal-bg:rgba(255,255,255,.78);
|
||||
}
|
||||
:root[data-theme="b"]{
|
||||
--bg:#15151C; --surface:#1E1E27; --border:#2B2B37; --border-subtle:#26262F;
|
||||
--text:#E8E8EE; --heading:#F6F6F9; --muted:#9A9AA8; --faint:#6E6E7E;
|
||||
--primary:#F5A300; --primary-dark:#D98C00; --on-primary:#1A1408; --brand50:#2A2212; --accent:#F87171;
|
||||
--shadow:rgba(0,0,0,.55);
|
||||
--glow1:rgba(245,163,0,.04); --glow2:rgba(248,113,113,.03); --seal-bg:rgba(30,30,39,.78);
|
||||
}
|
||||
:root[data-theme="c"]{
|
||||
--bg:#FAF5EC; --surface:#FFFDF8; --border:#EADDC9; --border-subtle:#F1E8D8;
|
||||
--text:#33271F; --heading:#241A14; --muted:#917A64; --faint:#B9A488;
|
||||
--primary:#7C1F2C; --primary-dark:#5F1621; --on-primary:#F7E9D6; --brand50:#F3E7D2; --accent:#A23522;
|
||||
--shadow:rgba(74,20,32,.22);
|
||||
--glow1:rgba(124,31,44,.05); --glow2:rgba(181,134,47,.04); --seal-bg:rgba(255,253,248,.78);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0;}
|
||||
html{background:var(--bg);height:100%;}
|
||||
body{
|
||||
min-height:100%;
|
||||
background-color:var(--bg);
|
||||
background-image:
|
||||
radial-gradient(circle at 12% 0%, var(--glow1), transparent 38%),
|
||||
radial-gradient(circle at 100% 100%, var(--glow2), transparent 38%);
|
||||
color:var(--text);
|
||||
font:var(--fs-title)/1.75 var(--font);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
-webkit-tap-highlight-color:transparent;
|
||||
transition:background-color .25s,color .25s;
|
||||
}
|
||||
/* 自适应满屏:页眉贴顶、页脚贴底、中间信息弹性填充;内容超屏时自然滚动 */
|
||||
.page{max-width:560px;margin:0 auto;padding:0 16px 24px;min-height:100vh;min-height:100dvh;display:flex;flex-direction:column;}
|
||||
a{color:var(--primary);text-decoration:none;}
|
||||
.shop-name{font-size:16px;font-weight:700;}
|
||||
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;}
|
||||
.foot a{color:var(--muted);text-decoration:underline;}
|
||||
.empty{text-align:center;padding:60px 20px;color:var(--muted);}
|
||||
.empty h1{font-size:18px;color:var(--ink);margin-bottom:8px;}
|
||||
|
||||
/* 顶部题签 */
|
||||
.mast{
|
||||
display:flex;align-items:center;justify-content:center;gap:10px;
|
||||
padding:18px 0 12px;color:var(--muted);font-size:var(--fs-sm);letter-spacing:.3em;
|
||||
border-bottom:1px solid var(--border);position:relative;
|
||||
}
|
||||
.mast a{color:var(--primary-dark);font-weight:700;letter-spacing:.3em;}
|
||||
/* 主题选择器:与 App ThemePickerPill 同构(衬衫图标 + 主题名 + ▾ + 下拉单选) */
|
||||
.thm{
|
||||
position:absolute;right:0;top:50%;transform:translateY(-58%);
|
||||
display:flex;align-items:center;gap:6px;height:30px;padding:0 11px;
|
||||
border-radius:var(--r-pill);border:1px solid var(--border);background:var(--surface);
|
||||
color:var(--text);font-size:var(--fs-sm);font-family:var(--font);cursor:pointer;letter-spacing:0;
|
||||
}
|
||||
.thm svg{width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
|
||||
.thm svg.cv{width:13px;height:13px;}
|
||||
.thm-menu{
|
||||
position:absolute;right:0;top:calc(100% + 6px);z-index:50;min-width:136px;padding:4px;
|
||||
background:var(--surface);border:1px solid var(--border);border-radius:var(--r-md);
|
||||
box-shadow:0 4px 14px var(--shadow);
|
||||
}
|
||||
.thm-menu[hidden]{display:none;}
|
||||
.thm-menu button{
|
||||
display:flex;align-items:center;gap:4px;width:100%;height:36px;padding:0 10px;
|
||||
border:none;background:none;color:var(--text);font-size:var(--fs-body);
|
||||
font-family:var(--font);cursor:pointer;border-radius:4px;text-align:left;
|
||||
}
|
||||
.thm-menu button:hover{background:var(--brand50);}
|
||||
.thm-menu button.on{color:var(--primary);font-weight:600;}
|
||||
.thm-menu .ck{width:16px;color:var(--primary);font-weight:700;flex:none;}
|
||||
|
||||
/* 图片区(永远存在)+ 右上「正品」章(--accent 酒红) */
|
||||
.hero{margin:18px 0 0;position:relative;animation:rise .5s ease both;}
|
||||
.frame{position:relative;background:var(--surface);border:1px solid var(--border);border-radius:var(--r-xl);overflow:hidden;box-shadow:var(--sh-1);}
|
||||
.gallery{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;}
|
||||
.gallery::-webkit-scrollbar{display:none;}
|
||||
.gallery img{width:100%;flex:0 0 100%;scroll-snap-align:center;aspect-ratio:1/1;object-fit:contain;background:var(--surface);display:block;cursor:zoom-in;}
|
||||
.placeholder{
|
||||
aspect-ratio:16/10;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
gap:10px;color:var(--muted);font-size:var(--fs-sm);letter-spacing:.2em;background:var(--brand50);
|
||||
}
|
||||
.placeholder svg{width:44px;height:66px;stroke:var(--primary);opacity:.4;}
|
||||
.seal{
|
||||
position:absolute;top:12px;right:12px;z-index:3;width:56px;height:56px;border-radius:var(--r-pill);
|
||||
border:2px solid var(--accent);color:var(--accent);background:var(--seal-bg);
|
||||
display:flex;align-items:center;justify-content:center;transform:rotate(8deg);
|
||||
font-size:14px;font-weight:700;letter-spacing:.16em;text-indent:.16em;
|
||||
box-shadow:inset 0 0 0 2px var(--seal-bg);
|
||||
}
|
||||
.dots{position:absolute;left:0;right:0;bottom:10px;display:flex;justify-content:center;gap:6px;z-index:3;}
|
||||
.dots i{width:6px;height:6px;border-radius:var(--r-pill);background:var(--border);transition:all .25s;}
|
||||
.dots i.on{background:var(--primary);width:16px;border-radius:3px;}
|
||||
|
||||
/* 全屏灯箱(深底 = --toast-bg 提浓) */
|
||||
.lb{position:fixed;inset:0;z-index:99;background:rgba(31,35,48,.96);}
|
||||
.lb[hidden]{display:none;}
|
||||
.lb-track{display:flex;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;height:100%;scrollbar-width:none;}
|
||||
.lb-track::-webkit-scrollbar{display:none;}
|
||||
.lb-track img{width:100vw;height:100%;flex:0 0 100vw;scroll-snap-align:center;object-fit:contain;}
|
||||
.lb-x{position:absolute;top:16px;right:16px;z-index:2;width:38px;height:38px;border-radius:var(--r-pill);
|
||||
border:1px solid rgba(255,255,255,.35);background:var(--scrim);color:#fff;font-size:16px;cursor:pointer;}
|
||||
.lb-n{position:absolute;bottom:22px;left:0;right:0;text-align:center;color:rgba(255,255,255,.85);
|
||||
font-size:var(--fs-sm);letter-spacing:.2em;font-family:var(--font-mono);}
|
||||
|
||||
/* 题名 */
|
||||
.head{text-align:center;padding:20px 6px 2px;animation:rise .5s ease .06s both;}
|
||||
h1{font-size:var(--fs-h1);line-height:1.45;font-weight:700;color:var(--heading);}
|
||||
.sub{color:var(--muted);font-size:var(--fs-body);letter-spacing:.12em;margin-top:5px;}
|
||||
.price{margin-top:10px;color:var(--accent);font-size:var(--fs-display);font-weight:700;}
|
||||
.price small{font-size:var(--fs-sm);color:var(--muted);font-weight:400;letter-spacing:.08em;}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:12px;}
|
||||
.chip{font-size:var(--fs-sm);color:var(--primary);border-radius:var(--r-pill);padding:2px 11px;background:var(--brand50);}
|
||||
|
||||
/* 分节 */
|
||||
.sec{margin-top:22px;animation:rise .5s ease .12s both;}
|
||||
.sec-t{
|
||||
display:flex;align-items:center;gap:12px;color:var(--primary-dark);font-size:var(--fs-body);
|
||||
letter-spacing:.3em;text-indent:.3em;font-weight:700;justify-content:center;margin-bottom:10px;
|
||||
}
|
||||
.sec-t::before,.sec-t::after{content:"";height:1px;background:var(--border);flex:1;max-width:56px;}
|
||||
.card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r-xl);padding:4px 16px;box-shadow:var(--sh-1);}
|
||||
|
||||
/* 信息行:label / value 两端对齐(App 列表行式) */
|
||||
.row{display:flex;align-items:baseline;justify-content:space-between;gap:14px;padding:11px 0;font-size:14px;}
|
||||
.row+.row{border-top:1px solid var(--border-subtle);}
|
||||
.row span{color:var(--muted);flex:none;}
|
||||
.row b{font-weight:600;text-align:right;word-break:break-all;color:var(--text);}
|
||||
.row b.mono,.mono{font-family:var(--font-mono);font-size:var(--fs-body);}
|
||||
|
||||
.desc{padding:13px 2px;font-size:14px;color:var(--text);white-space:pre-wrap;text-align:justify;}
|
||||
.more{display:block;text-align:center;padding:12px 0 10px;font-size:13.5px;font-weight:600;border-top:1px solid var(--border-subtle);}
|
||||
|
||||
/* 页脚:flex 撑到视口底部 */
|
||||
.foot{margin-top:auto;padding-top:30px;text-align:center;color:var(--muted);font-size:var(--fs-sm);line-height:2;animation:rise .5s ease .18s both;}
|
||||
.foot-rule{display:flex;align-items:center;gap:12px;margin-bottom:8px;color:var(--border);}
|
||||
.foot-rule::before,.foot-rule::after{content:"";height:1px;background:var(--border);flex:1;}
|
||||
.foot a{color:var(--muted);}
|
||||
.foot .brand{color:var(--primary);font-weight:600;}
|
||||
.foot .icp{font-size:var(--fs-xs);opacity:.9;}
|
||||
|
||||
.empty{text-align:center;padding:80px 20px 60px;animation:rise .5s ease both;}
|
||||
.empty h1{font-size:19px;margin-bottom:10px;}
|
||||
.empty p{color:var(--muted);font-size:var(--fs-body);}
|
||||
|
||||
@keyframes rise{from{opacity:0;transform:translateY(10px);}to{opacity:1;transform:none;}}
|
||||
@media (prefers-reduced-motion:reduce){*{animation:none!important;}}
|
||||
</style>
|
||||
<script>
|
||||
/* 首屏前定主题防闪烁:记忆值 > 系统深色(→b) > a */
|
||||
(function(){try{
|
||||
var t=localStorage.getItem('jiu-pub-theme');
|
||||
if(!t&&window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches)t='b';
|
||||
if(t==='b'||t==='c')document.documentElement.setAttribute('data-theme',t);
|
||||
}catch(e){}})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="page">
|
||||
<header class="mast"><a href="https://jiu.51yanmei.com">岩美酒库</a>
|
||||
<button class="thm" id="thm" aria-label="切换主题">
|
||||
<svg viewBox="0 0 24 24"><path d="M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z"/></svg>
|
||||
<span id="thmName">经典蓝</span>
|
||||
<svg class="cv" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</button>
|
||||
<div class="thm-menu" id="thmMenu" hidden>
|
||||
<button data-t="a"><span class="ck"></span>A · 经典蓝</button>
|
||||
<button data-t="b"><span class="ck"></span>B · 琥珀</button>
|
||||
<button data-t="c"><span class="ck"></span>C · 酒窖</button>
|
||||
</div>
|
||||
</header>
|
||||
{{if .Found}}
|
||||
{{if .Images}}
|
||||
<div class="gallery">
|
||||
{{range $i, $img := .Images}}
|
||||
<img src="{{$img}}" alt="{{$.Name}}" decoding="async"{{if $i}} loading="lazy"{{end}}>
|
||||
{{end}}
|
||||
<div class="hero">
|
||||
<div class="frame">
|
||||
{{if .Images}}
|
||||
<div class="gallery" id="gal">
|
||||
{{range $i, $img := .Images}}<img src="{{$img}}" alt="{{$.Name}}" decoding="async"{{if $i}} loading="lazy"{{end}}>{{end}}
|
||||
</div>
|
||||
{{if gt (len .Images) 1}}<div class="dots">{{range .Images}}<i></i>{{end}}</div>{{end}}
|
||||
{{else}}
|
||||
<div class="placeholder">
|
||||
<svg viewBox="0 0 40 64" fill="none" stroke-width="1.4" stroke-linecap="round">
|
||||
<path d="M16 2h8M17 2v14c0 3 6 3 6 0V2M17 12c-6 5-9 9-9 16v28a4 4 0 0 0 4 4h16a4 4 0 0 0 4-4V28c0-7-3-11-9-16"/>
|
||||
<path d="M8 38h24" stroke-dasharray="2 3"/>
|
||||
</svg>
|
||||
<span>实物图整理中</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="seal">正品</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="card">
|
||||
<div class="head">
|
||||
<h1>{{.Name}}</h1>
|
||||
{{if .Keywords}}<div class="chips">{{range .Keywords}}<span class="chip">{{.}}</span>{{end}}</div>{{end}}
|
||||
{{if or .Series .Spec}}<div class="sub">{{.Series}}{{if and .Series .Spec}} · {{end}}{{.Spec}}</div>{{end}}
|
||||
{{if .Price}}<div class="price">{{.Price}}{{if .Unit}} <small>/ {{.Unit}}</small>{{end}}</div>{{end}}
|
||||
{{if .Keywords}}<div class="chips">{{range .Keywords}}<span class="chip">{{.}}</span>{{end}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
<div class="verify">✓ 正品保障 · 扫码验真 · 官方渠道供应</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="sec">
|
||||
<div class="sec-t">商品信息</div>
|
||||
{{if .Code}}<div class="row"><span>商品编号</span><b>{{.Code}}</b></div>{{end}}
|
||||
{{if .Series}}<div class="row"><span>系列</span><b>{{.Series}}</b></div>{{end}}
|
||||
{{if .Spec}}<div class="row"><span>规格</span><b>{{.Spec}}</b></div>{{end}}
|
||||
{{if .Origin}}<div class="row"><span>产地</span><b>{{.Origin}}</b></div>{{end}}
|
||||
<div class="row"><span>保质期</span><b>{{.ShelfLife}}</b></div>
|
||||
<div class="row"><span>储存方式</span><b>{{.Storage}}</b></div>
|
||||
<div class="card">
|
||||
{{if .Code}}<div class="row"><span>商品编号</span><b class="mono">{{.Code}}</b></div>{{end}}
|
||||
{{if .Series}}<div class="row"><span>系列</span><b>{{.Series}}</b></div>{{end}}
|
||||
{{if .Spec}}<div class="row"><span>规格</span><b>{{.Spec}}</b></div>{{end}}
|
||||
{{if .Origin}}<div class="row"><span>产地</span><b>{{.Origin}}</b></div>{{end}}
|
||||
<div class="row"><span>保质期</span><b>{{.ShelfLife}}</b></div>
|
||||
<div class="row"><span>储存方式</span><b>{{.Storage}}</b></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .HasBatch}}
|
||||
<div class="card">
|
||||
<div class="sec">
|
||||
<div class="sec-t">批次信息</div>
|
||||
{{if .ProdDate}}<div class="row"><span>生产日期</span><b>{{.ProdDate}}</b></div>{{end}}
|
||||
{{if .BatchNo}}<div class="row"><span>批次号</span><b>{{.BatchNo}}</b></div>{{end}}
|
||||
{{if .InDate}}<div class="row"><span>入库日期</span><b>{{.InDate}}</b></div>{{end}}
|
||||
<div class="card">
|
||||
{{if .ProdDate}}<div class="row"><span>生产日期</span><b class="mono">{{.ProdDate}}</b></div>{{end}}
|
||||
{{if .BatchNo}}<div class="row"><span>批次号</span><b class="mono">{{.BatchNo}}</b></div>{{end}}
|
||||
{{if .InDate}}<div class="row"><span>入库日期</span><b class="mono">{{.InDate}}</b></div>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .DescBody}}
|
||||
<div class="card">
|
||||
<div class="sec">
|
||||
<div class="sec-t">{{.DescTitle}}</div>
|
||||
<div class="desc">{{.DescBody}}</div>
|
||||
<div class="card"><div class="desc">{{.DescBody}}</div></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .HasShop}}
|
||||
<div class="card">
|
||||
<div class="sec">
|
||||
<div class="sec-t">门店信息</div>
|
||||
<div class="row"><span>门店</span><b>{{if .ShopCode}}<a class="shop-name" href="/shop/{{.ShopCode}}">{{.ShopName}} ›</a>{{else}}{{.ShopName}}{{end}}</b></div>
|
||||
{{if .ShopPhone}}<div class="row"><span>电话</span><b><a href="tel:{{.ShopPhone}}">{{.ShopPhone}}</a></b></div>{{end}}
|
||||
{{if .ShopAddr}}<div class="row"><span>地址</span><b>{{.ShopAddr}}</b></div>{{end}}
|
||||
{{if .ShopHours}}<div class="row"><span>营业时间</span><b>{{.ShopHours}}</b></div>{{end}}
|
||||
{{if .ShopWx}}<div class="row"><span>微信</span><b>{{.ShopWx}}</b></div>{{end}}
|
||||
{{if .ShopCode}}<div class="row"><span></span><b><a href="/shop/{{.ShopCode}}">查看本店更多商品 ›</a></b></div>{{end}}
|
||||
<div class="card">
|
||||
<div class="row"><span>门店</span><b>{{if .ShopCode}}<a href="/shop/{{.ShopCode}}">{{.ShopName}} ›</a>{{else}}{{.ShopName}}{{end}}</b></div>
|
||||
{{if .ShopPhone}}<div class="row"><span>电话</span><b class="mono"><a href="tel:{{.ShopPhone}}">{{.ShopPhone}}</a></b></div>{{end}}
|
||||
{{if .ShopAddr}}<div class="row"><span>地址</span><b>{{.ShopAddr}}</b></div>{{end}}
|
||||
{{if .ShopHours}}<div class="row"><span>营业时间</span><b>{{.ShopHours}}</b></div>{{end}}
|
||||
{{if .ShopWx}}<div class="row"><span>微信</span><b class="mono">{{.ShopWx}}</b></div>{{end}}
|
||||
{{if .ShopCode}}<a class="more" href="/shop/{{.ShopCode}}">查看本店更多商品 ›</a>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="card empty">
|
||||
<div class="empty">
|
||||
<h1>商品不存在或已下架</h1>
|
||||
<p>请确认链接来源,或联系门店核实。</p>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="foot">岩美酒库 · 正品溯源{{if .AppURL}} · <a href="{{.AppURL}}">在 App 中查看</a>{{end}}</div>
|
||||
<footer class="foot">
|
||||
<div class="foot-rule">◆</div>
|
||||
<div>技术支持:<a class="brand" href="https://jiu.51yanmei.com">岩美科技</a> · <a class="icp" href="https://beian.miit.gov.cn" target="_blank" rel="noopener">京ICP备2026039814号</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var NAMES={a:'经典蓝',b:'琥珀',c:'酒窖'},root=document.documentElement,
|
||||
btn=document.getElementById('thm'),menu=document.getElementById('thmMenu'),
|
||||
label=document.getElementById('thmName');
|
||||
if(!btn||!menu)return;
|
||||
var opts=[].slice.call(menu.children);
|
||||
function cur(){return root.getAttribute('data-theme')||'a';}
|
||||
function paint(){
|
||||
label.textContent=NAMES[cur()];
|
||||
opts.forEach(function(b){var on=b.getAttribute('data-t')===cur();
|
||||
b.className=on?'on':'';b.querySelector('.ck').textContent=on?'✓':'';});
|
||||
}
|
||||
btn.addEventListener('click',function(e){e.stopPropagation();menu.hidden=!menu.hidden;});
|
||||
opts.forEach(function(b){b.addEventListener('click',function(e){
|
||||
e.stopPropagation();
|
||||
var t=b.getAttribute('data-t');
|
||||
if(t==='a')root.removeAttribute('data-theme');else root.setAttribute('data-theme',t);
|
||||
try{localStorage.setItem('jiu-pub-theme',t);}catch(err){}
|
||||
paint();menu.hidden=true;
|
||||
});});
|
||||
document.addEventListener('click',function(){menu.hidden=true;});
|
||||
paint();
|
||||
})();
|
||||
</script>
|
||||
{{if .Images}}
|
||||
<div class="lb" id="lb" hidden>
|
||||
<button class="lb-x" id="lbx" aria-label="关闭">✕</button>
|
||||
<div class="lb-track" id="lbt">
|
||||
{{range .Images}}<img src="{{.}}" alt="" loading="lazy">{{end}}
|
||||
</div>
|
||||
<div class="lb-n" id="lbn"></div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var gal=document.getElementById('gal'),lb=document.getElementById('lb'),
|
||||
lbt=document.getElementById('lbt'),lbn=document.getElementById('lbn');
|
||||
if(!gal||!lb)return;
|
||||
var imgs=[].slice.call(gal.children),n=imgs.length,
|
||||
dots=[].slice.call(document.querySelectorAll('.dots i'));
|
||||
function idx(el){return Math.min(n-1,Math.round(el.scrollLeft/el.clientWidth));}
|
||||
function paint(){var i=idx(gal);for(var k=0;k<dots.length;k++)dots[k].className=k===i?'on':'';}
|
||||
if(dots.length){gal.addEventListener('scroll',function(){requestAnimationFrame(paint);},{passive:true});}
|
||||
function num(){lbn.textContent=(idx(lbt)+1)+' / '+n;}
|
||||
imgs.forEach(function(im,i){im.addEventListener('click',function(){
|
||||
lb.hidden=false;document.body.style.overflow='hidden';
|
||||
lbt.scrollLeft=lbt.clientWidth*i;num();
|
||||
});});
|
||||
lbt.addEventListener('scroll',function(){requestAnimationFrame(num);},{passive:true});
|
||||
function close(){lb.hidden=true;document.body.style.overflow='';}
|
||||
document.getElementById('lbx').addEventListener('click',close);
|
||||
lb.addEventListener('click',function(e){if(e.target===lb||e.target===lbt)close();});
|
||||
})();
|
||||
</script>
|
||||
{{end}}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,83 +5,240 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.PageTitle}}</title>{{.OGTags}}
|
||||
<style>
|
||||
:root{--primary:#2563AC;--primary-dark:#154072;--ink:#232934;--muted:#6E7888;
|
||||
--border:#DCE2EB;--paper:#F5F7FA;--head:#F0F4FF;}
|
||||
*{box-sizing:border-box;margin:0;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
|
||||
body{background:var(--paper);color:var(--ink);line-height:1.6;font-size:15px;}
|
||||
.wrap{max-width:640px;margin:0 auto;padding:14px 14px 40px;}
|
||||
.head{display:flex;align-items:center;gap:12px;padding:6px 2px 14px;}
|
||||
.head img{width:44px;height:44px;border-radius:10px;object-fit:cover;border:1px solid var(--border);background:#fff;}
|
||||
.head h1{font-size:18px;line-height:1.3;}
|
||||
.head .sub{font-size:12px;color:var(--muted);}
|
||||
form{display:flex;gap:8px;margin-bottom:12px;}
|
||||
input[type=search]{flex:1;border:1px solid var(--border);border-radius:10px;padding:9px 12px;font-size:14px;background:#fff;outline:none;}
|
||||
/* 设计系统单一真源:变量名与三主题(a/b/c)色值逐字取自 .superpowers/prototype/tokens.css。
|
||||
与商品公开页同族;改配色须先改原型令牌再同步此处。--glow 为本页派生变量(主色低透明度)。 */
|
||||
:root{
|
||||
--r-md:6px; --r-lg:10px; --r-xl:14px; --r-pill:999px;
|
||||
--fs-h1:21px; --fs-title:15px; --fs-body:13px; --fs-sm:12px; --fs-xs:11px;
|
||||
--font:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei","Inter",sans-serif;
|
||||
--font-mono:"JetBrains Mono","SF Mono",ui-monospace,monospace;
|
||||
/* 主题 A(默认) */
|
||||
--bg:#F5F7FA; --surface:#FFFFFF; --border:#DCE2EB; --border-subtle:#ECEFF4;
|
||||
--text:#232934; --heading:#1B2430; --muted:#6E7888; --faint:#99A3B3;
|
||||
--primary:#2563AC; --primary-dark:#154072; --on-primary:#FFFFFF; --brand50:#EEF4FB; --accent:#8B2331;
|
||||
--shadow:rgba(20,40,70,.20); --sh-1:0 1px 3px var(--shadow);
|
||||
--glow1:rgba(37,99,172,.05); --glow2:rgba(21,64,114,.04);
|
||||
}
|
||||
:root[data-theme="b"]{
|
||||
--bg:#15151C; --surface:#1E1E27; --border:#2B2B37; --border-subtle:#26262F;
|
||||
--text:#E8E8EE; --heading:#F6F6F9; --muted:#9A9AA8; --faint:#6E6E7E;
|
||||
--primary:#F5A300; --primary-dark:#D98C00; --on-primary:#1A1408; --brand50:#2A2212; --accent:#F87171;
|
||||
--shadow:rgba(0,0,0,.55);
|
||||
--glow1:rgba(245,163,0,.04); --glow2:rgba(248,113,113,.03);
|
||||
}
|
||||
:root[data-theme="c"]{
|
||||
--bg:#FAF5EC; --surface:#FFFDF8; --border:#EADDC9; --border-subtle:#F1E8D8;
|
||||
--text:#33271F; --heading:#241A14; --muted:#917A64; --faint:#B9A488;
|
||||
--primary:#7C1F2C; --primary-dark:#5F1621; --on-primary:#F7E9D6; --brand50:#F3E7D2; --accent:#A23522;
|
||||
--shadow:rgba(74,20,32,.22);
|
||||
--glow1:rgba(124,31,44,.05); --glow2:rgba(181,134,47,.04);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0;}
|
||||
html{background:var(--bg);height:100%;}
|
||||
body{
|
||||
min-height:100%;
|
||||
background-color:var(--bg);
|
||||
background-image:
|
||||
radial-gradient(circle at 12% 0%, var(--glow1), transparent 38%),
|
||||
radial-gradient(circle at 100% 100%, var(--glow2), transparent 38%);
|
||||
color:var(--text);
|
||||
font:var(--fs-title)/1.7 var(--font);
|
||||
-webkit-font-smoothing:antialiased;
|
||||
-webkit-tap-highlight-color:transparent;
|
||||
transition:background-color .25s,color .25s;
|
||||
}
|
||||
/* 自适应满屏:页眉贴顶、页脚贴底、中间信息弹性填充;内容超屏时自然滚动 */
|
||||
.page{max-width:560px;margin:0 auto;padding:0 16px 24px;min-height:100vh;min-height:100dvh;display:flex;flex-direction:column;}
|
||||
a{color:var(--primary);text-decoration:none;}
|
||||
|
||||
.mast{
|
||||
display:flex;align-items:center;justify-content:center;gap:10px;
|
||||
padding:18px 0 12px;color:var(--muted);font-size:var(--fs-sm);letter-spacing:.3em;
|
||||
border-bottom:1px solid var(--border);position:relative;
|
||||
}
|
||||
.mast a{color:var(--primary-dark);font-weight:700;letter-spacing:.3em;}
|
||||
/* 主题选择器:与 App ThemePickerPill 同构(衬衫图标 + 主题名 + ▾ + 下拉单选) */
|
||||
.thm{
|
||||
position:absolute;right:0;top:50%;transform:translateY(-58%);
|
||||
display:flex;align-items:center;gap:6px;height:30px;padding:0 11px;
|
||||
border-radius:var(--r-pill);border:1px solid var(--border);background:var(--surface);
|
||||
color:var(--text);font-size:var(--fs-sm);font-family:var(--font);cursor:pointer;letter-spacing:0;
|
||||
}
|
||||
.thm svg{width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
|
||||
.thm svg.cv{width:13px;height:13px;}
|
||||
.thm-menu{
|
||||
position:absolute;right:0;top:calc(100% + 6px);z-index:50;min-width:136px;padding:4px;
|
||||
background:var(--surface);border:1px solid var(--border);border-radius:var(--r-md);
|
||||
box-shadow:0 4px 14px var(--shadow);
|
||||
}
|
||||
.thm-menu[hidden]{display:none;}
|
||||
.thm-menu button{
|
||||
display:flex;align-items:center;gap:4px;width:100%;height:36px;padding:0 10px;
|
||||
border:none;background:none;color:var(--text);font-size:var(--fs-body);
|
||||
font-family:var(--font);cursor:pointer;border-radius:4px;text-align:left;letter-spacing:0;text-indent:0;
|
||||
}
|
||||
.thm-menu button:hover{background:var(--brand50);}
|
||||
.thm-menu button.on{color:var(--primary);font-weight:600;}
|
||||
.thm-menu .ck{width:16px;color:var(--primary);font-weight:700;flex:none;}
|
||||
|
||||
/* 店铺题名 */
|
||||
.shop{text-align:center;padding:24px 0 4px;animation:rise .5s ease both;}
|
||||
.shop img{width:56px;height:56px;border-radius:var(--r-pill);object-fit:cover;border:1px solid var(--border);background:var(--surface);padding:2px;margin-bottom:10px;}
|
||||
.shop h1{font-size:var(--fs-h1);font-weight:700;color:var(--heading);letter-spacing:.06em;}
|
||||
.shop .sub{color:var(--muted);font-size:var(--fs-sm);letter-spacing:.2em;margin-top:5px;}
|
||||
.shop .sub b{color:var(--primary);font-weight:600;}
|
||||
|
||||
form{display:flex;gap:10px;margin:16px 0 4px;animation:rise .5s ease .06s both;}
|
||||
input[type=search]{
|
||||
flex:1;border:1px solid var(--border);border-radius:var(--r-lg);padding:10px 14px;font-size:14px;
|
||||
background:var(--surface);outline:none;color:var(--text);
|
||||
}
|
||||
input[type=search]::placeholder{color:var(--faint);}
|
||||
input[type=search]:focus{border-color:var(--primary);}
|
||||
button{border:none;background:var(--primary);color:#fff;border-radius:10px;padding:0 18px;font-size:14px;}
|
||||
.item{display:flex;gap:12px;background:#fff;border:1px solid var(--border);border-radius:12px;padding:12px;margin-bottom:10px;color:var(--ink);}
|
||||
.item img{width:64px;height:64px;border-radius:8px;object-fit:cover;border:1px solid var(--border);flex:none;background:var(--paper);}
|
||||
.item .ph{width:64px;height:64px;border-radius:8px;border:1px solid var(--border);flex:none;background:var(--head);display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:11px;}
|
||||
.item .m{flex:1;min-width:0;}
|
||||
.item .nm{font-weight:600;font-size:15px;line-height:1.4;}
|
||||
.item .sb{font-size:12.5px;color:var(--muted);margin-top:2px;}
|
||||
.item .cd{font-size:12px;color:var(--muted);font-family:ui-monospace,Menlo,monospace;margin-top:2px;}
|
||||
.item .pr{font-size:15px;font-weight:700;color:var(--primary);flex:none;align-self:center;}
|
||||
a{text-decoration:none;color:inherit;}
|
||||
.pager{display:flex;justify-content:space-between;align-items:center;margin-top:16px;font-size:14px;}
|
||||
.pager a{color:var(--primary);font-weight:600;padding:8px 4px;}
|
||||
.pager .off{color:var(--border);}
|
||||
.pager .pg{color:var(--muted);font-size:12.5px;}
|
||||
.empty{text-align:center;padding:60px 20px;color:var(--muted);background:#fff;border:1px solid var(--border);border-radius:12px;}
|
||||
.empty h2{font-size:16px;color:var(--ink);margin-bottom:6px;}
|
||||
.foot{text-align:center;color:var(--muted);font-size:12px;margin-top:18px;}
|
||||
button{
|
||||
border:none;background:var(--primary);color:var(--on-primary);border-radius:var(--r-lg);
|
||||
padding:0 20px;font-size:14px;letter-spacing:.2em;text-indent:.2em;cursor:pointer;font-family:var(--font);
|
||||
}
|
||||
|
||||
.sec-t{
|
||||
display:flex;align-items:center;gap:12px;color:var(--primary-dark);font-size:var(--fs-body);
|
||||
letter-spacing:.3em;text-indent:.3em;font-weight:700;justify-content:center;margin:18px 0 12px;
|
||||
}
|
||||
.sec-t::before,.sec-t::after{content:"";height:1px;background:var(--border);flex:1;max-width:56px;}
|
||||
|
||||
/* 商品条目 */
|
||||
.list{animation:rise .5s ease .12s both;}
|
||||
.item{
|
||||
display:flex;gap:14px;align-items:center;background:var(--surface);border:1px solid var(--border);
|
||||
border-radius:var(--r-xl);padding:12px 14px;margin-bottom:10px;color:var(--text);position:relative;box-shadow:var(--sh-1);
|
||||
}
|
||||
.item::after{content:"›";position:absolute;right:12px;top:50%;transform:translateY(-58%);color:var(--faint);font-size:16px;}
|
||||
.item img,.item .ph{width:58px;height:58px;border-radius:var(--r-md);flex:none;border:1px solid var(--border-subtle);background:var(--brand50);}
|
||||
.item img{object-fit:cover;display:block;}
|
||||
.item .ph{display:flex;align-items:center;justify-content:center;}
|
||||
.item .ph svg{width:20px;height:32px;stroke:var(--primary);opacity:.4;}
|
||||
.item .m{flex:1;min-width:0;padding-right:16px;}
|
||||
.item .nm{font-weight:700;font-size:var(--fs-title);line-height:1.45;color:var(--heading);}
|
||||
.item .sb{font-size:var(--fs-sm);color:var(--muted);margin-top:2px;}
|
||||
.item .cd{font-size:var(--fs-xs);color:var(--faint);font-family:var(--font-mono);margin-top:2px;}
|
||||
.item .pr{flex:none;color:var(--accent);font-weight:700;font-size:16px;padding-right:14px;}
|
||||
|
||||
.pager{display:flex;justify-content:space-between;align-items:center;margin-top:16px;font-size:13.5px;animation:rise .5s ease .16s both;}
|
||||
.pager a{color:var(--primary);font-weight:600;letter-spacing:.08em;padding:8px 2px;}
|
||||
.pager .off{color:var(--border);letter-spacing:.08em;}
|
||||
.pager .pg{color:var(--muted);font-size:var(--fs-sm);letter-spacing:.12em;}
|
||||
|
||||
.empty{text-align:center;padding:56px 20px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r-xl);animation:rise .5s ease both;box-shadow:var(--sh-1);}
|
||||
.empty h2{font-size:17px;margin-bottom:8px;color:var(--heading);}
|
||||
.empty p{color:var(--muted);font-size:var(--fs-body);}
|
||||
|
||||
.foot{margin-top:auto;padding-top:30px;text-align:center;color:var(--muted);font-size:var(--fs-sm);line-height:2;}
|
||||
.foot-rule{display:flex;align-items:center;gap:12px;margin-bottom:8px;color:var(--border);}
|
||||
.foot-rule::before,.foot-rule::after{content:"";height:1px;background:var(--border);flex:1;}
|
||||
.foot a{color:var(--muted);}
|
||||
.foot .brand{color:var(--primary);font-weight:600;}
|
||||
.foot .icp{font-size:var(--fs-xs);opacity:.9;}
|
||||
|
||||
@keyframes rise{from{opacity:0;transform:translateY(10px);}to{opacity:1;transform:none;}}
|
||||
@media (prefers-reduced-motion:reduce){*{animation:none!important;}}
|
||||
</style>
|
||||
<script>
|
||||
/* 首屏前定主题防闪烁:记忆值 > 系统深色(→b) > a */
|
||||
(function(){try{
|
||||
var t=localStorage.getItem('jiu-pub-theme');
|
||||
if(!t&&window.matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches)t='b';
|
||||
if(t==='b'||t==='c')document.documentElement.setAttribute('data-theme',t);
|
||||
}catch(e){}})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
{{if .Found}}
|
||||
<div class="head">
|
||||
{{if .LogoURL}}<img src="{{.LogoURL}}" alt="{{.ShopName}}" decoding="async">{{end}}
|
||||
<div>
|
||||
<h1>{{.ShopName}}</h1>
|
||||
<div class="sub">在售商品 {{.Total}} 件 · 正品保障</div>
|
||||
<div class="page">
|
||||
<header class="mast"><a href="https://jiu.51yanmei.com">岩美酒库</a>
|
||||
<button class="thm" id="thm" aria-label="切换主题">
|
||||
<svg viewBox="0 0 24 24"><path d="M20.38 3.46 16 2a4 4 0 0 1-8 0L3.62 3.46a2 2 0 0 0-1.34 2.23l.58 3.47a1 1 0 0 0 .99.84H6v10c0 1.1.9 2 2 2h8a2 2 0 0 0 2-2V10h2.15a1 1 0 0 0 .99-.84l.58-3.47a2 2 0 0 0-1.34-2.23z"/></svg>
|
||||
<span id="thmName">经典蓝</span>
|
||||
<svg class="cv" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</button>
|
||||
<div class="thm-menu" id="thmMenu" hidden>
|
||||
<button data-t="a"><span class="ck"></span>A · 经典蓝</button>
|
||||
<button data-t="b"><span class="ck"></span>B · 琥珀</button>
|
||||
<button data-t="c"><span class="ck"></span>C · 酒窖</button>
|
||||
</div>
|
||||
</header>
|
||||
{{if .Found}}
|
||||
<div class="shop">
|
||||
{{if .LogoURL}}<img src="{{.LogoURL}}" alt="{{.ShopName}}" decoding="async">{{end}}
|
||||
<h1>{{.ShopName}}</h1>
|
||||
<div class="sub">在售商品 <b>{{.Total}}</b> 件</div>
|
||||
</div>
|
||||
|
||||
<form method="get" action="">
|
||||
<input type="search" name="q" value="{{.Q}}" placeholder="搜索本店商品(名称 / 编号 / 拼音)">
|
||||
<input type="search" name="q" value="{{.Q}}" placeholder="搜索本店商品:名称 / 编号 / 拼音">
|
||||
<button type="submit">搜索</button>
|
||||
</form>
|
||||
|
||||
<div class="sec-t">在售商品</div>
|
||||
|
||||
{{if .Items}}
|
||||
<div class="list">
|
||||
{{range .Items}}
|
||||
<a class="item" href="/product/{{.PublicID}}">
|
||||
{{if .Img}}<img src="{{.Img}}" alt="{{.Name}}" loading="lazy" decoding="async">{{else}}<span class="ph">暂无图</span>{{end}}
|
||||
{{if .Img}}<img src="{{.Img}}" alt="{{.Name}}" loading="lazy" decoding="async">{{else}}<span class="ph"><svg viewBox="0 0 40 64" fill="none" stroke-width="2" stroke-linecap="round"><path d="M16 2h8M17 2v14c0 3 6 3 6 0V2M17 12c-6 5-9 9-9 16v28a4 4 0 0 0 4 4h16a4 4 0 0 0 4-4V28c0-7-3-11-9-16"/></svg></span>{{end}}
|
||||
<span class="m">
|
||||
<span class="nm">{{.Name}}</span>
|
||||
<span class="nm" style="display:block">{{.Name}}</span>
|
||||
{{if .Sub}}<span class="sb" style="display:block">{{.Sub}}</span>{{end}}
|
||||
{{if .Code}}<span class="cd" style="display:block">{{.Code}}</span>{{end}}
|
||||
</span>
|
||||
{{if .Price}}<span class="pr">{{.Price}}</span>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
<div class="pager">
|
||||
{{if .PrevURL}}<a href="{{.PrevURL}}">‹ 上一页</a>{{else}}<span class="off">‹ 上一页</span>{{end}}
|
||||
<span class="pg">第 {{.Page}} 页 · 共 {{.Total}} 件</span>
|
||||
{{if .NextURL}}<a href="{{.NextURL}}">下一页 ›</a>{{else}}<span class="off">下一页 ›</span>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pager">
|
||||
{{if .PrevURL}}<a href="{{.PrevURL}}">‹ 上一页</a>{{else}}<span class="off">‹ 上一页</span>{{end}}
|
||||
<span class="pg">第 {{.Page}} 页 · 共 {{.Total}} 件</span>
|
||||
{{if .NextURL}}<a href="{{.NextURL}}">下一页 ›</a>{{else}}<span class="off">下一页 ›</span>{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty">
|
||||
<h2>{{if .Q}}没有找到「{{.Q}}」相关商品{{else}}本店暂无在售商品{{end}}</h2>
|
||||
{{if .Q}}<p>换个关键词试试,或清空搜索查看全部。</p>{{end}}
|
||||
</div>
|
||||
<div class="empty">
|
||||
<h2>{{if .Q}}没有找到「{{.Q}}」相关商品{{else}}本店暂无在售商品{{end}}</h2>
|
||||
{{if .Q}}<p>换个关键词试试,或清空搜索查看全部。</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty">
|
||||
<div class="empty" style="margin-top:40px">
|
||||
<h2>店铺不存在</h2>
|
||||
<p>请确认链接来源。</p>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="foot">岩美酒库 · 正品溯源</div>
|
||||
<footer class="foot">
|
||||
<div class="foot-rule">◆</div>
|
||||
<div>技术支持:<a class="brand" href="https://jiu.51yanmei.com">岩美科技</a> · <a class="icp" href="https://beian.miit.gov.cn" target="_blank" rel="noopener">京ICP备2026039814号</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var NAMES={a:'经典蓝',b:'琥珀',c:'酒窖'},root=document.documentElement,
|
||||
btn=document.getElementById('thm'),menu=document.getElementById('thmMenu'),
|
||||
label=document.getElementById('thmName');
|
||||
if(!btn||!menu)return;
|
||||
var opts=[].slice.call(menu.children);
|
||||
function cur(){return root.getAttribute('data-theme')||'a';}
|
||||
function paint(){
|
||||
label.textContent=NAMES[cur()];
|
||||
opts.forEach(function(b){var on=b.getAttribute('data-t')===cur();
|
||||
b.className=on?'on':'';b.querySelector('.ck').textContent=on?'✓':'';});
|
||||
}
|
||||
btn.addEventListener('click',function(e){e.stopPropagation();menu.hidden=!menu.hidden;});
|
||||
opts.forEach(function(b){b.addEventListener('click',function(e){
|
||||
e.stopPropagation();
|
||||
var t=b.getAttribute('data-t');
|
||||
if(t==='a')root.removeAttribute('data-theme');else root.setAttribute('data-theme',t);
|
||||
try{localStorage.setItem('jiu-pub-theme',t);}catch(err){}
|
||||
paint();menu.hidden=true;
|
||||
});});
|
||||
document.addEventListener('click',function(){menu.hidden=true;});
|
||||
paint();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -89,6 +89,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
|
||||
inv.GET("", inventoryH.List)
|
||||
inv.GET("/summary", inventoryH.Summary)
|
||||
inv.GET("/logs", inventoryH.Logs)
|
||||
inv.PUT("/:id/status", inventoryH.UpdateStatus)
|
||||
inv.POST("/checks", inventoryH.CreateCheck)
|
||||
inv.GET("/checks/:id", inventoryH.GetCheck)
|
||||
|
||||
|
||||
@@ -134,6 +134,9 @@ type Inventory struct {
|
||||
StockInItemID *uint64 `json:"stock_in_item_id"`
|
||||
InventoryCheckID *uint64 `json:"inventory_check_id"`
|
||||
Quantity float64 `gorm:"type:decimal(12,3);not null;default:0" json:"quantity"`
|
||||
// Status 库存行三态(2026-07-07):stock=库存(默认,不公开) / on_sale=在售(公开酒单展示) /
|
||||
// sold=已售(出库审核扣光自动置入,留痕不软删)。stock⇄on_sale 走抽屉 switch;sold 仅出库流程写入。
|
||||
Status string `gorm:"type:enum('stock','on_sale','sold');not null;default:'stock';index:idx_inv_status" json:"status"`
|
||||
// Snapshot fields: copied from product/warehouse/stock-in at approval time.
|
||||
// They reflect the state at the moment of stock-in and are NOT updated when the
|
||||
// referenced product or warehouse record is later modified.
|
||||
|
||||
@@ -209,6 +209,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
inventory.GET("/summary", inventoryH.Summary)
|
||||
inventory.GET("/logs", inventoryH.Logs)
|
||||
inventory.PUT("/:id/remark", inventoryH.UpdateRemark)
|
||||
inventory.PUT("/:id/status", inventoryH.UpdateStatus)
|
||||
inventory.POST("/checks", inventoryH.CreateCheck)
|
||||
inventory.GET("/checks/:id", inventoryH.GetCheck)
|
||||
inventory.PUT("/checks/:id/complete", inventoryH.CompleteCheck)
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
// 库存三态(2026-07-07):出库卖光 → sold 留痕不软删;部分出库不改状态;退单恢复 → 回 stock。
|
||||
|
||||
func TestApproveStockOut_SellOutMarksSold(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "STS001")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
|
||||
p := testutil.CreateTestProduct(db, shop.ID, "茅台")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
|
||||
inv := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p.ID, Quantity: 5, Status: "on_sale"}
|
||||
require.NoError(t, db.Create(&inv).Error)
|
||||
|
||||
order := model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "OUT-STS-1",
|
||||
WarehouseID: wh.ID, OperatorID: user.ID, Status: "pending",
|
||||
OrderDate: model.Date{Time: time.Now()},
|
||||
Items: []model.StockOutItem{{ShopID: shop.ID, ProductID: p.ID, Quantity: 5}},
|
||||
}
|
||||
require.NoError(t, db.Create(&order).Error)
|
||||
require.NoError(t, NewStockService(db).ApproveStockOut(shop.ID, order.ID, user.ID))
|
||||
|
||||
// 卖光:行保留(deleted_at 空)、qty=0、status=sold
|
||||
var after model.Inventory
|
||||
require.NoError(t, db.Unscoped().Where("id = ?", inv.ID).First(&after).Error)
|
||||
assert.Equal(t, "sold", after.Status)
|
||||
assert.Equal(t, float64(0), after.Quantity)
|
||||
assert.Nil(t, after.DeletedAt, "卖光不再软删")
|
||||
}
|
||||
|
||||
func TestApproveStockOut_PartialKeepsStatus(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "STS002")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
|
||||
p := testutil.CreateTestProduct(db, shop.ID, "五粮液")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
|
||||
inv := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p.ID, Quantity: 10, Status: "on_sale"}
|
||||
require.NoError(t, db.Create(&inv).Error)
|
||||
|
||||
order := model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "OUT-STS-2",
|
||||
WarehouseID: wh.ID, OperatorID: user.ID, Status: "pending",
|
||||
OrderDate: model.Date{Time: time.Now()},
|
||||
Items: []model.StockOutItem{{ShopID: shop.ID, ProductID: p.ID, Quantity: 3}},
|
||||
}
|
||||
require.NoError(t, db.Create(&order).Error)
|
||||
require.NoError(t, NewStockService(db).ApproveStockOut(shop.ID, order.ID, user.ID))
|
||||
|
||||
var after model.Inventory
|
||||
require.NoError(t, db.Where("id = ?", inv.ID).First(&after).Error)
|
||||
assert.Equal(t, "on_sale", after.Status, "部分出库不改状态")
|
||||
assert.Equal(t, float64(7), after.Quantity)
|
||||
}
|
||||
|
||||
func TestReturnStockOut_RestoresToStock(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "STS003")
|
||||
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
|
||||
p := testutil.CreateTestProduct(db, shop.ID, "剑南春")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
|
||||
|
||||
inv := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p.ID, Quantity: 2, Status: "on_sale"}
|
||||
require.NoError(t, db.Create(&inv).Error)
|
||||
|
||||
order := model.StockOutOrder{
|
||||
TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: "OUT-STS-3",
|
||||
WarehouseID: wh.ID, OperatorID: user.ID, Status: "pending",
|
||||
OrderDate: model.Date{Time: time.Now()},
|
||||
Items: []model.StockOutItem{{ShopID: shop.ID, ProductID: p.ID, Quantity: 2, SalePrice: 100}},
|
||||
}
|
||||
require.NoError(t, db.Create(&order).Error)
|
||||
svc := NewStockService(db)
|
||||
require.NoError(t, svc.ApproveStockOut(shop.ID, order.ID, user.ID))
|
||||
|
||||
// 退单 → 新建的恢复行应为 stock(保守:需重新手动挂售)
|
||||
require.NoError(t, svc.ReturnStockOut(shop.ID, order.ID, user.ID, "admin", []uint64{order.Items[0].ID}))
|
||||
var restored model.Inventory
|
||||
require.NoError(t, db.Where("shop_id = ? AND product_id = ? AND quantity > 0 AND deleted_at IS NULL", shop.ID, p.ID).
|
||||
Order("id DESC").First(&restored).Error)
|
||||
assert.Equal(t, "stock", restored.Status)
|
||||
assert.Equal(t, float64(2), restored.Quantity)
|
||||
}
|
||||
@@ -411,7 +411,8 @@ func (s *StockService) ApproveStockOut(shopID, orderID, reviewerID uint64) error
|
||||
b := &batches[i]
|
||||
if b.Quantity <= remaining {
|
||||
remaining -= b.Quantity
|
||||
tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "deleted_at": now})
|
||||
// 卖光的批次置「已售」留痕(2026-07-07 三态改造:不再软删)
|
||||
tx.Model(b).Updates(map[string]interface{}{"quantity": 0, "status": "sold"})
|
||||
} else {
|
||||
tx.Model(b).Update("quantity", gorm.Expr("quantity - ?", remaining))
|
||||
remaining = 0
|
||||
|
||||
@@ -414,6 +414,7 @@ CREATE TABLE IF NOT EXISTS `inventories` (
|
||||
`stock_in_item_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`inventory_check_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`quantity` DECIMAL(12,3) NOT NULL DEFAULT 0,
|
||||
`status` ENUM('stock','on_sale','sold') NOT NULL DEFAULT 'stock' COMMENT '库存/在售(公开)/已售(卖光留痕)',
|
||||
`product_code` VARCHAR(50) DEFAULT NULL,
|
||||
`product_name` VARCHAR(200) DEFAULT NULL,
|
||||
`series` VARCHAR(100) DEFAULT NULL,
|
||||
@@ -434,7 +435,8 @@ CREATE TABLE IF NOT EXISTS `inventories` (
|
||||
KEY `idx_shop_wh_product` (`shop_id`, `warehouse_id`, `product_id`),
|
||||
KEY `idx_stock_in_item` (`stock_in_item_id`),
|
||||
KEY `idx_inventory_check` (`inventory_check_id`),
|
||||
KEY `idx_deleted_at` (`deleted_at`)
|
||||
KEY `idx_deleted_at` (`deleted_at`),
|
||||
KEY `idx_inv_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存批次记录';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
@@ -434,6 +434,7 @@ func SetupTestDB() *gorm.DB {
|
||||
stock_in_item_id INTEGER,
|
||||
inventory_check_id INTEGER,
|
||||
quantity REAL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'stock',
|
||||
product_code TEXT DEFAULT '',
|
||||
product_name TEXT DEFAULT '',
|
||||
series TEXT DEFAULT '',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,6 +30,7 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
|
||||
int? _warehouseId;
|
||||
String _keyword = '';
|
||||
String _code = ''; // 商品编码独立筛选(原型 codeInput)
|
||||
String _status = ''; // 三态筛选(空=后端默认排除已售)
|
||||
List<String> _series = [];
|
||||
List<String> _spec = [];
|
||||
PageResult<Inventory>? _cache;
|
||||
@@ -53,6 +54,7 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
|
||||
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<PageResult<Inventory>> {
|
||||
reload();
|
||||
}
|
||||
|
||||
void setStatus(String status) {
|
||||
_status = status;
|
||||
_page = 1;
|
||||
reload();
|
||||
}
|
||||
|
||||
void setSeries(List<String> series) {
|
||||
_series = series;
|
||||
_page = 1;
|
||||
|
||||
@@ -16,6 +16,7 @@ class InventoryRepository {
|
||||
String? code,
|
||||
List<String>? series,
|
||||
List<String>? 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<void> 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<void> updateRemark(int id, String remark) async {
|
||||
try {
|
||||
await _client.put('/inventory/$id/remark', data: {'remark': remark});
|
||||
|
||||
@@ -38,12 +38,19 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
Set<String> _filterWarehouse = {};
|
||||
Set<String> _filterSpec = {};
|
||||
Set<String> _filterSeries = {};
|
||||
// 库存状态前端筛选(派生自 qty vs 安全库存):全部/在售/预警/缺货
|
||||
// 库存三态筛选(2026-07-07 status 落库,服务端过滤):全部/在售/库存/已售;
|
||||
// 默认「全部」= 后端排除已售(已售仅点已售卡/筛选时显示)。预警缺货退出状态维度。
|
||||
String _statusFilter = '全部';
|
||||
static const _statusParam = {
|
||||
'全部': '',
|
||||
'在售': 'on_sale',
|
||||
'库存': 'stock',
|
||||
'已售': 'sold',
|
||||
};
|
||||
Set<String>? _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<InventoryListScreen> {
|
||||
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<InventoryListScreen> {
|
||||
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<InventoryListScreen> {
|
||||
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<InventoryListScreen> {
|
||||
selected: _statusFilter == s,
|
||||
caret: false,
|
||||
onTap: () {
|
||||
setState(() => _statusFilter = s);
|
||||
_applyStatusFilter(s);
|
||||
setSheet(() {});
|
||||
}),
|
||||
]),
|
||||
@@ -512,12 +538,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
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<InventoryListScreen> {
|
||||
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<InventoryListScreen> {
|
||||
?.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 ?? <String>{};
|
||||
@@ -739,12 +766,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
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<InventoryListScreen> {
|
||||
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 = <Widget>[];
|
||||
|
||||
@@ -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<bool>? 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,10 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
const Map<String, IconData> kStatusIcons = {
|
||||
// 库存状态
|
||||
// 库存状态(三态落库 2026-07-07:在售/库存/已售;预警/缺货为数量派生提示)
|
||||
'在售': LucideIcons.shoppingCart, // i-cart
|
||||
'库存': LucideIcons.box, // i-box
|
||||
'已售': LucideIcons.check, // i-check
|
||||
'预警': LucideIcons.triangleAlert, // i-alert
|
||||
'缺货': LucideIcons.x, // i-close
|
||||
// 单据状态
|
||||
|
||||
@@ -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<void> 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<void> showProductEditorDrawer(
|
||||
qty: qty,
|
||||
unit: unit,
|
||||
cost: cost,
|
||||
status: status),
|
||||
status: status,
|
||||
inventoryId: inventoryId,
|
||||
inventoryStatus: inventoryStatus,
|
||||
onStatusChanged: onStatusChanged),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -83,7 +91,10 @@ Future<void> 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<void> _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(
|
||||
|
||||
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 416 KiB |
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 422 KiB After Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 145 KiB After Width: | Height: | Size: 142 KiB |
@@ -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<Override> _overrides() => [
|
||||
stockValue: 2640000,
|
||||
inStockQty: 18920,
|
||||
shortageCount: 17,
|
||||
soldCount: 17,
|
||||
warningCount: 6,
|
||||
// 月初快照 → 环比 ▲2.3% / ▲1.1% / ▼0.6%(对齐原型示例文案)
|
||||
lastMonthSku: 1255,
|
||||
|
||||
@@ -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<Override> _overrides() => [
|
||||
stockValue: 2640000,
|
||||
inStockQty: 18920,
|
||||
shortageCount: 17,
|
||||
soldCount: 17,
|
||||
warningCount: 6,
|
||||
lastMonthSku: 1255,
|
||||
lastMonthValue: 2611276,
|
||||
|
||||
@@ -51,6 +51,7 @@ class _FakeInvRepo implements InventoryRepository {
|
||||
String? code,
|
||||
List<String>? series,
|
||||
List<String>? spec,
|
||||
String? status,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async =>
|
||||
|
||||
@@ -99,6 +99,7 @@ class _FakeInventoryRepository extends InventoryRepository {
|
||||
String? code,
|
||||
List<String>? series,
|
||||
List<String>? spec,
|
||||
String? status,
|
||||
int page = 1,
|
||||
int pageSize = 50,
|
||||
}) async {
|
||||
|
||||
@@ -549,6 +549,7 @@
|
||||
<tr><td class="mono">stock_in_item_id</td><td class="mono ty">BIGINT UNSIGNED</td><td class="ctr">是</td><td class="mono df">NULL</td><td>来源入库明细行。批次/生产日期显示优先从此关联取(sii),是本表的「真值上游」</td></tr>
|
||||
<tr><td class="mono">inventory_check_id</td><td class="mono ty">BIGINT UNSIGNED</td><td class="ctr">是</td><td class="mono df">NULL</td><td>若由盘点调整产生,指向盘点单 inventory_checks</td></tr>
|
||||
<tr><td class="mono">quantity</td><td class="mono ty">DECIMAL(12,3)</td><td class="ctr">否</td><td class="mono df">0</td><td>当前批次结存数量(出入库审核时在事务内增减)</td></tr>
|
||||
<tr><td class="mono">status</td><td class="mono ty">ENUM('stock','on_sale','sold')</td><td class="ctr">否</td><td class="mono df">'stock'</td><td>库存三态(2026-07-07):stock=库存(在库不公开,默认)/ on_sale=在售(公开酒单展示,抽屉 switch 手动挂售)/ sold=已售(出库审核扣光自动置入,<b>留痕不软删</b>;列表默认排除,点已售卡才显示)。公开店铺页只列 on_sale;索引 idx_inv_status</td></tr>
|
||||
<tr><td class="mono">product_code</td><td class="mono ty">VARCHAR(50)</td><td class="ctr">是</td><td class="mono df">NULL</td><td>商品编号快照(拷贝自入库时),product 缺失时兜底显示</td></tr>
|
||||
<tr><td class="mono">product_name</td><td class="mono ty">VARCHAR(200)</td><td class="ctr">是</td><td class="mono df">NULL</td><td>商品名称快照,兜底显示</td></tr>
|
||||
<tr><td class="mono">series</td><td class="mono ty">VARCHAR(100)</td><td class="ctr">是</td><td class="mono df">NULL</td><td>系列快照(语义=型号),兜底显示</td></tr>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>库存三态改造(在售/库存/已售)设计方案 — 酒库管理系统</title>
|
||||
<style>
|
||||
:root{
|
||||
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
|
||||
--success:#2E8B57; --success-bg:#E6F3EC; --warn:#B45309; --warn-bg:#FFF4E5; --accent:#8B2331;
|
||||
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
|
||||
}
|
||||
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
|
||||
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
|
||||
h1{font-size:22px;margin:0 0 4px;}
|
||||
h2{font-size:17px;margin:30px 0 12px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
|
||||
h3{font-size:14px;margin:18px 0 8px;color:var(--accent);}
|
||||
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
|
||||
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin:14px 0;}
|
||||
.lead{font-size:13.5px;color:var(--ink);}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0;}
|
||||
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border-bottom:1px solid var(--border);}
|
||||
td{padding:8px 10px;border-bottom:1px solid #EEF1F5;vertical-align:top;}
|
||||
.mono{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;}
|
||||
code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
|
||||
pre{background:#1E2430;color:#D8E0EC;border-radius:8px;padding:12px 14px;overflow:auto;font-size:12px;line-height:1.55;}
|
||||
.tag{display:inline-block;font-size:11px;padding:1px 8px;border-radius:10px;font-weight:600;}
|
||||
.tag.ok{background:var(--success-bg);color:var(--success);}
|
||||
.tag.info{background:var(--head);color:var(--primary);}
|
||||
.tag.gray{background:#EEF1F5;color:var(--muted);}
|
||||
.callout{border-left:4px solid var(--primary);background:#F0F6FF;padding:10px 14px;border-radius:4px;margin:12px 0;font-size:13px;}
|
||||
.callout.danger{border-color:var(--danger);background:var(--danger-bg);}
|
||||
.callout.warn{border-color:var(--warn);background:var(--warn-bg);}
|
||||
ul,ol{margin:6px 0;padding-left:22px;}
|
||||
li{margin:3px 0;font-size:13px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>库存三态改造:在售 / 库存 / 已售</h1>
|
||||
<div class="sub">2026-07-07 设计 · 用户需求:库存行持久化三态 + 公开酒单只挂「在售」+ 卖光留痕「已售」+ 抽屉 switch 切换(桌面/移动)</div>
|
||||
|
||||
<h2>一、现状与目标</h2>
|
||||
<div class="card lead">
|
||||
<p><b>现状</b>:库存「状态」是显示时派生的(在售/预警/缺货 = 数量 vs 安全库存),数据库无字段;出库扣减到 0 的批次被<b>软删除</b>,从列表彻底消失;公开店铺页展示<b>全部</b>有库存商品(3841 件全裸奔)。</p>
|
||||
<p><b>目标</b>:状态落库为三态,门店可控地把商品挂上/撤下公开酒单,卖光的序列号留痕可查。</p>
|
||||
<table>
|
||||
<tr><th>状态</th><th>DB 值</th><th>语义</th><th>进入方式</th><th>公开酒单</th></tr>
|
||||
<tr><td><span class="tag info">库存</span></td><td class="mono">stock</td><td>在库、不对外展示(默认)</td><td>入库审核默认 / switch 撤售 / 退单恢复</td><td>不显示</td></tr>
|
||||
<tr><td><span class="tag ok">在售</span></td><td class="mono">on_sale</td><td>在库、公开酒单可见</td><td>抽屉 switch 手动挂售</td><td><b>显示</b></td></tr>
|
||||
<tr><td><span class="tag gray">已售</span></td><td class="mono">sold</td><td>出库卖光,留痕台账</td><td>出库审核扣减到 0 自动置入(<b>不再软删</b>)</td><td>不显示</td></tr>
|
||||
</table>
|
||||
<p>流转:<code>stock ⇄ on_sale</code>(手动)→ <code>sold</code>(出库审核自动,不可手动进出);出库<b>退单</b>恢复数量时置回 <code>stock</code>(保守,需重新手动挂售)。盘点<b>盘亏</b>归零维持现状软删(灭失≠售出,不算已售)。</p>
|
||||
</div>
|
||||
|
||||
<div class="callout danger"><b>⚠️ 上线即时影响,需确认:</b>存量 3841 行全部迁移为「库存」(你拍板的口径)→ <b>公开店铺页上线瞬间清空</b>,之后靠门店逐个把商品切到「在售」才会出现在酒单里。这是预期行为(酒单从「全量裸奔」变「人工精选」),但要提前告知门店有这步运营动作。</div>
|
||||
|
||||
<h2>二、数据模型</h2>
|
||||
<div class="card lead">
|
||||
<ul>
|
||||
<li><code>inventories</code> 加列:<code>status enum('stock','on_sale','sold') NOT NULL DEFAULT 'stock'</code> + 索引 <code>idx_inv_shop_status(shop_id, status)</code>。<code>schema.sql</code> 与 model 同步,AutoMigrate 自动加列,存量行落默认值 <code>stock</code>——「现在所有状态改成库存」零迁移脚本达成。</li>
|
||||
<li><b>为什么不走 custom_fields JSON</b>(破例说明):这是参与列表筛选、公开页过滤、KPI 统计的核心查询列,需要索引与枚举约束,JSON 扩展列不适用。</li>
|
||||
<li>软删语义收窄:<code>deleted_at</code> 今后只表示「行不存在」(盘亏灭失/数据修正),「卖光」不再软删。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>三、后端改动</h2>
|
||||
<div class="card lead">
|
||||
<table>
|
||||
<tr><th>#</th><th>位置</th><th>改动</th></tr>
|
||||
<tr><td>1</td><td class="mono">model/stock.go + schema/schema.sql</td><td>Inventory 加 Status 字段;<code>docs/db-schema.html</code> 同步</td></tr>
|
||||
<tr><td>2</td><td class="mono">service/stock.go:414(出库 FIFO)</td><td>批次扣到 0:<code>quantity=0 + status='sold'</code>,<b>不再打 deleted_at</b>;部分扣减不改状态</td></tr>
|
||||
<tr><td>3</td><td class="mono">service/stock.go(出库退单)</td><td>恢复数量的行若为 sold → 置回 <code>stock</code>(并清 quantity 归还)</td></tr>
|
||||
<tr><td>4</td><td>新接口</td><td><code>PUT /api/v1/inventory/:id/status</code>,body <code>{"status":"on_sale"|"stock"}</code>——只收这两值(sold 拒 400);shop_id 归属校验;受 ReadOnly() 保护,operator 可用(店员挂酒单是日常操作)</td></tr>
|
||||
<tr><td>5</td><td>库存 List</td><td>返回 <code>status</code>;新增 <code>status=</code> 筛选参数(可多值逗号分隔);<b>默认排除已售</b>(2026-07-07 用户拍板:仅点「已售」KPI 卡或状态筛选选已售时才显示),显式传 <code>status=sold</code> 才返回已售行</td></tr>
|
||||
<tr><td>6</td><td>库存 Summary</td><td><code>sku_count / in_stock_qty / stock_value / 月初快照</code> 一律排除 sold;新增 <code>sold_count</code>;<code>shortage_count</code>(qty≤0 未删行)语义被 sold 取代 → 保留字段但预期归零</td></tr>
|
||||
<tr><td>7</td><td>公开接口 + SSR 店铺页</td><td><code>ListShopProducts</code>/<code>queryShopProducts</code> 的库存子查询加 <code>status='on_sale'</code>(qty>0 保留)——公开酒单只剩在售;<b>商品单页不过滤</b>(历史标签扫码永远可看,已售商品批次块自然消失)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>四、设计系统(L1 先行——switch 组件不存在)</h2>
|
||||
<div class="card lead">
|
||||
<ol>
|
||||
<li>原型 <code>.superpowers/prototype/atoms.css</code> 登记 <code>.switch</code> 组件(38×22 pill 轨道 + 圆点滑块,on=<code>--primary</code>、off=<code>--border</code>,禁用态 faint;全走 tokens)+ <code>index.html</code> 组件清单登记;<code>mobile-atoms.css</code> 如需尺寸变体一并登记。</li>
|
||||
<li>Flutter 对照新建 <code>client/lib/widgets/ds/ds_switch.dart</code>(颜色单源过 <code>check_ds_code</code> 闸)。</li>
|
||||
<li>状态徽章三态色复用现有 DsBadge tone:在售=ok 绿、库存=info 蓝;「已售」需中性灰 tone——确认 DsBadge 有无 neutral,无则原型 badge 先补 neutral 变体再同步。</li>
|
||||
<li>原型 <code>inventory.html / m-inventory.html</code>(同步态屏)状态列/徽章文案同步为三态。</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>五、前端改动(client)</h2>
|
||||
<div class="card lead">
|
||||
<table>
|
||||
<tr><th>#</th><th>位置</th><th>改动</th></tr>
|
||||
<tr><td>1</td><td class="mono">models/inventory.dart</td><td>加 <code>status</code> 解析(缺省 'stock' 容错旧响应)</td></tr>
|
||||
<tr><td>2</td><td>库存列表(桌面+移动)</td><td>状态列/卡片徽章改三态显示;状态筛选改「全部/在售/库存/已售」走后端参数;<b>预警/缺货退出状态列</b>——低于安全库存仍由数量列染色(既有 qty.low)承担;KPI 第四卡「缺货预警」改为「已售」(主数 sold_count,点击筛选已售;副行保留「需补货 N 项」预警数)</td></tr>
|
||||
<tr><td>3</td><td class="mono">product_editor_drawer.dart</td><td>「状态」行 = 三态徽章 + <b>徽章后紧跟 DsSwitch</b>(在售⇄库存);已售时只显示灰徽章无 switch;切换调 PUT 接口,成功即时更新徽章并刷新列表;readonly 用户 WriteGuard 禁用。桌面 drawer 与移动 sheet 同组件,一处生效</td></tr>
|
||||
<tr><td>4</td><td>_openEditor 调用</td><td>传入库存行 id 与 status(switch 操作对象=该 product 的库存行;序列号模型下 1 product≈1 行)</td></tr>
|
||||
<tr><td>5</td><td>golden</td><td>库存列表桌面/移动 + 抽屉相关 golden 重录(管理员形态)</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h2>六、兼容与发版顺序</h2>
|
||||
<div class="card lead">
|
||||
<ol>
|
||||
<li><b>server-v1.1.10</b> 先发:AutoMigrate 加列默认 stock;旧 client 无 switch、仍显示旧派生状态(升级窗口内两套口径并存,可接受);公开酒单即刻只剩在售(上线即空,见顶部提醒)。</li>
|
||||
<li><b>client-v1.1.6</b> 跟发:三态列表 + 抽屉 switch。</li>
|
||||
<li>回滚:server 回退上一 tag——新列留在库里无害;已置 sold 的行回退后在旧逻辑下表现为「qty=0 未删」即旧「缺货」态,数据不损。</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>七、测试与验收</h2>
|
||||
<div class="card lead">
|
||||
<ul>
|
||||
<li>单测:出库审核卖光 → 行 status=sold 且 deleted_at 为空;部分出库不改状态;退单恢复 → 回 stock;状态接口(合法值/拒 sold/跨租户 404/readonly 403);公开列表与 SSR 店铺页只含 on_sale;Summary 排除 sold + sold_count 正确。</li>
|
||||
<li>手工验收:抽屉切「在售」→ 公开酒单出现该商品;出库卖光 → 列表变「已售」灰徽章、酒单消失、KPI 已售 +1;已售商品抽屉无 switch;手机端 sheet 内 switch 可操作。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h2>八、明确不做(本期)</h2>
|
||||
<div class="card lead">
|
||||
<ul>
|
||||
<li>不做「批量挂售」(列表多选批量切在售)——首期逐个切,量大再加。</li>
|
||||
<li>不做已售行的自动归档/清理——台账语义,先留着观察增长。</li>
|
||||
<li>不动商品公开单页的可见性——历史标签永远可扫。</li>
|
||||
<li>盘亏归零不置已售——维持软删。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -38,6 +38,7 @@
|
||||
<h2>🏗 设计方案</h2>
|
||||
<ul>
|
||||
<li><a href="pay支付对接开发指南.html">pay 支付对接开发指南</a><span class="tag html">HTML</span> <span class="hint">— jiu 门店应用内购买/续费授权:付款走 pay,付成功后 pay 签名 webhook 回调 jiu 直接续期。含签名算法、下单/回调接口契约、套餐 biz_code→权益映射、续期逻辑、安全红线、客户端(Web+App webview)、联调清单。pay 侧已就绪,本文档=jiu 侧要实现的部分</span></li>
|
||||
<li><a href="design/inventory-sale-status.html">库存三态改造(在售/库存/已售)设计方案</a><span class="tag html">HTML</span> <span class="hint">— 库存行持久化状态 + 公开酒单只挂在售 + 卖光留痕已售 + 抽屉 switch(2026-07-07)</span></li>
|
||||
<li><a href="design/public-page-speedup.html">公开页提速方案(传输压缩 + 公开页全面去 Flutter 化)</a><span class="tag html">HTML</span> <span class="hint">— 扫码首开 30s 根因与两级修复:CI 预压缩+gzip_static+协商缓存 / 商品页+店铺列表页后端 SSR,公开动线零 Flutter(2026-07-07)</span></li>
|
||||
<li><a href="design/order-return-prototype.html">退单原型(已审核单据)</a><span class="tag html">HTML</span></li>
|
||||
<li><a href="design/order-print-layouts.html">出入库单打印排版方案(6 种黑白样式精选)</a><span class="tag html">HTML</span> · <a href="design/order-print-layouts.pdf">PDF</a></li>
|
||||
|
||||
@@ -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"
|
||||
|
||||