Compare commits

...

30 Commits

Author SHA1 Message Date
wangjia 39a2f14e0b chore: release client-v1.1.8
Design Source Checks / design-source (push) Successful in 56s
Deploy Client / build-client-web (push) Successful in 1m11s
Deploy Client / build-macos (push) Successful in 2m47s
Deploy Client / build-android (push) Successful in 9m55s
Deploy Client / build-ios (push) Successful in 4m6s
Deploy Client / build-windows (push) Successful in 2m47s
Deploy Client / release-deploy-client (push) Successful in 1m30s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:18:38 +08:00
wangjia 2c25bbfd85 feat(client): 库存移动卡片品名后缀生产日期 + 商品抽屉生产日期行
- 原型先行(L1):mobile-atoms 新增 .mc-nm .mc-date 原子(xs muted mono 左距8),
  index.html 登记,m-inventory 卡片模板与详情 sheet 同步补生产日期
- MCard 新增 nmSuffix 插槽镜像 .mc-date;库存窄屏卡片品名后缀生产日期小字
- Product 模型补 production_date(只读侧,不进 toJson);商品抽屉信息区
  新增「生产日期」行(分类后、当前库存前,mono)
- 重生成库存移动 golden ×6(inventory_list_mobile / m_inventory 三主题)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:56:43 +08:00
wangjia b20be72155 docs: 重画系统架构图——单脊柱布局替代五线乌鸦脚,补 Redis 节点
Design Source Checks / design-source (push) Successful in 53s
客户端五端收进分组条(单一竖线入 nginx),标签白底不再压线,
nginx 泳道排满不留空带,MySQL/Redis 并排(jiu_redis 上线后架构图欠账)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 22:27:28 +08:00
wangjia fb8337b156 ci: retrigger checks——pangolin runner 误挂 mac 标签抢跑已摘除
Design Source Checks / design-source (push) Successful in 1m2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 19:12:59 +08:00
wangjia 05203a9b5b refactor: 原型迁移 .superpowers/prototype → design/prototype(与 CONTRACT 同目录)
Design Source Checks / design-source (push) Failing after 14m23s
- git mv 全目录(历史保留);.gitignore 收敛为整个 .superpowers/ 忽略
- 全仓 16 处引用同步(CI checks.yml / l1-sync / screens.mjs / hooks /
  CLAUDE.md / CONTRACT / SSR 模板注释 / docs / web 注释)
