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 @@ - - - - - - -使用手册 · 岩美酒库管理系统 - - - - - - - - - - - - - - - - - -
-
- - - - -
- - 版本 - v1.0.54 -
-
-
- - -
- - - - - -
- -
-

1. 系统概述

-

岩美酒库管理系统专为酒行门店设计,将入库、出库、库存查询、财务往来等日常业务整合到一个平台,支持多人协同操作,数据实时同步。

-

核心功能一览

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
模块主要用途
入库管理新建入库单、提交审核、审批通过后自动更新库存
出库管理新建出库单、提交审核、审批时自动扣减库存
库存管理查询实时库存、库存盘点、打印标签
财务管理查看应付款(欠供应商)和应收款(客户欠款)、标记结清
往来单位管理供应商和客户的基本信息
基础数据维护商品名称、系列、规格字典
系统设置用户管理、仓库管理、编号规则、数据导入
-

支持平台:Web 浏览器(现已上线);Windows、macOS、iOS、Android 客户端即将推出。

-
- -
- -
-

2. 登录与退出

-

2.1 登录

-
    -
  1. 打开系统,进入登录页面。
  2. -
  3. 在「门店编号」栏输入酒行的门店代号(例如 S001)。
  4. -
  5. 输入「用户名」和「密码」。
  6. -
  7. 点击「登录」按钮。
  8. -
-

登录成功后,系统自动跳转到「入库管理」页面。

-

提示:系统会记录最近登录过的用户名,点击输入框可从候选列表中快速选择。

-

2.2 退出登录

-

点击顶部右侧用户名旁的下拉箭头,选择「退出登录」。

-

2.3 查看当前登录信息

-

点击顶部左侧「岩美」标题或右侧「门店编号」区域,弹出门店信息面板,显示门店编号、登录账号、姓名、系统版本。

-
- -
- -
-

3. 界面说明

-

系统主界面由三个区域组成:

-
┌──────────────────────────────────────────────────────────┐
-│  顶部导航栏(标题、门店编号、用户名、退出)              │
-├──────────┬───────────────────────────────────────────────┤
-│          │                                               │
-│  左侧    │           主内容区域                         │
-│  侧边栏  │                                               │
-│          │                                               │
-├──────────┴───────────────────────────────────────────────┤
-│  状态栏(门店、用户、登录时间、当前时间、连接状态)       │
-└──────────────────────────────────────────────────────────┘
-
-

左侧侧边栏导航项

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
导航项功能
入库管理入库单录入与审核
出库管理出库单录入与审核
库存管理库存查询与盘点
财务管理应付款和应收款
往来单位供应商与客户
基础数据商品名称、系列、规格字典
系统设置用户、仓库、编号规则等配置
-

点击侧边栏顶部的菜单图标可收起或展开侧边栏,节省屏幕空间。

-

更新提示:有新版本时,内容区顶部会显示黄色提示横幅;如果是必须更新的版本,会弹出对话框要求立即更新。

-

离线提示:网络断开时,顶部显示红色横幅「网络连接已断开」,状态栏变红,此时展示上次加载的缓存数据。

-
- -
- -
-

4. 角色与权限

-

系统有四种角色,权限从高到低依次为:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
角色说明可执行操作
超级管理员系统最高权限账号全部操作,含数据清空
管理员门店管理人员全部操作,含用户管理、酒行信息编辑
操作员日常录入审核人员新建、编辑、提交、审核单据,查询数据
只读仅查看数据只能查看,不能新建、修改、删除任何数据
-

权限说明

-
    -
  • 只读账号点击任何写操作按钮时,系统将直接提示「无权限」。
  • -
  • 新增或编辑用户、重置密码仅管理员可操作。
  • -
  • 修改酒行基本信息仅管理员可操作。
  • -
  • 数据清空仅超级管理员可操作。
  • -
-
- -
- -
-

5. 入库管理

-

入库管理页面包含两个标签页:入库审核(新建和待审核)和入库单(已完成记录)。

-

5.1 入库单状态说明

-

单据状态按以下流程流转:

-

草稿待审核已审批(库存增加)

-

待审核阶段也可被拒绝,变为已拒绝状态(库存不变)。

- - - - - - - - - - - - - - - - - - - - - - - - - -
状态含义
草稿已保存但未提交,可继续编辑或删除
待审核已提交等待审批,不可再编辑
已审批审批通过,库存已增加,同时生成应付账款记录
已拒绝审批不通过,库存不变
-

5.2 新建入库单

-
    -
  1. 点击左侧「入库管理」,切换到「入库审核」标签页。
  2. -
  3. 点击右上角「新建入库审核单」按钮。
  4. -
  5. 填写入库基本信息: -
      -
    • 仓库(必填):选择货物入库的目标仓库。
    • -
    • 供应商(必填):选择供货的往来单位。
    • -
    • 入库日期(必填):默认为今天,可修改。
    • -
    • 备注(选填):填写本批次入库说明。
    • -
    -
  6. -
  7. 在商品明细区域填写每一行商品: -
      -
    • 商品名称(必填):从字典中选择或直接输入。
    • -
    • 系列(必填):选择商品系列。
    • -
    • 规格(必填):选择商品规格,如 500ml×6。
    • -
    • 生产日期(必填):点击日历图标选择。
    • -
    • 批次号(选填):填写批次编号,用于后续追踪查询。
    • -
    • 数量(必填):填写入库数量。
    • -
    • 单价(必填):填写进货单价,系统自动计算总金额。
    • -
    -
  8. -
  9. 点击「添加商品」可继续增加商品行,点击行末「删除」可移除该行。
  10. -
  11. 完成后选择操作: -
      -
    • 保存草稿:保存后可以继续修改。
    • -
    • 保存并提交审核:提交后进入待审核状态,不可再编辑。
    • -
    -
  12. -
-

5.3 提交审核

-

草稿状态的入库单,在列表中点击「提交」按钮,确认后单据进入「待审核」状态。

-

5.4 审批入库单

-

