Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b14eb426b | |||
| 0de9ba7c21 | |||
| 47d6490bf2 | |||
| 8000f00e9e | |||
| d8f7fd903e | |||
| db9afb952c | |||
| f3efda3691 | |||
| d19e88e3f4 | |||
| dbea2dc11a | |||
| 58a0bbca1b | |||
| 451947c8d1 | |||
| 9ea38a9b56 | |||
| 656a7459d8 | |||
| 31bf645c30 | |||
| 3573f015c3 | |||
| d9f1f6956e | |||
| 75d0accb14 |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -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`)、商品抽屉成本行。
|
||||
- 财务口径不变:应收/应付、退单冲账均按上述合计列;出库退单展示与冲账同为售价口径。
|
||||
|
||||
### 库存变更
|
||||
|
||||
@@ -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)
|
||||
|
||||
// admin:unit_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 / readonly:unit_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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.go(2026-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")
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
// 公开页 SSR(2026-07-07 设计 docs/design/public-page-speedup.html):
|
||||
// 商品页 /product/:public_id 与店铺页 /shop/:shop_code 由后端直出轻量 HTML(~20KB),
|
||||
// 顾客扫码/分享动线不再加载 Flutter Web(12.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 公开商品数据装配结果——GetProduct(JSON API)与 ProductPage(SSR)共用。
|
||||
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 店铺公开商品列表查询——ListShopProducts(JSON API)与 ShopPage(SSR)共用。
|
||||
// keyword 为空时不过滤(API 现状);非空时按 名称/编号/全拼/首字母 模糊匹配(SSR 搜索框)。
|
||||
func (h *PublicHandler) queryShopProducts(shopID uint64, page, pageSize int, keyword string) ([]publicProductResp, int64, error) {
|
||||
// 仅列「有库存」的商品:JOIN 库存按 product 聚合(数量>0)的子查询。
|
||||
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>
|
||||
@@ -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((
|
||||
|
||||
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 209 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 215 KiB |
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 142 KiB |
|
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),
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
# 店铺公开商品列表页 SSR(2026-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;
|
||||
|
||||
@@ -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/<public_id>?code=P1331</span> 首开约 30 秒 · 目标机 ali(3Mbps 固定带宽)· 用户拍板:所有公开页不使用 Flutter</div>
|
||||
|
||||
<h2>一、问题与实测</h2>
|
||||
<div class="card lead">
|
||||
<p>公开商品页(顾客扫标签二维码 / 微信分享打开)走的是 Flutter Web 深链:无论从 <code>/product/<id></code> 短链还是 <code>/app/product/<id></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 出网带宽固定 3Mbps(PayByBandwidth)→ 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>< 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>A′1 · 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 meta(title/description/image/url)直接写进模板 head,<b>复用现有 <code>buildProductOG</code>/<code>buildOGTitle</code></b>;微信/飞书分享卡片行为不变,且不再需要 UA 分流</td></tr>
|
||||
<tr><td>样式</td><td>内联 CSS,全页 < 20KB(gzip 后 ~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/<shop_code></code>(B4 店铺 SSR 页);页脚小链接「在 App 中查看」→ <code>/app/product/<id></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>(backend,router.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/<public_id></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/<id></code></td><td>Flutter 壳(~30s)</td><td><span class="tag ok">轻量页 <0.5s</span> 历史已印标签直接受益,无需换码</td></tr>
|
||||
<tr><td>浏览器直开 / 刷新 <code>/app/product/<id></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/<shop_code></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/<id></code> 首开 < 0.5s(total time),页面含商品名/图/门店信息</li>
|
||||
<li>微信发送 <code>/product/<id></code> 链接 → 分享卡片(标题/图)正常</li>
|
||||
<li>响应体不含 cost / purchase_price 等敏感字段名</li>
|
||||
<li>App 内「公开页预览」导航正常;<code>/app/product/<id></code> 刷新落到轻量页</li>
|
||||
<li><code>/shop/<code></code> 直开出店铺列表页,搜索(?q=)与翻页(?page=)可用;商品卡点进 <code>/product/<id></code>;超日配额出 429</li>
|
||||
<li>手机弱网(蜂窝)实测扫标签二维码体感</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2>六、明确不做(本期)</h2>
|
||||
<div class="card lead">
|
||||
<ul>
|
||||
<li><b>brotli</b>:ali 的 nginx 1.24 无 brotli 模块,装第三方模块运维成本 > 收益(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/<id></code> 保持不变,历史标签零作废。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -38,6 +38,7 @@
|
||||
<h2>🏗 设计方案</h2>
|
||||
<ul>
|
||||
<li><a href="pay支付对接开发指南.html">pay 支付对接开发指南</a><span class="tag html">HTML</span> <span class="hint">— jiu 门店应用内购买/续费授权:付款走 pay,付成功后 pay 签名 webhook 回调 jiu 直接续期。含签名算法、下单/回调接口契约、套餐 biz_code→权益映射、续期逻辑、安全红线、客户端(Web+App webview)、联调清单。pay 侧已就绪,本文档=jiu 侧要实现的部分</span></li>
|
||||
<li><a href="design/public-page-speedup.html">公开页提速方案(传输压缩 + 公开页全面去 Flutter 化)</a><span class="tag html">HTML</span> <span class="hint">— 扫码首开 30s 根因与两级修复:CI 预压缩+gzip_static+协商缓存 / 商品页+店铺列表页后端 SSR,公开动线零 Flutter(2026-07-07)</span></li>
|
||||
<li><a href="design/order-return-prototype.html">退单原型(已审核单据)</a><span class="tag html">HTML</span></li>
|
||||
<li><a href="design/order-print-layouts.html">出入库单打印排版方案(6 种黑白样式精选)</a><span class="tag html">HTML</span> · <a href="design/order-print-layouts.pdf">PDF</a></li>
|
||||
<li><a href="design/stock-in-cost-confirm-design.html">入库确认进价(暂估价前向补偿)设计</a><span class="tag html">HTML</span></li>
|
||||
|
||||