Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ec1d4209a | |||
| 22f07ed805 | |||
| 45b6ec832b | |||
| ba127826b2 | |||
| 0e2a4086e1 | |||
| 99f34a6223 | |||
| ce92d619ed | |||
| 22a31f6eb8 | |||
| 1c901432a6 | |||
| e124ed7c65 |
@@ -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(共/未完成/已完成及分级列表)。
|
||||
@@ -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
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -308,6 +308,28 @@ CI/CD(Forgejo,`.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 <子命令> [参数]`
|
||||
|
||||
---
|
||||
|
||||
### 搜索实现(拼音)
|
||||
|
||||
商品名搜索同时支持汉字、全拼、首字母:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 超级管理员专属
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -53,12 +53,6 @@ server {
|
||||
try_files $uri /app/index.html;
|
||||
}
|
||||
|
||||
# 扫码页(/scan/<id> → scan.html,JS 从 URL 读取 id)
|
||||
location /scan/ {
|
||||
root /opt/jiu/marketing;
|
||||
try_files /scan.html =404;
|
||||
}
|
||||
|
||||
# 桌面客户端安装包下载(Windows .exe / macOS .zip)
|
||||
location /downloads/ {
|
||||
alias /opt/jiu/downloads/;
|
||||
|
||||
@@ -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
@@ -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_price;2) 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 CRUD(16 个方法);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
@@ -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_price;2) 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 CRUD(16 个方法);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
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
/** 把描述里的 `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(`📄 HTML:todo/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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
Reference in New Issue
Block a user