操作员及以上权限的用户均可审批。

-
    -
  1. 在「入库审核」标签页中找到待审核的入库单。
  2. -
  3. 点击「通过」:弹窗确认,确认后系统自动将商品入库,库存增加,同时生成一笔应付账款记录。
  4. -
  5. 点击「拒绝」:弹窗确认,拒绝后库存不变,单据进入「已拒绝」状态。
  6. -
-

注意:审批通过后操作不可撤销,请仔细核对商品数量和单价后再确认。

-

5.5 查看入库单详情

-

点击入库单号(蓝色链接)可弹出详情页,显示完整的商品明细、数量、金额及审核信息。详情页右上角有打印按钮,可直接打印入库单。

-

5.6 打印入库单

-

在列表中点击对应行的「打印」按钮,直接调用打印机打印入库单据。

-

5.7 打印商品标签

-
    -
  1. 在列表中点击对应行的「打标签」按钮。
  2. -
  3. 弹出商品标签打印对话框,列出本次入库的所有商品。
  4. -
  5. 勾选需要打印标签的商品(默认全选)。
  6. -
  7. 点击「打印选中」,系统逐张打印选中商品的标签。
  8. -
-

标签内容包括:商品名称、编码、系列、规格、批次号、生产日期、酒行名称及联系方式、二维码。

-

5.8 标记货款结清

-

审批通过的入库单会自动生成应付账款记录。货款付清后,在列表中点击「结清」按钮,确认后将对应财务记录标记为已结清。

-

5.9 筛选与导出

-
    -
  • 仓库筛选:点击列头「仓库」的筛选图标,选择仓库进行过滤。
  • -
  • 供应商筛选:点击列头「供应商」的筛选图标,选择供应商进行过滤。
  • -
  • 日期筛选:点击「选择日期」按钮,选择日期范围。
  • -
  • 导出:点击「导出」按钮,将当前列表导出为 Excel 文件。
  • -
-
- -
- -
-

6. 出库管理

-

出库管理与入库管理操作流程类似,包含出库审核(待处理)和出库单(已完成)两个标签页。

-

6.1 出库单状态说明

-

出库单状态流转与入库单相同:草稿 → 待审核 → 已审批或已拒绝。

-

审批通过后:

-
    -
  • 系统会先检查库存是否充足,库存不足时审批失败并给出提示,库存不变。
  • -
  • 库存充足时自动扣减库存,并生成应收账款记录。
  • -
-

6.2 新建出库单

-
    -
  1. 点击左侧「出库管理」,切换到「出库审核」标签页。
  2. -
  3. 点击右上角「新建出库审核单」按钮。
  4. -
  5. 填写基本信息: -
      -
    • 仓库(必填):选择出货仓库。
    • -
    • 客户(必填):选择购买方(往来单位中的客户类型)。
    • -
    • 出库日期(必填):默认今天,可修改。
    • -
    -
  6. -
  7. 填写商品明细:商品名称、系列、规格、数量、单价(操作方式同入库单)。
  8. -
  9. 保存草稿或保存并提交审核。
  10. -
-

注意:出库数量不能超过当前库存,审批时系统会自动检查,库存不足会提示失败。

-

6.3 审批出库单

-

操作步骤与入库审批相同。审批通过后库存自动扣减,同时生成应收账款记录。

-

6.4 打印出库单

-

在列表中点击「打印」按钮,打印出库单据。

-

6.5 标记货款结清

-

出库单审批通过后生成应收账款。收到货款后,在列表中点击「结清」按钮,将对应财务记录标记为已结清。

-
- -
- -
-

7. 库存管理

-

7.1 查询库存

-

点击左侧「库存管理」进入库存列表。

-

列表显示每个商品的实时库存数量、所在仓库、单价、金额、生产日期、批次等信息。

-
    -
  • 搜索:在搜索框输入商品名称关键字(也支持拼音或拼音首字母),实时过滤结果。
  • -
  • 仓库筛选:点击列头「仓库」的筛选图标,按仓库查看库存。
  • -
  • 导出:点击「导出」按钮,将当前库存列表导出为 Excel 文件。
  • -
-

7.2 修改备注

-

在库存列表中,点击「备注」列对应单元格(显示铅笔图标或现有备注内容),弹出编辑框,填写后点击「保存」即可。

-

7.3 打印商品标签

-

在库存列表中点击某商品行的「打印标签」按钮,可单独打印该商品的标签。

-

7.4 库存盘点

-
    -
  1. 在库存管理页面切换到「库存盘点」标签页。
  2. -
  3. 选择要盘点的仓库,系统自动加载该仓库所有商品的账面库存数量。
  4. -
  5. 选择盘点类型(全盘)。
  6. -
  7. 逐行填写实际盘点数量(与账面不符时修改数量)。
  8. -
  9. 点击「提交盘点」完成。
  10. -
-

盘点单编号由系统自动生成。

-

7.5 库存流水

-

「库存流水」标签页可查看每次库存变动记录,包括入库、出库的时间、变动数量等。

-
- -
- -
-

8. 财务管理

-

8.1 财务记录说明

-

系统在以下情况自动生成财务记录:

-
    -
  • 入库单审批通过 → 自动生成应付账款(欠供应商的货款)
  • -
  • 出库单审批通过 → 自动生成应收账款(客户欠的货款)
  • -
-

点击左侧「财务管理」进入财务页面,包含三个标签:

- - - - - - - - - - - - - - - - - - - - - -
标签内容
全部记录所有财务流水
应付账款需要向供应商付款的记录
应收账款客户尚未付款的记录
-

8.2 查看财务记录

-
    -
  • 默认显示当前月份的记录,可通过月份选择器查看历史月份。
  • -
  • 支持按「类型」和「往来单位」列头筛选。
  • -
  • 点击「导出」可导出当月数据为 Excel 文件。
  • -
-

8.3 标记结清

-

方式一:在入库单或出库单列表中点击「结清」按钮,直接结清该单据对应的账款。

-

