feat: 更新商城前后端代码

This commit is contained in:
2026-04-01 22:03:59 +08:00
parent f6e150ba97
commit 1f16966174
34 changed files with 3014 additions and 5538 deletions

View File

@@ -3,7 +3,7 @@ setlocal
cd /d "%~dp0"
rem Backend (new window)
start "mengyastore-backend" cmd /k "cd /d ""%~dp0mengyastore-backend"" && go run ."
start "mengyastore-backend-go" cmd /k "cd /d ""%~dp0mengyastore-backend-go"" && go run ."
rem Frontend (new window)
start "mengyastore-frontend" cmd /k "cd /d ""%~dp0mengyastore-frontend"" && npm run dev"

View File

@@ -12,8 +12,10 @@ require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
@@ -31,8 +33,10 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/net v0.24.0 // indirect

View File

@@ -4,6 +4,8 @@ github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
@@ -12,6 +14,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
@@ -64,6 +68,8 @@ github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtos
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -82,6 +88,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=

View File

@@ -50,8 +50,8 @@ type ProductRow struct {
TotalSold int `gorm:"default:0"`
ViewCount int `gorm:"default:0"`
DeliveryMode string `gorm:"size:20;default:'auto'"`
ShowNote bool `gorm:"default:false"`
ShowContact bool `gorm:"default:false"`
ShowNote bool `gorm:"default:true"`
ShowContact bool `gorm:"default:true"`
CreatedAt time.Time `gorm:"index"`
}

View File

@@ -1,11 +1,13 @@
package handlers
import (
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"mengyastore-backend/internal/cache"
"mengyastore-backend/internal/models"
)
@@ -30,7 +32,19 @@ type togglePayload struct {
Active bool `json:"active"`
}
// VerifyAdminToken 验证管理员令牌是否正确,返回 {"valid": true/false},不暴露实际令牌值。
// invalidateProductCache removes the cached product list so that the next
// public request rebuilds it from the freshest DB state (Write-Invalidate).
func (h *AdminHandler) invalidateProductCache(c *gin.Context) {
if h.cache == nil {
return
}
if err := h.cache.Del(c.Request.Context(), cache.KeyProductList); err != nil {
log.Printf("[cache] DEL %s error: %v", cache.KeyProductList, err)
}
}
// VerifyAdminToken checks whether the supplied token is correct.
// Returns {"valid": true/false} without leaking the real token value.
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
var payload struct {
Token string `json:"token"`
@@ -84,7 +98,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
Active: active,
RequireLogin: payload.RequireLogin,
MaxPerAccount: payload.MaxPerAccount,
DeliveryMode: payload.DeliveryMode,
DeliveryMode: "auto",
ShowNote: payload.ShowNote,
ShowContact: payload.ShowContact,
}
@@ -93,6 +107,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.invalidateProductCache(c) // Write-Invalidate: clear stale cache
c.JSON(http.StatusOK, gin.H{"data": created})
}
@@ -127,7 +142,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
Active: active,
RequireLogin: payload.RequireLogin,
MaxPerAccount: payload.MaxPerAccount,
DeliveryMode: payload.DeliveryMode,
DeliveryMode: "auto",
ShowNote: payload.ShowNote,
ShowContact: payload.ShowContact,
}
@@ -136,6 +151,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
h.invalidateProductCache(c)
c.JSON(http.StatusOK, gin.H{"data": updated})
}
@@ -154,6 +170,7 @@ func (h *AdminHandler) ToggleProduct(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
h.invalidateProductCache(c)
c.JSON(http.StatusOK, gin.H{"data": updated})
}
@@ -166,6 +183,7 @@ func (h *AdminHandler) DeleteProduct(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.invalidateProductCache(c)
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
}

View File

@@ -1,295 +1,529 @@
package handlers
import (
"fmt"
"log"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"mengyastore-backend/internal/auth"
"mengyastore-backend/internal/email"
"mengyastore-backend/internal/models"
"mengyastore-backend/internal/storage"
)
const qrSize = "320x320"
type OrderHandler struct {
productStore *storage.ProductStore
orderStore *storage.OrderStore
siteStore *storage.SiteStore
authClient *auth.SproutGateClient
}
type checkoutPayload struct {
ProductID string `json:"productId"`
Quantity int `json:"quantity"`
Note string `json:"note"`
ContactPhone string `json:"contactPhone"`
ContactEmail string `json:"contactEmail"`
NotifyEmail string `json:"notifyEmail"`
}
func NewOrderHandler(productStore *storage.ProductStore, orderStore *storage.OrderStore, siteStore *storage.SiteStore, authClient *auth.SproutGateClient) *OrderHandler {
return &OrderHandler{productStore: productStore, orderStore: orderStore, siteStore: siteStore, authClient: authClient}
}
func (h *OrderHandler) sendOrderNotify(toEmail, toName, productName, orderID string, qty int, codes []string, isManual bool) {
if toEmail == "" {
return
}
cfg, err := h.siteStore.GetSMTPConfig()
if err != nil || !cfg.IsConfiguredEmail() {
return
}
go func() {
emailCfg := email.Config{
SMTPHost: cfg.Host,
SMTPPort: cfg.Port,
From: cfg.Email,
Password: cfg.Password,
FromName: cfg.FromName,
}
if err := email.SendOrderNotify(emailCfg, email.OrderNotifyData{
ToEmail: toEmail,
ToName: toName,
ProductName: productName,
OrderID: orderID,
Quantity: qty,
Codes: codes,
IsManual: isManual,
}); err != nil {
log.Printf("[Email] 发送通知失败 order=%s to=%s: %v", orderID, toEmail, err)
} else {
log.Printf("[Email] 发送通知成功 order=%s to=%s", orderID, toEmail)
}
}()
}
func (h *OrderHandler) tryExtractUserWithEmail(c *gin.Context) (account, username, userEmail string) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
return "", "", ""
}
userToken := strings.TrimPrefix(authHeader, "Bearer ")
result, err := h.authClient.VerifyToken(userToken)
if err != nil || !result.Valid || result.User == nil {
return "", "", ""
}
return result.User.Account, result.User.Username, result.User.Email
}
func (h *OrderHandler) CreateOrder(c *gin.Context) {
userAccount, userName, userEmail := h.tryExtractUserWithEmail(c)
var payload checkoutPayload
if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return
}
payload.ProductID = strings.TrimSpace(payload.ProductID)
if payload.ProductID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少必填字段"})
return
}
if payload.Quantity <= 0 {
payload.Quantity = 1
}
product, err := h.productStore.GetByID(payload.ProductID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
if !product.Active {
c.JSON(http.StatusBadRequest, gin.H{"error": "商品暂时无法购买"})
return
}
if product.RequireLogin && userAccount == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "该商品需要登录后才能购买"})
return
}
if product.MaxPerAccount > 0 && userAccount != "" {
purchased, err := h.orderStore.CountPurchasedByAccount(userAccount, product.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if purchased+payload.Quantity > product.MaxPerAccount {
remain := product.MaxPerAccount - purchased
if remain <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("每个账户最多购买 %d 个,您已达上限", product.MaxPerAccount)})
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("每个账户最多购买 %d 个,您还可购买 %d 个", product.MaxPerAccount, remain)})
}
return
}
}
if product.Quantity < payload.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "库存不足"})
return
}
isManual := product.DeliveryMode == "manual"
var deliveredCodes []string
var updatedProduct models.Product
if isManual {
updatedProduct = product
} else {
var ok bool
deliveredCodes, ok = extractCodes(&product, payload.Quantity)
deliveredCodes, ok := extractCodes(&product, payload.Quantity)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "卡密不足"})
return
}
product.Quantity = len(product.Codes)
updatedProduct, err = h.productStore.Update(product.ID, product)
updatedProduct, err := h.productStore.Update(product.ID, product)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
deliveryMode := product.DeliveryMode
if deliveryMode == "" {
deliveryMode = "auto"
}
// 通知邮箱优先级:
// 1. SproutGate 账号邮箱(已登录用户,最可靠)
// 2. 前端传入的 notifyEmail来自 authState.email
// 3. 用户在结账表单填写的联系邮箱
// 4. 均为空则不发送
deliveryMode := "auto"
notifyEmail := strings.TrimSpace(userEmail)
if notifyEmail == "" {
notifyEmail = strings.TrimSpace(payload.NotifyEmail)
}
if notifyEmail == "" {
notifyEmail = strings.TrimSpace(payload.ContactEmail)
}
// 自动发货订单立即设为已完成(卡密已提取);手动发货订单初始状态为待处理,由管理员确认。
orderStatus := "pending"
if !isManual {
orderStatus = "completed"
if notifyEmail == "" {
notifyEmail = strings.TrimSpace(payload.ContactEmail)
}
order := models.Order{
ProductID: updatedProduct.ID,
ProductName: updatedProduct.Name,
UserAccount: userAccount,
UserName: userName,
Quantity: payload.Quantity,
DeliveredCodes: deliveredCodes,
Status: orderStatus,
Status: "completed",
DeliveryMode: deliveryMode,
Note: strings.TrimSpace(payload.Note),
ContactPhone: strings.TrimSpace(payload.ContactPhone),
ContactEmail: strings.TrimSpace(payload.ContactEmail),
NotifyEmail: notifyEmail,
}
created, err := h.orderStore.Create(order)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !isManual {
created, err := h.orderStore.Create(order)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.productStore.IncrementSold(updatedProduct.ID, payload.Quantity); err != nil {
log.Printf("[Order] 更新销量失败 (非致命): %v", err)
}
// 自动发货:立即发送卡密通知邮件
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, deliveredCodes, false)
} else {
// 手动发货:告知用户订单已收到,等待发货
h.sendOrderNotify(notifyEmail, userName, updatedProduct.Name, created.ID, payload.Quantity, nil, true)
}
qrPayload := fmt.Sprintf("order:%s:%s", created.ID, created.ProductID)
qrURL := fmt.Sprintf("https://api.qrserver.com/v1/create-qr-code/?size=%s&data=%s", qrSize, url.QueryEscape(qrPayload))
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"orderId": created.ID,
"qrCodeUrl": qrURL,
"productId": created.ProductID,
"productQty": created.Quantity,
"viewCount": updatedProduct.ViewCount,
"status": created.Status,
},
})
}
func (h *OrderHandler) ConfirmOrder(c *gin.Context) {
orderID := c.Param("id")
order, err := h.orderStore.Confirm(orderID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
isManual := order.DeliveryMode == "manual"
// 手动发货确认后,发送"已发货"通知邮件
if isManual {
confirmNotifyEmail := order.NotifyEmail
if confirmNotifyEmail == "" {
confirmNotifyEmail = order.ContactEmail
}
h.sendOrderNotify(confirmNotifyEmail, order.UserName, order.ProductName, order.ID, order.Quantity, order.DeliveredCodes, false)
}
c.JSON(http.StatusOK, gin.H{
"data": gin.H{
"orderId": order.ID,
"status": order.Status,
"deliveryMode": order.DeliveryMode,
"deliveredCodes": order.DeliveredCodes,
"isManual": isManual,
},
})
}
func (h *OrderHandler) ListMyOrders(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
userToken := strings.TrimPrefix(authHeader, "Bearer ")
result, err := h.authClient.VerifyToken(userToken)
if err != nil || !result.Valid || result.User == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return
}
orders, err := h.orderStore.ListByAccount(result.User.Account)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"data": orders})
}
func extractCodes(product *models.Product, count int) ([]string, bool) {
if count <= 0 {
return nil, false
}
if len(product.Codes) < count {
return nil, false
}
delivered := make([]string, count)
copy(delivered, product.Codes[:count])
product.Codes = product.Codes[count:]
return delivered, true
}

View File

