Compare commits

..

17 Commits

Author SHA1 Message Date
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
wangjia dbea2dc11a chore: release server-v1.1.8
Design Source Checks / design-source (push) Successful in 17s
Deploy Server / release-deploy-server (push) Successful in 56s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:06:52 +08:00
wangjia 58a0bbca1b docs: 公开页提速设计方案(压缩A′ + 公开页去Flutter化)+ 登记索引
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:02:21 +08:00
wangjia 451947c8d1 devops: nginx 运行时 gzip + /app 协商缓存 + /shop SSR 页边缘闸
- gzip on(js/wasm/json/css,图片刻意不压):Web 版首包 12.5MB → ~4.4MB
- /app/ 加 Cache-Control no-cache(etag 304 协商,产物无 hash 不做长缓存)
- 新增 /shop/ location:与 shops API 同参数(jiu_shoplist 2r/s + UA 拦截 + limit_conn 10)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:02:21 +08:00
wangjia 9ea38a9b56 feat(backend): 公开页 SSR 化——商品页/店铺页后端直出,顾客动线零 Flutter
- /product/:public_id 从「Flutter 壳注入 OG」改为 go:embed 模板直出轻量页
  (~5KB vs 12.5MB 首包,3Mbps 带宽下 30s → <0.5s),OG 分享卡片行为不变