方式二:在财务管理列表中找到对应记录,点击「结清」按钮,单笔结清。

-

结清后该记录状态从「未结清」变为「已结清」。

-

8.4 财务汇总

-

财务管理页面显示当期应付和应收总额,便于掌握资金往来概况。

-
- -
- -
-

9. 往来单位

-

往来单位管理供应商客户两类信息,新建入库单时选择供应商,新建出库单时选择客户。

-

9.1 查看往来单位

-

点击左侧「往来单位」,通过「供应商」和「客户」标签页分别查看。列表支持按名称搜索和导出。

-

9.2 新建往来单位

-
    -
  1. 在对应标签页点击右上角「新建」按钮。
  2. -
  3. 填写信息: -
      -
    • 名称(必填)
    • -
    • 联系电话(选填)
    • -
    • 地址(选填)
    • -
    • 卡号(选填,如有账户绑定)
    • -
    • 初始余额(选填,导入历史数据时填写)
    • -
    • 备注(选填)
    • -
    -
  4. -
  5. 点击「保存」。
  6. -
-

9.3 编辑往来单位

-

在列表中点击「编辑」按钮,修改信息后保存。

-

9.4 删除往来单位

-

在列表中点击「删除」按钮,确认后删除。

-

注意:已关联入库单或出库单的往来单位不可删除。

-
- -
- -
-

10. 基础数据(商品管理)

-

点击左侧「基础数据」,进入商品字典管理页面,包含三个标签:商品名称系列规格

-

这些字典数据是新建入库或出库单时选择商品的基础,需要先在此处维护好商品信息。

-

10.1 商品名称管理

-

商品名称是酒品的主名称,例如「茅台飞天 53°」。

-
    -
  1. 在「商品名称」标签页,点击「新建」按钮。
  2. -
  3. 填写商品名称(必填)、编号(选填)、备注(选填)。
  4. -
  5. 点击「保存」。
  6. -
-

支持按名称或编号搜索,支持分页浏览,支持编辑和删除。

-

10.2 系列管理

-

系列是商品所属的系列或品牌线,例如「飞天系列」「王子系列」。操作步骤与商品名称相同。

-

10.3 规格管理

-

规格用于描述包装规格,例如「500ml×6」「1000ml×1」。新建时可填写「单品数量」(即一箱包含的单瓶数量)。

-

10.4 商品详情与二维码

-

每个商品都有唯一的二维码,可通过扫码访问商品公开信息页面。在商品详情页可查看或上传商品图片。

-
- -
- -
-

11. 系统设置

-

点击左侧「系统设置」,包含七个标签页。

-

11.1 酒行信息

-

显示本门店的基本信息:门店编号、门店名称、地址、联系电话、负责人。

-

编辑(仅管理员):点击「编辑信息」按钮,修改门店名称、地址、电话、负责人后保存。门店编号不可修改。

-

11.2 用户管理(仅管理员)

-

查看用户:显示本门店所有用户的姓名、用户名、角色、启用状态。

-

新增用户

-
    -
  1. 点击「新增用户」按钮。
  2. -
  3. 填写用户名(登录账号,不可重复)、姓名、手机号、初始密码。
  4. -
  5. 选择角色(操作员、管理员、只读、超级管理员)。
  6. -
  7. 点击「保存」。
  8. -
-

编辑用户:点击用户行的「编辑」按钮,可修改姓名、手机号、角色(用户名不可修改)。

-

重置密码:点击「重置密码」按钮,输入新密码后确认,下次登录时使用新密码。

-

启用或停用:在用户列表中拨动状态开关。停用后该用户无法登录。

-

11.3 仓库管理

-

查看仓库:显示所有仓库名称、位置、是否为默认仓库。

-

新建仓库

-
    -
  1. 点击「新建」按钮。
  2. -
  3. 填写仓库名称(必填)、位置(选填)。
  4. -
  5. 可勾选「设为默认仓库」,新建单据时将自动选择此仓库。
  6. -
  7. 点击「保存」。
  8. -
-

编辑或删除:点击对应按钮操作。已有库存的仓库不可删除。

-

11.4 编号规则

-

系统为每类单据自动生成唯一编号,格式由前缀、日期和序号组成,例如入库单编号 RK20260523001

-

查看当前规则:列表显示每种单据的前缀、当前序号及下一编号示例。

-

修改规则

-
    -
  1. 点击对应规则行的「编辑」按钮。
  2. -
  3. 可修改「前缀」和「当前序号」。
  4. -
  5. 点击「保存」,修改后对新建单据生效。
  6. -
-

注意:请勿将序号调小至已使用过的范围,否则可能导致编号重复。

-

11.5 系统参数

-

配置系统的全局参数:

-
    -
  • 系统名称、货币单位、日期格式、时区(通常无需修改)
  • -
  • 入库单需要审核:关闭后提交即视为通过,无需审批
  • -
  • 出库单需要审核:关闭后同上
  • -
  • 允许超量出库:开启后,出库数量超过库存时仍可审批通过
  • -
-

修改后点击「保存设置」生效,点击「重置默认」恢复出厂默认值。

-

11.6 数据导入

-

通过 Excel 文件批量导入历史数据。支持以下数据类型:往来单位、商品名称、商品系列、商品规格、商品编码、库存。

-

各类型的 Excel 格式要求详见系统内「系统设置 → 数据导入」页面说明。

-

操作步骤

-
    -
  1. 在对应数据类型行点击「选择文件」,选择 Excel(.xls 或 .xlsx)文件。
  2. -
  3. 全部文件选择完成后,点击「全部导入」按钮。
  4. -
  5. 系统依次处理每个文件并显示导入结果(新增、跳过重复、失败数量)。
  6. -
  7. 如有失败,根据错误提示修正文件后重新导入。
  8. -
-

导入规则:以名称去重,已存在的记录不重复导入。导入过程中请勿切换页面。

-

数据清空(仅超级管理员):在数据导入页面下方「危险操作」区域,选择要清空的数据表,点击确认后输入「确认清空」完成操作。此操作不可撤销,请务必提前做好数据备份。