@@ -240,6 +240,32 @@ func (s *ProductStore) IncrementView(id, fingerprint string) (models.Product, bo
return rowToModel(row, nil), true, nil
}
// IncrementViewCount atomically increments view_count by 1 and returns the
// updated product (without codes). It is called by the handler when Redis-based
// deduplication has already confirmed this is a new view, so no in-memory lock
// is needed here.
func (s *ProductStore) IncrementViewCount(id string) (models.Product, error) {
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).
UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error; err != nil {
return models.Product{}, err
}
var row database.ProductRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, fmt.Errorf("product not found")
}
return rowToModel(row, nil), nil
}
// GetViewState returns the current product state without modifying any counter.
// Used by the handler when a view is detected as duplicate via Redis.
func (s *ProductStore) GetViewState(id string) (models.Product, error) {
var row database.ProductRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, fmt.Errorf("product not found")
}
return rowToModel(row, nil), nil
}
func (s *ProductStore) Delete(id string) error {
if err := s.db.Where("product_id = ?", id).Delete(&database.ProductCodeRow{}).Error; err != nil {
return err
@@ -272,7 +298,7 @@ func normalizeProduct(item models.Product) models.Product {
item.VerificationURL = strings.TrimSpace(item.VerificationURL)
item.Codes = sanitizeCodes(item.Codes)
item.Quantity = len(item.Codes)
if item.DeliveryMode == "" {
if item.DeliveryMode == "" || item.DeliveryMode == "manual" {
item.DeliveryMode = "auto"
}
return item

View File

@@ -15,8 +15,10 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^5.0.4",
"tailwindcss": "^4.2.2",
"vite": "^5.2.7",
"vite-plugin-pwa": "^1.2.0"
}
@@ -2925,6 +2927,290 @@
"sourcemap-codec": "^1.4.8"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
"integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "^5.19.0",
"jiti": "^2.6.1",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.2.2"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
"integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-arm64": "4.2.2",
"@tailwindcss/oxide-darwin-x64": "4.2.2",
"@tailwindcss/oxide-freebsd-x64": "4.2.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
"@tailwindcss/oxide-linux-x64-musl": "4.2.2",
"@tailwindcss/oxide-wasm32-wasi": "4.2.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
"integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
"integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
"integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
"integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
"integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
"integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
"integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
"integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
"integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
"integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.8.1",
"@emnapi/runtime": "^1.8.1",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.1.1",
"@tybys/wasm-util": "^0.10.1",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
"integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
"integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz",
"integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.2.2",
"@tailwindcss/oxide": "4.2.2",
"tailwindcss": "4.2.2"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3799,6 +4085,20 @@
"dev": true,
"license": "ISC"
},
"node_modules/enhanced-resolve": {
"version": "5.20.1",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
"integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.0"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
@@ -5034,6 +5334,279 @@
"node": ">=6"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -6152,6 +6725,27 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
"integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
"integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/temp-dir": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",

View File

@@ -16,8 +16,10 @@
"vue-router": "^4.3.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^5.0.4",
"tailwindcss": "^4.2.2",
"vite": "^5.2.7",
"vite-plugin-pwa": "^1.2.0"
}

View File

@@ -1,65 +1,105 @@
<template>
<div class="app">
<div class="relative flex flex-col min-h-screen">
<!-- Random background -->
<div class="app-randbg" aria-hidden="true">
<img
v-if="randBgUrl"
class="app-randbg-img"
:src="randBgUrl"
alt=""
decoding="async"
fetchpriority="low"
/>
</div>
<!-- PWA Splash Screen -->
<SplashScreen :visible="showSplash" />
<header class="top-bar">
<div class="brand" @click="onLogoClick">
<div class="brand-mark">
<img src="/logo.png" alt="萌芽小店" draggable="false" />
<!-- Top navigation bar -->
<header class="flex items-center justify-between gap-5 px-[5vw] py-4 glass-strong border-b border-white/35 shadow-[0_4px_24px_rgba(33,33,40,0.08)] sticky top-0 z-[100] max-md:flex-row max-md:flex-nowrap max-md:px-[3vw] max-md:py-2 max-md:gap-1.5">
<!-- Brand -->
<div class="flex gap-4 items-center cursor-pointer select-none max-md:gap-2 max-md:flex-shrink-0" @click="onLogoClick">
<div class="w-[46px] h-[46px] rounded-[10px] overflow-hidden border border-white/60 glass-strong shadow-[0_12px_24px_rgba(33,33,40,0.18)] flex items-center justify-center max-md:w-8 max-md:h-8">
<img src="/logo.png" alt="萌芽小店" draggable="false" class="w-full h-full object-contain" />
</div>
<div>
<h1>萌芽小店</h1>
<h1 class="text-[28px] tracking-wide max-md:text-base max-md:whitespace-nowrap">萌芽小店</h1>
</div>
</div>
<div class="top-actions">
<!-- Actions -->
<div class="flex gap-3 items-center max-md:gap-1 max-md:flex-shrink-0 max-md:overflow-hidden">
<button class="ghost" @click="goHome">商店</button>
<template v-if="loggedIn">
<button class="ghost wishlist-nav" @click="goWishlist">
<span class="wishlist-icon"></span>
<button class="ghost relative inline-flex items-center gap-1" @click="goWishlist">
<span class="max-md:text-xs"></span>
收藏夹
<span v-if="wishlistCount > 0" class="wishlist-badge">{{ wishlistCount }}</span>
<span
v-if="wishlistCount > 0"
class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[11px] font-bold leading-none max-md:min-w-[14px] max-md:h-[14px] max-md:text-[9px]"
>{{ wishlistCount }}</span>
</button>
<button class="ghost" @click="goMyOrders">我的订单</button>
<div class="user-badge" @click="goProfile">
<div
class="flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/60 border border-white/35 cursor-pointer hover:bg-white/85 transition-colors max-md:px-1.5 max-md:gap-1"
@click="goProfile"
>
<img
v-if="authState.avatarUrl"
class="user-avatar"
class="w-7 h-7 rounded-full object-cover border border-white/35 max-md:w-5 max-md:h-5"
:src="authState.avatarUrl"
:alt="authState.username"
/>
<div v-else class="user-avatar user-avatar-placeholder">
<div
v-else
class="w-7 h-7 rounded-full flex items-center justify-center bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold border border-white/35 max-md:w-5 max-md:h-5 max-md:text-xs"
>
{{ (authState.username || authState.account || '?').charAt(0) }}
</div>
<span class="user-name">{{ authState.username || authState.account }}</span>
<span class="text-[17px] font-bold text-apptext max-w-[100px] overflow-hidden text-ellipsis whitespace-nowrap max-md:text-xs max-md:max-w-[50px]">
{{ authState.username || authState.account }}
</span>
</div>
<button class="ghost" @click="logout">退出</button>
</template>
<a v-else class="login-btn" :href="loginUrl">萌芽账号登录</a>
<a
v-else
class="inline-flex items-center px-[18px] py-[10px] rounded-lg bg-gradient-to-br from-accent to-accent2 text-white no-underline text-[17px] shadow-[0_10px_30px_rgba(145,168,208,0.35)] transition-transform hover:-translate-y-px max-md:text-xs max-md:px-2 max-md:py-1.5 max-md:whitespace-nowrap"
:href="loginUrl"
>萌芽账号登录</a>
</div>
</header>
<!-- Admin modal -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showAdminModal" class="admin-modal-overlay" @click.self="showAdminModal = false">
<div class="admin-modal">
<button class="admin-modal-close" @click="showAdminModal = false">&times;</button>
<div class="admin-modal-icon">
<div
v-if="showAdminModal"
class="admin-modal fixed inset-0 z-[1000] flex items-center justify-center bg-apptext/35 backdrop-blur-lg"
@click.self="showAdminModal = false"
>
<div class="admin-modal relative w-[360px] max-w-[90vw] px-8 pt-9 pb-7 glass-strong rounded-xl shadow-[0_24px_60px_rgba(33,33,40,0.22)] text-center">
<button
class="absolute top-3 right-4 bg-transparent border-none text-[22px] text-muted hover:text-apptext cursor-pointer px-2 py-1 rounded leading-none transition-colors"
@click="showAdminModal = false"
>&times;</button>
<div class="inline-flex items-center justify-center w-14 h-14 rounded-[10px] bg-gradient-to-br from-accent to-accent2 text-white mb-4">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</div>
<h3>管理员验证</h3>
<p class="admin-modal-hint">请输入管理员令牌以访问后台</p>
<h3 class="text-[22px] mb-1.5">管理员验证</h3>
<p class="text-[15px] text-muted mb-5">请输入管理员令牌以访问后台</p>
<form @submit.prevent="submitAdminToken">
<input
ref="tokenInputRef"
v-model="tokenInput"
type="password"
class="admin-modal-input"
class="w-full px-4 py-3 rounded-lg border border-white/35 bg-white/65 text-center tracking-widest text-[18px] outline-none transition focus:border-accent focus:shadow-[0_0_0_3px_rgba(180,154,203,0.18)]"
style="font-family: var(--font-serif);"
placeholder="输入 Token…"
autocomplete="off"
/>
<p v-if="tokenError" class="admin-modal-error">{{ tokenError }}</p>
<button type="submit" class="primary admin-modal-btn" :disabled="!tokenInput.trim()">
<p v-if="tokenError" class="text-[15px] text-[#d4566a] mt-2">{{ tokenError }}</p>
<button type="submit" class="primary w-full mt-4 py-3 text-[17px]" :disabled="!tokenInput.trim()">
进入后台
</button>
</form>
@@ -68,38 +108,49 @@
</Transition>
</Teleport>
<div class="app-body">
<main class="main-content">
<!-- Page body -->
<div class="flex-1 flex flex-col px-[5vw] py-7 max-md:px-[2vw] max-md:py-3">
<main class="flex-1 py-7 min-h-0">
<router-view />
</main>
<footer class="footer">
<div class="footer-inner">
<div class="footer-brand">
<img src="/logo.png" alt="萌芽小店" class="footer-logo" />
<span class="footer-title">萌芽小店</span>
<!-- Footer -->
<footer class="mt-3 px-[26px] py-6 glass rounded-[var(--radius-default)] shadow-[0_-4px_30px_rgba(33,33,40,0.06)] max-md:px-3.5 max-md:py-5">
<div class="flex flex-col items-center gap-3.5">
<!-- Brand -->
<div class="flex items-center gap-2.5">
<img src="/logo.png" alt="萌芽小店" class="w-7 h-7 rounded-[5px] object-contain border border-white/50 shadow-[0_4px_12px_rgba(33,33,40,0.1)]" />
<span class="text-[19px] font-semibold text-apptext tracking-[0.5px]" style="font-family: var(--font-serif);">萌芽小店</span>
</div>
<div class="footer-divider"></div>
<div class="footer-links">
<a class="footer-mail" href="mailto:mail@smyhub.com">
<!-- Divider -->
<div class="w-10 h-0.5 rounded-full bg-gradient-to-r from-accent to-accent2 opacity-50"></div>
<!-- Links / stats -->
<div class="flex items-center gap-6 flex-wrap justify-center max-md:flex-row max-md:flex-wrap max-md:gap-2.5 max-md:justify-center">
<a
class="inline-flex items-center gap-1.5 text-[#3d5a88] no-underline text-[15px] font-semibold px-3 py-1.5 rounded bg-[rgba(145,168,208,0.12)] hover:bg-[rgba(145,168,208,0.22)] transition-colors"
href="mailto:mail@smyhub.com"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
mail@smyhub.com
</a>
<span v-if="statsLoaded" class="footer-stat">
<span v-if="statsLoaded" class="inline-flex items-center gap-1.5 text-[15px] font-semibold text-[#6b4d85] px-3 py-1.5 rounded bg-[rgba(180,154,203,0.12)]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>
累计下单 {{ stats.totalOrders }}
</span>
<span v-if="statsLoaded" class="footer-stat footer-stat-visit">
<span v-if="statsLoaded" class="inline-flex items-center gap-1.5 text-[15px] font-semibold text-[#3d5a88] px-3 py-1.5 rounded bg-[rgba(145,168,208,0.12)]">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
累计访问 {{ stats.totalVisits }}
</span>
</div>
<p class="footer-copy">© 2025 2026 萌芽小店 · All rights reserved</p>
<p class="text-[14px] text-[#45424a]/95 tracking-[0.3px]">© 2025 2026 萌芽小店 · All rights reserved</p>
</div>
</footer>
</div>
<!-- Floating chat widget (visible to logged-in users only) -->
<!-- Floating chat widget -->
<ChatWidget
v-if="loggedIn && authState.token"
:user-token="authState.token"
@@ -109,9 +160,15 @@
<!-- PWA update toast -->
<Transition name="pwa-toast">
<div v-if="needRefresh" class="pwa-update-toast">
<div
v-if="needRefresh"
class="fixed bottom-20 left-1/2 -translate-x-1/2 z-[9999] flex items-center gap-3 px-[18px] py-2.5 rounded-3xl bg-[#1a1a1a] text-white text-[13px] shadow-[0_4px_24px_rgba(0,0,0,0.25)] whitespace-nowrap"
>
<span>发现新版本</span>
<button @click="updateServiceWorker(true)">立即更新</button>
<button
class="bg-[#4ade80] text-[#111] border-none rounded-xl px-3.5 py-1 text-xs font-semibold cursor-pointer"
@click="updateServiceWorker(true)"
>立即更新</button>
</div>
</Transition>
</div>
@@ -127,6 +184,8 @@ import ChatWidget from './modules/chat/ChatWidget.vue'
import SplashScreen from './modules/shared/SplashScreen.vue'
import { useRegisterSW } from 'virtual:pwa-register/vue'
const RAND_BG_JSON_URL = 'https://randbg.api.smyhub.com/api/random?format=json'
const { needRefresh, updateServiceWorker } = useRegisterSW({
onRegisteredSW(swUrl, r) {
if (r) setInterval(() => r.update(), 1000 * 60 * 60)
@@ -134,6 +193,7 @@ const { needRefresh, updateServiceWorker } = useRegisterSW({
})
const showSplash = ref(true)
const randBgUrl = ref('')
const router = useRouter()
const stats = reactive({ totalOrders: 0, totalVisits: 0 })
@@ -200,6 +260,13 @@ const SPLASH_MIN_MS = 1400
onMounted(async () => {
const splashStart = Date.now()
fetch(RAND_BG_JSON_URL)
.then((r) => r.json())
.then((data) => {
if (data && typeof data.url === 'string') randBgUrl.value = data.url
})
.catch(() => {})
await loadWishlist()
try {
recordSiteVisit().then((result) => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,42 @@
<template>
<section class="admin-layout">
<section class="flex glass border border-white/35 rounded-[var(--radius-default)] shadow-[var(--shadow-card)] backdrop-blur-[16px] overflow-hidden min-h-[calc(100vh-120px)] max-md:flex-col max-md:min-h-auto">
<!-- Sidebar -->
<nav class="admin-sidebar">
<div class="sidebar-brand">
<span>管理后台</span>
<nav class="w-[180px] flex-shrink-0 bg-white/65 border-r border-white/35 flex flex-col max-md:w-full max-md:border-r-0 max-md:border-b max-md:border-white/35">
<div class="px-[18px] py-5 pb-3.5 text-base font-bold text-apptext border-b border-white/35 tracking-[0.3px] max-md:px-4 max-md:py-3 max-md:text-[15px]">
管理后台
</div>
<div class="sidebar-nav">
<div class="flex flex-col py-2.5 gap-0.5 max-md:flex-row max-md:py-0 max-md:gap-0 max-md:overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<button
v-for="item in NAV_ITEMS"
:key="item.id"
:class="['nav-item', activeSection === item.id ? 'nav-item--active' : '']"
class="flex items-center gap-2.5 px-[18px] py-2.5 text-[15px] font-medium text-muted bg-transparent border-none cursor-pointer text-left rounded-none transition whitespace-nowrap hover:bg-[rgba(180,154,203,0.1)] hover:text-apptext max-md:flex-col max-md:gap-0.5 max-md:px-3.5 max-md:py-2 max-md:text-xs max-md:flex-shrink-0 max-md:items-center"
:class="activeSection === item.id ? '!bg-[rgba(180,154,203,0.18)] !text-apptext font-bold border-r-[3px] border-accent max-md:border-r-0 max-md:border-b-[3px]' : ''"
@click="activeSection = item.id"
type="button"
>
<span class="nav-icon" v-html="item.icon"></span>
<span class="nav-label">{{ item.label }}</span>
<span class="flex items-center opacity-70 flex-shrink-0" :class="activeSection === item.id ? '!opacity-100' : ''" v-html="item.icon"></span>
<span>{{ item.label }}</span>
</button>
</div>
</nav>
<!-- Main content -->
<div class="admin-content">
<div class="flex-1 min-w-0 p-[22px_24px] overflow-auto max-md:p-3.5">
<!-- Top bar -->
<div class="admin-topbar">
<div class="topbar-title">{{ currentNavLabel }}</div>
<div class="topbar-actions">
<button class="ghost small-btn" @click="refresh">
<div class="flex items-center justify-between mb-[18px] max-md:mb-3">
<div class="text-xl font-bold text-apptext max-md:text-base">{{ currentNavLabel }}</div>
<div class="flex gap-2 items-center">
<button class="ghost inline-flex items-center gap-1.5 text-sm px-3.5 py-1.5" @click="refresh">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
刷新
</button>
<button v-if="activeSection === 'products'" class="primary small-btn" @click="openCreate">
<button v-if="activeSection === 'products'" class="primary inline-flex items-center gap-1.5 text-sm px-3.5 py-1.5" @click="openCreate">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
添加商品
</button>
</div>
</div>
<!-- Token row (always visible) -->
<AdminTokenRow
:show="!token || !!message"
v-model:token="token"
@@ -44,28 +44,19 @@
:inline-message="token && message ? message : ''"
/>
<!-- Section: Products -->
<div v-if="activeSection === 'products'">
<p class="section-tip tag"> {{ products.length }} 件商品</p>
<AdminProductTable
:products="products"
@edit="openEdit"
@toggle="toggle"
@remove="remove"
/>
<p class="text-[13px] text-muted mb-2.5"> {{ products.length }} 件商品</p>
<AdminProductTable :products="products" @edit="openEdit" @toggle="toggle" @remove="remove" />
</div>
<!-- Section: Orders -->
<div v-else-if="activeSection === 'orders'">
<AdminOrderTable :orders="orders" @remove="removeOrder" />
</div>
<!-- Section: Chat -->
<div v-else-if="activeSection === 'chat'">
<AdminChatPanel :admin-token="token" />
</div>
<!-- Section: Settings -->
<div v-else-if="activeSection === 'settings'">
<AdminMaintenanceRow
v-model:enabled="maintenanceEnabled"
@@ -73,11 +64,11 @@
:message="maintenanceMsg"
@save="saveMaintenance"
/>
<AdminSMTPRow
:config="smtpConfig"
:message="smtpMsg"
@save="saveSMTPConfig"
/>
<AdminSMTPRow :config="smtpConfig" :message="smtpMsg" @save="saveSMTPConfig" />
</div>
<div v-else-if="activeSection === 'status'">
<AdminSystemStatusPanel ref="statusPanelRef" :admin-token="token" />
</div>
</div>
</section>
@@ -91,20 +82,13 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref, watch, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
fetchAdminProducts,
createProduct,
updateProduct,
toggleProduct,
deleteProduct,
fetchAdminOrders,
deleteAdminOrder,
fetchSiteMaintenance,
setSiteMaintenance,
fetchSMTPConfig,
setSMTPConfig
fetchAdminProducts, createProduct, updateProduct, toggleProduct, deleteProduct,
fetchAdminOrders, deleteAdminOrder,
fetchSiteMaintenance, setSiteMaintenance,
fetchSMTPConfig, setSMTPConfig
} from '../shared/api'
import AdminTokenRow from './components/AdminTokenRow.vue'
import AdminMaintenanceRow from './components/AdminMaintenanceRow.vue'
@@ -113,382 +97,114 @@ import AdminProductTable from './components/AdminProductTable.vue'
import AdminProductModal from './components/AdminProductModal.vue'
import AdminOrderTable from './components/AdminOrderTable.vue'
import AdminChatPanel from './components/AdminChatPanel.vue'
import AdminSystemStatusPanel from './components/AdminSystemStatusPanel.vue'
const route = useRoute()
const router = useRouter()
const NAV_ITEMS = [
{
id: 'products',
label: '商品管理',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>'
},
{
id: 'orders',
label: '订单记录',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>'
},
{
id: 'chat',
label: '用户消息',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>'
},
{
id: 'settings',
label: '站点设置',
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>'
}
{ id: 'products', label: '商品管理', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></svg>' },
{ id: 'orders', label: '订单记录', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>' },
{ id: 'chat', label: '用户消息', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>' },
{ id: 'settings', label: '站点设置', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg>' },
{ id: 'status', label: '系统状态', icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/><polyline points="6 9 10 13 14 9 18 13"/></svg>' }
]
const activeSection = ref('products')
const currentNavLabel = computed(() => NAV_ITEMS.find(i => i.id === activeSection.value)?.label || '')
const statusPanelRef = ref(null)
const token = ref(route.query.token || '')
const products = ref([])
const orders = ref([])
const message = ref('')
const editorOpen = ref(false)
const selectedItem = ref(null)
const maintenanceEnabled = ref(false)
const maintenanceReason = ref('')
const maintenanceMsg = ref('')
const smtpConfig = ref({})
const smtpMsg = ref('')
const syncQuery = () => {
if (token.value) {
router.replace({ query: { token: token.value } })
}
}
const syncQuery = () => { if (token.value) router.replace({ query: { token: token.value } }) }
const refresh = async () => {
if (!token.value) {
message.value = '请先输入 token'
if (activeSection.value === 'status') {
await nextTick()
statusPanelRef.value?.load()
return
}
if (!token.value) { message.value = '请先输入 token'; return }
try {
const [prods, ords] = await Promise.all([
fetchAdminProducts(token.value),
fetchAdminOrders(token.value)
])
const [prods, ords] = await Promise.all([fetchAdminProducts(token.value), fetchAdminOrders(token.value)])
products.value = prods
orders.value = ords
message.value = '数据已更新'
} catch {
message.value = '获取失败,请检查 token'
}
} catch { message.value = '获取失败,请检查 token' }
}
const loadMaintenance = async () => {
try {
const { maintenance, reason } = await fetchSiteMaintenance()
maintenanceEnabled.value = maintenance
maintenanceReason.value = reason || ''
} catch {
maintenanceMsg.value = '加载维护状态失败'
}
} catch { maintenanceMsg.value = '加载维护状态失败' }
}
const loadSMTPConfig = async () => {
if (!token.value) return
try {
smtpConfig.value = await fetchSMTPConfig(token.value)
} catch {
smtpMsg.value = '加载 SMTP 配置失败'
}
try { smtpConfig.value = await fetchSMTPConfig(token.value) } catch { smtpMsg.value = '加载 SMTP 配置失败' }
}
const saveSMTPConfig = async (cfg) => {
if (!token.value) {
smtpMsg.value = '请先输入 token'
return
}
try {
await setSMTPConfig(token.value, cfg)
smtpMsg.value = '配置已保存'
await loadSMTPConfig()
} catch {
smtpMsg.value = '保存失败,请检查 token'
}
if (!token.value) { smtpMsg.value = '请先输入 token'; return }
try { await setSMTPConfig(token.value, cfg); smtpMsg.value = '配置已保存'; await loadSMTPConfig() }
catch { smtpMsg.value = '保存失败,请检查 token' }
}
const saveMaintenance = async () => {
if (!token.value) {
maintenanceMsg.value = '请先输入 token'
return
}
if (!token.value) { maintenanceMsg.value = '请先输入 token'; return }
try {
await setSiteMaintenance(token.value, maintenanceEnabled.value, maintenanceReason.value)
maintenanceMsg.value = maintenanceEnabled.value ? '维护模式已开启' : '维护模式已关闭'
} catch {
maintenanceMsg.value = '保存失败,请检查 token'
}
} catch { maintenanceMsg.value = '保存失败,请检查 token' }
}
const handleFormSubmit = async (payload) => {
if (!token.value) {
message.value = '请先输入 token'
return
}
if (!token.value) { message.value = '请先输入 token'; return }
try {
if (payload.id) {
await updateProduct(token.value, payload.id, payload)
message.value = '已更新商品'
} else {
await createProduct(token.value, payload)
message.value = '已新增商品'
}
if (payload.id) { await updateProduct(token.value, payload.id, payload); message.value = '已更新商品' }
else { await createProduct(token.value, payload); message.value = '已新增商品' }
closeEditor()
await refresh()
} catch {
message.value = '操作失败,请检查输入'
}
} catch { message.value = '操作失败,请检查输入' }
}
const toggle = async (item) => {
if (!token.value) {
message.value = '请先输入 token'
return
}
if (!token.value) { message.value = '请先输入 token'; return }
await toggleProduct(token.value, item.id, !item.active)
await refresh()
}
const remove = async (item) => {
if (!token.value) {
message.value = '请先输入 token'
return
}
if (!token.value) { message.value = '请先输入 token'; return }
await deleteProduct(token.value, item.id)
await refresh()
}
const removeOrder = async (orderId) => {
if (!token.value) return
try {
await deleteAdminOrder(token.value, orderId)
orders.value = orders.value.filter((o) => o.id !== orderId)
} catch {
message.value = '删除订单失败'
}
try { await deleteAdminOrder(token.value, orderId); orders.value = orders.value.filter((o) => o.id !== orderId) }
catch { message.value = '删除订单失败' }
}
const openCreate = () => {
selectedItem.value = null
editorOpen.value = true
}
const openCreate = () => { selectedItem.value = null; editorOpen.value = true }
const openEdit = (item) => { selectedItem.value = item; editorOpen.value = true }
const closeEditor = () => { editorOpen.value = false; selectedItem.value = null }
const openEdit = (item) => {
selectedItem.value = item
editorOpen.value = true
}
const closeEditor = () => {
editorOpen.value = false
selectedItem.value = null
}
watch(token, (val) => {
syncQuery()
if (val) loadSMTPConfig()
})
watch(token, (val) => { syncQuery(); if (val) loadSMTPConfig() })
onMounted(async () => {
await loadMaintenance()
if (token.value) {
await Promise.all([refresh(), loadSMTPConfig()])
}
if (token.value) await Promise.all([refresh(), loadSMTPConfig()])
})
</script>
<style scoped>
/* ── Layout ── */
.admin-layout {
display: flex;
min-height: calc(100vh - 120px);
gap: 0;
background: var(--glass);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
backdrop-filter: blur(16px);
overflow: hidden;
}
/* ── Sidebar ── */
.admin-sidebar {
width: 180px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.65);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
}
.sidebar-brand {
padding: 20px 18px 14px;
font-size: 16px;
font-weight: 700;
color: var(--text);
border-bottom: 1px solid var(--line);
letter-spacing: 0.3px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
padding: 10px 0;
gap: 2px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 18px;
font-size: 15px;
font-family: inherit;
font-weight: 500;
color: var(--muted);
background: none;
border: none;
cursor: pointer;
text-align: left;
border-radius: 0;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.nav-item:hover {
background: rgba(180, 154, 203, 0.1);
color: var(--text);
}
.nav-item--active {
background: rgba(180, 154, 203, 0.18);
color: var(--text);
font-weight: 700;
border-right: 3px solid var(--accent);
}
.nav-icon {
display: flex;
align-items: center;
opacity: 0.7;
flex-shrink: 0;
}
.nav-item--active .nav-icon {
opacity: 1;
}
/* ── Content area ── */
.admin-content {
flex: 1;
min-width: 0;
padding: 22px 24px;
overflow: auto;
}
/* ── Top bar ── */
.admin-topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18px;
}
.topbar-title {
font-size: 20px;
font-weight: 700;
color: var(--text);
}
.topbar-actions {
display: flex;
gap: 8px;
align-items: center;
}
.small-btn {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 14px;
padding: 7px 13px;
}
.section-tip {
font-size: 13px;
color: var(--muted);
margin-bottom: 10px;
}
.tag {
font-size: 14px;
color: var(--muted);
}
/* ── Mobile: sidebar becomes top tabs ── */
@media (max-width: 900px) {
.admin-layout {
flex-direction: column;
min-height: auto;
}
.admin-sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--line);
}
.sidebar-brand {
padding: 12px 16px 10px;
font-size: 15px;
}
.sidebar-nav {
flex-direction: row;
padding: 0;
gap: 0;
overflow-x: auto;
scrollbar-width: none;
}
.sidebar-nav::-webkit-scrollbar {
display: none;
}
.nav-item {
flex-direction: column;
gap: 3px;
padding: 8px 14px;
font-size: 12px;
border-right: none;
border-bottom: 3px solid transparent;
flex-shrink: 0;
align-items: center;
}
.nav-item--active {
border-right: none;
border-bottom: 3px solid var(--accent);
background: rgba(180, 154, 203, 0.12);
}
.admin-content {
padding: 14px;
}
.admin-topbar {
margin-bottom: 12px;
}
.topbar-title {
font-size: 16px;
}
}
</style>

View File

@@ -1,76 +1,103 @@
<template>
<div class="chat-panel-section">
<div class="chat-panel-header">
<h3>用户消息</h3>
<button class="refresh-btn" @click="load" :disabled="loading">刷新</button>
<div class="mt-8 max-md:mt-4">
<div class="flex items-center gap-3 mb-3.5">
<h3 class="text-lg font-bold text-apptext">用户消息</h3>
<button
class="px-3.5 py-1.5 rounded border border-white/35 bg-white/70 text-[13px] cursor-pointer text-apptext disabled:opacity-50"
style="font-family: var(--font-serif);"
@click="load" :disabled="loading"
>刷新</button>
</div>
<div v-if="loading" class="chat-empty">加载中</div>
<div v-else-if="Object.keys(conversations).length === 0" class="chat-empty">暂无用户消息</div>
<div v-if="loading" class="py-6 text-center text-muted text-sm">加载中</div>
<div v-else-if="Object.keys(conversations).length === 0" class="py-6 text-center text-muted text-sm">暂无用户消息</div>
<div v-else class="chat-layout">
<!-- Left: conversation list -->
<div class="conv-list">
<div class="conv-list-title">对话列表</div>
<div v-else class="grid h-[520px] rounded-xl border border-white/35 overflow-hidden bg-white/40 shadow-[0_8px_24px_rgba(33,33,40,0.08)] max-md:grid-cols-1 max-md:grid-rows-[auto,1fr]" style="grid-template-columns: 200px 1fr;">
<!-- Conversation list -->
<div class="border-r border-white/35 overflow-y-auto bg-white/65 flex flex-col max-md:border-r-0 max-md:border-b max-md:overflow-x-auto max-md:overflow-y-hidden max-md:flex-row max-md:max-h-[72px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<div class="px-3.5 py-3 pb-2 text-[11px] font-bold tracking-[0.8px] uppercase text-muted border-b border-white/35 flex-shrink-0 max-md:hidden">对话列表</div>
<div
v-for="(msgs, account) in conversations"
:key="account"
:class="['conv-item', selectedAccount === account ? 'conv-item--active' : '']"
class="flex items-center gap-2.5 px-3 py-2.5 cursor-pointer border-b border-white/50 last:border-b-0 transition-colors hover:bg-[rgba(180,154,203,0.08)] max-md:flex-col max-md:flex-shrink-0 max-md:items-center max-md:min-w-[70px] max-md:max-w-20 max-md:px-1.5 max-md:py-2 max-md:border-b-0 max-md:border-r max-md:border-white/35 max-md:text-center"
:class="selectedAccount === account ? '!bg-[rgba(180,154,203,0.14)] border-l-[3px] border-accent pl-[9px] max-md:border-l-0 max-md:border-b-[2px] max-md:pl-1.5' : ''"
@click="selectConv(account, msgs)"
>
<div class="conv-avatar">{{ (getDisplayName(msgs) || account).slice(0, 1).toUpperCase() }}</div>
<div class="conv-info">
<div class="conv-name">{{ getDisplayName(msgs) || account }}</div>
<div class="conv-preview">{{ latestMsg(msgs) }}</div>
<div class="w-[34px] h-[34px] rounded-full bg-[#e8e8e8] text-[#444] text-sm font-bold flex items-center justify-center flex-shrink-0 max-md:w-7 max-md:h-7 max-md:text-xs">
{{ (getDisplayName(msgs) || account).slice(0, 1).toUpperCase() }}
</div>
<div class="flex-1 min-w-0 max-md:flex max-md:flex-col max-md:items-center">
<div class="text-[13px] font-semibold text-apptext whitespace-nowrap overflow-hidden text-ellipsis max-md:text-[11px] max-md:max-w-16">{{ getDisplayName(msgs) || account }}</div>
<div class="text-[11px] text-muted mt-0.5 whitespace-nowrap overflow-hidden text-ellipsis max-md:hidden">{{ latestMsg(msgs) }}</div>
</div>
</div>
</div>
<!-- Right: message thread -->
<div class="conv-thread" v-if="selectedAccount">
<div class="thread-top">
<div class="thread-user">
<div class="thread-avatar">{{ (displayName || selectedAccount).slice(0, 1).toUpperCase() }}</div>
<div class="thread-user-info">
<span class="thread-name">{{ displayName || selectedAccount }}</span>
<span class="thread-account">{{ selectedAccount }}</span>
<!-- Thread -->
<div v-if="selectedAccount" class="flex flex-col overflow-hidden bg-white/30">
<!-- Thread top -->
<div class="flex items-center justify-between px-4 py-2.5 border-b border-white/35 bg-white/75 flex-shrink-0">
<div class="flex items-center gap-2.5">
<div class="w-8 h-8 rounded-full bg-[#e8e8e8] text-[#444] text-[13px] font-bold flex items-center justify-center flex-shrink-0">
{{ (displayName || selectedAccount).slice(0, 1).toUpperCase() }}
</div>
<div class="flex flex-col gap-0">
<span class="text-sm font-bold text-apptext leading-[1.2]">{{ displayName || selectedAccount }}</span>
<span class="text-[11px] text-muted">{{ selectedAccount }}</span>
</div>
</div>
<button class="clear-btn" @click="clearConv" :disabled="clearing">清除记录</button>
<button
class="px-3 py-1.5 rounded border border-[rgba(210,70,70,0.3)] bg-[rgba(210,70,70,0.06)] text-[#c04040] text-xs cursor-pointer whitespace-nowrap hover:bg-[rgba(210,70,70,0.14)] transition disabled:opacity-50"
style="font-family: var(--font-serif);"
@click="clearConv" :disabled="clearing"
>清除记录</button>
</div>
<div class="thread-messages" ref="threadEl">
<!-- Messages -->
<div class="flex-1 overflow-y-auto px-4 py-4 flex flex-col gap-3 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" ref="threadEl">
<div
v-for="msg in currentMessages"
:key="msg.id"
:class="['msg-row', msg.fromAdmin ? 'msg-row--admin' : 'msg-row--user']"
class="flex"
:class="msg.fromAdmin ? 'justify-end' : 'justify-start'"
>
<div class="msg-bubble">
<div class="msg-meta">
<span class="msg-from">{{ msg.fromAdmin ? '客服' : (msg.accountName || msg.accountId) }}</span>
<span class="msg-time">{{ formatTime(msg.sentAt) }}</span>
<div class="flex flex-col gap-0.5 max-w-[72%]" :class="msg.fromAdmin ? 'items-end' : ''">
<div class="flex gap-1.5 items-baseline px-0.5">
<span class="text-[11px] font-semibold text-muted">{{ msg.fromAdmin ? '客服' : (msg.accountName || msg.accountId) }}</span>
<span class="text-[10px] text-muted opacity-75">{{ formatTime(msg.sentAt) }}</span>
</div>
<div class="msg-content">{{ msg.content }}</div>
<div
class="px-3.5 py-2.5 rounded-xl text-sm leading-[1.55] whitespace-pre-wrap break-words"
:class="msg.fromAdmin
? 'bg-[#2c2b2d] text-white rounded-br-[4px]'
: 'bg-white/85 text-apptext border border-white/35 rounded-bl-[4px]'"
>{{ msg.content }}</div>
</div>
</div>
</div>
<form class="reply-row" @submit.prevent="sendReply">
<!-- Reply input -->
<form class="flex gap-2 px-3.5 py-2.5 border-t border-white/35 bg-white/70 flex-shrink-0" @submit.prevent="sendReply">
<input
v-model="replyText"
class="reply-input"
class="flex-1 px-3 py-2 rounded-lg border border-white/35 text-sm bg-white/90 text-apptext outline-none focus:border-accent transition"
style="font-family: var(--font-serif);"
placeholder="输入回复内容…"
maxlength="500"
:disabled="replying"
/>
<button class="reply-send-btn" type="submit" :disabled="replying || !replyText.trim()">发送</button>
<button
class="px-[18px] py-2 rounded-lg bg-[#2c2b2d] text-white border-none cursor-pointer text-sm font-semibold disabled:opacity-45 disabled:cursor-default hover:not-disabled:opacity-80 transition"
style="font-family: var(--font-serif);"
type="submit" :disabled="replying || !replyText.trim()"
>发送</button>
</form>
<p v-if="replyError" class="reply-error">{{ replyError }}</p>
<p v-if="replyError" class="text-xs text-[#d64848] px-3.5 pb-2 text-right">{{ replyError }}</p>
</div>
<div class="conv-thread conv-thread--empty" v-else>
<div class="empty-hint">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<div v-else class="flex items-center justify-center text-muted text-sm bg-white/30">
<div class="flex flex-col items-center gap-2.5">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-30"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<p>选择左侧对话</p>
</div>
</div>
@@ -80,16 +107,9 @@
<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import {
fetchAdminAllConversations,
fetchAdminConversation,
adminSendChatReply,
adminClearConversation
} from '../../shared/api.js'
import { fetchAdminAllConversations, fetchAdminConversation, adminSendChatReply, adminClearConversation } from '../../shared/api.js'
const props = defineProps({
adminToken: { type: String, default: '' }
})
const props = defineProps({ adminToken: { type: String, default: '' } })
const conversations = ref({})
const loading = ref(false)
@@ -106,8 +126,7 @@ let pollTimer = null
const formatTime = (iso) => {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleString('zh-CN', { hour12: false })
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
}
const getDisplayName = (msgs) => {
@@ -125,9 +144,7 @@ const latestMsg = (msgs) => {
const scrollThreadBottom = async () => {
await nextTick()
if (threadEl.value) {
threadEl.value.scrollTop = threadEl.value.scrollHeight
}
if (threadEl.value) threadEl.value.scrollTop = threadEl.value.scrollHeight
}
const load = async () => {
@@ -135,14 +152,11 @@ const load = async () => {
loading.value = true
try {
conversations.value = await fetchAdminAllConversations(props.adminToken)
// 若当前已选中某个会话,则刷新其消息列表
if (selectedAccount.value) {
currentMessages.value = conversations.value[selectedAccount.value] || []
await scrollThreadBottom()
}
} finally {
loading.value = false
}
} finally { loading.value = false }
}
const selectConv = async (account, msgs) => {
@@ -169,9 +183,7 @@ const sendReply = async () => {
}
} catch (err) {
replyError.value = err?.response?.data?.error || '发送失败'
} finally {
replying.value = false
}
} finally { replying.value = false }
}
const clearConv = async () => {
@@ -183,423 +195,10 @@ const clearConv = async () => {
delete conversations.value[selectedAccount.value]
selectedAccount.value = ''
currentMessages.value = []
} catch {
alert('清除失败,请重试')
} finally {
clearing.value = false
}
} catch { alert('清除失败,请重试') }
finally { clearing.value = false }
}
onMounted(() => {
load()
pollTimer = setInterval(load, 8000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
onMounted(() => { load(); pollTimer = setInterval(load, 8000) })
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer) })
</script>
<style scoped>
.chat-panel-section {
margin-top: 32px;
}
.chat-panel-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.chat-panel-header h3 {
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.refresh-btn {
padding: 5px 14px;
border-radius: 6px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.7);
font-size: 13px;
cursor: pointer;
color: var(--text);
font-family: inherit;
}
.chat-empty {
padding: 24px 0;
text-align: center;
color: var(--muted);
font-size: 14px;
}
.chat-layout {
display: grid;
grid-template-columns: 200px 1fr;
height: 520px;
border-radius: 12px;
border: 1px solid var(--line);
overflow: hidden;
background: rgba(255, 255, 255, 0.4);
box-shadow: 0 8px 24px rgba(33, 33, 40, 0.08);
}
/* Conversation list */
.conv-list {
border-right: 1px solid var(--line);
overflow-y: auto;
background: rgba(255, 255, 255, 0.65);
display: flex;
flex-direction: column;
}
.conv-list-title {
padding: 12px 14px 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid var(--line);
flex-shrink: 0;
}
.conv-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.5);
transition: background 0.15s;
}
.conv-item:hover {
background: rgba(180, 154, 203, 0.08);
}
.conv-item--active {
background: rgba(180, 154, 203, 0.14);
border-left: 3px solid var(--accent);
padding-left: 9px;
}
.conv-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: #e8e8e8;
color: #444;
font-size: 14px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.conv-info {
flex: 1;
min-width: 0;
}
.conv-name {
font-size: 13px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-preview {
font-size: 11px;
color: var(--muted);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Message thread */
.conv-thread {
display: flex;
flex-direction: column;
overflow: hidden;
background: rgba(255, 255, 255, 0.3);
}
.conv-thread--empty {
align-items: center;
justify-content: center;
color: var(--muted);
font-size: 14px;
}
.empty-hint {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
color: var(--muted);
font-size: 14px;
}
.thread-top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.75);
flex-shrink: 0;
}
.thread-user {
display: flex;
align-items: center;
gap: 10px;
}
.thread-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #e8e8e8;
color: #444;
font-size: 13px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.thread-user-info {
display: flex;
flex-direction: column;
gap: 1px;
}
.thread-name {
font-size: 14px;
font-weight: 700;
color: var(--text);
line-height: 1.2;
}
.thread-account {
font-size: 11px;
color: var(--muted);
}
.clear-btn {
padding: 5px 12px;
border-radius: 6px;
border: 1px solid rgba(210, 70, 70, 0.3);
background: rgba(210, 70, 70, 0.06);
color: #c04040;
font-size: 12px;
cursor: pointer;
font-family: inherit;
transition: background 0.15s;
white-space: nowrap;
}
.clear-btn:hover:not(:disabled) {
background: rgba(210, 70, 70, 0.14);
}
.thread-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.msg-row {
display: flex;
}
.msg-row--user {
justify-content: flex-start;
}
.msg-row--admin {
justify-content: flex-end;
}
.msg-bubble {
display: flex;
flex-direction: column;
gap: 3px;
max-width: 72%;
}
.msg-row--admin .msg-bubble {
align-items: flex-end;
}
.msg-meta {
display: flex;
gap: 6px;
align-items: baseline;
padding: 0 2px;
}
.msg-from {
font-size: 11px;
font-weight: 600;
color: var(--muted);
}
.msg-time {
font-size: 10px;
color: var(--muted);
opacity: 0.75;
}
.msg-content {
padding: 9px 13px;
border-radius: 12px;
font-size: 14px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.msg-row--user .msg-content {
background: rgba(255, 255, 255, 0.85);
color: var(--text);
border: 1px solid var(--line);
border-bottom-left-radius: 4px;
}
.msg-row--admin .msg-content {
background: #2c2b2d;
color: #fff;
border-bottom-right-radius: 4px;
}
.reply-row {
display: flex;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
}
.reply-input {
flex: 1;
padding: 8px 12px;
border-radius: 7px;
border: 1px solid var(--line);
font-size: 14px;
font-family: inherit;
background: rgba(255, 255, 255, 0.9);
color: var(--text);
outline: none;
}
.reply-input:focus {
border-color: var(--accent, #7c6af0);
}
.reply-send-btn {
padding: 8px 18px;
border-radius: 7px;
background: #2c2b2d;
color: #fff;
border: none;
cursor: pointer;
font-size: 14px;
font-family: inherit;
font-weight: 600;
transition: opacity 0.2s;
}
.reply-send-btn:disabled {
opacity: 0.45;
cursor: default;
}
.reply-send-btn:not(:disabled):hover {
opacity: 0.82;
}
.reply-error {
font-size: 12px;
color: #d64848;
padding: 0 14px 8px;
text-align: right;
}
@media (max-width: 900px) {
.chat-panel-section {
margin-top: 16px;
}
.chat-layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
height: 520px;
}
.conv-list {
border-right: none;
border-bottom: 1px solid var(--line);
overflow-x: auto;
overflow-y: hidden;
flex-direction: row;
max-height: 72px;
scrollbar-width: none;
}
.conv-list::-webkit-scrollbar {
display: none;
}
.conv-list-title {
display: none;
}
.conv-item {
flex-shrink: 0;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 70px;
max-width: 80px;
padding: 8px 6px;
border-bottom: none;
border-right: 1px solid var(--line);
text-align: center;
}
.conv-info {
display: flex;
flex-direction: column;
align-items: center;
}
.conv-name {
font-size: 11px;
max-width: 64px;
}
.conv-preview {
display: none;
}
.conv-avatar {
width: 28px;
height: 28px;
font-size: 12px;
}
}
</style>

View File

@@ -1,25 +1,30 @@
<template>
<div class="maintenance-row">
<div class="maintenance-left">
<span class="maintenance-label">站点维护模式</span>
<div class="flex items-center gap-4 flex-wrap mb-4 px-[18px] py-3.5 bg-white/50 border border-white/35 rounded-lg max-md:flex-col max-md:items-stretch max-md:px-3 max-md:py-2.5 max-md:gap-2.5">
<!-- Left: label + toggle -->
<div class="flex items-center gap-3 flex-shrink-0">
<span class="text-[15px] font-semibold text-apptext whitespace-nowrap">站点维护模式</span>
<button
:class="['maintenance-toggle', enabled ? 'toggle-on' : 'toggle-off']"
class="px-4 py-1.5 rounded-full text-sm font-semibold border-none cursor-pointer transition"
:class="enabled
? 'bg-[rgba(201,90,106,0.15)] text-[#c95a6a] hover:bg-[rgba(201,90,106,0.25)]'
: 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68] hover:bg-[rgba(100,185,140,0.25)]'"
style="font-family: var(--font-serif);"
type="button"
@click="$emit('update:enabled', !enabled)"
>
{{ enabled ? '维护中' : '正常运行' }}
</button>
>{{ enabled ? '维护中' : '正常运行' }}</button>
<span
v-if="message"
class="msg-tag"
:class="{ error: message.includes('失败') }"
class="text-[15px] px-2.5 py-1 rounded whitespace-nowrap"
:class="message.includes('失败') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2 bg-[rgba(145,168,208,0.1)]'"
>{{ message }}</span>
</div>
<div class="maintenance-right">
<!-- Right: reason input + save -->
<div class="flex items-center gap-2.5 flex-1 min-w-0 max-md:flex-nowrap">
<input
:value="reason"
@input="$emit('update:reason', $event.target.value)"
class="maintenance-reason-input"
class="flex-1 min-w-0 px-3 py-2 rounded-lg border border-white/35 bg-white/70 text-[15px] outline-none focus:border-accent transition max-md:text-sm max-md:py-1.5"
style="font-family: var(--font-serif);"
placeholder="维护原因(选填)"
/>
<button class="ghost" type="button" @click="$emit('save')">保存</button>
@@ -33,123 +38,5 @@ defineProps({
reason: { type: String, default: '' },
message: { type: String, default: '' }
})
defineEmits(['update:enabled', 'update:reason', 'save'])
</script>
<style scoped>
.maintenance-row {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 16px;
padding: 14px 18px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--line);
border-radius: 8px;
}
.maintenance-left {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.maintenance-label {
font-size: 15px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.maintenance-toggle {
padding: 6px 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
transition: background 0.2s ease, transform 0.15s ease;
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
}
.toggle-on {
background: rgba(201, 90, 106, 0.15);
color: #c95a6a;
}
.toggle-on:hover {
background: rgba(201, 90, 106, 0.25);
}
.toggle-off {
background: rgba(100, 185, 140, 0.15);
color: #3a9a68;
}
.toggle-off:hover {
background: rgba(100, 185, 140, 0.25);
}
.maintenance-right {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.maintenance-reason-input {
flex: 1;
min-width: 0;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
font-size: 15px;
outline: none;
transition: border-color 0.2s ease;
}
.maintenance-reason-input:focus {
border-color: var(--accent);
}
.msg-tag {
font-size: 15px;
color: var(--accent-2);
padding: 4px 10px;
border-radius: 5px;
background: rgba(145, 168, 208, 0.1);
white-space: nowrap;
}
.msg-tag.error {
color: #c95a6a;
background: rgba(201, 90, 106, 0.08);
}
@media (max-width: 900px) {
.maintenance-row {
flex-direction: column;
align-items: stretch;
padding: 10px 12px;
gap: 10px;
}
.maintenance-right {
flex-direction: row;
flex-wrap: nowrap;
}
.maintenance-reason-input {
flex: 1;
min-width: 0;
font-size: 14px;
padding: 7px 10px;
}
}
</style>

View File

@@ -1,75 +1,87 @@
<template>
<div class="orders-section">
<div class="orders-header">
<h3>下单记录</h3>
<div class="mt-7 max-md:mt-3">
<div class="flex items-baseline gap-3 mb-3">
<h3 class="text-lg font-bold text-apptext">下单记录</h3>
<p class="tag"> {{ orders.length }} 条订单含未登录用户</p>
</div>
<div v-if="orders.length === 0" class="empty-tip tag">暂无订单记录</div>
<div v-if="orders.length === 0" class="py-5 text-center text-muted text-sm">暂无订单记录</div>
<div v-else class="table-wrap">
<!-- Pagination toolbar (top) -->
<div class="pagination-bar" v-if="totalPages > 1">
<button class="page-btn" :disabled="currentPage === 1" @click="currentPage--"></button>
<span class="page-info"> {{ currentPage }} / {{ totalPages }} {{ orders.length }} </span>
<button class="page-btn" :disabled="currentPage === totalPages" @click="currentPage++"></button>
<div v-else class="rounded-lg overflow-hidden border border-white/35">
<!-- Pagination -->
<div v-if="totalPages > 1" class="flex items-center gap-3 px-3.5 py-2.5 border-b border-white/35 bg-white/70 max-md:gap-2 max-md:px-2.5">
<button
class="px-3 py-1 rounded border border-white/35 bg-white/80 text-base cursor-pointer text-apptext disabled:opacity-35 disabled:cursor-default hover:not-disabled:bg-[rgba(124,106,240,0.1)] transition leading-none"
style="font-family: var(--font-serif);"
:disabled="currentPage === 1" @click="currentPage--"
></button>
<span class="text-[13px] text-muted max-md:text-xs"> {{ currentPage }} / {{ totalPages }} {{ orders.length }} </span>
<button
class="px-3 py-1 rounded border border-white/35 bg-white/80 text-base cursor-pointer text-apptext disabled:opacity-35 disabled:cursor-default hover:not-disabled:bg-[rgba(124,106,240,0.1)] transition leading-none"
style="font-family: var(--font-serif);"
:disabled="currentPage === totalPages" @click="currentPage++"
></button>
</div>
<table class="table">
<div class="overflow-x-auto max-md:[webkit-overflow-scrolling:touch]">
<table class="table bg-white/45 max-md:min-w-[560px]">
<thead>
<tr>
<th>商品</th>
<th>用户</th>
<th class="col-num">数量</th>
<th>发货</th>
<th>状态</th>
<th>下单时间</th>
<th class="col-action"></th>
<tr class="bg-white/70 border-b-2 border-white/35">
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 max-md:px-2 max-md:py-1.5 max-md:text-xs">商品</th>
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 max-md:px-2 max-md:py-1.5 max-md:text-xs">用户</th>
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 text-center max-md:px-2 max-md:py-1.5 max-md:text-xs">数量</th>
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 max-md:px-2 max-md:py-1.5 max-md:text-xs">发货</th>
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 max-md:px-2 max-md:py-1.5 max-md:text-xs">状态</th>
<th class="text-[13px] font-semibold uppercase tracking-[0.5px] text-muted px-3.5 py-2.5 max-md:hidden">下单时间</th>
<th class="w-10 text-center px-3.5 py-2.5"></th>
</tr>
</thead>
<tbody>
<template v-for="order in pagedOrders" :key="order.id">
<tr :class="{ 'row-pending': order.status === 'pending' }">
<td>
<div class="order-product">
<span class="order-product-name">{{ order.productName }}</span>
<span class="order-id">{{ order.id }}</span>
<tr
class="border-b border-white/35 last:border-b-0 transition-colors hover:bg-white/70"
:class="{ 'opacity-70': order.status === 'pending' }"
>
<td class="px-3.5 py-3 text-[15px] align-middle max-md:px-2 max-md:py-1.5 max-md:text-xs">
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-apptext text-[15px] max-md:text-xs">{{ order.productName }}</span>
<span class="text-xs text-muted font-mono">{{ order.id }}</span>
</div>
</td>
<td>
<div v-if="order.userAccount" class="user-info">
<span class="user-name">{{ order.userName || order.userAccount }}</span>
<span class="user-account">{{ order.userAccount }}</span>
<td class="px-3.5 py-3 text-[15px] align-middle max-md:px-2 max-md:py-1.5 max-md:text-xs">
<div v-if="order.userAccount" class="flex flex-col gap-0.5">
<span class="font-semibold text-apptext max-md:text-xs">{{ order.userName || order.userAccount }}</span>
<span class="text-xs text-muted">{{ order.userAccount }}</span>
</div>
<span v-else class="anon-badge">匿名</span>
<span v-else class="px-2.5 py-0.5 rounded-full bg-[rgba(140,140,145,0.12)] text-muted text-[13px]">匿名</span>
</td>
<td class="col-num">{{ order.quantity }}</td>
<td>
<span :class="['delivery-badge', order.deliveryMode === 'manual' ? 'delivery-manual' : 'delivery-auto']">
{{ order.deliveryMode === 'manual' ? '手动' : '自动' }}
</span>
<td class="px-3.5 py-3 text-center align-middle max-md:px-2 max-md:py-1.5 max-md:text-xs">{{ order.quantity }}</td>
<td class="px-3.5 py-3 align-middle max-md:px-2 max-md:py-1.5">
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-semibold"
:class="order.deliveryMode === 'manual' ? 'bg-[rgba(90,120,200,0.12)] text-[#5a78c8]' : 'bg-[rgba(100,185,140,0.12)] text-[#3a9a68]'"
>{{ order.deliveryMode === 'manual' ? '手动' : '自动' }}</span>
</td>
<td>
<span :class="['status-badge', order.status === 'completed' ? 'status-done' : 'status-wait']">
{{ order.status === 'completed' ? '已完成' : '待付款' }}
</span>
<td class="px-3.5 py-3 align-middle max-md:px-2 max-md:py-1.5">
<span
class="inline-block px-2.5 py-0.5 rounded-full text-[13px] font-semibold"
:class="order.status === 'completed' ? 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]' : 'bg-[rgba(220,178,90,0.15)] text-[#b87e20]'"
>{{ order.status === 'completed' ? '已完成' : '待付款' }}</span>
</td>
<td class="col-time">{{ formatTime(order.createdAt) }}</td>
<td class="col-action">
<button class="del-btn" @click="remove(order.id)" title="删除此订单"></button>
<td class="px-3.5 py-3 text-[13px] text-muted whitespace-nowrap align-middle max-md:hidden">{{ formatTime(order.createdAt) }}</td>
<td class="px-3.5 py-3 text-center align-middle">
<button
class="bg-transparent border-none text-muted text-[13px] cursor-pointer px-1.5 py-0.5 rounded leading-none hover:text-[#d64848] hover:bg-[rgba(214,72,72,0.1)] transition"
@click="remove(order.id)" title="删除此订单"
></button>
</td>
</tr>
<tr v-if="order.note || order.contactPhone || order.contactEmail" class="extra-row">
<td colspan="7">
<div class="extra-info">
<span v-if="order.note" class="extra-item">
<span class="extra-label">备注</span>{{ order.note }}
</span>
<span v-if="order.contactPhone" class="extra-item">
<span class="extra-label">手机</span>{{ order.contactPhone }}
</span>
<span v-if="order.contactEmail" class="extra-item">
<span class="extra-label">邮箱</span>{{ order.contactEmail }}
</span>
<tr v-if="order.note || order.contactPhone || order.contactEmail" class="border-b border-white/35 last:border-b-0">
<td colspan="7" class="px-3.5 pb-2.5 pt-1.5 bg-[rgba(250,250,255,0.5)] max-md:px-2">
<div class="flex flex-wrap gap-3 gap-x-5 text-[13px] text-apptext">
<span v-if="order.note" class="inline-flex gap-0.5"><span class="text-muted font-semibold">备注</span>{{ order.note }}</span>
<span v-if="order.contactPhone" class="inline-flex gap-0.5"><span class="text-muted font-semibold">手机</span>{{ order.contactPhone }}</span>
<span v-if="order.contactEmail" class="inline-flex gap-0.5"><span class="text-muted font-semibold">邮箱</span>{{ order.contactEmail }}</span>
</div>
</td>
</tr>
@@ -78,15 +90,13 @@
</table>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
orders: { type: Array, default: () => [] }
})
const props = defineProps({ orders: { type: Array, default: () => [] } })
const emit = defineEmits(['remove'])
const PAGE_SIZE = 10
@@ -98,307 +108,14 @@ const pagedOrders = computed(() => {
return props.orders.slice(start, start + PAGE_SIZE)
})
// 订单列表变化时重置到第一页
watch(() => props.orders.length, () => { currentPage.value = 1 })
const remove = (id) => {
if (confirm('确认删除此订单记录?此操作不可撤销。')) {
emit('remove', id)
}
if (confirm('确认删除此订单记录?此操作不可撤销。')) emit('remove', id)
}
const formatTime = (iso) => {
if (!iso) return '-'
const d = new Date(iso)
return d.toLocaleString('zh-CN', { hour12: false })
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
}
</script>
<style scoped>
.orders-section {
margin-top: 28px;
}
.orders-header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 12px;
}
.orders-header h3 {
font-size: 18px;
font-weight: 700;
color: var(--text);
}
.empty-tip {
padding: 20px 0;
text-align: center;
color: var(--muted);
}
.table-wrap {
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--line);
}
.table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.45);
}
.table thead tr {
background: rgba(255, 255, 255, 0.7);
border-bottom: 2px solid var(--line);
}
.table th {
padding: 11px 14px;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--muted);
}
.table td {
padding: 12px 14px;
font-size: 15px;
vertical-align: middle;
}
.table tbody tr {
border-bottom: 1px solid var(--line);
transition: background 0.15s ease;
}
.table tbody tr:last-child {
border-bottom: none;
}
.table tbody tr:hover {
background: rgba(255, 255, 255, 0.7);
}
.table tbody tr.row-pending {
opacity: 0.7;
}
.col-num {
text-align: center;
}
.col-time {
font-size: 13px;
color: var(--muted);
white-space: nowrap;
}
.order-product {
display: flex;
flex-direction: column;
gap: 2px;
}
.order-product-name {
font-weight: 600;
color: var(--text);
font-size: 15px;
}
.order-id {
font-size: 12px;
color: var(--muted);
font-family: monospace;
}
.user-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-name {
font-weight: 600;
font-size: 15px;
color: var(--text);
}
.user-account {
font-size: 12px;
color: var(--muted);
}
.anon-badge {
padding: 3px 10px;
border-radius: 999px;
background: rgba(140, 140, 145, 0.12);
color: var(--muted);
font-size: 13px;
}
.status-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
}
.status-done {
background: rgba(100, 185, 140, 0.15);
color: #3a9a68;
}
.status-wait {
background: rgba(220, 178, 90, 0.15);
color: #b87e20;
}
.delivery-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.delivery-auto {
background: rgba(100, 185, 140, 0.12);
color: #3a9a68;
}
.delivery-manual {
background: rgba(90, 120, 200, 0.12);
color: #5a78c8;
}
.extra-row td {
padding: 6px 14px 10px;
background: rgba(250, 250, 255, 0.5);
}
.extra-info {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
font-size: 13px;
color: var(--text);
}
.extra-item {
display: inline-flex;
gap: 3px;
}
.extra-label {
color: var(--muted);
font-weight: 600;
}
.col-action {
text-align: center;
width: 40px;
}
.del-btn {
background: none;
border: none;
color: var(--muted);
font-size: 13px;
cursor: pointer;
padding: 3px 6px;
border-radius: 4px;
line-height: 1;
transition: color 0.15s, background 0.15s;
}
.del-btn:hover {
color: #d64848;
background: rgba(214, 72, 72, 0.1);
}
.tag {
font-size: 14px;
color: var(--muted);
}
.pagination-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
}
.page-btn {
padding: 4px 12px;
border-radius: 6px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.8);
font-size: 16px;
cursor: pointer;
color: var(--text);
font-family: inherit;
transition: background 0.15s;
line-height: 1;
}
.page-btn:disabled {
opacity: 0.35;
cursor: default;
}
.page-btn:not(:disabled):hover {
background: rgba(124, 106, 240, 0.1);
}
.page-info {
font-size: 13px;
color: var(--muted);
}
@media (max-width: 900px) {
.orders-section {
margin-top: 12px;
}
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table {
min-width: 560px;
}
.table th,
.table td {
padding: 7px 8px;
font-size: 12px;
}
.col-time {
display: none;
}
.pagination-bar {
padding: 6px 10px;
gap: 8px;
}
.page-info {
font-size: 12px;
}
.page-btn {
width: 28px;
height: 28px;
font-size: 16px;
}
}
</style>

