Compare commits

..

10 Commits

Author SHA1 Message Date
wangjia 1ec1d4209a chore: release v1.0.24
Deploy / build-linux-web (push) Successful in 59s
Deploy / build-windows (push) Successful in 1m50s
Deploy / build-macos (push) Successful in 1m22s
Deploy / build-android (push) Successful in 1m26s
Deploy / build-ios (push) Successful in 7s
Deploy / release-deploy (push) Successful in 1m39s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:31:55 +08:00
wangjia 22f07ed805 feat(client): 入库表单关联商品属性字典(产地/保质期/储存方式/描述文档)
- product_option.dart: 新增 4 个字典 model(Origin/ShelfLife/Storage/DescriptionDoc)
- product_option_repository.dart: 新增 4 个 list 方法(listOrigins/ShelfLives/Storages/DescriptionDocs)
- product_option_provider.dart: 新增 4 个 FutureProvider 供表单使用
- product_repository.dart: findOrCreate 加 originId/shelfLifeId/storageId/descriptionDocId 可选参数
- stock_in_form_screen.dart: _ItemRow 加 4 个可选 ID 字段;新增 4 个 SearchableOptionField 方法;移动端卡片视图增加 4 个可选字段;提交时传入属性 ID

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 07:03:07 +08:00
wangjia 45b6ec832b feat(client): 公开商品页改读真实数据,移除茅台 mock 硬编码
- _DescriptionSection: 接受 title/body/keywords 三参数,移除 _mockBody/_mockKeywords 和茅台专属文案
- _ParamsCard: 新增 origin/shelfLife/storage 参数;产地空时不显示,生产日期无时不显示,保质期/储存方式用后端返回值(含默认话术)
- 数据提取段:新增 origin/shelfLife/storage/descriptionTitle/descriptionKeywords 从 API 响应读取
- 删除未使用的 _extractKeywords 方法

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 06:58:28 +08:00
wangjia ba127826b2 feat(backend): 新增商品属性字典表(产地/保质期/储存方式/描述文档)
- model/product_attr.go: 新增 4 张字典表 struct(TenantBase 范式)
- model/product.go: Product 增加 4 个可空外键 + belongs-to 关联
- handler/product_attr.go: 4 组 16 个 CRUD handler(shop_id 隔离)
- handler/product.go: FindOrCreate/Update 支持写入 4 个属性 ID
- handler/public.go: Preload 4 表 + 三级回退介绍 + 默认话术常量
- router/router.go: /product-options/{origins,shelf-lives,storages,description-docs} 路由
- main.go: AutoMigrate 注册 4 个新 model
- testutil/setup.go: SQLite 测试库补齐新列与新表

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 06:56:16 +08:00
wangjia 0e2a4086e1 chore(todo): 补录 product 公开页 6 条待办(价格展示、硬编码清理、死交互等) 2026-06-08 02:12:54 +08:00
wangjia 99f34a6223 chore(web): 删除 scan.html,清理 /scan/ nginx 路由和文档
scan.html 从未被实际触发——二维码 URL 格式为 /product/<uuid>,
由 Flutter Web PublicProductScreen 处理,scan.html 是孤立死页。

- 删除 web/scan.html
- 移除 nginx-jiu.conf 中 /scan/ location 块
- 更新文档路由表(/scan/<id> → /product/<uuid>)
- 标记 todo #4 完成

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 02:07:19 +08:00
wangjia ce92d619ed chore(todo): 标记 #1 两套 CSS 设计令牌问题已完成(v1.0.23) 2026-06-08 01:48:41 +08:00
wangjia 22a31f6eb8 fix(web): 合并两套 CSS 设计令牌,清理 features 冗余样式
- 删除 colors_and_type.css(与 color.css 完全相同,改 scan.html 引用 color.css)
- 删除 shared.css(无任何页面引用,已被 base.njk+style.css 替代)
- 移除 features-inventory.css / features-approval.css 头部 105 行重复代码
  (reset、.btn、.container、.section、.eyebrow、.topnav 均由 base.njk 提供,features 页已走模板)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:48:32 +08:00
wangjia 1c901432a6 chore(todo): 删除不再需要的旧版备份 todo.legacy.html 2026-06-08 01:40:17 +08:00
wangjia e124ed7c65 feat(todo): 新增 /todo slash command,支持 add/done/list/reopen/rm
- todo/todo.json:结构化 JSON 真相源(id/标题/重要度3级/平台标签/版本)
- todo/todo.mjs:Node 零依赖单文件 CLI,写操作自动重渲染 HTML
- todo/todo.html:重新设计布局(未完成置顶/已完成置底/sticky 筛选栏)
- .claude/commands/todo.md:/todo slash command
- CLAUDE.md:追加 TODO 管理规范(唯一真相源、完成即标记、禁止使用其它 todo 工具)