-

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 版

-

浏览器直接访问,功能完整,无需安装,支持 PC 与平板。

- ✓ 已上线 - - 立即使用 - -
- - -
-
-

Windows

-

原生桌面客户端,支持 Win 10/11(64 位),离线模式与本地打印。

- ✓ 已支持 - - 下载 Windows 版 - -
- - -
-
-

macOS

-

支持 Apple Silicon 与 Intel,解压即用,首次运行右键→打开。

- ✓ 已支持 - - 下载 macOS 版 - -
- - -
-
-

iOS

-

iPhone 与 iPad 通用,通过 TestFlight 安装。

- 敬请期待 - - 即将推出 - -
- - -
-
-

Android

-

手机与平板通用,APK 直接安装,支持 Android 6.0 及以上。

- ✓ 已支持 - - 下载 Android 版 - -
-
-
-
- - -
-
-

系统要求

-

在以下环境中均经过测试与验证。

-
-

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 个版本。

- -
- -
-
-
v1.0.54
-
2026-06-17
- 当前版本 -
-
- - -
-
- - 新功能 - -
-
    - -
  • 只读账号自动隐藏所有写操作按钮(新增/编辑/删除/审核/提交/结清/导入等),顶栏显示「只读」标识,权限一目了然
  • - -
  • 左侧抽屉和系统设置页新增「退出登录」入口,窄屏也能方便退出
  • - -
  • 酒行信息新增「微信号」显示
  • - -
-
- -
-
- - 改进 - -
-
    - -
  • 网络请求失败时自动重试(间隔递增),离线时提供带状态反馈的「重试」按钮
  • - -
  • iOS 顶栏避开状态栏,菜单按钮不再被时间/信号遮挡
  • - -
-
- -
-
- - 问题修复 - -
-
    - -
  • 库存导入改用 Excel 中的「入库日期」作为入库时间(不再统一记为导入当天);相同数据重复导入时自动跳过,仅更新有变化的字段,避免重复与误覆盖
  • - -
  • 修复 iOS 上顶栏被状态栏遮挡导致无法打开菜单/退出登录的问题
  • - -
-
- -
-
- -
-
-
v1.0.53
-
2026-06-15
- -
-
- - -
-
- - 问题修复 - -
-
    - -
  • 修复 Windows 上点击打印无反应的问题(GBK 编码改为直接调用系统 API,不再依赖第三方插件)
  • - -
