diff --git a/backend/config/config.go b/backend/config/config.go
index f20f1f0..591d8d9 100644
--- a/backend/config/config.go
+++ b/backend/config/config.go
@@ -14,6 +14,16 @@ type Config struct {
Storage StorageConfig
Session SessionConfig
RateLimit RateLimitConfig
+ Pay PayConfig
+}
+
+// PayConfig pay 收款中枢对接(契约:~/code/pay-contract openapi.yaml v1.0.0)。
+// Secret 为 jiu↔pay 双向 HMAC 共享密钥(pay 侧 BIZ_JIU_SECRET 同值),只经环境变量注入;
+// 为空时购买接口返回 503,webhook 一律拒绝。
+type PayConfig struct {
+ BaseURL string `mapstructure:"base_url"` // pay 服务地址
+ Secret string `mapstructure:"secret"` // HMAC 共享密钥(PAY_SECRET)
+ ReturnURL string `mapstructure:"return_url"` // 支付完成回跳页(pay 会拼 out_trade_no)
}
type ServerConfig struct {
@@ -86,6 +96,9 @@ func Load() {
_ = viper.BindEnv("storage.base_url", "STORAGE_BASE_URL")
_ = viper.BindEnv("storage.public_url", "STORAGE_PUBLIC_URL")
_ = viper.BindEnv("storage.web_dir", "STORAGE_WEB_DIR")
+ _ = viper.BindEnv("pay.base_url", "PAY_BASE_URL")
+ _ = viper.BindEnv("pay.secret", "PAY_SECRET")
+ _ = viper.BindEnv("pay.return_url", "PAY_RETURN_URL")
// 默认值
viper.SetDefault("server.port", "8080")
@@ -116,6 +129,8 @@ func Load() {
viper.SetDefault("storage.base_url", "http://localhost:8080/images")
viper.SetDefault("storage.public_url", "http://localhost:8081")
viper.SetDefault("storage.web_dir", "./web")
+ viper.SetDefault("pay.base_url", "https://pay.51yanmei.com")
+ viper.SetDefault("pay.return_url", "https://jiu.51yanmei.com/license/result/")
if err := viper.ReadInConfig(); err != nil {
log.Println("[config] no config file found, using defaults and env vars")
diff --git a/backend/internal/handler/pay.go b/backend/internal/handler/pay.go
new file mode 100644
index 0000000..d5c9887
--- /dev/null
+++ b/backend/internal/handler/pay.go
@@ -0,0 +1,90 @@
+package handler
+
+import (
+ "errors"
+ "log"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/wangjia/jiu/backend/internal/middleware"
+ "github.com/wangjia/jiu/backend/internal/service"
+ "github.com/wangjia/jiu/backend/internal/util"
+)
+
+type PayHandler struct {
+ svc *service.PayService
+}
+
+func NewPayHandler(svc *service.PayService) *PayHandler {
+ return &PayHandler{svc: svc}
+}
+
+// Purchase POST /api/v1/license/purchase — 在线购买/续费下单,返回收银台 pay_url。
+// 仅管理员可购买(handler 内判权,同 withdraw 模式)。
+func (h *PayHandler) Purchase(c *gin.Context) {
+ role := middleware.GetRole(c)
+ if role != "admin" && role != "superadmin" {
+ c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可购买授权"})
+ return
+ }
+ var req struct {
+ BizCode string `json:"biz_code" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ res, err := h.svc.CreatePurchase(middleware.GetShopID(c), middleware.GetUserID(c), req.BizCode)
+ if err != nil {
+ switch {
+ case errors.Is(err, service.ErrPayNotConfigured):
+ c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
+ case errors.Is(err, service.ErrUnknownPlan):
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ default:
+ log.Printf("[pay] purchase failed shop=%d biz_code=%s: %v", middleware.GetShopID(c), req.BizCode, err)
+ c.JSON(http.StatusBadGateway, gin.H{"error": "下单失败,请稍后重试"})
+ }
+ return
+ }
+ util.RespondSuccess(c, res)
+}
+
+// PurchaseStatus GET /api/v1/license/purchase/:out_trade_no — 结果页轮询购买单状态。
+func (h *PayHandler) PurchaseStatus(c *gin.Context) {
+ st, err := h.svc.Status(middleware.GetShopID(c), c.Param("out_trade_no"))
+ if err != nil {
+ if errors.Is(err, service.ErrPurchaseNotFound) {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ util.RespondSuccess(c, st)
+}
+
+// Callback POST /api/v1/pay/callback — pay 支付成功 webhook(公开路由,HMAC 验签)。
+// 契约:验签失败回 401;受理成功回 200 + {"code":"SUCCESS"},否则 pay 每 60s 重试 24h。
+func (h *PayHandler) Callback(c *gin.Context) {
+ rawBody, err := c.GetRawData()
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"code": "FAIL", "message": "read body failed"})
+ return
+ }
+ err = h.svc.HandleCallback(rawBody,
+ c.GetHeader("X-Pay-Timestamp"), c.GetHeader("X-Pay-Nonce"), c.GetHeader("X-Pay-Sign"))
+ if err != nil {
+ if errors.Is(err, service.ErrPaySignature) {
+ c.JSON(http.StatusUnauthorized, gin.H{"code": "FAIL", "message": "signature verification failed"})
+ return
+ }
+ // 其余错误(暂时性/金额不符/单不存在)回非 SUCCESS,让 pay 重试;金额不符会持续失败并留日志告警
+ log.Printf("[pay] callback rejected: %v", err)
+ c.JSON(http.StatusOK, gin.H{"code": "FAIL", "message": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"})
+}
diff --git a/backend/internal/model/license_purchase.go b/backend/internal/model/license_purchase.go
new file mode 100644
index 0000000..46ccb2d
--- /dev/null
+++ b/backend/internal/model/license_purchase.go
@@ -0,0 +1,19 @@
+package model
+
+import "time"
+
+// LicensePurchase 在线购买/续费记录(走 pay 收款中枢,契约见 ~/code/pay-contract)。
+// out_trade_no = pay 订单号,兼作对账键与幂等键(同一单只续期一次)。
+// Amount 存 pay 下单响应回传的权威金额(如 "2999.00"),webhook 回调时逐分核对。
+type LicensePurchase struct {
+ Base
+ ShopID uint64 `gorm:"not null;index" json:"shop_id"`
+ UserID uint64 `gorm:"not null" json:"user_id"`
+ ProductBizCode string `gorm:"size:64;not null" json:"product_biz_code"`
+ Amount string `gorm:"size:16" json:"amount"`
+ OutTradeNo string `gorm:"size:64;uniqueIndex:uk_out_trade_no" json:"out_trade_no"`
+ Status string `gorm:"type:enum('pending','paid','failed');default:'pending';index" json:"status"`
+ TradeNo string `gorm:"size:64" json:"trade_no,omitempty"` // 渠道交易号(支付宝/微信)
+ Channel string `gorm:"size:16" json:"channel,omitempty"`
+ PaidAt *time.Time `json:"paid_at,omitempty"`
+}
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index 1adf40f..2ef8604 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -15,11 +15,14 @@ func Setup(r *gin.Engine, db *gorm.DB) {
authSvc := service.NewAuthService(db)
licenseSvc := service.NewLicenseService(db)
stockSvc := service.NewStockService(db)
+ paySvc := service.NewPayService(db, config.C.Pay.BaseURL, config.C.Pay.Secret, config.C.Pay.ReturnURL)
+ service.StartPayReconcile(paySvc) // 查单兜底(PAY_SECRET 未配置时空转不启动)
// 处理器
authH := handler.NewAuthHandler(authSvc, licenseSvc)
sessionH := handler.NewSessionHandler(authSvc)
licenseH := handler.NewLicenseHandler(licenseSvc)
+ payH := handler.NewPayHandler(paySvc)
productH := handler.NewProductHandler(db)
warehouseH := handler.NewWarehouseHandler(db)
partnerH := handler.NewPartnerHandler(db)
@@ -79,6 +82,9 @@ func Setup(r *gin.Engine, db *gorm.DB) {
public.POST("/register", registerIP, authH.Register)
}
+ // pay 支付成功 webhook(公开路由,HMAC 验签在 handler 内;按 IP 限流防噪)
+ v1.POST("/pay/callback", publicReadIP, payH.Callback)
+
// 需要 JWT 的基础路由组
api := v1.Group("")
api.Use(middleware.JWT(db))
@@ -104,6 +110,9 @@ func Setup(r *gin.Engine, db *gorm.DB) {
license.GET("/verify", licenseH.Verify)
license.POST("/deactivate", licenseH.Deactivate)
license.GET("/devices", licenseH.Devices)
+ // 在线购买/续费(走 pay 收款中枢;仅管理员,handler 内判权)
+ license.POST("/purchase", payH.Purchase)
+ license.GET("/purchase/:out_trade_no", payH.PurchaseStatus)
}
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
diff --git a/backend/internal/service/pay.go b/backend/internal/service/pay.go
new file mode 100644
index 0000000..90909d1
--- /dev/null
+++ b/backend/internal/service/pay.go
@@ -0,0 +1,473 @@
+package service
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+
+ "github.com/wangjia/jiu/backend/internal/middleware"
+ "github.com/wangjia/jiu/backend/internal/model"
+ "github.com/wangjia/jiu/backend/internal/util"
+)
+
+// PayService jiu ↔ pay 收款中枢对接(契约 ~/code/pay-contract openapi.yaml v1.0.0)。
+// 四块职责:下单(CreatePurchase)、webhook 入账(HandleCallback)、续期(entitle)、查单兜底(reconcileOnce)。
+type PayService struct {
+ db *gorm.DB
+ baseURL string
+ secret string
+ retURL string
+ client *http.Client
+
+ prodMu sync.Mutex
+ prodCache map[string]int64 // biz_code -> pay product_id
+ prodAt time.Time
+}
+
+var (
+ ErrPayNotConfigured = errors.New("在线支付未配置")
+ ErrUnknownPlan = errors.New("未知套餐")
+ ErrPaySignature = errors.New("签名校验失败")
+ ErrPayAmount = errors.New("回调金额与订单不符")
+ ErrPurchaseNotFound = errors.New("购买记录不存在")
+)
+
+// payPlan biz_code → 权益映射(与 pay 侧 seed 的套餐一一对应,金额权威在 pay,此处 price 仅作前端展示核对)。
+type payPlan struct {
+ Days int
+ Tier string
+ Type string // License.Type: monthly | annual
+ MaxDevices int
+ Features model.JSON
+}
+
+var payPlans = map[string]payPlan{
+ "monthly_standard": {Days: 30, Tier: "standard", Type: "monthly", MaxDevices: 2,
+ Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
+ "annual_standard": {Days: 365, Tier: "standard", Type: "annual", MaxDevices: 2,
+ Features: model.JSON{"max_warehouses": 1, "image_quota": 1000, "ai_analysis": false}},
+ "monthly_pro": {Days: 30, Tier: "pro", Type: "monthly", MaxDevices: 5,
+ Features: model.JSON{"max_warehouses": 0, "image_quota": 10000, "ai_analysis": true}},
+ "annual_pro": {Days: 365, Tier: "pro", Type: "annual", MaxDevices: 5,
+ Features: model.JSON{"max_warehouses": 0, "image_quota": 10000, "ai_analysis": true}},
+}
+
+func NewPayService(db *gorm.DB, baseURL, secret, returnURL string) *PayService {
+ return &PayService{
+ db: db,
+ baseURL: strings.TrimRight(baseURL, "/"),
+ secret: secret,
+ retURL: returnURL,
+ client: &http.Client{Timeout: 10 * time.Second},
+ }
+}
+
+func (s *PayService) Configured() bool { return s.secret != "" }
+
+// ---------- ① 购买下单 ----------
+
+type PurchaseResult struct {
+ PayURL string `json:"pay_url"`
+ OutTradeNo string `json:"out_trade_no"`
+ Amount string `json:"amount"`
+ Subject string `json:"subject"`
+}
+
+// CreatePurchase 建购买记录并调 pay 下单,返回收银台跳转 URL。
+func (s *PayService) CreatePurchase(shopID, userID uint64, bizCode string) (*PurchaseResult, error) {
+ if !s.Configured() {
+ return nil, ErrPayNotConfigured
+ }
+ if _, ok := payPlans[bizCode]; !ok {
+ return nil, ErrUnknownPlan
+ }
+
+ productID, err := s.productID(bizCode)
+ if err != nil {
+ return nil, fmt.Errorf("获取套餐信息失败: %w", err)
+ }
+
+ p := model.LicensePurchase{ShopID: shopID, UserID: userID, ProductBizCode: bizCode, Status: "pending"}
+ if err := s.db.Create(&p).Error; err != nil {
+ return nil, err
+ }
+
+ reqBody, _ := json.Marshal(map[string]any{
+ "product_id": productID,
+ "biz_system": "jiu",
+ "biz_ref": strconv.FormatUint(p.ID, 10),
+ "return_url": s.retURL,
+ })
+ respBody, err := s.signedPost("/api/v1/orders", reqBody)
+ if err != nil {
+ return nil, fmt.Errorf("pay 下单失败: %w", err)
+ }
+ var resp struct {
+ Data PurchaseResult `json:"data"`
+ }
+ if err := json.Unmarshal(respBody, &resp); err != nil || resp.Data.PayURL == "" || resp.Data.OutTradeNo == "" {
+ return nil, fmt.Errorf("pay 下单响应异常")
+ }
+
+ if err := s.db.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{
+ "out_trade_no": resp.Data.OutTradeNo,
+ "amount": resp.Data.Amount,
+ }).Error; err != nil {
+ return nil, err
+ }
+ return &resp.Data, nil
+}
+
+// signedPost 按契约对原始 body 签名后 POST 到 pay。
+func (s *PayService) signedPost(path string, rawBody []byte) ([]byte, error) {
+ req, err := http.NewRequest(http.MethodPost, s.baseURL+path, strings.NewReader(string(rawBody)))
+ if err != nil {
+ return nil, err
+ }
+ ts := strconv.FormatInt(time.Now().Unix(), 10)
+ nonce := uuid.New().String()
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Pay-System", "jiu")
+ req.Header.Set("X-Pay-Timestamp", ts)
+ req.Header.Set("X-Pay-Nonce", nonce)
+ req.Header.Set("X-Pay-Sign", util.PaySign(s.secret, "jiu", ts, nonce, string(rawBody)))
+
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("pay HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+ return body, nil
+}
+
+// productID 按 biz_code 查 pay 套餐 id(GET /api/v1/products,内存缓存 10 分钟)。
+func (s *PayService) productID(bizCode string) (int64, error) {
+ s.prodMu.Lock()
+ defer s.prodMu.Unlock()
+ if s.prodCache != nil && time.Since(s.prodAt) < 10*time.Minute {
+ if id, ok := s.prodCache[bizCode]; ok {
+ return id, nil
+ }
+ }
+ resp, err := s.client.Get(s.baseURL + "/api/v1/products")
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if resp.StatusCode != http.StatusOK {
+ return 0, fmt.Errorf("pay HTTP %d", resp.StatusCode)
+ }
+ var pr struct {
+ Data []struct {
+ ID int64 `json:"id"`
+ BizCode string `json:"biz_code"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &pr); err != nil {
+ return 0, err
+ }
+ cache := make(map[string]int64, len(pr.Data))
+ for _, p := range pr.Data {
+ if p.BizCode != "" {
+ cache[p.BizCode] = p.ID
+ }
+ }
+ s.prodCache, s.prodAt = cache, time.Now()
+ id, ok := cache[bizCode]
+ if !ok {
+ return 0, fmt.Errorf("pay 侧无 biz_code=%s 的套餐", bizCode)
+ }
+ return id, nil
+}
+
+// ---------- ② webhook 入账 ----------
+
+type payNotification struct {
+ OutTradeNo string `json:"out_trade_no"`
+ BizSystem string `json:"biz_system"`
+ BizRef string `json:"biz_ref"`
+ ProductBizCode string `json:"product_biz_code"`
+ Amount string `json:"amount"`
+ TradeNo string `json:"trade_no"`
+ Channel string `json:"channel"`
+ PaidAt string `json:"paid_at"`
+}
+
+// HandleCallback 验签 + 幂等 + 金额核对 + 续期。错误分两类:
+// ErrPaySignature(回 401,不重试也无效);其余(回非 SUCCESS,pay 会重试)。
+func (s *PayService) HandleCallback(rawBody []byte, ts, nonce, sign string) error {
+ if !s.Configured() {
+ return ErrPaySignature
+ }
+ if !util.PaySignVerify(s.secret, sign, "jiu", ts, nonce, string(rawBody)) {
+ return ErrPaySignature
+ }
+ tsInt, err := strconv.ParseInt(ts, 10, 64)
+ if err != nil {
+ return ErrPaySignature
+ }
+ if d := time.Since(time.Unix(tsInt, 0)); d > 5*time.Minute || d < -5*time.Minute {
+ return ErrPaySignature
+ }
+
+ var n payNotification
+ if err := json.Unmarshal(rawBody, &n); err != nil || n.OutTradeNo == "" {
+ return fmt.Errorf("回调体解析失败")
+ }
+ paidAt := time.Now()
+ if t, err := time.Parse(time.RFC3339, n.PaidAt); err == nil {
+ paidAt = t
+ }
+ return s.settle(n.OutTradeNo, n.ProductBizCode, n.Amount, n.TradeNo, n.Channel, paidAt)
+}
+
+// settle 入账:幂等(同 out_trade_no 只续一次)+ 金额核对 + 同事务续期。
+// webhook 与查单兜底共用此入口。
+func (s *PayService) settle(outTradeNo, bizCode, amount, tradeNo, channel string, paidAt time.Time) error {
+ var shopID uint64
+ err := s.db.Transaction(func(tx *gorm.DB) error {
+ var p model.LicensePurchase
+ if err := tx.Set("gorm:query_option", "FOR UPDATE").
+ Where("out_trade_no = ?", outTradeNo).First(&p).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrPurchaseNotFound
+ }
+ return err
+ }
+ if p.Status == "paid" { // 幂等:pay 会重发
+ return nil
+ }
+ if !amountEqual(p.Amount, amount) {
+ log.Printf("[pay] amount mismatch out_trade_no=%s purchase=%s callback=%s", outTradeNo, p.Amount, amount)
+ return ErrPayAmount
+ }
+ // 权益按建单时的套餐映射;回调 biz_code 仅一致性校验(不一致以本地为准并告警)
+ if bizCode != "" && bizCode != p.ProductBizCode {
+ log.Printf("[pay] biz_code mismatch out_trade_no=%s purchase=%s callback=%s", outTradeNo, p.ProductBizCode, bizCode)
+ }
+ plan, ok := payPlans[p.ProductBizCode]
+ if !ok {
+ return ErrUnknownPlan
+ }
+ if err := entitle(tx, p.ShopID, plan); err != nil {
+ return err
+ }
+ shopID = p.ShopID
+ return tx.Model(&model.LicensePurchase{}).Where("id = ?", p.ID).Updates(map[string]any{
+ "status": "paid",
+ "trade_no": tradeNo,
+ "channel": channel,
+ "paid_at": paidAt,
+ }).Error
+ })
+ if err != nil {
+ return err
+ }
+ if shopID != 0 {
+ // 事务提交后失效授权 phase 缓存,写权限即时恢复(同 Redeem)
+ middleware.InvalidateLicensePhase(shopID)
+ log.Printf("[pay] settled out_trade_no=%s shop=%d", outTradeNo, shopID)
+ }
+ return nil
+}
+
+// entitle 给门店续期:时长直接叠加(未过期从到期日叠,已过期从现在起算),
+// 并写入套餐权益。逻辑对齐 LicenseService.Redeem 的叠加段。
+func entitle(tx *gorm.DB, shopID uint64, plan payPlan) error {
+ now := time.Now()
+ var lic model.License
+ err := tx.Set("gorm:query_option", "FOR UPDATE").
+ Where("shop_id = ? AND is_active = ?", shopID, true).
+ Order("id DESC").First(&lic).Error
+ creating := errors.Is(err, gorm.ErrRecordNotFound)
+ if err != nil && !creating {
+ return err
+ }
+
+ base := now
+ if !creating && lic.ExpiresAt != nil && lic.ExpiresAt.After(now) {
+ base = *lic.ExpiresAt
+ }
+ expires := base.Add(time.Duration(plan.Days) * 24 * time.Hour)
+
+ if creating {
+ lic = model.License{
+ ShopID: shopID,
+ LicenseKey: "PAY-" + uuid.New().String(),
+ Type: plan.Type,
+ Tier: plan.Tier,
+ ExpiresAt: &expires,
+ IsActive: true,
+ MaxDevices: plan.MaxDevices,
+ Features: plan.Features,
+ }
+ return tx.Create(&lic).Error
+ }
+ return tx.Model(&lic).Updates(map[string]any{
+ "type": plan.Type,
+ "tier": plan.Tier,
+ "expires_at": expires,
+ "is_active": true,
+ "max_devices": plan.MaxDevices,
+ "features": plan.Features,
+ }).Error
+}
+
+// amountEqual 金额按分归一比较("2999.00" == "2999" == "2999.0")。
+func amountEqual(a, b string) bool {
+ ca, ea := toCents(a)
+ cb, eb := toCents(b)
+ return ea == nil && eb == nil && ca == cb
+}
+
+func toCents(s string) (int64, error) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return 0, fmt.Errorf("empty amount")
+ }
+ neg := false
+ if strings.HasPrefix(s, "-") {
+ neg, s = true, s[1:]
+ }
+ intPart, frac, _ := strings.Cut(s, ".")
+ if intPart == "" {
+ intPart = "0"
+ }
+ frac = frac + "00"
+ i, err := strconv.ParseInt(intPart, 10, 64)
+ if err != nil {
+ return 0, err
+ }
+ f, err := strconv.ParseInt(frac[:2], 10, 64)
+ if err != nil {
+ return 0, err
+ }
+ c := i*100 + f
+ if neg {
+ c = -c
+ }
+ return c, nil
+}
+
+// ---------- ③ 状态查询(结果页轮询) ----------
+
+type PurchaseStatus struct {
+ OutTradeNo string `json:"out_trade_no"`
+ Status string `json:"status"`
+ BizCode string `json:"product_biz_code"`
+ Amount string `json:"amount"`
+ PaidAt *time.Time `json:"paid_at,omitempty"`
+ ExpiresAt *time.Time `json:"expires_at,omitempty"` // 续期后的门店授权到期时间
+}
+
+func (s *PayService) Status(shopID uint64, outTradeNo string) (*PurchaseStatus, error) {
+ var p model.LicensePurchase
+ if err := s.db.Where("shop_id = ? AND out_trade_no = ?", shopID, outTradeNo).First(&p).Error; err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrPurchaseNotFound
+ }
+ return nil, err
+ }
+ st := &PurchaseStatus{OutTradeNo: p.OutTradeNo, Status: p.Status, BizCode: p.ProductBizCode, Amount: p.Amount, PaidAt: p.PaidAt}
+ if p.Status == "paid" {
+ var lic model.License
+ if err := s.db.Where("shop_id = ? AND is_active = ?", shopID, true).
+ Order("id DESC").First(&lic).Error; err == nil {
+ st.ExpiresAt = lic.ExpiresAt
+ }
+ }
+ return st, nil
+}
+
+// ---------- ④ 查单兜底 ----------
+
+// StartPayReconcile 后台每 60s 对 pending 超 5 分钟的购买单主动查 pay 对账,
+// 防 webhook 全丢。与 webhook 同一入账入口(settle),天然幂等。
+func StartPayReconcile(s *PayService) {
+ if !s.Configured() {
+ log.Println("[pay] PAY_SECRET 未配置,查单兜底不启动")
+ return
+ }
+ go func() {
+ for {
+ time.Sleep(time.Minute)
+ s.reconcileOnce()
+ }
+ }()
+}
+
+func (s *PayService) reconcileOnce() {
+ var pendings []model.LicensePurchase
+ cutoff := time.Now().Add(-5 * time.Minute)
+ if err := s.db.Where("status = ? AND out_trade_no <> '' AND created_at < ?", "pending", cutoff).
+ Limit(50).Find(&pendings).Error; err != nil {
+ return
+ }
+ for _, p := range pendings {
+ st, err := s.queryOrder(p.OutTradeNo)
+ if err != nil {
+ continue
+ }
+ switch st.Status {
+ case "paid":
+ paidAt := time.Now()
+ if st.PaidAt != nil {
+ paidAt = *st.PaidAt
+ }
+ if err := s.settle(p.OutTradeNo, p.ProductBizCode, st.Amount, st.TradeNo, "", paidAt); err != nil {
+ log.Printf("[pay] reconcile settle failed out_trade_no=%s: %v", p.OutTradeNo, err)
+ } else {
+ log.Printf("[pay] reconcile settled out_trade_no=%s (webhook missed)", p.OutTradeNo)
+ }
+ case "closed":
+ s.db.Model(&model.LicensePurchase{}).Where("id = ? AND status = 'pending'", p.ID).
+ Update("status", "failed")
+ case "refunded":
+ // 本轮不冲权益,仅记录(退款处理后续设计)
+ log.Printf("[pay] order refunded out_trade_no=%s (no-op)", p.OutTradeNo)
+ }
+ }
+}
+
+type payOrderStatus struct {
+ OutTradeNo string `json:"out_trade_no"`
+ Amount string `json:"amount"`
+ Status string `json:"status"` // pending | paid | closed | refunded
+ TradeNo string `json:"trade_no"`
+ PaidAt *time.Time `json:"paid_at"`
+}
+
+// queryOrder 查单(契约未要求签名头)。
+func (s *PayService) queryOrder(outTradeNo string) (*payOrderStatus, error) {
+ resp, err := s.client.Get(s.baseURL + "/api/v1/orders/" + outTradeNo)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("pay HTTP %d", resp.StatusCode)
+ }
+ var r struct {
+ Data payOrderStatus `json:"data"`
+ }
+ if err := json.Unmarshal(body, &r); err != nil {
+ return nil, err
+ }
+ return &r.Data, nil
+}
diff --git a/backend/internal/service/pay_test.go b/backend/internal/service/pay_test.go
new file mode 100644
index 0000000..555106f
--- /dev/null
+++ b/backend/internal/service/pay_test.go
@@ -0,0 +1,293 @@
+package service
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+
+ "github.com/wangjia/jiu/backend/internal/model"
+ "github.com/wangjia/jiu/backend/internal/util"
+ "github.com/wangjia/jiu/backend/testutil"
+)
+
+const testPaySecret = "test-shared-secret"
+
+func newTestPaySvc(db *gorm.DB, baseURL string) *PayService {
+ return NewPayService(db, baseURL, testPaySecret, "https://jiu.example.com/license/result/")
+}
+
+// signedCallbackArgs 按契约给回调体生成签名参数(模拟 pay 侧签名)。
+func signedCallbackArgs(body []byte) (ts, nonce, sign string) {
+ ts = strconv.FormatInt(time.Now().Unix(), 10)
+ nonce = "test-nonce"
+ sign = util.PaySign(testPaySecret, "jiu", ts, nonce, string(body))
+ return
+}
+
+func callbackBody(outTradeNo, bizCode, amount string) []byte {
+ b, _ := json.Marshal(map[string]any{
+ "out_trade_no": outTradeNo,
+ "biz_system": "jiu",
+ "biz_ref": "1",
+ "product_biz_code": bizCode,
+ "amount": amount,
+ "trade_no": "2026070322001",
+ "channel": "alipay",
+ "paid_at": time.Now().Format(time.RFC3339),
+ })
+ return b
+}
+
+func createPendingPurchase(t *testing.T, db *gorm.DB, shopID uint64, bizCode, amount, otn string) *model.LicensePurchase {
+ t.Helper()
+ p := &model.LicensePurchase{ShopID: shopID, UserID: 1, ProductBizCode: bizCode, Amount: amount, OutTradeNo: otn, Status: "pending"}
+ require.NoError(t, db.Create(p).Error)
+ return p
+}
+
+// ---------- 签名 ----------
+
+func TestPaySign_Vector(t *testing.T) {
+ // 与契约参考实现一致:base64(HMAC_SHA256(secret, join("\n", parts)))
+ got := util.PaySign("secret", "jiu", "1751520000", "nonce", `{"a":1}`)
+ assert.NotEmpty(t, got)
+ assert.True(t, util.PaySignVerify("secret", got, "jiu", "1751520000", "nonce", `{"a":1}`))
+ assert.False(t, util.PaySignVerify("secret", got, "jiu", "1751520001", "nonce", `{"a":1}`))
+ assert.False(t, util.PaySignVerify("other", got, "jiu", "1751520000", "nonce", `{"a":1}`))
+}
+
+func TestAmountEqual(t *testing.T) {
+ assert.True(t, amountEqual("2999.00", "2999"))
+ assert.True(t, amountEqual("2999.0", "2999.00"))
+ assert.True(t, amountEqual("0.01", "0.01"))
+ assert.False(t, amountEqual("2999.00", "2999.01"))
+ assert.False(t, amountEqual("", "2999"))
+ assert.False(t, amountEqual("abc", "2999"))
+}
+
+// ---------- 回调:验签门 ----------
+
+func TestHandleCallback_BadSignature(t *testing.T) {
+ db := testutil.SetupTestDB()
+ svc := newTestPaySvc(db, "http://pay.invalid")
+ body := callbackBody("yanmei-1", "annual_standard", "2999.00")
+ ts, nonce, _ := signedCallbackArgs(body)
+
+ err := svc.HandleCallback(body, ts, nonce, "forged-signature")
+ assert.ErrorIs(t, err, ErrPaySignature)
+}
+
+func TestHandleCallback_ExpiredTimestamp(t *testing.T) {
+ db := testutil.SetupTestDB()
+ svc := newTestPaySvc(db, "http://pay.invalid")
+ body := callbackBody("yanmei-1", "annual_standard", "2999.00")
+ ts := strconv.FormatInt(time.Now().Add(-10*time.Minute).Unix(), 10)
+ sign := util.PaySign(testPaySecret, "jiu", ts, "n", string(body))
+
+ err := svc.HandleCallback(body, ts, "n", sign)
+ assert.ErrorIs(t, err, ErrPaySignature)
+}
+
+func TestHandleCallback_NotConfigured(t *testing.T) {
+ db := testutil.SetupTestDB()
+ svc := NewPayService(db, "http://pay.invalid", "", "")
+ body := callbackBody("yanmei-1", "annual_standard", "2999.00")
+ ts, nonce, sign := signedCallbackArgs(body)
+ assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPaySignature)
+}
+
+// ---------- 回调:入账 ----------
+
+func TestHandleCallback_SettleAndRenew(t *testing.T) {
+ db := testutil.SetupTestDB()
+ shop := testutil.CreateTestShop(db, "PAY001")
+ createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-otn-1")
+
+ svc := newTestPaySvc(db, "http://pay.invalid")
+ body := callbackBody("yanmei-otn-1", "annual_standard", "2999.00")
+ ts, nonce, sign := signedCallbackArgs(body)
+ require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
+
+ var p model.LicensePurchase
+ require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-1").First(&p).Error)
+ assert.Equal(t, "paid", p.Status)
+ assert.Equal(t, "alipay", p.Channel)
+ assert.NotNil(t, p.PaidAt)
+
+ var lic model.License
+ require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
+ assert.Equal(t, "standard", lic.Tier)
+ assert.Equal(t, "annual", lic.Type)
+ assert.Equal(t, 2, lic.MaxDevices)
+ assert.InDelta(t, 365, daysFromNow(lic.ExpiresAt), 1)
+ assert.Equal(t, float64(1000), lic.Features["image_quota"]) // JSON 数字解出 float64
+}
+
+func TestHandleCallback_Idempotent(t *testing.T) {
+ db := testutil.SetupTestDB()
+ shop := testutil.CreateTestShop(db, "PAY002")
+ createPendingPurchase(t, db, shop.ID, "monthly_pro", "599.00", "yanmei-otn-2")
+
+ svc := newTestPaySvc(db, "http://pay.invalid")
+ body := callbackBody("yanmei-otn-2", "monthly_pro", "599.00")
+ ts, nonce, sign := signedCallbackArgs(body)
+ require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
+
+ var expires1 time.Time
+ {
+ var lic model.License
+ require.NoError(t, db.Where("shop_id = ?", shop.ID).First(&lic).Error)
+ expires1 = *lic.ExpiresAt
+ }
+
+ // pay 重发同一单:不得重复续期
+ ts2, nonce2, sign2 := signedCallbackArgs(body)
+ require.NoError(t, svc.HandleCallback(body, ts2, nonce2, sign2))
+ var lic model.License
+ require.NoError(t, db.Where("shop_id = ?", shop.ID).First(&lic).Error)
+ assert.True(t, lic.ExpiresAt.Equal(expires1), "重发不得二次叠加")
+}
+
+func TestHandleCallback_AmountMismatch(t *testing.T) {
+ db := testutil.SetupTestDB()
+ shop := testutil.CreateTestShop(db, "PAY003")
+ createPendingPurchase(t, db, shop.ID, "annual_pro", "5999.00", "yanmei-otn-3")
+
+ svc := newTestPaySvc(db, "http://pay.invalid")
+ body := callbackBody("yanmei-otn-3", "annual_pro", "0.01") // 篡改金额
+ ts, nonce, sign := signedCallbackArgs(body)
+ assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPayAmount)
+
+ var p model.LicensePurchase
+ require.NoError(t, db.Where("out_trade_no = ?", "yanmei-otn-3").First(&p).Error)
+ assert.Equal(t, "pending", p.Status, "金额不符不得入账")
+}
+
+func TestHandleCallback_UnknownOrder(t *testing.T) {
+ db := testutil.SetupTestDB()
+ svc := newTestPaySvc(db, "http://pay.invalid")
+ body := callbackBody("yanmei-not-exist", "annual_standard", "2999.00")
+ ts, nonce, sign := signedCallbackArgs(body)
+ assert.ErrorIs(t, svc.HandleCallback(body, ts, nonce, sign), ErrPurchaseNotFound)
+}
+
+// ---------- 续期叠加 ----------
+
+func TestEntitle_StackOnActiveLicense(t *testing.T) {
+ db := testutil.SetupTestDB()
+ shop := testutil.CreateTestShop(db, "PAY004")
+ // 现有授权还剩 100 天(如试用/兑换券),购买年付应从到期日往后叠
+ future := time.Now().Add(100 * 24 * time.Hour)
+ require.NoError(t, db.Create(&model.License{
+ ShopID: shop.ID, LicenseKey: "SEED-1", Type: "trial", Tier: "standard",
+ ExpiresAt: &future, IsActive: true, MaxDevices: 3,
+ }).Error)
+ createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-otn-4")
+
+ svc := newTestPaySvc(db, "http://pay.invalid")
+ body := callbackBody("yanmei-otn-4", "annual_standard", "2999.00")
+ ts, nonce, sign := signedCallbackArgs(body)
+ require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
+
+ var lic model.License
+ require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
+ assert.InDelta(t, 100+365, daysFromNow(lic.ExpiresAt), 1, "未过期应从到期日叠加")
+ assert.Equal(t, "annual", lic.Type)
+}
+
+func TestEntitle_ExpiredStartsFromNow(t *testing.T) {
+ db := testutil.SetupTestDB()
+ shop := testutil.CreateTestShop(db, "PAY005")
+ past := time.Now().Add(-30 * 24 * time.Hour)
+ require.NoError(t, db.Create(&model.License{
+ ShopID: shop.ID, LicenseKey: "SEED-2", Type: "trial", Tier: "standard",
+ ExpiresAt: &past, IsActive: true, MaxDevices: 3,
+ }).Error)
+ createPendingPurchase(t, db, shop.ID, "monthly_standard", "299.00", "yanmei-otn-5")
+
+ svc := newTestPaySvc(db, "http://pay.invalid")
+ body := callbackBody("yanmei-otn-5", "monthly_standard", "299.00")
+ ts, nonce, sign := signedCallbackArgs(body)
+ require.NoError(t, svc.HandleCallback(body, ts, nonce, sign))
+
+ var lic model.License
+ require.NoError(t, db.Where("shop_id = ?", shop.ID).Order("id DESC").First(&lic).Error)
+ assert.InDelta(t, 30, daysFromNow(lic.ExpiresAt), 1, "已过期应从现在起算")
+}
+
+// ---------- 下单 ----------
+
+func TestCreatePurchase_HappyPath(t *testing.T) {
+ db := testutil.SetupTestDB()
+ shop := testutil.CreateTestShop(db, "PAY006")
+
+ // 假 pay 服务:/products 列表 + /orders 验签后返回 pay_url
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/products", func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprint(w, `{"data":[{"id":3,"biz_code":"annual_standard"},{"id":4,"biz_code":"monthly_pro"}]}`)
+ })
+ var gotBizRef string
+ mux.HandleFunc("POST /api/v1/orders", func(w http.ResponseWriter, r *http.Request) {
+ body := make([]byte, r.ContentLength)
+ _, _ = r.Body.Read(body)
+ if !util.PaySignVerify(testPaySecret, r.Header.Get("X-Pay-Sign"),
+ r.Header.Get("X-Pay-System"), r.Header.Get("X-Pay-Timestamp"), r.Header.Get("X-Pay-Nonce"), string(body)) {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ var req map[string]any
+ _ = json.Unmarshal(body, &req)
+ gotBizRef, _ = req["biz_ref"].(string)
+ fmt.Fprint(w, `{"data":{"pay_url":"https://openapi.alipay.com/gateway","out_trade_no":"yanmei-new-1","amount":"2999.00","subject":"年付标准"}}`)
+ })
+ payServer := httptest.NewServer(mux)
+ defer payServer.Close()
+
+ svc := newTestPaySvc(db, payServer.URL)
+ res, err := svc.CreatePurchase(shop.ID, 1, "annual_standard")
+ require.NoError(t, err)
+ assert.Equal(t, "https://openapi.alipay.com/gateway", res.PayURL)
+ assert.Equal(t, "yanmei-new-1", res.OutTradeNo)
+
+ var p model.LicensePurchase
+ require.NoError(t, db.Where("out_trade_no = ?", "yanmei-new-1").First(&p).Error)
+ assert.Equal(t, "pending", p.Status)
+ assert.Equal(t, "2999.00", p.Amount)
+ assert.Equal(t, strconv.FormatUint(p.ID, 10), gotBizRef, "biz_ref 应为购买记录 id")
+}
+
+func TestCreatePurchase_UnknownPlanAndUnconfigured(t *testing.T) {
+ db := testutil.SetupTestDB()
+ svc := newTestPaySvc(db, "http://pay.invalid")
+ _, err := svc.CreatePurchase(1, 1, "no_such_plan")
+ assert.ErrorIs(t, err, ErrUnknownPlan)
+
+ unconfigured := NewPayService(db, "http://pay.invalid", "", "")
+ _, err = unconfigured.CreatePurchase(1, 1, "annual_standard")
+ assert.ErrorIs(t, err, ErrPayNotConfigured)
+}
+
+// ---------- Status ----------
+
+func TestStatus_ScopedToShop(t *testing.T) {
+ db := testutil.SetupTestDB()
+ shop := testutil.CreateTestShop(db, "PAY007")
+ other := testutil.CreateTestShop(db, "PAY008")
+ createPendingPurchase(t, db, shop.ID, "annual_standard", "2999.00", "yanmei-otn-7")
+
+ svc := newTestPaySvc(db, "http://pay.invalid")
+ st, err := svc.Status(shop.ID, "yanmei-otn-7")
+ require.NoError(t, err)
+ assert.Equal(t, "pending", st.Status)
+
+ _, err = svc.Status(other.ID, "yanmei-otn-7")
+ assert.ErrorIs(t, err, ErrPurchaseNotFound, "跨店不可见")
+}
diff --git a/backend/internal/util/paysign.go b/backend/internal/util/paysign.go
new file mode 100644
index 0000000..8080adf
--- /dev/null
+++ b/backend/internal/util/paysign.go
@@ -0,0 +1,21 @@
+package util
+
+import (
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "strings"
+)
+
+// PaySign pay 收款中枢双向 HMAC 签名(契约 ~/code/pay-contract §签名,与 pay 侧 util.HMACSign 一致):
+// sign = base64( HMAC_SHA256( secret, biz_system + "\n" + timestamp + "\n" + nonce + "\n" + rawBody ) )
+func PaySign(secret string, parts ...string) string {
+ m := hmac.New(sha256.New, []byte(secret))
+ m.Write([]byte(strings.Join(parts, "\n")))
+ return base64.StdEncoding.EncodeToString(m.Sum(nil))
+}
+
+// PaySignVerify 常量时间比较验签。
+func PaySignVerify(secret, got string, parts ...string) bool {
+ return hmac.Equal([]byte(PaySign(secret, parts...)), []byte(got))
+}
diff --git a/backend/main.go b/backend/main.go
index f185115..71b0310 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -121,6 +121,7 @@ func autoMigrate(db *gorm.DB) {
&model.License{},
&model.LicenseDevice{},
&model.LicenseCode{},
+ &model.LicensePurchase{},
&model.UserSession{},
&model.LoginAttempt{},
&model.ProductCategory{},
diff --git a/backend/schema/schema.sql b/backend/schema/schema.sql
index a15bd46..584dba1 100644
--- a/backend/schema/schema.sql
+++ b/backend/schema/schema.sql
@@ -157,6 +157,31 @@ CREATE TABLE IF NOT EXISTS `license_codes` (
KEY `idx_redeemed_shop` (`redeemed_shop_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='兑换券码池';
+-- ------------------------------------------------------------
+-- 在线购买/续费记录(pay 收款中枢,契约 pay-contract v1.0.0)
+-- out_trade_no = pay 订单号,对账键 + 幂等键;amount 为 pay 回传权威金额
+-- ------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS `license_purchases` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+ `shop_id` BIGINT UNSIGNED NOT NULL,
+ `user_id` BIGINT UNSIGNED NOT NULL COMMENT '下单管理员',
+ `product_biz_code` VARCHAR(64) NOT NULL COMMENT '套餐稳定码(monthly_standard/annual_standard/monthly_pro/annual_pro)',
+ `amount` VARCHAR(16) DEFAULT NULL COMMENT 'pay 下单回传金额,如 2999.00',
+ `out_trade_no` VARCHAR(64) DEFAULT NULL COMMENT 'pay 订单号',
+ `status` ENUM('pending','paid','failed') NOT NULL DEFAULT 'pending',
+ `trade_no` VARCHAR(64) DEFAULT NULL COMMENT '渠道交易号(支付宝/微信)',
+ `channel` VARCHAR(16) DEFAULT NULL,
+ `paid_at` DATETIME DEFAULT NULL,
+ `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `deleted_at` DATETIME DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_out_trade_no` (`out_trade_no`),
+ KEY `idx_shop` (`shop_id`),
+ KEY `idx_status` (`status`),
+ KEY `idx_deleted_at` (`deleted_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='在线购买/续费记录';
+
-- ------------------------------------------------------------
-- 商品分类
-- ------------------------------------------------------------
diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go
index 1a1354b..555bb8d 100644
--- a/backend/testutil/setup.go
+++ b/backend/testutil/setup.go
@@ -150,6 +150,21 @@ func SetupTestDB() *gorm.DB {
created_at DATETIME,
updated_at DATETIME
)`,
+ `CREATE TABLE IF NOT EXISTS license_purchases (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ created_at DATETIME,
+ updated_at DATETIME,
+ deleted_at DATETIME,
+ shop_id INTEGER NOT NULL,
+ user_id INTEGER NOT NULL,
+ product_biz_code TEXT NOT NULL,
+ amount TEXT,
+ out_trade_no TEXT UNIQUE,
+ status TEXT NOT NULL DEFAULT 'pending',
+ trade_no TEXT,
+ channel TEXT,
+ paid_at DATETIME
+ )`,
`CREATE TABLE IF NOT EXISTS license_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
license_id INTEGER NOT NULL,
diff --git a/web/assets/color.css b/web/assets/color.css
deleted file mode 100644
index 9574df8..0000000
--- a/web/assets/color.css
+++ /dev/null
@@ -1,261 +0,0 @@
-/* =========================================================================
- 岩美 Design System — Foundations
- Tokens for color, type, spacing, radius, shadow.
- Import once at root:
- ========================================================================= */
-
-: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);
-}
diff --git a/web/assets/features-approval.css b/web/assets/features-approval.css
deleted file mode 100644
index c30d490..0000000
--- a/web/assets/features-approval.css
+++ /dev/null
@@ -1,810 +0,0 @@
-.feat-hero {
- position: relative; overflow: hidden;
- padding: 80px 0 72px;
- background:
- radial-gradient(900px 480px at 10% 30%, rgba(37, 99, 172, 0.06), transparent 60%),
- radial-gradient(900px 480px at 90% 90%, rgba(139, 35, 49, 0.04), transparent 60%),
- linear-gradient(180deg, #FBFCFD 0%, #FFFFFF 80%);
- border-bottom: 1px solid var(--border-subtle);
-}
-.feat-hero-inner {
- display: grid;
- grid-template-columns: 1fr 1.2fr;
- gap: 64px;
- align-items: center;
-}
-.feat-breadcrumb {
- display: flex; align-items: center; gap: 8px;
- font-size: var(--text-sm); color: var(--fg-muted);
- margin-bottom: 20px;
-}
-.feat-breadcrumb a { color: var(--fg-muted); }
-.feat-breadcrumb a:hover { color: var(--brand-500); }
-.feat-breadcrumb .icon { width: 14px; height: 14px; color: var(--gray-300); }
-.feat-hero h1 {
- font-size: 48px; line-height: 1.15;
- letter-spacing: var(--tracking-cn-display);
- color: var(--brand-900); margin: 0 0 18px;
- font-weight: 700;
-}
-.feat-hero h1 em {
- font-style: normal; color: var(--accent-500);
-}
-.feat-hero p.lead {
- font-size: var(--text-xl); line-height: 1.6;
- color: var(--gray-600); margin: 0 0 28px;
-}
-.feat-hero .actions { display: flex; gap: 12px; margin-bottom: 28px; }
-.feat-hero .quick-stats {
- display: grid; grid-template-columns: repeat(3, 1fr);
- border-top: 1px solid var(--border-subtle);
- padding-top: 20px;
-}
-.feat-hero .quick-stat-label { font-size: 11px; color: var(--fg-subtle); letter-spacing: 0.04em; }
-.feat-hero .quick-stat-val { font-size: var(--text-xl); font-weight: 600; color: var(--brand-900); font-variant-numeric: tabular-nums; margin-top: 4px; }
-
-/* ====================================================================
- HERO STAMP / DOCUMENT VISUAL
- ==================================================================== */
-.hero-doc-stack {
- position: relative;
- height: 520px;
-}
-.hero-doc {
- position: absolute;
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-lg);
- padding: 24px;
- transform-origin: center;
-}
-.hero-doc-back-2 {
- top: 36px; left: 24px; right: 64px; bottom: 64px;
- transform: rotate(-2deg);
- opacity: 0.6;
- background: var(--gray-50);
-}
-.hero-doc-back-1 {
- top: 18px; left: 12px; right: 80px; bottom: 80px;
- transform: rotate(2deg);
- opacity: 0.85;
- background: var(--gray-25);
-}
-.hero-doc-main {
- top: 0; left: 60px; right: 0; bottom: 60px;
- box-shadow: var(--shadow-xl);
-}
-.hero-doc-head {
- display: flex; justify-content: space-between; align-items: flex-start;
- padding-bottom: 16px;
- border-bottom: 1px solid var(--border-subtle);
- margin-bottom: 16px;
-}
-.hero-doc-id {
- font-family: var(--font-mono); font-size: var(--text-md);
- color: var(--brand-500); font-weight: 600;
-}
-.hero-doc-meta { font-size: 11px; color: var(--fg-muted); margin-top: 4px; font-family: var(--font-mono); }
-.hero-doc-status {
- background: var(--success-50); color: var(--success-700);
- font-size: 12px; font-weight: 600;
- padding: 4px 12px; border-radius: var(--radius-pill);
- display: inline-flex; align-items: center; gap: 4px;
-}
-.hero-doc-status .icon { width: 14px; height: 14px; }
-
-.hero-doc-row {
- display: grid; grid-template-columns: 1fr 60px 80px;
- padding: 8px 0;
- border-bottom: 1px dashed var(--border-subtle);
- font-size: var(--text-sm);
- color: var(--gray-700);
- font-variant-numeric: tabular-nums;
-}
-.hero-doc-row:last-of-type { border-bottom: 1px solid var(--border-default); }
-.hero-doc-foot {
- display: grid; grid-template-columns: 1fr 60px 80px;
- padding-top: 10px;
- font-weight: 600;
- color: var(--brand-900);
- font-size: var(--text-md);
-}
-
-/* Stamp visual on hero */
-.hero-stamp {
- position: absolute;
- right: -10px; bottom: 20px;
- width: 130px; height: 130px;
- border-radius: 50%;
- border: 3px solid var(--accent-500);
- background: rgba(139, 35, 49, 0.04);
- transform: rotate(-12deg);
- display: grid; place-items: center;
- z-index: 10;
- box-shadow: 0 6px 18px rgba(139, 35, 49, 0.15);
-}
-.hero-stamp::before {
- content: '';
- position: absolute; inset: 8px;
- border-radius: 50%;
- border: 1px solid var(--accent-500);
-}
-.hero-stamp svg.ring { position: absolute; inset: 0; width: 100%; height: 100%; }
-.hero-stamp-core {
- font-family: var(--font-sans);
- color: var(--accent-500);
- text-align: center;
- font-weight: 700;
- z-index: 1;
-}
-.hero-stamp-core .big { font-size: 22px; letter-spacing: 0.06em; }
-.hero-stamp-core .sub { font-size: 9px; letter-spacing: 0.16em; margin-top: 2px; font-weight: 600; }
-.hero-stamp-core .date { font-family: var(--font-mono); font-size: 10px; margin-top: 4px; color: var(--accent-700); font-weight: 500; }
-
-/* ====================================================================
- FEATURE BLOCKS — alternating
- ==================================================================== */
-.feat-block {
- padding: 96px 0;
- border-bottom: 1px solid var(--border-subtle);
-}
-.feat-block:nth-child(even) { background: var(--gray-25); }
-.feat-block-inner {
- display: grid;
- grid-template-columns: 5fr 7fr;
- gap: 64px;
- align-items: center;
-}
-.feat-block.reverse .feat-block-inner { grid-template-columns: 7fr 5fr; }
-.feat-block.reverse .feat-text { order: 2; }
-.feat-block.reverse .feat-visual { order: 1; }
-
-.feat-tag {
- display: inline-flex; align-items: center; gap: 6px;
- background: var(--brand-50);
- color: var(--brand-700);
- padding: 4px 10px;
- border-radius: var(--radius-pill);
- font-size: var(--text-xs);
- font-weight: 600;
- letter-spacing: 0.04em;
- margin-bottom: 16px;
-}
-.feat-tag.danger { background: var(--danger-50); color: var(--danger-700); }
-.feat-tag.success { background: var(--success-50); color: var(--success-700); }
-.feat-tag .icon { width: 14px; height: 14px; }
-
-.feat-text h2 {
- font-size: 36px; font-weight: 600;
- line-height: 1.18; letter-spacing: var(--tracking-cn-display);
- color: var(--brand-900); margin: 0 0 16px;
-}
-.feat-text p.lead {
- font-size: var(--text-lg); line-height: 1.7;
- color: var(--gray-600); margin: 0 0 24px;
-}
-.feat-bullets { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; }
-.feat-bullets li {
- display: grid; grid-template-columns: 22px 1fr;
- gap: 12px;
- font-size: var(--text-md); color: var(--gray-700);
- line-height: 1.6; align-items: start;
-}
-.feat-bullets li .icon { width: 18px; height: 18px; color: var(--brand-500); margin-top: 2px; }
-.feat-bullets li strong { color: var(--brand-900); font-weight: 600; display: block; margin-bottom: 2px; }
-
-/* ====================================================================
- BLOCK 1: STATE MACHINE
- ==================================================================== */
-.state-machine {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-md);
- padding: 48px 36px 40px;
- position: relative;
- overflow: hidden;
-}
-.state-machine-tabs {
- position: absolute; top: 16px; left: 16px;
- display: flex; gap: 2px;
- background: var(--gray-50);
- padding: 3px;
- border-radius: var(--radius-md);
- border: 1px solid var(--border-default);
-}
-.state-tab {
- padding: 4px 12px;
- font-size: 11px;
- font-weight: 500;
- color: var(--fg-muted);
- border-radius: var(--radius-sm);
- letter-spacing: 0.04em;
-}
-.state-tab.active {
- background: var(--brand-500); color: #fff;
-}
-.state-machine-meta {
- position: absolute; top: 24px; right: 20px;
- font-size: 11px; color: var(--fg-subtle);
- font-family: var(--font-mono); letter-spacing: 0.04em;
-}
-
-/* Diagram */
-.sm-diagram {
- display: grid;
- grid-template-columns: 1fr 50px 1fr 50px 1fr;
- gap: 0;
- align-items: center;
- margin-top: 24px;
-}
-.sm-node {
- display: flex; flex-direction: column;
- align-items: center; gap: 8px;
-}
-.sm-bubble {
- padding: 12px 18px;
- border-radius: var(--radius-pill);
- font-size: var(--text-md);
- font-weight: 600;
- letter-spacing: 0.04em;
- border: 2px solid;
- display: inline-flex; align-items: center; gap: 6px;
- background: var(--gray-0);
-}
-.sm-bubble .icon { width: 14px; height: 14px; }
-.sm-node.draft .sm-bubble { border-color: var(--gray-300); color: var(--gray-700); background: var(--gray-50); }
-.sm-node.pending .sm-bubble { border-color: var(--info-500); color: var(--info-700); background: var(--info-50); }
-.sm-node.approved .sm-bubble {
- border-color: var(--success-500); color: var(--success-700);
- background: var(--success-50);
- box-shadow: 0 0 0 4px rgba(46, 139, 87, 0.10);
-}
-.sm-node.rejected .sm-bubble { border-color: var(--danger-500); color: var(--danger-700); background: var(--danger-50); }
-.sm-node small { font-size: 11px; color: var(--fg-muted); font-family: var(--font-mono); }
-
-.sm-arrow {
- display: flex; align-items: center; justify-content: center;
- color: var(--gray-400);
- position: relative;
-}
-.sm-arrow svg { width: 36px; height: 14px; }
-.sm-arrow-label {
- position: absolute; top: -22px;
- font-size: 10px; color: var(--fg-subtle);
- font-family: var(--font-mono); letter-spacing: 0.04em;
- white-space: nowrap;
-}
-
-.sm-fork {
- display: flex; flex-direction: column; gap: 20px;
- position: relative;
-}
-.sm-fork::before {
- content: '';
- position: absolute;
- left: -28px; top: 50%; transform: translateY(-50%);
- width: 1px; height: 60%;
- background: var(--gray-300);
-}
-
-/* applies-to legend */
-.sm-applies {
- margin-top: 32px;
- padding-top: 20px;
- border-top: 1px dashed var(--border-default);
- display: flex; gap: 10px; align-items: center;
- flex-wrap: wrap;
- font-size: var(--text-xs);
- color: var(--fg-muted);
- letter-spacing: 0.02em;
-}
-.sm-applies-pill {
- display: inline-flex; align-items: center; gap: 6px;
- padding: 4px 10px;
- background: var(--gray-50);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-md);
- font-size: 11px;
- color: var(--gray-700);
- font-weight: 500;
-}
-.sm-applies-pill .icon { width: 12px; height: 12px; color: var(--brand-500); }
-
-/* ====================================================================
- BLOCK 2: AUTO-LINKING — Before/After
- ==================================================================== */
-.linking-visual {
- display: flex; flex-direction: column; gap: 0;
-}
-.linking-doc {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-md);
- padding: 20px 24px;
- position: relative;
-}
-.linking-doc-head {
- display: flex; justify-content: space-between; align-items: center;
- margin-bottom: 12px;
-}
-.linking-doc-id {
- font-family: var(--font-mono); font-size: var(--text-md);
- color: var(--brand-700); font-weight: 600;
-}
-.linking-doc-action {
- display: inline-flex; align-items: center; gap: 6px;
- padding: 6px 14px;
- background: var(--success-500); color: #fff;
- border-radius: var(--radius-md);
- font-size: 12px; font-weight: 600;
- letter-spacing: 0.04em;
-}
-.linking-doc-action .icon { width: 14px; height: 14px; }
-
-.linking-arrow {
- width: 100%;
- display: flex; align-items: center; justify-content: center;
- padding: 16px 0;
- position: relative;
-}
-.linking-arrow::before, .linking-arrow::after {
- content: ''; height: 1px; flex: 1;
- background: var(--gray-200);
-}
-.linking-arrow-label {
- margin: 0 14px;
- padding: 4px 14px;
- background: var(--success-50);
- color: var(--success-700);
- border-radius: var(--radius-pill);
- font-size: var(--text-xs);
- font-weight: 600;
- letter-spacing: 0.04em;
- display: inline-flex; align-items: center; gap: 6px;
-}
-.linking-arrow-label .icon { width: 13px; height: 13px; }
-
-.linking-effects {
- display: grid; grid-template-columns: 1fr 1fr;
- gap: 12px;
-}
-.linking-effect {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- padding: 20px;
- position: relative;
-}
-.linking-effect-label {
- font-size: 11px; color: var(--fg-muted);
- font-weight: 600; letter-spacing: 0.06em;
- text-transform: uppercase;
- margin-bottom: 12px;
- display: flex; align-items: center; gap: 6px;
-}
-.linking-effect-label .icon { width: 14px; height: 14px; color: var(--brand-500); }
-.linking-effect-num {
- font-size: 28px; font-weight: 700;
- color: var(--brand-900);
- font-variant-numeric: tabular-nums;
- font-family: var(--font-mono);
-}
-.linking-effect-from {
- font-size: 12px; color: var(--fg-subtle);
- font-family: var(--font-mono);
- margin-top: 4px;
- font-variant-numeric: tabular-nums;
-}
-.linking-effect-from .delta { color: var(--success-700); font-weight: 600; }
-
-/* ====================================================================
- BLOCK 3: STOCK VALIDATION — Error visual
- ==================================================================== */
-.validation-card {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-md);
- overflow: hidden;
-}
-.validation-head {
- padding: 16px 20px;
- background: var(--gray-50);
- border-bottom: 1px solid var(--border-subtle);
- display: flex; justify-content: space-between; align-items: center;
-}
-.validation-title { font-size: var(--text-sm); font-weight: 600; color: var(--brand-900); }
-.validation-meta { font-size: 11px; color: var(--fg-muted); font-family: var(--font-mono); }
-
-.validation-body { padding: 20px; }
-.validation-row {
- display: grid;
- grid-template-columns: 1fr 60px 60px 80px;
- gap: 12px;
- padding: 10px 0;
- border-bottom: 1px solid var(--border-subtle);
- font-size: var(--text-sm);
- align-items: center;
- font-variant-numeric: tabular-nums;
-}
-.validation-row.head { font-size: 11px; color: var(--fg-muted); font-weight: 500; padding-top: 0; }
-.validation-row .qty-req { font-weight: 600; color: var(--gray-900); }
-.validation-row .qty-stock { color: var(--gray-700); }
-.validation-row .qty-shortage {
- background: var(--danger-50);
- color: var(--danger-700);
- font-weight: 600;
- padding: 2px 8px;
- border-radius: var(--radius-pill);
- font-size: 11px;
- text-align: center;
- display: inline-block;
-}
-.validation-row.invalid .qty-req { color: var(--danger-700); }
-
-.error-toast {
- margin: 16px 0 0;
- padding: 14px 16px;
- background: var(--danger-50);
- border: 1px solid #F8B4B4;
- border-left: 3px solid var(--danger-500);
- border-radius: var(--radius-md);
- display: grid; grid-template-columns: 22px 1fr;
- gap: 12px;
-}
-.error-toast .icon { width: 18px; height: 18px; color: var(--danger-700); margin-top: 1px; }
-.error-toast-title {
- font-size: var(--text-sm); font-weight: 600;
- color: var(--danger-700);
- margin-bottom: 2px;
-}
-.error-toast-body { font-size: 12px; color: var(--gray-700); line-height: 1.55; }
-
-.api-response {
- background: var(--gray-900);
- color: #DCE2EB;
- font-family: var(--font-mono);
- font-size: 12px;
- padding: 16px 18px;
- border-radius: var(--radius-md);
- margin-top: 16px;
- line-height: 1.7;
- overflow-x: auto;
-}
-.api-response .key { color: #ADC9EA; }
-.api-response .str { color: #C97B86; }
-.api-response .num { color: #FFD9A6; }
-.api-response .bool { color: #C97B86; font-weight: 500; }
-
-/* ====================================================================
- BLOCK 4: AUDIT LOG TIMELINE
- ==================================================================== */
-.audit-timeline {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-md);
- overflow: hidden;
-}
-.audit-head {
- padding: 14px 18px;
- background: var(--gray-50);
- border-bottom: 1px solid var(--border-subtle);
- display: flex; justify-content: space-between; align-items: center;
-}
-.audit-doc-id { font-family: var(--font-mono); color: var(--brand-700); font-size: var(--text-sm); font-weight: 600; }
-.audit-doc-status {
- padding: 3px 10px; border-radius: var(--radius-pill);
- background: var(--success-50); color: var(--success-700);
- font-size: 11px; font-weight: 600;
-}
-
-.audit-events { padding: 4px 18px 18px; }
-.audit-event {
- display: grid;
- grid-template-columns: 32px 1fr;
- gap: 14px;
- padding: 14px 0;
- border-bottom: 1px solid var(--border-subtle);
- position: relative;
-}
-.audit-event:last-child { border-bottom: none; }
-
-.audit-event-marker {
- width: 32px; height: 32px;
- border-radius: 50%;
- display: grid; place-items: center;
- position: relative;
- z-index: 1;
-}
-.audit-event-marker .icon { width: 16px; height: 16px; }
-.audit-event.create .audit-event-marker { background: var(--gray-100); color: var(--gray-700); }
-.audit-event.submit .audit-event-marker { background: var(--info-50); color: var(--info-700); border: 1px solid var(--info-500); }
-.audit-event.approve .audit-event-marker { background: var(--success-50); color: var(--success-700); border: 1px solid var(--success-500); }
-.audit-event.reject .audit-event-marker { background: var(--danger-50); color: var(--danger-700); border: 1px solid var(--danger-500); }
-.audit-event.settle .audit-event-marker { background: var(--brand-50); color: var(--brand-700); border: 1px solid var(--brand-500); }
-
-.audit-event::before {
- content: '';
- position: absolute;
- left: 15px; top: 32px; bottom: -14px;
- width: 1px;
- background: var(--gray-200);
-}
-.audit-event:last-child::before { display: none; }
-
-.audit-event-body { padding-top: 5px; }
-.audit-event-action {
- font-size: var(--text-sm);
- font-weight: 600;
- color: var(--brand-900);
- margin-bottom: 2px;
-}
-.audit-event-meta {
- font-size: 12px; color: var(--fg-muted);
- display: flex; gap: 12px;
- flex-wrap: wrap;
- font-variant-numeric: tabular-nums;
- letter-spacing: 0.02em;
-}
-.audit-event-meta strong { color: var(--gray-800); font-weight: 500; }
-.audit-event-meta .time { font-family: var(--font-mono); font-size: 11px; }
-.audit-event-comment {
- margin-top: 6px;
- padding: 8px 12px;
- background: var(--gray-25);
- border-radius: var(--radius-sm);
- font-size: 12px;
- color: var(--gray-700);
- line-height: 1.55;
- font-style: italic;
- border-left: 2px solid var(--gray-300);
-}
-
-/* ====================================================================
- REVERSAL STYLES (unused, kept for possible future use)
- ==================================================================== */
-.reversal-pair {
- display: grid;
- grid-template-columns: 1fr 60px 1fr;
- gap: 0;
- align-items: center;
-}
-.reversal-doc {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-md);
- padding: 18px;
- position: relative;
-}
-.reversal-doc.original .reversal-stamp-mini { color: var(--success-500); border-color: var(--success-500); }
-.reversal-doc.reversal {
- background: linear-gradient(135deg, #FDFAFA 0%, #FFF 100%);
- border-color: #F1D2D7;
-}
-.reversal-stamp-mini {
- position: absolute;
- top: 14px; right: 14px;
- font-size: 10px;
- border: 1.5px solid var(--accent-500);
- color: var(--accent-500);
- padding: 2px 8px;
- border-radius: var(--radius-sm);
- font-weight: 700;
- letter-spacing: 0.08em;
- transform: rotate(-4deg);
-}
-.reversal-doc-id {
- font-family: var(--font-mono); font-size: 13px;
- color: var(--brand-700); font-weight: 600;
- margin-bottom: 4px;
-}
-.reversal-doc-meta {
- font-size: 11px; color: var(--fg-muted);
- font-family: var(--font-mono);
- margin-bottom: 12px;
- padding-bottom: 12px;
- border-bottom: 1px solid var(--border-subtle);
-}
-.reversal-doc-row {
- display: grid; grid-template-columns: 1fr 60px;
- font-size: 12px;
- padding: 5px 0;
- color: var(--gray-700);
- font-variant-numeric: tabular-nums;
-}
-.reversal-doc.reversal .reversal-doc-row { color: var(--accent-700); }
-.reversal-doc-foot {
- margin-top: 12px; padding-top: 10px;
- border-top: 1px solid var(--border-default);
- font-size: var(--text-sm); font-weight: 700;
- color: var(--brand-900);
- display: grid; grid-template-columns: 1fr 70px;
- font-variant-numeric: tabular-nums;
-}
-.reversal-doc.reversal .reversal-doc-foot { color: var(--accent-500); }
-.reversal-arrow {
- display: flex; flex-direction: column;
- align-items: center; gap: 6px;
- color: var(--accent-500);
- font-size: 10px;
- font-family: var(--font-mono);
- letter-spacing: 0.06em;
-}
-.reversal-arrow svg { width: 24px; height: 24px; }
-.reversal-arrow-label {
- background: var(--accent-50);
- color: var(--accent-700);
- padding: 3px 10px;
- border-radius: var(--radius-pill);
- font-size: 10px;
- font-weight: 600;
- letter-spacing: 0.04em;
-}
-
-.reversal-net {
- margin-top: 24px;
- padding: 16px 20px;
- background: var(--brand-900);
- color: #fff;
- border-radius: var(--radius-md);
- display: grid;
- grid-template-columns: 1fr 1fr 1fr;
- gap: 16px;
-}
-.reversal-net-item { text-align: center; }
-.reversal-net-label {
- font-size: 11px; color: #ADC9EA;
- letter-spacing: 0.06em; margin-bottom: 4px;
-}
-.reversal-net-val {
- font-size: var(--text-xl); font-weight: 700;
- font-variant-numeric: tabular-nums;
- font-family: var(--font-mono);
-}
-
-/* ====================================================================
- BLOCK 6: PERMISSION MATRIX
- ==================================================================== */
-.perm-matrix {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- overflow: hidden;
- box-shadow: var(--shadow-md);
-}
-.perm-row {
- display: grid;
- grid-template-columns: 200px repeat(4, 1fr);
- align-items: center;
- border-bottom: 1px solid var(--border-subtle);
- font-size: var(--text-sm);
-}
-.perm-row.head {
- background: var(--brand-900); color: #fff;
- font-size: 12px; font-weight: 500;
- letter-spacing: 0.04em;
-}
-.perm-row.head > div { padding: 14px 16px; }
-.perm-row.head > div:not(:first-child) { text-align: center; border-left: 1px solid rgba(255,255,255,0.08); }
-.perm-row:last-child { border-bottom: none; }
-.perm-role {
- padding: 16px 20px;
- background: var(--gray-25);
- font-weight: 600;
- color: var(--brand-900);
-}
-.perm-role small {
- display: block; font-weight: 400;
- color: var(--fg-muted);
- font-size: 11px;
- margin-top: 2px;
- letter-spacing: 0.02em;
-}
-.perm-cell {
- padding: 16px;
- text-align: center;
- border-left: 1px solid var(--border-subtle);
-}
-.perm-cell .icon { width: 18px; height: 18px; display: inline-block; }
-.perm-cell.yes .icon { color: var(--success-500); }
-.perm-cell.no .icon { color: var(--gray-300); }
-.perm-cell.partial { color: var(--warning-500); font-size: 11px; font-weight: 500; }
-
-/* ====================================================================
- USE CASES
- ==================================================================== */
-.usecases-grid {
- display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px;
- margin-top: 48px;
-}
-.usecase-card {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- padding: 28px;
-}
-.usecase-card .icon { width: 36px; height: 36px; color: var(--brand-500); margin-bottom: 16px; }
-.usecase-card h4 { margin: 0 0 8px; font-size: var(--text-lg); font-weight: 600; color: var(--brand-900); }
-.usecase-card p { margin: 0; font-size: var(--text-sm); color: var(--gray-700); line-height: 1.65; }
-.usecase-card .quote {
- margin-top: 14px;
- font-size: 12px;
- color: var(--fg-muted);
- font-style: italic;
- border-left: 2px solid var(--brand-300);
- padding-left: 12px;
-}
-
-/* Final CTA */
-.cta-strip {
- background:
- radial-gradient(800px 360px at 20% 30%, rgba(139, 35, 49, 0.20), transparent 60%),
- linear-gradient(135deg, #0A1F3B 0%, #15407D 100%);
- color: #fff;
- border-radius: var(--radius-xl);
- padding: 56px 48px;
- display: grid;
- grid-template-columns: 1.4fr 1fr;
- gap: 32px;
- align-items: center;
- margin: 0 0 96px;
-}
-.cta-strip h2 { font-size: 32px; font-weight: 600; letter-spacing: var(--tracking-cn-display); color: #fff; margin: 0 0 12px; line-height: 1.25; }
-.cta-strip p { font-size: var(--text-md); color: #ADC9EA; margin: 0; line-height: 1.6; }
-.cta-strip-actions { display: flex; flex-direction: column; gap: 10px; align-items: flex-end; }
-.cta-strip .btn-primary { background: #fff; color: var(--brand-700); }
-.cta-strip .btn-primary:hover { background: #DCE2EB; }
-.cta-strip .btn-secondary { background: transparent; color: #fff; border-color: rgba(255,255,255,0.30); }
-.cta-strip .btn-secondary:hover { background: rgba(255,255,255,0.10); }
-
-/* Footer */
-footer { background: var(--gray-25); border-top: 1px solid var(--border-subtle); padding: 64px 0 32px; color: var(--fg-muted); font-size: var(--text-sm); }
-.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); }
-
-@media (max-width: 1024px) {
- .feat-hero-inner, .feat-block-inner, .feat-block.reverse .feat-block-inner, .cta-strip, .linking-effects, .reversal-pair { grid-template-columns: 1fr; gap: 24px; }
- .feat-block.reverse .feat-text, .feat-block.reverse .feat-visual { order: initial; }
- .usecases-grid { grid-template-columns: 1fr 1fr; }
- .footer-grid { grid-template-columns: 1fr 1fr; }
- .feat-hero h1 { font-size: 36px; }
- .sm-diagram { grid-template-columns: 1fr; gap: 16px; }
- .sm-arrow { transform: rotate(90deg); }
- .perm-row { grid-template-columns: 130px repeat(4, 1fr); }
- .reversal-arrow { transform: rotate(90deg); padding: 16px 0; }
-}
-/* Hamburger */
-.nav-hamburger { display: none; flex-direction: column; justify-content: center; gap: 5px; width: 40px; height: 40px; padding: 8px; background: none; border: none; cursor: pointer; border-radius: 8px; }
-.nav-hamburger span { display: block; height: 2px; width: 22px; background: var(--gray-700); border-radius: 2px; }
-.nav-mobile-menu { display: none; flex-direction: column; background: var(--gray-0); border-top: 1px solid var(--border-subtle); padding: 8px 0 16px; }
-.nav-mobile-menu.open { display: flex; }
-.nav-mobile-menu a { padding: 12px 24px; font-size: var(--text-md); color: var(--gray-700); font-weight: 500; }
-.nav-mobile-menu a:hover { background: var(--gray-50); color: var(--brand-900); }
-.nav-mobile-cta { display: flex; flex-direction: column; gap: 8px; padding: 12px 24px 0; border-top: 1px solid var(--border-subtle); margin-top: 8px; }
-@media (max-width: 768px) {
- .nav-hamburger { display: flex; }
- .topnav-links, .topnav-cta { display: none; }
- .feat-hero-inner { grid-template-columns: 1fr; }
-}
-@media (max-width: 600px) {
- .topnav-inner { padding: 0 16px; }
- .feat-hero h1 { font-size: 28px; }
- .footer-grid { grid-template-columns: 1fr; gap: 32px; }
- .footer-bottom { flex-direction: column; gap: 8px; text-align: center; }
- .usecases-grid { grid-template-columns: 1fr; }
- .perm-row { overflow-x: auto; }
- .container { padding: 0 16px; }
-}
-
diff --git a/web/assets/features-inventory.css b/web/assets/features-inventory.css
deleted file mode 100644
index c13a80e..0000000
--- a/web/assets/features-inventory.css
+++ /dev/null
@@ -1,552 +0,0 @@
-.feat-hero {
- position: relative; overflow: hidden;
- padding: 80px 0 72px;
- background:
- radial-gradient(1000px 480px at 88% 8%, rgba(37, 99, 172, 0.08), transparent 60%),
- linear-gradient(180deg, #FBFCFD 0%, #FFFFFF 80%);
- border-bottom: 1px solid var(--border-subtle);
-}
-.feat-hero-inner {
- display: grid;
- grid-template-columns: 1fr 1.15fr;
- gap: 56px;
- align-items: center;
-}
-.feat-breadcrumb {
- display: flex; align-items: center; gap: 8px;
- font-size: var(--text-sm); color: var(--fg-muted);
- margin-bottom: 20px;
-}
-.feat-breadcrumb a { color: var(--fg-muted); }
-.feat-breadcrumb a:hover { color: var(--brand-500); }
-.feat-breadcrumb .icon { width: 14px; height: 14px; color: var(--gray-300); }
-.feat-hero h1 {
- font-size: 48px; line-height: 1.15;
- letter-spacing: var(--tracking-cn-display);
- color: var(--brand-900); margin: 0 0 18px;
- font-weight: 700;
-}
-.feat-hero p.lead {
- font-size: var(--text-xl); line-height: 1.6;
- color: var(--gray-600); margin: 0 0 28px;
-}
-.feat-hero .actions { display: flex; gap: 12px; margin-bottom: 28px; }
-.feat-hero .quick-stats {
- display: grid; grid-template-columns: repeat(3, 1fr);
- gap: 0; border-top: 1px solid var(--border-subtle);
- padding-top: 20px;
-}
-.feat-hero .quick-stat-label { font-size: 11px; color: var(--fg-subtle); letter-spacing: 0.04em; }
-.feat-hero .quick-stat-val { font-size: var(--text-xl); font-weight: 600; color: var(--brand-900); font-variant-numeric: tabular-nums; margin-top: 4px; }
-
-/* Hero composite: stacked cards */
-.composite { position: relative; height: 540px; }
-.composite-card {
- position: absolute;
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-md);
- overflow: hidden;
-}
-.composite-main { top: 0; left: 0; right: 60px; bottom: 60px; box-shadow: var(--shadow-xl); }
-.composite-secondary { right: 0; top: 70px; width: 240px; padding: 16px; box-shadow: var(--shadow-lg); }
-.composite-tertiary { left: 80px; bottom: 0; right: 0; padding: 14px; box-shadow: var(--shadow-md); }
-
-.mc-head {
- padding: 12px 16px;
- background: var(--gray-50);
- border-bottom: 1px solid var(--border-subtle);
- display: flex; justify-content: space-between; align-items: center;
-}
-.mc-title { font-size: 13px; font-weight: 600; color: var(--brand-900); }
-.mc-tabs { display: flex; gap: 4px; }
-.mc-tab { padding: 3px 10px; font-size: 11px; color: var(--fg-muted); border-radius: var(--radius-sm); }
-.mc-tab.active { background: var(--gray-0); color: var(--brand-700); border: 1px solid var(--brand-200); }
-
-.mc-stats { display: grid; grid-template-columns: repeat(3, 1fr); padding: 14px 16px; gap: 10px; border-bottom: 1px solid var(--border-subtle); background: var(--gray-25); }
-.mc-stat-label { font-size: 10px; color: var(--fg-subtle); margin-bottom: 2px; }
-.mc-stat-val { font-size: 16px; font-weight: 600; color: var(--brand-900); font-variant-numeric: tabular-nums; }
-.mc-stat-val .delta { font-size: 10px; color: var(--success-500); margin-left: 4px; font-weight: 500; }
-
-.mc-table { font-size: 11px; }
-.mc-thead, .mc-trow {
- display: grid;
- grid-template-columns: 88px 1fr 50px 60px 60px 70px;
- padding: 8px 16px;
- gap: 8px;
- align-items: center;
- font-variant-numeric: tabular-nums;
-}
-.mc-thead { background: var(--gray-50); font-size: 10px; font-weight: 500; color: var(--fg-muted); border-bottom: 1px solid var(--border-subtle); }
-.mc-trow { border-bottom: 1px solid var(--border-subtle); color: var(--gray-800); }
-.mc-trow:last-child { border-bottom: none; }
-.mc-trow .sku { font-family: var(--font-mono); color: var(--brand-500); font-size: 10px; }
-.mc-pill { display: inline-block; padding: 1px 7px; border-radius: var(--radius-pill); font-size: 9px; font-weight: 500; }
-.mc-pill.ok { background: var(--success-50); color: var(--success-700); }
-.mc-pill.warn { background: var(--warning-50); color: var(--warning-700); }
-.mc-pill.low { background: var(--danger-50); color: var(--danger-700); }
-
-/* Alert card secondary */
-.alert-card-title { font-size: 11px; color: var(--fg-muted); margin-bottom: 8px; letter-spacing: 0.04em; }
-.alert-row {
- display: flex; justify-content: space-between; align-items: center;
- padding: 8px 10px;
- background: var(--warning-50);
- border-left: 2px solid var(--warning-500);
- border-radius: 3px;
- margin-bottom: 6px;
- font-size: 12px;
-}
-.alert-row.danger { background: var(--danger-50); border-color: var(--danger-500); }
-.alert-row .name { color: var(--gray-900); font-weight: 500; flex: 1; }
-.alert-row .qty { color: var(--gray-700); font-variant-numeric: tabular-nums; font-family: var(--font-mono); font-size: 11px; }
-
-/* Batch card (tertiary) */
-.batch-title { font-size: 11px; color: var(--fg-muted); margin-bottom: 6px; letter-spacing: 0.04em; display: flex; align-items: center; gap: 6px; }
-.batch-title .icon { width: 12px; height: 12px; color: var(--brand-500); }
-.batch-row {
- display: grid;
- grid-template-columns: 90px 1fr 60px 50px;
- font-size: 11px;
- padding: 6px 0;
- border-bottom: 1px dashed var(--border-subtle);
- align-items: center;
- font-variant-numeric: tabular-nums;
-}
-.batch-row:last-child { border-bottom: none; }
-.batch-row .lot { font-family: var(--font-mono); color: var(--brand-600); font-size: 10px; }
-.batch-row .meta { color: var(--fg-muted); font-size: 10px; }
-
-/* ====================================================================
- FEATURE BLOCKS
- ==================================================================== */
-.feat-block {
- padding: 96px 0;
- border-bottom: 1px solid var(--border-subtle);
-}
-.feat-block:nth-child(even) { background: var(--gray-25); }
-.feat-block-inner {
- display: grid;
- grid-template-columns: 5fr 7fr;
- gap: 64px;
- align-items: center;
-}
-.feat-block.reverse .feat-block-inner { grid-template-columns: 7fr 5fr; }
-.feat-block.reverse .feat-text { order: 2; }
-.feat-block.reverse .feat-visual { order: 1; }
-
-.feat-tag {
- display: inline-flex; align-items: center; gap: 6px;
- background: var(--brand-50);
- color: var(--brand-700);
- padding: 4px 10px;
- border-radius: var(--radius-pill);
- font-size: var(--text-xs);
- font-weight: 600;
- letter-spacing: 0.04em;
- margin-bottom: 16px;
-}
-.feat-tag .icon { width: 14px; height: 14px; }
-
-.feat-text h2 {
- font-size: 36px;
- font-weight: 600;
- line-height: 1.18;
- letter-spacing: var(--tracking-cn-display);
- color: var(--brand-900);
- margin: 0 0 16px;
-}
-.feat-text p.lead {
- font-size: var(--text-lg);
- line-height: 1.7;
- color: var(--gray-600);
- margin: 0 0 24px;
-}
-.feat-bullets { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; }
-.feat-bullets li {
- display: grid;
- grid-template-columns: 22px 1fr;
- gap: 12px;
- font-size: var(--text-md);
- color: var(--gray-700);
- line-height: 1.6;
- align-items: start;
-}
-.feat-bullets li .icon { width: 18px; height: 18px; color: var(--brand-500); margin-top: 2px; }
-.feat-bullets li strong { color: var(--brand-900); font-weight: 600; display: block; margin-bottom: 2px; }
-
-/* Visual primitives */
-.visual-card {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-md);
- overflow: hidden;
-}
-.visual-card-head {
- padding: 14px 18px;
- background: var(--gray-50);
- border-bottom: 1px solid var(--border-subtle);
- display: flex; justify-content: space-between; align-items: center;
-}
-.visual-card-title { font-size: var(--text-sm); font-weight: 600; color: var(--brand-900); }
-.visual-card-meta { font-size: 11px; color: var(--fg-muted); }
-
-/* Inventory table visual */
-.inv-table { font-size: var(--text-sm); }
-.inv-table-row {
- display: grid;
- grid-template-columns: 110px 1fr 70px 80px 80px 110px;
- padding: 12px 18px;
- gap: 12px;
- border-bottom: 1px solid var(--border-subtle);
- align-items: center;
- font-variant-numeric: tabular-nums;
-}
-.inv-table-row.head {
- background: var(--gray-50);
- font-size: 11px; font-weight: 500;
- color: var(--fg-muted);
- letter-spacing: 0.02em;
- padding: 10px 18px;
-}
-.inv-table-row:last-child { border-bottom: none; }
-.inv-table-row:hover:not(.head) { background: var(--gray-25); }
-.inv-table-row .sku { font-family: var(--font-mono); color: var(--brand-500); font-size: 12px; }
-.inv-table-row .name { color: var(--gray-900); font-weight: 500; }
-.inv-table-row .name small { display: block; color: var(--fg-muted); font-weight: 400; font-size: 11px; margin-top: 1px; }
-
-/* Batch tracking visual — journey */
-.batch-journey {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-md);
- padding: 28px;
-}
-.batch-journey-head {
- display: flex; justify-content: space-between; align-items: flex-start;
- margin-bottom: 24px;
- padding-bottom: 18px;
- border-bottom: 1px solid var(--border-subtle);
-}
-.bj-meta-main { font-size: var(--text-sm); }
-.bj-lot { font-family: var(--font-mono); font-size: var(--text-xl); font-weight: 600; color: var(--brand-700); margin-bottom: 4px; }
-.bj-product { font-size: var(--text-md); color: var(--gray-900); font-weight: 500; }
-.bj-product small { color: var(--fg-muted); font-weight: 400; margin-left: 8px; }
-.bj-totals { text-align: right; }
-.bj-total-row { font-size: var(--text-xs); color: var(--fg-muted); display: flex; gap: 8px; justify-content: flex-end; margin-bottom: 4px; }
-.bj-total-row strong { color: var(--gray-900); font-weight: 600; font-variant-numeric: tabular-nums; }
-
-.bj-progress {
- background: var(--gray-100);
- height: 8px;
- border-radius: var(--radius-pill);
- margin-bottom: 24px;
- position: relative;
- overflow: hidden;
-}
-.bj-progress-bar {
- height: 100%;
- background: linear-gradient(90deg, var(--brand-500), var(--brand-400));
- width: 67%;
- border-radius: var(--radius-pill);
-}
-.bj-progress-labels { display: flex; justify-content: space-between; margin-top: -16px; }
-.bj-progress-labels span { font-size: 11px; color: var(--fg-muted); font-variant-numeric: tabular-nums; }
-
-.bj-timeline { display: flex; flex-direction: column; gap: 0; }
-.bj-event {
- display: grid;
- grid-template-columns: 16px 110px 1fr 80px 90px;
- gap: 16px;
- padding: 12px 0;
- align-items: center;
- border-bottom: 1px solid var(--border-subtle);
- position: relative;
-}
-.bj-event:last-child { border-bottom: none; }
-.bj-event-dot {
- width: 10px; height: 10px;
- border-radius: 50%;
- background: var(--gray-300);
- margin: 0 auto;
-}
-.bj-event.stockin .bj-event-dot { background: var(--brand-500); box-shadow: 0 0 0 4px var(--brand-50); }
-.bj-event.stockout .bj-event-dot { background: var(--accent-500); }
-.bj-event.adjust .bj-event-dot { background: var(--warning-500); }
-.bj-event-date { font-size: 11px; color: var(--fg-muted); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
-.bj-event-desc { font-size: var(--text-sm); color: var(--gray-800); }
-.bj-event-desc small { display: block; color: var(--fg-muted); font-size: 11px; margin-top: 2px; }
-.bj-event-qty { font-size: var(--text-md); font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; }
-.bj-event.stockin .bj-event-qty { color: var(--success-700); }
-.bj-event.stockout .bj-event-qty { color: var(--accent-500); }
-.bj-event-doc { font-size: 11px; color: var(--brand-500); font-family: var(--font-mono); text-align: right; }
-
-/* Stocktake comparison */
-.stocktake-table {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- overflow: hidden;
- box-shadow: var(--shadow-md);
-}
-.st-summary {
- display: grid; grid-template-columns: repeat(4, 1fr);
- padding: 18px 20px;
- background: var(--brand-900);
- color: #fff;
- gap: 16px;
-}
-.st-summary-item .label { font-size: 11px; color: #ADC9EA; margin-bottom: 4px; letter-spacing: 0.04em; }
-.st-summary-item .val { font-size: var(--text-xl); font-weight: 600; font-variant-numeric: tabular-nums; }
-.st-summary-item .val.neg { color: var(--danger-500); }
-
-.st-row {
- display: grid;
- grid-template-columns: 1fr 70px 70px 80px 90px;
- padding: 12px 20px;
- gap: 12px;
- border-bottom: 1px solid var(--border-subtle);
- font-size: var(--text-sm);
- align-items: center;
- font-variant-numeric: tabular-nums;
-}
-.st-row:last-child { border-bottom: none; }
-.st-row.head { background: var(--gray-50); font-size: 11px; color: var(--fg-muted); font-weight: 500; }
-.st-row.head > div { text-align: right; }
-.st-row.head > div:first-child { text-align: left; }
-.st-row .diff-pill {
- display: inline-block;
- padding: 2px 8px;
- border-radius: var(--radius-pill);
- font-size: 11px;
- font-weight: 600;
-}
-.st-row .diff-pill.zero { background: var(--gray-100); color: var(--gray-700); }
-.st-row .diff-pill.neg { background: var(--danger-50); color: var(--danger-700); }
-.st-row .diff-pill.pos { background: var(--success-50); color: var(--success-700); }
-.st-row > div:not(:first-child) { text-align: right; }
-
-/* Log timeline */
-.log-card {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-md);
- overflow: hidden;
-}
-.log-toolbar {
- display: flex; gap: 8px; padding: 12px 18px;
- background: var(--gray-50);
- border-bottom: 1px solid var(--border-subtle);
- align-items: center;
- font-size: var(--text-xs);
- color: var(--fg-muted);
-}
-.log-filter-pill {
- display: inline-flex; align-items: center; gap: 4px;
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-pill);
- padding: 3px 10px;
- font-size: 11px;
- color: var(--gray-700);
-}
-.log-filter-pill.active { background: var(--brand-50); border-color: var(--brand-300); color: var(--brand-700); }
-.log-filter-pill .icon { width: 12px; height: 12px; }
-.log-row {
- display: grid;
- grid-template-columns: 100px 60px 1fr 60px 80px;
- padding: 12px 18px;
- gap: 14px;
- border-bottom: 1px solid var(--border-subtle);
- font-size: var(--text-sm);
- align-items: center;
- font-variant-numeric: tabular-nums;
-}
-.log-row:last-child { border-bottom: none; }
-.log-time { font-family: var(--font-mono); font-size: 11px; color: var(--fg-muted); }
-.log-type-pill {
- display: inline-block;
- padding: 1px 8px;
- border-radius: var(--radius-pill);
- font-size: 10px;
- font-weight: 500;
-}
-.log-type-pill.in { background: var(--success-50); color: var(--success-700); }
-.log-type-pill.out { background: var(--accent-50); color: var(--accent-700); }
-.log-type-pill.check { background: var(--info-50); color: var(--info-700); }
-.log-desc { color: var(--gray-800); font-size: 13px; }
-.log-desc small { color: var(--fg-muted); display: block; font-size: 11px; margin-top: 1px; }
-.log-qty { text-align: right; font-weight: 600; }
-.log-qty.in { color: var(--success-700); }
-.log-qty.out { color: var(--accent-500); }
-.log-op { text-align: right; font-size: 12px; color: var(--gray-700); }
-
-/* Alert config */
-.alert-config-card {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- box-shadow: var(--shadow-md);
- padding: 24px;
-}
-.alert-config-card h4 {
- margin: 0 0 4px;
- font-size: var(--text-md);
- font-weight: 600;
- color: var(--brand-900);
-}
-.alert-config-card p.sub { margin: 0 0 20px; font-size: var(--text-sm); color: var(--fg-muted); }
-
-.threshold-bar {
- position: relative;
- height: 56px;
- margin: 28px 0 12px;
-}
-.threshold-track {
- position: absolute; top: 24px; left: 0; right: 0;
- height: 8px;
- background: linear-gradient(90deg, var(--danger-100, #FBC9C9) 0%, var(--danger-100, #FBC9C9) 18%, var(--warning-50) 18%, var(--warning-50) 42%, var(--success-50) 42%, var(--success-50) 100%);
- border-radius: var(--radius-pill);
-}
-.threshold-marker {
- position: absolute; top: 18px;
- width: 20px; height: 20px;
- border-radius: 50%;
- background: var(--gray-0);
- border: 3px solid var(--brand-500);
- transform: translateX(-50%);
- box-shadow: 0 2px 6px rgba(20,24,33,0.10);
-}
-.threshold-tick {
- position: absolute;
- top: 38px;
- transform: translateX(-50%);
- font-size: 10px;
- color: var(--fg-muted);
- font-family: var(--font-mono);
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
-}
-.threshold-label {
- position: absolute;
- bottom: 8px;
- transform: translateX(-50%);
- font-size: 10px;
- color: var(--fg-muted);
- letter-spacing: 0.04em;
- white-space: nowrap;
-}
-.alert-rules { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
-.alert-rule {
- display: flex; justify-content: space-between; align-items: center;
- padding: 10px 14px;
- background: var(--gray-25);
- border-radius: var(--radius-md);
- font-size: 13px;
- color: var(--gray-700);
-}
-.alert-rule strong { color: var(--brand-900); font-weight: 600; }
-.alert-rule .switch {
- width: 32px; height: 18px;
- background: var(--brand-500);
- border-radius: var(--radius-pill);
- position: relative;
-}
-.alert-rule .switch::after {
- content: '';
- position: absolute;
- top: 2px; right: 2px;
- width: 14px; height: 14px;
- background: #fff;
- border-radius: 50%;
-}
-.alert-rule .switch.off { background: var(--gray-300); }
-.alert-rule .switch.off::after { right: auto; left: 2px; }
-
-/* Use cases */
-.usecases-grid {
- display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px;
- margin-top: 48px;
-}
-.usecase-card {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
- padding: 28px;
-}
-.usecase-card .icon {
- width: 36px; height: 36px; color: var(--brand-500); margin-bottom: 16px;
-}
-.usecase-card h4 { margin: 0 0 8px; font-size: var(--text-lg); font-weight: 600; color: var(--brand-900); }
-.usecase-card p { margin: 0; font-size: var(--text-sm); color: var(--gray-700); line-height: 1.65; }
-
-/* Final CTA */
-.cta-strip {
- background: linear-gradient(135deg, #0A1F3B 0%, #15407D 100%);
- color: #fff;
- border-radius: var(--radius-xl);
- padding: 56px 48px;
- display: grid;
- grid-template-columns: 1.4fr 1fr;
- gap: 32px;
- align-items: center;
- margin: 0 0 96px;
-}
-.cta-strip h2 {
- font-size: 32px; font-weight: 600;
- letter-spacing: var(--tracking-cn-display);
- color: #fff; margin: 0 0 12px; line-height: 1.25;
-}
-.cta-strip p { font-size: var(--text-md); color: #ADC9EA; margin: 0; line-height: 1.6; }
-.cta-strip-actions { display: flex; flex-direction: column; gap: 10px; align-items: flex-end; }
-.cta-strip .btn-primary { background: #fff; color: var(--brand-700); }
-.cta-strip .btn-primary:hover { background: #DCE2EB; }
-.cta-strip .btn-secondary { background: transparent; color: #fff; border-color: rgba(255,255,255,0.30); }
-.cta-strip .btn-secondary:hover { background: rgba(255,255,255,0.10); }
-
-/* Footer */
-footer { background: var(--gray-25); border-top: 1px solid var(--border-subtle); padding: 64px 0 32px; color: var(--fg-muted); font-size: var(--text-sm); }
-.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); }
-
-@media (max-width: 1024px) {
- .feat-hero-inner, .feat-block-inner, .feat-block.reverse .feat-block-inner, .cta-strip { grid-template-columns: 1fr; gap: 32px; }
- .feat-block.reverse .feat-text, .feat-block.reverse .feat-visual { order: initial; }
- .usecases-grid { grid-template-columns: 1fr 1fr; }
- .footer-grid { grid-template-columns: 1fr 1fr; }
- .feat-hero h1 { font-size: 36px; }
- .composite { height: 480px; }
-}
-/* Hamburger */
-.nav-hamburger { display: none; flex-direction: column; justify-content: center; gap: 5px; width: 40px; height: 40px; padding: 8px; background: none; border: none; cursor: pointer; border-radius: 8px; }
-.nav-hamburger span { display: block; height: 2px; width: 22px; background: var(--gray-700); border-radius: 2px; }
-.nav-mobile-menu { display: none; flex-direction: column; background: var(--gray-0); border-top: 1px solid var(--border-subtle); padding: 8px 0 16px; }
-.nav-mobile-menu.open { display: flex; }
-.nav-mobile-menu a { padding: 12px 24px; font-size: var(--text-md); color: var(--gray-700); font-weight: 500; }
-.nav-mobile-menu a:hover { background: var(--gray-50); color: var(--brand-900); }
-.nav-mobile-cta { display: flex; flex-direction: column; gap: 8px; padding: 12px 24px 0; border-top: 1px solid var(--border-subtle); margin-top: 8px; }
-@media (max-width: 768px) {
- .nav-hamburger { display: flex; }
- .topnav-links, .topnav-cta { display: none; }
- .composite { display: none; }
-}
-@media (max-width: 600px) {
- .topnav-inner { padding: 0 16px; }
- .feat-hero h1 { font-size: 28px; }
- .footer-grid { grid-template-columns: 1fr; gap: 32px; }
- .footer-bottom { flex-direction: column; gap: 8px; text-align: center; }
- .usecases-grid { grid-template-columns: 1fr; }
- .container { padding: 0 16px; }
-}
-
diff --git a/web/assets/logo-full-inverse.svg b/web/assets/logo-full-inverse.svg
deleted file mode 100644
index f50ca27..0000000
--- a/web/assets/logo-full-inverse.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-
\ No newline at end of file
diff --git a/web/assets/style.css b/web/assets/style.css
deleted file mode 100644
index 1f12634..0000000
--- a/web/assets/style.css
+++ /dev/null
@@ -1,507 +0,0 @@
-/* ================================================================
- style.css — 全局样式
- Token 由 color.css 提供
- 分三层:① Reset & 基础 ② 组件 ③ 工具类
- ================================================================ */
-
-/* ----------------------------------------------------------------
- ① Reset & 基础
- ---------------------------------------------------------------- */
-* { 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); }
-
-/* Hamburger button (hidden on desktop) */
-.nav-hamburger {
- display: none; flex-direction: column; justify-content: center; gap: 5px;
- width: 40px; height: 40px; padding: 8px;
- background: none; border: none; cursor: pointer;
- border-radius: var(--radius-md);
-}
-.nav-hamburger span {
- display: block; height: 2px; width: 22px;
- background: var(--gray-700); border-radius: 2px;
- transition: transform 0.2s, opacity 0.2s;
-}
-
-/* Mobile menu (hidden by default) */
-.nav-mobile-menu {
- display: none; flex-direction: column;
- background: var(--gray-0);
- border-top: 1px solid var(--border-subtle);
- padding: 8px 0 16px;
-}
-.nav-mobile-menu.open { display: flex; }
-.nav-mobile-menu a {
- padding: 12px 24px; font-size: var(--text-md);
- color: var(--gray-700); font-weight: 500;
- transition: background var(--duration-fast) var(--ease-standard);
-}
-.nav-mobile-menu a:hover { background: var(--gray-50); color: var(--brand-900); }
-.nav-mobile-cta {
- display: flex; flex-direction: column; gap: 8px;
- padding: 12px 24px 0;
- border-top: 1px solid var(--border-subtle);
- margin-top: 8px;
-}
-.nav-mobile-cta .btn { justify-content: center; }
-
-/* -- 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);
-}
-
-/* ----------------------------------------------------------------
- ③ 工具类
- 命名规则:{属性}-{值}
- 间距值直接对应像素:p-8 = padding:8px,gap-16 = gap:16px
- ---------------------------------------------------------------- */
-
-/* -- Display -- */
-.d-flex { display: flex }
-.d-inline-flex { display: inline-flex }
-.d-grid { display: grid }
-.d-block { display: block }
-.d-inline { display: inline }
-.d-inline-block{ display: inline-block }
-.d-none { display: none }
-.place-center { display: grid; place-items: center } /* 图标容器常用 */
-
-/* -- Flex -- */
-.flex-col { flex-direction: column }
-.flex-wrap { flex-wrap: wrap }
-.flex-1 { flex: 1 }
-.flex-shrink-0 { flex-shrink: 0 }
-.items-center { align-items: center }
-.items-start { align-items: flex-start }
-.items-end { align-items: flex-end }
-.items-baseline { align-items: baseline }
-.justify-center { justify-content: center }
-.justify-between { justify-content: space-between }
-.justify-end { justify-content: flex-end }
-.justify-start { justify-content: flex-start }
-
-/* -- Grid 列模板 -- */
-.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr) }
-.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr) }
-.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr) }
-.grid-5 { display: grid; grid-template-columns: repeat(5, 1fr) }
-
-/* -- Gap -- */
-.gap-2 { gap: 2px }
-.gap-4 { gap: 4px }
-.gap-6 { gap: 6px }
-.gap-8 { gap: 8px }
-.gap-10 { gap: 10px }
-.gap-12 { gap: 12px }
-.gap-14 { gap: 14px }
-.gap-16 { gap: 16px }
-.gap-18 { gap: 18px }
-.gap-24 { gap: 24px }
-.gap-32 { gap: 32px }
-.gap-48 { gap: 48px }
-.gap-64 { gap: 64px }
-
-/* -- Padding(全方向) -- */
-.p-0 { padding: 0 }
-.p-4 { padding: 4px }
-.p-6 { padding: 6px }
-.p-8 { padding: 8px }
-.p-10 { padding: 10px }
-.p-12 { padding: 12px }
-.p-14 { padding: 14px }
-.p-16 { padding: 16px }
-.p-18 { padding: 18px }
-.p-20 { padding: 20px }
-.p-24 { padding: 24px }
-.p-28 { padding: 28px }
-.p-32 { padding: 32px }
-.p-48 { padding: 48px }
-.p-56 { padding: 56px }
-.p-64 { padding: 64px }
-
-/* px / py */
-.px-4 { padding-left: 4px; padding-right: 4px }
-.px-6 { padding-left: 6px; padding-right: 6px }
-.px-8 { padding-left: 8px; padding-right: 8px }
-.px-10 { padding-left: 10px; padding-right: 10px }
-.px-12 { padding-left: 12px; padding-right: 12px }
-.px-14 { padding-left: 14px; padding-right: 14px }
-.px-16 { padding-left: 16px; padding-right: 16px }
-.px-20 { padding-left: 20px; padding-right: 20px }
-.px-24 { padding-left: 24px; padding-right: 24px }
-.px-28 { padding-left: 28px; padding-right: 28px }
-.px-32 { padding-left: 32px; padding-right: 32px }
-
-.py-2 { padding-top: 2px; padding-bottom: 2px }
-.py-4 { padding-top: 4px; padding-bottom: 4px }
-.py-6 { padding-top: 6px; padding-bottom: 6px }
-.py-8 { padding-top: 8px; padding-bottom: 8px }
-.py-10 { padding-top: 10px; padding-bottom: 10px }
-.py-12 { padding-top: 12px; padding-bottom: 12px }
-.py-14 { padding-top: 14px; padding-bottom: 14px }
-.py-16 { padding-top: 16px; padding-bottom: 16px }
-.py-20 { padding-top: 20px; padding-bottom: 20px }
-.py-24 { padding-top: 24px; padding-bottom: 24px }
-.py-32 { padding-top: 32px; padding-bottom: 32px }
-
-/* pt / pb / pl / pr */
-.pt-4 { padding-top: 4px } .pb-4 { padding-bottom: 4px }
-.pt-6 { padding-top: 6px } .pb-6 { padding-bottom: 6px }
-.pt-8 { padding-top: 8px } .pb-8 { padding-bottom: 8px }
-.pt-12 { padding-top: 12px } .pb-12 { padding-bottom: 12px }
-.pt-14 { padding-top: 14px } .pb-14 { padding-bottom: 14px }
-.pt-16 { padding-top: 16px } .pb-16 { padding-bottom: 16px }
-.pt-24 { padding-top: 24px } .pb-24 { padding-bottom: 24px }
-.pt-32 { padding-top: 32px } .pb-32 { padding-bottom: 32px }
-.pl-12 { padding-left: 12px } .pr-12 { padding-right: 12px }
-.pl-16 { padding-left: 16px } .pr-16 { padding-right: 16px }
-.pl-24 { padding-left: 24px } .pr-24 { padding-right: 24px }
-
-/* -- Margin -- */
-.m-0 { margin: 0 }
-.mx-auto { margin-left: auto; margin-right: auto }
-
-.mb-0 { margin-bottom: 0 }
-.mb-2 { margin-bottom: 2px }
-.mb-4 { margin-bottom: 4px }
-.mb-6 { margin-bottom: 6px }
-.mb-8 { margin-bottom: 8px }
-.mb-10 { margin-bottom: 10px }
-.mb-12 { margin-bottom: 12px }
-.mb-14 { margin-bottom: 14px }
-.mb-16 { margin-bottom: 16px }
-.mb-18 { margin-bottom: 18px }
-.mb-24 { margin-bottom: 24px }
-.mb-32 { margin-bottom: 32px }
-.mb-48 { margin-bottom: 48px }
-
-.mt-2 { margin-top: 2px }
-.mt-4 { margin-top: 4px }
-.mt-6 { margin-top: 6px }
-.mt-8 { margin-top: 8px }
-.mt-12 { margin-top: 12px }
-.mt-16 { margin-top: 16px }
-.mt-24 { margin-top: 24px }
-.mt-32 { margin-top: 32px }
-.mt-48 { margin-top: 48px }
-
-.ml-4 { margin-left: 4px }
-.ml-8 { margin-left: 8px }
-.mr-4 { margin-right: 4px }
-.mr-8 { margin-right: 8px }
-
-/* -- Font size(复用 token) -- */
-.fs-xs { font-size: var(--text-xs) }
-.fs-sm { font-size: var(--text-sm) }
-.fs-md { font-size: var(--text-md) }
-.fs-lg { font-size: var(--text-lg) }
-.fs-xl { font-size: var(--text-xl) }
-.fs-2xl { font-size: var(--text-2xl) }
-.fs-3xl { font-size: var(--text-3xl) }
-.fs-4xl { font-size: var(--text-4xl) }
-
-/* -- Font weight -- */
-.fw-4 { font-weight: 400 }
-.fw-5 { font-weight: 500 }
-.fw-6 { font-weight: 600 }
-.fw-7 { font-weight: 700 }
-
-/* -- Line height -- */
-.lh-1 { line-height: 1 }
-.lh-12 { line-height: 1.2 }
-.lh-14 { line-height: 1.4 }
-.lh-15 { line-height: 1.5 }
-.lh-16 { line-height: 1.6 }
-.lh-17 { line-height: 1.7 }
-.lh-18 { line-height: 1.8 }
-
-/* -- Letter spacing -- */
-.ls-1 { letter-spacing: 0.04em }
-.ls-2 { letter-spacing: 0.06em }
-.ls-3 { letter-spacing: 0.10em }
-.ls-4 { letter-spacing: 0.12em }
-.ls-disp { letter-spacing: var(--tracking-cn-display) }
-
-/* -- Text color -- */
-.text-brand { color: var(--brand-500) }
-.text-brand-hi { color: var(--brand-700) }
-.text-brand-900{ color: var(--brand-900) }
-.text-default { color: var(--fg-default) }
-.text-muted { color: var(--fg-muted) }
-.text-subtle { color: var(--fg-subtle) }
-.text-gray-6 { color: var(--gray-600) }
-.text-gray-7 { color: var(--gray-700) }
-.text-gray-8 { color: var(--gray-800) }
-.text-gray-9 { color: var(--gray-900) }
-.text-white { color: #fff }
-.text-success { color: var(--success-700) }
-.text-warning { color: var(--warning-700) }
-.text-danger { color: var(--danger-700) }
-.text-info { color: var(--info-700) }
-
-/* -- Background -- */
-.bg-0 { background: var(--gray-0) }
-.bg-25 { background: var(--gray-25) }
-.bg-50 { background: var(--gray-50) }
-.bg-100 { background: var(--gray-100) }
-.bg-brand { background: var(--brand-500) }
-.bg-brand-50 { background: var(--brand-50) }
-.bg-brand-900{ background: var(--brand-900) }
-.bg-success-50 { background: var(--success-50) }
-.bg-warning-50 { background: var(--warning-50) }
-.bg-danger-50 { background: var(--danger-50) }
-.bg-info-50 { background: var(--info-50) }
-
-/* -- Border -- */
-.border { border: 1px solid var(--border-default) }
-.border-top { border-top: 1px solid var(--border-default) }
-.border-bottom { border-bottom: 1px solid var(--border-default) }
-.border-left { border-left: 1px solid var(--border-default) }
-.border-subtle { border-color: var(--border-subtle) }
-.border-strong { border-color: var(--border-strong) }
-.border-brand { border-color: var(--brand-500) }
-.border-0 { border: none }
-
-/* -- Border radius -- */
-.rounded-sm { border-radius: var(--radius-sm) }
-.rounded-md { border-radius: var(--radius-md) }
-.rounded-lg { border-radius: var(--radius-lg) }
-.rounded-xl { border-radius: var(--radius-xl) }
-.rounded-pill { border-radius: var(--radius-pill) }
-.rounded-full { border-radius: 50% }
-
-/* -- Shadow -- */
-.shadow-sm { box-shadow: var(--shadow-sm) }
-.shadow-md { box-shadow: var(--shadow-md) }
-.shadow-lg { box-shadow: var(--shadow-lg) }
-.shadow-xl { box-shadow: var(--shadow-xl) }
-
-/* -- Text helpers -- */
-.text-upper { text-transform: uppercase }
-.text-center { text-align: center }
-.text-right { text-align: right }
-.text-nowrap { white-space: nowrap }
-.font-mono { font-family: var(--font-mono) }
-.tabular { font-variant-numeric: tabular-nums }
-
-/* -- Sizing & position -- */
-.w-full { width: 100% }
-.h-full { height: 100% }
-.min-w-0 { min-width: 0 }
-.pos-relative { position: relative }
-.pos-absolute { position: absolute }
-.pos-sticky { position: sticky }
-.overflow-hidden { overflow: hidden }
-.overflow-x-auto { overflow-x: auto }
-
-/* -- Transition(常用属性组合) -- */
-.transition {
- transition: background var(--duration-fast) var(--ease-standard),
- border-color var(--duration-fast) var(--ease-standard),
- color var(--duration-fast) var(--ease-standard),
- box-shadow var(--duration-fast) var(--ease-standard);
-}
-
-/* ----------------------------------------------------------------
- Card & Badge 高频组合
- ---------------------------------------------------------------- */
-
-/* card:bg-0 + border + rounded-lg,三个页面共用 15+ 次 */
-.card {
- background: var(--gray-0);
- border: 1px solid var(--border-default);
- border-radius: var(--radius-lg);
-}
-
-/* badge:状态标签小胶囊 */
-.badge {
- display: inline-block;
- padding: 2px 10px;
- border-radius: var(--radius-pill);
- font-size: var(--text-xs);
- font-weight: 600;
-}
-.badge-brand { background: var(--brand-50); color: var(--brand-700) }
-.badge-success { background: var(--success-50); color: var(--success-700) }
-.badge-warning { background: var(--warning-50); color: var(--warning-700) }
-.badge-danger { background: var(--danger-50); color: var(--danger-700) }
-.badge-info { background: var(--info-50); color: var(--info-700) }
-.badge-gray { background: var(--gray-100); color: var(--gray-700) }
-.badge-solid { background: var(--brand-500); color: #fff }
-
-/* icon 尺寸 */
-.icon-sm { width: 14px; height: 14px }
-.icon-md { width: 18px; height: 18px }
-.icon-lg { width: 22px; height: 22px }
-.icon-xl { width: 28px; height: 28px }
-.icon-2xl { width: 36px; height: 36px }
-
-/* icon-box:方形图标容器(带背景色时使用) */
-.icon-box-sm { width: 28px; height: 28px; border-radius: var(--radius-sm); display: grid; place-items: center }
-.icon-box-md { width: 36px; height: 36px; border-radius: 8px; display: grid; place-items: center }
-.icon-box-lg { width: 40px; height: 40px; border-radius: 10px; display: grid; place-items: center }
-.icon-box-xl { width: 56px; height: 56px; border-radius: var(--radius-md); display: grid; place-items: center }
-
-/* ----------------------------------------------------------------
- Responsive
- ---------------------------------------------------------------- */
-@media (max-width: 1024px) {
- .footer-grid { grid-template-columns: 1fr 1fr; }
- .container { padding: 0 20px; }
-}
-@media (max-width: 768px) {
- .grid-3, .grid-4, .grid-5 { grid-template-columns: repeat(2, 1fr); }
- /* Show hamburger, hide desktop nav links */
- .nav-hamburger { display: flex; }
- .topnav-links, .topnav-cta { display: none; }
-}
-@media (max-width: 600px) {
- .topnav-inner { padding: 0 16px; }
- .footer-grid { grid-template-columns: 1fr; gap: 32px; }
- .footer-bottom { flex-direction: column; gap: 8px; text-align: center; }
- .grid-3, .grid-4, .grid-5 { grid-template-columns: 1fr; }
- .container { padding: 0 16px; }
- .section { padding-top: 48px; padding-bottom: 48px; }
- h1, .h1 { font-size: clamp(1.75rem, 7vw, 2.5rem); }
- h2, .h2 { font-size: clamp(1.375rem, 5vw, 2rem); }
-}
diff --git a/web/dist/changelog/index.html b/web/dist/changelog/index.html
deleted file mode 100644
index 63127ad..0000000
--- a/web/dist/changelog/index.html
+++ /dev/null
@@ -1,490 +0,0 @@
-
-
-
-
-
-
-更新日志 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- v1.0.29
- 2026-06-09
-
-
-
-
新功能
-
- - Windows / macOS 桌面客户端现在限制只能运行一个实例;重复打开时弹窗提示「程序已在运行中」并自动退出
-
-
-
-
-
-
- v1.0.27
- 2026-06-09
-
-
-
-
改进
-
- - 入库单新建表单:产地、保质期、储存方式、批次号、生产日期等选填字段默认折叠,点击「展开选填」后在行内显示,减少初始界面信息量
- - 手机端现可从屏幕左边缘向右滑动打开导航菜单,无需点击汉堡图标
- - 入库单详情弹窗在手机上商品明细改为卡片式竖排布局,不再显示难以阅读的横向宽表格
-
-
-
-
修复
-
- - macOS 客户端下载 zip 解压失败:打包改用 ditto,正确保留 symlink 和执行权限
-
-
-
-
-
-
- v1.0.26
- 2026-06-08
-
-
-
-
新功能
-
- - 公开商品 API 新增商品编码、条码、当前库存数量字段,供扫码页及第三方集成使用
- - 公开页「商品报错」「意见反馈」提交时自动携带门店编号和商品信息,方便运营追踪
-
-
-
-
修复
-
- - 注册页 API 地址改为配置文件驱动,支持分离部署场景
- - 注册成功提示的客户端导航路径修正为「系统设置」
- - 公开页页脚「关于岩美」链接改为配置项,不再硬编码
-
-
-
-
-
-
- v1.0.25
- 2026-06-08
-
-
-
-
新功能
-
- - 基础数据页新增「产地」「保质期」「储存方式」「描述文档」4 个字典维护 Tab
- - 商品详情页展示产地、保质期、储存方式参数,并在介绍区上方显示建议零售价
- - 入库单详情中商品列展示产地、保质期、储存方式信息
- - 公开扫码页新增「商品报错」「意见反馈」功能,用户可提交反馈
-
-
-
-
-
-
- v1.0.24
- 2026-06-08
-
-
-
-
新功能
-
- - 扫码商品页新增「查看本店其他商品」功能,点击即可浏览该门店全部上架商品
- - 门店可在设置中填写微信号,顾客扫码后可直接查看并添加门店微信
- - 商品公开页展示建议零售价(有填写时显示)
- - 入库时可为商品关联产地、保质期、储存方式、描述文档
-
-
-
-
改进
-
- - 新增产地/保质期/储存方式/描述文档四类基础数据字典,支持增删改查
-
-
-
-
-
-
- v1.0.22
- 2026-06-07
-
-
-
-
新功能
-
- - 官网移动端适配:手机浏览器下导航折叠、首页各区块单列排版
-
-
-
-
改进
-
- - 客户端移动端布局优化:入库/出库详情页商品明细改为卡片式竖排,操作按钮收纳至溢出菜单
- - 信息架构整理:仓库管理移入基础数据,系统设置新增授权 Tab 和数据管理组
-
-
-
-
-
-
- v1.0.21
- 2026-06-07
-
-
-
-
新功能
-
- - 商品详情页添加照片时,手机端(Android/iOS)支持直接拍照或从相册选择
-
-
-
-
-
-
- v1.0.18
- 2026-06-06
-
-
-
-
新功能
-
- - 移动端(Android)界面全面适配:侧边栏改为侧滑抽屉导航,各列表由宽表格自动切换为卡片流,弹窗与表单自适应屏宽
- - Android 客户端正式上线,随发版自动发布到下载页
-
-
-
-
-
-
- v1.0.17
- 2026-06-05
-
-
-
-
新功能
-
- - 意见反馈改为应用内直接提交,支持文字 + 图片(最多 9 张),不再跳转邮件
-
-
-
-
改进
-
- - 「关于」更名为「关于我们」并移至左侧菜单,成为独立页面
- - 打印时默认文件名带类型与时间戳,方便「打印到 PDF」归档
-
-
-
-
-
-
- v1.0.16
- 2026-06-05
-
-
-
-
新功能
-
- - 应用内更新:Windows/macOS 点「立即更新」直接在应用内下载并自动安装/重启(带进度条)
- - Windows 安装程序改为中文向导
-
-
-
-
-
-
- v1.0.13
- 2026-06-04
-
-
-
-
新功能
-
- - Windows 客户端改为提供安装程序(含安装向导、桌面/开始菜单快捷方式、卸载项)
-
-
-
-
-
-
- v1.0.4
- 2026-05-30
-
-
-
-
新功能
-
- - 支持 macOS 桌面客户端下载,下载 zip 解压即用
-
-
-
-
-
-
- v1.0.3
- 2026-05-28
-
-
-
-
新功能
-
- - 新增用户自助注册功能,在官网 /register/ 填写门店信息即可完成注册,系统自动生成门店编码
- - 支持 Windows 桌面客户端构建与发布
-
-
-
-
-
-
- v1.0.1
- 2026-05-25
-
-
-
-
改进
-
- - 库存搜索支持拼音和拼音首字母(如搜「mt」或「maotai」可匹配「茅台」)
- - 新增异常自动上报机制,线上错误可实时追踪
-
-
-
-
-
-
- v1.0.0
- 2026-05-24
-
-
-
初始版本发布,完成核心库存管理功能。
-
-
核心功能
-
- - 商品管理:多规格商品档案、图片、公开二维码扫码展示
- - 库存管理:入库审核、出库审核、库存盘点、流水记录
- - 财务管理:往来账目、对账单
- - 用户权限:管理员 / 成员 / 只读三级权限控制
- - 多租户隔离:门店数据完全独立
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/dist/docs/index.html b/web/dist/docs/index.html
deleted file mode 100644
index b0441a9..0000000
--- a/web/dist/docs/index.html
+++ /dev/null
@@ -1,889 +0,0 @@
-
-
-
-
-
-
-使用手册 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ⌘K
-
-
-
-
- 版本
- v1.0.54
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 1. 系统概述
- 岩美酒库管理系统专为酒行门店设计,将入库、出库、库存查询、财务往来等日常业务整合到一个平台,支持多人协同操作,数据实时同步。
-核心功能一览
-
-
-
-| 模块 |
-主要用途 |
-
-
-
-
-| 入库管理 |
-新建入库单、提交审核、审批通过后自动更新库存 |
-
-
-| 出库管理 |
-新建出库单、提交审核、审批时自动扣减库存 |
-
-
-| 库存管理 |
-查询实时库存、库存盘点、打印标签 |
-
-
-| 财务管理 |
-查看应付款(欠供应商)和应收款(客户欠款)、标记结清 |
-
-
-| 往来单位 |
-管理供应商和客户的基本信息 |
-
-
-| 基础数据 |
-维护商品名称、系列、规格字典 |
-
-
-| 系统设置 |
-用户管理、仓库管理、编号规则、数据导入 |
-
-
-
-支持平台:Web 浏览器(现已上线);Windows、macOS、iOS、Android 客户端即将推出。
-
-
-
-
-
- 2. 登录与退出
- 2.1 登录
-
-- 打开系统,进入登录页面。
-- 在「门店编号」栏输入酒行的门店代号(例如
S001)。
-- 输入「用户名」和「密码」。
-- 点击「登录」按钮。
-
-登录成功后,系统自动跳转到「入库管理」页面。
-提示:系统会记录最近登录过的用户名,点击输入框可从候选列表中快速选择。
-2.2 退出登录
-点击顶部右侧用户名旁的下拉箭头,选择「退出登录」。
-2.3 查看当前登录信息
-点击顶部左侧「岩美」标题或右侧「门店编号」区域,弹出门店信息面板,显示门店编号、登录账号、姓名、系统版本。
-
-
-
-
-
- 3. 界面说明
- 系统主界面由三个区域组成:
-┌──────────────────────────────────────────────────────────┐
-│ 顶部导航栏(标题、门店编号、用户名、退出) │
-├──────────┬───────────────────────────────────────────────┤
-│ │ │
-│ 左侧 │ 主内容区域 │
-│ 侧边栏 │ │
-│ │ │
-├──────────┴───────────────────────────────────────────────┤
-│ 状态栏(门店、用户、登录时间、当前时间、连接状态) │
-└──────────────────────────────────────────────────────────┘
-
-左侧侧边栏导航项
-
-
-
-| 导航项 |
-功能 |
-
-
-
-
-| 入库管理 |
-入库单录入与审核 |
-
-
-| 出库管理 |
-出库单录入与审核 |
-
-
-| 库存管理 |
-库存查询与盘点 |
-
-
-| 财务管理 |
-应付款和应收款 |
-
-
-| 往来单位 |
-供应商与客户 |
-
-
-| 基础数据 |
-商品名称、系列、规格字典 |
-
-
-| 系统设置 |
-用户、仓库、编号规则等配置 |
-
-
-
-点击侧边栏顶部的菜单图标可收起或展开侧边栏,节省屏幕空间。
-更新提示:有新版本时,内容区顶部会显示黄色提示横幅;如果是必须更新的版本,会弹出对话框要求立即更新。
-离线提示:网络断开时,顶部显示红色横幅「网络连接已断开」,状态栏变红,此时展示上次加载的缓存数据。
-
-
-
-
-
- 4. 角色与权限
- 系统有四种角色,权限从高到低依次为:
-
-
-
-| 角色 |
-说明 |
-可执行操作 |
-
-
-
-
-| 超级管理员 |
-系统最高权限账号 |
-全部操作,含数据清空 |
-
-
-| 管理员 |
-门店管理人员 |
-全部操作,含用户管理、酒行信息编辑 |
-
-
-| 操作员 |
-日常录入审核人员 |
-新建、编辑、提交、审核单据,查询数据 |
-
-
-| 只读 |
-仅查看数据 |
-只能查看,不能新建、修改、删除任何数据 |
-
-
-
-权限说明
-
-- 只读账号点击任何写操作按钮时,系统将直接提示「无权限」。
-- 新增或编辑用户、重置密码仅管理员可操作。
-- 修改酒行基本信息仅管理员可操作。
-- 数据清空仅超级管理员可操作。
-
-
-
-
-
-
- 5. 入库管理
- 入库管理页面包含两个标签页:入库审核(新建和待审核)和入库单(已完成记录)。
-5.1 入库单状态说明
-单据状态按以下流程流转:
-草稿 → 待审核 → 已审批(库存增加)
-待审核阶段也可被拒绝,变为已拒绝状态(库存不变)。
-
-
-
-| 状态 |
-含义 |
-
-
-
-
-| 草稿 |
-已保存但未提交,可继续编辑或删除 |
-
-
-| 待审核 |
-已提交等待审批,不可再编辑 |
-
-
-| 已审批 |
-审批通过,库存已增加,同时生成应付账款记录 |
-
-
-| 已拒绝 |
-审批不通过,库存不变 |
-
-
-
-5.2 新建入库单
-
-- 点击左侧「入库管理」,切换到「入库审核」标签页。
-- 点击右上角「新建入库审核单」按钮。
-- 填写入库基本信息:
-
-- 仓库(必填):选择货物入库的目标仓库。
-- 供应商(必填):选择供货的往来单位。
-- 入库日期(必填):默认为今天,可修改。
-- 备注(选填):填写本批次入库说明。
-
-
-- 在商品明细区域填写每一行商品:
-
-- 商品名称(必填):从字典中选择或直接输入。
-- 系列(必填):选择商品系列。
-- 规格(必填):选择商品规格,如 500ml×6。
-- 生产日期(必填):点击日历图标选择。
-- 批次号(选填):填写批次编号,用于后续追踪查询。
-- 数量(必填):填写入库数量。
-- 单价(必填):填写进货单价,系统自动计算总金额。
-
-
-- 点击「添加商品」可继续增加商品行,点击行末「删除」可移除该行。
-- 完成后选择操作:
-
-- 保存草稿:保存后可以继续修改。
-- 保存并提交审核:提交后进入待审核状态,不可再编辑。
-
-
-
-5.3 提交审核
-草稿状态的入库单,在列表中点击「提交」按钮,确认后单据进入「待审核」状态。
-5.4 审批入库单
-操作员及以上权限的用户均可审批。
-
-- 在「入库审核」标签页中找到待审核的入库单。
-- 点击「通过」:弹窗确认,确认后系统自动将商品入库,库存增加,同时生成一笔应付账款记录。
-- 点击「拒绝」:弹窗确认,拒绝后库存不变,单据进入「已拒绝」状态。
-
-注意:审批通过后操作不可撤销,请仔细核对商品数量和单价后再确认。
-5.5 查看入库单详情
-点击入库单号(蓝色链接)可弹出详情页,显示完整的商品明细、数量、金额及审核信息。详情页右上角有打印按钮,可直接打印入库单。
-5.6 打印入库单
-在列表中点击对应行的「打印」按钮,直接调用打印机打印入库单据。
-5.7 打印商品标签
-
-- 在列表中点击对应行的「打标签」按钮。
-- 弹出商品标签打印对话框,列出本次入库的所有商品。
-- 勾选需要打印标签的商品(默认全选)。
-- 点击「打印选中」,系统逐张打印选中商品的标签。
-
-标签内容包括:商品名称、编码、系列、规格、批次号、生产日期、酒行名称及联系方式、二维码。
-5.8 标记货款结清
-审批通过的入库单会自动生成应付账款记录。货款付清后,在列表中点击「结清」按钮,确认后将对应财务记录标记为已结清。
-5.9 筛选与导出
-
-- 仓库筛选:点击列头「仓库」的筛选图标,选择仓库进行过滤。
-- 供应商筛选:点击列头「供应商」的筛选图标,选择供应商进行过滤。
-- 日期筛选:点击「选择日期」按钮,选择日期范围。
-- 导出:点击「导出」按钮,将当前列表导出为 Excel 文件。
-
-
-
-
-
-
- 6. 出库管理
- 出库管理与入库管理操作流程类似,包含出库审核(待处理)和出库单(已完成)两个标签页。
-6.1 出库单状态说明
-出库单状态流转与入库单相同:草稿 → 待审核 → 已审批或已拒绝。
-审批通过后:
-
-- 系统会先检查库存是否充足,库存不足时审批失败并给出提示,库存不变。
-- 库存充足时自动扣减库存,并生成应收账款记录。
-
-6.2 新建出库单
-
-- 点击左侧「出库管理」,切换到「出库审核」标签页。
-- 点击右上角「新建出库审核单」按钮。
-- 填写基本信息:
-
-- 仓库(必填):选择出货仓库。
-- 客户(必填):选择购买方(往来单位中的客户类型)。
-- 出库日期(必填):默认今天,可修改。
-
-
-- 填写商品明细:商品名称、系列、规格、数量、单价(操作方式同入库单)。
-- 保存草稿或保存并提交审核。
-
-注意:出库数量不能超过当前库存,审批时系统会自动检查,库存不足会提示失败。
-6.3 审批出库单
-操作步骤与入库审批相同。审批通过后库存自动扣减,同时生成应收账款记录。
-6.4 打印出库单
-在列表中点击「打印」按钮,打印出库单据。
-6.5 标记货款结清
-出库单审批通过后生成应收账款。收到货款后,在列表中点击「结清」按钮,将对应财务记录标记为已结清。
-
-
-
-
-
- 7. 库存管理
- 7.1 查询库存
-点击左侧「库存管理」进入库存列表。
-列表显示每个商品的实时库存数量、所在仓库、单价、金额、生产日期、批次等信息。
-
-- 搜索:在搜索框输入商品名称关键字(也支持拼音或拼音首字母),实时过滤结果。
-- 仓库筛选:点击列头「仓库」的筛选图标,按仓库查看库存。
-- 导出:点击「导出」按钮,将当前库存列表导出为 Excel 文件。
-
-7.2 修改备注
-在库存列表中,点击「备注」列对应单元格(显示铅笔图标或现有备注内容),弹出编辑框,填写后点击「保存」即可。
-7.3 打印商品标签
-在库存列表中点击某商品行的「打印标签」按钮,可单独打印该商品的标签。
-7.4 库存盘点
-
-- 在库存管理页面切换到「库存盘点」标签页。
-- 选择要盘点的仓库,系统自动加载该仓库所有商品的账面库存数量。
-- 选择盘点类型(全盘)。
-- 逐行填写实际盘点数量(与账面不符时修改数量)。
-- 点击「提交盘点」完成。
-
-盘点单编号由系统自动生成。
-7.5 库存流水
-「库存流水」标签页可查看每次库存变动记录,包括入库、出库的时间、变动数量等。
-
-
-
-
-
- 8. 财务管理
- 8.1 财务记录说明
-系统在以下情况自动生成财务记录:
-
-- 入库单审批通过 → 自动生成应付账款(欠供应商的货款)
-- 出库单审批通过 → 自动生成应收账款(客户欠的货款)
-
-点击左侧「财务管理」进入财务页面,包含三个标签:
-
-
-
-| 标签 |
-内容 |
-
-
-
-
-| 全部记录 |
-所有财务流水 |
-
-
-| 应付账款 |
-需要向供应商付款的记录 |
-
-
-| 应收账款 |
-客户尚未付款的记录 |
-
-
-
-8.2 查看财务记录
-
-- 默认显示当前月份的记录,可通过月份选择器查看历史月份。
-- 支持按「类型」和「往来单位」列头筛选。
-- 点击「导出」可导出当月数据为 Excel 文件。
-
-8.3 标记结清
-方式一:在入库单或出库单列表中点击「结清」按钮,直接结清该单据对应的账款。
-方式二:在财务管理列表中找到对应记录,点击「结清」按钮,单笔结清。
-结清后该记录状态从「未结清」变为「已结清」。
-8.4 财务汇总
-财务管理页面显示当期应付和应收总额,便于掌握资金往来概况。
-
-
-
-
-
- 9. 往来单位
- 往来单位管理供应商和客户两类信息,新建入库单时选择供应商,新建出库单时选择客户。
-9.1 查看往来单位
-点击左侧「往来单位」,通过「供应商」和「客户」标签页分别查看。列表支持按名称搜索和导出。
-9.2 新建往来单位
-
-- 在对应标签页点击右上角「新建」按钮。
-- 填写信息:
-
-- 名称(必填)
-- 联系电话(选填)
-- 地址(选填)
-- 卡号(选填,如有账户绑定)
-- 初始余额(选填,导入历史数据时填写)
-- 备注(选填)
-
-
-- 点击「保存」。
-
-9.3 编辑往来单位
-在列表中点击「编辑」按钮,修改信息后保存。
-9.4 删除往来单位
-在列表中点击「删除」按钮,确认后删除。
-注意:已关联入库单或出库单的往来单位不可删除。
-
-
-
-
-
- 10. 基础数据(商品管理)
- 点击左侧「基础数据」,进入商品字典管理页面,包含三个标签:商品名称、系列、规格。
-这些字典数据是新建入库或出库单时选择商品的基础,需要先在此处维护好商品信息。
-10.1 商品名称管理
-商品名称是酒品的主名称,例如「茅台飞天 53°」。
-
-- 在「商品名称」标签页,点击「新建」按钮。
-- 填写商品名称(必填)、编号(选填)、备注(选填)。
-- 点击「保存」。
-
-支持按名称或编号搜索,支持分页浏览,支持编辑和删除。
-10.2 系列管理
-系列是商品所属的系列或品牌线,例如「飞天系列」「王子系列」。操作步骤与商品名称相同。
-10.3 规格管理
-规格用于描述包装规格,例如「500ml×6」「1000ml×1」。新建时可填写「单品数量」(即一箱包含的单瓶数量)。
-10.4 商品详情与二维码
-每个商品都有唯一的二维码,可通过扫码访问商品公开信息页面。在商品详情页可查看或上传商品图片。
-
-
-
-
-
- 11. 系统设置
- 点击左侧「系统设置」,包含七个标签页。
-11.1 酒行信息
-显示本门店的基本信息:门店编号、门店名称、地址、联系电话、负责人。
-编辑(仅管理员):点击「编辑信息」按钮,修改门店名称、地址、电话、负责人后保存。门店编号不可修改。
-11.2 用户管理(仅管理员)
-查看用户:显示本门店所有用户的姓名、用户名、角色、启用状态。
-新增用户:
-
-- 点击「新增用户」按钮。
-- 填写用户名(登录账号,不可重复)、姓名、手机号、初始密码。
-- 选择角色(操作员、管理员、只读、超级管理员)。
-- 点击「保存」。
-
-编辑用户:点击用户行的「编辑」按钮,可修改姓名、手机号、角色(用户名不可修改)。
-重置密码:点击「重置密码」按钮,输入新密码后确认,下次登录时使用新密码。
-启用或停用:在用户列表中拨动状态开关。停用后该用户无法登录。
-11.3 仓库管理
-查看仓库:显示所有仓库名称、位置、是否为默认仓库。
-新建仓库:
-
-- 点击「新建」按钮。
-- 填写仓库名称(必填)、位置(选填)。
-- 可勾选「设为默认仓库」,新建单据时将自动选择此仓库。
-- 点击「保存」。
-
-编辑或删除:点击对应按钮操作。已有库存的仓库不可删除。
-11.4 编号规则
-系统为每类单据自动生成唯一编号,格式由前缀、日期和序号组成,例如入库单编号 RK20260523001。
-查看当前规则:列表显示每种单据的前缀、当前序号及下一编号示例。
-修改规则:
-
-- 点击对应规则行的「编辑」按钮。
-- 可修改「前缀」和「当前序号」。
-- 点击「保存」,修改后对新建单据生效。
-
-注意:请勿将序号调小至已使用过的范围,否则可能导致编号重复。
-11.5 系统参数
-配置系统的全局参数:
-
-- 系统名称、货币单位、日期格式、时区(通常无需修改)
-- 入库单需要审核:关闭后提交即视为通过,无需审批
-- 出库单需要审核:关闭后同上
-- 允许超量出库:开启后,出库数量超过库存时仍可审批通过
-
-修改后点击「保存设置」生效,点击「重置默认」恢复出厂默认值。
-11.6 数据导入
-通过 Excel 文件批量导入历史数据。支持以下数据类型:往来单位、商品名称、商品系列、商品规格、商品编码、库存。
-各类型的 Excel 格式要求详见系统内「系统设置 → 数据导入」页面说明。
-操作步骤:
-
-- 在对应数据类型行点击「选择文件」,选择 Excel(.xls 或 .xlsx)文件。
-- 全部文件选择完成后,点击「全部导入」按钮。
-- 系统依次处理每个文件并显示导入结果(新增、跳过重复、失败数量)。
-- 如有失败,根据错误提示修正文件后重新导入。
-
-导入规则:以名称去重,已存在的记录不重复导入。导入过程中请勿切换页面。
-数据清空(仅超级管理员):在数据导入页面下方「危险操作」区域,选择要清空的数据表,点击确认后输入「确认清空」完成操作。此操作不可撤销,请务必提前做好数据备份。
-11.7 关于
-
-- 版本信息:显示当前系统版本。有新版本时可点击「立即更新」。
-- 授权信息:显示授权类型(试用、月付、年付、买断)、授权状态、到期时间。授权剩余不足 30 天时会显示倒计时提醒。
-- 意见反馈:点击「反馈 Bug」或「功能建议」,通过邮件向开发团队反馈。
-
-
-
-
-
-
- 12. 常见问题
- Q:入库单提交后发现填错了,怎么办?
-单据提交(待审核状态)后不可再修改。请联系管理员将该单据拒绝,然后重新新建一张正确的入库单。
-Q:出库审批时提示「库存不足」,怎么处理?
-出库数量超过当前仓库库存时,系统会拒绝审批。请检查出库数量,或确认对应商品的入库单是否已审批通过、库存已更新。
-Q:结清了财务记录但发现结清错了,如何撤销?
-目前系统不支持撤销结清操作。如需处理,请联系技术支持。
-Q:如何查找某批商品是从哪张入库单入库的?
-在库存管理列表中查看商品的批次号,然后在入库管理中按批次号搜索对应的入库单。
-Q:导入数据时提示「格式错误」,怎么处理?
-请检查 Excel 文件的列顺序是否与「系统设置 → 数据导入」页面说明的格式一致,且第一行为数据行(无需填写表头)。如问题持续,可将文件发送给技术支持排查。
-Q:用户无法登录,怎么处理?
-请管理员在「系统设置 → 用户管理」中检查该用户是否处于启用状态,并使用「重置密码」功能为其设置新密码。
-Q:操作时提示「无权限」,是什么原因?
-当前账号角色为「只读」,不允许执行写操作。如需操作,请联系管理员调整账号角色。
-Q:系统显示「网络连接已断开」,数据还准确吗?
-网络断开时,系统展示上次加载的缓存数据,可能不是最新状态。请恢复网络连接后刷新页面获取最新数据。
-Q:如何联系技术支持?
-进入「系统设置 → 关于」查看联系邮箱和技术支持时间。工作日(周一至周五)9:00 - 18:00 可获得响应。
-
-
-
-
-
-
这篇文档对您有帮助吗?
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/dist/download/index.html b/web/dist/download/index.html
deleted file mode 100644
index 148bea3..0000000
--- a/web/dist/download/index.html
+++ /dev/null
@@ -1,895 +0,0 @@
-
-
-
-
-
-
-下载 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
支持的平台
-
Web 版、Windows / macOS 桌面版与 Android 客户端现已可用,iOS 正在开发中。
-
-
-
-
-
-
-
-
-
系统要求
-
在以下环境中均经过测试与验证。
-
-
Web 版(已上线)
-
-
-
-| 要求 |
-最低 |
-推荐 |
-
-
-
-
-| 浏览器 |
-Chrome 90 / Firefox 90 / Safari 15 / Edge 90 |
-最新稳定版 |
-
-
-| 网络 |
-任意宽带 |
-10 Mbps+ |
-
-
-
-
Windows(已支持)
-
-
-
-| 要求 |
-版本 |
-
-
-
-
-| 系统 |
-Windows 10(64 位)及以上 |
-
-
-| 内存 |
-4 GB RAM |
-
-
-| 存储 |
-500 MB 可用空间 |
-
-
-
-
macOS(敬请期待)
-
-
-
-| 要求 |
-版本 |
-
-
-
-
-| 系统 |
-macOS 12 及以上 |
-
-
-| 内存 |
-4 GB RAM |
-
-
-| 存储 |
-500 MB 可用空间 |
-
-
-
-
iOS(敬请期待)
-
-
-
-| 要求 |
-版本 |
-
-
-
-
-| 系统 |
-iOS 15 及以上 |
-
-
-| 设备 |
-iPhone 8 / iPad 第 6 代及以上 |
-
-
-
-
Android(敬请期待)
-
-
-
-| 要求 |
-版本 |
-
-
-
-
-| 系统 |
-Android 9 及以上 |
-
-
-| 内存 |
-3 GB RAM |
-
-
-
-
-
-
-
-
-
-
-
-
版本更新日志
-
最近 3 个版本。
-
-
-
-
-
-
-
-
-
-
-
- 新功能
-
-
-
-
- - 只读账号自动隐藏所有写操作按钮(新增/编辑/删除/审核/提交/结清/导入等),顶栏显示「只读」标识,权限一目了然
-
- - 左侧抽屉和系统设置页新增「退出登录」入口,窄屏也能方便退出
-
- - 酒行信息新增「微信号」显示
-
-
-
-
-
-
-
- 改进
-
-
-
-
- - 网络请求失败时自动重试(间隔递增),离线时提供带状态反馈的「重试」按钮
-
- - iOS 顶栏避开状态栏,菜单按钮不再被时间/信号遮挡
-
-
-
-
-
-
-
- 问题修复
-
-
-
-
- - 库存导入改用 Excel 中的「入库日期」作为入库时间(不再统一记为导入当天);相同数据重复导入时自动跳过,仅更新有变化的字段,避免重复与误覆盖
-
- - 修复 iOS 上顶栏被状态栏遮挡导致无法打开菜单/退出登录的问题
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 问题修复
-
-
-
-
- - 修复 Windows 上点击打印无反应的问题(GBK 编码改为直接调用系统 API,不再依赖第三方插件)
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 改进
-
-
-
-
- - 热敏标签打印改用打印机内置中文点阵字体(TSS24.BF2/TSS16.BF2),文字清晰度大幅提升,彻底解决 203 DPI 下字体模糊问题
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/dist/features/approval.html b/web/dist/features/approval.html
deleted file mode 100644
index 736edbe..0000000
--- a/web/dist/features/approval.html
+++ /dev/null
@@ -1,719 +0,0 @@
-
-
-
-
-
-
-审核驱动业务流 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
功能详情 · 审核驱动业务流
-
每一笔库存变动,
都经过一次签字。
-
- 入库、出库、盘点 —— 统一状态机,统一审批界面。审批不仅是一个按钮,是一笔事务:库存动了,账款也跟着动,全程留痕。
-
-
-
-
-
-
-
-
-
-
-
-
-
RK20260523000007
-
入库审核 · A 仓 · 茅台经销华东
-
-
- 已审批
-
-
-
-
-
茅台飞天 53° · 500ml×6
4
¥8,000
-
-
-
-
-
-
-
-
- 审批人 李建国 · 2026-05-23 14:32:08
-
-
-
-
-
-
-
-
通过
-
APPROVED
-
2026.05.23
-
-
-
-
-
-
-
-
-
-
-
统一状态机
-
四种状态,
覆盖所有业务单据
-
- 入库、出库、盘点 —— 全部走同一套状态:草稿 → 待审核 → 已审批 / 已拒绝。员工学一次,全员通用。
-
-
- -
-
-
- 提交后即锁定
- 从「草稿」进入「待审核」即不可编辑,避免审批人看到的与录入人提交的不一致。
-
-
- -
-
-
- 状态机由后端事务驱动
- 状态变更与库存 / 账款变更在同一事务内,要么都成功,要么都回滚。
-
-
- -
-
-
- 一套界面,三种单据复用
- 前端审批界面统一,节省培训成本,避免分模块状态差异。
-
-
-
-
-
-
-
-
-
stock_in_orders.status
-
-
-
-
- 同一状态机适用于:
- 入库单
- 出库单
- 盘点单
-
-
-
-
-
-
-
-
-
-
-
自动联动
-
按一次"通过",
库存与账款都跟着动
-
- 审批通过不是一个"标记",而是一组真实的数据库写入。库存增减、应付应收、批次创建 —— 在同一事务内完成。
-
-
- -
-
-
- 事务级一致性
- 底层用 FOR UPDATE 锁住库存行,杜绝并发超卖。
-
-
- -
-
-
- 账款自动落地
- 入库审批 → 自动生成应付;出库审批 → 自动生成应收。挂在对应往来单位名下,可逐月对账。
-
-
- -
-
-
- 批次自动建立
- 每笔入库审批通过即创建一个新批次,后续出库按 FIFO 自动扣减。
-
-
-
-
-
-
-
-
-
-
-
RK20260523000007
-
36 瓶 · ¥48,400 · 待审核
-
-
-
-
-
-
-
- 事务内同步执行
-
-
-
-
-
-
- 库存
-
-
+36 瓶
-
A 仓 · 2,780 → 2,816
-
-
-
- 应付账款
-
-
+¥48,400
-
茅台经销华东 · 新增 1 笔
-
-
-
- 批次
-
-
+4 个
-
L-2026-05-001 ~ L-2026-05-004
-
-
-
- 库存流水
-
-
+4 条
-
逐 SKU 写入 inventory_logs
-
-
-
-
-
-
-
-
-
-
-
-
出库库存校验
-
库存不足,
审批直接被拦下
-
- 出库单审批前,系统按 SKU 实时核对库存。任一行不足,整单审批失败、库存与账款均不变。永远不会超卖。
-
-
-
-
-
-
-
-
出库单 CK20260523000012 · 审批前校验
-
客户 北辰大酒店 · B 仓
-
-
-
-
-
茅台飞天 53° · 500ml×6
-
24
-
148
-
充足
-
-
-
剑南春水晶剑 52°
-
20
-
12
-
短缺 8
-
-
-
拉菲传奇波尔多 · 750ml
-
6
-
64
-
充足
-
-
-
-
-
-
审批已拒绝 · 库存不足
-
- 第 2 行「剑南春水晶剑」B 仓库存仅 12 瓶,本单需 20 瓶,短缺 8 瓶。请补足库存后重新提交,或修改出库数量。
-
-
-
-
-
- // HTTP/1.1 422 Unprocessable Entity
-{
- "ok": false,
- "error": "insufficient_inventory",
- "items": [
- { "sku": "XJ-330-024", "required": 20, "available": 12 }
- ]
-}
-
-
-
-
-
-
-
-
-
-
-
-
审计留痕
-
每一步操作,
都签着人名、贴着时间
-
- 从录入到结清,每一次状态变更都记录经办人、时间、决策、备注。是单据的完整生命周期,也是合规审计的完整证据。
-
-
- -
-
-
- 谁动的、什么时候动的
- 14 个字段:actor / role / device / IP / 时间 / 备注 / 旧值 / 新值 / 原因 …
-
-
- -
-
-
- 永久保留
- 审计日志不会被任何角色(含超级管理员)删除或修改。
-
-
- -
-
-
- 可导出
- 按单据 / 时间段 / 经办人 任意组合筛选导出,应对税务、内审、合规检查。
-
-
-
-
-
-
-
-
-
RK20260523000007
-
已审批
-
-
-
-
-
-
创建草稿
-
- 王芳 · 操作员
- 2026-05-23 11:24:08
- 192.168.10.42 · 入库 PC
-
-
-
-
-
-
-
-
编辑明细
-
- 王芳
- 2026-05-23 13:02:51
- 修改 4 项 · 新增 1 项
-
-
-
-
-
-
-
-
提交审核
-
- 王芳
- 2026-05-23 13:15:22
- 状态 · 草稿 → 待审核
-
-
-
-
-
-
-
-
审批通过
-
- 李建国 · 管理员
- 2026-05-23 14:32:08
- 库存 +36 · 应付 +¥48,400
-
-
-
-
-
-
-
-
-
应付结清
-
- 张磊 · 财务
- 2026-05-26 10:08:33
- AP20260523000012 · 全额结清
-
-
-
-
-
-
-
-
-
-
-
-
-
-
权限矩阵
-
四级角色,
各干各的事
-
- 系统内置四个角色,按岗位职责拆分审批权。最小权限原则 —— 不该看的看不到,不该签的签不了。
-
-
- -
-
-
- 角色绑定门店
- 一个用户只属于一个门店(shop_id),跨店访问数据天然隔离。
-
-
- -
-
-
- 角色升降随时生效
- 管理员调整角色后,下一次请求即按新权限校验,无需重新登录。
-
-
- -
-
-
- 只读角色可看不可改
- 适合财务、税务、内审岗位 —— 看见全部数据,但不能产生任何变更。
-
-
-
-
-
-
-
-
-
角色
-
查看
-
录入
-
审批
-
设置
-
-
-
超级管理员superadmin · 系统最高
-
-
-
-
-
-
-
管理员admin · 门店负责人
-
-
-
-
-
-
-
操作员operator · 日常录入
-
-
-
-
-
-
-
只读readonly · 财务 / 内审
-
-
-
-
-
-
-
-
-
- 允许
-
-
- 禁止
-
-
-
-
-
-
-
-
-
-
典型场景
-
- 审核驱动业务流,落到实际操作里是什么样
-
-
- 三个常见门店场景,看审核流如何把日常工作中的失误拦在事前。
-
-
-
-
-
-
避免超卖发不了货
-
大客户来订 24 瓶飞天,操作员录了出库单。审批时系统提示某 SKU 库存只剩 12 瓶 —— 提前发现,先补货后出库。
-
"以前是发了货才发现没库存,半夜紧急调货。现在审批就拦下,省事多了。"
-
-
-
-
财务来对账,3 秒定位
-
财务问"这批拉菲是哪个供应商进的,谁审的",打开单据点击「审计链」,从草稿、提交、审批到结清的每一步都列得清清楚楚。
-
"以前要翻三个 Excel 表对照,现在一张图就讲完了。"
-
-
-
-
录错了不慌
-
审批通过的单据不可撤销,但可补录一张方向相反的修正单提交审核,两笔记录都完整保留在审计链中,库存与账款同步修正。
-
"原始数据保留,修正也留痕,内审完全不会有问题。"
-
-
-
-
-
-
-
-
-
-
-
每一笔库存变动都有人负责,
就从一次审批开始。
-
30 天免费试用,无需绑定支付方式。所有审批与审计数据可随时导出。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/dist/features/approval/index.html b/web/dist/features/approval/index.html
deleted file mode 100644
index 1dddbed..0000000
--- a/web/dist/features/approval/index.html
+++ /dev/null
@@ -1,1743 +0,0 @@
-
-
-
-
-
-审核驱动业务流 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
功能详情 · 审核驱动业务流
-
每一笔库存变动,
都经过一次签字。
-
- 入库、出库、盘点、调拨 —— 统一状态机,统一审批界面。审批不仅是一个按钮,是一笔事务:库存动了,账款也跟着动,全程留痕。
-
-
-
-
-
-
-
-
-
-
-
-
-
RK20260523000007
-
入库审核 · A 仓 · 茅台经销华东
-
-
- 已审批
-
-
-
-
-
茅台飞天 53° · 500ml×6
4
¥8,000
-
-
-
-
-
-
-
-
- 审批人 李建国 · 2026-05-23 14:32:08
-
-
-
-
-
-
-
-
通过
-
APPROVED
-
2026.05.23
-
-
-
-
-
-
-
-
-
-
-
统一状态机
-
四种状态,
覆盖所有业务单据
-
- 入库、出库、盘点、调拨 —— 全部走同一套状态:草稿 → 待审核 → 已审批 / 已拒绝。员工学一次,全员通用。
-
-
- -
-
-
- 提交后即锁定
- 从「草稿」进入「待审核」即不可编辑,避免审批人看到的与录入人提交的不一致。
-
-
- -
-
-
- 状态机由后端事务驱动
- 状态变更与库存 / 账款变更在同一事务内,要么都成功,要么都回滚。
-
-
- -
-
-
- 一套界面,四种单据复用
- 前端审批界面统一,节省培训成本,避免分模块状态差异。
-
-
-
-
-
-
-
-
-
入库单
-
出库单
-
盘点单
-
调拨单
-
-
stock_in_orders.status
-
-
-
-
- 同一状态机适用于:
- 入库单
- 出库单
- 盘点单
- 调拨单
-
-
-
-
-
-
-
-
-
-
-
自动联动
-
按一次"通过",
库存与账款都跟着动
-
- 审批通过不是一个"标记",而是一组真实的数据库写入。库存增减、应付应收、批次创建 —— 在同一事务内完成。
-
-
- -
-
-
- 事务级一致性
- 底层用 FOR UPDATE 锁住库存行,杜绝并发超卖。
-
-
- -
-
-
- 账款自动落地
- 入库审批 → 自动生成应付;出库审批 → 自动生成应收。挂在对应往来单位名下,可逐月对账。
-
-
- -
-
-
- 批次自动建立
- 每笔入库审批通过即创建一个新批次,后续出库按 FIFO 自动扣减。
-
-
-
-
-
-
-
-
-
-
-
RK20260523000007
-
36 瓶 · ¥48,400 · 待审核
-
-
-
-
-
-
-
- 事务内同步执行
-
-
-
-
-
-
- 库存
-
-
+36 瓶
-
A 仓 · 2,780 → 2,816
-
-
-
- 应付账款
-
-
+¥48,400
-
茅台经销华东 · 新增 1 笔
-
-
-
- 批次
-
-
+4 个
-
L-2026-05-001 ~ L-2026-05-004
-
-
-
- 库存流水
-
-
+4 条
-
逐 SKU 写入 inventory_logs
-
-
-
-
-
-
-
-
-
-
-
-
出库库存校验
-
库存不足,
审批直接被拦下
-
- 出库单审批前,系统按 SKU 实时核对库存。任一行不足,整单审批失败、库存与账款均不变。永远不会超卖。
-
-
- -
-
-
- 逐行校验
- 按出库单每一行的 商品 × 仓库 × 数量 校验,失败明确指出是哪一行。
-
-
- -
-
-
- 整单回滚
- 校验失败时单据状态回到「待审核」,无需重新录入。
-
-
- -
-
-
- 可配置宽松模式
- 系统设置中可开启「允许超量出库」(备货场景使用),缺省关闭。
-
-
-
-
-
-
-
-
-
出库单 CK20260523000012 · 审批前校验
-
客户 北辰大酒店 · B 仓
-
-
-
-
-
茅台飞天 53° · 500ml×6
-
24
-
148
-
充足
-
-
-
剑南春水晶剑 52°
-
20
-
12
-
短缺 8
-
-
-
拉菲传奇波尔多 · 750ml
-
6
-
64
-
充足
-
-
-
-
-
-
审批已拒绝 · 库存不足
-
- 第 2 行「剑南春水晶剑」B 仓库存仅 12 瓶,本单需 20 瓶,短缺 8 瓶。请补足库存后重新提交,或修改出库数量。
-
-
-
-
-
- // HTTP/1.1 422 Unprocessable Entity
-{
- "ok": false,
- "error": "insufficient_inventory",
- "items": [
- { "sku": "XJ-330-024", "required": 20, "available": 12 }
- ]
-}
-
-
-
-
-
-
-
-
-
-
-
-
审计留痕
-
每一步操作,
都签着人名、贴着时间
-
- 从录入到结清,每一次状态变更都记录经办人、时间、决策、备注。是单据的完整生命周期,也是合规审计的完整证据。
-
-
- -
-
-
- 谁动的、什么时候动的
- 14 个字段:actor / role / device / IP / 时间 / 备注 / 旧值 / 新值 / 原因 …
-
-
- -
-
-
- 永久保留
- 审计日志不会被任何角色(含超级管理员)删除或修改。
-
-
- -
-
-
- 可导出
- 按单据 / 时间段 / 经办人 任意组合筛选导出,应对税务、内审、合规检查。
-
-
-
-
-
-
-
-
-
RK20260523000007
-
已审批
-
-
-
-
-
-
创建草稿
-
- 王芳 · 操作员
- 2026-05-23 11:24:08
- 192.168.10.42 · 入库 PC
-
-
-
-
-
-
-
-
编辑明细
-
- 王芳
- 2026-05-23 13:02:51
- 修改 4 项 · 新增 1 项
-
-
-
-
-
-
-
-
提交审核
-
- 王芳
- 2026-05-23 13:15:22
- 状态 · 草稿 → 待审核
-
-
-
-
-
-
-
-
审批通过
-
- 李建国 · 管理员
- 2026-05-23 14:32:08
- 库存 +36 · 应付 +¥48,400
-
-
-
-
-
-
-
-
-
应付结清
-
- 张磊 · 财务
- 2026-05-26 10:08:33
- AP20260523000012 · 全额结清
-
-
-
-
-
-
-
-
-
-
-
-
-
-
红冲机制
-
审批不可撤销,
但错误可以修正
-
- 审批一旦通过,原单据永远是历史的真实记录。如有错误,只能通过红冲(反向调整单)来修正 —— 既保留审计链,又能纠正库存与账款。
-
-
- -
-
-
- 原单不动
- 原始单据完整保留,不被覆盖、不被删除,永久可查。
-
-
- -
-
-
- 反向调整自动生成
- 点击「红冲」一键生成反向单据,数量取反、关联原单据 ID、状态自动进入「待审核」。
-
-
- -
-
-
- 净库存为零
- 红冲单审批通过后,库存与账款回到红冲前的状态,但审计链上多了两笔记录而非清除。
-
-
-
-
-
-
-
-
-
已审批
-
RK20260523000007
-
2026-05-23 · 入库 · 茅台经销华东
-
-
-
-
-
-
-
-
-
-
-
红 冲
-
RK20260524000001
-
2026-05-24 · 反向调整 · 关联 ...000007
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
权限矩阵
-
四级角色,
各干各的事
-
- 系统内置四个角色,按岗位职责拆分审批权。最小权限原则 —— 不该看的看不到,不该签的签不了。
-
-
- -
-
-
- 角色绑定门店
- 一个用户只属于一个门店(shop_id),跨店访问数据天然隔离。
-
-
- -
-
-
- 角色升降随时生效
- 管理员调整角色后,下一次请求即按新权限校验,无需重新登录。
-
-
- -
-
-
- 只读角色可看不可改
- 适合财务、税务、内审岗位 —— 看见全部数据,但不能产生任何变更。
-
-
-
-
-
-
-
-
-
角色
-
查看
-
录入
-
审批
-
设置
-
-
-
超级管理员superadmin · 系统最高
-
-
-
-
-
-
-
管理员admin · 门店负责人
-
-
-
-
-
-
-
操作员operator · 日常录入
-
-
-
-
-
-
-
只读readonly · 财务 / 内审
-
-
-
-
-
-
-
-
-
- 允许
-
-
- 禁止
-
-
-
-
-
-
-
-
-
-
典型场景
-
- 审核驱动业务流,落到实际操作里是什么样
-
-
- 三个常见门店场景,看审核流如何把日常工作中的失误拦在事前。
-
-
-
-
-
-
避免超卖发不了货
-
大客户来订 24 瓶飞天,操作员录了出库单。审批时系统提示某 SKU 库存只剩 12 瓶 —— 提前发现,先补货后出库。
-
"以前是发了货才发现没库存,半夜紧急调货。现在审批就拦下,省事多了。"
-
-
-
-
财务来对账,3 秒定位
-
财务问"这批拉菲是哪个供应商进的,谁审的",打开单据点击「审计链」,从草稿、提交、审批到结清的每一步都列得清清楚楚。
-
"以前要翻三个 Excel 表对照,现在一张图就讲完了。"
-
-
-
-
录错了不慌
-
新员工录错入库单一次性多写了 10 瓶,审批通过后才发现。点击「红冲」,反向调整单一键生成,1 分钟修正库存与账款。
-
"原始数据保留,红冲也留痕,内审完全不会有问题。"
-
-
-
-
-
-
-
-
-
-
-
每一笔库存变动都有人负责,
就从一次审批开始。
-
30 天免费试用,无需绑定支付方式。所有审批与审计数据可随时导出。
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/dist/features/inventory.html b/web/dist/features/inventory.html
deleted file mode 100644
index 8d49fac..0000000
--- a/web/dist/features/inventory.html
+++ /dev/null
@@ -1,756 +0,0 @@
-
-
-
-
-
-
-库存管理 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
功能详情 · 库存管理
-
实时库存,
跨多仓追溯每一瓶酒。
-
- 从入库到售出,每一次库存变动都有记录、可追溯、可回放。多仓库统一管理、批次级追踪、账实差异自动核算 — 不止是库存表。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
WT-501-006
-
茅台飞天 53°
-
A 仓
-
148
-
L-2026-04
-
充足
-
-
-
XJ-468-001
-
五粮液第八代
-
A 仓
-
92
-
L-2026-03
-
充足
-
-
-
XJ-330-024
-
剑南春水晶剑
-
B 仓
-
12
-
L-2026-02
-
偏低
-
-
-
PJ-750-012
-
拉菲传奇波尔多
-
A 仓
-
64
-
L-2026-05
-
充足
-
-
-
PJ-700-008
-
轩尼诗 VSOP
-
B 仓
-
3
-
L-2026-01
-
告急
-
-
-
-
-
-
-
-
-
-
-
- 批次追踪 · L-2026-05
-
-
-
入库 04-28
-
初始 50 瓶
-
A 仓
-
+50
-
-
-
出库 05-12
-
客户 · 春和宴
-
8 瓶
-
-8
-
-
-
出库 05-19
-
客户 · 北辰大酒店
-
12 瓶
-
-12
-
-
-
-
-
-
-
-
-
-
-
实时库存查询
-
不止是库存表,是一个可筛选的工作面板
-
- 库存数据自动跟随入库、出库、盘点变动 — 每一次写入都是一次事务,不存在「待同步」状态。
-
-
- -
-
-
- 多维筛选与列控制
- 按仓库、商品系列、库存状态、批次任意组合筛选,列可隐藏或重排。
-
-
- -
-
-
- 模糊搜索与拼音匹配
- 支持商品名、SKU、拼音首字母混合搜索,与你输入"飞天"同等结果。
-
-
- -
-
-
- 行内编辑备注
- 点击「备注」列即可弹出编辑框,不必进入详情页改字段。
-
-
- -
-
-
- 一键导出 Excel
- 筛选后的视图与数据一同导出,可直接发给会计、税务。
-
-
-
-
-
-
-
-
-
库存列表 · 全部仓库
-
共 2,816 瓶 · ¥486,290
-
-
-
-
-
WT-501-006
-
茅台飞天 53°500ml×6 · L-2026-04
-
A 仓
-
148
-
¥298,400
-
充足
-
-
-
XJ-468-001
-
五粮液第八代 52°500ml · L-2026-03
-
A 仓
-
92
-
¥92,920
-
充足
-
-
-
XJ-330-024
-
剑南春水晶剑 52°500ml×6 · L-2026-02
-
B 仓
-
12
-
¥4,560
-
偏低
-
-
-
PJ-750-012
-
拉菲传奇波尔多750ml · L-2026-05
-
A 仓
-
64
-
¥18,560
-
充足
-
-
-
PJ-700-008
-
轩尼诗 VSOP700ml · L-2026-01
-
B 仓
-
3
-
¥1,770
-
告急
-
-
-
-
-
-
-
-
-
-
-
-
批次追踪
-
每一批货的命运,
都可以回放给你看
-
- 每张审批通过的入库单都会自动形成一个独立批次,可追踪其后续被谁出库、剩余多少、还在哪个仓库。
-
-
- -
-
-
- 批次自动生成
- 入库审批通过即建立批次,无需手动维护。
-
-
- -
-
-
- 逐瓶溯源
- 出库时按"先进先出"自动从最早批次扣减,留下完整的批次→客户对应关系。
-
-
- -
-
-
- 标签二维码
- 每个批次都可打印含二维码的标签,扫码即查批次详情。
-
-
-
-
-
-
-
-
-
-
L-2026-05
-
茅台飞天 53°500ml×6 · A 仓
-
-
-
初始入库 50 瓶
-
累计出库 20 瓶
-
剩余 30 瓶
-
-
-
-
- 已出库 60%
- 40% 库存
-
-
-
-
-
-
2026-04-28
-
入库 · 供应商 茅台经销华东经办人:李建国
-
+50
-
RK0007
-
-
-
-
2026-05-12
-
出库 · 客户 春和宴酒楼经办人:王芳
-
−8
-
CK0034
-
-
-
-
2026-05-19
-
出库 · 客户 北辰大酒店经办人:王芳
-
−12
-
CK0041
-
-
-
-
— 当前 —
-
剩余 30 瓶预计 6 月底前售完
-
30
-
-
-
-
-
-
-
-
-
-
-
-
-
全仓盘点
-
账面 vs 实际,差异一目了然
-
- 逐 SKU 录入实盘数量,系统自动核算账实差异、损耗金额,并一键转为盘亏调整单。
-
-
- -
-
-
- 跨多仓库联动
- 一次盘点可同步多仓库,避免分仓时数据不一致。
-
-
- -
-
-
- 差异自动核算
- 实盘填好后立即显示账面、实际、差异数量与金额。
-
-
- -
-
-
- 历史盘点对比
- 可对比上一次盘点结果,识别长期偏差最大的 SKU。
-
-
-
-
-
-
-
-
-
-
-
茅台飞天 53° · 500ml×6
-
148
-
148
-
0
-
—
-
-
-
五粮液第八代 52° · 500ml
-
92
-
90
-
−2
-
−¥2,020
-
-
-
剑南春水晶剑 52° · 500ml×6
-
12
-
12
-
0
-
—
-
-
-
拉菲传奇波尔多 · 750ml
-
64
-
60
-
−4
-
−¥1,160
-
-
-
轩尼诗 VSOP · 700ml
-
3
-
3
-
0
-
—
-
-
-
人头马 X.O · 700ml
-
40
-
38
-
−2
-
−¥3,660
-
-
-
-
-
-
-
-
-
-
-
库存流水审计
-
谁动了我的库存?
每一次都查得清楚
-
- 所有库存变动都会自动写入流水表,包括入库、出库、盘点调整,附带时间戳与经办人。
-
-
- -
-
-
- 按类型 / 时间 / SKU 任意筛选
- 排查异常时可秒级定位。
-
-
- -
-
-
- 关联原单据
- 每条流水都关联到对应入库单 / 出库单 / 盘点单,一键跳转。
-
-
- -
-
-
- 永久保留
- 流水数据永不自动清除,满足审计与合规要求。
-
-
-
-
-
-
-
-
- 全部类型
- 入库
- 出库
- 盘点
- 本月 · 共 1,284 条
-
-
-
05-23 14:32
-
入库
-
茅台飞天 53° · 500ml×6RK20260523000007 · A 仓
-
+4
-
李建国
-
-
-
05-23 11:48
-
出库
-
五粮液第八代 52°CK20260523000012 · 客户 春和宴
-
−6
-
王芳
-
-
-
05-23 10:15
-
盘点
-
拉菲传奇波尔多PD20260523001 · 账实差异
-
−2
-
张磊
-
-
-
05-22 17:20
-
出库
-
人头马 X.O · 700mlCK20260522000008 · 客户 北辰
-
−2
-
王芳
-
-
-
05-22 14:08
-
入库
-
轩尼诗 VSOP · 700mlRK20260522000005 · B 仓
-
+24
-
李建国
-
-
-
-
-
-
-
-
-
-
-
库存状态
-
一眼看出哪些商品
需要补货
-
- 为每件商品设置最低库存量,系统根据当前库存自动标记「充足 / 偏低 / 告急」,无需手工比对。
-
-
- -
-
-
- 按 SKU 设置最低库存
- 热销商品阈值高,冷门商品阈值低 — 每件单独配置。
-
-
- -
-
-
- 库存列表一目了然
- 库存状态标签跟随列表显示,随时筛选「偏低」「告急」的 SKU。
-
-
- -
-
-
- 商品二维码防伪
- 每件商品生成专属二维码,顾客扫码即可查看批次与防伪信息。
-
-
-
-
-
-
-
-
库存状态一览
-
基于最低库存量 (min_stock) 自动标记状态。
-
-
- 茅台飞天 53°
- 充足
-
-
- 剑南春水晶剑
- 偏低
-
-
- 轩尼诗 VSOP
- 告急
-
-
-
-
-
-
-
-
-
-
-
典型场景
-
- 库存管理,落到实际操作里是什么样
-
-
- 三个常见门店场景,看库存管理如何省下你每天的时间。
-
-
-
-
-
-
客户来取货前,先看一眼批次
-
客户要 12 瓶飞天,先在批次追踪里看哪一批快到期;优先发出旧批次,新批次留着。
-
-
-
-
月底盘点,2 小时变 30 分钟
-
用移动端扫码逐瓶录入,系统对比账面 → 实时显示差异。损耗自动转入应付,不用再算。
-
-
-
-
财务来问"这瓶怎么少了",3 秒回答
-
打开库存流水,按 SKU 筛选,全部入库、出库、盘点变动一目了然,每条都关联到原始单据。
-
-
-
-
-
-
-
-
-
-
-
试试看你今天的库存有几瓶。
-
30 天免费试用,无需绑定支付。所有数据云端实时备份,可随时导出。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/dist/features/inventory/index.html b/web/dist/features/inventory/index.html
deleted file mode 100644
index c59b576..0000000
--- a/web/dist/features/inventory/index.html
+++ /dev/null
@@ -1,1457 +0,0 @@
-
-
-
-
-
-库存管理 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
功能详情 · 库存管理
-
实时库存,
跨多仓追溯每一瓶酒。
-
- 从入库到售出,每一次库存变动都有记录、可追溯、可回放。多仓库统一管理、批次级追踪、账实差异自动核算 — 不止是库存表。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
WT-501-006
-
茅台飞天 53°
-
A 仓
-
148
-
L-2026-04
-
充足
-
-
-
XJ-468-001
-
五粮液第八代
-
A 仓
-
92
-
L-2026-03
-
充足
-
-
-
XJ-330-024
-
剑南春水晶剑
-
B 仓
-
12
-
L-2026-02
-
偏低
-
-
-
PJ-750-012
-
拉菲传奇波尔多
-
A 仓
-
64
-
L-2026-05
-
充足
-
-
-
PJ-700-008
-
轩尼诗 VSOP
-
B 仓
-
3
-
L-2026-01
-
告急
-
-
-
-
-
-
-
-
-
-
-
- 批次追踪 · L-2026-05
-
-
-
入库 04-28
-
初始 50 瓶
-
A 仓
-
+50
-
-
-
出库 05-12
-
客户 · 春和宴
-
8 瓶
-
-8
-
-
-
出库 05-19
-
客户 · 北辰大酒店
-
12 瓶
-
-12
-
-
-
-
-
-
-
-
-
-
-
实时库存查询
-
不止是库存表,是一个可筛选的工作面板
-
- 库存数据自动跟随入库、出库、盘点变动 — 每一次写入都是一次事务,不存在「待同步」状态。
-
-
- -
-
-
- 多维筛选与列控制
- 按仓库、商品系列、库存状态、批次任意组合筛选,列可隐藏或重排。
-
-
- -
-
-
- 模糊搜索与拼音匹配
- 支持商品名、SKU、拼音首字母混合搜索,与你输入"飞天"同等结果。
-
-
- -
-
-
- 行内编辑备注
- 点击「备注」列即可弹出编辑框,不必进入详情页改字段。
-
-
- -
-
-
- 一键导出 Excel
- 筛选后的视图与数据一同导出,可直接发给会计、税务。
-
-
-
-
-
-
-
-
-
库存列表 · 全部仓库
-
共 2,816 瓶 · ¥486,290
-
-
-
-
-
WT-501-006
-
茅台飞天 53°500ml×6 · L-2026-04
-
A 仓
-
148
-
¥298,400
-
充足
-
-
-
XJ-468-001
-
五粮液第八代 52°500ml · L-2026-03
-
A 仓
-
92
-
¥92,920
-
充足
-
-
-
XJ-330-024
-
剑南春水晶剑 52°500ml×6 · L-2026-02
-
B 仓
-
12
-
¥4,560
-
偏低
-
-
-
PJ-750-012
-
拉菲传奇波尔多750ml · L-2026-05
-
A 仓
-
64
-
¥18,560
-
充足
-
-
-
PJ-700-008
-
轩尼诗 VSOP700ml · L-2026-01
-
B 仓
-
3
-
¥1,770
-
告急
-
-
-
-
-
-
-
-
-
-
-
-
批次追踪
-
每一批货的命运,
都可以回放给你看
-
- 每张审批通过的入库单都会自动形成一个独立批次,可追踪其后续被谁出库、剩余多少、还在哪个仓库。
-
-
- -
-
-
- 批次自动生成
- 入库审批通过即建立批次,无需手动维护。
-
-
- -
-
-
- 逐瓶溯源
- 出库时按"先进先出"自动从最早批次扣减,留下完整的批次→客户对应关系。
-
-
- -
-
-
- 过期预警
- 按生产日期 + 保质期推算,临近到期前自动标记。
-
-
- -
-
-
- 标签二维码
- 每个批次都可打印含二维码的标签,扫码即查批次详情。
-
-
-
-
-
-
-
-
-
-
L-2026-05
-
茅台飞天 53°500ml×6 · A 仓
-
-
-
初始入库 50 瓶
-
累计出库 20 瓶
-
剩余 30 瓶
-
-
-
-
- 已出库 60%
- 40% 库存
-
-
-
-
-
-
2026-04-28
-
入库 · 供应商 茅台经销华东经办人:李建国
-
+50
-
RK0007
-
-
-
-
2026-05-12
-
出库 · 客户 春和宴酒楼经办人:王芳
-
−8
-
CK0034
-
-
-
-
2026-05-19
-
出库 · 客户 北辰大酒店经办人:王芳
-
−12
-
CK0041
-
-
-
-
— 当前 —
-
剩余 30 瓶预计 6 月底前售完
-
30
-
-
-
-
-
-
-
-
-
-
-
-
-
全仓盘点
-
账面 vs 实际,差异一目了然
-
- 逐 SKU 录入实盘数量,系统自动核算账实差异、损耗金额,并一键转为盘亏调整单。
-
-
- -
-
-
- 跨多仓库联动
- v1.6.2 新增 — 一次盘点可同步多仓库,避免分仓时数据不一致。
-
-
- -
-
-
- 差异自动核算
- 实盘填好后立即显示账面、实际、差异数量与金额。
-
-
- -
-
-
- 历史盘点对比
- 可对比上一次盘点结果,识别长期偏差最大的 SKU。
-
-
-
-
-
-
-
-
-
-
-
茅台飞天 53° · 500ml×6
-
148
-
148
-
0
-
—
-
-
-
五粮液第八代 52° · 500ml
-
92
-
90
-
−2
-
−¥2,020
-
-
-
剑南春水晶剑 52° · 500ml×6
-
12
-
12
-
0
-
—
-
-
-
拉菲传奇波尔多 · 750ml
-
64
-
60
-
−4
-
−¥1,160
-
-
-
轩尼诗 VSOP · 700ml
-
3
-
3
-
0
-
—
-
-
-
人头马 X.O · 700ml
-
40
-
38
-
−2
-
−¥3,660
-
-
-
-
-
-
-
-
-
-
-
库存流水审计
-
谁动了我的库存?
每一次都查得清楚
-
- 所有库存变动都会自动写入流水表,包括入库、出库、盘点调整、单据红冲,附带时间戳与经办人。
-
-
- -
-
-
- 按类型 / 时间 / SKU 任意筛选
- 排查异常时可秒级定位。
-
-
- -
-
-
- 关联原单据
- 每条流水都关联到对应入库单 / 出库单 / 盘点单,一键跳转。
-
-
- -
-
-
- 永久保留
- 流水数据永不自动清除,满足审计与合规要求。
-
-
-
-
-
-
-
-
- 全部类型
- 入库
- 出库
- 盘点
- 本月 · 共 1,284 条
-
-
-
05-23 14:32
-
入库
-
茅台飞天 53° · 500ml×6RK20260523000007 · A 仓
-
+4
-
李建国
-
-
-
05-23 11:48
-
出库
-
五粮液第八代 52°CK20260523000012 · 客户 春和宴
-
−6
-
王芳
-
-
-
05-23 10:15
-
盘点
-
拉菲传奇波尔多PD20260523001 · 账实差异
-
−2
-
张磊
-
-
-
05-22 17:20
-
出库
-
人头马 X.O · 700mlCK20260522000008 · 客户 北辰
-
−2
-
王芳
-
-
-
05-22 14:08
-
入库
-
轩尼诗 VSOP · 700mlRK20260522000005 · B 仓
-
+24
-
李建国
-
-
-
-
-
-
-
-
-
-
-
库存预警
-
该补货之前,
系统已经替你看见了
-
- 每个 SKU 都可单独设置安全库存阈值,触发即在 Web 与移动端同步推送,永远不会错过补货时机。
-
-
- -
-
-
- 按 SKU 灵活配置
- 热销商品阈值高,冷门商品阈值低 — 不需要一刀切。
-
-
- -
-
-
- 多渠道通知
- Web 顶栏、移动 App 推送、可选邮件 / 短信通知。
-
-
- -
-
-
- 智能补货建议
- 基于近 30 天销售速率 × 补货周期,给出建议补货量。v1.7 Beta
-
-
-
-
-
-
-
-
预警阈值 · 茅台飞天 53°
-
当库存低于阈值时,自动推送给指定操作员。
-
-
-
-
-
0
-
20
-
50
-
200
-
告急
-
偏低
-
充足
-
-
-
- 当前阈值
- 35 瓶
-
-
-
-
-
低于 50 瓶 · 推送给 张磊(仓储)
-
-
-
-
低于 20 瓶 · 短信通知 李建国(采购)
-
-
-
-
-
-
-
-
-
-
-
-
-
典型场景
-
- 库存管理,落到实际操作里是什么样
-
-
- 三个常见门店场景,看库存管理如何省下你每天的时间。
-
-
-
-
-
-
客户来取货前,先看一眼批次
-
客户要 12 瓶飞天,先在批次追踪里看哪一批快到期;优先发出旧批次,新批次留着。
-
-
-
-
月底盘点,2 小时变 30 分钟
-
用移动端扫码逐瓶录入,系统对比账面 → 实时显示差异。损耗自动转入应付,不用再算。
-
-
-
-
财务来问"这瓶怎么少了",3 秒回答
-
打开库存流水,按 SKU 筛选,全部入库、出库、盘点变动一目了然,每条都关联到原始单据。
-
-
-
-
-
-
-
-
-
-
-
试试看你今天的库存有几瓶。
-
30 天免费试用,无需绑定支付。所有数据云端实时备份,可随时导出。
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/dist/index.html b/web/dist/index.html
deleted file mode 100644
index d0301cf..0000000
--- a/web/dist/index.html
+++ /dev/null
@@ -1,752 +0,0 @@
-
-
-
-
-
-
-为酒行与酒店设计的库存管理平台 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- v1.0.54
- 2026-06-17 更新 ·
- 查看更新
-
-
从入库到出库,
一套系统管到底。
-
岩美酒库管理系统,为酒行与酒店设计的库存、审核、财务一体化平台。审核驱动业务流,库存与账款自动同步,五端无缝接入。
-
-
-
无需安装
-
30 天免费
-
支持私有部署
-
-
-
-
-
-
-
-
yanmei.app/inventory
-
-
-
-
-
-
-
-
库存总量
-
2,816 瓶
-
↑ 4.2% 较上月
-
-
-
库存金额
-
¥486,290
-
↑ 2.8% 较上月
-
-
-
本月入库
-
128 单
-
↑ 12 单
-
-
-
-
-
-
WT-501-006
茅台飞天 53° · 500ml×6
A 仓
148
¥298,400
充足
-
XJ-468-001
五粮液第八代 52° · 500ml
A 仓
92
¥92,920
充足
-
XJ-330-024
剑南春水晶剑 52° · 500ml×6
B 仓
12
¥4,560
偏低
-
PJ-750-012
拉菲传奇波尔多 · 750ml
A 仓
64
¥18,560
充足
-
PJ-700-008
轩尼诗 VSOP · 700ml
B 仓
38
¥22,420
入库中
-
-
-
-
-
-
-
-
入库单已审批
-
RK20260523000007 库存 +24 瓶
生成应付 ¥48,400
-
-
-
-
-
-
-
-
-
-
-
专为酒水经营场所设计
-
5端
Web / iOS / Android / Win / Mac
-
-
-
-
-
-
-
-
-
-
-
核心模块
-
全流程模块,覆盖酒水经营每一个环节
-
从商品建档到入库出库、从财务结算到扫码防伪,岩美一站式承载日常运营。
-
-
-
-
入库管理
-
审核驱动的入库流程。草稿、提交、审批、库存更新、应付账款生成 — 一气呵成。
-
- - 多商品行明细录入,自动算金额
- - 审批通过即同步库存与应付账款
- - 支持单据打印、商品标签打印
- - 批次号追踪,生产日期可追溯
-
-
了解审核流详情 →
-
-
-
-
出库管理
-
出库前自动校验库存,审批通过即扣减库存并生成应收。库存不足,单据无法通过。
-
-
-
-
库存管理
-
实时查询每个 SKU 的库存数量、所在仓库、批次。支持全仓盘点与库存流水。
-
了解更多 →
-
-
-
-
财务管理
-
审批同步生成应付应收,月度汇总。结清一键完成,可按往来单位筛选导出。
-
-
-
-
往来单位
-
供应商与客户统一档案管理。卡号、初始余额、联系信息集中维护。
-
-
-
-
基础数据
-
商品名称、系列、规格三级字典,单品数量配置。先建字典,后录单据。
-
-
-
-
系统设置
-
用户与权限、多仓库、编号规则、参数与数据导入 — 自助配置,无需开发。
-
-
-
-
扫码防伪
-
每件商品生成专属二维码,顾客扫码可查看商品名称、批次、出售门店与「已通过岩美防伪验证」标识。
-
-
-
-
-
-
-
-
-
业务流程
-
审核驱动业务流,每一笔交易都可追溯
-
从单据录入到库存变动、账款生成,每一步都有清晰状态与经办人记录。
-
-
-
一张入库单的完整生命周期
-
-
1
录入草稿
选择仓库、供应商、入库日期,逐行录入商品明细。系统自动算金额,可随时保存草稿。
-
2
提交审核
确认无误后提交。单据进入「待审核」,此时不可再编辑,保证数据完整。
-
3
审批通过
操作员及以上权限审批。通过后库存自动 +N,同时生成一笔应付账款,账款挂在对应供应商名下。
-
4
结清账款
财务结算时一键「结清」,财务记录由「未结清」变为「已结清」。账目清晰,按月汇总。
-
-
-
-
-
-
RK20260523000007
-
已审批
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
数据洞察
-
把每一瓶酒的进出,转成可分析的数据
-
报表自动生成,可按日、按月、按往来单位维度切换。导出 Excel 一键完成。
-
-
-
-
本月财务流水
2026 年 5 月 · 应付与应收对比
-
-
-
-
-
- 应付账款
- 应收账款
-
-
-
-
-
-
-
-
财务汇总自动生成
每月应付应收按往来单位、按时间维度自动汇总。无需手工统计,月底一键导出。
-
-
-
-
损耗趋势可见
每次盘点的账实差异自动累计为损耗数据,结合周转率帮你发现异常 SKU。
-
-
-
-
Excel 进出自由
历史数据可批量导入,月度报表可一键导出。与会计、税务系统无缝衔接。
-
-
-
-
库存状态实时标识
设置商品最低库存量,系统自动标记「充足 / 偏低 / 告急」,一眼识别需补货的 SKU。
-
-
-
-
-
-
-
-
-
-
安全与权限
-
企业级权限控制,操作可追溯
-
四级角色细分权限,每一次单据操作都有经办人记录。数据备份与审批留痕,按合规要求设计。
-
-
四级角色
超级管理员、管理员、操作员、只读。按岗位分配,最小权限原则。
-
操作审计
谁、什么时候、做了什么 — 每一笔单据都有经办人与时间戳。
-
审批留痕
审批不可撤销,单据从草稿到结清的每一步状态完整保留。
-
每日备份
云端每日自动备份,支持私有部署。数据始终在你的控制下。
-
-
-
-
-
-
-
-
-
价格
-
按门店付费,明码标价
-
所有方案均含全功能、五端同步、无限单据、技术支持。仅在用户数、门店数、部署方式上区分。
-
-
-
入门方案
-
试用版
-
¥0/ 30 天
-
体验完整功能,无需绑卡
-
- - 单门店 · 最多 3 用户
- - 全部模块开放
- - Web 端 + 移动端
- - 微信技术支持
-
-
免费开通
-
-
-
标准方案
-
单店版
-
¥299/ 月
-
年付折扣 ¥2,988 / 年(约省 ¥600)
-
- - 单门店 · 最多 10 用户
- - 全部模块 + 五端同步
- - 无限单据、无限商品
- - 云端每日备份
- - 工作日 9-18 点支持
-
-
免费试用 30 天
-
-
-
企业方案
-
连锁版
-
定制
-
多门店连锁,含私有部署
-
- - 多门店 · 总部数据汇总
- - 不限用户数
- - 支持私有部署 / 内网
- - 专属实施与培训服务
- - 7×24 专属客户经理
-
-
联系销售
-
-
-
-
-
-
-
-
-
常见问题
-
购买前常见的问题
-
-
- 支持哪些操作系统和设备?
- 系统支持 Windows、macOS 桌面客户端,iOS 与 Android 移动 App,以及主流浏览器的 Web 版本。同一账号可在五端同步使用,数据实时更新。
-
-
- 没有网络时还能操作吗?
- 网络中断时,系统会自动切换到离线模式,展示上次加载的缓存数据。库存查询与单据录入可继续,恢复网络后自动同步至云端。审批与生成账款类操作需要网络。
-
-
- 能否从原有 Excel / 旧系统迁移数据?
- 可以。「系统设置 → 数据导入」支持往来单位、商品名称/系列/规格、库存等数据的批量 Excel 导入,按提供的模板填写即可。导入过程中会显示成功条数与失败原因。
-
-
- 审批通过后发现错误,怎么办?
- 审批通过后操作不可撤销。如发现录入错误,可由具备权限的用户新建一张反向单据(如:误入 10 箱则再出库 10 箱)来修正库存与应收应付,审批链完整保留。
-
-
- 数据安全如何保障?
- 云端版采用每日自动备份策略,数据传输全程 HTTPS 加密。企业版支持私有部署,数据存放在你自己的服务器,岩美不接触任何业务数据。
-
-
- 可以试用多长时间?
- 注册后即可免费试用 30 天,全部功能开放、五端可用,无需绑定支付方式。30 天到期后,升级到单店版(¥299/月)即可继续使用,已录入的数据完整保留。
-
-
- 只读账号能做什么?
- 只读账号可查看全部数据,包括单据、库存、财务、报表,并可导出 Excel。但不能新建、修改、审批、删除任何数据。适合财务、税务、审计岗位的查阅需求。
-
-
-
-
-
-
-
-
-
-
-
用一杯咖啡的时间,
看看你的酒库到底有多少瓶。
-
30 天免费试用,无需绑卡。任何时间可取消,已录入数据保留 90 天。
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/dist/privacy/index.html b/web/dist/privacy/index.html
deleted file mode 100644
index 9512a65..0000000
--- a/web/dist/privacy/index.html
+++ /dev/null
@@ -1,234 +0,0 @@
-
-
-
-
-
-
-隐私政策 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 简要说明:我们收集您的数据只为提供服务,不出售给第三方,不用于广告推送。您的业务数据(商品、库存、财务等)属于您,您可以随时导出或要求删除。
-
-
-
一、我们是谁
-
岩美技术有限公司(以下简称「我们」)是「岩美酒库管理系统」的运营主体,注册地:中华人民共和国上海市。本隐私政策说明我们在您使用本系统过程中如何收集、使用、存储和保护您的个人信息及业务数据。
-
-
二、我们收集哪些数据
-
账号信息:注册时您提供的门店名称、联系邮箱、联系电话(选填)等。
-
业务数据:您主动录入的商品档案、库存记录、入库/出库单、财务数据、往来单位信息等。这些数据完全由您控制。
-
设备信息:客户端应用会生成一个随机设备标识符(UUID),用于授权码的多设备管理,不与任何个人身份信息关联。
-
使用日志:服务器会记录访问日志(IP 地址、请求时间、操作类型),用于安全审计和异常排查,保留期不超过 90 天。
-
异常上报:客户端发生崩溃或技术性错误时,会自动上报错误堆栈信息,不包含任何业务数据或个人信息。
-
意见反馈:您主动提交的文字反馈和图片,用于产品改进。
-
-
三、我们如何使用数据
-
- - 提供服务:存储和处理您的业务数据,确保您可以随时访问
- - 账号验证:通过邮箱进行密码重置、授权到期提醒等必要通知
- - 安全防护:检测异常登录、防止未授权访问
- - 技术支持:利用日志和异常报告排查您反馈的问题
- - 产品改进:分析匿名化的使用模式以优化功能(不关联个人身份)
-
-
我们不会:将您的数据出售给第三方;将您的业务数据用于广告定向;在未获得您明确同意的情况下将数据用于上述目的以外的用途。
-
-
四、数据存储与安全
-
存储位置:数据存储在中国大陆的云服务器上(AWS 上海区域)。
-
传输安全:所有数据传输均使用 HTTPS/TLS 加密。
-
访问控制:多租户隔离架构确保不同门店的数据严格隔离,任何数据库查询都附带门店 ID 条件。
-
备份:数据库每日自动备份,备份保留 7 天,用于灾难恢复。
-
密码存储:密码经 bcrypt 哈希处理后存储,我们无法获取您的明文密码。
-
-
五、数据共享
-
我们仅在以下有限情况下与第三方共享数据:
-
- - 基础设施服务商:AWS(云计算)提供存储和计算基础设施,他们受严格的数据处理协议约束
- - 法律要求:当法律法规或有权机关要求披露时,我们会依法配合,但会在法律允许范围内尽量通知您
-
-
除上述情况外,我们不与任何第三方共享您的数据。
-
-
六、公开商品页
-
您选择「公开」的商品信息(名称、图片、规格等)会通过扫码链接向访客展示。您可以随时在系统内取消商品的公开状态。门店联系方式(如微信号)也仅在您主动填写后才会在公开页显示。
-
-
七、您的权利
-
- - 访问:随时在系统内查看您的所有业务数据
- - 导出:通过系统内导出功能获取数据副本
- - 更正:修改账号信息或业务数据
- - 删除:注销账号后 30 天内数据将被永久删除
- - 投诉:如认为我们违反本政策,可发送邮件至 yammy2023@163.com
-
-
-
八、Cookie 与本地存储
-
Web 端使用 localStorage 存储登录 token(不使用 Cookie),用于维持登录状态。客户端应用使用系统本地存储保存登录凭证和设备标识符。我们不使用第三方追踪 Cookie。
-
-
九、未成年人
-
本系统面向企业用户,不面向 18 岁以下未成年人。如发现未成年人注册账号,请联系我们删除相关信息。
-
-
十、政策变更
-
我们可能根据业务变化或法律要求更新本政策。重大变更将通过系统内通知或注册邮件提前告知,并在新版本生效前给予至少 7 天的知悉期。
-
-
联系我们
-
如对本隐私政策有任何疑问或建议,请联系:
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/dist/register/index.html b/web/dist/register/index.html
deleted file mode 100644
index 00ad146..0000000
--- a/web/dist/register/index.html
+++ /dev/null
@@ -1,432 +0,0 @@
-
-
-
-
-
-
-注册新门店 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
注册新门店
-
填写以下信息,创建您的专属酒库管理账户。注册完成后系统自动分配门店编码,用于登录客户端。
-
-
-
-
-
-
-
-
-
-
-
注册成功!
-
您的门店编码已生成,请妥善保存:
-
-
- —
-
-
-
-
-
登录客户端时请输入:
-
-
- | 门店编码 |
- — |
-
-
- | 用户名 |
- — |
-
-
- | 密码 |
- 您设置的密码 |
-
-
-
-
-
如忘记门店编码,登录后可在「系统设置」中查看。
-
- 前往登录
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/dist/terms/index.html b/web/dist/terms/index.html
deleted file mode 100644
index da11428..0000000
--- a/web/dist/terms/index.html
+++ /dev/null
@@ -1,229 +0,0 @@
-
-
-
-
-
-
-服务条款 · 岩美酒库管理系统
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
一、接受条款
-
欢迎使用岩美酒库管理系统(以下简称「本系统」或「服务」)。本服务由岩美技术有限公司(以下简称「我们」)提供。在注册或使用本系统前,请仔细阅读本服务条款(以下简称「本条款」)。
-
一旦您注册账号或开始使用本系统的任何功能,即视为您已阅读、理解并同意受本条款约束。如您不同意本条款的任何内容,请勿注册或继续使用本系统。
-
-
二、服务描述
-
本系统是一款面向酒行、酒庄、酒店等行业的库存管理软件,提供以下核心功能:
-
- - 商品档案管理(多规格、图片、公开展示页)
- - 入库与出库审核流程管理
- - 实时库存盘点与流水记录
- - 往来账目与财务管理
- - 多用户权限管理(管理员/成员/只读)
- - 多端访问(Web 浏览器、Windows/macOS/iOS/Android 客户端)
-
-
我们保留在不提前通知的情况下随时修改、中断或终止服务部分功能的权利,但我们会尽力提前告知重大变更。
-
-
三、账号注册与安全
-
注册信息:您在注册时须提供真实、准确的门店信息,包括门店名称、联系邮箱等。您有责任及时更新账号信息以保持其准确性。
-
账号安全:您有责任妥善保管账号密码,并对在您账号下发生的所有活动负责。如发现账号存在未授权访问,请立即通过 yammy2023@163.com 联系我们。
-
一门店一账号:一个门店编码对应一个独立数据空间,多用户可在同一门店下协作。严禁将账号转售、出租或用于非法用途。
-
-
四、数据所有权与使用
-
您的数据归您所有:您在本系统中录入的商品信息、库存数据、财务记录等(以下简称「用户数据」)的所有权归您所有。我们不会将您的用户数据出售给第三方。
-
数据使用授权:您授权我们为提供服务而存储、处理和传输您的数据,包括必要的系统维护、备份和安全操作。
-
数据导出:您可以随时通过系统内的导出功能获取您的数据副本。
-
数据删除:注销账号后,您的数据将在 30 天宽限期后从我们的服务器永久删除(备份留存不超过 90 天)。
-
-
五、付费与授权
-
本系统采用授权码制度:新注册门店自动获得 30 天试用期,试用期满后需购买正式授权码方可继续使用完整功能。
-
- - 试用期:30 天,功能与正式版完全相同
- - 宽限期:授权到期后 7 天内,系统仍可正常使用,并提示续费
- - 只读期:到期后第 7 至 15 天,系统进入只读模式,可查看数据但不能新建或修改
- - 锁定期:到期超过 15 天后,无法登录,但数据完整保留,续费后可恢复访问
-
-
授权费用以官网公示为准。已支付的费用在正常服务情况下不予退款,但因系统原因导致无法使用的,可申请按比例退款或延期。
-
-
六、禁止行为
-
您在使用本服务时,不得进行以下行为:
-
- - 上传、传播违法、侵权、虚假或有害内容
- - 尝试破解、反编译或绕过系统安全措施
- - 使用自动化工具(爬虫、脚本等)大量抓取数据
- - 冒充他人或伪造身份信息
- - 干扰系统正常运行或其他用户的正常使用
- - 将服务用于任何违反中华人民共和国法律法规的用途
-
-
-
七、知识产权
-
本系统的软件代码、界面设计、品牌标识(「岩美」商标及 Logo)等均属我们的知识产权,受法律保护。未经授权,您不得复制、修改、分发或以商业目的使用上述内容。
-
-
八、服务中断与免责
-
我们努力保持 99% 以上的可用性,但不对以下情况导致的损失承担责任:
-
- - 不可抗力(地震、台风、战争、网络基础设施故障等)
- - 您自身操作失误或账号密码泄露
- - 第三方服务(云服务商、网络运营商等)的故障
- - 计划内维护窗口期(我们会提前 24 小时公告)
-
-
-
九、条款变更
-
我们可能不定期更新本条款。重大变更将通过系统内通知或邮件告知注册用户,并在新版本生效前给予至少 7 天的知悉期。继续使用服务即视为您接受修订后的条款。
-
-
十、适用法律与争议解决
-
本条款受中华人民共和国法律管辖。如发生争议,双方应首先通过友好协商解决;协商不成的,提交上海市有管辖权的人民法院诉讼解决。
-
-
联系我们
-
如对本条款有任何疑问,请通过以下方式联系我们:
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/features/approval.njk b/web/features/approval.njk
deleted file mode 100644
index 1f01e8f..0000000
--- a/web/features/approval.njk
+++ /dev/null
@@ -1,615 +0,0 @@
----
-layout: base.njk
-title: 审核驱动业务流
-description: 岩美酒库管理系统审核流功能:入库/出库/盘点单据全程审核,状态机驱动,多角色权限控制。
-permalink: /features/approval.html
-pageExtraCss: /assets/features-approval.css
----
-
-
-
-
-
-
-
功能详情 · 审核驱动业务流
-
每一笔库存变动,
都经过一次签字。
-
- 入库、出库、盘点 —— 统一状态机,统一审批界面。审批不仅是一个按钮,是一笔事务:库存动了,账款也跟着动,全程留痕。
-
-
-
-
-
-
-
-
-
-
-
-
-
RK20260523000007
-
入库审核 · A 仓 · 茅台经销华东
-
-
- 已审批
-
-
-
-
-
茅台飞天 53° · 500ml×6
4
¥8,000
-
-
-
-
-
-
-
-
- 审批人 李建国 · 2026-05-23 14:32:08
-
-
-
-
-
-
-
-
通过
-
APPROVED
-
2026.05.23
-
-
-
-
-
-
-
-
-
-
-
统一状态机
-
四种状态,
覆盖所有业务单据
-
- 入库、出库、盘点 —— 全部走同一套状态:草稿 → 待审核 → 已审批 / 已拒绝。员工学一次,全员通用。
-
-
- -
-
-
- 提交后即锁定
- 从「草稿」进入「待审核」即不可编辑,避免审批人看到的与录入人提交的不一致。
-
-
- -
-
-
- 状态机由后端事务驱动
- 状态变更与库存 / 账款变更在同一事务内,要么都成功,要么都回滚。
-
-
- -
-
-
- 一套界面,三种单据复用
- 前端审批界面统一,节省培训成本,避免分模块状态差异。
-
-
-
-
-
-
-
-
-
stock_in_orders.status
-
-
-
-
- 同一状态机适用于:
- 入库单
- 出库单
- 盘点单
-
-
-
-
-
-
-
-
-
-
-
自动联动
-
按一次"通过",
库存与账款都跟着动
-
- 审批通过不是一个"标记",而是一组真实的数据库写入。库存增减、应付应收、批次创建 —— 在同一事务内完成。
-
-
- -
-
-
- 事务级一致性
- 底层用 FOR UPDATE 锁住库存行,杜绝并发超卖。
-
-
- -
-
-
- 账款自动落地
- 入库审批 → 自动生成应付;出库审批 → 自动生成应收。挂在对应往来单位名下,可逐月对账。
-
-
- -
-
-
- 批次自动建立
- 每笔入库审批通过即创建一个新批次,后续出库按 FIFO 自动扣减。
-
-
-
-
-
-
-
-
-
-
-
RK20260523000007
-
36 瓶 · ¥48,400 · 待审核
-
-
-
-
-
-
-
- 事务内同步执行
-
-
-
-
-
-
- 库存
-
-
+36 瓶
-
A 仓 · 2,780 → 2,816
-
-
-
- 应付账款
-
-
+¥48,400
-
茅台经销华东 · 新增 1 笔
-
-
-
- 批次
-
-
+4 个
-
L-2026-05-001 ~ L-2026-05-004
-
-
-
- 库存流水
-
-
+4 条
-
逐 SKU 写入 inventory_logs
-
-
-
-
-
-
-
-
-
-
-
-
出库库存校验
-
库存不足,
审批直接被拦下
-
- 出库单审批前,系统按 SKU 实时核对库存。任一行不足,整单审批失败、库存与账款均不变。永远不会超卖。
-
-
-
-
-
-
-
-
出库单 CK20260523000012 · 审批前校验
-
客户 北辰大酒店 · B 仓
-
-
-
-
-
茅台飞天 53° · 500ml×6
-
24
-
148
-
充足
-
-
-
剑南春水晶剑 52°
-
20
-
12
-
短缺 8
-
-
-
拉菲传奇波尔多 · 750ml
-
6
-
64
-
充足
-
-
-
-
-
-
审批已拒绝 · 库存不足
-
- 第 2 行「剑南春水晶剑」B 仓库存仅 12 瓶,本单需 20 瓶,短缺 8 瓶。请补足库存后重新提交,或修改出库数量。
-
-
-
-
-
- // HTTP/1.1 422 Unprocessable Entity
-{
- "ok": false,
- "error": "insufficient_inventory",
- "items": [
- { "sku": "XJ-330-024", "required": 20, "available": 12 }
- ]
-}
-
-
-
-
-
-
-
-
-
-
-
-
审计留痕
-
每一步操作,
都签着人名、贴着时间
-
- 从录入到结清,每一次状态变更都记录经办人、时间、决策、备注。是单据的完整生命周期,也是合规审计的完整证据。
-
-
- -
-
-
- 谁动的、什么时候动的
- 14 个字段:actor / role / device / IP / 时间 / 备注 / 旧值 / 新值 / 原因 …
-
-
- -
-
-
- 永久保留
- 审计日志不会被任何角色(含超级管理员)删除或修改。
-
-
- -
-
-
- 可导出
- 按单据 / 时间段 / 经办人 任意组合筛选导出,应对税务、内审、合规检查。
-
-
-
-
-
-
-
-
-
RK20260523000007
-
已审批
-
-
-
-
-
-
创建草稿
-
- 王芳 · 操作员
- 2026-05-23 11:24:08
- 192.168.10.42 · 入库 PC
-
-
-
-
-
-
-
-
编辑明细
-
- 王芳
- 2026-05-23 13:02:51
- 修改 4 项 · 新增 1 项
-
-
-
-
-
-
-
-
提交审核
-
- 王芳
- 2026-05-23 13:15:22
- 状态 · 草稿 → 待审核
-
-
-
-
-
-
-
-
审批通过
-
- 李建国 · 管理员
- 2026-05-23 14:32:08
- 库存 +36 · 应付 +¥48,400
-
-
-
-
-
-
-
-
-
应付结清
-
- 张磊 · 财务
- 2026-05-26 10:08:33
- AP20260523000012 · 全额结清
-
-
-
-
-
-
-
-
-
-
-
-
-
-
权限矩阵
-
四级角色,
各干各的事
-
- 系统内置四个角色,按岗位职责拆分审批权。最小权限原则 —— 不该看的看不到,不该签的签不了。
-
-
- -
-
-
- 角色绑定门店
- 一个用户只属于一个门店(shop_id),跨店访问数据天然隔离。
-
-
- -
-
-
- 角色升降随时生效
- 管理员调整角色后,下一次请求即按新权限校验,无需重新登录。
-
-
- -
-
-
- 只读角色可看不可改
- 适合财务、税务、内审岗位 —— 看见全部数据,但不能产生任何变更。
-
-
-
-
-
-
-
-
-
角色
-
查看
-
录入
-
审批
-
设置
-
-
-
超级管理员superadmin · 系统最高
-
-
-
-
-
-
-
管理员admin · 门店负责人
-
-
-
-
-
-
-
操作员operator · 日常录入
-
-
-
-
-
-
-
只读readonly · 财务 / 内审
-
-
-
-
-
-
-
-
-
- 允许
-
-
- 禁止
-
-
-
-
-
-
-
-
-
-
典型场景
-
- 审核驱动业务流,落到实际操作里是什么样
-
-
- 三个常见门店场景,看审核流如何把日常工作中的失误拦在事前。
-
-
-
-
-
-
避免超卖发不了货
-
大客户来订 24 瓶飞天,操作员录了出库单。审批时系统提示某 SKU 库存只剩 12 瓶 —— 提前发现,先补货后出库。
-
"以前是发了货才发现没库存,半夜紧急调货。现在审批就拦下,省事多了。"
-
-
-
-
财务来对账,3 秒定位
-
财务问"这批拉菲是哪个供应商进的,谁审的",打开单据点击「审计链」,从草稿、提交、审批到结清的每一步都列得清清楚楚。
-
"以前要翻三个 Excel 表对照,现在一张图就讲完了。"
-
-
-
-
录错了不慌
-
审批通过的单据不可撤销,但可补录一张方向相反的修正单提交审核,两笔记录都完整保留在审计链中,库存与账款同步修正。
-
"原始数据保留,修正也留痕,内审完全不会有问题。"
-
-
-
-
-
-
-
-
-
-
-
每一笔库存变动都有人负责,
就从一次审批开始。
-
30 天免费试用,无需绑定支付方式。所有审批与审计数据可随时导出。
-
-
-
-
-
-
diff --git a/web/features/inventory.njk b/web/features/inventory.njk
deleted file mode 100644
index 295f456..0000000
--- a/web/features/inventory.njk
+++ /dev/null
@@ -1,652 +0,0 @@
----
-layout: base.njk
-title: 库存管理
-description: 岩美酒库管理系统库存管理功能:实时库存查询、批次追踪、全仓盘点、库存流水审计。
-permalink: /features/inventory.html
-pageExtraCss: /assets/features-inventory.css
----
-
-
-
-
-
-
-
功能详情 · 库存管理
-
实时库存,
跨多仓追溯每一瓶酒。
-
- 从入库到售出,每一次库存变动都有记录、可追溯、可回放。多仓库统一管理、批次级追踪、账实差异自动核算 — 不止是库存表。
-
-
-
-
-
-
-
-
-
-
-
-
-
-
WT-501-006
-
茅台飞天 53°
-
A 仓
-
148
-
L-2026-04
-
充足
-
-
-
XJ-468-001
-
五粮液第八代
-
A 仓
-
92
-
L-2026-03
-
充足
-
-
-
XJ-330-024
-
剑南春水晶剑
-
B 仓
-
12
-
L-2026-02
-
偏低
-
-
-
PJ-750-012
-
拉菲传奇波尔多
-
A 仓
-
64
-
L-2026-05
-
充足
-
-
-
PJ-700-008
-
轩尼诗 VSOP
-
B 仓
-
3
-
L-2026-01
-
告急
-
-
-
-
-
-
-
-
-
-
-
- 批次追踪 · L-2026-05
-
-
-
入库 04-28
-
初始 50 瓶
-
A 仓
-
+50
-
-
-
出库 05-12
-
客户 · 春和宴
-
8 瓶
-
-8
-
-
-
出库 05-19
-
客户 · 北辰大酒店
-
12 瓶
-
-12
-
-
-
-
-
-
-
-
-
-
-
实时库存查询
-
不止是库存表,是一个可筛选的工作面板
-
- 库存数据自动跟随入库、出库、盘点变动 — 每一次写入都是一次事务,不存在「待同步」状态。
-
-
- -
-
-
- 多维筛选与列控制
- 按仓库、商品系列、库存状态、批次任意组合筛选,列可隐藏或重排。
-
-
- -
-
-
- 模糊搜索与拼音匹配
- 支持商品名、SKU、拼音首字母混合搜索,与你输入"飞天"同等结果。
-
-
- -
-
-
- 行内编辑备注
- 点击「备注」列即可弹出编辑框,不必进入详情页改字段。
-
-
- -
-
-
- 一键导出 Excel
- 筛选后的视图与数据一同导出,可直接发给会计、税务。
-
-
-
-
-
-
-
-
-
库存列表 · 全部仓库
-
共 2,816 瓶 · ¥486,290
-
-
-
-
-
WT-501-006
-
茅台飞天 53°500ml×6 · L-2026-04
-
A 仓
-
148
-
¥298,400
-
充足
-
-
-
XJ-468-001
-
五粮液第八代 52°500ml · L-2026-03
-
A 仓
-
92
-
¥92,920
-
充足
-
-
-
XJ-330-024
-
剑南春水晶剑 52°500ml×6 · L-2026-02
-
B 仓
-
12
-
¥4,560
-
偏低
-
-
-
PJ-750-012
-
拉菲传奇波尔多750ml · L-2026-05
-
A 仓
-
64
-
¥18,560
-
充足
-
-
-
PJ-700-008
-
轩尼诗 VSOP700ml · L-2026-01
-
B 仓
-
3
-
¥1,770
-
告急
-
-
-
-
-
-
-
-
-
-
-
-
批次追踪
-
每一批货的命运,
都可以回放给你看
-
- 每张审批通过的入库单都会自动形成一个独立批次,可追踪其后续被谁出库、剩余多少、还在哪个仓库。
-
-
- -
-
-
- 批次自动生成
- 入库审批通过即建立批次,无需手动维护。
-
-
- -
-
-
- 逐瓶溯源
- 出库时按"先进先出"自动从最早批次扣减,留下完整的批次→客户对应关系。
-
-
- -
-
-
- 标签二维码
- 每个批次都可打印含二维码的标签,扫码即查批次详情。
-
-
-
-
-
-
-
-
-
-
L-2026-05
-
茅台飞天 53°500ml×6 · A 仓
-
-
-
初始入库 50 瓶
-
累计出库 20 瓶
-
剩余 30 瓶
-
-
-
-
- 已出库 60%
- 40% 库存
-
-
-
-
-
-
2026-04-28
-
入库 · 供应商 茅台经销华东经办人:李建国
-
+50
-
RK0007
-
-
-
-
2026-05-12
-
出库 · 客户 春和宴酒楼经办人:王芳
-
−8
-
CK0034
-
-
-
-
2026-05-19
-
出库 · 客户 北辰大酒店经办人:王芳
-
−12
-
CK0041
-
-
-
-
— 当前 —
-
剩余 30 瓶预计 6 月底前售完
-
30
-
-
-
-
-
-
-
-
-
-
-
-
-
全仓盘点
-
账面 vs 实际,差异一目了然
-
- 逐 SKU 录入实盘数量,系统自动核算账实差异、损耗金额,并一键转为盘亏调整单。
-
-
- -
-
-
- 跨多仓库联动
- 一次盘点可同步多仓库,避免分仓时数据不一致。
-
-
- -
-
-
- 差异自动核算
- 实盘填好后立即显示账面、实际、差异数量与金额。
-
-
- -
-
-
- 历史盘点对比
- 可对比上一次盘点结果,识别长期偏差最大的 SKU。
-
-
-
-
-
-
-
-
-
-
-
茅台飞天 53° · 500ml×6
-
148
-
148
-
0
-
—
-
-
-
五粮液第八代 52° · 500ml
-
92
-
90
-
−2
-
−¥2,020
-
-
-
剑南春水晶剑 52° · 500ml×6
-
12
-
12
-
0
-
—
-
-
-
拉菲传奇波尔多 · 750ml
-
64
-
60
-
−4
-
−¥1,160
-
-
-
轩尼诗 VSOP · 700ml
-
3
-
3
-
0
-
—
-
-
-
人头马 X.O · 700ml
-
40
-
38
-
−2
-
−¥3,660
-
-
-
-
-
-
-
-
-
-
-
库存流水审计
-
谁动了我的库存?
每一次都查得清楚
-
- 所有库存变动都会自动写入流水表,包括入库、出库、盘点调整,附带时间戳与经办人。
-
-
- -
-
-
- 按类型 / 时间 / SKU 任意筛选
- 排查异常时可秒级定位。
-
-
- -
-
-
- 关联原单据
- 每条流水都关联到对应入库单 / 出库单 / 盘点单,一键跳转。
-
-
- -
-
-
- 永久保留
- 流水数据永不自动清除,满足审计与合规要求。
-
-
-
-
-
-
-
-
- 全部类型
- 入库
- 出库
- 盘点
- 本月 · 共 1,284 条
-
-
-
05-23 14:32
-
入库
-
茅台飞天 53° · 500ml×6RK20260523000007 · A 仓
-
+4
-
李建国
-
-
-
05-23 11:48
-
出库
-
五粮液第八代 52°CK20260523000012 · 客户 春和宴
-
−6
-
王芳
-
-
-
05-23 10:15
-
盘点
-
拉菲传奇波尔多PD20260523001 · 账实差异
-
−2
-
张磊
-
-
-
05-22 17:20
-
出库
-
人头马 X.O · 700mlCK20260522000008 · 客户 北辰
-
−2
-
王芳
-
-
-
05-22 14:08
-
入库
-
轩尼诗 VSOP · 700mlRK20260522000005 · B 仓
-
+24
-
李建国
-
-
-
-
-
-
-
-
-
-
-
库存状态
-
一眼看出哪些商品
需要补货
-
- 为每件商品设置最低库存量,系统根据当前库存自动标记「充足 / 偏低 / 告急」,无需手工比对。
-
-
- -
-
-
- 按 SKU 设置最低库存
- 热销商品阈值高,冷门商品阈值低 — 每件单独配置。
-
-
- -
-
-
- 库存列表一目了然
- 库存状态标签跟随列表显示,随时筛选「偏低」「告急」的 SKU。
-
-
- -
-
-
- 商品二维码防伪
- 每件商品生成专属二维码,顾客扫码即可查看批次与防伪信息。
-
-
-
-
-
-
-
-
库存状态一览
-
基于最低库存量 (min_stock) 自动标记状态。
-
-
- 茅台飞天 53°
- 充足
-
-
- 剑南春水晶剑
- 偏低
-
-
- 轩尼诗 VSOP
- 告急
-
-
-
-
-
-
-
-
-
-
-
典型场景
-
- 库存管理,落到实际操作里是什么样
-
-
- 三个常见门店场景,看库存管理如何省下你每天的时间。
-
-
-
-
-
-
客户来取货前,先看一眼批次
-
客户要 12 瓶飞天,先在批次追踪里看哪一批快到期;优先发出旧批次,新批次留着。
-
-
-
-
月底盘点,2 小时变 30 分钟
-
用移动端扫码逐瓶录入,系统对比账面 → 实时显示差异。损耗自动转入应付,不用再算。
-
-
-
-
财务来问"这瓶怎么少了",3 秒回答
-
打开库存流水,按 SKU 筛选,全部入库、出库、盘点变动一目了然,每条都关联到原始单据。
-
-
-
-
-
-
-
-
-
-
-
试试看你今天的库存有几瓶。
-
30 天免费试用,无需绑定支付。所有数据云端实时备份,可随时导出。
-
-
-
-
-
-