旧 todo.html 备份为 todo.legacy.html;存量 6 条待办已导入 todo.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 01:31:41 +08:00
33 changed files with 2734 additions and 1396 deletions
+67
View File
@@ -0,0 +1,67 @@
---
description: 项目 TODO 管理(list / add / done / reopen / rm
argument-hint: "list | add <描述> | done <id> [version] | reopen <id> | rm <id>"
model: claude-sonnet-4-6
allowed-tools: Bash, Read
---
管理项目 `todo/todo.json` 待办清单,所有写操作会自动重渲染 `todo/todo.html`
**重要规则**:所有待办项必须通过本命令管理,禁止使用其它 todo 工具(TaskCreate/TodoWrite 等)。
---
根据 `$ARGUMENTS` 分派,缺省等同 `list`
## list(或无参数)
运行以下命令并将终端 summary 转述给用户,同时提示 HTML 路径便于浏览:
```bash
node todo/todo.mjs list
```
## add <描述>
`$ARGUMENTS` 去掉首词 `add` 后的内容解析为待办描述,推断:
- `--title`:核心一句话标题(简洁,去掉平台/类别信息)
- `--level``high`(阻断/紧急)/ `mid`(重要,默认)/ `low`(一般/优化)
- 关键词「紧急」「阻断」「必须」「严重」→ high;「重要」「需要」→ mid;其余 → low
- `--tags`:从描述推断平台/类别,可多个逗号分隔,常用值:`前端,后端,Web,mac,Windows,Android,iOS,数据库,CI/CD,文档`
- `--desc`:补充说明(可选,如有具体文件/路径/上下文则填入)
然后运行:
```bash
node todo/todo.mjs add --title "..." --level mid --tags "前端,Web" --desc "..."
```
转述:「已添加 #id [级别] 标题 标签」,并显示 summary。
## done <id> [version]
`$ARGUMENTS` 提取 id(数字)和可选 version(格式 vX.Y.Z):
- 有 version`node todo/todo.mjs done <id> --version <version>`
- 无 version`node todo/todo.mjs done <id>`(脚本自动用 `git describe --tags --abbrev=0`
转述:「#id 标记完成,记入版本 vX.Y.Z」,并显示 summary。
## reopen <id>
```bash
node todo/todo.mjs reopen <id>
```
转述「#id 已重新开启」。
## rm <id>
```bash
node todo/todo.mjs rm <id>
```
转述「#id 已删除」。
---
所有命令完成后,在终端显示来自脚本的完整 summary(共/未完成/已完成及分级列表)。
+12
View File
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.24] - 2026-06-08
### 新功能
- 扫码商品页新增「查看本店其他商品」功能,点击即可浏览该门店全部上架商品
- 门店可在设置中填写微信号,顾客扫码后可直接查看并添加门店微信
- 商品公开页展示建议零售价(有填写时显示)
- 入库时可为商品关联产地、保质期、储存方式、描述文档,关联后扫码页自动呈现真实信息
- 扫码商品页产地、保质期、储存方式、商品介绍改读数据库真实数据,不再显示占位内容
### 改进
- 新增产地/保质期/储存方式/描述文档四类基础数据字典,支持增删改查,可在入库时按需关联商品
## [1.0.23] - 2026-06-07
### 修复
+22
View File
@@ -308,6 +308,28 @@ CI/CDForgejo`.gitea/workflows/deploy.yml`)收到 tag 后自动:编译
}
```
### 项目 TODO 管理
**唯一待办系统**:任务执行过程中如发现需要做的事项,**必须**用 `/todo add <描述>` 记入项目 todo 系统,**禁止**使用 TaskCreate/TodoWrite 等其它 todo 工具,保持单一真相源。
- **真相源**`todo/todo.json`(结构化 JSON,含 id/标题/重要度/平台标签/创建时间/完成版本)
- **HTML**`todo/todo.html` 为生成物,每次写操作自动重渲染
- **重要度**`high`(阻断/紧急)/ `mid`(重要)/ `low`(一般/优化)三级
- **完成即标记**:完成对应 todo 后运行 `/todo done <id>`,脚本自动记入当前 git tag 版本
常用命令:
```
/todo — 查看 summary(含未完成分级列表)
/todo add 修复... — 新增待办(Claude 自动推断级别/标签)
/todo done <id> — 标记完成,自动记入版本号
/todo reopen <id> — 撤销完成
/todo rm <id> — 删除
```
底层脚本:`node todo/todo.mjs <子命令> [参数]`
---
### 搜索实现(拼音)
商品名搜索同时支持汉字、全拼、首字母:
+56 -27
View File
@@ -127,22 +127,26 @@ func (h *ProductHandler) Update(c *gin.Context) {
namePinyin, nameInitials := util.ToPinyin(req.Name)
// 只更新业务字段,防止 Save() 覆盖 shop_id / created_at 等系统字段
if err := h.db.Model(&product).Updates(map[string]interface{}{
"code": req.Code,
"barcode": req.Barcode,
"name": req.Name,
"series": req.Series,
"spec": req.Spec,
"unit": req.Unit,
"category_id": req.CategoryID,
"brand": req.Brand,
"purchase_price": req.PurchasePrice,
"sale_price": req.SalePrice,
"min_stock": req.MinStock,
"description": req.Description,
"remark": req.Remark,
"custom_fields": req.CustomFields,
"name_pinyin": namePinyin,
"name_initials": nameInitials,
"code": req.Code,
"barcode": req.Barcode,
"name": req.Name,
"series": req.Series,
"spec": req.Spec,
"unit": req.Unit,
"category_id": req.CategoryID,
"brand": req.Brand,
"purchase_price": req.PurchasePrice,
"sale_price": req.SalePrice,
"min_stock": req.MinStock,
"description": req.Description,
"remark": req.Remark,
"custom_fields": req.CustomFields,
"name_pinyin": namePinyin,
"name_initials": nameInitials,
"origin_id": req.OriginID,
"shelf_life_id": req.ShelfLifeID,
"storage_id": req.StorageID,
"description_doc_id": req.DescriptionDocID,
}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -208,9 +212,13 @@ func (h *ProductHandler) QRCode(c *gin.Context) {
func (h *ProductHandler) FindOrCreate(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Name string `json:"name" binding:"required"`
Series string `json:"series"`
Spec string `json:"spec"`
Name string `json:"name" binding:"required"`
Series string `json:"series"`
Spec string `json:"spec"`
OriginID *uint64 `json:"origin_id"`
ShelfLifeID *uint64 `json:"shelf_life_id"`
StorageID *uint64 `json:"storage_id"`
DescriptionDocID *uint64 `json:"description_doc_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -221,6 +229,23 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
err := h.db.Where("shop_id = ? AND name = ? AND series = ? AND spec = ? AND deleted_at IS NULL",
shopID, req.Name, req.Series, req.Spec).First(&product).Error
if err == nil {
// 商品已存在:如果传入了属性 ID,则更新属性关联
attrUpdates := map[string]interface{}{}
if req.OriginID != nil {
attrUpdates["origin_id"] = req.OriginID
}
if req.ShelfLifeID != nil {
attrUpdates["shelf_life_id"] = req.ShelfLifeID
}
if req.StorageID != nil {
attrUpdates["storage_id"] = req.StorageID
}
if req.DescriptionDocID != nil {
attrUpdates["description_doc_id"] = req.DescriptionDocID
}
if len(attrUpdates) > 0 {
h.db.Model(&product).Updates(attrUpdates)
}
c.JSON(http.StatusOK, gin.H{"data": product})
return
}
@@ -233,14 +258,18 @@ func (h *ProductHandler) FindOrCreate(c *gin.Context) {
h.db.Model(&model.Product{}).Where("shop_id = ? AND deleted_at IS NULL", shopID).Count(&count)
namePinyin, nameInitials := util.ToPinyin(req.Name)
product = model.Product{
TenantBase: model.TenantBase{ShopID: shopID},
PublicID: uuid.New().String(),
Name: req.Name,
Series: req.Series,
Spec: req.Spec,
Code: fmt.Sprintf("P%03d", count+1),
NamePinyin: namePinyin,
NameInitials: nameInitials,
TenantBase: model.TenantBase{ShopID: shopID},
PublicID: uuid.New().String(),
Name: req.Name,
Series: req.Series,
Spec: req.Spec,
Code: fmt.Sprintf("P%03d", count+1),
NamePinyin: namePinyin,
NameInitials: nameInitials,
OriginID: req.OriginID,
ShelfLifeID: req.ShelfLifeID,
StorageID: req.StorageID,
DescriptionDocID: req.DescriptionDocID,
}
if createErr := h.db.Create(&product).Error; createErr != nil {
// Race condition: try to find the record created by another request
+268
View File
@@ -0,0 +1,268 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
// ProductAttrHandler 处理商品属性字典(产地/保质期/储存方式/描述文档)
type ProductAttrHandler struct {
db *gorm.DB
}
func NewProductAttrHandler(db *gorm.DB) *ProductAttrHandler {
return &ProductAttrHandler{db: db}
}
// ── 产地(origins) ──────────────────────────────────────────────
func (h *ProductAttrHandler) ListOrigins(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductOriginOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *ProductAttrHandler) CreateOrigin(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Code string `json:"code"`
Name string `json:"name" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
item := model.ProductOriginOption{
TenantBase: model.TenantBase{ShopID: shopID},
Code: req.Code,
Name: req.Name,
Remark: req.Remark,
}
if err := h.db.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
}
func (h *ProductAttrHandler) UpdateOrigin(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Code string `json:"code"`
Name string `json:"name" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var item model.ProductOriginOption
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
}
func (h *ProductAttrHandler) DeleteOrigin(c *gin.Context) {
shopID := middleware.GetShopID(c)
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductOriginOption{})
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// ── 保质期(shelf-lives) ─────────────────────────────────────────
func (h *ProductAttrHandler) ListShelfLives(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductShelfLifeOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *ProductAttrHandler) CreateShelfLife(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Code string `json:"code"`
Name string `json:"name" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
item := model.ProductShelfLifeOption{
TenantBase: model.TenantBase{ShopID: shopID},
Code: req.Code,
Name: req.Name,
Remark: req.Remark,
}
if err := h.db.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
}
func (h *ProductAttrHandler) UpdateShelfLife(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Code string `json:"code"`
Name string `json:"name" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var item model.ProductShelfLifeOption
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
}
func (h *ProductAttrHandler) DeleteShelfLife(c *gin.Context) {
shopID := middleware.GetShopID(c)
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductShelfLifeOption{})
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// ── 储存方式(storages) ──────────────────────────────────────────
func (h *ProductAttrHandler) ListStorages(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductStorageOption, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *ProductAttrHandler) CreateStorage(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Code string `json:"code"`
Name string `json:"name" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
item := model.ProductStorageOption{
TenantBase: model.TenantBase{ShopID: shopID},
Code: req.Code,
Name: req.Name,
Remark: req.Remark,
}
if err := h.db.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
}
func (h *ProductAttrHandler) UpdateStorage(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Code string `json:"code"`
Name string `json:"name" binding:"required"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var item model.ProductStorageOption
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Code = req.Code
item.Name = req.Name
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
}
func (h *ProductAttrHandler) DeleteStorage(c *gin.Context) {
shopID := middleware.GetShopID(c)
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductStorageOption{})
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// ── 描述文档(description-docs) ─────────────────────────────────
func (h *ProductAttrHandler) ListDescriptionDocs(c *gin.Context) {
shopID := middleware.GetShopID(c)
items := make([]model.ProductDescriptionDoc, 0)
h.db.Where("shop_id = ?", shopID).Order("id ASC").Find(&items)
c.JSON(http.StatusOK, gin.H{"data": items})
}
func (h *ProductAttrHandler) CreateDescriptionDoc(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Title string `json:"title" binding:"required"`
Content string `json:"content"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
item := model.ProductDescriptionDoc{
TenantBase: model.TenantBase{ShopID: shopID},
Title: req.Title,
Content: req.Content,
Remark: req.Remark,
}
if err := h.db.Create(&item).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"data": item})
}
func (h *ProductAttrHandler) UpdateDescriptionDoc(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
Title string `json:"title" binding:"required"`
Content string `json:"content"`
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var item model.ProductDescriptionDoc
if err := h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).First(&item).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
item.Title = req.Title
item.Content = req.Content
item.Remark = req.Remark
h.db.Save(&item)
c.JSON(http.StatusOK, gin.H{"data": item})
}
func (h *ProductAttrHandler) DeleteDescriptionDoc(c *gin.Context) {
shopID := middleware.GetShopID(c)
h.db.Where("id = ? AND shop_id = ?", c.Param("id"), shopID).Delete(&model.ProductDescriptionDoc{})
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
+188 -10
View File
@@ -1,7 +1,11 @@
package handler
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -9,6 +13,12 @@ import (
"github.com/wangjia/jiu/backend/internal/model"
)
// 默认话术(保质期/储存方式空时使用)
const (
defaultShelfLife = "无限期(适饮)"
defaultStorage = "阴凉干燥、避光保存"
)
type PublicHandler struct {
db *gorm.DB
}
@@ -24,6 +34,10 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
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 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
@@ -39,6 +53,7 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
"address": shop.Address,
"phone": shop.Phone,
"business_hours": shop.BusinessHours,
"wechat_id": shop.WechatID,
}
}
@@ -60,22 +75,99 @@ func (h *PublicHandler) GetProduct(c *gin.Context) {
}
}
// 产地:空串表示无,前端不展示该行
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,
"name": product.Name,
"series": product.Series,
"spec": product.Spec,
"brand": product.Brand,
"unit": product.Unit,
"description": product.Description,
"images": product.Images,
"shop": shopData,
"batch": batchData,
"id": product.ID,
"name": product.Name,
"series": product.Series,
"spec": product.Spec,
"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,
"images": product.Images,
"shop": shopData,
"batch": batchData,
},
})
}
// buildDescription 商品介绍三级回退逻辑
// 1. DescriptionDoc.Content(结构化介绍文档)
// 2. Product.Description(旧自由文本)
// 3. 通用中性兜底(用真实字段拼,不含品牌专属词)
func buildDescription(p model.Product) (title, body string, keywords []string) {
// 1. 描述文档
if p.DescriptionDoc != nil && p.DescriptionDoc.Content != "" {
return p.DescriptionDoc.Title, p.DescriptionDoc.Content, buildKeywords(p)
}
// 2. 旧 Description 自由文本
if p.Description != "" {
return "商品介绍", p.Description, buildKeywords(p)
}
// 3. 通用中性兜底
parts := make([]string, 0, 4)
if p.Brand != "" && p.Name != "" {
parts = append(parts, fmt.Sprintf("%s%s", p.Brand, p.Name))
} else if p.Name != "" {
parts = append(parts, p.Name)
}
if p.Series != "" {
parts = append(parts, fmt.Sprintf("%s系列", p.Series))
}
if p.Spec != "" {
parts = append(parts, fmt.Sprintf("规格%s", p.Spec))
}
intro := strings.Join(parts, "")
if intro != "" {
intro += "。"
}
intro += "本商品由门店正品供应,支持扫码验真,请认准官方渠道。"
return "商品介绍", intro, buildKeywords(p)
}
// buildKeywords 从真实商品属性派生关键词 chips
func buildKeywords(p model.Product) []string {
kws := make([]string, 0, 4)
if p.Brand != "" {
kws = append(kws, p.Brand)
}
if p.Series != "" {
kws = append(kws, p.Series)
}
kws = append(kws, "正品保障", "扫码验真")
return kws
}
// GetRelease GET /api/v1/public/release (no auth)
func (h *PublicHandler) GetRelease(c *gin.Context) {
cfg, err := loadVersionConfig()
@@ -93,3 +185,89 @@ func (h *PublicHandler) GetRelease(c *gin.Context) {
"download_urls": cfg.DownloadURLs,
})
}
type publicProductImage struct {
URL string `json:"url"`
}
type publicProductResp struct {
ID uint64 `json:"id"`
PublicID string `json:"public_id"`
Name string `json:"name"`
Series string `json:"series"`
Spec string `json:"spec"`
Brand string `json:"brand"`
Unit string `json:"unit"`
SalePrice float64 `json:"sale_price"`
Images []publicProductImage `json:"images"`
}
// ListShopProducts GET /api/v1/public/shops/:shop_code/products (no auth)
func (h *PublicHandler) ListShopProducts(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 {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "shop not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 50 {
pageSize = 20
}
query := h.db.Model(&model.Product{}).
Where("shop_id = ? AND public_id IS NOT NULL AND public_id != '' AND deleted_at IS NULL", shop.ID)
var total int64
if err := query.Count(&total).Error; 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("id DESC").
Find(&products).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
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,
Name: p.Name,
Series: p.Series,
Spec: p.Spec,
Brand: p.Brand,
Unit: p.Unit,
SalePrice: p.SalePrice,
Images: imgs,
}
}
c.JSON(http.StatusOK, gin.H{
"data": listData,
"total": total,
"page": page,
"page_size": pageSize,
})
}
+2
View File
@@ -47,6 +47,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
Phone string `json:"phone"`
ManagerName string `json:"manager_name"`
LogoURL string `json:"logo_url"`
WechatID string `json:"wechat_id"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -58,6 +59,7 @@ func (h *ShopHandler) UpdateInfo(c *gin.Context) {
"address": req.Address,
"phone": req.Phone,
"manager_name": req.ManagerName,
"wechat_id": req.WechatID,
}
if req.LogoURL != "" {
updates["logo_url"] = req.LogoURL
+16 -6
View File
@@ -22,11 +22,21 @@ type Product struct {
SalePrice float64 `gorm:"type:decimal(12,2)" json:"sale_price"`
MinStock int `gorm:"default:0" json:"min_stock"`
Description string `gorm:"type:text" json:"description"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
NamePinyin string `gorm:"size:400;index" json:"-"`
NameInitials string `gorm:"size:100;index" json:"-"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
Remark string `gorm:"size:500" json:"remark"`
NamePinyin string `gorm:"size:400;index" json:"-"`
NameInitials string `gorm:"size:100;index" json:"-"`
Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
// 商品属性字典外键(可空,公开页展示用)
OriginID *uint64 `json:"origin_id"`
ShelfLifeID *uint64 `json:"shelf_life_id"`
StorageID *uint64 `json:"storage_id"`
DescriptionDocID *uint64 `json:"description_doc_id"`
Category *ProductCategory `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
Images []ProductImage `gorm:"foreignKey:ProductID" json:"images,omitempty"`
Origin *ProductOriginOption `gorm:"foreignKey:OriginID" json:"origin,omitempty"`
ShelfLife *ProductShelfLifeOption `gorm:"foreignKey:ShelfLifeID" json:"shelf_life,omitempty"`
Storage *ProductStorageOption `gorm:"foreignKey:StorageID" json:"storage,omitempty"`
DescriptionDoc *ProductDescriptionDoc `gorm:"foreignKey:DescriptionDocID" json:"description_doc,omitempty"`
}
+34
View File
@@ -0,0 +1,34 @@
package model
// ProductOriginOption 产地字典(门店级)
type ProductOriginOption struct {
TenantBase
Code string `gorm:"size:50" json:"code"`
Name string `gorm:"size:200;not null" json:"name"`
Remark string `gorm:"size:500" json:"remark"`
}
// ProductShelfLifeOption 保质期字典(门店级)
type ProductShelfLifeOption struct {
TenantBase
Code string `gorm:"size:50" json:"code"`
Name string `gorm:"size:200;not null" json:"name"` // 如「无限期(适饮)」「24个月」
Remark string `gorm:"size:500" json:"remark"`
}
// ProductStorageOption 储存方式字典(门店级)
type ProductStorageOption struct {
TenantBase
Code string `gorm:"size:50" json:"code"`
Name string `gorm:"size:200;not null" json:"name"` // 如「阴凉干燥、避光保存」
Remark string `gorm:"size:500" json:"remark"`
}
// ProductDescriptionDoc 商品描述文档字典(门店级)
// Title = 酒类型(如「酱香型白酒」),Content = 商品介绍正文
type ProductDescriptionDoc struct {
TenantBase
Title string `gorm:"size:200;not null" json:"title"`
Content string `gorm:"type:text" json:"content"`
Remark string `gorm:"size:500" json:"remark"`
}
+1
View File
@@ -10,6 +10,7 @@ type Shop struct {
BusinessHours string `gorm:"size:100" json:"business_hours"`
ManagerName string `gorm:"size:50" json:"manager_name"`
LogoURL string `gorm:"column:logo_url;size:500" json:"logo_url"`
WechatID string `gorm:"size:100" json:"wechat_id"`
BusinessLicense string `gorm:"size:500" json:"business_license"`
ShopPhotos JSON `gorm:"type:json" json:"shop_photos,omitempty"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
+23
View File
@@ -27,6 +27,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
importH := handler.NewImportHandler(db)
userH := handler.NewUserHandler(db)
productOptH := handler.NewProductOptionHandler(db)
productAttrH := handler.NewProductAttrHandler(db)
productImageH := handler.NewProductImageHandler(db)
financeH := handler.NewFinanceHandler(db)
numberRuleH := handler.NewNumberRuleHandler(db)
@@ -57,6 +58,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
public := v1.Group("/public")
{
public.GET("/products/:public_id", publicH.GetProduct)
public.GET("/shops/:shop_code/products", publicH.ListShopProducts)
public.GET("/release", publicH.GetRelease)
public.POST("/errors", errorReportH.Submit)
public.POST("/register", authH.Register)
@@ -217,6 +219,27 @@ func Setup(r *gin.Engine, db *gorm.DB) {
opts.POST("/specs", productOptH.CreateSpec)
opts.PUT("/specs/:id", productOptH.UpdateSpec)
opts.DELETE("/specs/:id", productOptH.DeleteSpec)
// 商品属性字典(产地/保质期/储存方式/描述文档)
opts.GET("/origins", productAttrH.ListOrigins)
opts.POST("/origins", productAttrH.CreateOrigin)
opts.PUT("/origins/:id", productAttrH.UpdateOrigin)
opts.DELETE("/origins/:id", productAttrH.DeleteOrigin)
opts.GET("/shelf-lives", productAttrH.ListShelfLives)
opts.POST("/shelf-lives", productAttrH.CreateShelfLife)
opts.PUT("/shelf-lives/:id", productAttrH.UpdateShelfLife)
opts.DELETE("/shelf-lives/:id", productAttrH.DeleteShelfLife)
opts.GET("/storages", productAttrH.ListStorages)
opts.POST("/storages", productAttrH.CreateStorage)
opts.PUT("/storages/:id", productAttrH.UpdateStorage)
opts.DELETE("/storages/:id", productAttrH.DeleteStorage)
opts.GET("/description-docs", productAttrH.ListDescriptionDocs)
opts.POST("/description-docs", productAttrH.CreateDescriptionDoc)
opts.PUT("/description-docs/:id", productAttrH.UpdateDescriptionDoc)
opts.DELETE("/description-docs/:id", productAttrH.DeleteDescriptionDoc)
}
// 超级管理员专属
+4
View File
@@ -107,6 +107,10 @@ func autoMigrate(db *gorm.DB) {
&model.ProductNameOption{},
&model.ProductSeriesOption{},
&model.ProductSpecOption{},
&model.ProductOriginOption{},
&model.ProductShelfLifeOption{},
&model.ProductStorageOption{},
&model.ProductDescriptionDoc{},
&model.ProductImage{},
&model.ErrorReport{},
&model.Feedback{},
+46 -1
View File
@@ -58,6 +58,7 @@ func SetupTestDB() *gorm.DB {
phone TEXT,
manager_name TEXT,
logo_url TEXT DEFAULT '',
wechat_id TEXT DEFAULT '',
business_license TEXT,
business_hours TEXT,
shop_photos TEXT,
@@ -124,7 +125,51 @@ func SetupTestDB() *gorm.DB {
custom_fields TEXT,
remark TEXT,
name_pinyin TEXT DEFAULT '',
name_initials TEXT DEFAULT ''
name_initials TEXT DEFAULT '',
origin_id INTEGER,
shelf_life_id INTEGER,
storage_id INTEGER,
description_doc_id INTEGER
)`,
`CREATE TABLE IF NOT EXISTS product_origin_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_shelf_life_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_storage_options (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS product_description_docs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
shop_id INTEGER NOT NULL,
title TEXT NOT NULL,
content TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
+11 -1
View File
@@ -14,6 +14,7 @@ import '../../screens/finance/finance_screen.dart';
import '../../screens/products/products_screen.dart';
import '../../screens/products/product_detail_screen.dart';
import '../../screens/public/public_product_screen.dart';
import '../../screens/public/public_shop_products_screen.dart';
import '../../screens/settings/settings_screen.dart';
import '../../screens/about/about_screen.dart';
import '../auth/auth_state.dart';
@@ -40,7 +41,8 @@ class _RouterNotifier extends ChangeNotifier {
final isLoggedIn = authState.isLoggedIn;
final loc = state.matchedLocation;
final isPublicRoute = loc == '/login' ||
loc.startsWith('/product/');
loc.startsWith('/product/') ||
loc.startsWith('/shop/');
final result = !authState.initialized
? null
: (!isLoggedIn && !isPublicRoute)
@@ -79,6 +81,14 @@ final appRouterProvider = Provider<GoRouter>((ref) {
builder: (context, state) =>
PublicProductScreen(publicId: state.pathParameters['public_id']!),
),
// Public shop product list — no auth, no shell nav bar
GoRoute(
path: '/shop/:shop_code',
builder: (context, state) => PublicShopProductsScreen(
shopCode: state.pathParameters['shop_code']!,
shopName: state.uri.queryParameters['shopName'] ?? '',
),
),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
+92
View File
@@ -66,3 +66,95 @@ class ProductSpecOption {
remark: json['remark'] as String?,
);
}
// ── 商品属性字典(产地/保质期/储存方式) ─────────────────────────
class ProductOriginOption {
final int id;
final String? code;
final String name;
final String? remark;
const ProductOriginOption({
required this.id,
this.code,
required this.name,
this.remark,
});
factory ProductOriginOption.fromJson(Map<String, dynamic> json) =>
ProductOriginOption(
id: (json['id'] as num).toInt(),
code: json['code'] as String?,
name: json['name'] as String,
remark: json['remark'] as String?,
);
}
class ProductShelfLifeOption {
final int id;
final String? code;
final String name;
final String? remark;
const ProductShelfLifeOption({
required this.id,
this.code,
required this.name,
this.remark,
});
factory ProductShelfLifeOption.fromJson(Map<String, dynamic> json) =>
ProductShelfLifeOption(
id: (json['id'] as num).toInt(),
code: json['code'] as String?,
name: json['name'] as String,
remark: json['remark'] as String?,
);
}
class ProductStorageOption {
final int id;
final String? code;
final String name;
final String? remark;
const ProductStorageOption({
required this.id,
this.code,
required this.name,
this.remark,
});
factory ProductStorageOption.fromJson(Map<String, dynamic> json) =>
ProductStorageOption(
id: (json['id'] as num).toInt(),
code: json['code'] as String?,
name: json['name'] as String,
remark: json['remark'] as String?,
);
}
// ── 描述文档 ─────────────────────────────────────────────────────
class ProductDescriptionDoc {
final int id;
final String title;
final String? content;
final String? remark;
const ProductDescriptionDoc({
required this.id,
required this.title,
this.content,
this.remark,
});
factory ProductDescriptionDoc.fromJson(Map<String, dynamic> json) =>
ProductDescriptionDoc(
id: (json['id'] as num).toInt(),
title: json['title'] as String,
content: json['content'] as String?,
remark: json['remark'] as String?,
);
}
+3
View File
@@ -6,6 +6,7 @@ class ShopInfo {
final String phone;
final String managerName;
final String logoUrl;
final String wechatId;
const ShopInfo({
required this.id,
@@ -15,6 +16,7 @@ class ShopInfo {
required this.phone,
required this.managerName,
this.logoUrl = '',
this.wechatId = '',
});
factory ShopInfo.fromJson(Map<String, dynamic> json) => ShopInfo(
@@ -25,5 +27,6 @@ class ShopInfo {
phone: json['phone'] as String? ?? '',
managerName: json['manager_name'] as String? ?? '',
logoUrl: json['logo_url'] as String? ?? '',
wechatId: json['wechat_id'] as String? ?? '',
);
}
@@ -125,3 +125,39 @@ class ProductSpecListNotifier extends AsyncNotifier<List<ProductSpecOption>> {
reload();
}
}
// ── 产地 ──────────────────────────────────────────────────────
final productOriginListProvider =
FutureProvider<List<ProductOriginOption>>((ref) async {
ref.watch(authStateProvider.select((s) => s.user?.shopId));
ref.watch(networkRecoveryCountProvider);
return ref.read(productOptionRepositoryProvider).listOrigins();
});
// ── 保质期 ────────────────────────────────────────────────────
final productShelfLifeListProvider =
FutureProvider<List<ProductShelfLifeOption>>((ref) async {
ref.watch(authStateProvider.select((s) => s.user?.shopId));
ref.watch(networkRecoveryCountProvider);
return ref.read(productOptionRepositoryProvider).listShelfLives();
});
// ── 储存方式 ──────────────────────────────────────────────────
final productStorageListProvider =
FutureProvider<List<ProductStorageOption>>((ref) async {
ref.watch(authStateProvider.select((s) => s.user?.shopId));
ref.watch(networkRecoveryCountProvider);
return ref.read(productOptionRepositoryProvider).listStorages();
});
// ── 描述文档 ──────────────────────────────────────────────────
final productDescriptionDocListProvider =
FutureProvider<List<ProductDescriptionDoc>>((ref) async {
ref.watch(authStateProvider.select((s) => s.user?.shopId));
ref.watch(networkRecoveryCountProvider);
return ref.read(productOptionRepositoryProvider).listDescriptionDocs();
});
@@ -127,4 +127,56 @@ class ProductOptionRepository {
statusCode: e.response?.statusCode);
}
}
// ── 产地 ────────────────────────────────────────────────────
Future<List<ProductOriginOption>> listOrigins() async {
try {
final resp = await _client.get('/product-options/origins');
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
return data.map((e) => ProductOriginOption.fromJson(e as Map<String, dynamic>)).toList();
} on DioException catch (e) {
throw AppException(e.response?.data?['error'] as String? ?? '获取产地列表失败',
statusCode: e.response?.statusCode);
}
}
// ── 保质期 ──────────────────────────────────────────────────
Future<List<ProductShelfLifeOption>> listShelfLives() async {
try {
final resp = await _client.get('/product-options/shelf-lives');
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
return data.map((e) => ProductShelfLifeOption.fromJson(e as Map<String, dynamic>)).toList();
} on DioException catch (e) {
throw AppException(e.response?.data?['error'] as String? ?? '获取保质期列表失败',
statusCode: e.response?.statusCode);
}
}
// ── 储存方式 ─────────────────────────────────────────────────
Future<List<ProductStorageOption>> listStorages() async {
try {
final resp = await _client.get('/product-options/storages');
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
return data.map((e) => ProductStorageOption.fromJson(e as Map<String, dynamic>)).toList();
} on DioException catch (e) {
throw AppException(e.response?.data?['error'] as String? ?? '获取储存方式列表失败',
statusCode: e.response?.statusCode);
}
}
// ── 描述文档 ─────────────────────────────────────────────────
Future<List<ProductDescriptionDoc>> listDescriptionDocs() async {
try {
final resp = await _client.get('/product-options/description-docs');
final data = (resp.data as Map<String, dynamic>)['data'] as List? ?? [];
return data.map((e) => ProductDescriptionDoc.fromJson(e as Map<String, dynamic>)).toList();
} on DioException catch (e) {
throw AppException(e.response?.data?['error'] as String? ?? '获取描述文档列表失败',
statusCode: e.response?.statusCode);
}
}
}
@@ -135,13 +135,22 @@ class ProductRepository {
required String name,
String series = '',
String spec = '',
int? originId,
int? shelfLifeId,
int? storageId,
int? descriptionDocId,
}) async {
try {
final resp = await _client.post('/products/find-or-create', data: {
final body = <String, dynamic>{
'name': name,
'series': series,
'spec': spec,
});
};
if (originId != null) body['origin_id'] = originId;
if (shelfLifeId != null) body['shelf_life_id'] = shelfLifeId;
if (storageId != null) body['storage_id'] = storageId;
if (descriptionDocId != null) body['description_doc_id'] = descriptionDocId;
final resp = await _client.post('/products/find-or-create', data: body);
return Product.fromJson(
(resp.data as Map<String, dynamic>)['data'] as Map<String, dynamic>);
} on DioException catch (e) {
@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:dio/dio.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../core/config/app_config.dart';
@@ -115,6 +116,14 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
final brand = d['brand'] as String? ?? '';
final unit = d['unit'] as String? ?? '';
final description = d['description'] as String? ?? '';
final descriptionTitle = d['description_title'] as String? ?? '商品介绍';
final descriptionKeywords = (d['description_keywords'] as List<dynamic>?)
?.map((e) => e as String)
.toList() ?? const <String>[];
final salePrice = (d['sale_price'] as num?)?.toDouble() ?? 0.0;
final origin = d['origin'] as String? ?? '';
final shelfLife = d['shelf_life'] as String? ?? '无限期(适饮)';
final storage = d['storage'] as String? ?? '阴凉干燥、避光保存';
final shop = d['shop'] as Map<String, dynamic>?;
final batch = d['batch'] as Map<String, dynamic>?;
final quickSpecs = _parseQuickSpecs(spec, batch?['production_date'] as String?);
@@ -146,14 +155,23 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
unit: unit,
),
),
// 4. Quick specs (only if we could parse any)
// 4a. Sale price badge (only when price > 0)
if (salePrice > 0)
SliverToBoxAdapter(
child: _PriceBadge(price: salePrice),
),
// 4b. Quick specs (only if we could parse any)
if (quickSpecs.isNotEmpty)
SliverToBoxAdapter(
child: _QuickSpecs(items: quickSpecs),
),
// 5. Description — always shown; falls back to editorial mock text
// 5. Description — always shown; body/keywords come from backend (三级回退)
SliverToBoxAdapter(
child: _DescriptionSection(description: description),
child: _DescriptionSection(
title: descriptionTitle,
body: description,
keywords: descriptionKeywords,
),
),
// 6. Params — full table
SliverToBoxAdapter(
@@ -164,6 +182,9 @@ class _PublicProductScreenState extends State<PublicProductScreen> {
series: series,
unit: unit,
productionDate: productionDate,
origin: origin,
shelfLife: shelfLife,
storage: storage,
),
),
// 7. Authenticity card (batch + stamp)
@@ -593,6 +614,43 @@ class _VerifiedRibbon extends StatelessWidget {
}
}
// ── Price Badge ───────────────────────────────────────────────────────
class _PriceBadge extends StatelessWidget {
final double price;
const _PriceBadge({required this.price});
@override
Widget build(BuildContext context) {
final priceStr = price % 1 == 0
? price.toInt().toString()
: price.toStringAsFixed(2);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
decoration: const BoxDecoration(
color: _kPaper,
border: Border(bottom: BorderSide(color: _kBorder)),
),
child: Row(
children: [
const Text('建议零售价',
style: TextStyle(fontSize: 13, color: _kTextMid)),
const Spacer(),
Text(
'¥$priceStr',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: _kBurgundy,
letterSpacing: 0.04,
),
),
],
),
);
}
}
// ── Title Block ───────────────────────────────────────────────────────
class _TitleBlock extends StatelessWidget {
@@ -813,22 +871,17 @@ class _QuickSpecs extends StatelessWidget {
// ── Description Section ───────────────────────────────────────────────
class _DescriptionSection extends StatelessWidget {
final String description;
const _DescriptionSection({required this.description});
static const _mockBody =
'飞天牌茅台酒采用本地优质红高粱与小麦为原料,承袭历经数百年沉淀的酱香酿造工艺,'
'经一年一个生产周期、两次投料、九次蒸煮、八次发酵、七次取酒,'
'于陶坛中陈藏至少五年,方得装瓶。色泽微黄透明,开瓶酱香盈室,'
'入口绵柔不烈,咽下后舌底生津,余香萦绕,是中国酱香型白酒之典范。';
static const _mockKeywords = ['酱香典范', '陈藏五年', '回味悠长', '国家地理标志保护产品'];
final String title;
final String body;
final List<String> keywords;
const _DescriptionSection({
required this.title,
required this.body,
required this.keywords,
});
@override
Widget build(BuildContext context) {
final body = description.isNotEmpty ? description : _mockBody;
final keywords = description.isNotEmpty ? _extractKeywords(description) : _mockKeywords;
return Container(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
decoration: const BoxDecoration(
@@ -838,7 +891,7 @@ class _DescriptionSection extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _SectionHeader(title: '商品介绍'),
_SectionHeader(title: title),
const SizedBox(height: 14),
Text(
body,
@@ -865,10 +918,6 @@ class _DescriptionSection extends StatelessWidget {
);
}
List<String> _extractKeywords(String text) {
final matches = RegExp(r'【([^】]+)】').allMatches(text);
return matches.map((m) => m.group(1)!).toList();
}
}
// ── Authenticity Card ─────────────────────────────────────────────────
@@ -1062,6 +1111,9 @@ class _StampPainter extends CustomPainter {
class _ParamsCard extends StatelessWidget {
final String publicId, spec, brand, series, unit;
final String? productionDate;
final String origin; // 空串表示无产地数据,不显示该行
final String shelfLife; // 后端已回填默认值
final String storage; // 后端已回填默认值
const _ParamsCard({
required this.publicId,
required this.spec,
@@ -1069,6 +1121,9 @@ class _ParamsCard extends StatelessWidget {
required this.series,
required this.unit,
this.productionDate,
this.origin = '',
this.shelfLife = '无限期(适饮)',
this.storage = '阴凉干燥、避光保存',
});
@override
@@ -1086,10 +1141,10 @@ class _ParamsCard extends StatelessWidget {
if (degM != null) (label: '酒精度', value: '${degM.group(1)!}% vol', mono: false),
if (mlM != null) (label: '净含量', value: '${mlM.group(1)!} ml', mono: false),
if (spec.isNotEmpty) (label: '规格', value: spec, mono: false),
(label: '产地', value: '贵州省仁怀市茅台镇', mono: false),
(label: '生产日期', value: productionDate ?? '2024-01-01', mono: false),
(label: '保质期', value: '无限期(适饮)', mono: false),
(label: '储存方式', value: '阴凉干燥、避光保存', mono: false),
if (origin.isNotEmpty) (label: '产地', value: origin, mono: false),
if (productionDate != null) (label: '生产日期', value: productionDate!, mono: false),
(label: '保质期', value: shelfLife, mono: false),
(label: '储存方式', value: storage, mono: false),
];
if (rows.isEmpty) return const SizedBox.shrink();
@@ -1176,6 +1231,7 @@ class _ShopCard extends StatelessWidget {
final address = shop['address'] as String? ?? '';
final phone = shop['phone'] as String? ?? '';
final hours = shop['business_hours'] as String? ?? '';
final wechatId = shop['wechat_id'] as String? ?? '';
// Avatar text: first 2 chars of shop name
final avatarText = name.length >= 2 ? name.substring(0, 2) : name;
@@ -1238,7 +1294,10 @@ class _ShopCard extends StatelessWidget {
SizedBox(
width: double.infinity, height: 44,
child: ElevatedButton(
onPressed: () {},
onPressed: code.isNotEmpty
? () => context.push(
'/shop/$code?shopName=${Uri.encodeComponent(name)}')
: null,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0A1F3B),
foregroundColor: Colors.white,
@@ -1249,27 +1308,58 @@ class _ShopCard extends StatelessWidget {
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.04)),
),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity, height: 40,
child: OutlinedButton.icon(
onPressed: () {},
style: OutlinedButton.styleFrom(
foregroundColor: _kInkDeep,
side: const BorderSide(color: _kBorder, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
if (wechatId.isNotEmpty) ...[
const SizedBox(height: 10),
SizedBox(
width: double.infinity, height: 40,
child: OutlinedButton.icon(
onPressed: () => _showWechatDialog(context, wechatId),
style: OutlinedButton.styleFrom(
foregroundColor: _kInkDeep,
side: const BorderSide(color: _kBorder, width: 1.5),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
),
icon: const Icon(Icons.person_outline, size: 16),
label: const Text('添加门店微信',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
),
icon: const Icon(Icons.person_outline, size: 16),
label: const Text('添加门店微信',
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
),
),
],
],
),
);
}
}
void _showWechatDialog(BuildContext context, String wechatId) {
showDialog<void>(
context: context,
builder: (_) => AlertDialog(
title: const Text('门店微信号', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wechat, size: 48, color: Color(0xFF07C160)),
const SizedBox(height: 12),
SelectableText(
wechatId,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, letterSpacing: 0.5),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
const Text('长按上方微信号可复制', style: TextStyle(fontSize: 12, color: _kTextMid)),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('关闭'),
),
],
),
);
}
class _ShopInfoRow extends StatelessWidget {
final IconData icon;
final String text;
@@ -0,0 +1,381 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../core/config/app_config.dart';
const _kPaper = Color(0xFFFAF8F5);
const _kPaperDeep = Color(0xFFF4F1EB);
const _kInkDeep = Color(0xFF161412);
const _kTextMid = Color(0xFF6E7888);
const _kBurgundy = Color(0xFF8B2331);
const _kBorder = Color(0xFFE8E4DC);
class PublicShopProductsScreen extends StatefulWidget {
final String shopCode;
final String shopName;
const PublicShopProductsScreen({
super.key,
required this.shopCode,
this.shopName = '',
});
@override
State<PublicShopProductsScreen> createState() =>
_PublicShopProductsScreenState();
}
class _PublicShopProductsScreenState
extends State<PublicShopProductsScreen> {
final _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 15),
));
final List<Map<String, dynamic>> _items = [];
bool _loading = true;
bool _loadingMore = false;
String? _error;
int _page = 1;
bool _hasMore = true;
int _total = 0;
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
_load(refresh: true);
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200 &&
!_loadingMore &&
_hasMore) {
_load();
}
}
Future<void> _load({bool refresh = false}) async {
if (refresh) {
setState(() {
_loading = true;
_error = null;
_page = 1;
_hasMore = true;
_items.clear();
});
} else {
if (_loadingMore) return;
setState(() => _loadingMore = true);
}
try {
final resp = await _dio.get(
'${AppConfig.apiBaseUrl}/public/shops/${widget.shopCode}/products',
queryParameters: {'page': _page, 'page_size': 20},
);
final body = resp.data as Map<String, dynamic>;
final list = (body['data'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final total = (body['total'] as num?)?.toInt() ?? 0;
if (!mounted) return;
setState(() {
_total = total;
_items.addAll(list);
_hasMore = _items.length < total;
_page++;
_loading = false;
_loadingMore = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_loading = false;
_loadingMore = false;
});
}
}
@override
Widget build(BuildContext context) {
final title =
widget.shopName.isNotEmpty ? widget.shopName : '本店商品';
return Scaffold(
backgroundColor: _kPaperDeep,
appBar: AppBar(
backgroundColor: _kPaper,
elevation: 0,
surfaceTintColor: Colors.transparent,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, size: 18),
color: _kInkDeep,
onPressed: () => context.pop(),
),
title: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _kInkDeep),
),
centerTitle: true,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1),
child: Container(height: 1, color: _kBorder),
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_loading) {
return const Center(
child: CircularProgressIndicator(
color: _kBurgundy, strokeWidth: 2));
}
if (_error != null && _items.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 56,
height: 56,
decoration: const BoxDecoration(
color: Color(0xFFFDECEC), shape: BoxShape.circle),
child: const Icon(Icons.error_outline,
size: 28, color: Color(0xFFD14343)),
),
const SizedBox(height: 16),
const Text('加载失败,请稍后重试',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: _kInkDeep)),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => _load(refresh: true),
style: ElevatedButton.styleFrom(
backgroundColor: _kBurgundy,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8)),
),
child: const Text('重试'),
),
],
),
),
);
}
if (_items.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inventory_2_outlined, size: 48, color: _kTextMid),
SizedBox(height: 12),
Text('暂无上架商品',
style: TextStyle(fontSize: 15, color: _kTextMid)),
],
),
);
}
return RefreshIndicator(
color: _kBurgundy,
onRefresh: () => _load(refresh: true),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: CustomScrollView(
controller: _scrollController,
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
sliver: SliverToBoxAdapter(
child: Text(
'$_total 件商品',
style: const TextStyle(
fontSize: 12, color: _kTextMid),
),
),
),
SliverPadding(
padding: const EdgeInsets.all(12),
sliver: SliverGrid(
delegate: SliverChildBuilderDelegate(
(context, index) =>
_ProductCard(item: _items[index]),
childCount: _items.length,
),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 10,
childAspectRatio: 0.72,
),
),
),
if (_loadingMore)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(
color: _kBurgundy, strokeWidth: 2)),
),
),
if (!_hasMore && _items.isNotEmpty)
const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.all(20),
child: Center(
child: Text('已展示全部商品',
style: TextStyle(
fontSize: 12, color: _kTextMid))),
),
),
],
),
),
),
);
}
}
class _ProductCard extends StatelessWidget {
final Map<String, dynamic> item;
const _ProductCard({required this.item});
@override
Widget build(BuildContext context) {
final publicId = item['public_id'] as String? ?? '';
final name = item['name'] as String? ?? '';
final series = item['series'] as String? ?? '';
final spec = item['spec'] as String? ?? '';
final images = (item['images'] as List<dynamic>? ?? [])
.cast<Map<String, dynamic>>();
final imageUrl = images.isNotEmpty
? AppConfig.baseUrl + (images.first['url'] as String)
: null;
final subtitle = [series, spec].where((s) => s.isNotEmpty).join(' · ');
return GestureDetector(
onTap: () {
if (publicId.isNotEmpty) {
context.push('/product/$publicId');
}
},
child: Container(
decoration: BoxDecoration(
color: _kPaper,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: _kBorder, width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 商品图片
ClipRRect(
borderRadius:
const BorderRadius.vertical(top: Radius.circular(10)),
child: AspectRatio(
aspectRatio: 1,
child: imageUrl != null
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_PlaceholderImg(),
)
: _PlaceholderImg(),
),
),
// 商品信息
Expanded(
child: Padding(
padding:
const EdgeInsets.fromLTRB(8, 6, 8, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _kInkDeep,
height: 1.3),
),
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11, color: _kTextMid),
),
],
const Spacer(),
Row(
children: [
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: _kBurgundy,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'查看详情',
style: TextStyle(
fontSize: 10,
color: Colors.white,
fontWeight: FontWeight.w500),
),
),
],
),
],
),
),
),
],
),
),
);
}
}
class _PlaceholderImg extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
color: _kPaperDeep,
child: const Center(
child: Icon(Icons.wine_bar_outlined, size: 40, color: _kBorder),
),
);
}
}
@@ -1822,6 +1822,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
late final TextEditingController _addressCtrl;
late final TextEditingController _phoneCtrl;
late final TextEditingController _managerCtrl;
late final TextEditingController _wechatCtrl;
bool _saving = false;
bool _uploadingLogo = false;
@@ -1832,6 +1833,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
_addressCtrl = TextEditingController(text: widget.shop.address);
_phoneCtrl = TextEditingController(text: widget.shop.phone);
_managerCtrl = TextEditingController(text: widget.shop.managerName);
_wechatCtrl = TextEditingController(text: widget.shop.wechatId);
}
@override
@@ -1840,6 +1842,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
_addressCtrl.dispose();
_phoneCtrl.dispose();
_managerCtrl.dispose();
_wechatCtrl.dispose();
super.dispose();
}
@@ -1882,6 +1885,7 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
'address': _addressCtrl.text.trim(),
'phone': _phoneCtrl.text.trim(),
'manager_name': _managerCtrl.text.trim(),
'wechat_id': _wechatCtrl.text.trim(),
});
if (mounted) {
Navigator.of(context).pop();
@@ -1950,6 +1954,14 @@ class _ShopEditDialogState extends ConsumerState<_ShopEditDialog> {
controller: _managerCtrl,
decoration: const InputDecoration(labelText: '负责人'),
),
const SizedBox(height: 12),
TextField(
controller: _wechatCtrl,
decoration: const InputDecoration(
labelText: '微信号(可选)',
hintText: '填写后顾客扫码可查看',
),
),
],
),
),
@@ -226,7 +226,15 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
try {
final product = await ref
.read(productRepositoryProvider)
.findOrCreate(name: name, series: series, spec: spec);
.findOrCreate(
name: name,
series: series,
spec: spec,
originId: item.selectedOriginId,
shelfLifeId: item.selectedShelfLifeId,
storageId: item.selectedStorageId,
descriptionDocId: item.selectedDescriptionDocId,
);
item.productId = product.id;
} catch (e) {
if (mounted) {
@@ -761,6 +769,78 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
);
}
Widget _originField(_ItemRow item) {
final asyncOrigins = ref.watch(productOriginListProvider);
return asyncOrigins.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (origins) => SearchableOptionField(
options: origins
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
.toList(),
selectedId: item.selectedOriginId,
hint: '选择产地(可选)',
dialogTitle: '选择产地',
isRequired: false,
onChanged: (v) => setState(() => item.selectedOriginId = v),
),
);
}
Widget _shelfLifeField(_ItemRow item) {
final asyncShelfLives = ref.watch(productShelfLifeListProvider);
return asyncShelfLives.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (shelfLives) => SearchableOptionField(
options: shelfLives
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
.toList(),
selectedId: item.selectedShelfLifeId,
hint: '选择保质期(可选)',
dialogTitle: '选择保质期',
isRequired: false,
onChanged: (v) => setState(() => item.selectedShelfLifeId = v),
),
);
}
Widget _storageField(_ItemRow item) {
final asyncStorages = ref.watch(productStorageListProvider);
return asyncStorages.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (storages) => SearchableOptionField(
options: storages
.map((o) => OptionItem(id: o.id, name: o.name, code: o.code))
.toList(),
selectedId: item.selectedStorageId,
hint: '选择储存方式(可选)',
dialogTitle: '选择储存方式',
isRequired: false,
onChanged: (v) => setState(() => item.selectedStorageId = v),
),
);
}
Widget _descriptionDocField(_ItemRow item) {
final asyncDocs = ref.watch(productDescriptionDocListProvider);
return asyncDocs.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
data: (docs) => SearchableOptionField(
options: docs
.map((o) => OptionItem(id: o.id, name: o.title))
.toList(),
selectedId: item.selectedDescriptionDocId,
hint: '选择介绍文档(可选)',
dialogTitle: '选择介绍文档',
isRequired: false,
onChanged: (v) => setState(() => item.selectedDescriptionDocId = v),
),
);
}
int _specQtyOf(_ItemRow item) =>
ref.read(productSpecListProvider).valueOrNull
?.where((o) => o.id == item.selectedSpecId)
@@ -864,6 +944,10 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
MobileCardField('金额', '¥${amount.toStringAsFixed(2)}'),
MobileCardField('批次号', null, valueWidget: _batchField(item)),
MobileCardField('生产日期', null, valueWidget: _dateField(item)),
MobileCardField('产地', null, valueWidget: _originField(item)),
MobileCardField('保质期', null, valueWidget: _shelfLifeField(item)),
MobileCardField('储存方式', null, valueWidget: _storageField(item)),
MobileCardField('介绍文档', null, valueWidget: _descriptionDocField(item)),
],
);
}
@@ -908,6 +992,11 @@ class _ItemRow {
int? selectedNameId;
int? selectedSeriesId;
int? selectedSpecId;
// 商品属性字典(可选,入库时关联到商品)
int? selectedOriginId;
int? selectedShelfLifeId;
int? selectedStorageId;
int? selectedDescriptionDocId;
final TextEditingController qtyCtrl = TextEditingController(text: '1');
final TextEditingController priceCtrl = TextEditingController();
final TextEditingController batchNoCtrl = TextEditingController();
-6
View File
@@ -53,12 +53,6 @@ server {
try_files $uri /app/index.html;
}
# 扫码页(/scan/<id> → scan.htmlJS 从 URL 读取 id
location /scan/ {
root /opt/jiu/marketing;
try_files /scan.html =404;
}
# 桌面客户端安装包下载(Windows .exe / macOS .zip
location /downloads/ {
alias /opt/jiu/downloads/;
+2 -3
View File
@@ -155,8 +155,7 @@ git push nas main && git push nas v1.1.0
│ ├── assets/
│ ├── index.html
│ ├── docs.html
── download.html
│ └── scan.html
── download.html
├── images/ # 商品图片上传目录
└── web-old/ # 上一版本 Flutter Web(回滚备用)
```
@@ -175,5 +174,5 @@ Systemd 服务名:`jiu`
| `/api/*`, `/health`, `/version` | Go 后端 API |
| `/images/*` | 商品图片静态文件 |
| `/app/` | Flutter 管理端 SPA |
| `/product/<uuid>` | 商品扫码公开页(Flutter Web 路由) |
| `/` | 营销首页 |
| `/scan/<id>` | 商品扫码页 |
+444 -267
View File
@@ -3,326 +3,503 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web 站点问题清单 / TODO · 岩美</title>
<title>酒库管理系统 — 项目 TODO</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 0 0 80px;
font-family: "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1f2933; background: #f7f9fb; line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
header {
background: linear-gradient(135deg, #0A1F3B 0%, #15407D 100%);
color: #fff; padding: 40px 32px 32px;
}
header h1 { margin: 0 0 8px; font-size: 26px; font-weight: 700; }
header p { margin: 0; color: #ADC9EA; font-size: 14px; }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 24px; }
.meta { margin-top: 16px; font-size: 13px; color: #8aa3c4; }
* { box-sizing: border-box; }
body {
margin: 0; padding: 0 0 80px;
font-family: "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1f2933; background: #f7f9fb; line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
header {
background: linear-gradient(135deg, #0A1F3B 0%, #15407D 100%);
color: #fff; padding: 36px 32px 28px;
}
header h1 { margin: 0 0 6px; font-size: 24px; font-weight: 700; }
header .header-meta { color: #ADC9EA; font-size: 13px; margin-top: 4px; }
.stats { display: flex; gap: 16px; margin-top: 14px; }
.stat-pill {
background: rgba(255,255,255,0.12); border-radius: 20px;
padding: 4px 14px; font-size: 13px; color: #fff;
}
.stat-pill strong { font-size: 18px; font-weight: 700; margin-right: 2px; }
.legend { display: flex; flex-wrap: wrap; gap: 10px; margin: 24px auto 8px; max-width: 1040px; padding: 0 24px; }
.tag { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.t-block { background: #fde8e8; color: #b91c1c; } /* 阻断/必须改 */
.t-high { background: #fef0d8; color: #9a6700; } /* 重要 */
.t-low { background: #e6f0fb; color: #1d4ed8; } /* 一般/优化 */
.t-known { background: #ececec; color: #555; } /* 已知占位 */
.t-ver { background: #e3f5ea; color: #1f7a44; } /* 已核实 */
.t-done { background: #d1f0dc; color: #1f7a44; } /* 已完成 */
/* ── 筛选栏 ── */
.filter-bar {
position: sticky; top: 0; z-index: 10;
background: #fff; border-bottom: 1px solid #e4e9ef;
padding: 10px 28px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
}
.filter-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.filter-label { font-size: 12px; color: #7b8794; white-space: nowrap; }
.filter-chip, .filter-btn {
padding: 3px 12px; border-radius: 14px; border: 1.5px solid #d1d9e6;
background: #f7f9fb; color: #52606d; font-size: 12.5px; cursor: pointer;
transition: all .15s; white-space: nowrap;
}
.filter-chip:hover, .filter-btn:hover { border-color: #2563AC; color: #2563AC; }
.filter-chip.active, .filter-btn.active {
background: #2563AC; border-color: #2563AC; color: #fff;
}
.filter-sep { width: 1px; height: 20px; background: #e4e9ef; margin: 0 4px; }
section { max-width: 1040px; margin: 28px auto 0; padding: 0 24px; }
h2 {
font-size: 18px; font-weight: 700; color: #0A1F3B;
border-left: 4px solid #2563AC; padding-left: 12px; margin: 0 0 14px;
}
ul.todo { list-style: none; margin: 0; padding: 0; }
ul.todo li {
background: #fff; border: 1px solid #e4e9ef; border-radius: 8px;
padding: 14px 16px; margin-bottom: 10px;
display: grid; grid-template-columns: 22px 1fr; gap: 12px; align-items: start;
}
ul.todo li:has(input:checked) { background: #f3faf5; border-color: #b7e4c7; opacity: 0.7; }
ul.todo li:has(input:checked) .item-title { color: #52606d; text-decoration: line-through; }
ul.todo li input[type=checkbox] { margin-top: 4px; width: 16px; height: 16px; }
.item-title { font-weight: 600; font-size: 15px; }
.item-title .tag { margin-left: 8px; vertical-align: middle; }
.item-desc { font-size: 13.5px; color: #52606d; margin-top: 4px; }
.item-desc code {
background: #f0f2f5; padding: 1px 5px; border-radius: 3px;
font-family: "JetBrains Mono", monospace; font-size: 12.5px; color: #b91c1c;
}
.file { font-family: "JetBrains Mono", monospace; font-size: 12px; color: #7b8794; }
.note { background: #fffbe9; border:1px solid #f0e3b0; border-radius:8px; padding:12px 16px; font-size:13px; color:#6b5b1d; margin-top:14px; }
/* ── 内容区 ── */
.wrap { max-width: 980px; margin: 0 auto; padding: 0 24px; }
.section-title {
font-size: 16px; font-weight: 700; color: #0A1F3B;
border-left: 4px solid #2563AC; padding-left: 12px;
margin: 28px 0 12px;
}
.section-title.done-title { border-color: #6cbb8a; }
ul.todo-list { list-style: none; margin: 0; padding: 0; }
.todo-card {
background: #fff; border: 1px solid #e4e9ef; border-radius: 10px;
padding: 14px 18px; margin-bottom: 10px;
}
.todo-card.done { background: #f3faf5; border-color: #b7e4c7; opacity: .75; }
.card-header { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
.item-title { font-weight: 600; font-size: 15px; flex: 1; }
.todo-card.done .item-title { text-decoration: line-through; color: #52606d; }
.item-desc { font-size: 13.5px; color: #52606d; margin-top: 6px; }
.item-desc code {
background: #f0f2f5; padding: 1px 5px; border-radius: 3px;
font-family: "JetBrains Mono", monospace; font-size: 12.5px; color: #b91c1c;
}
.card-footer { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; }
.item-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #8aa3c4; }
.meta-date { white-space: nowrap; }
/* ── tags ── */
.tag { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.t-block { background: #fde8e8; color: #b91c1c; }
.t-high { background: #fef0d8; color: #9a6700; }
.t-low { background: #e6f0fb; color: #1d4ed8; }
.t-tag { background: #efefef; color: #555; cursor: pointer; transition: .12s; }
.t-tag:hover { background: #d9e8fb; color: #1d4ed8; }
.ver-badge { font-weight: 700; color: #1f7a44; }
/* ── 折叠 ── */
.done-section { margin-bottom: 24px; }
.done-toggle {
background: none; border: 1.5px solid #d1d9e6; border-radius: 6px;
padding: 4px 14px; cursor: pointer; font-size: 13px; color: #52606d;
margin-bottom: 10px; transition: .15s;
}
.done-toggle:hover { border-color: #6cbb8a; color: #1f7a44; }
.empty-tip { font-size: 14px; color: #8aa3c4; margin: 8px 0 24px; }
/* 隐藏 */
.hidden { display: none !important; }
</style>
</head>
<body>
<header>
<div class="wrap">
<h1>Web 营销站点 — 问题清单 / TODO</h1>
<p>检查范围:产品介绍(首页)· 功能详情页 · 价格 · 下载 · 文档 · 注册 · 页眉页脚</p>
<div class="meta">生成日期 2026-06-07 · 对照后端实现核对 · 代码版本 v1.0.20</div>
<h1>酒库管理系统 — 项目 TODO</h1>
<div class="header-meta">生成于 2026-06-08 · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>15</strong>全部</div>
<div class="stat-pill"><strong>7</strong>未完成</div>
<div class="stat-pill"><strong>8</strong>已完成</div>
</div>
</div>
</header>
<div class="legend">
<span class="tag t-block">阻断 必须改</span>
<span class="tag t-high">重要</span>
<span class="tag t-low">一般/优化</span>
<span class="tag t-known">已知占位(暂留)</span>
<span class="tag t-ver">已核实代码</span>
<span class="tag t-done">已完成</span>
<div class="filter-bar" id="filterBar">
<div class="filter-group">
<span class="filter-label">重要</span>
<button class="filter-btn active" data-filter-level="all">全部</button>
<button class="filter-btn" data-filter-level="high">高优</button>
<button class="filter-btn" data-filter-level="mid">重要</button>
<button class="filter-btn" data-filter-level="low">一般</button>
</div>
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">状态</span>
<button class="filter-btn active" data-filter-done="all">全部</button>
<button class="filter-btn" data-filter-done="0">未完成</button>
<button class="filter-btn" data-filter-done="1">已完成</button>
</div>
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">平台 / 标签</span>
<button class="filter-chip" data-filter-tag="Android">Android</button><button class="filter-chip" data-filter-tag="Web">Web</button><button class="filter-chip" data-filter-tag="iOS">iOS</button><button class="filter-chip" data-filter-tag="前端">前端</button><button class="filter-chip" data-filter-tag="后端">后端</button><button class="filter-chip" data-filter-tag="数据库">数据库</button>
</div>
</div>
<!-- ============ 虚假/夸大宣传(最优先,已核实代码) ============ -->
<section>
<h2>一、功能宣传与实际实现不符(已对照后端代码核实</h2>
<ul class="todo">
<li><input type="checkbox" checked />
<div>
<div class="item-title">「红冲入库 / 红冲出库」未实现 <span class="tag t-block">阻断</span><span class="tag t-ver">已核实</span></div>
<div class="item-desc">后端 <code>internal/</code> 全局无任何红冲 / reversal / 冲销逻辑,但首页 FAQ「审批通过后发现错误」与 <span class="file">features/approval.html</span> 第 5 块(红冲 / 净额演示)大篇幅宣传。属虚假宣传,需删除或落地功能。</div>
<div class="wrap">
<div class="section-title">📋 未完成(7</div>
<ul class="todo-list" id="pendingList">
<li class="todo-card"
data-id="2"
data-level="high"
data-tags="Web"
data-done="0">
<div class="card-header">
<span class="item-title">备案号为占位假值</span>
<span class="tag t-block">高优 · 紧急</span>
</div>
<div class="item-desc">现为沪ICP备2026000000号(全0),上线前必须替换真实备案号。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">「调拨单」未实现 <span class="tag t-block">阻断</span><span class="tag t-ver">已核实</span></div>
<div class="item-desc">后端无 transfer / 调拨 任何实现。<span class="file">features/approval.html</span> 状态机把「调拨单」与入库/出库/盘点并列为四大单据。需移除调拨相关展示。</div>
<li class="todo-card"
data-id="3"
data-level="low"
data-tags="前端,Web"
data-done="0">
<div class="card-header">
<span class="item-title">注册接口同源依赖问题</span>
<span class="tag t-low">一般 / 优化</span>
</div>
<div class="item-desc">用 window.location.origin+'/api/v1/public/register',要求同源部署;分离部署时指错。需确认部署形态。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">「库存预警 / 阈值推送」严重夸大 <span class="tag t-block">阻断</span><span class="tag t-ver">已核实</span></div>
<div class="item-desc">后端仅有 <code>min_stock</code> 单一字段用于显示「偏低/告急」标记,<b>没有</b>任何告警、推送、按 SKU 多级阈值配置、按操作员分发的能力。但首页「库存预警实时提醒」与 <span class="file">features/inventory.html</span> 整块「预警阈值配置 / 推送给张磊 / 短信通知李建国」均为虚构界面。需大幅收敛文案。</div>
<li class="todo-card"
data-id="5"
data-level="low"
data-tags="前端,Web"
data-done="0">
<div class="card-header">
<span class="item-title">页脚双份维护</span>
<span class="tag t-low">一般 / 优化</span>
</div>
<div class="item-desc">njk 站点用 footer.njk 读变量,功能页把页脚硬编码各写一份,改一处不同步。根因同「两套模板」。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">「短信通知 / 邮件提醒」未实现 <span class="tag t-high">重要</span><span class="tag t-ver">已核实</span></div>
<div class="item-desc">后端无 SMS / 邮件发送能力。inventory/approval 页多处「短信通知」「邮件提醒」需删除。</div>
<li class="todo-card"
data-id="6"
data-level="low"
data-tags="前端,Web"
data-done="0">
<div class="card-header">
<span class="item-title">注册成功提示路径需核对</span>
<span class="tag t-low">一般 / 优化</span>
</div>
<div class="item-desc">提示「忘记门店编码可在「系统设置→关于」查看」,需与实际客户端菜单路径一致。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">「过期预警 / 保质期推算」未实现 <span class="tag t-high">重要</span><span class="tag t-ver">已核实</span></div>
<div class="item-desc">后端 expire 相关仅为「license 授权到期」,无商品保质期/过期预警。<span class="file">features/inventory.html</span> 批次块的「过期预警」与阈值卡「临近过期 30 天邮件提醒」为虚构。</div>
<li class="todo-card"
data-id="13"
data-level="low"
data-tags="后端,iOS,Android"
data-done="0">
<div class="card-header">
<span class="item-title">公开商品 API 补充 batch.quantity 和 product code/barcode</span>
<span class="tag t-low">一般 / 优化</span>
</div>
<div class="item-desc">Flutter _AuthenticityCard 读取 batch['quantity'] 但后端 public.go batchData 未返回 quantity。另 product code/barcode 未暴露,参数卡无法显示商品编码。handler/public.go GetProduct</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">「智能补货建议 v1.7 Beta」「SSO 单点登录」未实现 <span class="tag t-low">一般</span><span class="tag t-ver">已核实</span></div>
<div class="item-desc">补货建议(inventory 页标 v1.7 Beta)、价格连锁版的「SSO 单点登录」后端均无。若作为路线图保留需明确标注「规划中」,否则移除。</div>
<li class="todo-card"
data-id="14"
data-level="low"
data-tags="前端,iOS,Android"
data-done="0">
<div class="card-header">
<span class="item-title">Footer「商品报错」「意见反馈」补充实际功能</span>
<span class="tag t-low">一般 / 优化</span>
</div>
<div class="item-desc">public_product_screen.dart _Footer 底部两个链接无 url/action,点击静默。需设计方案:跳转反馈表单、弹出联系方式、或发邮件。line:1353-1355</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">「允许超量出库」可配置开关 待核实 <span class="tag t-low">一般</span></div>
<div class="item-desc">approval 页声称系统设置可开启「允许超量出库」,后端未检索到 oversell/超量 配置项,需确认是否真有该设置。</div>
<li class="todo-card"
data-id="18"
data-level="low"
data-tags="前端,iOS,Android"
data-done="0">
<div class="card-header">
<span class="item-title">基础数据管理 UI 增加 4 个字典维护 Tab</span>
<span class="tag t-low">一般 / 优化</span>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">营销数据疑似杜撰 <span class="tag t-high">重要</span></div>
<div class="item-desc">首页 Trust 区「1,280+ 门店 / 8.4% 损耗下降 / 99.9% 可用性」无数据支撑;功能页「14 个审计字段」「审批延迟 ≤1s」等均需核实,否则改为中性表述。</div>
<div class="item-desc">products_screen.dart 基础数据管理页增加产地/保质期/储存方式/描述文档 4 个维护 Tab,复用现有 option 维护组件(增删改查);描述文档 Tab 含 title+content 多行输入</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
</div>
</div>
</li>
</ul>
<div class="note">✅ 已核实<b>确实存在</b>的功能(文案无需改):FIFO 批次扣减出库(<code>stock.go ApproveStockOut</code>)、多仓库(<code>warehouse</code>)、库存盘点、入库/出库审批联动库存+应付应收、商品 <code>min_stock</code> 状态标记、拼音搜索、扫码验真(scan.html)。</div>
</section>
<!-- ============ 版本/品牌一致性 ============ -->
<section>
<h2>二、版本号与品牌/域名一致性</h2>
<ul class="todo">
<li><input type="checkbox" checked />
<div>
<div class="item-title">首页 Hero 版本号造假 <span class="tag t-block">阻断</span></div>
<div class="item-desc">首页徽标硬编码「v1.6 · 新增多仓库联动盘点」,inventory 页又写「v1.6.2」,而真实版本为 <b>v1.0.20</b>(CHANGELOG)。需统一为真实版本,最好像下载页一样从 <code>changelog</code> 数据动态取。<span class="file">index.njk:180-183</span></div>
<div class="done-section">
<div class="section-title done-title">✅ 已完成(8</div>
<button class="done-toggle" id="doneToggle">▾ 展开已完成列表</button>
<ul class="todo-list hidden" id="doneList">
<li class="todo-card done"
data-id="10"
data-level="mid"
data-tags="前端,后端,iOS,Android"
data-done="1">
<div class="card-header">
<span class="item-title">公开商品 API 补充 sale_price 并在页面展示价格</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">product model 有 sale_price 字段但 public.go GetProduct 未返回。需:1) 后端 batchData/productData 加 sale_price2) Flutter _TitleBlock 或单独区块展示建议零售价。client: public_product_screen.dart, backend: handler/public.go</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">品牌域名三处不统一 <span class="tag t-high">重要</span></div>
<div class="item-desc">首页预览写 <code>yanmei.app/inventory</code>、客服邮箱 <code>support@yanmei.app</code>,而真实应用域名是 <code>jiu.51yanmei.com</code>。需统一域名口径。<span class="file">index.njk:201 · _data/site.json</span></div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">下载页 API 域名硬编码 <span class="tag t-low">一般</span></div>
<div class="item-desc"><code>https://jiu.51yanmei.com</code> 直接写死在 <span class="file">download.njk</span> 的 JS 里(释放接口、各端下载、Web 入口),应抽到 <code>_data/site.json</code> 统一管理,便于换域名/环境。</div>
</div>
</li>
</ul>
</section>
<!-- ============ 页眉 ============ -->
<section>
<h2>三、页眉(导航)</h2>
<ul class="todo">
<li><input type="checkbox" checked />
<div>
<div class="item-title">功能详情页死链:<code>/download.html</code> <span class="tag t-block">阻断</span></div>
<div class="item-desc">两个功能页(手写 HTML)导航和页脚都链向 <code>/download.html</code><code>/docs.html</code>,但 Eleventy 实际产出 pretty URL <code>/download/</code><code>/docs/</code>。dist 中无 <code>download.html</code> → 死链。统一改为 <code>/download/</code><code>/docs/</code><span class="file">features/inventory.html:660-661 · approval.html 同</span></div>
<li class="todo-card done"
data-id="9"
data-level="mid"
data-tags="前端,iOS,Android"
data-done="1">
<div class="card-header">
<span class="item-title">设计并实现添加门店微信功能</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">PublicProductScreen _ShopCard「添加门店微信」按钮 onPressed: () {} 为空。需设计方案:门店维护微信号字段 or 二维码图片,扫码页展示。涉及后端 shop model 扩展 + 前端展示。client/lib/screens/public/public_product_screen.dart:1255</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">功能详情页未进主导航(孤岛页面) <span class="tag t-high">重要</span></div>
<div class="item-desc"><span class="file">features/inventory.html</span><span class="file">approval.html</span> 制作精良,但主站导航「产品」只跳首页锚点 <code>/#modules</code>,这两页仅能从页脚或彼此跳转进入。建议在导航或首页模块卡加「了解更多」入口。</div>
</div>
</li>
<li><input type="checkbox" />
<div>
<div class="item-title">两套 CSS / 设计令牌并存 <span class="tag t-high">重要</span></div>
<div class="item-desc">主站 njk 用 <code>color.css + style.css</code>,功能页用 <code>colors_and_type.css</code>,导航/页脚样式各写一份。维护分裂、易不一致,建议合并到一套并让功能页也走 base.njk 模板。</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">注册入口不一致 <span class="tag t-low">一般</span></div>
<div class="item-desc">功能页 JS 未登录态指向 <code>/app/login</code><code>/app/register</code>Flutter 内路由),而 njk 站点有独立 web 注册页 <code>/register/</code>。两套注册入口口径不一,需统一。</div>
</div>
</li>
</ul>
</section>
<!-- ============ 页脚 ============ -->
<section>
<h2>四、页脚</h2>
<ul class="todo">
<li><input type="checkbox" checked />
<div>
<div class="item-title">大量占位死链 <code>href="#"</code> <span class="tag t-high">重要</span></div>
<div class="item-desc">页脚以下链接全部为空锚点,需逐一落地或移除:API 文档、视频教程、数据导入模板、提交工单、技术支持、服务等级、关于岩美、客户案例、招贤纳士、联系我们、版本更新。<span class="file">footer.njk · features/*.html</span></div>
<li class="todo-card done"
data-id="8"
data-level="mid"
data-tags="前端,iOS,Android"
data-done="1">
<div class="card-header">
<span class="item-title">补全「查看本店其他商品」功能</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">PublicProductScreen _ShopCard 底部按钮 onPressed: () {} 为空,需实现跳转至该门店商品列表页。client/lib/screens/public/public_product_screen.dart:1240</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li><input type="checkbox" />
<div>
<div class="item-title">备案号为占位假值 <span class="tag t-known">已知占位</span></div>
<div class="item-desc">现为 <code>沪 ICP 备 2026000000 号 · 沪公网安备 31010000000000 号</code>(全 0)。按要求暂填,<b>上线前必须替换为真实备案号</b>(且 ICP 主体地需与实际服务器/主体一致,岩美若在沪外需核对)。<span class="file">_data/site.json:5</span></div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">客服邮箱/电话为占位 <span class="tag t-high">重要</span></div>
<div class="item-desc">邮箱 <code>support@yanmei.app</code>(与真实域名不符)、电话 <code>400-880-8888</code>(明显假号)。需换成真实联系方式。<span class="file">_data/site.json:7-8</span></div>
</div>
</li>
<li><input type="checkbox" />
<div>
<div class="item-title">页脚双份维护 <span class="tag t-low">一般</span></div>
<div class="item-desc">njk 站点用 <code>footer.njk</code>(读 site.json 变量),功能页则把页脚、备案、联系方式硬编码各写一份,改一处不同步。根因同「两套模板」,建议功能页接入 base.njk。</div>
</div>
</li>
</ul>
</section>
<!-- ============ 价格 ============ -->
<section>
<h2>五、价格</h2>
<ul class="todo">
<li><input type="checkbox" checked />
<div>
<div class="item-title">价格数字需确认是否真实对外 <span class="tag t-high">重要</span></div>
<div class="item-desc">单店版 <code>¥299/月</code>、年付 <code>¥2,988/年</code>、连锁版「定制」疑为占位。上线前需确认真实报价。</div>
<li class="todo-card done"
data-id="17"
data-level="mid"
data-tags="前端,iOS,Android"
data-done="1">
<div class="card-header">
<span class="item-title">入库表单关联 4 个商品属性字典</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">stock_in_form_screen.dart 生产日期字段附近增加产地/保质期/储存方式/描述文档 4 个下拉选择(数据来自字典 List 接口);FindOrCreate/Update 接受并写入 4 个 id;前端新增对应 API client 方法</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">「购买 / 联系销售 / 预约演示」按钮无对应流程 <span class="tag t-high">重要</span></div>
<div class="item-desc">价格卡「购买」「联系销售」、Hero「观看演示」、CTA「预约 1 对 1 演示」全部指向 <code>/app/</code>,但并无支付、销售联系、演示预约流程。按钮文案与去向不符。<span class="file">index.njk:554,568,188,622</span></div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">「试用版」与「单店版」文案口径混乱 <span class="tag t-low">一般</span></div>
<div class="item-desc">价格区把「试用版(¥0/30天)」与「单店版(¥299)」分列两卡,而 FAQ 又称「标准版提供 30 天免费试用」。试用 vs 标准 的口径需统一。</div>
</div>
</li>
</ul>
</section>
<!-- ============ 下载 ============ -->
<section>
<h2>六、下载页</h2>
<ul class="todo">
<li><input type="checkbox" checked />
<div>
<div class="item-title">下载按钮失败无降级 <span class="tag t-high">重要</span></div>
<div class="item-desc">Windows/macOS/Android 按钮初始 <code>href="#"</code>,依赖运行时 fetch <code>/api/v1/public/release</code> 回填真实下载地址;接口失败 / 无数据时点击无任何反应、也无提示。需加失败兜底(如提示「暂未就绪」或禁用态)。<span class="file">download.njk:500-543</span></div>
<li class="todo-card done"
data-id="16"
data-level="mid"
data-tags="后端,前端,iOS,Android"
data-done="1">
<div class="card-header">
<span class="item-title">public.go 返回产地/保质期/储存/介绍,公开页去硬编码</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">public.go GetProduct Preload 4 张新表 + 三级回退(描述文档→旧Description→通用中性兜底)+ 默认值常量(保质期/储存方式);Flutter public_product_screen.dart _ParamsCard/_DescriptionSection 改读真实数据,移除茅台 mock</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="iOS">iOS</span> <span class="tag t-tag" data-tag="Android">Android</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">iOS 状态依赖接口字段 <span class="tag t-low">一般</span></div>
<div class="item-desc">iOS 卡片仅当接口返回 <code>download_urls.ios</code> 才从「敬请期待」切换为「TestFlight 安装」。需确认 TestFlight 公开链接是否已配到 release 接口,否则 iOS 永远显示敬请期待(与首页「五端就绪」矛盾)。</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">首页「五端就绪」与下载页 iOS 状态矛盾 <span class="tag t-high">重要</span></div>
<div class="item-desc">首页平台区把 Web/Win/macOS/iOS/Android 全列为可用,下载页 iOS 仍是「敬请期待」。需对齐口径。</div>
</div>
</li>
</ul>
</section>
<!-- ============ 文档 ============ -->
<section>
<h2>七、文档页</h2>
<ul class="todo">
<li><input type="checkbox" checked />
<div>
<div class="item-title"><code>⌘K</code> 搜索快捷键是装饰 <span class="tag t-low">一般</span></div>
<div class="item-desc">文档顶栏显示 <code>⌘K</code> 徽标但未绑定键盘事件聚焦搜索框;搜索本身仅按整章显隐,无关键词高亮/定位。要么实现快捷键,要么去掉徽标。<span class="file">docs.njk</span></div>
<li class="todo-card done"
data-id="15"
data-level="mid"
data-tags="后端,数据库"
data-done="1">
<div class="card-header">
<span class="item-title">4 张商品属性字典表 + 后端 CRUD</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">新增 product_origin_options/product_shelf_life_options/product_storage_options/product_description_docs 4 张字典表(TenantBase+shop_id 隔离);Product 增加 4 个可空外键;AutoMigrate 注册;handler/product_attr.go CRUD16 个方法);router.go 追加 4 组路由在 /product-options/ 下</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="后端">后端</span> <span class="tag t-tag" data-tag="数据库">数据库</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li><input type="checkbox" checked />
<div>
<div class="item-title">反馈接口路径不一致 / 待确认 <span class="tag t-low">一般</span></div>
<div class="item-desc">文档反馈用相对路径 <code>/api/v1/public/feedback</code>,下载页却用绝对域名。需确认该端点真实存在且同源可达。<span class="file">docs.njk:182</span></div>
</div>
</li>
</ul>
</section>
<!-- ============ 注册 ============ -->
<section>
<h2>八、注册页</h2>
<ul class="todo">
<li><input type="checkbox" />
<div>
<div class="item-title">注册接口同源依赖 <span class="tag t-low">一般</span></div>
<div class="item-desc"><code>window.location.origin + '/api/v1/public/register'</code>,要求静态站与后端同源部署;本地预览或分离部署时会指错。需确认部署形态(与下载页写死域名的做法也不统一)。<span class="file">register.njk:175</span></div>
<li class="todo-card done"
data-id="4"
data-level="low"
data-tags="前端,Web"
data-done="1">
<div class="card-header">
<span class="item-title">scan.html / features 走 passthrough 不享模板</span>
<span class="tag t-low">一般 / 优化</span>
</div>
<div class="item-desc">features/scan.html 直接复制,不经 njk 模板,页脚/备案/CSS 都得手动同步。建议长期改造为模板化。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li><input type="checkbox" />
<div>
<div class="item-title">成功提示路径需核对 <span class="tag t-low">一般</span></div>
<div class="item-desc">提示「忘记门店编码可在『系统设置 → 关于』查看」,需与实际客户端菜单路径一致。</div>
</div>
</li>
</ul>
</section>
<!-- ============ 构建/工程 ============ -->
<section>
<h2>九、构建 / 工程结构</h2>
<ul class="todo">
<li><input type="checkbox" checked />
<div>
<div class="item-title">dist 残留旧产物 <code>docs.html</code> <span class="tag t-low">一般</span></div>
<div class="item-desc">dist 同时存在 <code>docs.html</code>(疑似旧构建残留)与 <code>docs/index.html</code>,造成同内容双 URL。建议清理 dist 后全量重建,避免历史残留。</div>
<li class="todo-card done"
data-id="1"
data-level="mid"
data-tags="前端,Web"
data-done="1">
<div class="card-header">
<span class="item-title">两套 CSS / 设计令牌并存</span>
<span class="tag t-high">重要</span>
</div>
<div class="item-desc">主站 njk 用 color.css+style.css,功能页用 colors_and_type.css,导航/页脚各写一份,维护分裂。建议合并到一套,功能页走 base.njk 模板。</div>
<div class="card-footer">
<div class="tag-row"><span class="tag t-tag" data-tag="前端">前端</span> <span class="tag t-tag" data-tag="Web">Web</span></div>
<div class="item-meta">
<span class="meta-date">🕐 2026-06-08</span>
<span class="meta-date">✅ 完成 2026-06-08 · <span class="ver-badge">v1.0.23</span></span>
</div>
</div>
</li>
<li><input type="checkbox" />
<div>
<div class="item-title">scan.html / features 走 passthrough,不享模板 <span class="tag t-low">一般</span></div>
<div class="item-desc"><code>.eleventy.js</code> 把 features、scan.html 直接复制,不经 njk 模板,导致页脚、备案、域名、CSS 都得手动同步——是上面多个「双份维护」问题的工程根因。建议长期改造为模板化。</div>
</div>
</li>
</ul>
</section>
</ul>
</div>
</div>
<script>
(function() {
// 筛选状态
let curLevel = 'all';
let curDone = 'all';
let curTags = new Set();
function applyFilter() {
document.querySelectorAll('.todo-card').forEach(card => {
const lvl = card.dataset.level;
const dn = card.dataset.done;
const tags = card.dataset.tags ? card.dataset.tags.split(',') : [];
let show = true;
if (curLevel !== 'all' && lvl !== curLevel) show = false;
if (curDone !== 'all' && dn !== curDone) show = false;
if (curTags.size > 0 && ![...curTags].some(t => tags.includes(t))) show = false;
card.classList.toggle('hidden', !show);
});
}
// 重要度
document.querySelectorAll('[data-filter-level]').forEach(btn => {
btn.addEventListener('click', () => {
curLevel = btn.dataset.filterLevel;
document.querySelectorAll('[data-filter-level]').forEach(b => b.classList.toggle('active', b === btn));
applyFilter();
});
});
// 状态
document.querySelectorAll('[data-filter-done]').forEach(btn => {
btn.addEventListener('click', () => {
curDone = btn.dataset.filterDone;
document.querySelectorAll('[data-filter-done]').forEach(b => b.classList.toggle('active', b === btn));
applyFilter();
});
});
// 标签(多选)
document.querySelectorAll('[data-filter-tag]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.filterTag;
if (curTags.has(t)) { curTags.delete(t); btn.classList.remove('active'); }
else { curTags.add(t); btn.classList.add('active'); }
applyFilter();
});
});
// 点击卡片标签 → 快速筛选
document.querySelectorAll('.t-tag').forEach(tag => {
tag.addEventListener('click', e => {
e.stopPropagation();
const t = tag.dataset.tag;
const chip = document.querySelector('[data-filter-tag="'+t+'"]');
if (chip) chip.click();
});
});
// 已完成折叠
const toggle = document.getElementById('doneToggle');
const doneList = document.getElementById('doneList');
if (toggle && doneList) {
toggle.addEventListener('click', () => {
const collapsed = doneList.classList.toggle('hidden');
toggle.textContent = collapsed ? '▾ 展开已完成列表' : '▴ 收起已完成列表';
});
}
})();
</script>
</body>
</html>
</html>
+228
View File
@@ -0,0 +1,228 @@
{
"meta": {
"title": "酒库管理系统 — 项目 TODO",
"updated_at": "2026-06-07T23:28:53.001Z"
},
"seq": 18,
"items": [
{
"id": 1,
"title": "两套 CSS / 设计令牌并存",
"desc": "主站 njk 用 color.css+style.css,功能页用 colors_and_type.css,导航/页脚各写一份,维护分裂。建议合并到一套,功能页走 base.njk 模板。",
"level": "mid",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.138Z",
"done": true,
"completed_at": "2026-06-07T17:48:38.554Z",
"version": "v1.0.23"
},
{
"id": 2,
"title": "备案号为占位假值",
"desc": "现为沪ICP备2026000000号(全0),上线前必须替换真实备案号。",
"level": "high",
"tags": [
"Web"
],
"created_at": "2026-06-07T17:30:32.188Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 3,
"title": "注册接口同源依赖问题",
"desc": "用 window.location.origin+'/api/v1/public/register',要求同源部署;分离部署时指错。需确认部署形态。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.237Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 4,
"title": "scan.html / features 走 passthrough 不享模板",
"desc": "features/scan.html 直接复制,不经 njk 模板,页脚/备案/CSS 都得手动同步。建议长期改造为模板化。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.287Z",
"done": true,
"completed_at": "2026-06-07T18:07:13.211Z",
"version": "v1.0.23"
},
{
"id": 5,
"title": "页脚双份维护",
"desc": "njk 站点用 footer.njk 读变量,功能页把页脚硬编码各写一份,改一处不同步。根因同「两套模板」。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.334Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 6,
"title": "注册成功提示路径需核对",
"desc": "提示「忘记门店编码可在「系统设置→关于」查看」,需与实际客户端菜单路径一致。",
"level": "low",
"tags": [
"前端",
"Web"
],
"created_at": "2026-06-07T17:30:32.381Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 8,
"title": "补全「查看本店其他商品」功能",
"desc": "PublicProductScreen _ShopCard 底部按钮 onPressed: () {} 为空,需实现跳转至该门店商品列表页。client/lib/screens/public/public_product_screen.dart:1240",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:08:24.432Z",
"done": true,
"completed_at": "2026-06-07T23:18:23.239Z",
"version": "v1.0.23"
},
{
"id": 9,
"title": "设计并实现添加门店微信功能",
"desc": "PublicProductScreen _ShopCard「添加门店微信」按钮 onPressed: () {} 为空。需设计方案:门店维护微信号字段 or 二维码图片,扫码页展示。涉及后端 shop model 扩展 + 前端展示。client/lib/screens/public/public_product_screen.dart:1255",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:08:32.748Z",
"done": true,
"completed_at": "2026-06-07T23:28:52.932Z",
"version": "v1.0.23"
},
{
"id": 10,
"title": "公开商品 API 补充 sale_price 并在页面展示价格",
"desc": "product model 有 sale_price 字段但 public.go GetProduct 未返回。需:1) 后端 batchData/productData 加 sale_price2) Flutter _TitleBlock 或单独区块展示建议零售价。client: public_product_screen.dart, backend: handler/public.go",
"level": "mid",
"tags": [
"前端",
"后端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:21.217Z",
"done": true,
"completed_at": "2026-06-07T23:28:53.001Z",
"version": "v1.0.23"
},
{
"id": 13,
"title": "公开商品 API 补充 batch.quantity 和 product code/barcode",
"desc": "Flutter _AuthenticityCard 读取 batch['quantity'] 但后端 public.go batchData 未返回 quantity。另 product code/barcode 未暴露,参数卡无法显示商品编码。handler/public.go GetProduct",
"level": "low",
"tags": [
"后端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:43.136Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 14,
"title": "Footer「商品报错」「意见反馈」补充实际功能",
"desc": "public_product_screen.dart _Footer 底部两个链接无 url/action,点击静默。需设计方案:跳转反馈表单、弹出联系方式、或发邮件。line:1353-1355",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T18:12:50.299Z",
"done": false,
"completed_at": null,
"version": null
},
{
"id": 15,
"title": "4 张商品属性字典表 + 后端 CRUD",
"desc": "新增 product_origin_options/product_shelf_life_options/product_storage_options/product_description_docs 4 张字典表(TenantBase+shop_id 隔离);Product 增加 4 个可空外键;AutoMigrate 注册;handler/product_attr.go CRUD16 个方法);router.go 追加 4 组路由在 /product-options/ 下",
"level": "mid",
"tags": [
"后端",
"数据库"
],
"created_at": "2026-06-07T22:51:51.881Z",
"done": true,
"completed_at": "2026-06-07T22:56:24.807Z",
"version": "v1.0.23"
},
{
"id": 16,
"title": "public.go 返回产地/保质期/储存/介绍,公开页去硬编码",
"desc": "public.go GetProduct Preload 4 张新表 + 三级回退(描述文档→旧Description→通用中性兜底)+ 默认值常量(保质期/储存方式);Flutter public_product_screen.dart _ParamsCard/_DescriptionSection 改读真实数据,移除茅台 mock",
"level": "mid",
"tags": [
"后端",
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:51:59.988Z",
"done": true,
"completed_at": "2026-06-07T22:58:32.882Z",
"version": "v1.0.23"
},
{
"id": 17,
"title": "入库表单关联 4 个商品属性字典",
"desc": "stock_in_form_screen.dart 生产日期字段附近增加产地/保质期/储存方式/描述文档 4 个下拉选择(数据来自字典 List 接口);FindOrCreate/Update 接受并写入 4 个 id;前端新增对应 API client 方法",
"level": "mid",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:52:07.331Z",
"done": true,
"completed_at": "2026-06-07T23:03:13.631Z",
"version": "v1.0.23"
},
{
"id": 18,
"title": "基础数据管理 UI 增加 4 个字典维护 Tab",
"desc": "products_screen.dart 基础数据管理页增加产地/保质期/储存方式/描述文档 4 个维护 Tab,复用现有 option 维护组件(增删改查);描述文档 Tab 含 title+content 多行输入",
"level": "low",
"tags": [
"前端",
"iOS",
"Android"
],
"created_at": "2026-06-07T22:52:13.283Z",
"done": false,
"completed_at": null,
"version": null
}
]
}
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env node
/**
* todo/todo.mjs — 项目 TODO 管理 CLI(零依赖,Node.js >= 18
*
* 子命令:
* add --title "..." [--level high|mid|low] [--tags a,b] [--desc "..."]
* done <id> [--version vX.Y.Z]
* reopen <id>
* rm <id>
* list
* render
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { execSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const __dir = dirname(fileURLToPath(import.meta.url));
const DB = resolve(__dir, 'todo.json');
const HTML = resolve(__dir, 'todo.html');
// ─── 数据读写 ────────────────────────────────────────────────────────────────
function load() {
if (!existsSync(DB)) {
return { meta: { title: '酒库管理系统 — 项目 TODO', updated_at: now() }, seq: 0, items: [] };
}
return JSON.parse(readFileSync(DB, 'utf8'));
}
function save(db) {
db.meta.updated_at = now();
writeFileSync(DB, JSON.stringify(db, null, 2) + '\n', 'utf8');
}
function now() {
return new Date().toISOString();
}
function fmtDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}
function gitTag() {
try {
return execSync('git describe --tags --abbrev=0', { encoding: 'utf8', stdio: ['ignore','pipe','ignore'] }).trim();
} catch {
return null;
}
}
// ─── 终端 summary ────────────────────────────────────────────────────────────
const LEVEL_LABEL = { high: '高', mid: '中', low: '低' };
const LEVEL_ORDER = { high: 0, mid: 1, low: 2 };
function printSummary(db) {
const pending = db.items.filter(i => !i.done)
.sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id);
const done = db.items.filter(i => i.done)
.sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1);
console.log(`\nTODO 共 ${db.items.length} · 未完成 ${pending.length} · 已完成 ${done.length}`);
if (pending.length) {
console.log('未完成(按重要度):');
for (const i of pending) {
const tags = i.tags && i.tags.length ? i.tags.join('/') : '';
const lvl = LEVEL_LABEL[i.level] || i.level;
console.log(` [${lvl}] #${i.id} ${i.title}${tags ? ' ' + tags : ''}`);
}
}
if (done.length) {
console.log('已完成(最近 5 条):');
for (const i of done.slice(0, 5)) {
const ver = i.version ? `${i.version}` : '';
console.log(` ✅ #${i.id} ${i.title}${ver}`);
}
}
console.log('');
}
// ─── HTML 渲染 ───────────────────────────────────────────────────────────────
function esc(s) {
return String(s ?? '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
/** 把描述里的 `code` 渲染成 <code> */
function renderDesc(s) {
if (!s) return '';
return esc(s).replace(/`([^`]+)`/g, '<code>$1</code>');
}
function levelClass(level) {
return { high: 't-block', mid: 't-high', low: 't-low' }[level] || 't-low';
}
function levelText(level) {
return { high: '高优 · 紧急', mid: '重要', low: '一般 / 优化' }[level] || level;
}
function renderItems(items) {
if (!items.length) return '<p class="empty-tip">暂无条目</p>';
return items.map(i => {
const tags = (i.tags||[]).map(t => `<span class="tag t-tag" data-tag="${esc(t)}">${esc(t)}</span>`).join(' ');
const desc = i.desc ? `<div class="item-desc">${renderDesc(i.desc)}</div>` : '';
const meta = `<div class="item-meta">
<span class="meta-date">🕐 ${fmtDate(i.created_at)}</span>
${i.done ? `<span class="meta-date">✅ 完成 ${fmtDate(i.completed_at)}${i.version ? ' · <span class="ver-badge">'+esc(i.version)+'</span>' : ''}</span>` : ''}
</div>`;
const lvlCls = levelClass(i.level);
return `
<li class="todo-card${i.done ? ' done' : ''}"
data-id="${i.id}"
data-level="${esc(i.level)}"
data-tags="${esc((i.tags||[]).join(','))}"
data-done="${i.done ? '1' : '0'}">
<div class="card-header">
<span class="item-title">${esc(i.title)}</span>
<span class="tag ${lvlCls}">${levelText(i.level)}</span>
</div>
${desc}
<div class="card-footer">
<div class="tag-row">${tags}</div>
${meta}
</div>
</li>`;
}).join('\n');
}
function buildHtml(db) {
const allTags = [...new Set(db.items.flatMap(i => i.tags||[]))].sort();
const pending = db.items.filter(i => !i.done)
.sort((a,b) => (LEVEL_ORDER[a.level]??9) - (LEVEL_ORDER[b.level]??9) || a.id - b.id);
const done = db.items.filter(i => i.done)
.sort((a,b) => (b.completed_at||'') > (a.completed_at||'') ? 1 : -1);
const tagChips = allTags.map(t =>
`<button class="filter-chip" data-filter-tag="${esc(t)}">${esc(t)}</button>`
).join('');
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${esc(db.meta.title)}</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 0 0 80px;
font-family: "Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #1f2933; background: #f7f9fb; line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
header {
background: linear-gradient(135deg, #0A1F3B 0%, #15407D 100%);
color: #fff; padding: 36px 32px 28px;
}
header h1 { margin: 0 0 6px; font-size: 24px; font-weight: 700; }
header .header-meta { color: #ADC9EA; font-size: 13px; margin-top: 4px; }
.stats { display: flex; gap: 16px; margin-top: 14px; }
.stat-pill {
background: rgba(255,255,255,0.12); border-radius: 20px;
padding: 4px 14px; font-size: 13px; color: #fff;
}
.stat-pill strong { font-size: 18px; font-weight: 700; margin-right: 2px; }
/* ── 筛选栏 ── */
.filter-bar {
position: sticky; top: 0; z-index: 10;
background: #fff; border-bottom: 1px solid #e4e9ef;
padding: 10px 28px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
}
.filter-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.filter-label { font-size: 12px; color: #7b8794; white-space: nowrap; }
.filter-chip, .filter-btn {
padding: 3px 12px; border-radius: 14px; border: 1.5px solid #d1d9e6;
background: #f7f9fb; color: #52606d; font-size: 12.5px; cursor: pointer;
transition: all .15s; white-space: nowrap;
}
.filter-chip:hover, .filter-btn:hover { border-color: #2563AC; color: #2563AC; }
.filter-chip.active, .filter-btn.active {
background: #2563AC; border-color: #2563AC; color: #fff;
}
.filter-sep { width: 1px; height: 20px; background: #e4e9ef; margin: 0 4px; }
/* ── 内容区 ── */
.wrap { max-width: 980px; margin: 0 auto; padding: 0 24px; }
.section-title {
font-size: 16px; font-weight: 700; color: #0A1F3B;
border-left: 4px solid #2563AC; padding-left: 12px;
margin: 28px 0 12px;
}
.section-title.done-title { border-color: #6cbb8a; }
ul.todo-list { list-style: none; margin: 0; padding: 0; }
.todo-card {
background: #fff; border: 1px solid #e4e9ef; border-radius: 10px;
padding: 14px 18px; margin-bottom: 10px;
}
.todo-card.done { background: #f3faf5; border-color: #b7e4c7; opacity: .75; }
.card-header { display: flex; align-items: flex-start; gap: 10px; flex-wrap: wrap; }
.item-title { font-weight: 600; font-size: 15px; flex: 1; }
.todo-card.done .item-title { text-decoration: line-through; color: #52606d; }
.item-desc { font-size: 13.5px; color: #52606d; margin-top: 6px; }
.item-desc code {
background: #f0f2f5; padding: 1px 5px; border-radius: 3px;
font-family: "JetBrains Mono", monospace; font-size: 12.5px; color: #b91c1c;
}
.card-footer { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; }
.tag-row { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; }
.item-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: #8aa3c4; }
.meta-date { white-space: nowrap; }
/* ── tags ── */
.tag { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.t-block { background: #fde8e8; color: #b91c1c; }
.t-high { background: #fef0d8; color: #9a6700; }
.t-low { background: #e6f0fb; color: #1d4ed8; }
.t-tag { background: #efefef; color: #555; cursor: pointer; transition: .12s; }
.t-tag:hover { background: #d9e8fb; color: #1d4ed8; }
.ver-badge { font-weight: 700; color: #1f7a44; }
/* ── 折叠 ── */
.done-section { margin-bottom: 24px; }
.done-toggle {
background: none; border: 1.5px solid #d1d9e6; border-radius: 6px;
padding: 4px 14px; cursor: pointer; font-size: 13px; color: #52606d;
margin-bottom: 10px; transition: .15s;
}
.done-toggle:hover { border-color: #6cbb8a; color: #1f7a44; }
.empty-tip { font-size: 14px; color: #8aa3c4; margin: 8px 0 24px; }
/* 隐藏 */
.hidden { display: none !important; }
</style>
</head>
<body>
<header>
<div class="wrap">
<h1>${esc(db.meta.title)}</h1>
<div class="header-meta">生成于 ${fmtDate(db.meta.updated_at)} · 真相源 todo/todo.json</div>
<div class="stats">
<div class="stat-pill"><strong>${db.items.length}</strong>全部</div>
<div class="stat-pill"><strong>${pending.length}</strong>未完成</div>
<div class="stat-pill"><strong>${done.length}</strong>已完成</div>
</div>
</div>
</header>
<div class="filter-bar" id="filterBar">
<div class="filter-group">
<span class="filter-label">重要度</span>
<button class="filter-btn active" data-filter-level="all">全部</button>
<button class="filter-btn" data-filter-level="high">高优</button>
<button class="filter-btn" data-filter-level="mid">重要</button>
<button class="filter-btn" data-filter-level="low">一般</button>
</div>
<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">状态</span>
<button class="filter-btn active" data-filter-done="all">全部</button>
<button class="filter-btn" data-filter-done="0">未完成</button>
<button class="filter-btn" data-filter-done="1">已完成</button>
</div>
${allTags.length ? `<div class="filter-sep"></div>
<div class="filter-group">
<span class="filter-label">平台 / 标签</span>
${tagChips}
</div>` : ''}
</div>
<div class="wrap">
<div class="section-title">📋 未完成(${pending.length}</div>
<ul class="todo-list" id="pendingList">
${renderItems(pending)}
</ul>
<div class="done-section">
<div class="section-title done-title">✅ 已完成(${done.length}</div>
<button class="done-toggle" id="doneToggle">▾ 展开已完成列表</button>
<ul class="todo-list hidden" id="doneList">
${renderItems(done)}
</ul>
</div>
</div>
<script>
(function() {
// 筛选状态
let curLevel = 'all';
let curDone = 'all';
let curTags = new Set();
function applyFilter() {
document.querySelectorAll('.todo-card').forEach(card => {
const lvl = card.dataset.level;
const dn = card.dataset.done;
const tags = card.dataset.tags ? card.dataset.tags.split(',') : [];
let show = true;
if (curLevel !== 'all' && lvl !== curLevel) show = false;
if (curDone !== 'all' && dn !== curDone) show = false;
if (curTags.size > 0 && ![...curTags].some(t => tags.includes(t))) show = false;
card.classList.toggle('hidden', !show);
});
}
// 重要度
document.querySelectorAll('[data-filter-level]').forEach(btn => {
btn.addEventListener('click', () => {
curLevel = btn.dataset.filterLevel;
document.querySelectorAll('[data-filter-level]').forEach(b => b.classList.toggle('active', b === btn));
applyFilter();
});
});
// 状态
document.querySelectorAll('[data-filter-done]').forEach(btn => {
btn.addEventListener('click', () => {
curDone = btn.dataset.filterDone;
document.querySelectorAll('[data-filter-done]').forEach(b => b.classList.toggle('active', b === btn));
applyFilter();
});
});
// 标签(多选)
document.querySelectorAll('[data-filter-tag]').forEach(btn => {
btn.addEventListener('click', () => {
const t = btn.dataset.filterTag;
if (curTags.has(t)) { curTags.delete(t); btn.classList.remove('active'); }
else { curTags.add(t); btn.classList.add('active'); }
applyFilter();
});
});
// 点击卡片标签 → 快速筛选
document.querySelectorAll('.t-tag').forEach(tag => {
tag.addEventListener('click', e => {
e.stopPropagation();
const t = tag.dataset.tag;
const chip = document.querySelector('[data-filter-tag="'+t+'"]');
if (chip) chip.click();
});
});
// 已完成折叠
const toggle = document.getElementById('doneToggle');
const doneList = document.getElementById('doneList');
if (toggle && doneList) {
toggle.addEventListener('click', () => {
const collapsed = doneList.classList.toggle('hidden');
toggle.textContent = collapsed ? '▾ 展开已完成列表' : '▴ 收起已完成列表';
});
}
})();
</script>
</body>
</html>`;
}
// ─── CLI 解析 ────────────────────────────────────────────────────────────────
function parseArgs(argv) {
const args = {};
let pos = [];
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
const key = argv[i].slice(2);
const val = argv[i+1] && !argv[i+1].startsWith('--') ? argv[++i] : true;
args[key] = val;
} else {
pos.push(argv[i]);
}
}
args._ = pos;
return args;
}
function findItem(db, id) {
const n = parseInt(id, 10);
const item = db.items.find(i => i.id === n);
if (!item) { console.error(`错误:找不到 id=${n} 的条目`); process.exit(1); }
return item;
}
// ─── 子命令 ─────────────────────────────────────────────────────────────────
function cmdAdd(db, args) {
const title = args.title || (args._.join(' ') || '').trim();
if (!title) { console.error('错误:--title 不能为空'); process.exit(1); }
const level = ['high','mid','low'].includes(args.level) ? args.level : 'mid';
const tags = args.tags ? args.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
const desc = args.desc || null;
db.seq++;
const item = {
id: db.seq,
title,
desc,
level,
tags,
created_at: now(),
done: false,
completed_at: null,
version: null,
};
db.items.push(item);
save(db);
writeFileSync(HTML, buildHtml(db), 'utf8');
console.log(`✅ 已添加 #${item.id} [${levelText(level)}] ${title}${tags.length ? ' ' + tags.join('/') : ''}`);
printSummary(db);
}
function cmdDone(db, args) {
const id = args._[0];
const item = findItem(db, id);
let version = args.version || null;
if (!version) {
version = gitTag();
if (!version) console.warn('⚠️ 无法从 git 获取版本号,version 将为空');
}
item.done = true;
item.completed_at = now();
item.version = version;
save(db);
writeFileSync(HTML, buildHtml(db), 'utf8');
console.log(`✅ #${item.id}${item.title}」已标记完成${version ? ',记入版本 ' + version : ''}`);
printSummary(db);
}
function cmdReopen(db, args) {
const id = args._[0];
const item = findItem(db, id);
item.done = false;
item.completed_at = null;
item.version = null;
save(db);
writeFileSync(HTML, buildHtml(db), 'utf8');
console.log(`🔄 #${item.id}${item.title}」已重新开启`);
printSummary(db);
}
function cmdRm(db, args) {
const id = parseInt(args._[0], 10);
const idx = db.items.findIndex(i => i.id === id);
if (idx < 0) { console.error(`错误:找不到 id=${id}`); process.exit(1); }
const [removed] = db.items.splice(idx, 1);
save(db);
writeFileSync(HTML, buildHtml(db), 'utf8');
console.log(`🗑️ #${removed.id}${removed.title}」已删除`);
printSummary(db);
}
function cmdList(db) {
// 确保 HTML 最新
writeFileSync(HTML, buildHtml(db), 'utf8');
printSummary(db);
console.log(`📄 HTMLtodo/todo.html`);
}
function cmdRender(db) {
writeFileSync(HTML, buildHtml(db), 'utf8');
console.log('🖨️ todo/todo.html 已重新渲染');
}
// ─── 入口 ────────────────────────────────────────────────────────────────────
const [,, subcmd, ...rest] = process.argv;
const db = load();
const args = parseArgs(rest);
switch (subcmd) {
case 'add': cmdAdd(db, args); break;
case 'done': cmdDone(db, args); break;
case 'reopen': cmdReopen(db, args); break;
case 'rm': cmdRm(db, args); break;
case 'list':
case undefined:
case '': cmdList(db); break;
case 'render': cmdRender(db); break;
default:
console.error(`未知子命令:${subcmd}\n用法:node todo.mjs add|done|reopen|rm|list|render`);
process.exit(1);
}
-261
View File
@@ -1,261 +0,0 @@
/* =========================================================================
岩美 Design System — Foundations
Tokens for color, type, spacing, radius, shadow.
Import once at root: <link rel="stylesheet" href="colors_and_type.css">
========================================================================= */
:root {
color-scheme: light;
/* ---------- Brand: Primary (Slate Blue) ---------- */
/* Trustworthy enterprise blue with a slight slate cast. */
--brand-50: #EEF4FB;
--brand-100: #D6E5F5;
--brand-200: #ADC9EA;
--brand-300: #7FA8DA;
--brand-400: #4F86C6;
--brand-500: #2563AC; /* Primary action */
--brand-600: #1B4F8E; /* Hover */
--brand-700: #154072; /* Pressed */
--brand-800: #0F3057;
--brand-900: #0A1F3B; /* Brand ink — headers, logo on light bg */
/* ---------- Neutrals (cool slate gray) ---------- */
--gray-0: #FFFFFF;
--gray-25: #FBFCFD;
--gray-50: #F5F7FA;
--gray-100: #ECEFF4;
--gray-200: #DCE2EB;
--gray-300: #C2CAD6;
--gray-400: #99A3B3;
--gray-500: #6E7888;
--gray-600: #4F5867;
--gray-700: #353C48;
--gray-800: #232934;
--gray-900: #141821;
/* ---------- Accent (bordeaux / 酒红) ---------- */
/* Used sparingly: brand context cue (wine), key highlights, marketing only. */
--accent-50: #FAEEF0;
--accent-100: #F1D2D7;
--accent-300: #C97B86;
--accent-500: #8B2331;
--accent-700: #5F1621;
/* ---------- Semantic ---------- */
--success-50: #E8F5EE;
--success-500: #2E8B57;
--success-700: #1F6B41;
--warning-50: #FFF4DB;
--warning-500: #E08E00;
--warning-700: #A66700;
--danger-50: #FDECEC;
--danger-500: #D14343;
--danger-700: #9E2A2A;
--info-50: #E5F1FB;
--info-500: #2F7BD0;
--info-700: #1F5C9F;
/* ---------- Semantic foreground / background ---------- */
--bg-app: var(--gray-50);
--bg-surface: var(--gray-0);
--bg-raised: var(--gray-0);
--bg-sunken: var(--gray-100);
--bg-overlay: rgba(20, 24, 33, 0.45);
--fg-default: var(--gray-800);
--fg-muted: var(--gray-600);
--fg-subtle: var(--gray-500);
--fg-disabled: var(--gray-400);
--fg-on-brand: #FFFFFF;
--fg-link: var(--brand-500);
--border-subtle: var(--gray-100);
--border-default: var(--gray-200);
--border-strong: var(--gray-300);
--border-focus: var(--brand-500);
/* ---------- Typography ---------- */
/* Chinese-primary stack with PingFang on macOS/iOS, Microsoft YaHei on Windows,
Noto Sans SC as web fallback (loaded via Google Fonts in index files). */
--font-sans: "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Source Han Sans CN", "Noto Sans SC", -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-display: var(--font-sans);
--font-mono: "JetBrains Mono", "SF Mono", "Roboto Mono", Menlo, Consolas,
"Microsoft YaHei", monospace;
/* Type scale — mobile-first, scales up for desktop dashboards */
--text-2xs: 11px;
--text-xs: 12px;
--text-sm: 13px;
--text-md: 14px; /* dashboard body default */
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 22px;
--text-3xl: 28px;
--text-4xl: 36px;
--text-5xl: 48px;
--leading-tight: 1.25;
--leading-snug: 1.4;
--leading-normal: 1.55;
--leading-loose: 1.75;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* Tracking — Chinese reads better with subtle positive tracking */
--tracking-tight: -0.01em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-cn-display: 0.04em; /* Chinese display headers */
/* ---------- Spacing (4px base) ---------- */
--space-0: 0;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
--space-20: 80px;
--space-24: 96px;
/* ---------- Radius — restrained, enterprise-grade ---------- */
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px; /* default for inputs, buttons, badges */
--radius-lg: 10px; /* cards */
--radius-xl: 14px;
--radius-pill: 999px;
/* ---------- Elevation — soft, neutral, no colored shadows ---------- */
--shadow-xs: 0 1px 2px rgba(20, 24, 33, 0.04);
--shadow-sm: 0 1px 2px rgba(20, 24, 33, 0.06), 0 1px 3px rgba(20, 24, 33, 0.04);
--shadow-md: 0 2px 4px rgba(20, 24, 33, 0.06), 0 4px 8px rgba(20, 24, 33, 0.05);
--shadow-lg: 0 4px 12px rgba(20, 24, 33, 0.08), 0 12px 24px rgba(20, 24, 33, 0.06);
--shadow-xl: 0 8px 20px rgba(20, 24, 33, 0.10), 0 20px 40px rgba(20, 24, 33, 0.08);
--shadow-inset: inset 0 1px 0 rgba(255,255,255,0.6), inset 0 -1px 0 rgba(20,24,33,0.04);
--ring-focus: 0 0 0 3px rgba(37, 99, 172, 0.22);
/* ---------- Motion ---------- */
--ease-standard: cubic-bezier(0.2, 0, 0, 1);
--ease-emphasized: cubic-bezier(0.2, 0, 0, 1.2);
--ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
--duration-fast: 120ms;
--duration-base: 180ms;
--duration-slow: 240ms;
/* ---------- Layout ---------- */
--layout-sidebar: 240px;
--layout-sidebar-collapsed: 64px;
--layout-topbar: 56px;
--layout-content-max: 1440px;
}
/* =========================================================================
Semantic element styles — apply to base elements within design system docs.
These do NOT bleed into ui_kits / pages with their own styles.
========================================================================= */
.ds-typography {
font-family: var(--font-sans);
color: var(--fg-default);
font-feature-settings: "tnum" 1, "ss01" 1;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.ds-typography h1,
.ds-h1 {
font-size: var(--text-4xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-cn-display);
color: var(--brand-900);
margin: 0 0 var(--space-4);
}
.ds-typography h2,
.ds-h2 {
font-size: var(--text-3xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-cn-display);
color: var(--brand-900);
margin: 0 0 var(--space-3);
}
.ds-typography h3,
.ds-h3 {
font-size: var(--text-2xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--gray-900);
margin: 0 0 var(--space-3);
}
.ds-typography h4,
.ds-h4 {
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--gray-900);
margin: 0 0 var(--space-2);
}
.ds-typography h5,
.ds-h5 {
font-size: var(--text-lg);
font-weight: var(--weight-semibold);
line-height: var(--leading-snug);
color: var(--gray-800);
margin: 0 0 var(--space-2);
}
.ds-typography p,
.ds-body {
font-size: var(--text-md);
font-weight: var(--weight-regular);
line-height: var(--leading-normal);
color: var(--fg-default);
margin: 0 0 var(--space-3);
}
.ds-typography small,
.ds-caption {
font-size: var(--text-xs);
color: var(--fg-muted);
line-height: var(--leading-snug);
}
.ds-typography code,
.ds-mono {
font-family: var(--font-mono);
font-size: 0.93em;
background: var(--gray-100);
padding: 1px 6px;
border-radius: var(--radius-sm);
color: var(--gray-800);
}
.ds-num {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum" 1;
}
.ds-label {
font-size: var(--text-xs);
font-weight: var(--weight-medium);
letter-spacing: var(--tracking-wide);
text-transform: none; /* Chinese never uppercases */
color: var(--fg-muted);
}
/* Focus ring shared across the system */
.ds-focusable:focus-visible,
:focus-visible {
outline: none;
box-shadow: var(--ring-focus);
border-color: var(--border-focus);
}
-105
View File
@@ -1,108 +1,3 @@
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: var(--font-sans); color: var(--fg-default);
background: var(--bg-surface);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: "tnum" 1;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
.btn {
display: inline-flex; align-items: center; gap: 8px;
height: 40px; padding: 0 18px;
border-radius: var(--radius-md);
font-size: var(--text-md); font-weight: 500;
border: 1px solid transparent;
transition: background var(--duration-fast) var(--ease-standard);
white-space: nowrap;
}
.btn-primary { background: var(--brand-500); color: #fff; }
.btn-primary:hover { background: var(--brand-600); }
.btn-secondary { background: var(--gray-0); color: var(--brand-900); border-color: var(--border-default); }
.btn-secondary:hover { background: var(--gray-50); border-color: var(--border-strong); }
.btn-ghost { background: transparent; color: var(--fg-default); }
.btn-ghost:hover { background: var(--gray-100); }
.btn-lg { height: 48px; padding: 0 24px; font-size: var(--text-lg); }
.btn .icon { width: 18px; height: 18px; }
.container { max-width: 1280px; margin: 0 auto; padding: 0 32px; }
.section { padding: 96px 0; }
.eyebrow {
font-size: var(--text-xs); font-weight: 600;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--brand-500); margin: 0 0 12px;
}
/* Top Nav */
.topnav { position: sticky; top: 0; z-index: 50; background: rgba(255,255,255,0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--border-subtle); }
.topnav-inner { height: 64px; display: flex; align-items: center; justify-content: space-between; max-width: 1440px; margin: 0 auto; padding: 0 32px; }
.topnav-brand { display: flex; align-items: center; gap: 10px; }
.topnav-brand img { height: 32px; }
.topnav-links { display: flex; align-items: center; gap: 4px; font-size: var(--text-md); color: var(--gray-700); }
.topnav-links a { padding: 8px 14px; border-radius: var(--radius-md); transition: background var(--duration-fast) var(--ease-standard); }
.topnav-links a:hover { background: var(--gray-100); color: var(--brand-900); }
.topnav-links a.active { color: var(--brand-500); background: var(--brand-50); }
.topnav-cta { display: flex; gap: 10px; align-items: center; }
/* Nav user dropdown */
.nav-user { position: relative; }
.nav-user-btn {
display: flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px 0 8px;
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
background: var(--gray-0);
font-size: var(--text-sm); color: var(--gray-800);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-standard);
}
.nav-user-btn:hover { background: var(--gray-50); }
.nav-avatar {
width: 22px; height: 22px; border-radius: 50%;
background: var(--brand-500); color: #fff;
font-size: 11px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.nav-user-btn .icon { width: 14px; height: 14px; color: var(--fg-muted); }
.nav-dropdown {
display: none; position: absolute; top: calc(100% + 8px); right: 0;
min-width: 200px;
background: var(--gray-0);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 6px;
z-index: 100;
}
.nav-dropdown.open { display: block; }
.nav-dropdown-info {
padding: 8px 10px 10px;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 6px;
}
.nav-dropdown-name { font-size: var(--text-sm); font-weight: 600; color: var(--brand-900); }
.nav-dropdown-role { font-size: 11px; color: var(--fg-muted); margin-top: 2px; }
.nav-dropdown a, .nav-dropdown button {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 8px 10px;
border-radius: var(--radius-md);
font-size: var(--text-sm); color: var(--gray-700);
background: none; border: none; text-align: left; cursor: pointer;
transition: background var(--duration-fast) var(--ease-standard);
}
.nav-dropdown a:hover, .nav-dropdown button:hover { background: var(--gray-50); color: var(--brand-900); }
.nav-dropdown .icon { width: 15px; height: 15px; color: var(--fg-muted); }
.nav-dropdown .logout { color: var(--danger-600); }
.nav-dropdown .logout .icon { color: var(--danger-400); }
/* ====================================================================
HERO
==================================================================== */
.feat-hero {
position: relative; overflow: hidden;
padding: 80px 0 72px;
-105
View File
@@ -1,108 +1,3 @@
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: var(--font-sans); color: var(--fg-default);
background: var(--bg-surface);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: "tnum" 1;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
.btn {
display: inline-flex; align-items: center; gap: 8px;
height: 40px; padding: 0 18px;
border-radius: var(--radius-md);
font-size: var(--text-md); font-weight: 500;
border: 1px solid transparent;
transition: background var(--duration-fast) var(--ease-standard);
white-space: nowrap;
}
.btn-primary { background: var(--brand-500); color: #fff; }
.btn-primary:hover { background: var(--brand-600); }
.btn-secondary { background: var(--gray-0); color: var(--brand-900); border-color: var(--border-default); }
.btn-secondary:hover { background: var(--gray-50); border-color: var(--border-strong); }
.btn-ghost { background: transparent; color: var(--fg-default); }
.btn-ghost:hover { background: var(--gray-100); }
.btn-lg { height: 48px; padding: 0 24px; font-size: var(--text-lg); }
.btn .icon { width: 18px; height: 18px; }
.container { max-width: 1280px; margin: 0 auto; padding: 0 32px; }
.section { padding: 96px 0; }
.eyebrow {
font-size: var(--text-xs); font-weight: 600;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--brand-500); margin: 0 0 12px;
}
/* Top Nav */
.topnav { position: sticky; top: 0; z-index: 50; background: rgba(255,255,255,0.85); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border-bottom: 1px solid var(--border-subtle); }
.topnav-inner { height: 64px; display: flex; align-items: center; justify-content: space-between; max-width: 1440px; margin: 0 auto; padding: 0 32px; }
.topnav-brand { display: flex; align-items: center; gap: 10px; }
.topnav-brand img { height: 32px; }
.topnav-links { display: flex; align-items: center; gap: 4px; font-size: var(--text-md); color: var(--gray-700); }
.topnav-links a { padding: 8px 14px; border-radius: var(--radius-md); transition: background var(--duration-fast) var(--ease-standard); }
.topnav-links a:hover { background: var(--gray-100); color: var(--brand-900); }
.topnav-links a.active { color: var(--brand-500); background: var(--brand-50); }
.topnav-cta { display: flex; gap: 10px; align-items: center; }
/* Nav user dropdown */
.nav-user { position: relative; }
.nav-user-btn {
display: flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px 0 8px;
border-radius: var(--radius-md);
border: 1px solid var(--border-default);
background: var(--gray-0);
font-size: var(--text-sm); color: var(--gray-800);
cursor: pointer;
transition: background var(--duration-fast) var(--ease-standard);
}
.nav-user-btn:hover { background: var(--gray-50); }
.nav-avatar {
width: 22px; height: 22px; border-radius: 50%;
background: var(--brand-500); color: #fff;
font-size: 11px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.nav-user-btn .icon { width: 14px; height: 14px; color: var(--fg-muted); }
.nav-dropdown {
display: none; position: absolute; top: calc(100% + 8px); right: 0;
min-width: 200px;
background: var(--gray-0);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
padding: 6px;
z-index: 100;
}
.nav-dropdown.open { display: block; }
.nav-dropdown-info {
padding: 8px 10px 10px;
border-bottom: 1px solid var(--border-subtle);
margin-bottom: 6px;
}
.nav-dropdown-name { font-size: var(--text-sm); font-weight: 600; color: var(--brand-900); }
.nav-dropdown-role { font-size: 11px; color: var(--fg-muted); margin-top: 2px; }
.nav-dropdown a, .nav-dropdown button {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 8px 10px;
border-radius: var(--radius-md);
font-size: var(--text-sm); color: var(--gray-700);
background: none; border: none; text-align: left; cursor: pointer;
transition: background var(--duration-fast) var(--ease-standard);
}
.nav-dropdown a:hover, .nav-dropdown button:hover { background: var(--gray-50); color: var(--brand-900); }
.nav-dropdown .icon { width: 15px; height: 15px; color: var(--fg-muted); }
.nav-dropdown .logout { color: var(--danger-600); }
.nav-dropdown .logout .icon { color: var(--danger-400); }
/* ====================================================================
HERO
==================================================================== */
.feat-hero {
position: relative; overflow: hidden;
padding: 80px 0 72px;
-183
View File
@@ -1,183 +0,0 @@
/* ================================================================
shared.css 跨页面公用样式
设计 token colors_and_type.css 提供
================================================================ */
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: var(--font-sans);
color: var(--fg-default);
background: var(--gray-25);
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
font-feature-settings: "tnum" 1;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font-family: inherit; cursor: pointer; }
/* ----------------------------------------------------------------
Buttons
---------------------------------------------------------------- */
.btn {
display: inline-flex; align-items: center; gap: 8px;
height: 40px; padding: 0 18px;
border-radius: var(--radius-md);
font-size: var(--text-md); font-weight: 500;
border: 1px solid transparent;
transition: background var(--duration-fast) var(--ease-standard),
border-color var(--duration-fast) var(--ease-standard),
color var(--duration-fast) var(--ease-standard);
white-space: nowrap;
}
.btn-primary { background: var(--brand-500); color: #fff; }
.btn-primary:hover { background: var(--brand-600); }
.btn-primary:active { background: var(--brand-700); }
.btn-secondary { background: var(--gray-0); color: var(--brand-900); border-color: var(--border-default); }
.btn-secondary:hover { background: var(--gray-50); border-color: var(--border-strong); }
.btn-ghost { background: transparent; color: var(--fg-default); }
.btn-ghost:hover { background: var(--gray-100); }
.btn-lg { height: 48px; padding: 0 24px; font-size: var(--text-lg); }
.btn .icon { width: 18px; height: 18px; }
/* ----------------------------------------------------------------
Layout helpers
---------------------------------------------------------------- */
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 32px;
}
.section { padding: 96px 0; }
.section-tight { padding: 64px 0; }
.eyebrow {
font-size: var(--text-xs);
font-weight: 600;
letter-spacing: 0.12em;
color: var(--brand-500);
text-transform: uppercase;
margin: 0 0 12px;
}
.section-title {
font-size: var(--text-4xl);
font-weight: 600;
line-height: 1.18;
letter-spacing: var(--tracking-cn-display);
color: var(--brand-900);
margin: 0 0 16px;
max-width: 720px;
}
.section-sub {
font-size: var(--text-lg);
line-height: 1.6;
color: var(--fg-muted);
margin: 0;
max-width: 640px;
}
/* ----------------------------------------------------------------
Top Nav
---------------------------------------------------------------- */
.topnav {
position: sticky; top: 0; z-index: 50;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--border-subtle);
}
.topnav-inner {
height: 64px;
display: flex; align-items: center; justify-content: space-between;
max-width: 1440px; margin: 0 auto; padding: 0 32px;
}
.topnav-brand { display: flex; align-items: center; gap: 10px; }
.topnav-brand img { height: 32px; }
.topnav-links {
display: flex; align-items: center; gap: 4px;
font-size: var(--text-md); color: var(--gray-700);
}
.topnav-links a {
padding: 8px 14px;
border-radius: var(--radius-md);
transition: background var(--duration-fast) var(--ease-standard);
}
.topnav-links a:hover { background: var(--gray-100); color: var(--brand-900); }
.topnav-cta { display: flex; gap: 10px; align-items: center; }
/* Nav user dropdown */
.nav-user { position: relative; }
.nav-user-btn {
display: flex; align-items: center; gap: 8px;
height: 36px; padding: 0 12px;
background: var(--gray-0); border: 1px solid var(--border-default);
border-radius: var(--radius-pill); cursor: pointer;
font-size: var(--text-md); color: var(--gray-800); font-weight: 500;
transition: border-color var(--duration-fast) var(--ease-standard);
}
.nav-user-btn:hover { border-color: var(--border-strong); }
.nav-avatar {
width: 22px; height: 22px; border-radius: 50%;
background: var(--brand-500); color: #fff;
display: grid; place-items: center;
font-size: 10px; font-weight: 700; flex-shrink: 0;
}
.nav-user-btn .icon { width: 14px; height: 14px; color: var(--fg-muted); }
.nav-dropdown {
display: none; position: absolute; top: calc(100% + 8px); right: 0;
min-width: 160px; background: var(--gray-0);
border: 1px solid var(--border-default); border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg); overflow: hidden; z-index: 100;
}
.nav-dropdown.open { display: block; }
.nav-dropdown a, .nav-dropdown button {
display: flex; align-items: center; gap: 8px;
width: 100%; padding: 10px 14px;
font-size: var(--text-sm); color: var(--gray-800);
background: none; border: none; cursor: pointer;
text-decoration: none; font-family: inherit;
transition: background var(--duration-fast) var(--ease-standard);
}
.nav-dropdown a:hover, .nav-dropdown button:hover { background: var(--gray-50); }
.nav-dropdown .icon { width: 14px; height: 14px; color: var(--fg-muted); }
.nav-dropdown-divider { height: 1px; background: var(--border-subtle); margin: 4px 0; }
.nav-dropdown .logout { color: var(--danger-600); }
/* ----------------------------------------------------------------
Footer
---------------------------------------------------------------- */
footer {
background: var(--gray-25);
border-top: 1px solid var(--border-subtle);
padding: 64px 0 40px;
}
.footer-grid {
display: grid;
grid-template-columns: 1.5fr repeat(4, 1fr);
gap: 48px;
margin-bottom: 48px;
}
.footer-brand img { height: 32px; margin-bottom: 16px; }
.footer-brand p { margin: 0 0 16px; max-width: 320px; line-height: 1.6; color: var(--fg-muted); }
.footer-contact { font-size: var(--text-sm); color: var(--gray-700); line-height: 1.8; }
.footer-col h5 {
font-size: var(--text-xs); font-weight: 600;
color: var(--brand-900); letter-spacing: 0.06em;
margin: 0 0 16px; text-transform: uppercase;
}
.footer-col ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; }
.footer-col li a { color: var(--gray-600); }
.footer-col li a:hover { color: var(--brand-500); }
.footer-bottom {
border-top: 1px solid var(--border-subtle); padding-top: 24px;
display: flex; justify-content: space-between;
font-size: var(--text-xs); color: var(--fg-subtle);
}
/* ----------------------------------------------------------------
Responsive
---------------------------------------------------------------- */
@media (max-width: 1024px) {
.footer-grid { grid-template-columns: 1fr 1fr; }
.container { padding: 0 20px; }
}
-379
View File
@@ -1,379 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>商品验真 — 岩美</title>
<meta name="description" content="岩美防伪验证 — 扫码查看商品详情与防伪信息" />
<link rel="stylesheet" href="/assets/colors_and_type.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; font-family: var(--font-sans); color: #1A1612; background: #F7F3EE; -webkit-font-smoothing: antialiased; min-height: 100vh; }
img { display: block; max-width: 100%; }
/* STATE SCREENS */
.state-screen { display: flex; align-items: center; justify-content: center; min-height: 100vh; flex-direction: column; gap: 16px; padding: 32px; text-align: center; }
.spinner { width: 36px; height: 36px; border: 3px solid #E8E2D9; border-top-color: var(--brand-500); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.state-text { font-size: 14px; color: #7A7068; }
.error-icon { width: 48px; height: 48px; background: var(--danger-50); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 24px; }
.error-title { font-size: 16px; font-weight: 600; color: #1A1612; }
.error-sub { font-size: 13px; color: #7A7068; max-width: 260px; }
.retry-btn { margin-top: 4px; padding: 10px 24px; background: var(--brand-500); color: #fff; border: none; border-radius: 6px; font-size: 14px; font-family: inherit; cursor: pointer; }
.retry-btn:active { background: var(--brand-700); }
/* MAIN CONTENT */
#content { display: none; }
/* IMAGE GALLERY */
.gallery { position: relative; background: #EDE8E2; }
.gallery-main { position: relative; overflow: hidden; }
.gallery-main img { width: 100%; aspect-ratio: 1; object-fit: cover; display: none; }
.gallery-main img.active { display: block; }
.gallery-placeholder { width: 100%; aspect-ratio: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; }
.placeholder-mark { width: 56px; height: 56px; background: #0F3057; border-radius: 10px; display: flex; align-items: center; justify-content: center; }
.placeholder-mark::after { content: '岩'; font-size: 24px; font-weight: 700; color: rgba(255,255,255,0.9); }
.placeholder-text { font-size: 13px; color: #9E9580; }
.gallery-counter { position: absolute; right: 12px; bottom: 12px; background: rgba(0,0,0,0.5); color: #fff; font-size: 12px; padding: 3px 10px; border-radius: 12px; }
.thumbnails { display: flex; gap: 8px; padding: 10px 14px; overflow-x: auto; background: #EDE8E2; }
.thumbnails::-webkit-scrollbar { display: none; }
.thumb { width: 52px; height: 52px; border-radius: 4px; object-fit: cover; border: 2px solid transparent; flex-shrink: 0; cursor: pointer; }
.thumb.active { border-color: var(--brand-500); }
/* VERIFIED RIBBON */
.ribbon { background: #EAF4ED; padding: 10px 16px; display: flex; align-items: center; gap: 10px; }
.ribbon-check { width: 22px; height: 22px; background: #2E8B57; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.ribbon-check::after { content: '✓'; color: #fff; font-size: 13px; font-weight: 700; }
.ribbon-text { flex: 1; }
.ribbon-title { font-size: 13px; font-weight: 600; color: #1F6B41; }
.ribbon-sub { font-size: 11px; color: #4A7A5C; margin-top: 1px; }
.ribbon-icon { font-size: 18px; color: #2E8B57; }
/* TITLE */
.title-section { background: #fff; padding: 20px 16px 18px; }
.product-name { font-size: 22px; font-weight: 700; color: #1A1612; line-height: 1.3; margin: 0 0 6px; }
.product-subtitle { font-size: 14px; color: #7A7068; margin: 0 0 14px; line-height: 1.4; }
.tags { display: flex; flex-wrap: wrap; gap: 6px; }
.tag { display: inline-block; padding: 3px 9px; border-radius: 4px; font-size: 12px; font-weight: 500; }
.tag-brand { background: #EEF4FB; color: #154072; }
.tag-unit { background: #F0ECE6; color: #6B6259; }
/* QUICK SPECS */
.quick-specs { background: #fff; display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid #F0ECE6; }
.quick-spec-item { padding: 16px 8px; text-align: center; border-left: 1px solid #F5F1EB; }
.quick-spec-item:first-child { border-left: none; }
.quick-spec-val { font-size: 20px; font-weight: 700; color: #1A1612; line-height: 1; font-variant-numeric: tabular-nums; }
.quick-spec-unit { font-size: 9px; color: #9E9580; font-weight: 500; margin-left: 1px; }
.quick-spec-label { font-size: 11px; color: #9E9580; margin-top: 4px; letter-spacing: 0.04em; }
/* PARAMS */
.params-card { background: #fff; margin-top: 10px; }
.card-header { padding: 14px 16px 12px; border-bottom: 1px solid #F0ECE6; font-size: 14px; font-weight: 600; color: #1A1612; display: flex; align-items: center; gap: 8px; }
.card-header::before { content: ''; display: block; width: 3px; height: 14px; background: #8B2331; border-radius: 2px; }
.param-row { display: flex; align-items: flex-start; padding: 10px 16px; border-bottom: 1px solid #F5F1EB; }
.param-row:last-child { border-bottom: none; }
.param-label { font-size: 13px; color: #9E9580; min-width: 72px; flex-shrink: 0; padding-top: 1px; }
.param-val { font-size: 13px; color: #1A1612; flex: 1; line-height: 1.5; }
/* AUTHENTICITY */
.auth-card { background: #FAF8F5; margin-top: 10px; padding: 20px 16px; }
.auth-inner { background: #fff; border: 1px solid #E8E4DC; border-radius: 8px; padding: 16px; display: flex; gap: 14px; align-items: center; }
.auth-rows { flex: 1; display: grid; grid-template-columns: 64px 1fr; gap: 8px; font-size: 12px; line-height: 1.7; }
.auth-lbl { color: #9E9580; }
.auth-val { color: #1A1612; font-weight: 500; }
.auth-mono { font-family: 'JetBrains Mono', monospace; color: var(--brand-700); font-weight: 600; letter-spacing: 0.04em; }
.auth-stamp { width: 72px; height: 72px; border-radius: 50%; border: 2px solid #8B2331; background: rgba(139,35,49,0.04); display: grid; place-items: center; flex-shrink: 0; position: relative; transform: rotate(-8deg); overflow: hidden; }
.auth-stamp-text { font-size: 8px; color: #8B2331; font-weight: 700; text-align: center; letter-spacing: 0.02em; line-height: 1.4; }
.auth-note { font-size: 11px; color: #A89E92; margin-top: 12px; text-align: center; line-height: 1.6; }
/* SHOP CARD */
.shop-card { background: #fff; margin-top: 10px; }
.shop-name-row { display: flex; align-items: center; gap: 8px; padding: 14px 16px 12px; border-bottom: 1px solid #F0ECE6; }
.shop-icon { font-size: 16px; }
.shop-name-text { font-size: 14px; font-weight: 600; color: #1A1612; }
/* FOOTER */
.footer { padding: 20px 16px 32px; display: flex; align-items: center; justify-content: center; gap: 8px; }
.footer-mark { width: 18px; height: 18px; background: #0F3057; border-radius: 3px; display: flex; align-items: center; justify-content: center; }
.footer-mark::after { content: '岩'; color: rgba(255,255,255,0.9); font-size: 10px; font-weight: 700; }
.footer-text { font-size: 11px; color: #A89E92; }
</style>
</head>
<body>
<div id="state-loading" class="state-screen">
<div class="spinner"></div>
<div class="state-text">正在查询商品信息…</div>
</div>
<div id="state-error" class="state-screen" style="display:none">
<div class="error-icon"></div>
<div class="error-title">商品不存在或已下架</div>
<div class="error-sub">请检查二维码是否完整,或联系出售方确认。</div>
<button class="retry-btn" onclick="load()">重试</button>
</div>
<div id="content">
<div class="gallery" id="gallery">
<div class="gallery-main" id="gallery-main">
<div class="gallery-placeholder" id="gallery-placeholder">
<div class="placeholder-mark"></div>
<div class="placeholder-text">暂无图片</div>
</div>
</div>
<div class="thumbnails" id="thumbnails" style="display:none"></div>
</div>
<div class="ribbon">
<div class="ribbon-check"></div>
<div class="ribbon-text">
<div class="ribbon-title">已通过岩美防伪验证</div>
<div class="ribbon-sub" id="ribbon-shop"></div>
</div>
<div class="ribbon-icon"></div>
</div>
<div class="title-section">
<h1 class="product-name" id="product-name"></h1>
<p class="product-subtitle" id="product-subtitle"></p>
<div class="tags" id="product-tags"></div>
</div>
<div class="quick-specs" id="quick-specs" style="display:none">
<!-- filled by JS -->
</div>
<div class="params-card" id="params-card">
<div class="card-header">商品参数</div>
<div id="params-rows"></div>
</div>
<div class="auth-card" id="auth-card" style="display:none">
<div class="card-header">批次与防伪</div>
<div class="auth-inner">
<div class="auth-rows" id="auth-rows"></div>
<div class="auth-stamp">
<div class="auth-stamp-text">岩美<br/>防伪<br/></div>
</div>
</div>
<div class="auth-note">如发现可疑商品请联系出售方确认</div>
</div>
<div class="shop-card" id="shop-card" style="display:none">
<div class="shop-name-row">
<span class="shop-icon">🏪</span>
<span class="shop-name-text" id="shop-display-name">门店信息</span>
</div>
<div id="shop-rows"></div>
</div>
<div class="footer">
<div class="footer-mark"></div>
<div class="footer-text">由岩美酒库管理系统提供防伪验证</div>
</div>
</div>
<script>
const API_BASE = location.hostname === 'localhost' ? 'http://localhost:8080/api/v1' : '/api/v1';
function getPublicId() {
// URL pattern: /scan/<public_id>
const parts = location.pathname.split('/').filter(Boolean);
const idx = parts.indexOf('scan');
return idx >= 0 ? parts[idx + 1] : null;
}
let _imgs = [];
let _current = 0;
function showImg(i) {
_current = i;
document.querySelectorAll('#gallery-main img').forEach((el, j) => {
el.classList.toggle('active', j === i);
});
document.querySelectorAll('.thumb').forEach((el, j) => {
el.classList.toggle('active', j === i);
});
const counter = document.getElementById('gallery-counter');
if (counter) counter.textContent = `${i + 1} / ${_imgs.length}`;
}
function renderGallery(imageUrls) {
const main = document.getElementById('gallery-main');
const thumbsEl = document.getElementById('thumbnails');
const placeholder = document.getElementById('gallery-placeholder');
if (!imageUrls || imageUrls.length === 0) return;
_imgs = imageUrls;
placeholder.style.display = 'none';
imageUrls.forEach((url, i) => {
const img = document.createElement('img');
img.src = url;
img.alt = '';
if (i === 0) img.classList.add('active');
img.onclick = () => openFullscreen(i);
main.appendChild(img);
});
if (imageUrls.length > 1) {
const counter = document.createElement('div');
counter.className = 'gallery-counter';
counter.id = 'gallery-counter';
counter.textContent = `1 / ${imageUrls.length}`;
main.appendChild(counter);
thumbsEl.style.display = 'flex';
imageUrls.forEach((url, i) => {
const t = document.createElement('img');
t.src = url;
t.className = 'thumb' + (i === 0 ? ' active' : '');
t.onclick = () => showImg(i);
thumbsEl.appendChild(t);
});
}
}
function openFullscreen(i) {
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.9);z-index:999;display:flex;align-items:center;justify-content:center;';
const img = document.createElement('img');
img.src = _imgs[i];
img.style.cssText = 'max-width:100%;max-height:100%;object-fit:contain;';
overlay.appendChild(img);
overlay.onclick = () => document.body.removeChild(overlay);
document.body.appendChild(overlay);
}
function parseQuickSpecs(spec, productionDate) {
if (!spec) return [];
const items = [];
const degM = spec.match(/(\d+(?:\.\d+)?)\s*(?:度|°|%vol)/i);
if (degM) items.push({ v: degM[1], u: '°', l: '酒精度' });
const mlM = spec.match(/(\d+(?:\.\d+)?)\s*ml/i);
if (mlM) items.push({ v: mlM[1], u: 'ml', l: '净含量' });
const xiangM = spec.match(/([清浓酱兼馥凤]香)/);
if (xiangM) items.push({ v: xiangM[1], u: '', l: '香型' });
if (productionDate) {
const yrM = productionDate.match(/^(\d{4})/);
if (yrM) items.push({ v: yrM[1], u: '', l: '年份' });
}
return items;
}
function paramRow(label, value) {
return `<div class="param-row"><div class="param-label">${label}</div><div class="param-val">${value}</div></div>`;
}
function render(d) {
const name = d.name || '';
const series = d.series || '';
const spec = d.spec || '';
const brand = d.brand || '';
const unit = d.unit || '';
const description = d.description || '';
const shop = d.shop;
const batch = d.batch;
const baseUrl = location.origin;
const images = (d.images || []).map(img => baseUrl + img.url);
renderGallery(images);
document.getElementById('product-name').textContent = name;
const subtitleParts = [series, spec].filter(Boolean);
const subtitleEl = document.getElementById('product-subtitle');
subtitleEl.textContent = subtitleParts.join(' · ');
if (!subtitleParts.length) subtitleEl.style.display = 'none';
const tagsEl = document.getElementById('product-tags');
if (brand) tagsEl.innerHTML += `<span class="tag tag-brand">${brand}</span>`;
if (unit) tagsEl.innerHTML += `<span class="tag tag-unit">${unit}</span>`;
// Ribbon shop info
if (shop && shop.name) {
document.getElementById('ribbon-shop').textContent = `出售方:${shop.name}`;
}
// Quick Specs — parse from spec string
const quickSpecsEl = document.getElementById('quick-specs');
const qs = parseQuickSpecs(spec, batch ? batch.production_date : null);
if (qs.length > 0) {
quickSpecsEl.style.display = 'grid';
quickSpecsEl.innerHTML = qs.map(it => `
<div class="quick-spec-item">
<div class="quick-spec-val">${it.v}<span class="quick-spec-unit">${it.u||''}</span></div>
<div class="quick-spec-label">${it.l}</div>
</div>`).join('');
}
// Params
let paramsHtml = '';
if (spec) paramsHtml += paramRow('规格', spec);
if (brand) paramsHtml += paramRow('品牌', brand);
if (unit) paramsHtml += paramRow('单位', unit);
if (description) paramsHtml += paramRow('描述', description);
if (paramsHtml) {
document.getElementById('params-rows').innerHTML = paramsHtml;
} else {
document.getElementById('params-card').style.display = 'none';
}
// Authenticity (batch info)
if (batch && (batch.batch_no || batch.production_date || batch.in_stock_date)) {
const authCard = document.getElementById('auth-card');
let authHtml = '';
if (batch.batch_no) authHtml += `<div class="auth-lbl">批次号</div><div class="auth-val auth-mono">${batch.batch_no}</div>`;
if (batch.production_date) authHtml += `<div class="auth-lbl">生产日期</div><div class="auth-val">${batch.production_date}</div>`;
if (batch.in_stock_date) authHtml += `<div class="auth-lbl">入库日期</div><div class="auth-val">${batch.in_stock_date}</div>`;
document.getElementById('auth-rows').innerHTML = authHtml;
authCard.style.display = 'block';
}
// Shop card
if (shop) {
const shopCard = document.getElementById('shop-card');
const shopName = shop.name || '';
document.getElementById('shop-display-name').textContent = shopName || '门店信息';
let shopHtml = '';
if (shop.code) shopHtml += paramRow('门店编号', shop.code);
if (shop.address) shopHtml += paramRow('地址', shop.address);
if (shop.phone) shopHtml += paramRow('电话', shop.phone);
if (shopHtml) {
document.getElementById('shop-rows').innerHTML = shopHtml;
shopCard.style.display = 'block';
}
}
document.getElementById('state-loading').style.display = 'none';
document.getElementById('content').style.display = 'block';
}
function load() {
const pid = getPublicId();
if (!pid) {
document.getElementById('state-loading').style.display = 'none';
document.getElementById('state-error').style.display = 'flex';
return;
}
document.getElementById('state-loading').style.display = 'flex';
document.getElementById('state-error').style.display = 'none';
document.getElementById('content').style.display = 'none';
fetch(`${API_BASE}/public/products/${pid}`)
.then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(data => render(data.data))
.catch(() => {
document.getElementById('state-loading').style.display = 'none';
document.getElementById('state-error').style.display = 'flex';
});
}
load();
</script>
</body>
</html>