-
- -
-
- -
-
-
v1.0.52
-
2026-06-15
- -
-
- - -
-
- - 改进 - -
-
    - -
  • 热敏标签打印改用打印机内置中文点阵字体(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 @@ - - - - - - -审核驱动业务流 · 岩美酒库管理系统 - - - - - - - - - - - - - - - - - -
-
-
-
- 首页 - - 产品功能 - - 审核驱动业务流 -
-

功能详情 · 审核驱动业务流

-

每一笔库存变动,
都经过一次签字

-

- 入库、出库、盘点 —— 统一状态机,统一审批界面。审批不仅是一个按钮,是一笔事务:库存动了,账款也跟着动,全程留痕。 -

- -
-
-
单据类型
-
3 种
-
-
-
操作留痕
-
全程
-
-
-
审批联动
-
库存 + 账款
-
-
-
- -
-
-
- -
-
-
-
RK20260523000007
-
入库审核 · A 仓 · 茅台经销华东
-
-
- 已审批 -
-
- -
-
茅台飞天 53° · 500ml×6
4
¥8,000
-
-
-
五粮液第八代 52°
20
¥18,400
-
-
-
剑南春水晶剑 52°
2
¥4,200
-
-
-
拉菲传奇波尔多
10
¥17,800
-
- -
-
合计
36
¥48,400
-
- -
- 审批人 李建国 · 2026-05-23 14:32:08 -
-
- - -
- - - - - - 岩美 · 审批通过 · APPROVED · 岩美 · - - -
-
通过
-
APPROVED
-
2026.05.23
-
-
-
-
-
- - -
-
-
-
统一状态机
-

四种状态,
覆盖所有业务单据

-

- 入库、出库、盘点 —— 全部走同一套状态:草稿 → 待审核 → 已审批 / 已拒绝。员工学一次,全员通用。 -

-
    -
  • - -
    - 提交后即锁定 - 从「草稿」进入「待审核」即不可编辑,避免审批人看到的与录入人提交的不一致。 -
    -
  • -
  • - -
    - 状态机由后端事务驱动 - 状态变更与库存 / 账款变更在同一事务内,要么都成功,要么都回滚。 -
    -
  • -
  • - -
    - 一套界面,三种单据复用 - 前端审批界面统一,节省培训成本,避免分模块状态差异。 -
    -
  • -
-
- -
-
-
-
入库单
-
出库单
-
盘点单
-
-
stock_in_orders.status
- -
-
-
- 草稿 -
- draft -
- -
- 提交 - -
- -
-
- 待审核 -
- pending -
- -
- 审批 - -
- -
-
-
- 已审批 -
- approved -
-
-
- 已拒绝 -
- rejected -
-
-
- -
- 同一状态机适用于: - 入库单 - 出库单 - 盘点单 -
-
-
-
-
- - -
-
-
-
自动联动
-

按一次"通过",
库存与账款都跟着动

-

- 审批通过不是一个"标记",而是一组真实的数据库写入。库存增减、应付应收、批次创建 —— 在同一事务内完成。 -

-
    -
  • - -
    - 事务级一致性 - 底层用 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 @@ - - - - - -审核驱动业务流 · 岩美酒库管理系统 - - - - - - - - - - - - -
-
-
-
- 首页 - - 产品功能 - - 审核驱动业务流 -
-

功能详情 · 审核驱动业务流

-

每一笔库存变动,
都经过一次签字

-

- 入库、出库、盘点、调拨 —— 统一状态机,统一审批界面。审批不仅是一个按钮,是一笔事务:库存动了,账款也跟着动,全程留痕。 -

- -
-
-
审批延迟
-
≤ 1s
-
-
-
审计字段
-
14
-
-
-
回滚机制
-
红冲
-
-
-
- -
-
-
- -
-
-
-
RK20260523000007
-
入库审核 · A 仓 · 茅台经销华东
-
-
- 已审批 -
-
- -
-
茅台飞天 53° · 500ml×6
4
¥8,000
-
-
-
五粮液第八代 52°
20
¥18,400
-
-
-
剑南春水晶剑 52°
2
¥4,200
-
-
-
拉菲传奇波尔多
10
¥17,800
-
- -
-
合计
36
¥48,400
-
- -
- 审批人 李建国 · 2026-05-23 14:32:08 -
-
- - -
- - - - - - 岩美 · 审批通过 · APPROVED · 岩美 · - - -
-
通过
-
APPROVED
-
2026.05.23
-
-
-
-
-
- - -
-
-
-
统一状态机
-

四种状态,
覆盖所有业务单据

-

- 入库、出库、盘点、调拨 —— 全部走同一套状态:草稿 → 待审核 → 已审批 / 已拒绝。员工学一次,全员通用。 -

-
    -
  • - -
    - 提交后即锁定 - 从「草稿」进入「待审核」即不可编辑,避免审批人看到的与录入人提交的不一致。 -
    -
  • -
  • - -
    - 状态机由后端事务驱动 - 状态变更与库存 / 账款变更在同一事务内,要么都成功,要么都回滚。 -
    -
  • -
  • - -
    - 一套界面,四种单据复用 - 前端审批界面统一,节省培训成本,避免分模块状态差异。 -
    -
  • -
-
- -
-
-
-
入库单
-
出库单
-
盘点单
-
调拨单
-
-
stock_in_orders.status
- -
-
-
- 草稿 -
- draft -
- -
- 提交 - -
- -
-
- 待审核 -
- pending -
- -
- 审批 - -
- -
-
-
- 已审批 -
- approved -
-
-
- 已拒绝 -
- rejected -
-
-
- -
- 同一状态机适用于: - 入库单 - 出库单 - 盘点单 - 调拨单 -
-
-
-
-
- - -
-
-
-
自动联动
-

按一次"通过",
库存与账款都跟着动

-

- 审批通过不是一个"标记",而是一组真实的数据库写入。库存增减、应付应收、批次创建 —— 在同一事务内完成。 -

-
    -
  • - -
    - 事务级一致性 - 底层用 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 · 入库 · 茅台经销华东
-
茅台飞天 53°
+4
-
五粮液第八代
+20
-
剑南春水晶剑
+2
-
拉菲传奇
+10
-
合计
+36 · ¥48,400
-
- -
- - 红冲 -
- -
-
红 冲
-
RK20260524000001
-
2026-05-24 · 反向调整 · 关联 ...000007
-
茅台飞天 53°
−4
-
五粮液第八代
−20
-
剑南春水晶剑
−2
-
拉菲传奇
−10
-
合计
−36 · −¥48,400
-
-
- -
-
-
原单库存影响
-
+36 瓶
-
-
-
红冲库存影响
-
−36 瓶
-
-
-
净影响
-
0
-
-
-
-
-
- - -
-
-
-
权限矩阵
-

四级角色,
各干各的事

-

- 系统内置四个角色,按岗位职责拆分审批权。最小权限原则 —— 不该看的看不到,不该签的签不了。 -

-
    -
  • - -
    - 角色绑定门店 - 一个用户只属于一个门店(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 @@ - - - - - - -库存管理 · 岩美酒库管理系统 - - - - - - - - - - - - - - - - - -
-
-
-
- 首页 - - 产品功能 - - 库存管理 -
-

功能详情 · 库存管理

-

实时库存,
跨多仓追溯每一瓶酒。

-

- 从入库到售出,每一次库存变动都有记录、可追溯、可回放。多仓库统一管理、批次级追踪、账实差异自动核算 — 不止是库存表。 -

- -
-
-
SKU 上限
-
无限
-
-
-
仓库支持
-
多仓库
-
-
-
流水保留
-
永久
-
-
-
- -
- -
-
-
库存列表 · A 仓
-
-
列表
-
流水
-
盘点
-
-
-
-
-
库存总量
-
2,816↑ 4.2%
-
-
-
库存金额
-
¥486K↑ 2.8%
-
-
-
预警 SKU
-
7
-
-
-
-
-
SKU
商品
仓库
库存
批次
状态
-
-
-
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
-
告急
-
-
-
- - -
-
- - 低库存商品 -
-
-
轩尼诗 VSOP
-
仅 3 瓶
-
-
-
剑南春水晶剑
-
12 瓶
-
-
-
人头马 X.O
-
8 瓶
-
-
- 查看全部 7 条 → -
-
- - -
-
- - 批次追踪 · L-2026-05 -
-
-
入库 04-28
-
初始 50 瓶
-
A 仓
-
+50
-
-
-
出库 05-12
-
客户 · 春和宴
-
8 瓶
-
-8
-
-
-
出库 05-19
-
客户 · 北辰大酒店
-
12 瓶
-
-12
-
-
-
-
-
- - -
-
-
-
实时库存查询
-

不止是库存表,是一个可筛选的工作面板

-

- 库存数据自动跟随入库、出库、盘点变动 — 每一次写入都是一次事务,不存在「待同步」状态。 -

-
    -
  • - -
    - 多维筛选与列控制 - 按仓库、商品系列、库存状态、批次任意组合筛选,列可隐藏或重排。 -
    -
  • -
  • - -
    - 模糊搜索与拼音匹配 - 支持商品名、SKU、拼音首字母混合搜索,与你输入"飞天"同等结果。 -
    -
  • -
  • - -
    - 行内编辑备注 - 点击「备注」列即可弹出编辑框,不必进入详情页改字段。 -
    -
  • -
  • - -
    - 一键导出 Excel - 筛选后的视图与数据一同导出,可直接发给会计、税务。 -
    -
  • -
-
- -
-
-
-
库存列表 · 全部仓库
-
共 2,816 瓶 · ¥486,290
-
-
-
-
SKU
商品名称 / 规格
仓库
库存
金额
状态
-
-
-
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。 -
    -
  • -
-
- -
-
-
-
-
盘点商品
-
128
-
-
-
账面合计
-
2,816
-
-
-
实际合计
-
2,808
-
-
-
损耗金额
-
−¥6,840
-
-
-
-
商品
账面
实际
差异
损耗
-
-
-
茅台飞天 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 @@ - - - - - -库存管理 · 岩美酒库管理系统 - - - - - - - - - - - - -
-
-
-
- 首页 - - 产品功能 - - 库存管理 -
-

功能详情 · 库存管理

-

实时库存,
跨多仓追溯每一瓶酒。

-

- 从入库到售出,每一次库存变动都有记录、可追溯、可回放。多仓库统一管理、批次级追踪、账实差异自动核算 — 不止是库存表。 -

- -
-
-
SKU 上限
-
无限
-
-
-
仓库支持
-
多仓库
-
-
-
流水保留
-
永久
-
-
-
- -
- -
-
-
库存列表 · A 仓
-
-
列表
-
流水
-
盘点
-
-
-
-
-
库存总量
-
2,816↑ 4.2%
-
-
-
库存金额
-
¥486K↑ 2.8%
-
-
-
预警 SKU
-
7
-
-
-
-
-
SKU
商品
仓库
库存
批次
状态
-
-
-
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
-
告急
-
-
-
- - -
-
- - 库存预警 -
-
-
轩尼诗 VSOP
-
仅 3 瓶
-
-
-
剑南春水晶剑
-
12 瓶
-
-
-
人头马 X.O
-
8 瓶
-
-
- 查看全部 7 条 → -
-
- - -
-
- - 批次追踪 · L-2026-05 -
-
-
入库 04-28
-
初始 50 瓶
-
A 仓
-
+50
-
-
-
出库 05-12
-
客户 · 春和宴
-
8 瓶
-
-8
-
-
-
出库 05-19
-
客户 · 北辰大酒店
-
12 瓶
-
-12
-
-
-
-
-
- - -
-
-
-
实时库存查询
-

不止是库存表,是一个可筛选的工作面板

-

- 库存数据自动跟随入库、出库、盘点变动 — 每一次写入都是一次事务,不存在「待同步」状态。 -

-
    -
  • - -
    - 多维筛选与列控制 - 按仓库、商品系列、库存状态、批次任意组合筛选,列可隐藏或重排。 -
    -
  • -
  • - -
    - 模糊搜索与拼音匹配 - 支持商品名、SKU、拼音首字母混合搜索,与你输入"飞天"同等结果。 -
    -
  • -
  • - -
    - 行内编辑备注 - 点击「备注」列即可弹出编辑框,不必进入详情页改字段。 -
    -
  • -
  • - -
    - 一键导出 Excel - 筛选后的视图与数据一同导出,可直接发给会计、税务。 -
    -
  • -
-
- -
-
-
-
库存列表 · 全部仓库
-
共 2,816 瓶 · ¥486,290
-
-
-
-
SKU
商品名称 / 规格
仓库
库存
金额
状态
-
-
-
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。 -
    -
  • -
-
- -
-
-
-
-
盘点商品
-
128
-
-
-
账面合计
-
2,816
-
-
-
实际合计
-
2,808
-
-
-
损耗金额
-
−¥6,840
-
-
-
-
商品
账面
实际
差异
损耗
-
-
-
茅台飞天 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 瓶 · 短信通知 李建国(采购)
-
-
-
-
临近过期 30 天 · 邮件提醒
-
-
-
-
-
-
-
- - -
-
-

典型场景

-

- 库存管理,落到实际操作里是什么样 -

-

- 三个常见门店场景,看库存管理如何省下你每天的时间。 -

- -
-
- -

客户来取货前,先看一眼批次

-

客户要 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 单
-
-
-
预警 SKU
-
7
-
↓ 库存不足
-
-
-
-
-
SKU
商品名称
仓库
库存
金额
状态
-
-
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
-
支持多仓库独立管理
-
FIFO
批次先进先出追溯
-
0
需安装依赖,Web 即用
-
-
-
- - -
-
-

核心模块

-

全流程模块,覆盖酒水经营每一个环节

-

从商品建档到入库出库、从财务结算到扫码防伪,岩美一站式承载日常运营。

-
-
-
-

入库管理

-

审核驱动的入库流程。草稿、提交、审批、库存更新、应付账款生成 — 一气呵成。

-
    -
  • 多商品行明细录入,自动算金额
  • -
  • 审批通过即同步库存与应付账款
  • -
  • 支持单据打印、商品标签打印
  • -
  • 批次号追踪,生产日期可追溯
  • -
- 了解审核流详情 → -
-
-
-

出库管理

-

出库前自动校验库存,审批通过即扣减库存并生成应收。库存不足,单据无法通过。

-
-
-
-

库存管理

-

实时查询每个 SKU 的库存数量、所在仓库、批次。支持全仓盘点与库存流水。

- 了解更多 → -
-
-
-

财务管理

-

审批同步生成应付应收,月度汇总。结清一键完成,可按往来单位筛选导出。

-
-
-
-

往来单位

-

供应商与客户统一档案管理。卡号、初始余额、联系信息集中维护。

-
-
-
-

基础数据

-

商品名称、系列、规格三级字典,单品数量配置。先建字典,后录单据。

-
-
-
-

系统设置

-

用户与权限、多仓库、编号规则、参数与数据导入 — 自助配置,无需开发。

-
-
-
-

扫码防伪

-

每件商品生成专属二维码,顾客扫码可查看商品名称、批次、出售门店与「已通过岩美防伪验证」标识。

-
-
-
-
- - -
-
-

业务流程

-

审核驱动业务流,每一笔交易都可追溯

-

从单据录入到库存变动、账款生成,每一步都有清晰状态与经办人记录。

-
-
-

一张入库单的完整生命周期

-
-
1

录入草稿

选择仓库、供应商、入库日期,逐行录入商品明细。系统自动算金额,可随时保存草稿。

-
2

提交审核

确认无误后提交。单据进入「待审核」,此时不可再编辑,保证数据完整。

-
3

审批通过

操作员及以上权限审批。通过后库存自动 +N,同时生成一笔应付账款,账款挂在对应供应商名下。

-
4

结清账款

财务结算时一键「结清」,财务记录由「未结清」变为「已结清」。账目清晰,按月汇总。

-
-
-
-
-
-
RK20260523000007
-
已审批
-
-
商品
规格
数量
金额
-
茅台飞天
500ml×6
4
¥8,000
-
五粮液
500ml
20
¥18,400
-
剑南春
500ml×6
2
¥4,200
-
拉菲传奇
750ml
10
¥17,800
-
合计
36
¥48,400
-
-
-
-
库存更新
+36 瓶
-
应付账款
¥48,400
-
-
-
-
-
- - -
-
-

多端覆盖

-

一个账号,五端同步

-

办公室开台账,仓库扫码盘点,老板出差查看实时报表。同一份数据,任意终端访问。

-
-

Web

主流浏览器,无需安装

-

Windows

桌面客户端

-

macOS

原生应用

-

iOS

TestFlight 内测中

-

Android

手机 / 平板

-
-
-
-

仓库里也能用得顺手

-

移动端扫码即查库存、入库、出库,断网也能继续工作,恢复网络后自动同步。

-
-
扫码即查商品 · 商品二维码防伪
-
离线缓存,断网继续作业
-
窄屏自动切换卡片流布局
-
手机拍照直接添加商品图片
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - -
-
-

数据洞察

-

把每一瓶酒的进出,转成可分析的数据

-

报表自动生成,可按日、按月、按往来单位维度切换。导出 Excel 一键完成。

-
-
-
-

本月财务流水

2026 年 5 月 · 应付与应收对比

-
-
-
-
-
-
-
- - - - - - - - - - - - -
- 应付账款 - 应收账款 -
-
-
-
本月应付
¥248,600↑ 12%
-
本月应收
¥182,400↑ 8%
-
已结清率
86.4%↑ 4%
-
-
-
-
-
-

财务汇总自动生成

每月应付应收按往来单位、按时间维度自动汇总。无需手工统计,月底一键导出。

-
-
-
-

损耗趋势可见

每次盘点的账实差异自动累计为损耗数据,结合周转率帮你发现异常 SKU。

-
-
-
-

Excel 进出自由

历史数据可批量导入,月度报表可一键导出。与会计、税务系统无缝衔接。

-
-
-
-

库存状态实时标识

设置商品最低库存量,系统自动标记「充足 / 偏低 / 告急」,一眼识别需补货的 SKU。

-
-
-
-
-
- - -
-
-

安全与权限

-

企业级权限控制,操作可追溯

-

四级角色细分权限,每一次单据操作都有经办人记录。数据备份与审批留痕,按合规要求设计。

-
-

四级角色

超级管理员、管理员、操作员、只读。按岗位分配,最小权限原则。

-

操作审计

谁、什么时候、做了什么 — 每一笔单据都有经办人与时间戳。

-

审批留痕

审批不可撤销,单据从草稿到结清的每一步状态完整保留。

-

每日备份

云端每日自动备份,支持私有部署。数据始终在你的控制下。

-
-
-
-
角色
说明
-
查看
录入
-
审批
设置
-
-
超级管理员
系统最高权限,含数据清空
-
管理员
门店管理人员,含用户与门店设置
-
操作员
日常录入与审核单据
-
只读
仅查看,不可修改任何数据
-
-
-
- - -
-
-

价格

-

按门店付费,明码标价

-

所有方案均含全功能、五端同步、无限单据、技术支持。仅在用户数、门店数、部署方式上区分。

-
-
-
入门方案
-
试用版
-
¥0/ 30 天
-
体验完整功能,无需绑卡
-
    -
  • 单门店 · 最多 3 用户
  • -
  • 全部模块开放
  • -
  • Web 端 + 移动端
  • -
  • 微信技术支持
  • -
- 免费开通 -
- -
-
企业方案
-
连锁版
-
定制
-
多门店连锁,含私有部署
-
    -
  • 多门店 · 总部数据汇总
  • -
  • 不限用户数
  • -
  • 支持私有部署 / 内网
  • -
  • 专属实施与培训服务
  • -
  • 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 @@ - - - - - - -隐私政策 · 岩美酒库管理系统 - - - - - - - - - - - - - - - - - - - - - - - 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 @@ - - - - - - -注册新门店 · 岩美酒库管理系统 - - - - - - - - - - - - - - - - -
-
- -
-

注册新门店

-

填写以下信息,创建您的专属酒库管理账户。注册完成后系统自动分配门店编码,用于登录客户端。

-
- - -
-
- - -
- 门店信息 - -
- - -
-
您的酒行正式名称,将显示在所有单据和商品标签上
-
- -
- - -
-
实际经营地址,打印标签和单据时展示
-
- -
- - -
-
主要负责人真实姓名,也是首个管理员账号的姓名
-
- -
- - -
-
对外联系电话,打印商品标签时会展示
-
- -
- - -
选填,最多 200 字;客户扫商品码时可见
-
-
- - -
- 管理员账号 - -
- - -
-
登录账号(3–30 位,字母/数字/下划线),创建后不可修改
-
- -
- - -
-
至少 6 位,建议字母数字组合
-
- -
- - -
-
-
- - - -
- - -
-
- -
-

注册成功!

-

您的门店编码已生成,请妥善保存:

- -
- - -
- - - -

如忘记门店编码,登录后可在「系统设置」中查看。

- - 前往登录 - -
- -
-
- - - - - - - - - - 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 @@ - - - - - - -服务条款 · 岩美酒库管理系统 - - - - - - - - - - - - - - - - - - - - - - - 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 ---- - - -
-
-
-
- 首页 - - 产品功能 - - 审核驱动业务流 -
-

功能详情 · 审核驱动业务流

-

每一笔库存变动,
都经过一次签字

-

- 入库、出库、盘点 —— 统一状态机,统一审批界面。审批不仅是一个按钮,是一笔事务:库存动了,账款也跟着动,全程留痕。 -

- -
-
-
单据类型
-
3 种
-
-
-
操作留痕
-
全程
-
-
-
审批联动
-
库存 + 账款
-
-
-
- -
-
-
- -
-
-
-
RK20260523000007
-
入库审核 · A 仓 · 茅台经销华东
-
-
- 已审批 -
-
- -
-
茅台飞天 53° · 500ml×6
4
¥8,000
-
-
-
五粮液第八代 52°
20
¥18,400
-
-
-
剑南春水晶剑 52°
2
¥4,200
-
-
-
拉菲传奇波尔多
10
¥17,800
-
- -
-
合计
36
¥48,400
-
- -
- 审批人 李建国 · 2026-05-23 14:32:08 -
-
- - -
- - - - - - 岩美 · 审批通过 · APPROVED · 岩美 · - - -
-
通过
-
APPROVED
-
2026.05.23
-
-
-
-
-
- - -
-
-
-
统一状态机
-

四种状态,
覆盖所有业务单据

-

- 入库、出库、盘点 —— 全部走同一套状态:草稿 → 待审核 → 已审批 / 已拒绝。员工学一次,全员通用。 -

-
    -
  • - -
    - 提交后即锁定 - 从「草稿」进入「待审核」即不可编辑,避免审批人看到的与录入人提交的不一致。 -
    -
  • -
  • - -
    - 状态机由后端事务驱动 - 状态变更与库存 / 账款变更在同一事务内,要么都成功,要么都回滚。 -
    -
  • -
  • - -
    - 一套界面,三种单据复用 - 前端审批界面统一,节省培训成本,避免分模块状态差异。 -
    -
  • -
-
- -
-
-
-
入库单
-
出库单
-
盘点单
-
-
stock_in_orders.status
- -
-
-
- 草稿 -
- draft -
- -
- 提交 - -
- -
-
- 待审核 -
- pending -
- -
- 审批 - -
- -
-
-
- 已审批 -
- approved -
-
-
- 已拒绝 -
- rejected -
-
-
- -
- 同一状态机适用于: - 入库单 - 出库单 - 盘点单 -
-
-
-
-
- - -
-
-
-
自动联动
-

按一次"通过",
库存与账款都跟着动

-

- 审批通过不是一个"标记",而是一组真实的数据库写入。库存增减、应付应收、批次创建 —— 在同一事务内完成。 -

-
    -
  • - -
    - 事务级一致性 - 底层用 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 ---- - - -
-
-
-
- 首页 - - 产品功能 - - 库存管理 -
-

功能详情 · 库存管理

-

实时库存,
跨多仓追溯每一瓶酒。

-

- 从入库到售出,每一次库存变动都有记录、可追溯、可回放。多仓库统一管理、批次级追踪、账实差异自动核算 — 不止是库存表。 -

- -
-
-
SKU 上限
-
无限
-
-
-
仓库支持
-
多仓库
-
-
-
流水保留
-
永久
-
-
-
- -
- -
-
-
库存列表 · A 仓
-
-
列表
-
流水
-
盘点
-
-
-
-
-
库存总量
-
2,816↑ 4.2%
-
-
-
库存金额
-
¥486K↑ 2.8%
-
-
-
预警 SKU
-
7
-
-
-
-
-
SKU
商品
仓库
库存
批次
状态
-
-
-
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
-
告急
-
-
-
- - -
-
- - 低库存商品 -
-
-
轩尼诗 VSOP
-
仅 3 瓶
-
-
-
剑南春水晶剑
-
12 瓶
-
-
-
人头马 X.O
-
8 瓶
-
-
- 查看全部 7 条 → -
-
- - -
-
- - 批次追踪 · L-2026-05 -
-
-
入库 04-28
-
初始 50 瓶
-
A 仓
-
+50
-
-
-
出库 05-12
-
客户 · 春和宴
-
8 瓶
-
-8
-
-
-
出库 05-19
-
客户 · 北辰大酒店
-
12 瓶
-
-12
-
-
-
-
-
- - -
-
-
-
实时库存查询
-

不止是库存表,是一个可筛选的工作面板

-

- 库存数据自动跟随入库、出库、盘点变动 — 每一次写入都是一次事务,不存在「待同步」状态。 -

-
    -
  • - -
    - 多维筛选与列控制 - 按仓库、商品系列、库存状态、批次任意组合筛选,列可隐藏或重排。 -
    -
  • -
  • - -
    - 模糊搜索与拼音匹配 - 支持商品名、SKU、拼音首字母混合搜索,与你输入"飞天"同等结果。 -
    -
  • -
  • - -
    - 行内编辑备注 - 点击「备注」列即可弹出编辑框,不必进入详情页改字段。 -
    -
  • -
  • - -
    - 一键导出 Excel - 筛选后的视图与数据一同导出,可直接发给会计、税务。 -
    -
  • -
-
- -
-
-
-
库存列表 · 全部仓库
-
共 2,816 瓶 · ¥486,290
-
-
-
-
SKU
商品名称 / 规格
仓库
库存
金额
状态
-
-
-
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。 -
    -
  • -
-
- -
-
-
-
-
盘点商品
-
128
-
-
-
账面合计
-
2,816
-
-
-
实际合计
-
2,808
-
-
-
损耗金额
-
−¥6,840
-
-
-
-
商品
账面
实际
差异
损耗
-
-
-
茅台飞天 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 天免费试用,无需绑定支付。所有数据云端实时备份,可随时导出。

-
- -
-
-
-