- 修 pre-commit 路径正则残留;5180 评审服务已切新路径
- 验证:check-ds 12 道 / l1-sync 6 道 / fidelity 抽查全过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 18:47:31 +08:00
wangjia d1b7e8e2e9 refactor(client): 前端体系治理——漏斗退役/老卡片标 legacy/fidelity 收阈/文档口径修正
- DsFilterHeader 删除(筛选已全部上移工具栏 chips,屏内零引用)
- mobile_list_card 标 LEGACY 禁新用(6 屏迁移 MCard 登记 todo #13)
- fidelity 阈值按 2026-07-07 实测收紧:一刀切 8% → 各屏实测最大残差+2pp
  (4%~7%),新阈下 36 屏×主题全过;proto 基准同步重摄(含库存改版)
- CLAUDE.md 修正:表格筛选口径(工具栏 chips+列头仅排序)、响应式导航
  (Drawer 退役→底部 5 tab,修文字滞后)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 18:24:48 +08:00
wangjia 21a4a8cab7 ci: 补三道缺口闸——CI 强制 flutter test + SSR 模板 token 同源 + 徽章图标映射同集
- checks.yml 加 flutter test(DoD 写了但此前无闸,golden 回归全靠自觉;
  本地 /release client 门禁同步加了 flutter test,该文件 gitignore 不入库)
- check-l1-sync ⑤:backend 公开页模板内联 token 值须与原型 tokens.css
  对应主题块一致(模板自包含手拷值,此前仅注释约定零校验;已注入漂移自证)
- check-l1-sync ⑥:BADGE_ICON ↔ kStatusIcons 状态词同集(此前两端手工转录)
- 顺修 gen_tokens 旧路径注释(client/lib/core/theme/tool/ → client/tool/)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 18:24:34 +08:00
wangjia 8912b8ef7f docs: 前端开发全景文档——设计系统/工具链/各道闸/规则单一入口
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 17:51:07 +08:00
wangjia 33987faa9b chore(scripts): local_test.sh 过滤锁屏 iPhone 的 xcodebuild 握手噪音
DTDK passcode protected 每 3s 刷屏(构建 generic/platform=iOS 不需真机),
stderr 定向过滤堆栈噪音,真错误仍透传。

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 14:04:49 +08:00
wangjia 43576d6248 chore: release client-v1.1.6
Design Source Checks / design-source (push) Successful in 30s
Deploy Client / build-client-web (push) Successful in 58s
Deploy Client / build-macos (push) Successful in 3m1s
Deploy Client / build-android (push) Successful in 1m50s
Deploy Client / build-ios (push) Failing after 55s
Deploy Client / build-windows (push) Successful in 2m5s
Deploy Client / release-deploy-client (push) Has been skipped
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 13:39:38 +08:00
wangjia abe8f8e669 chore: release server-v1.1.10
Design Source Checks / design-source (push) Has been cancelled
Deploy Server / release-deploy-server (push) Successful in 1m0s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 13:38:54 +08:00
wangjia 65c672b4ad feat(client): 库存三态 UI——抽屉挂售 switch + 三态徽章/筛选/已售 KPI 卡
- 新组件 DsSwitch(原型 atoms .switch 对照,L1 已登记:38×22 pill,on=primary)
- 商品抽屉状态行:三态徽章后跟挂售 switch(在售⇄库存 即时生效,已售只读,
  readonly 禁用),桌面 drawer / 移动 sheet 同组件
- 列表状态列/卡片徽章三态化(在售绿/库存蓝/已售灰,icons BADGE_ICON 补两词);
  状态筛选改「全部/在售/库存/已售」走服务端;预警缺货退出状态维度由数量染色承担
- KPI 第四卡「缺货预警」→「已售」(sold_count,点击筛选已售)
- 原型同步:atoms/index 登记 switch 与 b-库存/b-已售、inventory 两屏三态数据、
  抽屉状态行 switch;12 道 ds 闸 + L1 同源闸全过
- golden 重录 ×9(三态徽章 + 已售卡形态)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 13:35:19 +08:00
wangjia 73955a139c feat(backend): 库存三态落库(stock/on_sale/sold)——挂售可控 + 卖光留痕
- inventories 加 status 枚举列(默认 stock,AutoMigrate 存量即全部「库存」)+ 索引
- 出库审核扣光置 sold 不再软删(留痕台账);退单恢复行置回 stock;盘亏仍软删
- 新接口 PUT /inventory/:id/status(仅收 stock/on_sale,已售行拒改,租户校验)
- List 默认排除已售(显式 status=sold 才显示)+ status 多值筛选
- Summary:SKU/货值/数量排除已售,新增 sold_count
- 公开店铺页/API 只列 on_sale——酒单从全量裸奔改为人工精选
- 测试:卖光置售/部分不改/退单回库/接口边界/跨租户/汇总口径 8 用例
- 设计方案 docs/design/inventory-sale-status.html + db-schema/CLAUDE.md 同步

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 13:35:03 +08:00
wangjia b0b38db39f fix(backend): 店铺公开页分享卡片标题去掉「岩美酒库」尾缀
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 12:15:38 +08:00
wangjia 7b14eb426b chore: release server-v1.1.9
Design Source Checks / design-source (push) Successful in 17s
Deploy Server / release-deploy-server (push) Successful in 57s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:57:09 +08:00
wangjia 0de9ba7c21 feat(backend): 公开页自适应满屏 + 主题切换对齐 App ThemePickerPill
- 页面 flex 撑满视口(100dvh):页眉贴顶、页脚贴底、信息区弹性填充,
  底部不再留大片空白;内容超屏时自然滚动(移动端上下滑不受影响)
- 主题切换从 ◐ 循环钮改为与 App 一致的衬衫图标 pill + 下拉单选
  (A·经典蓝 / B·琥珀 / C·酒窖,勾选态),图标 path 取自原型 icons.js #i-shirt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:55:08 +08:00
wangjia 47d6490bf2 feat(backend): 公开页对齐设计系统真源 + 三主题支持
- 弃用手编宣纸黄配色,变量名与色值逐字取自 prototype/tokens.css
- 支持 a 经典蓝浅 / b 琥珀深 / c 酒窖暖浅 三主题:默认 a,
  首访跟随系统深色自动切 b,右上 ◐ 钮循环切换并记忆(localStorage)
- 结构保留:画框图集+印章、全屏灯箱、分节卡、页脚署名/ICP

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:48:10 +08:00
wangjia 8000f00e9e feat(backend): 商品公开页图集升级——滑动指示点 + 点击全屏灯箱
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:41:24 +08:00
wangjia d8f7fd903e fix(backend): 公开页页脚合一行——技术支持:岩美科技 · 京ICP备2026039814号
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:37:58 +08:00
wangjia db9afb952c fix(backend): 商品公开页移除「在 App 中查看」链接
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:35:44 +08:00
wangjia f3efda3691 fix(backend): 公开页去平台背书措辞——页脚改 Powered by 岩美科技
题签只留「岩美酒库」,页脚「正品溯源」改为中性技术署名;
正品类表述仅保留在商品介绍等门店内容语境中。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:29:19 +08:00
wangjia d19e88e3f4 feat(backend): 公开页视觉改版——酒背标美学 + 页脚品牌链接/ICP + 图片区常驻
- 宣纸暖白底 × 宋体衬线题名 × 酒红「正品」印章,信息行点线引导(酒单式)
- 图片区永远存在:无图时显示酒瓶线稿占位「实物图整理中」
- 页脚:岩美酒库链接(jiu.51yanmei.com)+ 京ICP备2026039814号(链工信部)
- 仍零 JS、零外部资源,整页 ~9KB;入场动效纯 CSS,reduced-motion 降级

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:27:17 +08:00
155 changed files with 1920 additions and 374 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ export PATH="/opt/homebrew/bin:$PATH"
```
- **client**
```bash
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings && flutter test
```
- **site**
```bash
+9 -3
View File
@@ -1,8 +1,9 @@
name: Design Source Checks
# 设计真相源检查(治理规则见 CLAUDE.md「设计真相源分层」):
# PR 与合并 main 时自动跑四道闸,全部纯静态检查node + dart format
# 不跑 golden/fidelity(重型像素闸维持「定期体检」定位,本地手动跑)。
# PR 与合并 main 时自动跑四道静态闸(node + dart format+ flutter test
# (单测 + golden ×3 主题回归自比,2026-07-07 纳入——此前 DoD 写了但无闸)。
# fidelity 重型像素闸维持「定期体检」定位,本地手动跑。
on:
pull_request:
push:
@@ -23,7 +24,7 @@ jobs:
run: sh scripts/ci/provision-mac.sh
- name: 原型设计系统闸(check-ds 12 道)
run: node .superpowers/prototype/tools/check-ds.mjs
run: node design/prototype/tools/check-ds.mjs
- name: L1 真相源同源闸(tokens 快照 / icons / 官网 token 值 / web hex
run: node tools/check-l1-sync.mjs
@@ -37,3 +38,8 @@ jobs:
node tool/gen_tokens.mjs
dart format lib/core/theme/app_tokens.g.dart lib/core/theme/app_dims.g.dart lib/core/theme/app_chrome.g.dart
git diff --exit-code -- lib/core/theme/app_tokens.g.dart lib/core/theme/app_dims.g.dart lib/core/theme/app_chrome.g.dart
- name: Flutter 测试(单测 + golden ×3 主题回归自比)
run: |
cd client
flutter test
+2 -4
View File
@@ -67,10 +67,8 @@ backend/gencode
# iOS 证书私钥(绝不入库)
.ios-certs/
# superpowers 工作目录brainstorm/sdd 是草稿不入库;
# prototype 是设计像素真源 + 设计系统守门闸(check-ds.mjs),入库供 CI 跑。
.superpowers/*
!.superpowers/prototype/
# superpowers 工作目录(草稿不入库)。原型已迁 design/prototype/2026-07-07)。
.superpowers/
# design-distill 保真目检对比图(可重生,见 tools/ds-compare.mjs
design/_compare/
+28
View File
@@ -5,6 +5,34 @@ 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.8] - 2026-07-10
### 新功能
- 库存列表(手机端)商品名称后新增生产日期小字,一眼区分同名不同批次的商品。
- 商品详情抽屉新增「生产日期」信息行。
## [1.1.7] - 2026-07-07
### 新功能
- 库存列表列头排序:点击 库存 / 成本价 / 总价 / 生产日期 / 入库时间 列头
即可全局排序(升序 ↑ / 降序 ↓ / 再点恢复默认)。
### 改进
- 库存筛选全部移到工具栏(与出库管理同一形式):规格 / 系列 / 仓库 / 状态
改为工具栏下拉标签,可单独清除;列头不再放筛选漏斗。
## [1.1.6] - 2026-07-07
### 新功能
- 库存三态管理:状态列改为「在售 / 库存 / 已售」。商品详情抽屉的状态徽章旁
新增开关,一键把商品挂上/撤下公开酒单(桌面与手机都支持);只有挂「在售」
的商品才会出现在店铺公开页。
- 卖光的商品以「已售」留痕:点库存页「已售」统计卡查看,平时列表不显示。
### 改进
- 库存筛选改为「全部 / 在售 / 库存 / 已售」;低库存提示改由数量颜色标注
(红=缺货、橙=低于安全库存)。
## [1.1.5] - 2026-07-06
### 新功能
+30
View File
@@ -5,6 +5,36 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.11] - 2026-07-07
### 新功能
- 库存列表支持按 库存 / 成本价 / 总价 / 生产日期 / 入库时间 排序,
全局生效(跨分页),成本类按显示口径排序结果一致。
## [1.1.10] - 2026-07-07
### 新功能
- 库存三态管理:每条库存新增「库存 / 在售 / 已售」状态。商品卖光后不再从
列表消失,而是以「已售」留痕(默认隐藏,点「已售」统计卡查看);出库退单
自动恢复为「库存」。
- 公开店铺酒单改为人工精选:只展示手动挂「在售」的商品。升级后酒单为空,
需在库存详情里将要公开的商品切换为「在售」。
- 店铺分享卡片标题不再带「岩美酒库」尾缀。
### 改进
- 库存统计口径:SKU 总数、在库数量、库存货值不再计入已售留痕,新增已售数。
## [1.1.9] - 2026-07-07
### 改进
- 商品/店铺公开页全新视觉:配色与 App 设计系统同源,支持三主题
(经典蓝 / 琥珀深色 / 酒窖暖色),右上角衬衫图标下拉切换并记忆选择,
首次打开自动跟随手机深浅色模式。
- 商品图集升级:多图滑动带指示点,点击图片全屏查看、全屏内可滑动切换;
无图商品保留图片占位区不再空缺。
- 页面自适应满屏:页脚固定在屏幕底部,底部不再出现大片空白。
- 页脚展示「技术支持:岩美科技」与 ICP 备案号,移除「在 App 中查看」入口。
## [1.1.8] - 2026-07-07
### 新功能
+7 -6
View File
@@ -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 管理
@@ -256,7 +257,7 @@ cd client && flutter test
### 设计真相源分层(原型漂移治理,2026-07-04 拍板)
原型(`.superpowers/prototype/`)与真实页面**不追求全量永久同步**,按层分治:
原型(`design/prototype/`)与真实页面**不追求全量永久同步**,按层分治:
- **L1 设计系统层**`tokens.css` / `atoms.css` / `mobile-atoms.css` / `icons.js` + `index.html` 登记):原型是**单一真源**。新增颜色/组件/图标必须**先登记原型再同步**代码与官网(如 `web/_includes/icons.njk`)。闸:pre-commit `check-ds.mjs` 12 道(本地钩子,新机器先跑 `sh scripts/hooks/install.sh`+ 代码侧 `client/tool/check_ds_code.mjs`
- **L2 屏级三态**(台账 = `design/CONTRACT.md` 块 4「真相源」列):
@@ -267,16 +268,16 @@ cd client && flutter test
- **漂移体检**:不逐提交强制同步;大版本/整屏改版前跑 `node tools/fidelity.mjs`,按报告逐屏决定「重新对齐原型」或「降级快照」,结果回填 CONTRACT。
- **CI 闸**`.gitea/workflows/checks.yml`PR + main push 自动跑):① `check-ds.mjs` 原型 12 道;② `tools/check-l1-sync.mjs` L1 同源四道(tokens 快照逐字节 / icons 两端同集同内容 / 官网 token 值对齐原型主题 A / web 硬编码 hex,白名单 #fff#1677ff`ds-allow` 可豁免);③ `client/tool/check_ds_code.mjs` Flutter 颜色单源;④ codegen 新鲜度(regen→`dart format`→与入库产物零 diff)。本地可直接跑同名命令。
### 表格与列头筛选(ds 真相源)
- 列表屏统一 `DsTable``widgets/ds/ds_table.dart`toolbar+表格+pager 连成一卡),列定义 `DsColumn`,可筛选列走其列头漏斗(`filtered` 态高亮),可隐藏列走列设置菜单
- 不要在 toolbar 放独立筛选按钮,筛选入口内嵌列头或工具栏 `DsChip`
### 表格与筛选/排序ds 真相源)
- 列表屏统一 `DsTable``widgets/ds/ds_table.dart`toolbar+表格+pager 连成一卡),列定义 `DsColumn`,可隐藏列走列设置菜单
- **筛选统一放工具栏 `DsChip`**2026-07-07 用户口径,形式参照出库管理:单选/多选 chip + 选中值/N 项摘要 + × 单清);列头**只放排序** `DsSortHeader`(点击循环 无→升→降→无,服务端全局排序)。列头漏斗 `DsFilterHeader` 已退役删除
-`FilterableColumnHeader`/`multi_select_dropdown.dart` 仅存量引用,勿在新屏使用
### 响应式 / 移动端适配
- 断点判定统一用 `context.isMobile``client/lib/core/responsive/responsive.dart`,宽度 < 600 为窄屏/手机),**禁止**在各处散落 `MediaQuery.width < 600` 这类魔法数
- **列表屏**:统一用 `DsTable``widgets/ds/ds_table.dart`,真相源表格:toolbar+列+pager);窄屏卡片流`MobileListCard` / `MobileCardField``widgets/mobile_list_card.dart`)。`DataTableCard` 已删除(2026-07-03),勿再引用
- **列表屏**:统一用 `DsTable``widgets/ds/ds_table.dart`,真相源表格:toolbar+列+pager);窄屏卡片流用 `MCard``widgets/ds/m_card.dart`,镜像原型 mobile-atoms)。旧 `MobileListCard` 为 legacy 仅存量引用(迁移在 todo),`DataTableCard` 已删除(2026-07-03),勿再引用
- **弹窗固定宽度**:一律用 `context.dialogWidth(X)`(≤ 屏宽 92%),**禁止**裸写 `width: <固定值>` 导致窄屏溢出
- **导航**:窄屏(手机)用 Drawer 抽屉(`app_shell.dart`),宽屏保持侧边栏;新增页面无需单独处理,挂在 shell 下即可
- **导航**:窄屏(手机)= 底部 5 tab(库存/入库/出库/财务/我的)+「我的」hub 聚合二级屏(`app_shell.dart` + `m_tab_bar.dart`2026-07-04 起,Drawer 已退役);宽屏保持侧边栏;一切弹层窄屏统一走 `showMSheet` 底部 sheet
- 平台判断仍遵守上面「前端平台判断」规则(`kIsWeb` 先于 `dart:io`
### 权限中间件(已全局挂载)
+80 -6
View File
@@ -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")
@@ -137,11 +158,28 @@ func (h *InventoryHandler) List(c *gin.Context) {
return
}
// 排序(2026-07-07 列头排序):白名单列 → SQL 表达式,防注入;带 id 兜底保证分页稳定。
// cost/price/prod_date 用与 SELECT 相同的 COALESCE 口径,排序结果与显示一致。
orderExpr := "inv.id DESC"
if sortCol, ok := map[string]string{
"qty": "inv.quantity",
"cost": "COALESCE(sii.cost_price, inv.unit_price, p.purchase_price)",
"price": "inv.quantity * COALESCE(sii.cost_price, inv.unit_price, p.purchase_price)",
"prod_date": "COALESCE(DATE(sii.production_date), DATE(inv.production_date))",
"in_time": "inv.created_at",
}[c.Query("sort_by")]; ok {
dir := "ASC"
if c.Query("sort_dir") == "desc" {
dir = "DESC"
}
orderExpr = sortCol + " " + dir + ", inv.id DESC"
}
// Data query
dataSQL := `
SELECT
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,
@@ -162,7 +200,7 @@ func (h *InventoryHandler) List(c *gin.Context) {
LEFT JOIN products p ON p.id = inv.product_id AND p.deleted_at IS NULL
LEFT JOIN warehouses w ON w.id = inv.warehouse_id
WHERE ` + baseWhere + `
ORDER BY inv.id DESC
ORDER BY ` + orderExpr + `
LIMIT ? OFFSET ?`
dataArgs := append(args, pageSize, (page-1)*pageSize)
@@ -179,10 +217,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 +237,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 +443,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)
+125
View File
@@ -3,6 +3,7 @@ package handler
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
@@ -442,3 +443,127 @@ 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)
// Summarysku_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)
}
// 列头排序(2026-07-07):白名单列全局排序,非法列忽略回默认
func TestInventoryHandler_ListSort(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_SORT")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
for i, qty := range []float64{5, 20, 10} {
p := testutil.CreateTestProduct(db, shop.ID, fmt.Sprintf("酒%d", i))
price := float64(100 * (i + 1))
require.NoError(t, db.Create(&model.Inventory{
ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p.ID,
Quantity: qty, UnitPrice: &price,
}).Error)
}
qtys := func(w *httptest.ResponseRecorder) []float64 {
rows := parseResponse(w)["data"].([]interface{})
out := make([]float64, len(rows))
for i, r := range rows {
out[i] = r.(map[string]interface{})["quantity"].(float64)
}
return out
}
// qty 升序
w := makeRequest(r, "GET", "/api/v1/inventory?sort_by=qty&sort_dir=asc", token, nil)
assert.Equal(t, []float64{5, 10, 20}, qtys(w))
// qty 降序
w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=qty&sort_dir=desc", token, nil)
assert.Equal(t, []float64{20, 10, 5}, qtys(w))
// 总价 = qty×进价:5×100=500, 20×200=4000, 10×300=3000 → 升序 5,10,20
w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=price&sort_dir=asc", token, nil)
assert.Equal(t, []float64{5, 10, 20}, qtys(w))
// 非法列忽略 → 默认 id 倒序(最后插入的 qty=10 在前)
w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=evil;drop", token, nil)
assert.Equal(t, []float64{10, 20, 5}, qtys(w))
}
+3 -5
View File
@@ -87,10 +87,10 @@ func (h *PublicHandler) loadPublicProduct(publicID string) (*publicProductData,
// queryShopProducts 店铺公开商品列表查询——ListShopProductsJSON API)与 ShopPageSSR)共用。
// 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,
+2 -2
View File
@@ -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)
+1
View File
@@ -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 酒窖暖浅)色值
逐字取自 design/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)色值逐字取自 design/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)
+3
View File
@@ -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 走抽屉 switchsold 仅出库流程写入。
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.
+1
View File
@@ -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)
}
+2 -1
View File
@@ -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
+3 -1
View File
@@ -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='库存批次记录';
-- ------------------------------------------------------------
+1
View File
@@ -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 '',
+8
View File
@@ -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,
+4
View File
@@ -14,6 +14,8 @@ class Product {
final double? purchasePrice;
final double? salePrice;
final int? minStock;
// 生产日期(YYYY-MM-DD,后端 Date 序列化,空串=未填)
final String? productionDate;
final String? description;
final String? remark;
final Map<String, dynamic>? customFields;
@@ -41,6 +43,7 @@ class Product {
this.purchasePrice,
this.salePrice,
this.minStock,
this.productionDate,
this.description,
this.remark,
this.customFields,
@@ -75,6 +78,7 @@ class Product {
minStock: json['min_stock'] != null
? (json['min_stock'] as num).toInt()
: null,
productionDate: json['production_date'] as String?,
description: json['description'] as String?,
remark: json['remark'] as String?,
customFields: json['custom_fields'] as Map<String, dynamic>?,
@@ -30,6 +30,9 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
int? _warehouseId;
String _keyword = '';
String _code = ''; // 商品编码独立筛选(原型 codeInput)
String _status = ''; // 三态筛选(空=后端默认排除已售)
String _sortBy = ''; // 列头排序(空=默认 id 倒序)
bool _sortAsc = true;
List<String> _series = [];
List<String> _spec = [];
PageResult<Inventory>? _cache;
@@ -53,6 +56,9 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
warehouseId: _warehouseId,
keyword: _keyword.isEmpty ? null : _keyword,
code: _code.isEmpty ? null : _code,
status: _status.isEmpty ? null : _status,
sortBy: _sortBy.isEmpty ? null : _sortBy,
sortAsc: _sortAsc,
series: _series.isEmpty ? null : _series,
spec: _spec.isEmpty ? null : _spec,
page: _page,
@@ -89,6 +95,19 @@ class InventoryListNotifier extends AsyncNotifier<PageResult<Inventory>> {
reload();
}
void setStatus(String status) {
_status = status;
_page = 1;
reload();
}
void setSort(String sortBy, bool asc) {
_sortBy = sortBy;
_sortAsc = asc;
_page = 1;
reload();
}
void setSeries(List<String> series) {
_series = series;
_page = 1;
@@ -16,6 +16,9 @@ class InventoryRepository {
String? code,
List<String>? series,
List<String>? spec,
String? status, // 三态筛选:stock/on_sale/sold(逗号可多值);空=后端默认(排除已售)
String? sortBy, // 列头排序:qty/cost/price/prod_date/in_time(后端白名单)
bool sortAsc = true,
int page = 1,
int pageSize = 50,
}) async {
@@ -29,6 +32,11 @@ 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,
if (sortBy != null && sortBy.isNotEmpty) ...{
'sort_by': sortBy,
'sort_dir': sortAsc ? 'asc' : 'desc',
},
};
final resp = await _client.get('/inventory', params: params);
return PageResult.fromJson(
@@ -67,6 +75,18 @@ class InventoryRepository {
}
}
/// 库存⇄在售 挂售切换(status 仅收 stock/on_salesold 由出库流程专属)
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,48 @@ 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] ?? '');
}
// ── 列头排序(点击循环 无→升→降→无,服务端全局排序) ──
String _sortBy = ''; // 列 key(屏内),空=未排序
bool _sortAsc = true;
static const _sortParam = {
'qty': 'qty',
'cost': 'cost',
'price': 'price',
'prodDate': 'prod_date',
'inTime': 'in_time',
};
void _toggleSort(String key) {
setState(() {
if (_sortBy != key) {
_sortBy = key;
_sortAsc = true;
} else if (_sortAsc) {
_sortAsc = false;
} else {
_sortBy = '';
_sortAsc = true;
}
});
ref
.read(inventoryListProvider.notifier)
.setSort(_sortBy.isEmpty ? '' : _sortParam[_sortBy]!, _sortAsc);
}
// 成本仅管理员可见(与出库同口径):非管理员隐藏成本价/总价列、卡片单价、货值 KPI。
bool get _isAdmin => ref.watch(isAdminProvider);
bool _colAllowed(ColDef c) =>
@@ -149,10 +203,28 @@ 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);
}
// ── 多选漏斗(规格/系列/仓库 ──
// ── 工具栏多选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移 ──
Widget _multiChip(String label, List<String> options, Set<String> selected,
ValueChanged<Set<String>> onChanged) {
return Builder(
builder: (chipCtx) => DsChip(
label: label,
value: selected.isEmpty
? null
: selected.length == 1
? selected.first
: '${selected.length}',
onTap: () => _openMultiFilter(chipCtx,
options: options, selected: selected, onChanged: onChanged),
onClear: () => onChanged({}),
),
);
}
// ── 多选菜单(规格/系列/仓库,chip 锚定) ──
void _openMultiFilter(
BuildContext anchorContext, {
required List<String> options,
@@ -223,7 +295,7 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
selected: _statusFilter == s,
caret: false,
onTap: () {
setState(() => _statusFilter = s);
_applyStatusFilter(s);
setSheet(() {});
}),
]),
@@ -512,12 +584,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 +598,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,
@@ -540,6 +613,8 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
return MCard(
onTap: item.productId != null ? () => _openEditor(item) : null,
nm: item.productName.isEmpty ? '-' : item.productName,
// 生产日期小字缀在品名后(原型 .mc-date2026-07-10 用户口径)
nmSuffix: (item.productionDate ?? '').isEmpty ? null : item.productionDate,
sub: sub.isEmpty ? null : sub,
badges: [DsTextIconBadge(status)],
amtWidget: Text.rich(
@@ -626,11 +701,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 +814,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 +855,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>[];
@@ -853,11 +928,40 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
);
}
// 桌面 toolbar合一搜索框 + 重置
// 状态筛选 chip 已按用户口径删除,状态列漏斗保留)
// 桌面 toolbar搜索 + 筛选 chips + 重置
// 2026-07-07 用户口径反转:筛选全部上移工具栏,形式
// 参照出库管理;列头只留排序,漏斗退役)
return Row(
children: [
SizedBox(width: 260, child: searchField),
const SizedBox(width: AppDims.sp2),
_multiChip('规格', specOptions, _filterSpec, (v) {
setState(() => _filterSpec = v);
ref
.read(inventoryListProvider.notifier)
.setSpec(v.toList());
}),
const SizedBox(width: AppDims.sp2),
_multiChip('系列', seriesOptions, _filterSeries, (v) {
setState(() => _filterSeries = v);
ref
.read(inventoryListProvider.notifier)
.setSeries(v.toList());
}),
const SizedBox(width: AppDims.sp2),
_multiChip('仓库', warehouseOptions, _filterWarehouse,
(v) => setState(() => _filterWarehouse = v)),
const SizedBox(width: AppDims.sp2),
Builder(
builder: (chipCtx) => DsChip(
label: '状态',
value: _statusFilter == '全部'
? null
: _statusFilter,
onTap: () => _openStatusMenu(chipCtx),
onClear: () => _applyStatusFilter('全部'),
),
),
const Spacer(),
DsButton('重置', small: true, onPressed: _clearFilters),
],
@@ -872,45 +976,18 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
},
emptyText: '没有匹配的商品 · 试试调整筛选或搜索',
columns: visibleCols.map((c) {
// 筛选已全部上移工具栏 chips(2026-07-07),列头只留排序。
final Widget? header = switch (c.key) {
'spec' => DsFilterHeader(
// 可排序列(原型 COLS sort:true,服务端全局排序)
'qty' ||
'cost' ||
'price' ||
'prodDate' ||
'inTime' =>
DsSortHeader(
label: c.label,
filtered: _filterSpec.isNotEmpty,
onOpen: (bctx) => _openMultiFilter(bctx,
options: specOptions,
selected: _filterSpec, onChanged: (v) {
setState(() => _filterSpec = v);
ref
.read(inventoryListProvider.notifier)
.setSpec(v.toList());
}),
),
'series' => DsFilterHeader(
label: c.label,
filtered: _filterSeries.isNotEmpty,
onOpen: (bctx) => _openMultiFilter(bctx,
options: seriesOptions,
selected: _filterSeries, onChanged: (v) {
setState(() => _filterSeries = v);
ref
.read(inventoryListProvider.notifier)
.setSeries(v.toList());
}),
),
'warehouse' => DsFilterHeader(
label: c.label,
filtered: _filterWarehouse.isNotEmpty,
onOpen: (bctx) => _openMultiFilter(bctx,
options: warehouseOptions,
selected: _filterWarehouse,
onChanged: (v) =>
setState(() => _filterWarehouse = v)),
),
// 原型 COLS 里带 filter 的列只有状态(单选)。
'status' => DsFilterHeader(
label: c.label,
filtered: _statusFilter != '全部',
onOpen: _openStatusMenu,
ascending: _sortBy == c.key ? _sortAsc : null,
onTap: () => _toggleSort(c.key),
),
_ => null,
};
+55
View File
@@ -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)),
],
),
),
),
),
),
),
);
}
}
+37 -26
View File
@@ -479,38 +479,49 @@ class _DsTableState extends State<DsTable> {
}
}
/// 原型 thead th .fh 漏斗列头:label + funnel(12, sw1.6),有筛选值时整体 primary
/// 点击回调携带列头自身 BuildContext(供 showDsMenu 锚定)
class DsFilterHeader extends StatelessWidget {
/// 原型 thead th .sh 排序列头(2026-07-07 登记):label + 方向箭头
/// 未排序 = 淡 chevron-down 暗示可排;激活整体 primaryasc=↑ / desc=↓
/// 点击由调用方循环 无→升→降→无(服务端全局排序)。
class DsSortHeader extends StatelessWidget {
final String label;
final bool filtered;
final void Function(BuildContext anchorContext) onOpen;
const DsFilterHeader(
{super.key,
required this.label,
required this.filtered,
required this.onOpen});
/// null = 未按本列排序;true = 升序;false = 降序
final bool? ascending;
final VoidCallback onTap;
const DsSortHeader(
{super.key, required this.label, this.ascending, required this.onTap});
@override
Widget build(BuildContext context) {
final t = context.tokens;
final color = filtered ? t.primary : t.muted;
return Builder(
builder: (bctx) => InkWell(
onTap: () => onOpen(bctx),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: color)),
const SizedBox(width: 5),
Icon(LucideIcons.funnel, size: 12, color: color),
],
),
final on = ascending != null;
final color = on ? t.primary : t.muted;
return InkWell(
onTap: onTap,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(
fontSize: AppDims.fsSm,
fontWeight: FontWeight.w600,
color: color)),
const SizedBox(width: 4),
Opacity(
opacity: on ? 1 : .45,
child: Icon(
ascending == true
? LucideIcons.chevronUp
: LucideIcons.chevronDown,
size: 12,
color: color,
),
),
],
),
);
}
}
// DsFilterHeader(列头漏斗筛选)已于 2026-07-07 退役删除:筛选统一上移工具栏
// chips(用户口径,形式参照出库管理),列头仅保留 DsSortHeader 排序。
+30 -7
View File
@@ -101,6 +101,9 @@ class MCard extends StatelessWidget {
/// mc-nm 标题(600 heading)。
final String nm;
/// mc-date 标题后缀(原型 .mc-nm .mc-datexs muted mono,左距 8);null 不渲染。
final String? nmSuffix;
/// mc-sub 副行(xs muted mono);null 不渲染。
final String? sub;
@@ -119,6 +122,7 @@ class MCard extends StatelessWidget {
const MCard({
super.key,
required this.nm,
this.nmSuffix,
this.sub,
this.badges = const [],
this.amt,
@@ -151,13 +155,32 @@ class MCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(nm,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.heading)),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Flexible(
child: Text(nm,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: AppDims.fsBody,
fontWeight: FontWeight.w600,
color: t.heading)),
),
if (nmSuffix != null) ...[
const SizedBox(width: 8),
Text(nmSuffix!,
style: TextStyle(
fontSize: AppDims.fsXs,
color: t.muted,
fontFamily: AppFonts.mono,
fontFamilyFallback:
AppFonts.monoFallback)),
],
],
),
if (sub != null) ...[
const SizedBox(height: 3),
Text(sub!,
+3 -1
View File
@@ -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
// 单据状态
+5
View File
@@ -1,6 +1,11 @@
import 'package:flutter/material.dart';
import '../core/theme/context_tokens.dart';
// ⚠️ LEGACY2026-07-07 标注):本组件为旧移动卡片范式(label:value 竖排字段),
// 新范式 = widgets/ds/m_card.dart 的 MCard(镜像原型 mobile-atoms .m-card
// 图标徽章 + 双栏紧凑)。**新屏禁用本组件,一律用 MCard**;存量 6 屏
// (出入库录单/往来/盘点/基础数据/财务)迁移已登记 todo,迁完删除本文件。
/// 移动端列表卡片的单个字段(label: value)。
class MobileCardField {
final String label;
+81 -10
View File
@@ -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 / soldswitch 初始态)
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;
// 库存三态挂售 switchstock⇄on_salesold 只读无 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;
@@ -351,6 +394,9 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
if ((p.series ?? '').isNotEmpty) rows.add(('系列', b(p.series!)));
if ((p.spec ?? '').isNotEmpty) rows.add(('规格', b(p.spec!)));
if ((p.categoryName ?? '').isNotEmpty) rows.add(('分类', b(p.categoryName!)));
if ((p.productionDate ?? '').isNotEmpty) {
rows.add(('生产日期', b(p.productionDate!, mono: true)));
}
if (widget.qty != null) {
rows.add((
'当前库存',
@@ -366,15 +412,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(
Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 422 KiB

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 156 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,9 @@ class _FakeInvRepo implements InventoryRepository {
String? code,
List<String>? series,
List<String>? spec,
String? status,
String? sortBy,
bool sortAsc = true,
int page = 1,
int pageSize = 50,
}) async =>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

After

Width:  |  Height:  |  Size: 389 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 KiB

After

Width:  |  Height:  |  Size: 443 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 435 KiB

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 KiB

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

After

Width:  |  Height:  |  Size: 374 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 277 KiB

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 KiB

After

Width:  |  Height:  |  Size: 439 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 383 KiB

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 362 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

After

Width:  |  Height:  |  Size: 355 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 362 KiB

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

After

Width:  |  Height:  |  Size: 407 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 400 KiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 401 KiB

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 218 KiB

@@ -99,6 +99,9 @@ class _FakeInventoryRepository extends InventoryRepository {
String? code,
List<String>? series,
List<String>? spec,
String? status,
String? sortBy,
bool sortAsc = true,
int page = 1,
int pageSize = 50,
}) async {
+4 -4
View File
@@ -11,11 +11,11 @@
| 项 | 位置 | 说明 |
|---|---|---|
| token 单源 | `.superpowers/prototype/tokens.css` | 三主题 `[data-theme=a/b/c]`,已 snapshot 到 `client/lib/core/theme/token_source/tokens.css` |
| token 单源 | `design/prototype/tokens.css` | 三主题 `[data-theme=a/b/c]`,已 snapshot 到 `client/lib/core/theme/token_source/tokens.css` |
| token codegen | `client/lib/core/theme/tool/gen_tokens.mjs``app_tokens.g.dart` | 单源生成,**禁手改**`kTokensA/B/C` + `appTokensOf(key)` |
| 实现层 | `themes.dart`buildTheme+ `context_tokens.dart``context.tokens` | AppTokens ThemeExtension 挂三 ThemeData |
| 原型清单 | `.superpowers/prototype/screens/*.html` | 桌面 + `m-*` 移动;viewport 桌面 1280×860 / 移动 390×844 |
| 设计铁律 | `.superpowers/prototype/index.html`atoms 注册)+ `tools/check-ds.mjs` 闸 | 颜色只走 token;组件出自已登记 atoms |
| 原型清单 | `design/prototype/screens/*.html` | 桌面 + `m-*` 移动;viewport 桌面 1280×860 / 移动 390×844 |
| 设计铁律 | `design/prototype/index.html`atoms 注册)+ `tools/check-ds.mjs` 闸 | 颜色只走 token;组件出自已登记 atoms |
| 验收工具 | `~/.claude/skills/design-distill/tools/{shoot-prototype,diff}.mjs` | prototype shoot 已验证三主题可出图 |
### 主题
@@ -206,7 +206,7 @@ AppTokens 目前**仅颜色**。原型还驱动:
> - 关于:旧版 帮助文档/扫码防伪/系统信息 卡删除,构建号并入版本 cell;更新日志走 `/public/release`(与官网时间线同源),接口不可达回退只显当前版本。
**验收法(HTML→Flutter 特例)**HTML(Chromium) 与 Flutter 渲染器不同,**严格 0.1% pixelmatch 不适用跨渲染器**(见 design-distill `references/flutter-golden.md`)。本项目双闸:
1. **保真闸(人工目检)**`node tools/ds-compare.mjs --html .superpowers/prototype/screens/<screen>.html --prefix <prefix>` → 截原型基准 + 与 Flutter golden 用 montage 并排 → `design/_compare/`
1. **保真闸(人工目检)**`node tools/ds-compare.mjs --html design/prototype/screens/<screen>.html --prefix <prefix>` → 截原型基准 + 与 Flutter golden 用 montage 并排 → `design/_compare/`
2. **回归闸(自动)**`test/golden/<screen>_golden_test.dart``pumpGolden`/`goldenAcrossThemes``test/support/golden_harness.dart`)出 A/B/C 三主题 golden,入库随 `flutter test` 同渲染器自比,抓串色/漏 token。
3. token 残留闸:`grep 0xFF…` 该屏为 0(颜色全走 `context.tokens`)。
@@ -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=primaryoff=borderdisabled 半透明 */
.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);}
@@ -125,6 +135,12 @@ th.act,td.act{width:78px;}
thead th .fh{display:inline-flex; align-items:center; gap:5px; cursor:pointer;}
thead th .fh.filtered{color:var(--primary);}
.funnel{width:12px; height:12px; stroke-width:1.6;}
/* 列头排序2026-07-07label+方向箭头未排序淡 chevron-down 暗示可排激活整体 primary
点击循环 服务端全局排序 */
thead th .sh{display:inline-flex; align-items:center; gap:4px; cursor:pointer; user-select:none;}
thead th .sh.on{color:var(--primary);}
.sort-ic{width:12px; height:12px; stroke-width:2;}
thead th .sh:not(.on) .sort-ic{opacity:.45;}
tbody td{padding:12px 14px; font-size:var(--fs-body); border-bottom:1px solid var(--border-subtle); color:var(--text); white-space:nowrap;}
tbody tr:last-child td{border-bottom:none;}
tbody tr.click{cursor:pointer;} tbody tr:hover{background:var(--row-hover);}
@@ -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>
@@ -430,7 +437,7 @@
<section class="section" id="m-comp">
<h2>移动专属组件 .m-*</h2><p class="desc">来自 <code>mobile-atoms.css</code> 的移动专属原子,由 <code>mobile-shell.js</code> 装配外壳。<b>复用上方公用原子</b>(按钮/徽章/分段/输入),此处只展示移动新增的 <code>.m-*</code></p>
<div style="margin:0 0 16px"><a class="btn ghost" href="screens/mobile-kit.html"><svg viewBox="0 0 24 24"><use href="#i-ic62"/></svg>打开移动 UI Kit · 全部 .m-* 实机展示</a></div>
<div class="pcard"><div class="pcard-h"><h3>移动壳 + 列表(实机预览)</h3><span class="tk">.m-top · .m-tabbar · .m-page · .m-kpi · .m-search · .m-filters/.m-pill · .m-card/.m-row · .m-fab</span></div>
<div class="pcard"><div class="pcard-h"><h3>移动壳 + 列表(实机预览)</h3><span class="tk">.m-top · .m-tabbar · .m-page · .m-kpi · .m-search · .m-filters/.m-pill · .m-card/.m-row.mc-nm 可带 .mc-date 日期后缀)· .m-fab</span></div>
<div class="pcard-b" style="align-items:flex-start"><div class="mdemo">
<div class="mphone">
<div class="m-top"><div class="m-title">库存</div><div class="m-actions"><div class="m-ic"><svg viewBox="0 0 24 24"><use href="#i-shirt"/></svg></div><div class="m-ic"><svg viewBox="0 0 24 24"><use href="#i-bell"/></svg><span class="m-dot">3</span></div><div class="m-av"></div></div></div>
@@ -444,7 +451,7 @@
<div class="m-search" style="margin-top:12px"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input placeholder="商品名 / 拼音 / 编码"></div>
<div class="m-filters"><div class="m-pill on">全部</div><div class="m-pill">酱香</div><div class="m-pill">浓香</div><div class="m-pill">清香</div></div>
<div class="m-section">商品 · 共 10</div>
<a class="m-card"><div class="m-row"><div class="mc-main"><div class="mc-nm">茅台 飞天 53°</div><div class="mc-sub">MT-FT-500 · 500ml×6</div></div><div class="mc-right"><span class="badge b-在售">在售</span><span class="mc-amt">128 件</span></div><div class="mc-chev"><svg viewBox="0 0 24 24"><use href="#i-chevron-right"/></svg></div></div></a>
<a class="m-card"><div class="m-row"><div class="mc-main"><div class="mc-nm">茅台 飞天 53°<span class="mc-date">2024-07-11</span></div><div class="mc-sub">MT-FT-500 · 500ml×6</div></div><div class="mc-right"><span class="badge b-在售">在售</span><span class="mc-amt">128 件</span></div><div class="mc-chev"><svg viewBox="0 0 24 24"><use href="#i-chevron-right"/></svg></div></div></a>
<a class="m-card"><div class="m-row"><div class="mc-main"><div class="mc-nm">剑南春 水晶剑</div><div class="mc-sub">JNC-SJ-500 · 500ml×6</div></div><div class="mc-right"><span class="badge b-缺货">缺货</span><span class="mc-amt">8 件</span></div><div class="mc-chev"><svg viewBox="0 0 24 24"><use href="#i-chevron-right"/></svg></div></div></a>
</div></div>
<nav class="m-tabbar">
@@ -42,6 +42,7 @@
.m-row{display:flex; align-items:center; gap:12px;}
.m-row .mc-main{min-width:0; flex:1;}
.m-row .mc-nm{font-weight:600; color:var(--heading); font-size:var(--fs-body); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.m-row .mc-nm .mc-date{font-weight:400; font-size:var(--fs-xs); color:var(--muted); font-family:var(--font-mono); margin-left:8px;}
.m-row .mc-sub{font-size:var(--fs-xs); color:var(--muted); margin-top:3px; font-family:var(--font-mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.m-row .mc-right{display:flex; flex-direction:column; align-items:flex-end; gap:5px; flex:none;}
.m-row .mc-amt{font-weight:700; color:var(--heading); font-size:var(--fs-body); font-family:var(--font-mono);}
@@ -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,11 +54,15 @@
<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>
<div class="searchbox code"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input id="codeInput" placeholder="商品编码" oninput="state.code=this.value; state.page=1; render()"></div>
<!-- 搜索合一(2026-07-07 用户口径):名称/拼音/编码同框,与 Flutter 实现一致 -->
<div class="searchbox"><svg viewBox="0 0 24 24"><use href="#i-search"/></svg><input id="searchInput" placeholder="商品名 / 拼音 / 编码" oninput="state.q=this.value; state.page=1; render()"></div>
<!-- 2026-07-07 用户口径:筛选全部上移工具栏(参照出库管理),列头漏斗退役、只留排序 -->
<div class="chip" id="chipSpec" onclick="openDimFilter('spec',this)">规格 <span class="cv" id="specVal"></span><svg viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></div>
<div class="chip" id="chipSeries" onclick="openDimFilter('series',this)">系列 <span class="cv" id="seriesVal"></span><svg viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></div>
<div class="chip" id="chipWh" onclick="openDimFilter('wh',this)">仓库 <span class="cv" id="whVal"></span><svg viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></div>
<div class="chip" id="chipStatus">状态 <span class="cv" id="statusVal"></span><svg viewBox="0 0 24 24"><use href="#i-chevron-down"/></svg></div>
<div class="sp"></div>
<div class="btn ghost sm" onclick="clearFilters()">重置</div>
@@ -101,31 +105,32 @@
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 COLS=[{key:'product',label:'商品',fixed:true},{key:'spec',label:'规格'},{key:'series',label:'系列'},{key:'batch',label:'批次号'},{key:'qty',label:'库存',num:true},{key:'cost',label:'成本价',num:true},{key:'price',label:'总价',num:true},{key:'prodDate',label:'生产日期'},{key:'inDate',label:'入库时间'},{key:'supplier',label:'供应商'},{key:'status',label:'状态',filter:'status'},{key:'act',label:'操作',fixed:true}];
const state={q:'',code:'',status:'全部',page:1,perPage:10,hidden:new Set()};
const STATUS=['全部','在售','库存','已售'];
const COLS=[{key:'product',label:'商品',fixed:true},{key:'spec',label:'规格'},{key:'series',label:'系列'},{key:'batch',label:'批次号'},{key:'qty',label:'库存',num:true,sort:true},{key:'cost',label:'成本价',num:true,sort:true},{key:'price',label:'总价',num:true,sort:true},{key:'prodDate',label:'生产日期',sort:true},{key:'inDate',label:'入库时间',sort:true},{key:'supplier',label:'供应商'},{key:'status',label:'状态'},{key:'act',label:'操作',fixed:true}];
const state={q:'',code:'',status:'全部',page:1,perPage:10,hidden:new Set(),sortBy:null,sortAsc:true};
function setActive(k){ document.querySelectorAll('.nav[data-k]').forEach(e=>e.classList.toggle('active',e.dataset.k===k)); }
function navTo(k){ setActive(k); const inv=document.querySelector('[data-view="inventory"]'), ph=document.querySelector('[data-view="placeholder"]'); if(k==='inventory'){inv.classList.add('active');ph.classList.remove('active');return;} inv.classList.remove('active');ph.classList.add('active'); const nav=NAVS.find(n=>n.k===k); document.getElementById('phTitle').textContent=nav.label; document.getElementById('phIcon').innerHTML=navSvg(nav.d); }
function visibleCols(){ return COLS.filter(c=>!state.hidden.has(c.key)); }
function filtered(){ return ITEMS.filter(it=>{ if(state.status!=='全部'&&it.status!==state.status)return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q))return false;} if(state.code){const c=state.code.toLowerCase(); if(!it.code.toLowerCase().includes(c))return false;} return true; }); }
function filtered(){ const rows=ITEMS.filter(it=>{ if(state.status!=='全部'&&it.status!==state.status)return false; if(state.spec&&it.spec!==state.spec)return false; if(state.series&&it.series!==state.series)return false; if(state.wh&&it.supplier!==state.wh)return false; if(state.q){const q=state.q.toLowerCase(); if(!it.name.toLowerCase().includes(q)&&!it.code.toLowerCase().includes(q))return false;} return true; }); if(state.sortBy&&SORT_VAL[state.sortBy]){const v=SORT_VAL[state.sortBy];rows.sort((a,b)=>{const x=v(a),y=v(b);return (x<y?-1:x>y?1:0)*(state.sortAsc?1:-1);});} return rows; }
function render(){
document.getElementById('statusVal').textContent=state.status==='全部'?'':state.status;
[['spec','chipSpec','specVal'],['series','chipSeries','seriesVal'],['wh','chipWh','whVal']].forEach(([k,cid,vid])=>{ document.getElementById(vid).textContent=state[k]||''; document.getElementById(cid).classList.toggle('on',!!state[k]); });
document.getElementById('chipStatus').classList.toggle('on',state.status!=='全部');
const cols=visibleCols();
document.getElementById('thead').innerHTML='<tr>'+cols.map(c=>{ const cls=(c.num?'num ':'')+(c.key==='act'?'act ':''); if(c.filter){const on=state.status!=='全部'; return `<th class="${cls}"><span class="fh ${on?'filtered':''}" onclick="openFilter('${c.filter}',this)">${c.label} <svg class="funnel" viewBox="0 0 24 24"><use href="#i-ic22"/></svg></span></th>`;} return `<th class="${cls}">${c.label}</th>`; }).join('')+'</tr>';
document.getElementById('thead').innerHTML='<tr>'+cols.map(c=>{ const cls=(c.num?'num ':'')+(c.key==='act'?'act ':''); if(c.filter){const on=state.status!=='全部'; return `<th class="${cls}"><span class="fh ${on?'filtered':''}" onclick="openFilter('${c.filter}',this)">${c.label} <svg class="funnel" viewBox="0 0 24 24"><use href="#i-ic22"/></svg></span></th>`;} if(c.sort){const on=state.sortBy===c.key; const ic=!on?'i-chevron-down':(state.sortAsc?'i-ic51':'i-chevron-down'); return `<th class="${cls}"><span class="sh ${on?'on':''}" onclick="toggleSort('${c.key}')">${c.label} <svg class="sort-ic" viewBox="0 0 24 24"><use href="#${ic}"/></svg></span></th>`;} return `<th class="${cls}">${c.label}</th>`; }).join('')+'</tr>';
const rows=filtered(); const start=(state.page-1)*state.perPage; const pageRows=rows.slice(start,start+state.perPage); const tb=document.getElementById('tbody');
if(rows.length===0){ tb.innerHTML=`<tr><td colspan="${cols.length}"><div class="empty">没有匹配的商品 · 试试调整筛选或搜索</div></td></tr>`; }
else { tb.innerHTML=pageRows.map(it=>{ const idx=ITEMS.indexOf(it); return '<tr class="click" onclick="openProduct('+idx+')">'+cols.map(c=>{ if(c.key==='product')return `<td><div class="pname">${it.name}</div><div class="pcode">${it.code}</div></td>`; if(c.key==='qty')return `<td class="num"><span class="qty ${it.qty<=10?'low':''}">${it.qty}</span></td>`; if(c.key==='prodDate')return `<td style="font-family:var(--font-mono);color:var(--text)">${it.prodDate}</td>`; if(c.key==='inDate')return `<td style="font-family:var(--font-mono);color:var(--muted)">${it.inDate}</td>`; if(c.key==='status')return `<td><span class="badge b-${it.status}">${it.status}</span></td>`; if(c.key==='act')return `<td class="act"><div class="row-act" onclick="event.stopPropagation()"><svg viewBox="0 0 24 24" onclick="openProduct(${idx})"><use href="#i-eye"/></svg></div></td>`; return `<td class="${c.num?'num':''}">${it[c.key]}</td>`; }).join('')+'</tr>'; }).join(''); }
@@ -138,9 +143,15 @@ const PAGE_SIZES=[10,20,50,100];
function openPageSize(e){ openMenu(e.currentTarget, PAGE_SIZES.map(n=>({v:String(n),label:String(n),sel:n===state.perPage})), v=>{ closeMenus(); setPerPage(v); }, 'pgsize-menu', 88); }
function setPerPage(v){ state.perPage=+v; state.page=1; const el=document.getElementById('pageSizeVal'); if(el)el.textContent=v; render(); }
function filterStatus(s){ state.status=s; state.page=1; navTo('inventory'); render(); }
function clearFilters(){ state.q='';state.code='';state.status='全部';state.page=1; document.getElementById('searchInput').value=''; document.getElementById('codeInput').value=''; render(); }
// 列头排序:无→升→降→无 循环(真实实现为服务端全局排序)
function toggleSort(k){ if(state.sortBy!==k){state.sortBy=k;state.sortAsc=true;} else if(state.sortAsc){state.sortAsc=false;} else {state.sortBy=null;state.sortAsc=true;} state.page=1; render(); }
const SORT_VAL={qty:it=>it.qty,cost:it=>parseFloat(String(it.cost).replace(/[¥,]/g,''))||0,price:it=>parseFloat(String(it.price).replace(/[¥,]/g,''))||0,prodDate:it=>it.prodDate||'',inDate:it=>it.inDate||''};
function clearFilters(){ state.q='';state.status='全部';state.spec=null;state.series=null;state.wh=null;state.page=1; document.getElementById('searchInput').value=''; render(); }
function openFilter(kind,t){ openMenu(t,STATUS.map(v=>({v,label:v,sel:v===state.status})),v=>{ state.status=v; state.page=1; closeMenus(); render(); }); }
// 维度筛选 chip(规格/系列/仓库,2026-07-07 从列头漏斗上移;真实实现为多选)
const DIM_KEY={spec:'spec',series:'series',wh:'supplier'};
function openDimFilter(dim,t){ const key=DIM_KEY[dim]; const opts=['全部',...new Set(ITEMS.map(it=>it[key]).filter(Boolean))]; openMenu(t,opts.map(v=>({v,label:v,sel:(state[dim]||'全部')===v})),v=>{ state[dim]=v==='全部'?null:v; state.page=1; closeMenus(); render(); }); }
document.getElementById('chipStatus').onclick=e=>openFilter('status',e.currentTarget);
const COL_KEY='jiu-inv-hidden';
function saveHidden(){ try{ localStorage.setItem(COL_KEY, JSON.stringify([...state.hidden])); }catch(e){} }
@@ -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>
@@ -36,16 +36,16 @@
<script src="mobile-shell.js"></script>
<script>
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:'国窖 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:'泸州老窖 特曲',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:'郎酒 红花郎 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:'在售'},
{name:'茅台 飞天 53°',code:'MT-FT-500',spec:'500ml×6',series:'飞天',batch:'PZ240711',prod:'2024-07-11',qty:128,unit:'件',cost:'¥2,680',supplier:'贵州茅台经销',status:'在售'},
{name:'五粮液 普五 52°',code:'WLY-PW-500',spec:'500ml×6',series:'普五',batch:'PZ240428',prod:'2024-04-28',qty:64,unit:'件',cost:'¥1,050',supplier:'宜宾五粮液',status:'在售'},
{name:'剑南春 水晶剑',code:'JNC-SJ-500',spec:'500ml×6',series:'水晶剑',batch:'PZ231115',prod:'2023-11-15',qty:8,unit:'件',cost:'¥438',supplier:'绵竹剑南春',status:'已售'},
{name:'国窖 1573',code:'GJ-1573-500',spec:'500ml×6',series:'1573',batch:'PZ240208',prod:'2024-02-08',qty:42,unit:'件',cost:'¥980',supplier:'泸州老窖',status:'在售'},
{name:'洋河 梦之蓝 M6',code:'YH-M6-500',spec:'550ml×6',series:'梦之蓝M6',batch:'PZ240520',prod:'2024-05-20',qty:23,unit:'件',cost:'¥620',supplier:'洋河股份',status:'库存'},
{name:'泸州老窖 特曲',code:'LZLJ-TQ-500',spec:'500ml×6',series:'特曲',batch:'PZ240630',prod:'2024-06-30',qty:156,unit:'件',cost:'¥320',supplier:'泸州老窖',status:'在售'},
{name:'习酒 窖藏 1988',code:'XJ-JC1988',spec:'500ml×6',series:'窖藏1988',batch:'PZ230918',prod:'2023-09-18',qty:31,unit:'件',cost:'¥760',supplier:'贵州习酒',status:'在售'},
{name:'汾酒 青花 20',code:'FJ-QH20-500',spec:'500ml×6',series:'青花20',batch:'PZ240112',prod:'2024-01-12',qty:0,unit:'件',cost:'¥420',supplier:'山西杏花村',status:'已售'},
{name:'郎酒 红花郎 15',code:'LJ-HHL15',spec:'500ml×6',series:'红花郎15',batch:'PZ240325',prod:'2024-03-25',qty:54,unit:'件',cost:'¥560',supplier:'四川郎酒',status:'在售'},
{name:'西凤酒 旗舰版',code:'XF-QJ-500',spec:'500ml×6',series:'旗舰版',batch:'PZ240614',prod:'2024-06-14',qty:88,unit:'件',cost:'¥280',supplier:'陕西西凤',status:'在售'},
];
const state={q:''};
@@ -57,7 +57,7 @@ function render(){
if(!rows.length){ L.innerHTML=`<div class="m-empty"><svg viewBox="0 0 24 24"><use href="#i-box"/></svg>没有匹配的商品 · 调整筛选或搜索</div>`; return; }
L.innerHTML=rows.map((it,i)=>`<a class="m-card" onclick="openItem(${ITEMS.indexOf(it)})">
<div class="m-row">
<div class="mc-main"><div class="mc-nm">${it.name}</div><div class="mc-sub">${it.code} · ${it.spec} · ${it.cost}</div></div><!-- 单价=成本进价,仅管理员可见(2026-07-06 -->
<div class="mc-main"><div class="mc-nm">${it.name}<span class="mc-date">${it.prod}</span></div><div class="mc-sub">${it.code} · ${it.spec} · ${it.cost}</div></div><!-- 单价=成本进价,仅管理员可见(2026-07-06 -->
<div class="mc-right"><span class="badge b-${it.status}">${it.status}</span><span class="mc-amt"><span class="${it.qty<=10?'qty-low':''}">${it.qty}</span> ${it.unit}</span></div>
<div class="mc-chev"><svg viewBox="0 0 24 24"><use href="#i-chevron-right"/></svg></div>
</div>
@@ -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 style="font-family:var(--font-mono)">${it.prod}</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>

Some files were not shown because too many files have changed in this diff Show More