View File

@@ -1,7 +1,15 @@
<template>
<div v-if="open" class="modal-mask" @click.self="$emit('close')">
<section class="modal-card">
<div class="modal-header">
<div
v-if="open"
class="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto overscroll-contain p-6 bg-[rgba(30,28,32,0.35)] backdrop-blur-lg max-md:p-3"
@click.self="$emit('close')"
>
<section
class="flex w-[min(920px,100%)] max-h-[min(calc(100dvh-32px),calc(100vh-32px))] flex-col min-h-0 my-4 shrink-0 overflow-hidden rounded-xl border border-white/35 bg-white/95 p-7 shadow-[0_24px_60px_rgba(33,33,40,0.2)] max-md:my-3 max-md:max-h-[min(calc(100dvh-24px),calc(100vh-24px))] max-md:p-[18px]"
@click.stop
>
<!-- Header -->
<div class="flex shrink-0 items-start justify-between gap-4 pb-[22px]">
<div>
<h3>{{ form.id ? '编辑商品' : '添加新商品' }}</h3>
<p class="tag">封面图和最多 5 张商品截图共用这一套编辑表单</p>
@@ -9,12 +17,14 @@
<button class="ghost" @click="$emit('close')">关闭</button>
</div>
<div class="modal-body">
<!-- Body (scrolls; keeps footer actions reachable) -->
<div class="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto pr-1">
<div class="form-field">
<label>商品名称</label>
<input v-model="form.name" placeholder="商品名称" />
</div>
<div class="form-row">
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div class="form-field">
<label>原价</label>
<input v-model.number="form.price" type="number" step="0.01" />
@@ -25,38 +35,40 @@
<p class="tag">留空或不小于原价时将不启用折扣</p>
</div>
</div>
<div class="form-field">
<label>封面链接http</label>
<input v-model="form.coverUrl" placeholder="http://..." />
</div>
<div class="form-field">
<div class="field-head">
<div class="flex items-center gap-3 flex-wrap">
<label>商品库存</label>
<span class="tag"> {{ form.inventoryItems.length }} </span>
<button class="ghost small" type="button" @click="addInventoryItem">添加商品库存</button>
<button class="ghost px-3.5 py-1.5 text-[13px] rounded-full" type="button" @click="addInventoryItem">添加商品库存</button>
</div>
<p class="tag">每个输入框保存一条可发放内容购买后会直接展示给用户</p>
<div class="inventory-list">
<div v-for="(_, index) in form.inventoryItems" :key="index" class="inventory-row">
<div class="flex flex-col gap-2 mt-1">
<div v-for="(_, index) in form.inventoryItems" :key="index" class="flex items-center gap-2 max-md:flex-col max-md:items-stretch">
<input
:ref="(el) => setInventoryInputRef(el, index)"
v-model="form.inventoryItems[index]"
class="flex-1"
placeholder="库存内容,可填卡密、下载链接等"
/>
<button
v-if="form.inventoryItems.length > 1"
class="ghost small"
class="ghost px-3.5 py-1.5 text-[13px] rounded-full"
type="button"
@click="removeInventoryItem(index)"
>
删除
</button>
>删除</button>
</div>
</div>
</div>
<div class="form-field">
<label>商品截图链接最多 5 </label>
<div class="screenshot-grid">
<div class="grid grid-cols-2 gap-2.5 max-md:grid-cols-1">
<input
v-for="(_, index) in screenshotInputSlots"
:key="index"
@@ -65,26 +77,30 @@
/>
</div>
</div>
<div class="form-field">
<label>商品介绍Markdown</label>
<textarea v-model="form.description"></textarea>
</div>
<div class="form-field">
<label>商品标签英文逗号分隔</label>
<input v-model="form.tagsText" placeholder="例如: chatgpt, linux, 域名" />
<p class="tag">用于前端搜索与筛选多个标签用英文逗号分开</p>
</div>
<div class="form-field">
<label>是否上架</label>
<select v-model="form.active">
<select v-model="form.active" class="rounded-lg border border-white/35 bg-white/70 px-3 py-2.5 text-[15px] outline-none" style="font-family: var(--font-serif);">
<option :value="true">上架</option>
<option :value="false">下架</option>
</select>
</div>
<div class="form-row">
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div class="form-field">
<label>
<input type="checkbox" v-model="form.requireLogin" class="inline-checkbox" />
<label class="flex items-center font-semibold text-apptext cursor-default">
<input type="checkbox" v-model="form.requireLogin" class="w-auto mr-1.5 accent-accent cursor-pointer" />
需要登录才能购买
</label>
<p class="tag">开启后未登录用户将无法完成购买</p>
@@ -94,26 +110,25 @@
<input v-model.number="form.maxPerAccount" type="number" min="0" step="1" placeholder="0 表示不限制" />
</div>
</div>
<div class="form-row">
<div class="form-field">
<label>发货模式</label>
<select v-model="form.deliveryMode">
<option value="auto">自动发货下单后自动提取内容</option>
<option value="manual">手动发货管理员手动处理</option>
</select>
<p class="tag">手动发货不会自动提取库存内容</p>
<p class="rounded-lg border border-white/35 bg-white/70 px-3 py-2.5 text-[15px] text-apptext m-0" style="font-family: var(--font-serif);">
自动发货下单后自动提取库存内容
</p>
<p class="tag">手动发货已暂时关闭后续版本再开放</p>
</div>
</div>
<div class="form-row">
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div class="form-field">
<label>
<input type="checkbox" v-model="form.showNote" class="inline-checkbox" />
<label class="flex items-center font-semibold text-apptext cursor-default">
<input type="checkbox" v-model="form.showNote" class="w-auto mr-1.5 accent-accent cursor-pointer" />
下单时显示备注输入框
</label>
</div>
<div class="form-field">
<label>
<input type="checkbox" v-model="form.showContact" class="inline-checkbox" />
<label class="flex items-center font-semibold text-apptext cursor-default">
<input type="checkbox" v-model="form.showContact" class="w-auto mr-1.5 accent-accent cursor-pointer" />
下单时显示联系方式输入框
</label>
<p class="tag">包含手机号和邮箱两个输入框</p>
@@ -121,7 +136,8 @@
</div>
</div>
<div class="modal-actions">
<!-- Actions -->
<div class="flex shrink-0 gap-3 border-t border-white/35 bg-white/95 pt-4 mt-5">
<button class="primary" @click="handleSubmit">{{ form.id ? '更新商品' : '新增商品' }}</button>
<button class="ghost" @click="resetForm">重置表单</button>
</div>
@@ -144,86 +160,45 @@ const props = defineProps({
const emit = defineEmits(['close', 'submit'])
const createScreenshotSlots = (values = []) =>
Array.from({ length: MAX_SCREENSHOT_URLS }, (_, i) => values[i] || '')
const createScreenshotSlots = (values = []) => Array.from({ length: MAX_SCREENSHOT_URLS }, (_, i) => values[i] || '')
const createInventoryItems = (values = []) => {
const items = values
.map((v) => (v || '').trim())
.filter((v) => v)
.slice(0, MAX_INVENTORY_ITEMS)
const items = values.map((v) => (v || '').trim()).filter((v) => v).slice(0, MAX_INVENTORY_ITEMS)
return items.length ? items : ['']
}
const normalizeInventoryItems = (values = []) =>
values
.map((item) => (item || '').trim())
.filter((item) => item)
.slice(0, MAX_INVENTORY_ITEMS)
values.map((item) => (item || '').trim()).filter((item) => item).slice(0, MAX_INVENTORY_ITEMS)
const normalizeScreenshotUrls = (values = []) =>
values.map((item) => item.trim()).filter(Boolean).slice(0, MAX_SCREENSHOT_URLS)
const makeEmptyForm = () => ({
id: '',
name: '',
price: 0,
discountPrice: 0,
tagsText: '',
coverUrl: '',
screenshotUrls: createScreenshotSlots(),
inventoryItems: createInventoryItems(),
description: '',
active: true,
requireLogin: false,
maxPerAccount: 0,
deliveryMode: 'auto',
showNote: false,
showContact: false
id: '', name: '', price: 0, discountPrice: 0, tagsText: '', coverUrl: '',
screenshotUrls: createScreenshotSlots(), inventoryItems: createInventoryItems(),
description: '', active: true, requireLogin: false, maxPerAccount: 0,
deliveryMode: 'auto', showNote: true, showContact: true
})
const form = reactive(makeEmptyForm())
const fillForm = (item) => {
Object.assign(form, {
id: item?.id || '',
name: item?.name || '',
price: item?.price || 0,
discountPrice: item?.discountPrice || 0,
tagsText: (item?.tags || []).join(','),
coverUrl: item?.coverUrl || '',
id: item?.id || '', name: item?.name || '', price: item?.price || 0, discountPrice: item?.discountPrice || 0,
tagsText: (item?.tags || []).join(','), coverUrl: item?.coverUrl || '',
screenshotUrls: createScreenshotSlots(item?.screenshotUrls || []),
inventoryItems: createInventoryItems(item?.codes || []),
description: item?.description || '',
active: item?.active ?? true,
requireLogin: item?.requireLogin ?? false,
maxPerAccount: item?.maxPerAccount ?? 0,
deliveryMode: item?.deliveryMode || 'auto',
showNote: item?.showNote ?? false,
showContact: item?.showContact ?? false
description: item?.description || '', active: item?.active ?? true,
requireLogin: item?.requireLogin ?? false, maxPerAccount: item?.maxPerAccount ?? 0,
deliveryMode: 'auto', showNote: item?.showNote ?? true, showContact: item?.showContact ?? true
})
}
const resetForm = () => {
Object.assign(form, makeEmptyForm())
}
const resetForm = () => { Object.assign(form, makeEmptyForm()) }
watch(
() => props.editItem,
(item) => {
if (item) {
fillForm(item)
} else {
resetForm()
}
},
{ immediate: true }
)
watch(() => props.editItem, (item) => { if (item) { fillForm(item) } else { resetForm() } }, { immediate: true })
const setInventoryInputRef = (el, index) => {
if (!el) return
inventoryInputRefs.value[index] = el
}
const setInventoryInputRef = (el, index) => { if (!el) return; inventoryInputRefs.value[index] = el }
const addInventoryItem = async () => {
form.inventoryItems.push('')
@@ -235,10 +210,7 @@ const addInventoryItem = async () => {
}
const removeInventoryItem = async (index) => {
if (form.inventoryItems.length <= 1) {
form.inventoryItems[0] = ''
return
}
if (form.inventoryItems.length <= 1) { form.inventoryItems[0] = ''; return }
form.inventoryItems.splice(index, 1)
inventoryInputRefs.value.splice(index, 1)
await nextTick()
@@ -249,183 +221,13 @@ const removeInventoryItem = async (index) => {
const handleSubmit = () => {
emit('submit', {
id: form.id,
name: form.name,
price: form.price,
discountPrice: form.discountPrice || 0,
tags: form.tagsText,
coverUrl: form.coverUrl,
id: form.id, name: form.name, price: form.price, discountPrice: form.discountPrice || 0,
tags: form.tagsText, coverUrl: form.coverUrl,
codes: normalizeInventoryItems(form.inventoryItems),
screenshotUrls: normalizeScreenshotUrls(form.screenshotUrls),
description: form.description,
active: form.active,
requireLogin: form.requireLogin,
maxPerAccount: form.maxPerAccount || 0,
deliveryMode: form.deliveryMode || 'auto',
showNote: form.showNote,
showContact: form.showContact
description: form.description, active: form.active, requireLogin: form.requireLogin,
maxPerAccount: form.maxPerAccount || 0, deliveryMode: 'auto',
showNote: form.showNote, showContact: form.showContact
})
}
</script>
<style scoped>
.modal-mask {
position: fixed;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(30, 28, 32, 0.35);
backdrop-filter: blur(8px);
}
.modal-card {
width: min(920px, 100%);
max-height: calc(100vh - 48px);
overflow: auto;
padding: 28px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--line);
box-shadow: 0 24px 60px rgba(33, 33, 40, 0.2);
}
.modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 22px;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 2px;
}
.modal-actions {
display: flex;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.form-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 12px;
}
.form-field input,
.form-field textarea {
border-radius: 8px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
padding: 10px 12px;
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
}
.form-field textarea {
min-height: 120px;
resize: vertical;
}
select {
border-radius: 8px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
padding: 10px 12px;
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
font-size: 15px;
}
.form-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field-head {
display: flex;
align-items: center;
gap: 12px;
}
.inventory-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.inventory-row {
display: flex;
align-items: center;
gap: 8px;
}
.inventory-row input {
flex: 1;
}
.screenshot-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.small {
padding: 7px 13px;
font-size: 13px;
border-radius: 999px;
}
.tag {
font-size: 14px;
color: var(--muted);
}
.inline-checkbox {
width: auto;
margin-right: 6px;
accent-color: var(--accent);
cursor: pointer;
}
.form-field label {
display: flex;
align-items: center;
font-weight: 600;
color: var(--text);
cursor: default;
}
@media (max-width: 900px) {
.modal-mask {
padding: 12px;
}
.modal-card {
padding: 18px;
max-height: calc(100vh - 24px);
}
.form-row,
.screenshot-grid {
grid-template-columns: 1fr;
}
.field-head {
flex-wrap: wrap;
}
.inventory-row {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -1,60 +1,73 @@
<template>
<div class="table-wrap">
<table class="table">
<div class="rounded-lg overflow-x-auto border border-white/35 max-md:[webkit-overflow-scrolling:touch]">
<table class="table bg-white/45 max-md:min-w-[580px]">
<thead>
<tr>
<th>商品</th>
<th>价格</th>
<th class="col-num">库存</th>
<th class="col-num">浏览量</th>
<th>状态</th>
<th>操作</th>
<tr class="bg-white/70 border-b-2 border-white/35">
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">商品</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">价格</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">库存</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted text-center max-md:px-2.5 max-md:py-2 max-md:text-xs">浏览量</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">状态</th>
<th class="px-4 py-3 text-sm font-semibold uppercase tracking-[0.6px] text-muted max-md:px-2.5 max-md:py-2 max-md:text-xs">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="item in products" :key="item.id" :class="{ 'row-inactive': !item.active }">
<td>
<div class="admin-product-cell">
<img class="admin-product-thumb" :src="item.coverUrl" :alt="item.name" />
<div class="admin-product-info">
<span class="product-name">{{ item.name }}</span>
<span class="product-id">{{ item.id }}</span>
</div>
</div>
</td>
<td>
<div v-if="item.price === 0" class="price-cell">
<span class="price-free">免费</span>
</div>
<div
v-else-if="item.discountPrice > 0 && item.discountPrice < item.price"
class="price-cell"
<tr
v-for="item in products"
:key="item.id"
class="border-b border-white/35 last:border-b-0 transition-colors hover:bg-white/70"
:class="{ 'opacity-55': !item.active }"
>
<span class="price-original">¥{{ item.price.toFixed(2) }}</span>
<span class="price-discount">¥{{ item.discountPrice.toFixed(2) }}</span>
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
<div class="flex items-center gap-3">
<img
class="w-12 h-12 rounded-md object-cover border border-white/35 flex-shrink-0 shadow-[0_4px_10px_rgba(33,33,40,0.1)] max-md:w-9 max-md:h-9"
:src="item.coverUrl" :alt="item.name"
/>
<div class="flex flex-col gap-0.5">
<span class="text-base font-semibold text-apptext max-md:text-[13px]">{{ item.name }}</span>
<span class="text-[13px] text-muted font-mono max-md:text-[11px]">{{ item.id }}</span>
</div>
</div>
<span v-else class="price-normal">¥{{ item.price.toFixed(2) }}</span>
</td>
<td class="col-num">
<span :class="['stock-badge', item.quantity === 0 ? 'stock-empty' : 'stock-ok']">
{{ item.quantity }}
</span>
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
<div v-if="item.price === 0" class="font-black text-[#3a9a68] text-[17px]">免费</div>
<div v-else-if="item.discountPrice > 0 && item.discountPrice < item.price" class="flex flex-col gap-0.5">
<span class="line-through text-muted text-sm">¥{{ item.price.toFixed(2) }}</span>
<span class="font-bold text-[#e8826a] text-[17px]">¥{{ item.discountPrice.toFixed(2) }}</span>
</div>
<span v-else class="font-semibold text-apptext">¥{{ item.price.toFixed(2) }}</span>
</td>
<td class="col-num">
<span class="view-count">{{ item.viewCount || 0 }}</span>
<td class="px-4 py-3.5 text-center align-middle max-md:px-2.5 max-md:py-2">
<span
class="inline-block px-2.5 py-0.5 rounded-full text-[15px] font-semibold"
:class="item.quantity === 0 ? 'bg-[rgba(201,90,106,0.12)] text-[#c95a6a]' : 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'"
>{{ item.quantity }}</span>
</td>
<td>
<span :class="['status-badge', item.active ? 'status-on' : 'status-off']">
{{ item.active ? '上架' : '下架' }}
</span>
<td class="px-4 py-3.5 text-center text-[15px] text-muted align-middle max-md:px-2.5 max-md:py-2">{{ item.viewCount || 0 }}</td>
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
<span
class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-sm font-semibold tracking-[0.3px]"
:class="item.active ? 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]' : 'bg-[rgba(140,140,145,0.12)] text-muted'"
>{{ item.active ? '上架' : '下架' }}</span>
</td>
<td>
<div class="row-actions">
<button class="act-edit" @click="$emit('edit', item)">编辑</button>
<button class="act-toggle" @click="$emit('toggle', item)">
{{ item.active ? '下架' : '上架' }}
</button>
<button class="act-delete" @click="$emit('remove', item)">删除</button>
<td class="px-4 py-3.5 align-middle max-md:px-2.5 max-md:py-2">
<div class="flex gap-1.5 items-center">
<button
class="px-3 py-1.5 rounded text-sm font-semibold border-none cursor-pointer transition bg-[rgba(145,168,208,0.15)] text-accent2 hover:bg-[rgba(145,168,208,0.28)] max-md:px-2 max-md:py-1 max-md:text-xs"
style="font-family: var(--font-serif);"
@click="$emit('edit', item)"
>编辑</button>
<button
class="px-3 py-1.5 rounded text-sm font-semibold border-none cursor-pointer transition bg-[rgba(180,154,203,0.12)] text-accent hover:bg-[rgba(180,154,203,0.24)] max-md:px-2 max-md:py-1 max-md:text-xs"
style="font-family: var(--font-serif);"
@click="$emit('toggle', item)"
>{{ item.active ? '下架' : '上架' }}</button>
<button
class="px-3 py-1.5 rounded text-sm font-semibold border-none cursor-pointer transition bg-[rgba(201,90,106,0.1)] text-[#c95a6a] hover:bg-[rgba(201,90,106,0.2)] max-md:px-2 max-md:py-1 max-md:text-xs"
style="font-family: var(--font-serif);"
@click="$emit('remove', item)"
>删除</button>
</div>
</td>
</tr>
@@ -64,254 +77,6 @@
</template>
<script setup>
defineProps({
products: { type: Array, default: () => [] }
})
defineProps({ products: { type: Array, default: () => [] } })
defineEmits(['edit', 'toggle', 'remove'])
</script>
<style scoped>
.table-wrap {
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--line);
}
.table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.45);
}
.table thead tr {
background: rgba(255, 255, 255, 0.7);
border-bottom: 2px solid var(--line);
}
.table th {
padding: 12px 16px;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--muted);
}
.table td {
padding: 14px 16px;
font-size: 16px;
vertical-align: middle;
}
.table tbody tr {
border-bottom: 1px solid var(--line);
transition: background 0.15s ease;
}
.table tbody tr:last-child {
border-bottom: none;
}
.table tbody tr:hover {
background: rgba(255, 255, 255, 0.7);
}
.table tbody tr.row-inactive {
opacity: 0.55;
}
.col-num {
text-align: center;
}
.admin-product-cell {
display: flex;
align-items: center;
gap: 12px;
}
.admin-product-thumb {
width: 48px;
height: 48px;
border-radius: 6px;
object-fit: cover;
border: 1px solid var(--line);
flex-shrink: 0;
box-shadow: 0 4px 10px rgba(33, 33, 40, 0.1);
}
.admin-product-info {
display: flex;
flex-direction: column;
gap: 3px;
}
.product-name {
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.product-id {
font-size: 13px;
color: var(--muted);
font-family: monospace;
}
.price-cell {
display: flex;
flex-direction: column;
gap: 2px;
}
.price-original {
text-decoration: line-through;
color: var(--muted);
font-size: 14px;
}
.price-discount {
font-weight: 700;
color: #e8826a;
font-size: 17px;
}
.price-free {
font-weight: 900;
color: #3a9a68;
font-size: 17px;
}
.price-normal {
font-weight: 600;
color: var(--text);
}
.stock-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 999px;
font-size: 15px;
font-weight: 600;
}
.stock-ok {
background: rgba(100, 185, 140, 0.15);
color: #3a9a68;
}
.stock-empty {
background: rgba(201, 90, 106, 0.12);
color: #c95a6a;
}
.view-count {
color: var(--muted);
font-size: 15px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 12px;
border-radius: 999px;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.3px;
}
.status-on {
background: rgba(100, 185, 140, 0.15);
color: #3a9a68;
}
.status-off {
background: rgba(140, 140, 145, 0.12);
color: var(--muted);
}
.row-actions {
display: flex;
gap: 6px;
align-items: center;
}
.act-edit,
.act-toggle,
.act-delete {
padding: 6px 12px;
border-radius: 5px;
font-size: 14px;
font-weight: 600;
border: none;
cursor: pointer;
transition: opacity 0.15s ease, transform 0.15s ease;
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
}
.act-edit {
background: rgba(145, 168, 208, 0.15);
color: var(--accent-2);
}
.act-edit:hover {
background: rgba(145, 168, 208, 0.28);
}
.act-toggle {
background: rgba(180, 154, 203, 0.12);
color: var(--accent);
}
.act-toggle:hover {
background: rgba(180, 154, 203, 0.24);
}
.act-delete {
background: rgba(201, 90, 106, 0.1);
color: #c95a6a;
}
.act-delete:hover {
background: rgba(201, 90, 106, 0.2);
}
@media (max-width: 900px) {
.table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.table {
min-width: 580px;
}
.table th,
.table td {
padding: 8px 10px;
font-size: 13px;
}
.admin-product-thumb {
width: 36px;
height: 36px;
}
.product-name {
font-size: 13px;
}
.product-id {
font-size: 11px;
}
.act-edit,
.act-toggle,
.act-delete {
padding: 4px 8px;
font-size: 12px;
}
}
</style>

View File

@@ -1,41 +1,54 @@
<template>
<div class="smtp-row">
<div class="smtp-header">
<span class="smtp-label">邮件通知配置</span>
<span class="smtp-desc tag">下单/发货时自动给用户发送通知邮件支持 QQ / 163 / Gmail / 自定义域名邮箱</span>
<span v-if="message" class="msg-tag" :class="{ error: message.includes('失败') }">{{ message }}</span>
<div class="px-[18px] py-3.5 bg-white/50 border border-white/35 rounded-lg mb-4">
<!-- Header -->
<div class="flex items-center gap-3 flex-wrap mb-3">
<span class="text-[15px] font-semibold text-apptext whitespace-nowrap">邮件通知配置</span>
<span class="text-[13px] text-muted">下单/发货时自动给用户发送通知邮件支持 QQ / 163 / Gmail / 自定义域名邮箱</span>
<span
v-if="message"
class="text-sm px-2 py-0.5 rounded whitespace-nowrap"
:class="message.includes('失败') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2 bg-[rgba(145,168,208,0.1)]'"
>{{ message }}</span>
</div>
<div class="smtp-enable-row">
<label class="smtp-toggle">
<input type="checkbox" v-model="form.enabled" />
<!-- Enable toggle -->
<div class="flex items-center gap-2.5 mb-3">
<label class="flex items-center gap-1.5 cursor-pointer text-sm text-apptext font-medium">
<input type="checkbox" v-model="form.enabled" class="w-4 h-4 accent-accent cursor-pointer" />
<span>启用邮件通知</span>
</label>
<span class="smtp-status-tag" :class="form.enabled ? 'tag-on' : 'tag-off'">
{{ form.enabled ? '已启用' : '已关闭' }}
</span>
<span
class="text-xs font-semibold px-2 py-0.5 rounded-full"
:class="form.enabled ? 'bg-[rgba(74,222,128,0.15)] text-[#2d8a4e]' : 'bg-black/[0.06] text-[#888]'"
>{{ form.enabled ? '已启用' : '已关闭' }}</span>
</div>
<div class="smtp-fields" :class="{ 'smtp-fields-disabled': !form.enabled }">
<label class="smtp-field">
<span>发件邮箱</span>
<input v-model="form.email" type="email" placeholder="noreply@yourdomain.com" :disabled="!form.enabled" />
<!-- Fields -->
<div
class="flex gap-2.5 flex-wrap items-end max-md:flex-col"
:class="{ 'opacity-45 pointer-events-none': !form.enabled }"
>
<label class="flex flex-col gap-1 flex-1 min-w-[160px] max-md:min-w-0 max-md:w-full">
<span class="text-[13px] text-muted">发件邮箱</span>
<input v-model="form.email" type="email" placeholder="noreply@yourdomain.com" :disabled="!form.enabled" class="px-2.5 py-2 rounded-lg border border-white/35 bg-white/70 text-sm outline-none focus:border-accent transition w-full" style="font-family: var(--font-serif);" />
</label>
<label class="smtp-field">
<span>SMTP 密码 / 授权码</span>
<input v-model="form.password" type="password" placeholder="QQ/163 填授权码;其他填密码" autocomplete="new-password" :disabled="!form.enabled" />
<label class="flex flex-col gap-1 flex-1 min-w-[160px] max-md:min-w-0 max-md:w-full">
<span class="text-[13px] text-muted">SMTP 密码 / 授权码</span>
<input v-model="form.password" type="password" placeholder="QQ/163 填授权码;其他填密码" autocomplete="new-password" :disabled="!form.enabled" class="px-2.5 py-2 rounded-lg border border-white/35 bg-white/70 text-sm outline-none focus:border-accent transition w-full" style="font-family: var(--font-serif);" />
</label>
<label class="smtp-field">
<span>发件人名称</span>
<input v-model="form.fromName" type="text" placeholder="萌芽小店" :disabled="!form.enabled" />
<label class="flex flex-col gap-1 flex-1 min-w-[160px] max-md:min-w-0 max-md:w-full">
<span class="text-[13px] text-muted">发件人名称</span>
<input v-model="form.fromName" type="text" placeholder="萌芽小店" :disabled="!form.enabled" class="px-2.5 py-2 rounded-lg border border-white/35 bg-white/70 text-sm outline-none focus:border-accent transition w-full" style="font-family: var(--font-serif);" />
</label>
<label class="smtp-field">
<span>SMTP 主机</span>
<input v-model="form.host" type="text" placeholder="smtp.qq.com" :disabled="!form.enabled" />
<label class="flex flex-col gap-1 flex-1 min-w-[160px] max-md:min-w-0 max-md:w-full">
<span class="text-[13px] text-muted">SMTP 主机</span>
<input v-model="form.host" type="text" placeholder="smtp.qq.com" :disabled="!form.enabled" class="px-2.5 py-2 rounded-lg border border-white/35 bg-white/70 text-sm outline-none focus:border-accent transition w-full" style="font-family: var(--font-serif);" />
</label>
<label class="smtp-field smtp-field-port">
<span>端口</span>
<input v-model="form.port" type="text" placeholder="465" :disabled="!form.enabled" />
<label class="flex flex-col gap-1 w-[90px] flex-shrink-0 max-md:w-full">
<span class="text-[13px] text-muted">端口</span>
<input v-model="form.port" type="text" placeholder="465" :disabled="!form.enabled" class="px-2.5 py-2 rounded-lg border border-white/35 bg-white/70 text-sm outline-none focus:border-accent transition w-full" style="font-family: var(--font-serif);" />
</label>
<button class="primary smtp-save-btn" type="button" :disabled="saving" @click="save">
<button class="primary self-end px-[18px] py-2 text-sm whitespace-nowrap" type="button" :disabled="saving" @click="save">
{{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
@@ -51,17 +64,9 @@ const props = defineProps({
})
const emit = defineEmits(['save'])
const saving = ref(false)
const form = reactive({
enabled: true,
email: '',
password: '',
fromName: '',
host: 'smtp.qq.com',
port: '465'
})
const form = reactive({ enabled: true, email: '', password: '', fromName: '', host: 'smtp.qq.com', port: '465' })
watch(() => props.config, (cfg) => {
if (!cfg) return
@@ -75,165 +80,6 @@ watch(() => props.config, (cfg) => {
const save = async () => {
saving.value = true
try {
await emit('save', { ...form })
} finally {
saving.value = false
}
try { await emit('save', { ...form }) } finally { saving.value = false }
}
</script>
<style scoped>
.smtp-row {
padding: 14px 18px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--line);
border-radius: 8px;
margin-bottom: 16px;
}
.smtp-header {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.smtp-label {
font-size: 15px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.smtp-desc {
font-size: 13px;
color: var(--muted);
}
.smtp-enable-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.smtp-toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 14px;
color: var(--text);
font-weight: 500;
}
.smtp-toggle input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent);
cursor: pointer;
}
.smtp-status-tag {
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
}
.tag-on {
background: rgba(74, 222, 128, 0.15);
color: #2d8a4e;
}
.tag-off {
background: rgba(0,0,0,0.06);
color: #888;
}
.smtp-fields {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: flex-end;
}
.smtp-fields-disabled {
opacity: 0.45;
pointer-events: none;
}
.smtp-field {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 160px;
}
.smtp-field-port {
max-width: 90px;
flex: 0 0 90px;
}
.smtp-field span {
font-size: 13px;
color: var(--muted);
}
.smtp-field input {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
font-size: 14px;
outline: none;
transition: border-color 0.2s ease;
width: 100%;
box-sizing: border-box;
}
.smtp-field input:focus {
border-color: var(--accent);
}
.smtp-save-btn {
align-self: flex-end;
padding: 8px 18px;
font-size: 14px;
white-space: nowrap;
}
.msg-tag {
font-size: 14px;
color: var(--accent-2);
padding: 3px 8px;
border-radius: 5px;
background: rgba(145, 168, 208, 0.1);
white-space: nowrap;
}
.msg-tag.error {
color: #c95a6a;
background: rgba(201, 90, 106, 0.08);
}
@media (max-width: 900px) {
.smtp-fields {
flex-direction: column;
}
.smtp-field {
min-width: 0;
width: 100%;
}
.smtp-field-port {
max-width: 100%;
flex: 1;
}
}
</style>

View File

@@ -1,21 +1,26 @@
<template>
<div v-if="show" class="token-row">
<div class="form-field token-field">
<div v-if="show" class="flex items-end gap-3.5 mb-4 p-4 bg-white/50 border border-white/35 rounded-lg flex-wrap max-md:flex-col max-md:items-stretch max-md:p-3">
<div class="form-field flex-1 max-w-[460px] !mb-0 max-md:max-w-full">
<label>管理 Token</label>
<div class="token-input-wrap">
<input :value="token" @input="$emit('update:token', $event.target.value)" placeholder="粘贴管理员令牌后自动加载…" />
<div class="flex gap-2 items-center">
<input
class="flex-1"
:value="token"
@input="$emit('update:token', $event.target.value)"
placeholder="粘贴管理员令牌后自动加载…"
/>
</div>
</div>
<p
v-if="message"
class="msg-tag"
:class="{ error: message.includes('失败') || message.includes('错误') }"
class="text-[15px] px-2.5 py-1 rounded bg-[rgba(145,168,208,0.1)] whitespace-nowrap"
:class="message.includes('失败') || message.includes('错误') ? 'text-[#c95a6a] bg-[rgba(201,90,106,0.08)]' : 'text-accent2'"
>{{ message }}</p>
</div>
<p
v-if="inlineMessage"
class="msg-inline"
:class="{ error: inlineMessage.includes('失败') || inlineMessage.includes('错误') }"
class="text-[15px] mb-3"
:class="inlineMessage.includes('失败') || inlineMessage.includes('错误') ? 'text-[#c95a6a]' : 'text-accent2'"
>{{ inlineMessage }}</p>
</template>
@@ -26,77 +31,5 @@ defineProps({
message: { type: String, default: '' },
inlineMessage: { type: String, default: '' }
})
defineEmits(['update:token'])
</script>
<style scoped>
.token-row {
display: flex;
align-items: flex-end;
gap: 14px;
margin-bottom: 16px;
padding: 16px 18px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid var(--line);
border-radius: 8px;
}
.token-field {
flex: 1;
max-width: 460px;
margin-bottom: 0;
}
.token-input-wrap {
display: flex;
gap: 8px;
align-items: center;
}
.token-input-wrap input {
flex: 1;
}
.msg-tag {
font-size: 15px;
color: var(--accent-2);
padding: 4px 10px;
border-radius: 5px;
background: rgba(145, 168, 208, 0.1);
white-space: nowrap;
}
.msg-tag.error {
color: #c95a6a;
background: rgba(201, 90, 106, 0.08);
}
.msg-inline {
font-size: 15px;
color: var(--accent-2);
margin-bottom: 12px;
}
.msg-inline.error {
color: #c95a6a;
}
.small {
padding: 7px 13px;
font-size: 13px;
border-radius: 999px;
}
@media (max-width: 900px) {
.token-row {
flex-direction: column;
align-items: stretch;
padding: 12px 12px;
}
.token-field {
max-width: 100%;
}
}
</style>

View File

@@ -1,15 +1,15 @@
<template>
<section class="page-card">
<div class="auth-callback">
<div v-if="status === 'loading'" class="auth-status">
<div class="flex justify-center py-12">
<div v-if="status === 'loading'" class="text-center flex flex-col items-center gap-3">
<h2>正在验证登录...</h2>
<p class="tag">请稍候正在与萌芽认证中心确认身份</p>
</div>
<div v-else-if="status === 'success'" class="auth-status">
<div v-else-if="status === 'success'" class="text-center flex flex-col items-center gap-3">
<h2>登录成功</h2>
<p class="tag">欢迎回来{{ displayName }}正在跳转...</p>
</div>
<div v-else class="auth-status">
<div v-else class="text-center flex flex-col items-center gap-3">
<h2>登录失败</h2>
<p class="tag">{{ errorMessage }}</p>
<button class="primary" @click="goHome">返回商店</button>
@@ -52,7 +52,6 @@ onMounted(async () => {
}
try {
// 验证 token 并从 SproutGate 获取最新用户信息
const verifyData = await verifySproutGateToken(token)
if (!verifyData.valid) {
status.value = 'error'
@@ -71,7 +70,6 @@ onMounted(async () => {
status.value = 'success'
setTimeout(() => router.push('/'), 1000)
} catch {
// 验证失败(如网络异常)时,回退使用 URL fragment 中的数据
setAuth({ token, account, username, avatarUrl: fragmentAvatar })
displayName.value = username || account
status.value = 'success'
@@ -79,19 +77,3 @@ onMounted(async () => {
}
})
</script>
<style scoped>
.auth-callback {
display: flex;
justify-content: center;
padding: 48px 0;
}
.auth-status {
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
</style>

View File

@@ -1,84 +1,109 @@
<template>
<!-- Floating toggle button -->
<button class="chat-fab" @click="toggleOpen" :title="open ? '关闭' : '联系客服'" aria-label="联系客服">
<!-- Floating action button -->
<button
class="fixed right-6 bottom-7 z-[1200] w-[52px] h-[52px] rounded-full bg-[#2c2b2d] text-white border-none cursor-pointer flex items-center justify-center shadow-[0_4px_18px_rgba(0,0,0,0.28)] transition hover:scale-[1.08] hover:shadow-[0_6px_24px_rgba(0,0,0,0.38)] max-[600px]:right-4 max-[600px]:bottom-5"
@click="toggleOpen" :title="open ? '关闭' : '联系客服'" aria-label="联系客服"
>
<Transition name="icon-flip" mode="out-in">
<svg v-if="!open" key="chat" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<svg v-else key="close" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
<svg v-if="!open" key="chat" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<svg v-else key="close" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</Transition>
</button>
<!-- Chat panel -->
<Transition name="chat-slide">
<div v-if="open" class="chat-panel">
<div
v-if="open"
class="fixed right-6 bottom-[94px] z-[1200] w-[340px] h-[500px] flex flex-col rounded-2xl bg-white/97 backdrop-blur-xl shadow-[0_16px_56px_rgba(0,0,0,0.16)] border border-white/60 overflow-hidden max-[600px]:right-2 max-[600px]:bottom-20 max-[600px]:w-[calc(100vw-16px)] max-[600px]:h-[440px]"
>
<!-- Header -->
<div class="chat-header">
<div class="chat-header-avatar">
<div class="flex items-center gap-2.5 px-4 py-3.5 bg-white border-b border-black/[0.08] flex-shrink-0">
<div class="w-9 h-9 rounded-full bg-[#f0f0f0] flex items-center justify-center flex-shrink-0 text-[#555]">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="chat-header-info">
<span class="chat-title">在线客服</span>
<span class="chat-subtitle">有问题随时找我们</span>
<div class="flex-1 flex flex-col gap-0">
<span class="text-[15px] font-bold text-[#1a1a1a] leading-[1.2]">在线客服</span>
<span class="text-[11px] text-[#888]">有问题随时找我们</span>
</div>
<button class="chat-close-btn" @click="toggleOpen" title="关闭">
<button
class="w-7 h-7 rounded-full bg-black/10 border-none text-[#333] cursor-pointer flex items-center justify-center transition hover:bg-black/[0.18] flex-shrink-0"
@click="toggleOpen" title="关闭"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Messages -->
<div class="chat-messages" ref="messagesEl">
<div v-if="loading" class="chat-status">
<span class="loading-dots"><span>.</span><span>.</span><span>.</span></span>
<!-- Messages area -->
<div
class="flex-1 overflow-y-auto px-3 py-3.5 flex flex-col gap-3 bg-[#f8f6fb] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
ref="messagesEl"
>
<div v-if="loading" class="text-center py-8">
<span class="text-[26px] text-muted tracking-[4px] loading-dots"><span>.</span><span>.</span><span>.</span></span>
</div>
<div v-else-if="messages.length === 0" class="chat-empty-state">
<div class="chat-empty-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.4"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<div v-else-if="messages.length === 0" class="flex flex-col items-center gap-1.5 py-10">
<div class="text-muted mb-1">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-40"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</div>
<p class="chat-empty-text">有任何疑问欢迎留言</p>
<p class="chat-empty-sub">客服在线时会尽快回复您</p>
<p class="text-sm font-semibold text-apptext m-0">有任何疑问欢迎留言</p>
<p class="text-xs text-muted m-0">客服在线时会尽快回复您</p>
</div>
<template v-else>
<div
v-for="msg in messages"
:key="msg.id"
:class="['msg-row', msg.fromAdmin ? 'msg-row--admin' : 'msg-row--me']"
class="flex items-end gap-1.5"
:class="msg.fromAdmin ? '' : 'flex-row-reverse'"
>
<div v-if="msg.fromAdmin" class="msg-avatar msg-avatar--admin">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</div>
<div class="msg-body">
<div class="msg-meta">
<span class="msg-name">{{ msg.fromAdmin ? '客服' : '我' }}</span>
<span class="msg-time">{{ formatTime(msg.sentAt) }}</span>
</div>
<div class="msg-content">{{ msg.content }}</div>
</div>
<div v-if="!msg.fromAdmin" class="msg-avatar msg-avatar--me">
<img v-if="props.userAvatar" :src="props.userAvatar" class="avatar-img" />
<!-- Avatar -->
<div
class="w-7 h-7 rounded-full text-[11px] font-bold flex items-center justify-center flex-shrink-0 overflow-hidden"
:class="msg.fromAdmin ? 'bg-[#2c2b2d] text-white' : 'bg-[#e8e8e8] text-[#444]'"
>
<template v-if="!msg.fromAdmin">
<img v-if="props.userAvatar" :src="props.userAvatar" class="w-7 h-7 object-cover rounded-full block" />
<span v-else>{{ (props.userName || '').charAt(0) }}</span>
</template>
<template v-else>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
</template>
</div>
<!-- Bubble -->
<div class="flex flex-col gap-0.5 max-w-[76%]" :class="msg.fromAdmin ? '' : 'items-end'">
<div class="flex gap-1.5 items-baseline px-0.5">
<span class="text-[11px] font-semibold text-muted">{{ msg.fromAdmin ? '客服' : '我' }}</span>
<span class="text-[10px] text-muted opacity-70">{{ formatTime(msg.sentAt) }}</span>
</div>
<div
class="px-3.5 py-2.5 text-sm leading-[1.55] whitespace-pre-wrap break-words shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="msg.fromAdmin
? 'bg-white text-[#1a1a1a] rounded-xl rounded-bl-[4px] border border-black/[0.08]'
: 'bg-[#2c2b2d] text-white rounded-xl rounded-br-[4px]'"
>{{ msg.content }}</div>
</div>
</div>
</template>
</div>
<!-- Input -->
<form class="chat-input-row" @submit.prevent="send">
<!-- Input row -->
<form class="flex items-center gap-2 px-3 py-2.5 border-t border-black/[0.06] bg-white flex-shrink-0" @submit.prevent="send">
<input
v-model="inputText"
class="chat-input"
class="flex-1 px-3.5 py-2.5 rounded-full border border-[rgba(180,154,203,0.3)] text-sm bg-[#f8f6fb] text-apptext outline-none transition focus:border-[#aaa] focus:bg-white"
style="font-family: var(--font-serif);"
placeholder="输入消息Enter 发送…"
maxlength="500"
:disabled="sending"
@keydown.enter.exact.prevent="send"
/>
<button class="chat-send-btn" type="submit" :disabled="sending || !inputText.trim()">
<button
class="w-[38px] h-[38px] rounded-full bg-[#2c2b2d] text-white border-none cursor-pointer flex items-center justify-center flex-shrink-0 transition disabled:opacity-55 disabled:cursor-default disabled:bg-[#888] hover:not-disabled:scale-[1.08] hover:not-disabled:opacity-85"
type="submit" :disabled="sending || !inputText.trim()"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2" fill="currentColor" stroke="none"/></svg>
</button>
</form>
<p class="chat-error" v-if="error">{{ error }}</p>
<p v-if="error" class="text-xs text-[#d64848] px-3.5 pb-2 text-center">{{ error }}</p>
</div>
</Transition>
</template>
@@ -105,36 +130,23 @@ let pollTimer = null
const formatTime = (iso) => {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
return new Date(iso).toLocaleString('zh-CN', { hour12: false, month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
}
const scrollBottom = async () => {
await nextTick()
if (messagesEl.value) {
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
const loadMessages = async () => {
try {
messages.value = await fetchMyChatMessages(props.userToken)
await scrollBottom()
} catch {
// 静默忽略轮询错误,避免频繁弹出错误提示
}
} catch { /* silent */ }
}
const startPolling = () => {
pollTimer = setInterval(loadMessages, 5000)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
const startPolling = () => { pollTimer = setInterval(loadMessages, 5000) }
const stopPolling = () => { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } }
const toggleOpen = async () => {
open.value = !open.value
@@ -171,375 +183,35 @@ onUnmounted(stopPolling)
</script>
<style scoped>
/* ── FAB button ── */
.chat-fab {
position: fixed;
right: 24px;
bottom: 28px;
z-index: 1200;
width: 52px;
height: 52px;
border-radius: 50%;
background: #2c2b2d;
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.28);
transition: transform 0.2s, box-shadow 0.2s;
}
.chat-fab:hover {
transform: scale(1.08);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.38);
}
/* ── Panel ── */
.chat-panel {
position: fixed;
right: 24px;
bottom: 94px;
z-index: 1200;
width: 340px;
height: 500px;
display: flex;
flex-direction: column;
border-radius: 16px;
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow: 0 16px 56px rgba(0, 0, 0, 0.16);
border: 1px solid rgba(255, 255, 255, 0.6);
overflow: hidden;
}
/* ── Header ── */
.chat-header {
display: flex;
align-items: center;
gap: 10px;
padding: 13px 16px;
background: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.chat-header-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #555;
}
.chat-header-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
}
.chat-title {
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.2;
}
.chat-subtitle {
font-size: 11px;
color: #888;
}
.chat-close-btn {
background: rgba(0,0,0,0.1);
border: none;
width: 28px;
height: 28px;
border-radius: 50%;
color: #333;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
flex-shrink: 0;
}
.chat-close-btn:hover {
background: rgba(0,0,0,0.18);
}
/* ── Messages area ── */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 14px 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: none;
background: #f8f6fb;
}
.chat-messages::-webkit-scrollbar {
display: none;
}
/* Loading dots */
.chat-status {
text-align: center;
padding: 30px 0;
}
.loading-dots {
font-size: 26px;
color: var(--muted);
letter-spacing: 4px;
}
.loading-dots span {
animation: blink 1.2s infinite;
}
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
/* ── Animations that cannot be expressed in Tailwind utilities ── */
@keyframes blink {
0%, 80%, 100% { opacity: 0.2; }
40% { opacity: 1; }
}
/* Empty state */
.chat-empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 40px 20px;
}
.loading-dots span { animation: blink 1.2s infinite; }
.loading-dots span:nth-child(2) { animation-delay: 0.2s; }
.loading-dots span:nth-child(3) { animation-delay: 0.4s; }
.chat-empty-icon {
color: var(--muted);
margin-bottom: 4px;
}
.chat-empty-text {
font-size: 14px;
font-weight: 600;
color: var(--text);
margin: 0;
}
.chat-empty-sub {
font-size: 12px;
color: var(--muted);
margin: 0;
}
/* Message rows */
.msg-row {
display: flex;
align-items: flex-end;
gap: 7px;
}
.msg-row--me {
flex-direction: row-reverse;
}
.msg-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.msg-avatar--admin {
background: #2c2b2d;
color: #fff;
}
.msg-avatar--me {
background: #e8e8e8;
color: #444;
overflow: hidden;
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
border-radius: 50%;
}
.avatar-img {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: 50%;
display: block;
}
.msg-body {
display: flex;
flex-direction: column;
gap: 3px;
max-width: 76%;
}
.msg-row--me .msg-body {
align-items: flex-end;
}
.msg-meta {
display: flex;
gap: 5px;
align-items: baseline;
padding: 0 3px;
}
.msg-name {
font-size: 11px;
font-weight: 600;
color: var(--muted);
}
.msg-time {
font-size: 10px;
color: var(--muted);
opacity: 0.7;
}
.msg-content {
padding: 9px 13px;
border-radius: 14px;
font-size: 14px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.msg-row--admin .msg-content {
background: #fff;
color: #1a1a1a;
border-bottom-left-radius: 4px;
border: 1px solid rgba(0,0,0,0.08);
}
.msg-row--me .msg-content {
background: #2c2b2d;
color: #fff;
border-bottom-right-radius: 4px;
}
/* ── Input row ── */
.chat-input-row {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-top: 1px solid rgba(0,0,0,0.06);
background: #fff;
flex-shrink: 0;
}
.chat-input {
flex: 1;
padding: 9px 13px;
border-radius: 999px;
border: 1.5px solid rgba(180, 154, 203, 0.3);
font-size: 14px;
font-family: inherit;
background: #f8f6fb;
color: var(--text);
outline: none;
transition: border-color 0.2s;
}
.chat-input:focus {
border-color: #aaa;
background: #fff;
}
.chat-send-btn {
width: 38px;
height: 38px;
border-radius: 50%;
background: #2c2b2d;
color: #fff;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: transform 0.15s, opacity 0.15s;
}
.chat-send-btn:disabled {
opacity: 0.55;
cursor: default;
background: #888;
}
.chat-send-btn:not(:disabled):hover {
transform: scale(1.08);
opacity: 0.85;
}
.chat-error {
font-size: 12px;
color: #d64848;
padding: 0 14px 8px;
text-align: center;
}
/* ── Animations ── */
/* Chat panel slide */
.chat-slide-enter-active,
.chat-slide-leave-active {
transition: opacity 0.22s ease, transform 0.22s ease;
}
.chat-slide-enter-from,
.chat-slide-leave-to {
opacity: 0;
transform: translateY(20px) scale(0.96);
}
/* Icon flip */
.icon-flip-enter-active,
.icon-flip-leave-active {
transition: opacity 0.15s, transform 0.15s;
}
.icon-flip-enter-from,
.icon-flip-leave-to {
opacity: 0;
transform: rotate(90deg) scale(0.7);
}
@media (max-width: 600px) {
.chat-panel {
right: 8px;
bottom: 80px;
width: calc(100vw - 16px);
height: 440px;
border-radius: 16px;
}
.chat-fab {
right: 16px;
bottom: 20px;
}
}
</style>

View File

@@ -1,93 +1,28 @@
<template>
<div class="maintenance-wrap">
<div class="maintenance-card">
<div class="maintenance-icon">
<div class="flex items-center justify-center min-h-[60vh] px-[5vw] py-10">
<div class="max-w-[480px] w-full glass-strong rounded-[var(--radius-default)] px-10 py-12 text-center shadow-[var(--shadow-card)] max-md:px-6 max-md:py-8">
<!-- Icon -->
<div class="inline-flex items-center justify-center w-20 h-20 rounded-[20px] bg-gradient-to-br from-accent to-accent2 text-white mb-6">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>
</svg>
</div>
<h1>站点维护中</h1>
<p class="divider-line"></p>
<p class="reason" v-if="reason">{{ reason }}</p>
<p class="reason muted" v-else>暂无维护说明请稍后再试</p>
<p class="tip">如有紧急需求请联系管理员</p>
<h1 class="text-[28px] font-bold text-apptext mb-4 tracking-[1px]">站点维护中</h1>
<!-- Divider -->
<div class="w-12 h-[3px] rounded-full bg-gradient-to-r from-accent to-accent2 opacity-60 mx-auto mb-5"></div>
<p v-if="reason" class="text-[17px] text-apptext leading-[1.7] mb-4">{{ reason }}</p>
<p v-else class="text-[17px] text-muted leading-[1.7] mb-4">暂无维护说明请稍后再试</p>
<p class="text-[15px] text-muted mt-2">如有紧急需求请联系管理员</p>
</div>
</div>
</template>
<script setup>
const props = defineProps({
reason: {
type: String,
default: ''
}
defineProps({
reason: { type: String, default: '' }
})
</script>
<style scoped>
.maintenance-wrap {
display: flex;
align-items: center;
justify-content: center;
min-height: 60vh;
padding: 40px 5vw;
}
.maintenance-card {
max-width: 480px;
width: 100%;
background: var(--glass-strong);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 48px 40px;
text-align: center;
box-shadow: var(--shadow);
backdrop-filter: blur(20px);
}
.maintenance-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: 20px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: white;
margin-bottom: 24px;
}
.maintenance-card h1 {
font-size: 28px;
font-weight: 700;
color: var(--text);
margin-bottom: 16px;
letter-spacing: 1px;
}
.divider-line {
width: 48px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
margin: 0 auto 20px;
opacity: 0.6;
}
.reason {
font-size: 17px;
color: var(--text);
line-height: 1.7;
margin-bottom: 16px;
}
.reason.muted {
color: var(--muted);
}
.tip {
font-size: 15px;
color: var(--muted);
margin-top: 8px;
}
</style>

View File

@@ -1,33 +1,34 @@
<template>
<Transition name="splash-fade" appear>
<div v-if="visible" class="splash-screen">
<!-- Ambient glow -->
<div class="splash-glow splash-glow-1"></div>
<div class="splash-glow splash-glow-2"></div>
<div v-if="visible" class="fixed inset-0 z-[99999] flex items-center justify-center overflow-hidden bg-gradient-to-br from-[#f8f5f2] via-[#e9f0f7] to-[#f4eaf1]">
<!-- Ambient glow blobs -->
<div class="glow-blob absolute w-[420px] h-[420px] -top-20 -left-24 rounded-full blur-[80px] pointer-events-none" style="background: radial-gradient(circle, rgba(180,154,203,0.35) 0%, transparent 70%); animation: glow-pulse 3s ease-in-out infinite alternate;"></div>
<div class="glow-blob absolute w-[380px] h-[380px] -bottom-16 -right-20 rounded-full blur-[80px] pointer-events-none" style="background: radial-gradient(circle, rgba(145,168,208,0.3) 0%, transparent 70%); animation: glow-pulse 3s ease-in-out 1.5s infinite alternate;"></div>
<!-- Content -->
<div class="splash-center">
<!-- Ripple rings -->
<div class="splash-rings">
<div class="ring ring-1"></div>
<div class="ring ring-2"></div>
<div class="ring ring-3"></div>
<!-- Logo -->
<div class="splash-logo-wrap">
<img src="/pwa-192x192.png" alt="萌芽小店" class="splash-logo" />
<!-- Center content -->
<div class="relative z-[1] flex flex-col items-center gap-0">
<!-- Rings + logo -->
<div class="relative w-40 h-40 flex items-center justify-center mb-7">
<div class="ring-anim absolute rounded-full border-2 border-[rgba(180,154,203,0.4)]" style="animation-delay: 0s;"></div>
<div class="ring-anim absolute rounded-full border-2 border-[rgba(180,154,203,0.4)]" style="animation-delay: 0.8s;"></div>
<div class="ring-anim absolute rounded-full border-2 border-[rgba(180,154,203,0.4)]" style="animation-delay: 1.6s;"></div>
<div class="relative z-[2]" style="animation: logo-float 2.8s ease-in-out infinite;">
<img
src="/pwa-192x192.png"
alt="萌芽小店"
class="w-[88px] h-[88px] rounded-[22px] object-cover shadow-[0_12px_40px_rgba(33,33,40,0.18),0_2px_8px_rgba(180,154,203,0.25)]"
/>
</div>
</div>
<!-- Title -->
<h1 class="splash-title">萌芽小店</h1>
<p class="splash-subtitle">加载中</p>
<h1 class="text-[26px] font-extrabold text-[#2c2b2d] tracking-[2px] mb-1.5" style="font-family: var(--font-serif);">萌芽小店</h1>
<p class="text-[13px] text-[#8a8690] tracking-[3px] mb-5">加载中</p>
<!-- Dots loader -->
<div class="splash-dots">
<span class="dot dot-1"></span>
<span class="dot dot-2"></span>
<span class="dot dot-3"></span>
<!-- Dots -->
<div class="flex gap-2 items-center mt-1">
<span class="dot-bounce block w-2 h-2 rounded-full bg-[#4ade80]" style="animation-delay: 0s;"></span>
<span class="dot-bounce block w-2 h-2 rounded-full bg-[#4ade80]" style="animation-delay: 0.2s;"></span>
<span class="dot-bounce block w-2 h-2 rounded-full bg-[#4ade80]" style="animation-delay: 0.4s;"></span>
</div>
</div>
</div>
@@ -35,104 +36,19 @@
</template>
<script setup>
defineProps({
visible: { type: Boolean, default: true }
})
defineProps({ visible: { type: Boolean, default: true } })
</script>
<style scoped>
.splash-screen {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #f8f5f2 0%, #e9f0f7 45%, #f4eaf1 100%);
overflow: hidden;
}
/* Ambient glow blobs */
.splash-glow {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
animation: glow-pulse 3s ease-in-out infinite alternate;
}
.splash-glow-1 {
width: 420px;
height: 420px;
top: -80px;
left: -100px;
background: radial-gradient(circle, rgba(180, 154, 203, 0.35) 0%, transparent 70%);
}
.splash-glow-2 {
width: 380px;
height: 380px;
bottom: -60px;
right: -80px;
background: radial-gradient(circle, rgba(145, 168, 208, 0.3) 0%, transparent 70%);
animation-delay: 1.5s;
}
/* Keyframe animations cannot be replicated with Tailwind utilities */
@keyframes glow-pulse {
from { opacity: 0.6; transform: scale(1); }
to { opacity: 1; transform: scale(1.12); }
}
/* Center content */
.splash-center {
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
position: relative;
z-index: 1;
}
/* Rings + logo */
.splash-rings {
position: relative;
width: 160px;
height: 160px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 28px;
}
.ring {
position: absolute;
border-radius: 50%;
border: 2px solid rgba(180, 154, 203, 0.4);
animation: ring-expand 2.4s ease-out infinite;
}
.ring-1 { width: 100px; height: 100px; animation-delay: 0s; }
.ring-2 { width: 100px; height: 100px; animation-delay: 0.8s; }
.ring-3 { width: 100px; height: 100px; animation-delay: 1.6s; }
@keyframes ring-expand {
0% { width: 88px; height: 88px; opacity: 0.8; border-color: rgba(180, 154, 203, 0.5); }
100% { width: 200px; height: 200px; opacity: 0; border-color: rgba(180, 154, 203, 0); }
}
/* Logo */
.splash-logo-wrap {
position: relative;
z-index: 2;
animation: logo-float 2.8s ease-in-out infinite;
}
.splash-logo {
width: 88px;
height: 88px;
border-radius: 22px;
object-fit: cover;
box-shadow: 0 12px 40px rgba(33, 33, 40, 0.18), 0 2px 8px rgba(180, 154, 203, 0.25);
0% { width: 88px; height: 88px; opacity: 0.8; border-color: rgba(180,154,203,0.5); }
100% { width: 200px; height: 200px; opacity: 0; border-color: rgba(180,154,203,0); }
}
@keyframes logo-float {
@@ -140,53 +56,25 @@ defineProps({
50% { transform: translateY(-8px); }
}
/* Title */
.splash-title {
font-size: 26px;
font-weight: 800;
color: #2c2b2d;
letter-spacing: 2px;
margin: 0 0 6px;
}
.splash-subtitle {
font-size: 13px;
color: #8a8690;
letter-spacing: 3px;
margin: 0 0 20px;
}
/* Dots */
.splash-dots {
display: flex;
gap: 8px;
align-items: center;
margin-top: 4px;
}
.dot {
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4ade80;
animation: dot-bounce 1.2s ease-in-out infinite;
}
.dot-1 { animation-delay: 0s; }
.dot-2 { animation-delay: 0.2s; }
.dot-3 { animation-delay: 0.4s; }
@keyframes dot-bounce {
0%, 60%, 100% { transform: scale(0.7); opacity: 0.5; }
30% { transform: scale(1.2); opacity: 1; }
}
/* Exit transition */
.ring-anim {
width: 100px;
height: 100px;
animation: ring-expand 2.4s ease-out infinite;
}
.dot-bounce {
animation: dot-bounce 1.2s ease-in-out infinite;
}
/* Splash exit transition */
.splash-fade-leave-active {
transition: opacity 0.45s ease, transform 0.45s ease;
}
.splash-fade-leave-to {
opacity: 0;
transform: scale(1.04);

View File

@@ -1,6 +1,7 @@
<template>
<section class="page-card" v-if="!loading && product">
<div class="checkout-header">
<!-- Header -->
<div class="flex items-center justify-between gap-4 mb-5">
<div>
<h2>立即下单 · {{ product.name }}</h2>
<p class="tag">一个扫码流程即可完成订单</p>
@@ -8,20 +9,24 @@
<button class="ghost" @click="goBack">返回商品</button>
</div>
<div class="checkout-grid">
<div class="checkout-summary">
<img :src="product.coverUrl" :alt="product.name" />
<div class="summary-content">
<!-- Grid: summary + form -->
<div class="grid gap-6 mb-5 max-md:grid-cols-1" style="grid-template-columns: minmax(280px, 1fr) 320px;">
<!-- Summary -->
<div class="flex gap-[18px] bg-white/80 rounded-[var(--radius-default)] border border-white/35 p-[18px] max-md:flex-col max-md:items-center">
<img
:src="product.coverUrl"
:alt="product.name"
class="w-[140px] h-[140px] object-cover rounded-lg max-md:w-full max-md:h-[200px]"
/>
<div class="flex-1 flex flex-col gap-2">
<h3>{{ product.name }}</h3>
<p class="tag">库存{{ product.quantity }}</p>
<p class="tag">浏览量{{ product.viewCount || 0 }}</p>
<p class="product-price">
<span v-if="isFree" class="free-price">免费</span>
<span
v-else-if="product.discountPrice > 0 && product.discountPrice < product.price"
>
<span class="price-original">¥ {{ product.price.toFixed(2) }}</span>
<span class="price-discount">¥ {{ product.discountPrice.toFixed(2) }}</span>
<p class="font-semibold text-accent2">
<span v-if="isFree" class="text-[#3a9a68] font-black">免费</span>
<span v-else-if="product.discountPrice > 0 && product.discountPrice < product.price">
<span class="line-through text-muted mr-1.5 text-[15px]">¥ {{ product.price.toFixed(2) }}</span>
¥ {{ product.discountPrice.toFixed(2) }}
</span>
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
</p>
@@ -29,14 +34,14 @@
</div>
</div>
<div class="checkout-form" v-if="!orderResult && product.requireLogin && !loggedIn">
<div class="require-login-block">
<p class="require-login-title">该商品需要登录后才能购买</p>
<a class="primary btn-inline" :href="loginUrl">立即登录</a>
</div>
<!-- Require-login gate -->
<div v-if="!orderResult && product.requireLogin && !loggedIn" class="flex flex-col gap-4 items-center p-8 text-center border border-white/35 rounded-lg bg-white/60">
<p class="text-[18px] font-semibold text-apptext">该商品需要登录后才能购买</p>
<a class="primary inline-block no-underline px-7 py-2.5 rounded-lg text-base font-bold cursor-pointer" :href="loginUrl">立即登录</a>
</div>
<form class="checkout-form" @submit.prevent="submitOrder" v-else-if="!orderResult">
<!-- Order form -->
<form v-else-if="!orderResult" class="flex flex-col gap-3" @submit.prevent="submitOrder">
<div class="form-field">
<label>下单数量</label>
<input
@@ -47,17 +52,16 @@
/>
</div>
<p class="tag">预计总价¥ {{ totalPrice.toFixed(2) }}</p>
<p class="tag limit-hint" v-if="product.maxPerAccount > 0">
<p class="tag text-accent" v-if="product.maxPerAccount > 0">
该商品每个账户最多可购买 {{ product.maxPerAccount }}
</p>
<div class="form-field" v-if="product.showNote">
<div class="form-field">
<label>备注选填</label>
<textarea v-model="form.note" placeholder="填写您的备注信息…" rows="3"></textarea>
</div>
<template v-if="product.showContact">
<div class="form-row contact-row">
<div class="grid grid-cols-2 gap-3 max-md:grid-cols-1">
<div class="form-field">
<label>手机号选填</label>
<input v-model="form.contactPhone" type="tel" placeholder="138xxxx0000" />
@@ -67,65 +71,67 @@
<input v-model="form.contactEmail" type="email" placeholder="you@example.com" />
</div>
</div>
</template>
<p class="tag login-hint" v-if="!loggedIn">
<a :href="loginUrl">登录萌芽账号</a> 后购买可享受历史订单记录专属优惠商品及更多购买权益
<p class="tag" v-if="!loggedIn">
<a class="text-accent2 no-underline font-semibold hover:underline" :href="loginUrl">登录萌芽账号</a> 后购买可享受历史订单记录专属优惠商品及更多购买权益
</p>
<div class="delivery-tip" v-if="product.deliveryMode === 'manual'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
该商品为手动发货付款后管理员将核验并处理您的订单
</div>
<button class="primary" type="submit" :disabled="submitting">
{{ submitting ? '生成中...' : '生成二维码下单' }}
</button>
<p class="error" v-if="error">{{ error }}</p>
<p class="text-[#d64848] text-[15px]" v-if="error">{{ error }}</p>
</form>
</div>
<section class="order-result" v-if="orderResult">
<h4>订单 {{ orderResult.orderId }} 已创建</h4>
<!-- Order result -->
<section v-if="orderResult" class="mt-4 text-center">
<h4 class="text-lg font-bold mb-2">订单 {{ orderResult.orderId }} 已创建</h4>
<template v-if="!confirmed">
<p class="tag">请扫描下方二维码完成付款</p>
<img :src="orderResult.qrCodeUrl" alt="下单二维码" />
<div class="confirm-area">
<p class="tag mb-2">请扫描下方二维码完成付款</p>
<img :src="orderResult.qrCodeUrl" alt="下单二维码" class="max-w-[320px] mx-auto w-full rounded-lg border border-white/35 my-3" />
<div class="flex flex-col items-center gap-2.5 my-4">
<button class="primary" :disabled="confirming" @click="doConfirm">
{{ confirming ? '确认中...' : '我已完成扫码付款' }}
</button>
<p class="tag">扫码完成后点击上方按钮获取购买内容</p>
</div>
<p class="error" v-if="confirmError">{{ confirmError }}</p>
<p class="text-[#d64848] text-[15px]" v-if="confirmError">{{ confirmError }}</p>
</template>
<template v-else>
<p class="tag confirmed-badge">订单已完成</p>
<div class="result-content" v-if="deliveredCodes.length">
<span class="inline-block px-4 py-1.5 rounded-full bg-gradient-to-br from-accent to-accent2 text-white text-[15px] font-semibold mb-3">订单已完成</span>
<div v-if="deliveredCodes.length" class="my-3 flex flex-col items-center gap-2">
<p class="tag">购买后内容</p>
<textarea class="delivery-box" readonly :value="deliveredCodes.join('\n')"></textarea>
<textarea
class="w-[min(520px,100%)] min-h-[120px] px-4 py-3.5 rounded-lg border border-white/35 bg-white/90 text-apptext text-base leading-[1.7] resize-none"
style="font-family: var(--font-serif);"
readonly
:value="deliveredCodes.join('\n')"
></textarea>
</div>
<div v-else-if="isManualDelivery" class="manual-delivery-notice">
<div v-else-if="isManualDelivery" class="flex items-start gap-3 p-4 bg-[rgba(90,180,120,0.08)] border border-[rgba(90,180,120,0.25)] rounded-lg text-apptext max-w-lg mx-auto text-left">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
<div>
<p class="manual-delivery-title">等待发货中</p>
<p class="text-base font-semibold text-[#3a9a68] mb-1">等待发货中</p>
<p class="tag">管理员将尽快处理您的订单并进行发货请关注您的邮箱或联系方式</p>
</div>
</div>
<p class="tag">感谢购买如有问题请联系售后邮箱</p>
<p class="tag mt-3">感谢购买如有问题请联系售后邮箱</p>
</template>
</section>
</section>
<section class="page-card" v-else-if="!loading">
<h2>未找到该商品</h2>
<p class="tag">该商品无法下单请返回商店页</p>
<div class="detail-actions">
<p class="tag mt-2">该商品无法下单请返回商店页</p>
<div class="flex gap-3 mt-[18px]">
<button class="ghost" @click="goBack">返回商品</button>
</div>
</section>
<section class="page-card" v-else>
<div class="status">加载中...</div>
<div class="py-6 text-muted">加载中...</div>
</section>
</template>
@@ -152,12 +158,7 @@ const deliveredCodes = ref([])
const loggedIn = computed(() => isLoggedIn())
const loginUrl = computed(() => getLoginUrl())
const form = reactive({
quantity: 1,
note: '',
contactPhone: '',
contactEmail: ''
})
const form = reactive({ quantity: 1, note: '', contactPhone: '', contactEmail: '' })
const isManualDelivery = ref(false)
const renderMarkdown = (content) => md.render(content || '')
@@ -171,11 +172,8 @@ const getPayPrice = (p) => {
const unitPrice = computed(() => getPayPrice(product.value))
const isFree = computed(() => unitPrice.value === 0)
const totalPrice = computed(() => {
if (!product.value) {
return 0
}
if (!product.value) return 0
const qty = form.quantity && form.quantity > 0 ? form.quantity : 1
return unitPrice.value * qty
})
@@ -183,9 +181,7 @@ const totalPrice = computed(() => {
const goBack = () => router.push('/')
const submitOrder = async () => {
if (!product.value) {
return
}
if (!product.value) return
error.value = ''
submitting.value = true
orderResult.value = null
@@ -211,9 +207,7 @@ const submitOrder = async () => {
}
const doConfirm = async () => {
if (!orderResult.value?.orderId) {
return
}
if (!orderResult.value?.orderId) return
confirmError.value = ''
confirming.value = true
try {
@@ -237,228 +231,3 @@ onMounted(async () => {
}
})
</script>
<style scoped>
.checkout-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.checkout-grid {
display: grid;
grid-template-columns: minmax(280px, 1fr) 320px;
gap: 24px;
margin-bottom: 20px;
}
.checkout-summary {
background: rgba(255, 255, 255, 0.8);
border-radius: var(--radius);
border: 1px solid var(--line);
padding: 18px;
display: flex;
gap: 18px;
}
.checkout-summary img {
width: 140px;
height: 140px;
object-fit: cover;
border-radius: 8px;
}
.summary-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.checkout-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.login-hint a {
color: var(--accent-2);
text-decoration: none;
font-weight: 600;
}
.login-hint a:hover {
text-decoration: underline;
}
.order-result {
margin-top: 16px;
text-align: center;
}
.order-result img {
max-width: 320px;
margin: 12px auto;
width: 100%;
border-radius: 8px;
border: 1px solid var(--line);
}
.confirm-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin: 16px 0;
}
.confirmed-badge {
display: inline-block;
padding: 6px 16px;
border-radius: 999px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: white;
font-size: 15px;
font-weight: 600;
margin-bottom: 12px;
}
.result-content {
margin: 12px 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.delivery-box {
width: min(520px, 100%);
min-height: 120px;
padding: 14px 16px;
border-radius: 8px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.9);
color: var(--text);
font-size: 16px;
line-height: 1.7;
resize: none;
}
.error {
color: #d64848;
font-size: 15px;
}
.status {
padding: 24px 0;
color: var(--muted);
}
.free-price {
color: #3a9a68;
font-weight: 900;
}
.require-login-block {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 32px 24px;
text-align: center;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(255, 255, 255, 0.6);
}
.require-login-title {
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.btn-inline {
display: inline-block;
text-decoration: none;
padding: 10px 28px;
border-radius: 8px;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
.limit-hint {
color: var(--accent);
}
textarea {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--line);
font-size: 15px;
font-family: inherit;
background: rgba(255, 255, 255, 0.9);
color: var(--text);
resize: vertical;
}
.contact-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.delivery-tip {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 14px;
background: rgba(90, 120, 200, 0.08);
border: 1px solid rgba(90, 120, 200, 0.2);
border-radius: 8px;
color: #5a78c8;
font-size: 14px;
}
.manual-delivery-notice {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: rgba(90, 180, 120, 0.08);
border: 1px solid rgba(90, 180, 120, 0.25);
border-radius: 8px;
color: var(--text);
}
.manual-delivery-title {
font-size: 16px;
font-weight: 600;
color: #3a9a68;
margin: 0 0 4px 0;
}
@media (max-width: 900px) {
.checkout-grid {
grid-template-columns: 1fr;
}
.checkout-summary {
flex-direction: column;
align-items: center;
}
.checkout-summary img {
width: 100%;
height: 200px;
}
.contact-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,82 +1,81 @@
<template>
<section class="page-card" v-if="!loading && product">
<div class="detail-body">
<div class="detail-summary">
<div class="flex flex-col gap-[18px]">
<!-- Summary -->
<div class="flex flex-col gap-2">
<h2>{{ product.name }}</h2>
<p class="tag">库存{{ product.quantity }}</p>
<p class="tag">浏览量{{ product.viewCount || 0 }}</p>
<p class="product-price">
<span v-if="isFree" class="free-price">免费</span>
<span
v-else-if="product.discountPrice > 0 && product.discountPrice < product.price"
>
<span class="price-original">¥ {{ product.price.toFixed(2) }}</span>
<span class="price-discount">¥ {{ product.discountPrice.toFixed(2) }}</span>
<p class="font-semibold text-accent2">
<span v-if="isFree" class="text-[#3a9a68] font-black">免费</span>
<span v-else-if="product.discountPrice > 0 && product.discountPrice < product.price">
<span class="line-through text-muted mr-1.5 text-[15px]">¥ {{ product.price.toFixed(2) }}</span>
<span class="font-semibold">¥ {{ product.discountPrice.toFixed(2) }}</span>
</span>
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
</p>
</div>
<div class="markdown detail-description" v-html="renderMarkdown(product.description)"></div>
<!-- Description (full markdown, not clamped) -->
<div
class="block text-apptext text-[17px] leading-[1.8] [display:block] [overflow:visible] [-webkit-box-orient:unset] [-webkit-line-clamp:unset] markdown"
v-html="renderMarkdown(product.description)"
></div>
<div class="detail-gallery" v-if="galleryImages.length">
<div class="detail-gallery-main">
<!-- Gallery -->
<div v-if="galleryImages.length" class="flex flex-col gap-3.5">
<div class="relative overflow-hidden rounded-[10px] border border-white/35 bg-white/45">
<button
v-if="galleryImages.length > 1"
class="gallery-nav prev"
class="absolute left-4 top-1/2 -translate-y-1/2 z-[1] w-[42px] h-[42px] rounded-full p-0 bg-white/88 text-apptext text-[28px] leading-none shadow-[0_10px_20px_rgba(33,33,40,0.12)]"
type="button"
aria-label="上一张"
@click="prevImage"
>
</button>
></button>
<img
class="detail-image"
class="w-full h-[360px] object-cover block max-md:h-60"
:src="galleryImages[currentImageIndex].url"
:alt="galleryImages[currentImageIndex].label"
/>
<button
v-if="galleryImages.length > 1"
class="gallery-nav next"
class="absolute right-4 top-1/2 -translate-y-1/2 z-[1] w-[42px] h-[42px] rounded-full p-0 bg-white/88 text-apptext text-[28px] leading-none shadow-[0_10px_20px_rgba(33,33,40,0.12)]"
type="button"
aria-label="下一张"
@click="nextImage"
>
</button>
></button>
</div>
<div class="detail-gallery-meta">
<div class="flex items-center gap-2.5 flex-wrap">
<span class="badge">{{ galleryImages[currentImageIndex].label }}</span>
<span class="tag">{{ currentImageIndex + 1 }} / {{ galleryImages.length }}</span>
</div>
<div class="detail-thumbs" v-if="galleryImages.length > 1">
<div v-if="galleryImages.length > 1" class="grid gap-2.5" style="grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));">
<button
v-for="(image, index) in galleryImages"
:key="`${image.url}-${index}`"
class="detail-thumb"
:class="{ active: index === currentImageIndex }"
class="flex flex-col gap-2 p-2 text-left bg-white/58 border transition max-md:p-1.5"
:class="index === currentImageIndex ? 'border-accent2 shadow-[0_10px_24px_rgba(145,168,208,0.16)]' : 'border-transparent'"
type="button"
@click="currentImageIndex = index"
>
<img :src="image.url" :alt="image.label" />
<span>{{ image.label }}</span>
<img :src="image.url" :alt="image.label" class="w-full h-[76px] object-cover rounded-md" />
<span class="text-sm text-muted">{{ image.label }}</span>
</button>
</div>
</div>
<div class="detail-actions">
<!-- Actions -->
<div class="flex gap-3 mt-[18px] flex-wrap">
<button class="buy-button" @click="goCheckout">立即下单</button>
<button
v-if="loggedIn"
class="ghost wishlist-action"
:class="{ 'wishlist-active': inWishlist }"
class="ghost"
:class="inWishlist ? 'text-[#e8826a] border-[rgba(232,130,106,0.35)] bg-[rgba(255,240,235,0.6)]' : 'text-muted'"
type="button"
@click="onToggleWishlist"
>
{{ inWishlist ? '★ 已收藏' : '☆ 加入收藏夹' }}
</button>
>{{ inWishlist ? '★ 已收藏' : '☆ 加入收藏夹' }}</button>
<button class="ghost" @click="goBack">返回商店首页</button>
</div>
</div>
@@ -84,14 +83,14 @@
<section class="page-card" v-else-if="!loading && !product">
<h2>未找到该商品</h2>
<p class="tag">请返回商店查看其他商品</p>
<div class="detail-actions">
<p class="tag mt-2">请返回商店查看其他商品</p>
<div class="flex gap-3 mt-[18px]">
<button class="ghost" @click="goBack">返回商店首页</button>
</div>
</section>
<section class="page-card" v-else>
<div class="status">加载中...</div>
<div class="py-6 text-muted">加载中...</div>
</section>
</template>
@@ -119,79 +118,43 @@ const getPayPrice = (p) => {
const unitPrice = computed(() => getPayPrice(product.value))
const isFree = computed(() => unitPrice.value === 0)
const renderMarkdown = (content) => md.render(content || '')
const galleryImages = computed(() => {
if (!product.value) {
return []
}
if (!product.value) return []
const images = []
const seen = new Set()
const appendImage = (url, label) => {
const trimmed = (url || '').trim()
if (!trimmed || seen.has(trimmed)) {
return
}
if (!trimmed || seen.has(trimmed)) return
seen.add(trimmed)
images.push({ url: trimmed, label })
}
appendImage(product.value.coverUrl, '商品封面')
const screenshots = Array.isArray(product.value.screenshotUrls)
? product.value.screenshotUrls
: []
screenshots.forEach((url, index) => {
appendImage(url, `商品截图 ${index + 1}`)
})
const screenshots = Array.isArray(product.value.screenshotUrls) ? product.value.screenshotUrls : []
screenshots.forEach((url, index) => { appendImage(url, `商品截图 ${index + 1}`) })
return images
})
const loggedIn = computed(() => isLoggedIn())
const inWishlist = computed(() => product.value ? isInWishlist(product.value.id) : false)
const onToggleWishlist = () => {
if (product.value) toggleWishlist(product.value.id)
}
const onToggleWishlist = () => { if (product.value) toggleWishlist(product.value.id) }
const goBack = () => {
router.push('/')
}
const goCheckout = () => {
if (!product.value) {
return
}
router.push(`/checkout/${product.value.id}`)
}
const goBack = () => { router.push('/') }
const goCheckout = () => { if (!product.value) return; router.push(`/checkout/${product.value.id}`) }
const prevImage = () => {
if (!galleryImages.value.length) {
return
}
currentImageIndex.value =
(currentImageIndex.value - 1 + galleryImages.value.length) % galleryImages.value.length
if (!galleryImages.value.length) return
currentImageIndex.value = (currentImageIndex.value - 1 + galleryImages.value.length) % galleryImages.value.length
}
const nextImage = () => {
if (!galleryImages.value.length) {
return
}
if (!galleryImages.value.length) return
currentImageIndex.value = (currentImageIndex.value + 1) % galleryImages.value.length
}
watch(
galleryImages,
(images) => {
if (!images.length || currentImageIndex.value >= images.length) {
currentImageIndex.value = 0
}
},
{ immediate: true }
)
watch(galleryImages, (images) => {
if (!images.length || currentImageIndex.value >= images.length) currentImageIndex.value = 0
}, { immediate: true })
onMounted(async () => {
try {
@@ -201,10 +164,7 @@ onMounted(async () => {
try {
const result = await recordProductView(product.value.id)
if (typeof result.viewCount === 'number') {
product.value = {
...product.value,
viewCount: result.viewCount
}
product.value = { ...product.value, viewCount: result.viewCount }
}
} catch (error) {
console.warn('record product view failed', error)
@@ -215,131 +175,3 @@ onMounted(async () => {
}
})
</script>
<style scoped>
.detail-body {
display: flex;
flex-direction: column;
gap: 18px;
}
.detail-summary {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-description {
display: block;
color: var(--text);
font-size: 17px;
line-height: 1.8;
-webkit-line-clamp: initial;
-webkit-box-orient: initial;
overflow: visible;
}
.detail-gallery {
display: flex;
flex-direction: column;
gap: 14px;
}
.detail-gallery-main {
position: relative;
overflow: hidden;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.45);
}
.gallery-nav {
position: absolute;
top: 50%;
z-index: 1;
width: 42px;
height: 42px;
border-radius: 999px;
padding: 0;
background: rgba(255, 255, 255, 0.88);
color: var(--text);
font-size: 28px;
line-height: 1;
transform: translateY(-50%);
box-shadow: 0 10px 20px rgba(33, 33, 40, 0.12);
}
.gallery-nav.prev {
left: 16px;
}
.gallery-nav.next {
right: 16px;
}
.detail-gallery-meta {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.detail-thumbs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.detail-thumb {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
text-align: left;
background: rgba(255, 255, 255, 0.58);
border: 1px solid transparent;
}
.detail-thumb.active {
border-color: var(--accent-2);
box-shadow: 0 10px 24px rgba(145, 168, 208, 0.16);
}
.detail-thumb img {
width: 100%;
height: 76px;
object-fit: cover;
border-radius: 6px;
}
.detail-thumb span {
font-size: 14px;
color: var(--muted);
}
.status {
padding: 24px 0;
color: var(--muted);
}
.free-price {
color: #3a9a68;
font-weight: 900;
}
.wishlist-action {
color: var(--muted);
}
.wishlist-action.wishlist-active {
color: #e8826a;
border-color: rgba(232, 130, 106, 0.35);
background: rgba(255, 240, 235, 0.6);
}
@media (max-width: 900px) {
.detail-thumb {
padding: 6px;
}
}
</style>

View File

@@ -1,13 +1,14 @@
<template>
<section class="page-card">
<div class="hero">
<h2>所有商品</h2>
<div class="filters">
<!-- Hero / filters row -->
<div class="flex items-center gap-4 flex-wrap mb-5 max-md:flex-col max-md:items-start max-md:gap-2.5 max-md:mb-3">
<h2 class="text-[26px] max-md:text-xl">所有商品</h2>
<div class="flex gap-3 flex-wrap max-md:flex-nowrap max-md:overflow-x-auto max-md:gap-2 max-md:w-full max-md:pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<button
v-for="opt in VIEW_OPTIONS"
:key="opt.value"
class="filter-btn"
:class="{ active: viewMode === opt.value }"
class="px-3.5 py-2 rounded-full border border-white/35 bg-white/60 text-apptext text-[15px] font-bold transition hover:-translate-y-px max-md:flex-shrink-0 max-md:px-2.5 max-md:py-1.5 max-md:text-[13px]"
:class="opt.value === viewMode ? 'bg-gradient-to-br from-accent to-accent2 !border-transparent !text-white shadow-[0_10px_30px_rgba(145,168,208,0.35)]' : ''"
type="button"
@click="setViewMode(opt.value)"
>
@@ -16,15 +17,16 @@
</div>
<input
v-model="searchQuery"
class="search-input"
class="w-[320px] max-w-full rounded-full border border-white/35 bg-white/70 px-3.5 py-2.5 text-[16px] outline-none focus:border-accent focus:shadow-[0_0_0_3px_rgba(180,154,203,0.18)] transition max-md:w-full max-md:text-sm max-md:py-2"
style="font-family: var(--font-serif);"
type="text"
placeholder="搜索商品/标签"
/>
</div>
<div v-if="loading" class="status">加载中...</div>
<div v-if="loading" class="py-6 text-muted">加载中...</div>
<div v-else>
<div class="grid">
<div class="product-grid">
<ProductCard
v-for="item in pagedProducts"
:key="item.id"
@@ -33,12 +35,12 @@
/>
</div>
<div class="pagination" v-if="totalPages > 1">
<button class="ghost pg-btn" :disabled="page === 1" @click="page = 1">首页</button>
<button class="ghost pg-btn" :disabled="page === 1" @click="page--">上一页</button>
<span class="pg-info"> {{ page }} / {{ totalPages }} </span>
<button class="ghost pg-btn" :disabled="page === totalPages" @click="page++">下一页</button>
<button class="ghost pg-btn" :disabled="page === totalPages" @click="page = totalPages">末页</button>
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2 mt-[22px] flex-wrap">
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === 1" @click="page = 1">首页</button>
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === 1" @click="page--">上一页</button>
<span class="text-sm text-muted px-1.5"> {{ page }} / {{ totalPages }} </span>
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === totalPages" @click="page++">下一页</button>
<button class="ghost rounded-full px-3.5 py-1.5 text-sm min-w-[60px] disabled:opacity-40 disabled:cursor-not-allowed" :disabled="page === totalPages" @click="page = totalPages">末页</button>
</div>
</div>
</section>
@@ -76,7 +78,6 @@ const updatePerPage = () => {
const getPayPrice = (item) => {
if (!item) return 0
// 折扣规则discountPrice > 0 且小于原价时启用price = 0 时显示"免费"
if (item.price === 0) return 0
if (item.discountPrice > 0 && item.discountPrice < item.price) return item.discountPrice
return item.price
@@ -95,38 +96,20 @@ const matchesSearch = (item) => {
const filteredProducts = computed(() => {
let list = [...products.value]
if (viewMode.value === 'free') list = list.filter((p) => isFree(p))
if ((searchQuery.value || '').trim()) list = list.filter((p) => matchesSearch(p))
switch (viewMode.value) {
case 'newest':
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
break
case 'most-sold':
list.sort((a, b) => (b.totalSold || 0) - (a.totalSold || 0))
break
case 'most-viewed':
list.sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0))
break
case 'price-high':
list.sort((a, b) => getPayPrice(b) - getPayPrice(a))
break
case 'price-low':
list.sort((a, b) => getPayPrice(a) - getPayPrice(b))
break
default:
break
case 'newest': list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); break
case 'most-sold': list.sort((a, b) => (b.totalSold || 0) - (a.totalSold || 0)); break
case 'most-viewed': list.sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0)); break
case 'price-high': list.sort((a, b) => getPayPrice(b) - getPayPrice(a)); break
case 'price-low': list.sort((a, b) => getPayPrice(a) - getPayPrice(b)); break
default: break
}
return list
})
const totalPages = computed(() =>
Math.max(1, Math.ceil(filteredProducts.value.length / perPage.value))
)
const totalPages = computed(() => Math.max(1, Math.ceil(filteredProducts.value.length / perPage.value)))
const pagedProducts = computed(() => {
const start = (page.value - 1) * perPage.value
return filteredProducts.value.slice(start, start + perPage.value)
@@ -159,134 +142,3 @@ onUnmounted(() => {
window.removeEventListener('resize', updatePerPage)
})
</script>
<style scoped>
.hero {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.filters {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.search-input {
width: 320px;
max-width: 100%;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7);
padding: 10px 14px;
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
font-size: 16px;
outline: none;
}
.search-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(180, 154, 203, 0.18);
}
.filter-btn {
padding: 8px 14px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.6);
color: var(--text);
font-size: 15px;
font-weight: 700;
transition: background 0.2s ease, color 0.2s ease, transform 0.15s ease;
}
.filter-btn:hover {
transform: translateY(-1px);
}
.filter-btn.active {
background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent;
color: #fff;
box-shadow: 0 10px 30px rgba(145, 168, 208, 0.35);
}
.status {
padding: 24px 0;
color: var(--muted);
}
.tag {
font-size: 14px;
color: var(--muted);
}
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-top: 22px;
flex-wrap: wrap;
}
.pg-btn {
padding: 6px 14px;
font-size: 14px;
border-radius: 999px;
min-width: 60px;
}
.pg-btn:disabled {
opacity: 0.38;
cursor: not-allowed;
}
.pg-info {
font-size: 14px;
color: var(--muted);
padding: 0 6px;
}
@media (max-width: 900px) {
.hero {
flex-direction: column;
align-items: flex-start;
gap: 10px;
margin-bottom: 12px;
}
.hero h2 {
font-size: 20px;
}
/* Filter buttons: scrollable horizontal row, no wrap */
.filters {
flex-wrap: nowrap;
overflow-x: auto;
gap: 8px;
width: 100%;
padding-bottom: 4px;
scrollbar-width: none;
}
.filters::-webkit-scrollbar {
display: none;
}
.filter-btn {
flex-shrink: 0;
padding: 6px 10px;
font-size: 13px;
}
.search-input {
width: 100%;
font-size: 14px;
padding: 8px 12px;
}
}
</style>

View File

@@ -1,50 +1,69 @@
<template>
<div
:class="['product-link', { 'is-disabled': isSoldOut }]"
class="block no-underline text-inherit h-full"
:class="isSoldOut ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
@click="$emit('click', item)"
>
<article class="product-card">
<div class="cover-wrap">
<img :src="item.coverUrl" :alt="item.name" />
<div v-if="isSoldOut" class="soldout-badge">已售空</div>
<div v-else-if="item.requireLogin" class="require-login-badge">需登录</div>
<article class="flex flex-col flex-1 gap-3 h-full bg-white/72 border border-white/35 rounded-lg p-[18px] shadow-[0_12px_30px_rgba(33,33,40,0.1)] max-md:p-2.5">
<!-- Cover image -->
<div class="relative rounded-md overflow-hidden">
<img
:src="item.coverUrl"
:alt="item.name"
class="w-full h-[140px] object-cover block max-md:h-[100px]"
/>
<div
v-if="isSoldOut"
class="absolute inset-0 flex items-center justify-center bg-[rgba(20,18,22,0.52)] backdrop-blur-sm text-white text-[22px] font-semibold tracking-[4px] pointer-events-none"
style="font-family: var(--font-serif);"
>已售空</div>
<div
v-else-if="item.requireLogin"
class="absolute top-2 right-2 px-2.5 py-0.5 rounded-full bg-accent/85 text-white text-[13px] font-bold pointer-events-none backdrop-blur-sm tracking-[0.5px]"
>需登录</div>
<button
v-if="loggedIn"
class="wishlist-btn"
:class="{ 'in-wishlist': inWishlist }"
class="absolute bottom-2 right-2 p-0 w-auto h-auto border-none bg-transparent shadow-none text-xl leading-none text-white/90 [text-shadow:0_1px_4px_rgba(0,0,0,0.5)] transition-transform hover:scale-125"
:class="inWishlist ? 'text-[#ffb347] [text-shadow:0_1px_6px_rgba(255,140,0,0.6)]' : ''"
type="button"
@click="onWishlistClick"
:title="inWishlist ? '取消收藏' : '加入收藏'"
>
{{ inWishlist ? '★' : '☆' }}
</button>
>{{ inWishlist ? '★' : '☆' }}</button>
</div>
<div class="card-top">
<h3 class="card-name">{{ item.name }}</h3>
<div class="card-price">
<span v-if="isFree" class="product-price free-price">免费</span>
<span
v-else-if="item.discountPrice > 0 && item.discountPrice < item.price"
class="product-price"
>
<span class="price-original">¥ {{ item.price.toFixed(2) }}</span>
<span class="price-discount">¥ {{ item.discountPrice.toFixed(2) }}</span>
<!-- Name + price -->
<div class="flex items-baseline justify-between gap-3 max-md:flex-row max-md:items-start max-md:gap-1">
<h3 class="text-[18px] font-bold text-apptext leading-[1.2] max-md:text-sm max-md:leading-[1.3] max-md:flex-1 max-md:min-w-0 max-md:break-all">{{ item.name }}</h3>
<div class="flex items-center justify-end max-md:flex-shrink-0 max-md:text-[13px]">
<span v-if="isFree" class="text-[#3a9a68] font-black max-md:text-[13px]">免费</span>
<span v-else-if="item.discountPrice > 0 && item.discountPrice < item.price" class="text-accent2 font-semibold">
<span class="line-through text-muted mr-1.5 text-[15px] max-md:text-[11px] max-md:mr-0.5">¥ {{ item.price.toFixed(2) }}</span>
¥ {{ item.discountPrice.toFixed(2) }}
</span>
<span v-else class="product-price">¥ {{ item.price.toFixed(2) }}</span>
<span v-else class="text-accent2 font-semibold">¥ {{ item.price.toFixed(2) }}</span>
</div>
</div>
<div class="card-mid">
<div class="markdown" v-html="renderedDescription"></div>
<!-- Description -->
<div class="flex-1 flex items-center justify-center text-center">
<div
class="markdown text-center text-[15px] text-muted leading-relaxed line-clamp-3 overflow-hidden max-md:text-xs max-md:line-clamp-2"
v-html="renderedDescription"
></div>
</div>
<div class="card-bottom">
<div v-if="item.tags && item.tags.length" class="tag-row">
<span v-for="tag in item.tags" :key="tag" class="tag-chip">{{ tag }}</span>
<!-- Tags + meta -->
<div class="flex flex-col gap-1.5 items-start max-md:gap-1 max-md:text-xs">
<div v-if="item.tags && item.tags.length" class="flex flex-wrap gap-2 mt-2 max-md:gap-1 max-md:mt-1">
<span
v-for="tag in item.tags"
:key="tag"
class="px-2.5 py-1 rounded-full text-sm font-bold text-accent2 bg-[rgba(145,168,208,0.08)] border border-[rgba(145,168,208,0.18)] max-md:text-[11px] max-md:px-1.5 max-md:py-0.5"
>{{ tag }}</span>
</div>
<div class="meta-row">
<div class="tag">库存{{ item.quantity }}</div>
<div class="tag">浏览量{{ item.viewCount || 0 }}</div>
<div class="flex gap-2.5 items-center flex-wrap">
<div class="text-sm text-muted max-md:text-[11px]">库存{{ item.quantity }}</div>
<div class="text-sm text-muted max-md:text-[11px]">浏览量{{ item.viewCount || 0 }}</div>
</div>
</div>
</article>
@@ -86,257 +105,3 @@ const isFree = computed(() => payPrice.value === 0)
const isSoldOut = computed(() => props.item && props.item.quantity === 0)
const renderedDescription = computed(() => md.render(props.item.description || ''))
</script>
<style scoped>
.product-link {
display: block;
text-decoration: none;
color: inherit;
height: 100%;
}
.product-link.is-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.product-card {
display: flex;
flex-direction: column;
flex: 1;
gap: 12px;
height: 100%;
}
.card-top {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.card-name {
font-size: 18px;
font-weight: 700;
color: var(--text);
line-height: 1.2;
}
.card-price {
display: flex;
align-items: center;
justify-content: flex-end;
}
.card-mid {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
}
.card-mid .markdown {
text-align: center;
font-size: 15px;
color: var(--muted);
line-height: 1.6;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
}
.card-bottom {
display: flex;
flex-direction: column;
gap: 6px;
align-items: flex-start;
}
.meta-row {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.cover-wrap {
position: relative;
border-radius: 6px;
overflow: hidden;
}
.cover-wrap img {
width: 100%;
height: 140px;
object-fit: cover;
display: block;
}
.soldout-badge {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(20, 18, 22, 0.52);
backdrop-filter: blur(3px);
color: #fff;
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
font-size: 22px;
font-weight: 600;
letter-spacing: 4px;
pointer-events: none;
}
.require-login-badge {
position: absolute;
top: 8px;
right: 8px;
padding: 3px 10px;
border-radius: 999px;
background: rgba(180, 154, 203, 0.85);
color: #fff;
font-size: 13px;
font-weight: 700;
pointer-events: none;
backdrop-filter: blur(4px);
letter-spacing: 0.5px;
}
.wishlist-btn {
position: absolute;
bottom: 8px;
right: 8px;
width: auto;
height: auto;
border-radius: 0;
border: none;
background: none;
backdrop-filter: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
line-height: 1;
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
transition: color 0.2s ease, transform 0.15s ease;
padding: 0;
box-shadow: none;
}
.wishlist-btn:hover {
transform: scale(1.2);
}
.wishlist-btn.in-wishlist {
color: #ffb347;
text-shadow: 0 1px 6px rgba(255, 140, 0, 0.6);
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.tag-chip {
padding: 4px 10px;
border-radius: 999px;
font-size: 14px;
font-weight: 700;
color: var(--accent-2);
background: rgba(145, 168, 208, 0.08);
border: 1px solid rgba(145, 168, 208, 0.18);
}
.tag {
font-size: 14px;
color: var(--muted);
}
.free-price {
color: #3a9a68;
font-weight: 900;
}
.price-original {
text-decoration: line-through;
color: var(--muted);
margin-right: 6px;
font-size: 15px;
}
.price-discount {
color: var(--accent-2);
font-weight: 600;
}
@media (max-width: 900px) {
.cover-wrap img {
height: 100px;
}
/* Name + price on one line, compressed */
.card-top {
flex-direction: row;
align-items: flex-start;
gap: 4px;
}
.card-name {
font-size: 14px;
line-height: 1.3;
flex: 1;
min-width: 0;
word-break: break-all;
}
.card-price {
flex-shrink: 0;
font-size: 13px;
}
.price-original {
font-size: 11px;
margin-right: 2px;
}
.free-price {
font-size: 13px;
}
.card-mid {
display: flex;
}
.card-mid .markdown {
font-size: 12px;
-webkit-line-clamp: 2;
}
.card-bottom {
gap: 4px;
font-size: 12px;
}
.tag-row {
display: flex;
gap: 4px;
margin-top: 4px;
}
.tag-chip {
font-size: 11px;
padding: 2px 7px;
}
.tag {
font-size: 11px;
}
}
</style>

View File

@@ -1,76 +1,104 @@
<template>
<section class="page-card" v-if="!loggedIn">
<div class="auth-prompt">
<div class="flex flex-col items-center gap-4 py-12 text-center">
<h2>请先登录</h2>
<p class="tag">登录萌芽账号后即可查看你的历史订单记录</p>
<a class="login-btn" :href="loginUrl">使用萌芽账号登录</a>
<a
class="inline-flex items-center px-[18px] py-[10px] rounded-lg bg-gradient-to-br from-accent to-accent2 text-white no-underline text-[17px] shadow-[0_10px_30px_rgba(145,168,208,0.35)] hover:-translate-y-px transition-transform"
:href="loginUrl"
>使用萌芽账号登录</a>
</div>
</section>
<section class="page-card" v-else>
<div class="orders-header">
<div class="header-info">
<h2>我的订单</h2>
<!-- Header -->
<div class="flex items-center justify-between gap-3.5 mb-[22px] flex-wrap max-md:gap-2.5">
<div>
<h2 class="text-[22px] mb-1 max-md:text-lg">我的订单</h2>
<p class="tag">
{{ authState.username || authState.account }}
<span v-if="orders.length" class="order-count"> {{ orders.length }} </span>
<span
v-if="orders.length"
class="ml-1.5 text-[13px] text-muted bg-[rgba(180,154,203,0.12)] px-2 py-0.5 rounded-full"
> {{ orders.length }} </span>
</p>
</div>
<div class="orders-actions">
<button class="ghost small-act" @click="refresh">
<div class="flex gap-2 flex-shrink-0">
<button
class="ghost inline-flex items-center gap-1.5 text-[13px] px-3 py-1.5"
@click="refresh"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>
刷新
</button>
<button class="ghost small-act" @click="goHome"> 返回商店</button>
<button class="ghost inline-flex items-center gap-1.5 text-[13px] px-3 py-1.5" @click="goHome"> 返回商店</button>
</div>
</div>
<div v-if="loading" class="status">加载中</div>
<div v-if="loading" class="py-6 text-muted text-sm">加载中</div>
<div v-else-if="orders.length === 0" class="empty-state">
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<div v-else-if="orders.length === 0" class="flex flex-col items-center gap-2.5 py-12 text-muted text-sm">
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="opacity-30"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<p>暂无订单记录</p>
</div>
<div v-else class="orders-list">
<div v-for="order in orders" :key="order.id" class="order-card">
<div v-else class="flex flex-col gap-3.5">
<div
v-for="order in orders"
:key="order.id"
class="rounded-[10px] bg-white/72 border border-white/35 overflow-hidden transition-shadow hover:shadow-[0_4px_16px_rgba(0,0,0,0.08)]"
>
<!-- Card header -->
<div class="order-card-header">
<div class="order-product-name">{{ order.productName }}</div>
<div class="order-status-badge" :class="order.deliveryMode === 'manual' && !order.deliveredCodes?.length ? 'badge-pending' : 'badge-done'">
<div class="flex items-center justify-between gap-3 px-4 pt-3.5 pb-2.5">
<div class="text-base font-bold text-apptext flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap max-md:text-[15px]">
{{ order.productName }}
</div>
<div
class="px-2.5 py-0.5 rounded-full text-xs font-semibold flex-shrink-0"
:class="order.deliveryMode === 'manual' && !order.deliveredCodes?.length
? 'bg-[rgba(145,168,208,0.15)] text-[#5a7db0]'
: 'bg-[rgba(100,185,140,0.15)] text-[#3a9a68]'"
>
{{ order.deliveryMode === 'manual' && !order.deliveredCodes?.length ? '等待发货' : '已完成' }}
</div>
</div>
<!-- Meta row -->
<div class="order-meta-row">
<span class="meta-item">
<div class="flex items-center gap-3.5 px-4 pb-3 flex-wrap border-b border-white/35 max-md:gap-2.5">
<span class="inline-flex items-center gap-1 text-[13px] text-muted">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{{ formatTime(order.createdAt) }}
</span>
<span class="meta-item">
<span class="inline-flex items-center gap-1 text-[13px] text-muted">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 12V22H4V12"/><path d="M22 7H2v5h20V7z"/><path d="M12 22V7"/><path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"/><path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"/></svg>
× {{ order.quantity }}
</span>
<span class="order-id-tag">{{ order.id.slice(0, 8) }}</span>
<span class="text-[11px] text-muted font-mono bg-black/[0.04] px-1.5 py-0.5 rounded">{{ order.id.slice(0, 8) }}</span>
</div>
<!-- Delivered codes -->
<div class="order-codes" v-if="order.deliveredCodes?.length">
<div class="codes-label">
<div v-if="order.deliveredCodes?.length" class="px-4 py-3">
<div class="flex items-center gap-1.5 text-xs font-semibold text-muted uppercase tracking-[0.5px] mb-2">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
发货内容
</div>
<div class="codes-list">
<div v-for="(code, i) in order.deliveredCodes" :key="i" class="code-item">
<span class="code-index">{{ i + 1 }}</span>
<span class="code-text">{{ code }}</span>
<div class="flex flex-col gap-1.5">
<div
v-for="(code, i) in order.deliveredCodes"
:key="i"
class="flex items-baseline gap-2.5 px-3 py-2 bg-white/90 border border-white/35 rounded-md"
>
<span class="text-[11px] text-muted font-bold min-w-[16px] flex-shrink-0">{{ i + 1 }}</span>
<span class="text-sm text-apptext break-all font-mono leading-relaxed">{{ code }}</span>
</div>
</div>
</div>
<!-- Manual delivery pending -->
<div class="pending-notice" v-else-if="order.deliveryMode === 'manual'">
<div
v-else-if="order.deliveryMode === 'manual'"
class="flex items-center gap-2 mx-4 my-3 px-3.5 py-2.5 bg-[rgba(145,168,208,0.1)] rounded-md border-l-[3px] border-accent2 text-[13px] text-[#5a7db0]"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
订单已提交等待人工发货请耐心等候
</div>
@@ -98,16 +126,8 @@ const formatTime = (isoStr) => {
if (!isoStr) return ''
try {
const d = new Date(isoStr)
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
} catch {
return isoStr
}
return d.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch { return isoStr }
}
const refresh = async () => {
@@ -123,243 +143,6 @@ const refresh = async () => {
}
onMounted(() => {
if (isLoggedIn()) {
refresh()
} else {
loading.value = false
}
if (isLoggedIn()) { refresh() } else { loading.value = false }
})
</script>
<style scoped>
/* ── Auth prompt ── */
.auth-prompt {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 48px 0;
text-align: center;
}
/* ── Header ── */
.orders-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
margin-bottom: 22px;
flex-wrap: wrap;
}
.header-info h2 {
margin: 0 0 4px;
font-size: 22px;
}
.order-count {
margin-left: 6px;
font-size: 13px;
color: var(--muted);
background: rgba(180, 154, 203, 0.12);
padding: 2px 8px;
border-radius: 999px;
}
.orders-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.small-act {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 13px;
padding: 6px 12px;
}
/* ── Empty & loading ── */
.status {
padding: 24px 0;
color: var(--muted);
font-size: 14px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 0;
color: var(--muted);
font-size: 14px;
}
/* ── Order list ── */
.orders-list {
display: flex;
flex-direction: column;
gap: 14px;
}
/* ── Order card ── */
.order-card {
padding: 0;
border-radius: 10px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid var(--line);
overflow: hidden;
transition: box-shadow 0.2s;
}
.order-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
.order-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 10px;
}
.order-product-name {
font-size: 16px;
font-weight: 700;
color: var(--text);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.order-status-badge {
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.badge-done {
background: rgba(100, 185, 140, 0.15);
color: #3a9a68;
}
.badge-pending {
background: rgba(145, 168, 208, 0.15);
color: #5a7db0;
}
/* Meta row */
.order-meta-row {
display: flex;
align-items: center;
gap: 14px;
padding: 0 16px 12px;
flex-wrap: wrap;
border-bottom: 1px solid var(--line);
}
.meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--muted);
}
.order-id-tag {
font-size: 11px;
color: var(--muted);
font-family: monospace;
background: rgba(0,0,0,0.04);
padding: 2px 7px;
border-radius: 4px;
}
/* Codes */
.order-codes {
padding: 12px 16px;
}
.codes-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 600;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.codes-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.code-item {
display: flex;
align-items: baseline;
gap: 10px;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid var(--line);
border-radius: 6px;
}
.code-index {
font-size: 11px;
color: var(--muted);
font-weight: 700;
min-width: 16px;
flex-shrink: 0;
}
.code-text {
font-size: 14px;
color: var(--text);
word-break: break-all;
font-family: monospace;
line-height: 1.5;
}
/* Pending notice */
.pending-notice {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 16px;
padding: 10px 14px;
background: rgba(145, 168, 208, 0.1);
border-radius: 6px;
border-left: 3px solid var(--accent-2);
font-size: 13px;
color: #5a7db0;
}
@media (max-width: 900px) {
.orders-header {
gap: 10px;
}
.header-info h2 {
font-size: 18px;
}
.order-product-name {
font-size: 15px;
}
.order-meta-row {
gap: 10px;
}
}
</style>

View File

@@ -1,25 +1,25 @@
<template>
<section class="page-card">
<div class="hero">
<div class="flex items-baseline gap-3 mb-5">
<h2>我的收藏夹</h2>
<p class="tag"> {{ wishlistItems.length }} 件商品</p>
</div>
<div v-if="loading" class="status">加载中...</div>
<div v-if="loading" class="py-6 text-muted">加载中...</div>
<div v-else-if="wishlistItems.length === 0" class="empty">
<div class="empty-icon"></div>
<p class="empty-title">收藏夹是空的</p>
<div v-else-if="wishlistItems.length === 0" class="flex flex-col items-center gap-3 py-[60px] text-center">
<div class="text-[52px] text-muted opacity-40 leading-none"></div>
<p class="text-xl font-semibold text-apptext">收藏夹是空的</p>
<p class="tag">在商品卡片上点击 即可加入收藏</p>
<button class="ghost" @click="$router.push('/')">去逛逛</button>
</div>
<div v-else class="grid">
<div v-else class="product-grid">
<ProductCard
v-for="item in wishlistItems"
:key="item.id"
:item="item"
:class="{ 'card-dimmed': !item.active || item.quantity === 0 }"
:class="{ 'opacity-50': !item.active || item.quantity === 0 }"
@click="handleClick"
/>
</div>
@@ -61,48 +61,3 @@ onMounted(async () => {
}
})
</script>
<style scoped>
.hero {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 20px;
}
.status {
padding: 24px 0;
color: var(--muted);
}
.tag {
font-size: 14px;
color: var(--muted);
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 0;
text-align: center;
}
.empty-icon {
font-size: 52px;
color: var(--muted);
opacity: 0.4;
line-height: 1;
}
.empty-title {
font-size: 20px;
font-weight: 600;
color: var(--text);
}
.card-dimmed {
opacity: 0.5;
}
</style>

View File

@@ -1,9 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
tailwindcss(),
vue(),
VitePWA({
registerType: 'autoUpdate',

View File

@@ -1,378 +0,0 @@
# 萌芽账户认证中心 API 文档
接入地址:
- 统一登录前端:`https://auth.shumengya.top`
- 后端 API`https://auth.api.shumengya.top`
- 本地开发 API`http://<host>:8080`
对外接入建议:
1. 第三方应用按钮跳转到统一登录前端。
2. 登录成功后回跳到业务站点。
3. 业务站点使用回跳带回的 `token` 调用后端 API。
示例按钮:
```html
<a href="https://auth.shumengya.top/?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123">
使用萌芽统一账户认证登录
</a>
```
回跳说明:
- 用户已登录时,统一登录前端会提示“继续授权”或“切换账号”。
- 登录成功后会回跳到 `redirect_uri`,并在 URL `#fragment` 中带上 `token``account``username``state`
- 第三方应用建议先调用 `GET /api/auth/verify``GET /api/auth/me` 校验令牌。
## 认证与统一登录
### 登录获取统一令牌
`POST /api/auth/login`
请求:
```json
{
"account": "demo",
"password": "demo123"
}
```
响应:
```json
{
"token": "jwt-token",
"expiresAt": "2026-03-14T12:00:00Z",
"user": {
"account": "demo",
"username": "示例用户",
"email": "demo@example.com",
"level": 0,
"sproutCoins": 10,
"secondaryEmails": ["demo2@example.com"],
"phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png",
"bio": "### 简介",
"createdAt": "2026-03-14T12:00:00Z",
"updatedAt": "2026-03-14T12:00:00Z"
}
}
```
### 校验令牌
`POST /api/auth/verify`
请求:
```json
{
"token": "jwt-token"
}
```
响应:
```json
{
"valid": true,
"user": { "account": "demo", "...": "..." }
}
```
### 获取当前用户信息
`GET /api/auth/me`
请求头:
`Authorization: Bearer <jwt-token>`
响应:
```json
{
"user": { "account": "demo", "...": "..." },
"checkIn": {
"rewardCoins": 1,
"checkedInToday": false,
"lastCheckInDate": "",
"lastCheckInAt": "",
"today": "2026-03-14"
}
}
```
> `user` 还会包含 `lastVisitAt`、`lastVisitDate`、`checkInDays`、`checkInStreak`、`visitDays`、`visitStreak` 等统计字段。
> 说明:密码不会返回。
### 每日签到
`POST /api/auth/check-in`
请求头:
`Authorization: Bearer <jwt-token>`
响应:
```json
{
"checkedIn": true,
"alreadyCheckedIn": false,
"rewardCoins": 1,
"awardedCoins": 1,
"message": "签到成功",
"user": { "account": "demo", "...": "..." }
}
```
### 更新当前用户资料
`PUT /api/auth/profile`
请求头:
`Authorization: Bearer <jwt-token>`
请求(字段可选):
```json
{
"password": "newpass",
"username": "新昵称",
"phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png",
"bio": "### 新简介"
}
```
响应:
```json
{
"user": { "account": "demo", "...": "..." }
}
```
## 用户广场
### 获取用户公开主页
`GET /api/public/users/{account}`
说明:
- 仅支持账户名 `account`,不支持昵称查询。
- 适合第三方应用展示用户公开资料。
响应:
```json
{
"user": {
"account": "demo",
"username": "示例用户",
"level": 3,
"sproutCoins": 10,
"avatarUrl": "https://example.com/avatar.png",
"bio": "### 简介"
}
}
```
### 注册账号(发送邮箱验证码)
`POST /api/auth/register`
请求:
```json
{
"account": "demo",
"password": "demo123",
"username": "示例用户",
"email": "demo@example.com"
}
```
响应:
```json
{
"sent": true,
"expiresAt": "2026-03-14T12:10:00Z"
}
```
### 验证邮箱并完成注册
`POST /api/auth/verify-email`
请求:
```json
{
"account": "demo",
"code": "123456"
}
```
响应:
```json
{
"created": true,
"user": { "account": "demo", "...": "..." }
}
```
### 忘记密码(发送重置验证码)
`POST /api/auth/forgot-password`
请求:
```json
{
"account": "demo",
"email": "demo@example.com"
}
```
响应:
```json
{
"sent": true,
"expiresAt": "2026-03-14T12:10:00Z"
}
```
### 重置密码
`POST /api/auth/reset-password`
请求:
```json
{
"account": "demo",
"code": "123456",
"newPassword": "newpass"
}
```
响应:
```json
{ "reset": true }
```
### 申请添加辅助邮箱(发送验证码)
`POST /api/auth/secondary-email/request`
请求头:
`Authorization: Bearer <jwt-token>`
请求:
```json
{
"email": "demo2@example.com"
}
```
响应:
```json
{
"sent": true,
"expiresAt": "2026-03-14T12:10:00Z"
}
```
### 验证辅助邮箱
`POST /api/auth/secondary-email/verify`
请求头:
`Authorization: Bearer <jwt-token>`
请求:
```json
{
"email": "demo2@example.com",
"code": "123456"
}
```
响应:
```json
{
"verified": true,
"user": { "account": "demo", "...": "..." }
}
```
## 管理端接口(需要管理员 Token
管理员 Token 存放在 `data/config/admin.json` 中;如果文件不存在,后端启动时会自动生成并写入该文件。
请求时可使用以下任一方式携带:
- Query`?token=<admin-token>`
- Header`X-Admin-Token: <admin-token>`
### 签到奖励设置
`GET /api/admin/check-in/config`
`PUT /api/admin/check-in/config`
请求:
```json
{
"rewardCoins": 1
}
```
- Header`Authorization: Bearer <admin-token>`
### 获取用户列表
`GET /api/admin/users`
响应:
```json
{
"total": 1,
"users": [{ "account": "demo", "...": "..." }]
}
```
### 新建用户
`POST /api/admin/users`
请求:
```json
{
"account": "demo",
"password": "demo123",
"username": "示例用户",
"email": "demo@example.com",
"level": 0,
"sproutCoins": 10,
"secondaryEmails": ["demo2@example.com"],
"phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png",
"bio": "### 简介"
}
```
### 更新用户
`PUT /api/admin/users/{account}`
请求(字段可选):
```json
{
"password": "newpass",
"username": "新昵称",
"level": 1,
"secondaryEmails": ["demo2@example.com"],
"sproutCoins": 99
}
```
### 删除用户
`DELETE /api/admin/users/{account}`
响应:
```json
{ "deleted": true }
```
## 数据存储说明
- 用户数据:`data/users/*.json`
- 注册待验证:`data/pending/*.json`
- 密码重置记录:`data/reset/*.json`
- 辅助邮箱验证:`data/secondary/*.json`
- 管理员 Token`data/config/admin.json`
- JWT 配置:`data/config/auth.json`
- 邮件配置:`data/config/email.json`
## 快速联调用示例
```bash
# 登录
curl -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"account":"demo","password":"demo123"}'
# 使用令牌获取用户信息
curl http://localhost:8080/api/auth/me \
-H 'Authorization: Bearer <jwt-token>'
```

View File

@@ -1,12 +1,12 @@
项目名称:萌芽小店项目-支持网关登录商品售卖平台
时间:2026.1 - 2026.03
技术栈Golang,Gin,MySQL,Redis,Vue
描述:前后端分离的轻量级商城平台,支持登录、商品展示与下单、自动/手动发货、邮件通知、收藏夹、客服聊天、维护模式与 PWA 离线缓存。
商品与订单管理、发货与订单状态联动、SMTP 邮件通知、后台运维(商品/订单/对话/站点设置)、用户聊天客服与维护模式能力。
通过限购与订单状态约束降低并发超卖风险;通过公开/用户/管理员分层实现权限边界隔离;通过分层架构与可运维配置提升可维护性。
后端采用分层解耦与 ORM 自动建表,支持多环境配置与容器化部署;前端路由守卫与接口封装增强可用性与一致性。
上线地址https://store.shumengya.top
开源地址https://github.com/shumengya/mengyastore
**项目名称:** 萌芽小店-网关登录商品售卖平台
**时间:** 2026.1 - 2026.03
**技术栈:** Golang,Gin,MySQL,Redis,Docker,Nginx,Vue,Vite,Tailwind CSS
**描述:** 前后端分离的轻量级商城,支持商品展示与下单、自动/手动发货、邮件通知、收藏夹、客服消息、维护模式与 PWA 离线缓存;后台覆盖商品/订单/对话与 SMTP、站点运维。后端分层Handler + Storage + GORM对接第三方网关登录与多环境配置、容器化部署公开接口、用户态与管理员令牌分层鉴权限购与订单状态约束降低并发超卖风险。引入 Redis商品列表与站点统计走 Cache-Aside、管理端写后失效浏览去重用 SET NX 适配多实例,缓存不可用时降级直查数据库;管理后台提供 API/MySQL/Redis 连接与基础信息面板,便于排查。前端路由守卫与接口封装统一调用方式
**项目地址:** https://store.smyhub.com | **开源地址:** https://github.com/shumengya/mengyastore

View File

@@ -0,0 +1,655 @@
# 萌芽账户认证中心 API 文档
访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`links.health``routePrefixes` 等)。**运行时未挂载**返回 Markdown 的 **`GET /api/docs`**;完整 HTTP 清单以 **`sproutgate-backend/main.go`** 与本文件、**`sproutgate-backend/后端文档.md`** 为准。
接入地址:
- 统一登录前端:`https://auth.shumengya.top`
- 后端 API`https://auth.api.shumengya.top`
- 本地开发 API`http://<host>:8080`
对外接入建议:
1. 第三方「登录」按钮跳转到统一登录前端的 **授权入口**(推荐 `.../authorize?...`,见下节)。
2. 用户在统一登录站点完成登录与授权确认后,回跳到业务站点。
3. 业务站点使用回跳 URL **`#`** 哈希中的 `token`,由**自有后端**调用 `POST /api/auth/verify``GET /api/auth/me` 校验并建立会话。
**推荐入口 URL生产** 将下列主机换为你的统一登录部署域名;子路径部署时在前端 `base` 后追加 `authorize`
```html
<a href="https://auth.shumengya.top/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&amp;state=abc123&amp;client_id=my-app&amp;client_name=%E6%88%91%E7%9A%84%E5%BA%94%E7%94%A8">
使用萌芽统一账户认证登录
</a>
```
说明:
- 若用户打开的是站点根路径且仅携带 `redirect_uri` / `return_url` 等查询参数,前端会自动 **重定向****`/authorize`**(保留同一套 query以使用独立授权确认页。
- `client_id` / `client_name` 会由浏览器写入 session并在用户登录时提交给后端用于累计 **应用接入记录**(详见下文「应用接入记录」)。
回跳说明:
-**`/authorize`** 流程下,已登录用户会看到明确的授权页(应用信息、权限说明、允许/拒绝);拒绝时回跳地址的 hash 中会携带 `error=access_denied` 等。
- 登录并「允许」后,浏览器回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)。
- 第三方拿到 `token` 后,建议服务端调用 **`POST /api/auth/verify`**(不更新「最后访问」等侧写,适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问/签到相关侧写,适合拉取个人中心级资料)校验并解析用户身份。
### 统一登录前端:查询参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `redirect_uri` | 与 `return_url` 至少其一 | 登录成功后的回跳地址,须进行 URL 编码;可为绝对 URL 或相对路径(相对路径相对统一登录站点解析)。 |
| `return_url` | 同上 | 与 `redirect_uri` 同义,二者都传时优先 `redirect_uri`。 |
| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 |
| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 |
| `client_id` | 否 | 第三方应用稳定标识;格式须符合后端校验(见下文「应用接入记录」)。写入 session 后随登录请求提交,用于 **应用接入记录**。 |
| `client_name` | 否 | 展示用名称(最长 128`client_id` 配对;可选。 |
### 回跳 URL`#` 哈希参数
成功授权后,前端将使用 [`URLSearchParams`](https://developer.mozilla.org/zh-CN/docs/Web/API/URLSearchParams) 写入哈希,例如:`https://app.example.com/auth/callback#token=...&expiresAt=...&account=...&username=...&state=...`
| 参数 | 说明 |
|------|------|
| `token` | JWT调用受保护接口时放在请求头 `Authorization: Bearer <token>`。 |
| `expiresAt` | 过期时间RFC3339与签发侧一致当前默认为登录时起算 **7 天**)。 |
| `account` | 账户名(与 JWT `sub` 一致)。 |
| `username` | 展示用昵称,可能为空。 |
| `state` | 若登录请求携带了 `state`,则原样返回。 |
业务站点回调页应用脚本读取 `location.hash`,解析后**仅在 HTTPS 环境**将 `token` 存于内存或安全存储,并尽快用后端 **`POST /api/auth/verify`** 校验(勿仅信任哈希中的明文字段)。
### 第三方后端接入建议
1. **仅信服务端**:回调页将 `token` 交给自有后端,由后端请求 `POST https://<api-host>/api/auth/verify`JSON body`{"token":"..."}`),根据 `valid``user.account` 建立会话。
2. **CORS**:浏览器直连 API 时须后端已配置 CORS本服务默认允许任意 `Origin`);若从服务端发起请求则不受 CORS 限制。
3. **令牌无效或封禁**`verify` / `me` 返回 **401**`verify` 因封禁返回 **403**`valid: false`)时,应作废本地会话并引导用户重新走统一登录。
## 应用接入记录authClients
用户在统一登录前端的 **个人中心 → 应用接入** 中可看到已与账号发生过认证关联的第三方应用列表。每条记录对应一个稳定的 **`client_id`**,并展示展示名(若有)、**首次接入时间**、**最近接入时间**(均为 RFC3339。持久化在 **MySQL `users` 表**中对应用户的 **`auth_clients` JSON 列**;对外 API 字段名仍为 **`authClients`**(与历史 JSON 文件存储时的形状一致)。
示例展示含义(与界面文案一致):**infogenie**(万象口袋)首次 `2026-03-22T16:33:24+08:00` · 最近 `2026-03-30T19:22:45+08:00` —— 其中括号内为 `client_name` / `X-Auth-Client-Name` / 登录体 `clientName` 带来的展示名,`infogenie``client_id`
### `client_id` 与名称规则
- **`client_id`(必填才能记一条)**:长度 164必须以 **英文字母或数字** 开头;其余字符仅可为 `[A-Za-z0-9_.:-]`(与后端正则一致)。
- **`client_name` / 展示名(可选)**:任意去首尾空白后的字符串,最长 **128** 字符,超出由服务端截断;对应存储字段为 `displayName`
### 在哪些接口上累计记录
记录仅在 **令牌有效、用户未封禁、且 `client_id` 通过校验** 时写入或刷新 **`lastSeenAt`**;已存在同一 `client_id` 时更新 **`lastSeenAt`**,必要时更新 **`displayName`****不改动** **`firstSeenAt`**。
| 方式 | 接口 / 场景 | 如何传递 `client_id` 与展示名 |
|------|----------------|--------------------------------|
| **A. 统一登录 URL** | 用户经前端完成登录 | 授权页 URL 查询参数 **`client_id`**、**`client_name`**(见上表)。前端写入 session登录请求 `POST /api/auth/login` 的 JSON 中会带 **`clientId`** / **`clientName`**。 |
| **B. 资源拥有者密码登录** | `POST /api/auth/login` | JSON **`clientId`**、**`clientName`**(可选,规则同上)。 |
| **C. 服务端持 JWT 调用** | `POST /api/auth/verify` | 请求头 **`X-Auth-Client`**(必须为合法 id、**`X-Auth-Client-Name`**(可选)。**Body 仅含 `token`,不能替代请求头。** |
| **D. 服务端持 JWT 调用** | `GET /api/auth/me` | 同上:**`X-Auth-Client`**、**`X-Auth-Client-Name`**,与 **`Authorization: Bearer`** 同时使用。 |
说明:
- **A + B**:在登录成功、签发 JWT **之后**写库;`POST /api/auth/login` 的响应 `user` 为 `OwnerPublic()`**含** `authClients`(若本次写入了新记录)。
- **C**`POST /api/auth/verify` 在校验通过且未封禁后写库;响应 `user`**`Public()`,不含 `authClients`**,避免向第三方泄露用户在其他应用上的关联列表。
- **D**`GET /api/auth/me` 在校验通过且未封禁后写库;响应 `user`**`OwnerPublic()`,含 `authClients`**,与前端个人中心一致。
**封禁用户**`POST /api/auth/verify``GET /api/auth/me` 在命中封禁或令牌无效时 **不会** 更新应用接入记录(在写库逻辑之前即返回错误)。
**浏览器直连 API**:若前端或网关需带上 `X-Auth-Client`,请确认 CORS 允许该自定义头(本服务 CORS 已包含 `X-Auth-Client``X-Auth-Client-Name`)。
### 存储结构(`authClients` 数组元素)
```json
{
"clientId": "infogenie",
"displayName": "万象口袋",
"firstSeenAt": "2026-03-22T16:33:24+08:00",
"lastSeenAt": "2026-03-30T19:22:45+08:00"
}
```
## 认证与统一登录
### 登录获取统一令牌
`POST /api/auth/login`
请求:
```json
{
"account": "demo",
"password": "demo123",
"clientId": "my-app",
"clientName": "我的应用"
}
```
`clientId` / `clientName` 可选;规则与应用接入请求头一致。传入且格式合法时,会在登录成功后写入 **应用接入记录**(见上文 **「应用接入记录authClients」**)。
响应:
```json
{
"token": "jwt-token",
"expiresAt": "2026-03-14T12:00:00Z",
"user": {
"account": "demo",
"username": "示例用户",
"email": "demo@example.com",
"level": 0,
"sproutCoins": 10,
"secondaryEmails": ["demo2@example.com"],
"phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 简介",
"createdAt": "2026-03-14T12:00:00Z",
"updatedAt": "2026-03-14T12:00:00Z"
}
}
```
若账户已被管理员封禁,返回 **403**,且**不会签发 JWT**,响应示例:
```json
{
"error": "account is banned",
"banReason": "违规内容"
}
```
`banReason` 可能为空字符串或省略。
**常见 HTTP 状态码(登录)**
| 状态码 | 含义 |
|--------|------|
| 200 | 成功,返回 `token``expiresAt``user`。 |
| 400 | 请求体非法或缺少 `account` / `password`。 |
| 401 | 账户不存在或密码错误(统一文案 `invalid credentials`)。 |
| 403 | 账户已封禁(见上文 JSON。 |
| 500 | 服务器内部错误(读库、签发 JWT 失败等)。 |
**JWT 概要**:算法 **HS256**;载荷含 `account`(与 `sub` 一致)、`iss`(见 `data/config/auth.json`)、`iat` / `exp`。客户端只需透传字符串,**勿在前端解析密钥**。
### 校验令牌
`POST /api/auth/verify`
请求头:**`Content-Type: application/json`**。可选 **`X-Auth-Client`**、**`X-Auth-Client-Name`**(累计应用接入记录,见专章)。
请求体:
```json
{
"token": "jwt-token"
}
```
响应:
```json
{
"valid": true,
"user": { "account": "demo", "...": "..." }
}
```
若账户已封禁,返回 **403**,正文示例(不返回 `user`
```json
{
"valid": false,
"error": "account is banned",
"banReason": "违规内容"
}
```
令牌过期、签名错误、issuer 不匹配、`token` 被撤销(`token_epoch`)等失败时返回 **401**,示例:`{"valid": false, "error": "invalid token"}``{"valid": false, "error": "token revoked"}`(以服务端实际字段为准)。
`verify``me` 的取舍:**仅校验身份、默认不更新「最后访问」等侧写**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer
**应用接入记录**:在 **`verify` / `me`** 上使用 **`X-Auth-Client`**、**`X-Auth-Client-Name`** 的约定,以及响应 `user` 是否包含 `authClients`,见上文 **「应用接入记录authClients」**。
### 获取当前用户信息
`GET /api/auth/me`
请求头:
- **`Authorization: Bearer <jwt-token>`**(必填)
可选 — **应用接入记录**(与 `verify` 相同语义,见 **「应用接入记录authClients」**
- **`X-Auth-Client`**、**`X-Auth-Client-Name`**
可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」
- `X-Visit-Ip`:客户端公网 IP与地理接口返回的 `ip` 一致即可)
- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName``regionName``cityName` 拼接为 `中国 四川 成都`
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求环境变量 **`GEO_LOOKUP_URL`** 指定的基址(默认与 `internal/clientgeo.DefaultLookupURL` 一致:`https://cf-ip-geo.smyhub.com/api`,实际请求会附加 **`?ip=`**)解析展示位置并写入用户记录。
响应:
```json
{
"user": { "account": "demo", "...": "..." },
"checkIn": {
"rewardCoins": 1,
"checkedInToday": false,
"lastCheckInDate": "",
"lastCheckInAt": "",
"today": "2026-03-14"
}
}
```
> `user` 还会包含 `lastVisitAt`、`lastVisitDate`、`checkInDays`、`checkInStreak`、`visitDays`、`visitStreak` 等统计字段。
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp`、`lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**`POST /api/auth/verify` 返回的 `user``Public()`)中不包含这两项**(避免第三方校验响应携带访问隐私)。**`GET /api/public/users/:account` 的 `user``PublicProfile()`)会包含这两项**,与公开主页展示策略一致。
> 说明:密码不会返回。
若账户在登录后被封禁,持旧 JWT 调用 `GET /api/auth/me``PUT /api/auth/profile``POST /api/auth/check-in`、辅助邮箱等需登录接口时,返回 **403**,正文同登录封禁响应(`error` + 可选 `banReason`)。客户端应作废本地令牌。
### 每日签到
`POST /api/auth/check-in`
请求头:
`Authorization: Bearer <jwt-token>`
响应:
```json
{
"checkedIn": true,
"alreadyCheckedIn": false,
"rewardCoins": 1,
"awardedCoins": 1,
"message": "签到成功",
"user": { "account": "demo", "...": "..." }
}
```
### 更新当前用户资料
`PUT /api/auth/profile`
请求头:
`Authorization: Bearer <jwt-token>`
请求(字段可选):
```json
{
"password": "newpass",
"username": "新昵称",
"phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 新简介"
}
```
说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`
响应:
```json
{
"user": { "account": "demo", "...": "..." }
}
```
## 用户广场
### 获取公开用户目录
`GET /api/public/users`
无需鉴权。返回未封禁用户的简要列表,默认按 **`createdAt`**(注册时间)升序。
响应:
```json
{
"total": 2,
"users": [
{
"account": "demo",
"username": "示例用户",
"level": 0,
"sproutCoins": 10,
"avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 简介",
"createdAt": "2026-03-14T12:00:00+08:00"
}
]
}
```
### 获取用户公开主页
`GET /api/public/users/{account}`
说明:
- 仅支持路径参数 **账户名** `account`(可与存储大小写不同,服务端按不区分大小写匹配),不支持昵称查询。
- 适合第三方应用展示用户公开资料。
- 若该账户已被封禁,返回 **404** `{"error":"user not found"}`(与不存在账户相同,避免公开资料泄露)。
- 响应体在 `user` 之外包含累计赞数 **`profileLikeCount`**。
- **`Authorization: Bearer`** 可选:若传入**有效未封禁**用户令牌且非主页主人,响应可额外包含 `viewerHasLikedToday``viewerLikesRemainingToday``profileLikeDailyMax`(当日是否已赞该主页、当日还可给多少人点赞、每自然日上限当前为 **5**,以后端常量为准)。若浏览者即主页主人,可返回 `viewerIsOwner: true`
- `user` 中含该用户**最近一次被服务端记录的**访问 IP`lastVisitIp`)与展示地理位置(`lastVisitDisplayLocation``POST /api/auth/verify` 返回的用户 JSON **不含**上述两项。
响应示例(未带 Bearer
```json
{
"user": {
"account": "demo",
"username": "示例用户",
"level": 3,
"sproutCoins": 10,
"avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"lastVisitIp": "203.0.113.1",
"lastVisitDisplayLocation": "中国 广东省 深圳市",
"bio": "### 简介"
},
"profileLikeCount": 12
}
```
### 公开主页点赞
`POST /api/public/users/{account}/like`
请求头:**`Authorization: Bearer <jwt-token>`**(必填)。
为路径中 **`account`** 对应用户的公开主页点赞。规则摘要(与 `internal/storage` 一致):
- 不能给自己点赞;每个点赞者对同一主页 **每个自然日最多一次**
- 每个点赞者每个自然日最多给 **5** 位**不同**用户点赞(超限返回 **400**,正文中可含 `viewerLikesRemainingToday``profileLikeDailyMax`)。
- 目标用户不存在或已封禁:**404**;点赞者封禁或令牌无效:**401** / **403** 等,以服务端 `error` 为准。
成功响应示例:
```json
{
"profileLikeCount": 13,
"viewerHasLikedToday": true,
"viewerLikesRemainingToday": 4,
"profileLikeDailyMax": 5
}
```
### 公开注册策略
`GET /api/public/registration-policy`
无需鉴权。用于前端判断是否展示「邀请码」输入框。
响应:
```json
{
"requireInviteCode": false
}
```
`requireInviteCode`**true** 时,`POST /api/auth/register` 必须携带有效 `inviteCode`(见下节)。
### 注册账号(发送邮箱验证码)
`POST /api/auth/register`
请求:
```json
{
"account": "demo",
"password": "demo123",
"username": "示例用户",
"email": "demo@example.com",
"inviteCode": "ABCD1234"
}
```
- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。
响应:
```json
{
"sent": true,
"expiresAt": "2026-03-14T12:10:00Z"
}
```
### 验证邮箱并完成注册
`POST /api/auth/verify-email`
请求:
```json
{
"account": "demo",
"code": "123456"
}
```
响应:
```json
{
"created": true,
"user": { "account": "demo", "...": "..." }
}
```
### 忘记密码(发送重置验证码)
`POST /api/auth/forgot-password`
请求:
```json
{
"account": "demo",
"email": "demo@example.com"
}
```
响应:
```json
{
"sent": true,
"expiresAt": "2026-03-14T12:10:00Z"
}
```
### 重置密码
`POST /api/auth/reset-password`
请求:
```json
{
"account": "demo",
"code": "123456",
"newPassword": "newpass"
}
```
响应:
```json
{ "reset": true }
```
### 申请添加辅助邮箱(发送验证码)
`POST /api/auth/secondary-email/request`
请求头:
`Authorization: Bearer <jwt-token>`
请求:
```json
{
"email": "demo2@example.com"
}
```
响应:
```json
{
"sent": true,
"expiresAt": "2026-03-14T12:10:00Z"
}
```
### 验证辅助邮箱
`POST /api/auth/secondary-email/verify`
请求头:
`Authorization: Bearer <jwt-token>`
请求:
```json
{
"email": "demo2@example.com",
"code": "123456"
}
```
响应:
```json
{
"verified": true,
"user": { "account": "demo", "...": "..." }
}
```
## 管理端接口(需要管理员 Token
管理员 Token 存放在 `data/config/admin.json` 中;如果文件不存在,后端启动时会自动生成并写入该文件。
请求时可使用以下任一方式携带:
- Query`?token=<admin-token>`
- Header`X-Admin-Token: <admin-token>`
### 签到奖励设置
`GET /api/admin/check-in/config`
`PUT /api/admin/check-in/config`
请求:
```json
{
"rewardCoins": 1
}
```
- Header`Authorization: Bearer <admin-token>`
### 注册策略与邀请码
`GET /api/admin/registration`
响应含 `requireInviteCode``invites` 数组(每项含 `code``note``maxUses``uses``expiresAt``createdAt`)。`maxUses` 为 0 表示不限次数。
`PUT /api/admin/registration`
请求:
```json
{ "requireInviteCode": true }
```
`POST /api/admin/registration/invites`
请求:
```json
{
"note": "内测批次",
"maxUses": 10,
"expiresAt": "2026-12-31T15:59:59Z"
}
```
`expiresAt` 可省略;须为 RFC3339。响应 `201``invite` 内含服务端生成的 8 位邀请码。
`DELETE /api/admin/registration/invites/{code}`
删除指定邀请码(`code` 与存储大小写可能不同,按不区分大小写匹配)。
### 获取用户列表
`GET /api/admin/users`
响应:
```json
{
"total": 1,
"users": [{ "account": "demo", "...": "..." }]
}
```
### 新建用户
`POST /api/admin/users`
请求:
```json
{
"account": "demo",
"password": "demo123",
"username": "示例用户",
"email": "demo@example.com",
"level": 0,
"sproutCoins": 10,
"secondaryEmails": ["demo2@example.com"],
"phone": "13800000000",
"avatarUrl": "https://example.com/avatar.png",
"websiteUrl": "https://example.com",
"bio": "### 简介"
}
```
### 更新用户
`PUT /api/admin/users/{account}`
请求(字段可选):
```json
{
"password": "newpass",
"username": "新昵称",
"level": 1,
"secondaryEmails": ["demo2@example.com"],
"sproutCoins": 99,
"websiteUrl": "https://example.com",
"banned": true,
"banReason": "违规说明(最多 500 字)"
}
```
- `banned`:是否封禁;解封时请传 `false`,并可将 `banReason` 置为空字符串。
- `banReason`:仅当用户处于封禁状态时允许设为非空;封禁时若首次写入会记录 `bannedAt`RFC3339存于数据库用户记录
管理员列表 `GET /api/admin/users` 中每条 `user``OwnerPublic()`,可含 `banned``banReason``bannedAt`(及 `authClients` 等)——以后端实际 JSON 为准。
### 删除用户
`DELETE /api/admin/users/{account}`
响应:
```json
{ "deleted": true }
```
## 数据存储说明
当前发行版**以 MySQL 为唯一运行时数据源**GORM `AutoMigrate`),不再使用 `data/users/*.json` 等文件作为线上读写路径。表与用途概要:
| 表名 | 用途 |
|------|------|
| `users` | 用户主数据;`auth_clients` 等为 JSON 列 |
| `pending_users` | 注册邮箱待验证 |
| `password_resets` | 找回密码验证码 |
| `secondary_email_verifications` | 辅助邮箱验证 |
| `app_configs` | `admin` / `auth` / `email` / `checkin` / `registration` 等 JSON 配置 |
| `invite_codes` | 邀请码 |
| `profile_likes` | 公开主页点赞记录 |
| `profile_like_daily_quota` | 点赞每日额度辅助 |
从旧版 **`sproutgate-backend/data/`** 目录迁移时,使用 **`go run ./cmd/migrate --data-dir ./data`**(详见 **`sproutgate-backend/后端文档.md`**)。
## 快速联调用示例
```bash
# 服务根路径 JSON 说明
curl -s http://localhost:8080/ | jq .
# 登录
curl -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"account":"demo","password":"demo123"}'
# 校验令牌(推荐第三方网关先调此接口)
curl -X POST http://localhost:8080/api/auth/verify \
-H 'Content-Type: application/json' \
-H 'X-Auth-Client: my-app' \
-d '{"token":"<jwt-token>"}'
# 同上,并携带应用展示名(累计应用接入记录)
curl -X POST http://localhost:8080/api/auth/verify \
-H 'Content-Type: application/json' \
-H 'X-Auth-Client: infogenie' \
-H 'X-Auth-Client-Name: 万象口袋' \
-d '{"token":"<jwt-token>"}'
# 使用令牌获取用户信息(会更新访问记录);可一并携带 X-Auth-Client 刷新「应用接入」最近时间
curl http://localhost:8080/api/auth/me \
-H 'Authorization: Bearer <jwt-token>' \
-H 'X-Auth-Client: infogenie' \
-H 'X-Auth-Client-Name: 万象口袋'
```