- 新增 /shop/:shop_code 店铺公开页短链:搜索(?q=)+分页(?page=),商品卡互跳
- 数据装配与公开 API 共用(loadPublicProduct / queryShopProducts),口径零漂移
- 图片异步拉取:首图 decoding=async、其余 loading=lazy
- 设计:docs/design/public-page-speedup.html

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-07 11:02:10 +08:00
wangjia 656a7459d8 chore: release client-v1.1.5
Design Source Checks / design-source (push) Successful in 16s
Deploy Client / build-client-web (push) Successful in 52s
Deploy Client / build-windows (push) Successful in 2m9s
Deploy Client / build-macos (push) Successful in 3m0s
Deploy Client / build-android (push) Successful in 1m47s
Deploy Client / build-ios (push) Successful in 3m23s
Deploy Client / release-deploy-client (push) Successful in 1m38s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:34:08 +08:00
wangjia 31bf645c30 chore: release server-v1.1.7
Design Source Checks / design-source (push) Successful in 16s
Deploy Server / release-deploy-server (push) Successful in 1m3s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:33:17 +08:00
wangjia 3573f015c3 fix(client): 出入库录单 golden 随日期漂移——表单支持钉死单据日期
根因:表单 _orderDate 默认 DateTime.now(),golden 07-04 录制后每天必挂
(整屏 diff 只有日期格一个数字)。表单加 initialDate 测试注入口,
golden 测试钉死 2026-07-04,基准图零重录、永不随日期漂移。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:26 +08:00
wangjia d9f1f6956e feat(client): 库存卡片显示单价(进价)+ 成本按角色隐藏
- 手机库存卡副行追加进价(编号 · 规格 · ¥xxx),仅管理员可见
- 宽屏成本价/总价列、货值 KPI、商品抽屉成本行对非管理员隐藏(isAdminProvider)
- m-inventory 原型同步 + 移动 golden ×6 重录(管理员形态基准)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:15 +08:00
wangjia 75d0accb14 feat(backend): 库存成本仅管理员可见——List 抹空 unit_price、Summary 抹零货值
与出库 stripStockOutCost 同口径:operator/readonly 服务端兜底抹除,
防抓包看到成本;sale_price 与数量等字段不受影响。CLAUDE.md 口径同步。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJ1g8XV1YhhmHRzhwWEW7o
2026-07-06 08:11:05 +08:00
29 changed files with 1536 additions and 191 deletions
@@ -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}</div></div>
<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-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>
+12
View File
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.5] - 2026-07-06
### 新功能
- 库存列表显示单价:手机端卡片副行、商品详情均可看到进价(仅管理员可见)。
- iOS 版新增首次启动隐私政策同意弹窗,「我的」页面新增注销账号入口。
### 改进
- 库存成本全面按角色隐藏:操作员/只读账号不再显示成本价、总价、库存货值
等成本信息(与出库单成本可见性同一口径)。
- iOS 版授权管理页改为「联系客服」入口(适配 App Store 上架要求)。
- 客服邮箱统一为 support@51yanmei.com(登录页、关于页、反馈入口)。
## [1.1.4] - 2026-07-05
### 新功能
+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.9] - 2026-07-07
### 改进
- 商品/店铺公开页全新视觉:配色与 App 设计系统同源,支持三主题
(经典蓝 / 琥珀深色 / 酒窖暖色),右上角衬衫图标下拉切换并记忆选择,
首次打开自动跟随手机深浅色模式。
- 商品图集升级:多图滑动带指示点,点击图片全屏查看、全屏内可滑动切换;
无图商品保留图片占位区不再空缺。
- 页面自适应满屏:页脚固定在屏幕底部,底部不再出现大片空白。
- 页脚展示「技术支持:岩美科技」与 ICP 备案号,移除「在 App 中查看」入口。
## [1.1.8] - 2026-07-07
### 新功能
- 商品公开页全面提速:扫标签二维码/微信分享打开的商品页改为服务端直出轻量页面
(约 5KB),首次打开从 30 秒左右降到 1 秒内,不再需要加载完整应用。
- 新增店铺公开页 `jiu.51yanmei.com/shop/门店编码`:展示本店全部在售商品,
支持搜索(汉字/拼音/编号)与翻页,可作为「店铺码」印刷或发给客户;
商品页与店铺页互相跳转,微信分享卡片正常。
### 改进
- 服务器开启传输压缩:网页版应用首次加载体积从 12.5MB 降到约 4.4MB
接口列表响应也随之变小;图片链路维持原有压缩与 30 天缓存。
## [1.1.7] - 2026-07-06
### 改进
- 库存成本仅管理员可见:库存列表不再向操作员/只读账号返回进价,库存货值
汇总同步隐藏(与出库单成本可见性同一口径,防止非管理员通过接口看到成本)。
## [1.1.6] - 2026-07-05
### 新功能
+1 -1
View File
@@ -221,7 +221,7 @@ cd client && flutter test
### 定价字段口径(2026-07 消歧后,唯一口径)
- 明细两侧价格分列:`cost_price`(成本/进价单价)+ `cost_amount`(成本小计);出库另有 `sale_price`(售价)+ `sale_amount`(售价小计,待定价=0)。**旧列 unit_price/total_price/total_amount 已弃用**(值已回填新列,观察一版后 DROP),新代码禁止读写旧列。
- 单据合计:入库 `cost_total`(应付=Σ总进价);出库 `sale_total`(应收=Σ售价小计)+ `profit_total`(总利润=Σ(售价>0?(售价-成本)×数量:0),建单落库,确认售价/确认进价联动重算,重算函数 `recalcStockOutProfit`)。
- **成本与利润仅管理员可见**:出库 List/Get 对 operator/readonly 服务端抹零(`stripStockOutCost`),前端同时隐藏对应列——新增出库相关展示时两侧都要遵守。
- **成本与利润仅管理员可见**:出库 List/Get 对 operator/readonly 服务端抹零(`stripStockOutCost`),前端同时隐藏对应列——新增出库相关展示时两侧都要遵守。**库存同口径**2026-07-06):库存 List 抹空 unit_price、Summary 抹零 stock_value/last_month_value`stripInventoryCost`),前端隐藏成本价/总价列、卡片单价、货值 KPI(`isAdminProvider`)、商品抽屉成本行。
- 财务口径不变:应收/应付、退单冲账均按上述合计列;出库退单展示与冲账同为售价口径。
### 库存变更
+17
View File
@@ -47,6 +47,17 @@ type inventoryRow struct {
CreatedAt string `json:"created_at"`
}
// stripInventoryCost 服务端兜底:库存成本仅管理员可见(与出库 stripStockOutCost 同口径)。
// operator/readonly 的 List 响应把 unit_price(成本进价)置空,sale_price 不受影响。
func stripInventoryCost(role string, rows []inventoryRow) {
if role == "admin" || role == "superadmin" {
return
}
for i := range rows {
rows[i].UnitPrice = nil
}
}
// List GET /api/v1/inventory
func (h *InventoryHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
@@ -162,6 +173,7 @@ func (h *InventoryHandler) List(c *gin.Context) {
return
}
stripInventoryCost(middleware.GetRole(c), rows)
c.JSON(http.StatusOK, gin.H{"data": rows, "total": total, "page": page, "page_size": pageSize})
}
@@ -210,6 +222,11 @@ func (h *InventoryHandler) Summary(c *gin.Context) {
util.RespondError(c, http.StatusInternalServerError, "QUERY_ERROR", "查询失败")
return
}
// 库存货值 = Σ(qty×进价),成本口径,仅管理员可见(同 stripInventoryCost
if role := middleware.GetRole(c); role != "admin" && role != "superadmin" {
s.StockValue = 0
s.LastMonthValue = 0
}
c.JSON(http.StatusOK, s)
}
@@ -393,3 +393,52 @@ func TestInventoryHandler_AfterStockInApprove(t *testing.T) {
invItem := data[0].(map[string]interface{})
assert.Equal(t, float64(20), invItem["quantity"])
}
// 库存成本仅管理员可见(stripInventoryCost):operator/readonly 的 List unit_price 抹空、
// Summary 货值抹零;admin 原样返回。与出库 stripStockOutCost 同口径。
func TestInventoryHandler_CostVisibility(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "INV_COST")
admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
operator := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator")
readonly := testutil.CreateTestUser(db, shop.ID, "ro", "pass", "readonly")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
product := testutil.CreateTestProduct(db, shop.ID, "Beer")
r := setupProtectedRouter(db)
price := 100.0
require.NoError(t, db.Create(&model.Inventory{
ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID,
Quantity: 10, UnitPrice: &price,
}).Error)
// adminunit_price / stock_value 原样可见
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
w := makeRequest(r, "GET", "/api/v1/inventory", adminToken, nil)
assert.Equal(t, http.StatusOK, w.Code)
row := parseResponse(w)["data"].([]interface{})[0].(map[string]interface{})
assert.Equal(t, float64(100), row["unit_price"])
w = makeRequest(r, "GET", "/api/v1/inventory/summary", adminToken, nil)
assert.Equal(t, float64(1000), parseResponse(w)["stock_value"].(float64))
// operator / readonlyunit_price 抹空、货值抹零,其余字段不受影响
for _, u := range []struct {
id uint64
role string
}{{operator.ID, "operator"}, {readonly.ID, "readonly"}} {
token := getAuthToken(u.id, shop.ID, u.role)
w = makeRequest(r, "GET", "/api/v1/inventory", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
row = parseResponse(w)["data"].([]interface{})[0].(map[string]interface{})
assert.Nil(t, row["unit_price"], "role=%s unit_price 应被抹空", u.role)
assert.Equal(t, float64(10), row["quantity"])
w = makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil)
resp := parseResponse(w)
assert.Equal(t, float64(0), resp["stock_value"].(float64), "role=%s 货值应抹零", u.role)
assert.Equal(t, float64(0), resp["last_month_value"].(float64))
assert.Equal(t, float64(1), resp["sku_count"].(float64))
assert.Equal(t, float64(10), resp["in_stock_qty"].(float64))
}
}
+29 -171
View File
@@ -5,15 +5,12 @@ import (
"fmt"
"html"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
)
@@ -32,75 +29,42 @@ func NewPublicHandler(db *gorm.DB) *PublicHandler {
}
// GetProduct GET /api/v1/public/products/:public_id (no auth)
// 数据装配走 loadPublicProduct(与 SSR 商品页共用,公开字段口径单一实现)。
func (h *PublicHandler) GetProduct(c *gin.Context) {
publicID := c.Param("public_id")
var product model.Product
if err := h.db.Where("public_id = ? AND deleted_at IS NULL", publicID).
Preload("Images").
Preload("Origin").
Preload("ShelfLife").
Preload("Storage").
Preload("DescriptionDoc").
First(&product).Error; err != nil {
d, err := h.loadPublicProduct(c.Param("public_id"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
product := d.Product
// Fetch shop public info
var shop model.Shop
shopData := gin.H{}
if err := h.db.Where("id = ?", product.ShopID).First(&shop).Error; err == nil {
if d.HasShop {
shopData = gin.H{
"name": shop.Name,
"code": shop.Code,
"address": shop.Address,
"phone": shop.Phone,
"business_hours": shop.BusinessHours,
"wechat_id": shop.WechatID,
"name": d.Shop.Name,
"code": d.Shop.Code,
"address": d.Shop.Address,
"phone": d.Shop.Phone,
"business_hours": d.Shop.BusinessHours,
"wechat_id": d.Shop.WechatID,
}
}
// Fetch latest inventory batch for this product
var inv model.Inventory
batchData := gin.H(nil)
if err := h.db.Where("product_id = ? AND quantity > 0 AND deleted_at IS NULL", product.ID).
Order("created_at DESC").
First(&inv).Error; err == nil {
if d.Batch != nil {
var pdStr *string
if inv.ProductionDate != nil {
s := inv.ProductionDate.Time.Format("2006-01-02")
if d.Batch.ProductionDate != nil {
s := d.Batch.ProductionDate.Time.Format("2006-01-02")
pdStr = &s
}
batchData = gin.H{
"production_date": pdStr,
"batch_no": inv.BatchNo,
"in_stock_date": inv.CreatedAt.Format("2006-01-02"),
"quantity": inv.Quantity,
"batch_no": d.Batch.BatchNo,
"in_stock_date": d.Batch.CreatedAt.Format("2006-01-02"),
"quantity": d.Batch.Quantity,
}
}
// 产地:空串表示无,前端不展示该行
origin := ""
if product.Origin != nil {
origin = product.Origin.Name
}
// 保质期:无关联时用默认话术
shelfLife := defaultShelfLife
if product.ShelfLife != nil {
shelfLife = product.ShelfLife.Name
}
// 储存方式:无关联时用默认话术
storage := defaultStorage
if product.Storage != nil {
storage = product.Storage.Name
}
// 介绍三级回退:描述文档 → 旧 Description → 通用中性兜底
descTitle, descBody, descKeywords := buildDescription(product)
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"id": product.ID,
@@ -112,12 +76,12 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
"brand": product.Brand,
"unit": product.Unit,
"sale_price": product.SalePrice,
"description": descBody,
"description_title": descTitle,
"description_keywords": descKeywords,
"origin": origin,
"shelf_life": shelfLife,
"storage": storage,
"description": d.DescBody,
"description_title": d.DescTitle,
"description_keywords": d.Keywords,
"origin": d.Origin,
"shelf_life": d.ShelfLife,
"storage": d.Storage,
"images": product.Images,
"shop": shopData,
"batch": batchData,
@@ -237,75 +201,13 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
pageSize = 20
}
// 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。
stockSub := h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0", shop.ID).
Group("product_id")
query := h.db.Model(&model.Product{}).
Joins("JOIN (?) AS stk ON stk.product_id = products.id", stockSub).
Where("products.shop_id = ? AND products.public_id IS NOT NULL AND products.public_id != '' AND products.deleted_at IS NULL", shop.ID)
var total int64
if err := query.Count(&total).Error; err != nil {
// 查询装配走 queryShopProducts(与 SSR 店铺页共用);API 不开放 keyword(现状不变)
listData, total, err := h.queryShopProducts(shop.ID, page, pageSize, "")
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var products []model.Product
offset := (page - 1) * pageSize
if err := query.Preload("Images").
Offset(offset).
Limit(pageSize).
Order("products.id DESC").
Find(&products).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 取本页商品的在库总量(IN 限定在本页 ≤pageSize 个 id,开销小)
qtyMap := make(map[uint64]float64, len(products))
if len(products) > 0 {
pageIDs := make([]uint64, len(products))
for i, p := range products {
pageIDs[i] = p.ID
}
var stockRows []struct {
ProductID uint64
Qty float64
}
h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND product_id IN ?", shop.ID, pageIDs).
Group("product_id").
Scan(&stockRows)
for _, s := range stockRows {
qtyMap[s.ProductID] = s.Qty
}
}
listData := make([]publicProductResp, len(products))
for i, p := range products {
imgs := make([]publicProductImage, len(p.Images))
for j, img := range p.Images {
imgs[j] = publicProductImage{URL: img.URL}
}
listData[i] = publicProductResp{
ID: p.ID,
PublicID: p.PublicID,
Code: p.Code,
Name: p.Name,
Series: p.Series,
Spec: p.Spec,
Brand: p.Brand,
Unit: p.Unit,
SalePrice: p.SalePrice,
Quantity: qtyMap[p.ID],
Images: imgs,
}
}
c.JSON(http.StatusOK, gin.H{
"data": listData,
"total": total,
@@ -314,51 +216,7 @@ func (h *PublicHandler) ListShopProducts(c *gin.Context) {
})
}
// ProductPage GET /product/:public_id
// 返回注入了基础 Open Graph 标签的 Flutter index.html,供微信/飞书等社交平台爬虫生成分享卡片。
// 找不到商品时原样返回 index.html,让 Flutter 自行展示"商品不存在";爬虫拿不到 OG 标签但页面不报错。
func (h *PublicHandler) ProductPage(c *gin.Context) {
publicID := c.Param("public_id")
// 读取 Flutter 构建产物 index.html
idxPath := config.C.Storage.WebDir + "/index.html"
idxBytes, err := os.ReadFile(idxPath)
if err != nil {
c.String(http.StatusInternalServerError, "index.html not found: %s", idxPath)
return
}
idxHTML := string(idxBytes)
// 查商品(只取 OG 所需字段,轻量查询)
var product model.Product
if err := h.db.Select("id, public_id, name, brand, series, spec, shop_id").
Where("public_id = ? AND deleted_at IS NULL", publicID).
Preload("Images").
First(&product).Error; err != nil {
// 查不到商品:原样返回 index.html,让 Flutter 展示"商品不存在"
c.Data(http.StatusOK, "text/html; charset=utf-8", idxBytes)
return
}
// 查门店名
var shop model.Shop
shopName := ""
if err := h.db.Select("name").Where("id = ?", product.ShopID).First(&shop).Error; err == nil {
shopName = shop.Name
}
// 构造 OG 标签并注入 </head> 前
ogTags := buildProductOG(product, shopName, config.C.Storage.PublicURL)
ogTitle := buildOGTitle(product)
// 同时替换 <title> 标签,微信/飞书等平台有时优先读 <title> 而非 og:title
// 用正则替换避免缩进空格导致字符串不匹配
titleRe := regexp.MustCompile(`<title>[^<]*</title>`)
out := titleRe.ReplaceAllString(idxHTML, "<title>"+html.EscapeString(ogTitle)+"</title>")
out = strings.Replace(out, "</head>", ogTags+"</head>", 1)
// 禁止缓存,确保爬虫每次都能拿到最新 OG 标签
c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(out))
}
// ProductPage 已 SSR 化,实现移至 public_page.go2026-07-07 公开页去 Flutter 化)。
// buildOGTitle 生成商品的分享标题:品牌 + 商品名(品牌已含在名字中时不重复)+ 系列。
func buildOGTitle(product model.Product) string {
@@ -403,8 +261,8 @@ func buildProductOG(product model.Product, shopName, publicURL string) string {
siteName = shopName + " · " + siteName
}
// og:url
pageURL := publicURL + "/app/product/" + product.PublicID
// og:url:指向 SSR 短链(公开页规范入口)
pageURL := publicURL + "/product/" + product.PublicID
var sb strings.Builder
sb.WriteString("\n")
+424
View File
@@ -0,0 +1,424 @@
package handler
import (
"bytes"
"embed"
"fmt"
"html"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
)
// 公开页 SSR2026-07-07 设计 docs/design/public-page-speedup.html):
// 商品页 /product/:public_id 与店铺页 /shop/:shop_code 由后端直出轻量 HTML~20KB),
// 顾客扫码/分享动线不再加载 Flutter Web12.5MB 首包在 3Mbps 带宽下约 30s)。
// 数据装配与公开 API 共用同一函数(loadPublicProduct / queryShopProducts),字段口径零漂移。
//go:embed templates/public_product.html templates/public_shop.html
var pageTemplates embed.FS
var (
productPageTpl = template.Must(template.ParseFS(pageTemplates, "templates/public_product.html"))
shopPageTpl = template.Must(template.ParseFS(pageTemplates, "templates/public_shop.html"))
)
// publicProductData 公开商品数据装配结果——GetProductJSON API)与 ProductPageSSR)共用。
type publicProductData struct {
Product model.Product
Shop model.Shop
HasShop bool
Batch *model.Inventory
Origin string
ShelfLife string
Storage string
DescTitle string
DescBody string
Keywords []string
}
// loadPublicProduct 按 public_id 装配公开商品数据(公开字段口径的单一实现)。
func (h *PublicHandler) loadPublicProduct(publicID string) (*publicProductData, error) {
var product model.Product
if err := h.db.Where("public_id = ? AND deleted_at IS NULL", publicID).
Preload("Images").
Preload("Origin").
Preload("ShelfLife").
Preload("Storage").
Preload("DescriptionDoc").
First(&product).Error; err != nil {
return nil, err
}
d := &publicProductData{Product: product, ShelfLife: defaultShelfLife, Storage: defaultStorage}
if err := h.db.Where("id = ?", product.ShopID).First(&d.Shop).Error; err == nil {
d.HasShop = true
}
// 最近一条有量库存作为批次信息
var inv model.Inventory
if err := h.db.Where("product_id = ? AND quantity > 0 AND deleted_at IS NULL", product.ID).
Order("created_at DESC").
First(&inv).Error; err == nil {
d.Batch = &inv
}
if product.Origin != nil {
d.Origin = product.Origin.Name
}
if product.ShelfLife != nil {
d.ShelfLife = product.ShelfLife.Name
}
if product.Storage != nil {
d.Storage = product.Storage.Name
}
d.DescTitle, d.DescBody, d.Keywords = buildDescription(product)
return d, nil
}
// queryShopProducts 店铺公开商品列表查询——ListShopProductsJSON API)与 ShopPageSSR)共用。
// keyword 为空时不过滤(API 现状);非空时按 名称/编号/全拼/首字母 模糊匹配(SSR 搜索框)。
func (h *PublicHandler) queryShopProducts(shopID uint64, page, pageSize int, keyword string) ([]publicProductResp, int64, error) {
// 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。
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).
Group("product_id")
query := h.db.Model(&model.Product{}).
Joins("JOIN (?) AS stk ON stk.product_id = products.id", stockSub).
Where("products.shop_id = ? AND products.public_id IS NOT NULL AND products.public_id != '' AND products.deleted_at IS NULL", shopID)
if kw := strings.TrimSpace(keyword); kw != "" {
like := "%" + kw + "%"
query = query.Where(
"products.name LIKE ? OR products.code LIKE ? OR products.name_pinyin LIKE ? OR products.name_initials LIKE ?",
like, like, like, like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var products []model.Product
if err := query.Preload("Images").
Offset((page - 1) * pageSize).
Limit(pageSize).
Order("products.id DESC").
Find(&products).Error; err != nil {
return nil, 0, err
}
// 取本页商品的在库总量(IN 限定在本页 ≤pageSize 个 id,开销小)
qtyMap := make(map[uint64]float64, len(products))
if len(products) > 0 {
pageIDs := make([]uint64, len(products))
for i, p := range products {
pageIDs[i] = p.ID
}
var stockRows []struct {
ProductID uint64
Qty float64
}
h.db.Model(&model.Inventory{}).
Select("product_id, SUM(quantity) AS qty").
Where("shop_id = ? AND deleted_at IS NULL AND quantity > 0 AND product_id IN ?", shopID, pageIDs).
Group("product_id").
Scan(&stockRows)
for _, s := range stockRows {
qtyMap[s.ProductID] = s.Qty
}
}
listData := make([]publicProductResp, len(products))
for i, p := range products {
imgs := make([]publicProductImage, len(p.Images))
for j, img := range p.Images {
imgs[j] = publicProductImage{URL: img.URL}
}
listData[i] = publicProductResp{
ID: p.ID,
PublicID: p.PublicID,
Code: p.Code,
Name: p.Name,
Series: p.Series,
Spec: p.Spec,
Brand: p.Brand,
Unit: p.Unit,
SalePrice: p.SalePrice,
Quantity: qtyMap[p.ID],
Images: imgs,
}
}
return listData, total, nil
}
// ── 商品公开页 SSR ──
type productPageVM struct {
PageTitle string
OGTags template.HTML
Found bool
Name string
Code string
Series string
Spec string
Unit string
Price string
Images []string
Keywords []string
DescTitle string
DescBody string
Origin string
ShelfLife string
Storage string
HasBatch bool
ProdDate string
BatchNo string
InDate string
HasShop bool
ShopName string
ShopCode string
ShopPhone string
ShopAddr string
ShopHours string
ShopWx string
}
// ProductPage GET /product/:public_id
// 后端直出轻量商品页(含 OG 标签,微信/飞书分享卡片与人看同一页)。
// 找不到商品时渲染同风格「商品不存在」页(HTTP 200,对爬虫友好的历史行为保持)。
func (h *PublicHandler) ProductPage(c *gin.Context) {
publicID := c.Param("public_id")
d, err := h.loadPublicProduct(publicID)
if err != nil {
renderPage(c, productPageTpl, productPageVM{PageTitle: "商品不存在 · 岩美酒库"})
return
}
p := d.Product
shopName := ""
if d.HasShop {
shopName = d.Shop.Name
}
// 页面主标题:品牌前缀(名称未含品牌时)+ 名称;系列走信息行与 chips
name := p.Name
if p.Brand != "" && !strings.Contains(p.Name, p.Brand) {
name = p.Brand + p.Name
}
vm := productPageVM{
PageTitle: buildOGTitle(p) + " · " + siteNameFor(shopName),
OGTags: template.HTML(buildProductOG(p, shopName, config.C.Storage.PublicURL)), // #nosec G203 -- buildProductOG 内部对所有值 html.EscapeString
Found: true,
Name: name,
Code: p.Code,
Series: p.Series,
Spec: p.Spec,
Unit: p.Unit,
Price: fmtYuan(p.SalePrice),
Keywords: d.Keywords,
DescTitle: d.DescTitle,
DescBody: d.DescBody,
Origin: d.Origin,
ShelfLife: d.ShelfLife,
Storage: d.Storage,
}
for _, img := range p.Images {
if img.URL != "" {
vm.Images = append(vm.Images, img.URL)
}
}
if d.Batch != nil {
vm.HasBatch = true
if d.Batch.ProductionDate != nil {
vm.ProdDate = d.Batch.ProductionDate.Time.Format("2006-01-02")
}
vm.BatchNo = d.Batch.BatchNo
vm.InDate = d.Batch.CreatedAt.Format("2006-01-02")
}
if d.HasShop {
vm.HasShop = true
vm.ShopName = d.Shop.Name
vm.ShopCode = d.Shop.Code
vm.ShopPhone = d.Shop.Phone
vm.ShopAddr = d.Shop.Address
vm.ShopHours = d.Shop.BusinessHours
vm.ShopWx = d.Shop.WechatID
}
renderPage(c, productPageTpl, vm)
}
// ── 店铺公开页 SSR ──
type shopPageItemVM struct {
PublicID string
Name string
Sub string
Code string
Img string
Price string
}
type shopPageVM struct {
PageTitle string
OGTags template.HTML
Found bool
ShopName string
LogoURL string
Total int64
Q string
Page int
Items []shopPageItemVM
PrevURL string
NextURL string
}
// ShopPage GET /shop/:shop_code
// 店铺公开商品列表直出页:搜索(?q=)+ 分页(?page=),商品卡链接到 /product/:public_id。
func (h *PublicHandler) ShopPage(c *gin.Context) {
shopCode := c.Param("shop_code")
var shop model.Shop
if err := h.db.Where("code = ? AND deleted_at IS NULL", shopCode).First(&shop).Error; err != nil {
renderPage(c, shopPageTpl, shopPageVM{PageTitle: "店铺不存在 · 岩美酒库"})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
const pageSize = 20 // 与公开 API 上限一致(反爬口径)
q := strings.TrimSpace(c.Query("q"))
items, total, err := h.queryShopProducts(shop.ID, page, pageSize, q)
if err != nil {
c.String(http.StatusInternalServerError, "查询失败")
return
}
vm := shopPageVM{
PageTitle: shop.Name + " · 在售商品 · 岩美酒库",
OGTags: template.HTML(buildShopOG(shop, total, config.C.Storage.PublicURL)), // #nosec G203 -- buildShopOG 内部对所有值 html.EscapeString
Found: true,
ShopName: shop.Name,
LogoURL: shop.LogoURL,
Total: total,
Q: q,
Page: page,
}
for _, it := range items {
sub := make([]string, 0, 2)
if it.Series != "" {
sub = append(sub, it.Series)
}
if it.Spec != "" {
sub = append(sub, it.Spec)
}
img := ""
if len(it.Images) > 0 {
img = it.Images[0].URL
}
name := it.Name
if it.Brand != "" && !strings.Contains(it.Name, it.Brand) {
name = it.Brand + it.Name
}
vm.Items = append(vm.Items, shopPageItemVM{
PublicID: it.PublicID,
Name: name,
Sub: strings.Join(sub, " · "),
Code: it.Code,
Img: img,
Price: fmtYuan(it.SalePrice),
})
}
if page > 1 {
vm.PrevURL = shopPageURL(q, page-1)
}
if int64(page*pageSize) < total {
vm.NextURL = shopPageURL(q, page+1)
}
renderPage(c, shopPageTpl, vm)
}
// buildShopOG 店铺页 OG 标签(值全部转义,同 buildProductOG 约定)。
func buildShopOG(shop model.Shop, total int64, publicURL string) string {
var sb strings.Builder
sb.WriteString("\n")
sb.WriteString(` <meta property="og:type" content="website">` + "\n")
sb.WriteString(` <meta property="og:site_name" content="岩美酒库">` + "\n")
sb.WriteString(` <meta property="og:url" content="` + html.EscapeString(publicURL+"/shop/"+shop.Code) + `">` + "\n")
sb.WriteString(` <meta property="og:title" content="` + html.EscapeString(shop.Name) + `">` + "\n")
sb.WriteString(` <meta property="og:description" content="` + html.EscapeString(fmt.Sprintf("在售商品 %d 件 · 正品保障", total)) + `">` + "\n")
if shop.LogoURL != "" {
sb.WriteString(` <meta property="og:image" content="` + html.EscapeString(publicURL+shop.LogoURL) + `">` + "\n")
}
return sb.String()
}
func shopPageURL(q string, page int) string {
v := url.Values{}
if q != "" {
v.Set("q", q)
}
if page > 1 {
v.Set("page", strconv.Itoa(page))
}
if enc := v.Encode(); enc != "" {
return "?" + enc
}
return "?"
}
func siteNameFor(shopName string) string {
if shopName != "" {
return shopName
}
return "岩美酒库"
}
// fmtYuan 价格显示:0 或负数返回空串(模板隐藏该行);整数不带小数,千分位分组。
func fmtYuan(v float64) string {
if v <= 0 {
return ""
}
s := strconv.FormatFloat(v, 'f', 2, 64)
s = strings.TrimSuffix(s, ".00")
parts := strings.SplitN(s, ".", 2)
digits := parts[0]
var b strings.Builder
for i, ch := range digits {
if i > 0 && (len(digits)-i)%3 == 0 {
b.WriteByte(',')
}
b.WriteRune(ch)
}
out := "¥" + b.String()
if len(parts) > 1 {
out += "." + parts[1]
}
return out
}
func renderPage(c *gin.Context, tpl *template.Template, vm interface{}) {
var buf bytes.Buffer
if err := tpl.Execute(&buf, vm); err != nil {
c.String(http.StatusInternalServerError, "render error")
return
}
// 内容含库存批次等动态信息,禁缓存(与旧 OG 注入行为一致,爬虫每次拿最新)
c.Header("Cache-Control", "no-store")
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
}
@@ -0,0 +1,156 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/testutil"
)
// SSR 公开页(2026-07-07 去 Flutter 化):商品页 /product/:id 与店铺页 /shop/:code
// 由后端直出轻量 HTML;断言内容、OG 标签、敏感字段零暴露、搜索与分页。
func setupPageRouter(db *gorm.DB) *gin.Engine {
h := NewPublicHandler(db)
r := gin.New()
r.Use(gin.Recovery())
r.GET("/product/:public_id", h.ProductPage)
r.GET("/shop/:shop_code", h.ShopPage)
return r
}
func getPage(r *gin.Engine, path string) *httptest.ResponseRecorder {
req := httptest.NewRequest("GET", path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestProductPage_SSR(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "PPG01")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
r := setupPageRouter(db)
p := testutil.CreateTestProduct(db, shop.ID, "茅台飞天53度")
setPublicID(db, p.ID, "pub-ssr-001")
addInventory(db, shop.ID, wh.ID, p.ID, 6)
w := getPage(r, "/product/pub-ssr-001")
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Header().Get("Content-Type"), "text/html")
assert.Equal(t, "no-store", w.Header().Get("Cache-Control"))
body := w.Body.String()
// 轻量页:不再是 Flutter 壳
assert.NotContains(t, body, "flutter_bootstrap")
assert.NotContains(t, body, `<base href="/app/">`)
// 内容
assert.Contains(t, body, "茅台飞天53度")
assert.Contains(t, body, shop.Name)
// OG 标签(og:url 指向 SSR 短链)
assert.Contains(t, body, `property="og:title"`)
assert.Contains(t, body, "/product/pub-ssr-001")
// 门店互跳;「在 App 中查看」已移除(2026-07-07 用户拍板)
assert.Contains(t, body, "/shop/"+shop.Code)
assert.NotContains(t, body, "/app/product/")
// 敏感字段零暴露(成本/进价永不出现在公开页)
for _, s := range []string{"cost", "purchase_price", "profit"} {
assert.NotContains(t, body, s, "公开页不得出现敏感字段名 %s", s)
}
}
func TestProductPage_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
r := setupPageRouter(db)
w := getPage(r, "/product/no-such-id")
// 历史行为保持:HTTP 200 + 友好文案(对爬虫不报错)
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "商品不存在")
}
func TestShopPage_SSR_SearchAndPaging(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SPG01")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
r := setupPageRouter(db)
// 25 个有库存商品 → 2 页;外加一个名称可搜索的
for i := 0; i < 25; i++ {
p := testutil.CreateTestProduct(db, shop.ID, "批量酒"+strings.Repeat("x", i%3))
setPublicID(db, p.ID, "sp-"+strings.Repeat("a", 1)+string(rune('A'+i%26))+strings.Repeat("b", i/26+1))
addInventory(db, shop.ID, wh.ID, p.ID, 3)
}
target := testutil.CreateTestProduct(db, shop.ID, "五粮液普五")
setPublicID(db, target.ID, "sp-target")
addInventory(db, shop.ID, wh.ID, target.ID, 2)
// 第 1 页:20 条 + 下一页链接
w := getPage(r, "/shop/"+shop.Code)
assert.Equal(t, http.StatusOK, w.Code)
body := w.Body.String()
assert.Contains(t, body, shop.Name)
assert.Contains(t, body, "26 件")
assert.Contains(t, body, "page=2")
assert.Equal(t, 20, strings.Count(body, `class="item"`), "第一页应有 20 张商品卡")
assert.NotContains(t, body, "flutter_bootstrap")
// 第 2 页:6 条 + 上一页链接
w = getPage(r, "/shop/"+shop.Code+"?page=2")
body = w.Body.String()
assert.Equal(t, 6, strings.Count(body, `class="item"`))
assert.Contains(t, body, "上一页")
// 搜索:只命中目标商品,商品卡链接到 SSR 商品页
w = getPage(r, "/shop/"+shop.Code+"?q="+urlQueryEscape("五粮液"))
body = w.Body.String()
assert.Equal(t, 1, strings.Count(body, `class="item"`))
assert.Contains(t, body, "/product/sp-target")
// 搜索无结果:空态文案
w = getPage(r, "/shop/"+shop.Code+"?q=NOPE")
assert.Contains(t, w.Body.String(), "没有找到")
}
func TestShopPage_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
r := setupPageRouter(db)
w := getPage(r, "/shop/NOSHOP")
assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "店铺不存在")
}
// API 与 SSR 共用装配后的回归:ListShopProducts 行为不变(不受 keyword 影响)
func TestListShopProducts_UnaffectedByRefactor(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SPG02")
wh := testutil.CreateTestWarehouse(db, shop.ID, "仓")
p := testutil.CreateTestProduct(db, shop.ID, "剑南春")
setPublicID(db, p.ID, "sp2-x")
addInventory(db, shop.ID, wh.ID, p.ID, 1)
r := setupPublicRouter(db)
w := getPage(r, "/api/v1/public/shops/"+shop.Code+"/products")
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
assert.Equal(t, float64(1), resp["total"].(float64))
}
func urlQueryEscape(s string) string {
// 测试用最小转义(仅中文场景)
var b strings.Builder
for _, c := range []byte(s) {
b.WriteString("%")
const hex = "0123456789ABCDEF"
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0xF])
}
return b.String()
}
@@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.PageTitle}}</title>{{.OGTags}}
<style>
/* 设计系统单一真源:变量名与三主题(a 经典蓝浅 / b 琥珀深 / c 酒窖暖浅)色值
逐字取自 .superpowers/prototype/tokens.css。SSR 页自包含无法直接引 tokens.css
改配色须先改原型令牌再同步此处。--glow/--seal-bg 为本页派生变量(主色低透明度/表面高透明度)。 */
:root{
--r-md:6px; --r-lg:10px; --r-xl:14px; --r-pill:999px;
--fs-display:28px; --fs-h1:21px; --fs-title:15px; --fs-body:13px; --fs-sm:12px; --fs-xs:11px;
--font:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei","Inter",sans-serif;
--font-mono:"JetBrains Mono","SF Mono",ui-monospace,monospace;
--scrim:rgba(15,20,30,.42);
/* 主题 A(默认) */
--bg:#F5F7FA; --surface:#FFFFFF; --border:#DCE2EB; --border-subtle:#ECEFF4;
--text:#232934; --heading:#1B2430; --muted:#6E7888; --faint:#99A3B3;
--primary:#2563AC; --primary-dark:#154072; --on-primary:#FFFFFF; --brand50:#EEF4FB; --accent:#8B2331;
--shadow:rgba(20,40,70,.20); --sh-1:0 1px 3px var(--shadow);
--glow1:rgba(37,99,172,.05); --glow2:rgba(21,64,114,.04); --seal-bg:rgba(255,255,255,.78);
}
:root[data-theme="b"]{
--bg:#15151C; --surface:#1E1E27; --border:#2B2B37; --border-subtle:#26262F;
--text:#E8E8EE; --heading:#F6F6F9; --muted:#9A9AA8; --faint:#6E6E7E;
--primary:#F5A300; --primary-dark:#D98C00; --on-primary:#1A1408; --brand50:#2A2212; --accent:#F87171;
--shadow:rgba(0,0,0,.55);
--glow1:rgba(245,163,0,.04); --glow2:rgba(248,113,113,.03); --seal-bg:rgba(30,30,39,.78);
}
:root[data-theme="c"]{
--bg:#FAF5EC; --surface:#FFFDF8; --border:#EADDC9; --border-subtle:#F1E8D8;
--text:#33271F; --heading:#241A14; --muted:#917A64; --faint:#B9A488;
--primary:#7C1F2C; --primary-dark:#5F1621; --on-primary:#F7E9D6; --brand50:#F3E7D2; --accent:#A23522;
--shadow:rgba(74,20,32,.22);
--glow1:rgba(124,31,44,.05); --glow2:rgba(181,134,47,.04); --seal-bg:rgba(255,253,248,.78);
}
*{box-sizing:border-box;margin:0;padding:0;}
html{background:var(--bg);height:100%;}
body{
min-height:100%;
background-color:var(--bg);
background-image:
radial-gradient(circle at 12% 0%, var(--glow1), transparent 38%),
radial-gradient(circle at 100% 100%, var(--glow2), transparent 38%);
color:var(--text);
font:var(--fs-title)/1.75 var(--font);
-webkit-font-smoothing:antialiased;
-webkit-tap-highlight-color:transparent;
transition:background-color .25s,color .25s;
}
/* 自适应满屏:页眉贴顶、页脚贴底、中间信息弹性填充;内容超屏时自然滚动 */
.page{max-width:560px;margin:0 auto;padding:0 16px 24px;min-height:100vh;min-height:100dvh;display:flex;flex-direction:column;}
a{color:var(--primary);text-decoration:none;}
/* 顶部题签 */
.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="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="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>
<div class="head">
<h1>{{.Name}}</h1>
{{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="sec">
<div class="sec-t">商品信息</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="sec">
<div class="sec-t">批次信息</div>
<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="sec">
<div class="sec-t">{{.DescTitle}}</div>
<div class="card"><div class="desc">{{.DescBody}}</div></div>
</div>
{{end}}
{{if .HasShop}}
<div class="sec">
<div class="sec-t">门店信息</div>
<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="empty">
<h1>商品不存在或已下架</h1>
<p>请确认链接来源,或联系门店核实。</p>
</div>
{{end}}
<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>
@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.PageTitle}}</title>{{.OGTags}}
<style>
/* 设计系统单一真源:变量名与三主题(a/b/c)色值逐字取自 .superpowers/prototype/tokens.css。
与商品公开页同族;改配色须先改原型令牌再同步此处。--glow 为本页派生变量(主色低透明度)。 */
:root{
--r-md:6px; --r-lg:10px; --r-xl:14px; --r-pill:999px;
--fs-h1:21px; --fs-title:15px; --fs-body:13px; --fs-sm:12px; --fs-xs:11px;
--font:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei","Inter",sans-serif;
--font-mono:"JetBrains Mono","SF Mono",ui-monospace,monospace;
/* 主题 A(默认) */
--bg:#F5F7FA; --surface:#FFFFFF; --border:#DCE2EB; --border-subtle:#ECEFF4;
--text:#232934; --heading:#1B2430; --muted:#6E7888; --faint:#99A3B3;
--primary:#2563AC; --primary-dark:#154072; --on-primary:#FFFFFF; --brand50:#EEF4FB; --accent:#8B2331;
--shadow:rgba(20,40,70,.20); --sh-1:0 1px 3px var(--shadow);
--glow1:rgba(37,99,172,.05); --glow2:rgba(21,64,114,.04);
}
:root[data-theme="b"]{
--bg:#15151C; --surface:#1E1E27; --border:#2B2B37; --border-subtle:#26262F;
--text:#E8E8EE; --heading:#F6F6F9; --muted:#9A9AA8; --faint:#6E6E7E;
--primary:#F5A300; --primary-dark:#D98C00; --on-primary:#1A1408; --brand50:#2A2212; --accent:#F87171;
--shadow:rgba(0,0,0,.55);
--glow1:rgba(245,163,0,.04); --glow2:rgba(248,113,113,.03);
}
:root[data-theme="c"]{
--bg:#FAF5EC; --surface:#FFFDF8; --border:#EADDC9; --border-subtle:#F1E8D8;
--text:#33271F; --heading:#241A14; --muted:#917A64; --faint:#B9A488;
--primary:#7C1F2C; --primary-dark:#5F1621; --on-primary:#F7E9D6; --brand50:#F3E7D2; --accent:#A23522;
--shadow:rgba(74,20,32,.22);
--glow1:rgba(124,31,44,.05); --glow2:rgba(181,134,47,.04);
}
*{box-sizing:border-box;margin:0;padding:0;}
html{background:var(--bg);height:100%;}
body{
min-height:100%;
background-color:var(--bg);
background-image:
radial-gradient(circle at 12% 0%, var(--glow1), transparent 38%),
radial-gradient(circle at 100% 100%, var(--glow2), transparent 38%);
color:var(--text);
font:var(--fs-title)/1.7 var(--font);
-webkit-font-smoothing:antialiased;
-webkit-tap-highlight-color:transparent;
transition:background-color .25s,color .25s;
}
/* 自适应满屏:页眉贴顶、页脚贴底、中间信息弹性填充;内容超屏时自然滚动 */
.page{max-width:560px;margin:0 auto;padding:0 16px 24px;min-height:100vh;min-height:100dvh;display:flex;flex-direction:column;}
a{color:var(--primary);text-decoration:none;}
.mast{
display:flex;align-items:center;justify-content:center;gap:10px;
padding:18px 0 12px;color:var(--muted);font-size:var(--fs-sm);letter-spacing:.3em;
border-bottom:1px solid var(--border);position:relative;
}
.mast a{color:var(--primary-dark);font-weight:700;letter-spacing:.3em;}
/* 主题选择器:与 App ThemePickerPill 同构(衬衫图标 + 主题名 + ▾ + 下拉单选) */
.thm{
position:absolute;right:0;top:50%;transform:translateY(-58%);
display:flex;align-items:center;gap:6px;height:30px;padding:0 11px;
border-radius:var(--r-pill);border:1px solid var(--border);background:var(--surface);
color:var(--text);font-size:var(--fs-sm);font-family:var(--font);cursor:pointer;letter-spacing:0;
}
.thm svg{width:15px;height:15px;stroke:var(--muted);fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
.thm svg.cv{width:13px;height:13px;}
.thm-menu{
position:absolute;right:0;top:calc(100% + 6px);z-index:50;min-width:136px;padding:4px;
background:var(--surface);border:1px solid var(--border);border-radius:var(--r-md);
box-shadow:0 4px 14px var(--shadow);
}
.thm-menu[hidden]{display:none;}
.thm-menu button{
display:flex;align-items:center;gap:4px;width:100%;height:36px;padding:0 10px;
border:none;background:none;color:var(--text);font-size:var(--fs-body);
font-family:var(--font);cursor:pointer;border-radius:4px;text-align:left;letter-spacing:0;text-indent:0;
}
.thm-menu button:hover{background:var(--brand50);}
.thm-menu button.on{color:var(--primary);font-weight:600;}
.thm-menu .ck{width:16px;color:var(--primary);font-weight:700;flex:none;}
/* 店铺题名 */
.shop{text-align:center;padding:24px 0 4px;animation:rise .5s ease both;}
.shop img{width:56px;height:56px;border-radius:var(--r-pill);object-fit:cover;border:1px solid var(--border);background:var(--surface);padding:2px;margin-bottom:10px;}
.shop h1{font-size:var(--fs-h1);font-weight:700;color:var(--heading);letter-spacing:.06em;}
.shop .sub{color:var(--muted);font-size:var(--fs-sm);letter-spacing:.2em;margin-top:5px;}
.shop .sub b{color:var(--primary);font-weight:600;}
form{display:flex;gap:10px;margin:16px 0 4px;animation:rise .5s ease .06s both;}
input[type=search]{
flex:1;border:1px solid var(--border);border-radius:var(--r-lg);padding:10px 14px;font-size:14px;
background:var(--surface);outline:none;color:var(--text);
}
input[type=search]::placeholder{color:var(--faint);}
input[type=search]:focus{border-color:var(--primary);}
button{
border:none;background:var(--primary);color: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="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="搜索本店商品:名称 / 编号 / 拼音">
<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"><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" 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>
<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>
{{end}}
{{else}}
<div class="empty" style="margin-top:40px">
<h2>店铺不存在</h2>
<p>请确认链接来源。</p>
</div>
{{end}}
<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>
+2
View File
@@ -65,6 +65,8 @@ func Setup(r *gin.Engine, db *gorm.DB) {
// 公开商品详情页(注入 OG 标签的 Flutter index.html,供社交分享爬虫读取)
r.GET("/product/:public_id", publicReadIP, dqProduct, publicH.ProductPage)
// 店铺公开页 SSR(挂与 shops API 同一套闸:分钟限流 + 日配额)
r.GET("/shop/:shop_code", shopListIP, dqShopList, publicH.ShopPage)
v1 := r.Group("/api/v1")
@@ -5,6 +5,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/context_tokens.dart';
import '../../core/theme/app_dims.g.dart';
import '../../core/auth/auth_state.dart';
import '../../core/storage/column_prefs.dart';
import '../../core/utils/export_util.dart';
import '../../models/inventory.dart';
@@ -114,12 +115,17 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
);
}
// 成本仅管理员可见(与出库同口径):非管理员隐藏成本价/总价列、卡片单价、货值 KPI。
bool get _isAdmin => ref.watch(isAdminProvider);
bool _colAllowed(ColDef c) =>
_isAdmin || (c.key != 'cost' && c.key != 'price');
// ── 列设置(原型 colBtn → openColMenu:多选保持打开) ──
void _openColMenu(BuildContext anchorContext) {
showDsMultiMenu<String>(
anchorContext,
itemsBuilder: () => [
for (final c in _colDefs.where((c) => !c.required))
for (final c in _colDefs.where((c) => !c.required && _colAllowed(c)))
DsMenuItem(
value: c.key,
label: c.label,
@@ -528,6 +534,8 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
final sub = [
if (item.productCode.isNotEmpty) item.productCode,
if (item.spec.isNotEmpty) item.spec,
// 单价(成本进价)仅管理员可见(2026-07-06 用户拍板)
if (_isAdmin && item.unitPrice != null) _fmtCost(item.unitPrice!),
].join(' · ');
return MCard(
onTap: item.productId != null ? () => _openEditor(item) : null,
@@ -626,8 +634,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
.toList();
final hidden = _hiddenCols ?? <String>{};
final visibleCols =
_colDefs.where((c) => !hidden.contains(c.key)).toList();
final visibleCols = _colDefs
.where((c) => !hidden.contains(c.key) && _colAllowed(c))
.toList();
final mobile = context.isMobile;
// 原型 .main{padding:22px 26px}:桌面整页留白统一在此;窄屏保持紧凑。
@@ -715,7 +724,8 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
),
MKpiItem(
label: '库存货值',
value: summary != null
// 货值=成本口径,非管理员后端抹零,前端显示占位
value: _isAdmin && summary != null
? yuanWan(summary.stockValue)
: '',
delta: _mDeltaText(valDelta, valTone),
@@ -752,8 +762,9 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
),
DsKpi(
title: '库存货值',
value:
summary != null ? yuanWan(summary.stockValue) : '',
value: _isAdmin && summary != null
? yuanWan(summary.stockValue)
: '',
icon: LucideIcons.database,
tone: DsKpiTone.ok,
delta: valDelta,
@@ -27,7 +27,10 @@ import '../../widgets/ds/ds_toast.dart';
/// 明细为可键盘操作的内联网格(名称/系列/规格可搜索+新增,生产日期/批次/数量/单价/售价内联编辑)。
class StockInFormScreen extends ConsumerStatefulWidget {
final int? editOrderId;
const StockInFormScreen({super.key, this.editOrderId});
/// 仅供 golden 测试钉死单据日期(默认 DateTime.now() 会让 golden 随日期漂移)。
final DateTime? initialDate;
const StockInFormScreen({super.key, this.editOrderId, this.initialDate});
@override
ConsumerState<StockInFormScreen> createState() => _StockInFormScreenState();
@@ -39,7 +42,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
final _warehouseFocus = FocusNode();
int? _warehouseId;
int? _partnerId;
DateTime _orderDate = DateTime.now();
late DateTime _orderDate = widget.initialDate ?? DateTime.now();
bool _submitting = false;
bool _loadingEdit = false;
StockInOrder? _loadedOrder;
@@ -142,7 +142,10 @@ class _ItemRow {
/// 明细行经「从库存批量选择」添加,商品/系列/规格/可用只读,仅数量、售价可内联编辑。
class StockOutFormScreen extends ConsumerStatefulWidget {
final int? editOrderId;
const StockOutFormScreen({super.key, this.editOrderId});
/// 仅供 golden 测试钉死单据日期(默认 DateTime.now() 会让 golden 随日期漂移)。
final DateTime? initialDate;
const StockOutFormScreen({super.key, this.editOrderId, this.initialDate});
@override
ConsumerState<StockOutFormScreen> createState() => _StockOutFormScreenState();
@@ -154,7 +157,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
final _warehouseFocus = FocusNode();
int? _warehouseId;
int? _partnerId;
DateTime _orderDate = DateTime.now();
late DateTime _orderDate = widget.initialDate ?? DateTime.now();
bool _submitting = false;
bool _loadingEdit = false;
Map<int, double> _inventoryMap = {};
@@ -11,6 +11,7 @@ import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../core/auth/auth_state.dart';
import '../core/config/app_config.dart';
import '../core/utils/dialog_util.dart';
import '../core/responsive/responsive.dart';
@@ -356,10 +357,14 @@ class _ProductEditorDrawerState extends ConsumerState<_ProductEditorDrawer> {
b('${_fmtQty(widget.qty!)} ${widget.unit ?? p.unit}'.trim())
));
}
// 成本仅管理员可见(与库存列表/出库同口径);p.purchasePrice 兜底也要一并门控
final isAdmin = ref.watch(isAdminProvider);
final cost = widget.cost ?? p.purchasePrice;
if (cost != null && cost > 0) rows.add(('成本价(单瓶)', b(_fmtMoney(cost))));
if (widget.qty != null && cost != null && cost > 0) {
rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost))));
if (isAdmin && cost != null && cost > 0) {
rows.add(('成本价(单瓶)', b(_fmtMoney(cost))));
if (widget.qty != null) {
rows.add(('总成本价', b(_fmtMoney(widget.qty! * cost))));
}
}
if ((widget.status ?? '').isNotEmpty) {
rows.add((
Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 145 KiB

@@ -242,6 +242,8 @@ List<Override> _overrides() => [
productSeriesListProvider.overrideWith(() => _FakeSeriesNotifier()),
productSpecListProvider.overrideWith(() => _FakeSpecNotifier()),
isReadonlyProvider.overrideWithValue(false),
// 成本列/货值/单价仅管理员可见——golden 基准取管理员形态
isAdminProvider.overrideWithValue(true),
inventorySummaryProvider.overrideWith((ref) => const InventorySummary(
skuCount: 1284,
stockValue: 2640000,
@@ -165,6 +165,8 @@ List<Override> _overrides() => [
productSeriesListProvider.overrideWith(() => _FakeSeriesNotifier()),
productSpecListProvider.overrideWith(() => _FakeSpecNotifier()),
isReadonlyProvider.overrideWithValue(false),
// 成本列/货值/单价仅管理员可见——golden 基准取管理员形态(卡片含单价)
isAdminProvider.overrideWithValue(true),
inventorySummaryProvider.overrideWith((ref) => const InventorySummary(
skuCount: 1284,
stockValue: 2640000,
@@ -73,7 +73,7 @@ void main() {
goldenAcrossThemes(
'stock-in form 录单(桌面)',
goldenPrefix: 'stock_in_form',
child: () => const Scaffold(body: StockInFormScreen()),
child: () => Scaffold(body: StockInFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(1280, 920),
);
@@ -81,7 +81,7 @@ void main() {
goldenAcrossThemes(
'stock-in form 录单(移动)',
goldenPrefix: 'stock_in_form_mobile',
child: () => const Scaffold(body: StockInFormScreen()),
child: () => Scaffold(body: StockInFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(390, 1400),
);
@@ -53,7 +53,7 @@ void main() {
goldenAcrossThemes(
'stock-out form 录单(桌面)',
goldenPrefix: 'stock_out_form',
child: () => const Scaffold(body: StockOutFormScreen()),
child: () => Scaffold(body: StockOutFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(1280, 920),
);
@@ -61,7 +61,7 @@ void main() {
goldenAcrossThemes(
'stock-out form 录单(移动)',
goldenPrefix: 'stock_out_form_mobile',
child: () => const Scaffold(body: StockOutFormScreen()),
child: () => Scaffold(body: StockOutFormScreen(initialDate: DateTime(2026, 7, 4))),
overrides: _overrides,
logical: const Size(390, 1400),
);
+30 -1
View File
@@ -54,6 +54,17 @@ server {
client_max_body_size 20m;
# 传输压缩(2026-07-07 公开页提速方案 A′):运行时 gzip,覆盖 Flutter Web 静态
# 大文件(main.dart.js 5.3MB→~1.3MB、canvaskit.wasm 7.2MB→~3MB)与反代 JSON。
# 图片(JPEG/PNG)刻意不在列——本身已是压缩格式,上传侧另有 1200px+q85 压缩。
gzip on;
gzip_comp_level 5;
gzip_min_length 1k;
gzip_vary on;
gzip_proxied any;
gzip_types application/javascript application/wasm application/json
text/css text/plain image/svg+xml application/octet-stream;
# certbot HTTP-01 续期通道(webroot 独立目录,不放 marketing——
# deploy-site 的 rsync --delete 会清掉站点目录里的挑战文件)
location ^~ /.well-known/acme-challenge/ {
@@ -115,6 +126,13 @@ server {
alias /opt/jiu/web/;
index index.html;
try_files $uri $uri/ /app/index.html;
# 协商缓存(2026-07-07 方案 A′):Flutter 产物文件名无 hash,长缓存会在发版后
# 新旧错配白屏,故走 etag 304——复访零字节,首访靠上面的 gzip。
# 注意 add_header 覆盖继承:补回 server 级安全头。
add_header Cache-Control "no-cache";
add_header Strict-Transport-Security "max-age=31536000" always;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
}
location = /app/index.html {
@@ -125,7 +143,18 @@ server {
expires 0;
}
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签(UA 只拦抓取库,不拦空 UA)
# 店铺公开商品列表页 SSR2026-07-07 新增短链):与 shops API 同一套边缘闸
location ~ ^/shop/ {
if ($jiu_bad_ua_lib) { return 403; }
limit_req zone=jiu_shoplist burst=5 nodelay;
limit_conn jiu_conn 10;
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
}
# 公开商品详情页(扫码跳转)→ 后端 SSR 轻量页(UA 只拦抓取库,不拦空 UA)
location ~ ^/product/ {
if ($jiu_bad_ua_lib) { return 403; }
limit_req zone=jiu_pub burst=20 nodelay;
+173
View File
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>公开商品页提速方案 — 酒库管理系统</title>
<style>
:root{
--primary:#2563AC; --primary-dark:#154072; --danger:#D14343; --danger-bg:#FDECEC;
--success:#2E8B57; --success-bg:#E6F3EC; --warn:#B45309; --warn-bg:#FFF4E5; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.65;}
h1{font-size:22px;margin:0 0 4px;}
h2{font-size:17px;margin:30px 0 12px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
h3{font-size:14px;margin:18px 0 8px;color:var(--accent);}
.sub{color:var(--muted);font-size:13px;margin-bottom:18px;}
.card{background:#fff;border:1px solid var(--border);border-radius:10px;padding:16px 20px;margin:14px 0;}
.lead{font-size:13.5px;color:var(--ink);}
table{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0;}
th{background:var(--head);color:var(--primary-dark);font-weight:600;font-size:12px;text-align:left;padding:9px 10px;border-bottom:1px solid var(--border);}
td{padding:8px 10px;border-bottom:1px solid #EEF1F5;vertical-align:top;}
.mono{font-family:ui-monospace,Menlo,monospace;font-size:12.5px;}
code{font-family:ui-monospace,Menlo,monospace;font-size:12px;background:#EEF2F8;padding:1px 5px;border-radius:4px;color:var(--primary-dark);}
pre{background:#1E2430;color:#D8E0EC;border-radius:8px;padding:12px 14px;overflow:auto;font-size:12px;line-height:1.55;}
pre .c{color:#7A8699;}
.tag{display:inline-block;font-size:11px;padding:1px 8px;border-radius:10px;font-weight:600;}
.tag.risk{background:var(--danger-bg);color:var(--danger);}
.tag.ok{background:var(--success-bg);color:var(--success);}
.tag.warn{background:var(--warn-bg);color:var(--warn);}
.callout{border-left:4px solid var(--primary);background:#F0F6FF;padding:10px 14px;border-radius:4px;margin:12px 0;font-size:13px;}
.callout.danger{border-color:var(--danger);background:var(--danger-bg);}
.callout.warn{border-color:var(--warn);background:var(--warn-bg);}
.callout.ok{border-color:var(--success);background:var(--success-bg);}
.callout b{color:var(--ink);}
ul,ol{margin:6px 0;padding-left:22px;}
li{margin:3px 0;font-size:13px;}
</style>
</head>
<body>
<h1>公开页提速方案(传输压缩 + 公开页全面去 Flutter 化)</h1>
<div class="sub">2026-07-07 设计 · 触发问题:<span class="mono">jiu.51yanmei.com/app/product/&lt;public_id&gt;?code=P1331</span> 首开约 30 秒 · 目标机 ali(3Mbps 固定带宽)· 用户拍板:所有公开页不使用 Flutter</div>
<h2>一、问题与实测</h2>
<div class="card lead">
<p>公开商品页(顾客扫标签二维码 / 微信分享打开)走的是 Flutter Web 深链:无论从 <code>/product/&lt;id&gt;</code> 短链还是 <code>/app/product/&lt;id&gt;</code> 进来,后端 <code>ProductPage</code> 都返回注入了 OG 标签的 <b>Flutter 应用壳 index.html</b>,浏览器随后要下完整个管理端应用才渲染出一个商品卡。</p>
<table>
<tr><th>环节</th><th>大小</th><th>实测耗时</th><th>问题</th></tr>
<tr><td>HTML(含 OG 注入)</td><td>2.1 KB</td><td>0.05s</td><td><span class="tag ok">正常</span></td></tr>
<tr><td>公开商品 API</td><td>830 B</td><td>0.05s</td><td><span class="tag ok">正常</span></td></tr>
<tr><td class="mono">main.dart.js</td><td>5.3 MB</td><td>11.8s</td><td><span class="tag risk">未压缩</span> 无 gzip、无缓存头</td></tr>
<tr><td class="mono">canvaskit.wasm</td><td>7.2 MB</td><td>16.4s</td><td><span class="tag risk">未压缩</span></td></tr>
</table>
<p><b>三因叠加</b>:① Flutter Web 首包 ~12.5MB;② nginx 全程无压缩(响应无 <code>content-encoding</code>);③ ECS 出网带宽固定 3MbpsPayByBandwidth)→ 12.5MB ÷ 3Mbps ≈ <b>33 秒</b>。重复访问可靠 etag 304,但<b>每个第一次扫码的顾客</b>都要全量拉。</p>
</div>
<h2>二、方案总览(两条腿,独立生效、互相叠加)</h2>
<div class="card lead">
<table>
<tr><th>方案</th><th>解决什么</th><th>改动面</th><th>预期效果</th></tr>
<tr><td><b>A′ · 传输层压缩 + 缓存(瘦身版)</b></td><td>员工 Web 版首开、API JSON 响应</td><td>仅 nginx conf ~10 行</td><td>Web 版首开 12.5MB → ~4.4MB,约 33s → <b>~12s</b>API 列表响应 70%</td></tr>
<tr><td><b>B · 公开页全面 SSR(去 Flutter</b></td><td>顾客扫码 / 分享动线与 App 彻底解耦</td><td>backend 直出<b>商品页 + 店铺列表页</b>两张模板</td><td>公开页首开 ~20KB<b>&lt; 0.5s</b>,全程零 Flutter</td></tr>
</table>
<p><b>公开面盘点(B 的完整范围)</b>:App 中匿名可访问的页面只有两张——商品公开页(<code>/product/:public_id</code>)与店铺公开商品列表页(Flutter 路由 <code>/shop/:shop_code</code>,现仅能从商品页点门店名进入,<b>无独立浏览器短链</b>:直开 <code>/shop/S001</code> 落营销站 404)。两张全部 SSR 化并互相跳转后,顾客动线不再触碰任何 Flutter 资源。登录/注册属 App 本体入口(员工用)不在范围;官网营销站本就是静态站。</p>
<p>A 仍然值得做——员工浏览器开 Web 版、以及公开页上「在 App 中打开」的兜底路径都吃到红利。</p>
</div>
<h2>三、方案 A′:nginx 运行时 gzip + 缓存策略(瘦身版)</h2>
<div class="card lead">
<div class="callout"><b>2026-07-07 用户评审后瘦身:</b>B(公开页 SSR)落地后,「顾客要下 12.5MB」的场景消失,原方案 A1「CI 预压缩 + gzip_static」重武器不再必要,<b>整体砍掉</b>——client 流水线零改动。保留的 A′ 只有 nginx 里 ~10 行运行时配置,随 server 发版同车带出,覆盖剩余收益:员工浏览器开 Web 版首开 33s→12s(低频、一次性)、API JSON 响应压缩(库存列表类响应小 ~70%)、公开页「在 App 中打开」兜底路径。</div>
<h3>A1 · nginx 运行时 gzip</h3>
<pre><span class="c"># server 块(deploy/nginx-jiu-ali.conf):低频大文件现压(2vCPU 压 5MB 约 0.1s,可接受)</span>
gzip on;
gzip_comp_level 5;
gzip_min_length 1k;
gzip_vary on;
gzip_proxied any;
gzip_types application/javascript application/wasm application/json
text/css text/plain image/svg+xml application/octet-stream;</pre>
<p><b>图片不在列</b>JPEG/PNG 本身是压缩格式,gzip 无收益。图片链路现状已健康——上传时后端统一缩到最长边 1200px + JPEG q85(线上实测中位数 ~91KB/张),<code>/images/</code> 已配 <code>expires 30d + immutable</code>,不动。</p>
<h3>A′2 · 缓存策略:协商缓存(304),不做长缓存</h3>
<p>Flutter Web 产物文件名<b>无内容 hash</b>main.dart.js 固定名),长缓存会在发版后出现新旧混用(main.dart.js 与 canvaskit 版本错配会白屏)。因此:</p>
<table>
<tr><th>路径</th><th>策略</th><th>说明</th></tr>
<tr><td class="mono">/app/index.html</td><td>no-store(现状不变)</td><td>入口永远最新</td></tr>
<tr><td class="mono">/app/ 其余全部</td><td><code>Cache-Control: no-cache</code>(协商)</td><td>复访全部 etag 304,零字节;Flutter 自带 service worker 二次访问另有本地缓存</td></tr>
</table>
</div>
<h2>四、方案 B:公开页后端轻量直出(商品页 + 店铺列表页)</h2>
<div class="card lead">
<h3>B1 · 现状与改造点</h3>
<p><code>backend/internal/handler/public.go</code><code>ProductPage</code>:读 Flutter index.html → 注入 OG 标签 → 返回应用壳。<b>改造为:直接渲染一张自包含的轻量商品 HTML 页</b>Go <code>html/template</code> + <code>go:embed</code> 内嵌模板),不再依赖 Flutter 产物。</p>
<table>
<tr><th></th><th>设计</th></tr>
<tr><td>模板位置</td><td><code>backend/internal/handler/templates/product_page.html</code>(go:embed,二进制自包含,部署不变)</td></tr>
<tr><td>页面内容</td><td>主图(多图横滑,纯 CSS scroll-snap)· 品牌/名称(系列)· 规格 · 商品编号(<code>?code=</code> 参数回显,与现 App 公开页口径一致,实施时核对)· 产地/保质期/储存条件/介绍(基础数据字典字段,取自现有公开查询)· 门店名 + 联系电话 · 页脚「岩美酒库 · 正品溯源」</td></tr>
<tr><td>数据来源</td><td>复用 <code>GetProduct</code><code>/api/v1/public/products/:id</code>)已有的查询/字段组装逻辑,抽出共用函数——<b>不新增查询口径,公开字段集与 API 完全一致</b>(成本/进价等敏感字段本就不在其中)</td></tr>
<tr><td>OG 兼容</td><td>OG metatitle/description/image/url)直接写进模板 head<b>复用现有 <code>buildProductOG</code>/<code>buildOGTitle</code></b>;微信/飞书分享卡片行为不变,且不再需要 UA 分流</td></tr>
<tr><td>样式</td><td>内联 CSS,全页 &lt; 20KBgzip 后 ~6KB);配色沿用设计系统主题 A 色值(品牌蓝 #2563AC 系),移动优先单列卡片</td></tr>
<tr><td>图片加载</td><td><b>全部异步拉取(2026-07-07 用户拍板)</b>:HTML 先到先渲染,图片不阻塞——首图 <code>decoding="async"</code> 优先加载,其余图 <code>loading="lazy"</code> 滚动到位才拉;店铺列表页同规则</td></tr>
<tr><td>兜底</td><td>商品不存在 → 渲染同风格「商品不存在或已下架」页(HTTP 200,现状对爬虫友好的行为保持)</td></tr>
<tr><td>互跳</td><td>门店名可点 → <code>/shop/&lt;shop_code&gt;</code>(B4 店铺 SSR 页);页脚小链接「在 App 中查看」→ <code>/app/product/&lt;id&gt;</code>(员工/深链兜底,普通顾客不需要)</td></tr>
</table>
<h3>B4 · 店铺公开商品列表页 SSR(新增短链)</h3>
<p>现状:Flutter 内部路由 <code>/shop/:shop_code</code>(从商品公开页点门店名进入),功能 = 该店公开商品列表 + 关键字搜索 + 分页,数据源 <code>ListShopProducts</code><code>/api/v1/public/shops/:shop_code/products</code>)。SSR 化设计:</p>
<table>
<tr><th></th><th>设计</th></tr>
<tr><td>新路由</td><td><code>GET /shop/:shop_code</code>backendrouter.go 公开区)——店铺公开页从此有独立浏览器短链,可印店铺码/分享</td></tr>
<tr><td>模板</td><td><code>templates/shop_page.html</code>(同 go:embed / 同风格):店铺名+logo · 搜索框(GET 表单 <code>?q=</code>,服务端过滤)· 商品卡列表(图/品牌型号版本/编号,图片 <code>loading="lazy"</code> 原生懒加载——20 卡×~90KB 主图只按滚动加载)· 上一页/下一页链接(<code>?page=N</code>,沿用 API 的 page_size=20 上限)· 每张商品卡链接 <code>/product/&lt;public_id&gt;</code></td></tr>
<tr><td>数据来源</td><td>复用 <code>ListShopProducts</code> 的查询组装(与 API 同一函数抽共用),公开字段集一致,零新增口径</td></tr>
<tr><td>OG</td><td>head 输出店铺级 OG(店名 + logo + 「共 N 件商品」),微信分享店铺链接出卡片</td></tr>
<tr><td>限流/反爬</td><td>路由挂与 shops API 同一套闸:<code>shopListIP</code> 分钟限流 + <code>dqShopList</code> 日配额(300/IP/日);nginx 新增 <code>location ~ ^/shop/</code>,进 <code>jiu_shoplist</code> 2r/s 专属 zone + <code>$jiu_bad_ua_lib</code> + limit_conn 10(与 shops API location 同参数,且同样须放在通用 location 之前)</td></tr>
<tr><td>兜底</td><td>店铺不存在 / 未开公开页 → 同风格「店铺不存在」页;无商品 → 空态文案</td></tr>
<tr><td>App 内导航</td><td>Flutter <code>/shop/:shop_code</code> 内部路由保留不动(员工 App 内预览用,不发 HTTP)</td></tr>
</table>
<h3>B2 · 入口矩阵(改造后各入口去向)</h3>
<table>
<tr><th>入口</th><th>现状</th><th>改造后</th></tr>
<tr><td>标签二维码 / App「复制公开链接」= <code>/product/&lt;id&gt;</code></td><td>Flutter 壳(~30s</td><td><span class="tag ok">轻量页 &lt;0.5s</span> 历史已印标签直接受益,无需换码</td></tr>
<tr><td>浏览器直开 / 刷新 <code>/app/product/&lt;id&gt;</code></td><td>nginx rewrite → 同一 ProductPage</td><td>同上轻量页(nginx 该 location 不动)</td></tr>
<tr><td>App 内浏览公开页(Flutter 路由 <code>/product/:public_id</code><code>/shop/:shop_code</code></td><td>客户端内部导航,不发 HTTP</td><td><span class="tag ok">不受影响</span></td></tr>
<tr><td>浏览器直开 <code>/shop/&lt;shop_code&gt;</code></td><td><span class="tag warn">404</span>(落营销站,无此入口)</td><td><span class="tag ok">店铺 SSR 页</span> 新增短链,可印店铺码</td></tr>
<tr><td>商品公开页点门店名</td><td>App 内跳 Flutter 店铺页</td><td>SSR 商品页 → SSR 店铺页,全程零 Flutter</td></tr>
<tr><td>微信/飞书爬虫抓分享卡片</td><td>OG 注入壳</td><td>OG 在轻量页 head,行为不变(店铺页新增店铺级 OG)</td></tr>
<tr><td>公开 API <code>/api/v1/public/products/:id</code><code>/api/v1/public/shops/:code/products</code></td><td>App 数据源</td><td><span class="tag ok">零改动</span></td></tr>
</table>
<h3>B3 · 反爬与限流</h3>
<p>路由与闸全部沿用现状:<code>publicReadIP</code> 分钟限流 + <code>dqProduct</code> 日配额(1000/IP/日)+ nginx <code>$jiu_bad_ua_lib</code>/limit_conn 不动。轻量页比现在还省服务器资源(不再读盘 index.html,模板编译一次常驻)。</p>
</div>
<h2>五、发版与回滚</h2>
<div class="card lead">
<table>
<tr><th>批次</th><th>内容</th><th>tag</th></tr>
<tr><td>唯一一批</td><td>方案 B(商品页 + 店铺页 SSR+ 方案 A′(nginx gzip/缓存头,含新增 /shop location)——全部 server 流水线一次带出,<b>client 零改动</b></td><td><code>server-v1.1.8</code></td></tr>
</table>
<ul>
<li><b>测试</b>ProductPage / ShopPage 单测(有图/无图/不存在/含 code 参数/搜索/分页的 HTML 断言 + OG 标签断言 + 敏感字段零暴露断言,沿用 public_anticrawl_test 写法);nginx 变更上线后 curl 验收。</li>
<li><b>回滚</b>server 回退上一 tag 即回到现状;nginx 压缩指令独立无状态,回退同批完成。</li>
</ul>
<h3>验收清单(上线后逐条打勾)</h3>
<ol>
<li><code>curl -H 'Accept-Encoding: gzip' --compressed -so /dev/null -w '%{size_download}' https://jiu.51yanmei.com/app/main.dart.js</code> → 响应带 <code>content-encoding: gzip</code>,下载量 ~1.3MB(原 5.3MB</li>
<li><code>/product/&lt;id&gt;</code> 首开 &lt; 0.5stotal time),页面含商品名/图/门店信息</li>
<li>微信发送 <code>/product/&lt;id&gt;</code> 链接 → 分享卡片(标题/图)正常</li>
<li>响应体不含 cost / purchase_price 等敏感字段名</li>
<li>App 内「公开页预览」导航正常;<code>/app/product/&lt;id&gt;</code> 刷新落到轻量页</li>
<li><code>/shop/&lt;code&gt;</code> 直开出店铺列表页,搜索(?q=)与翻页(?page=)可用;商品卡点进 <code>/product/&lt;id&gt;</code>;超日配额出 429</li>
<li>手机弱网(蜂窝)实测扫标签二维码体感</li>
</ol>
</div>
<h2>六、明确不做(本期)</h2>
<div class="card lead">
<ul>
<li><b>brotli</b>ali 的 nginx 1.24 无 brotli 模块,装第三方模块运维成本 &gt; 收益(gzip 已够),不做。</li>
<li><b>CDN / 带宽升配</b>:方案 B 落地后顾客路径只剩 ~20KB,3Mbps 足够;员工 Web 版首开 ~12s 可接受(App 端无此问题)。留作后续可选项,不在本期。</li>
<li><b>Flutter 产物瘦身</b>deferred loading / wasm 构建):改造面大、收益被方案 B 覆盖,不做。</li>
<li><b>换标签二维码内容</b>:短链 <code>/product/&lt;id&gt;</code> 保持不变,历史标签零作废。</li>
</ul>
</div>
</body>
</html>
+1
View File
@@ -38,6 +38,7 @@
<h2>🏗 设计方案</h2>
<ul>
<li><a href="pay支付对接开发指南.html">pay 支付对接开发指南</a><span class="tag html">HTML</span> <span class="hint">— jiu 门店应用内购买/续费授权:付款走 pay,付成功后 pay 签名 webhook 回调 jiu 直接续期。含签名算法、下单/回调接口契约、套餐 biz_code→权益映射、续期逻辑、安全红线、客户端(Web+App webview)、联调清单。pay 侧已就绪,本文档=jiu 侧要实现的部分</span></li>
<li><a href="design/public-page-speedup.html">公开页提速方案(传输压缩 + 公开页全面去 Flutter 化)</a><span class="tag html">HTML</span> <span class="hint">— 扫码首开 30s 根因与两级修复:CI 预压缩+gzip_static+协商缓存 / 商品页+店铺列表页后端 SSR,公开动线零 Flutter2026-07-07</span></li>
<li><a href="design/order-return-prototype.html">退单原型(已审核单据)</a><span class="tag html">HTML</span></li>
<li><a href="design/order-print-layouts.html">出入库单打印排版方案(6 种黑白样式精选)</a><span class="tag html">HTML</span> · <a href="design/order-print-layouts.pdf">PDF</a></li>
<li><a href="design/stock-in-cost-confirm-design.html">入库确认进价(暂估价前向补偿)设计</a><span class="tag html">HTML</span></li>