From 9a6ebe80c5140a9e8a97296bf8d3ba406380d0ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A0=91=E8=90=8C=E8=8A=BD?= <3205788256@qq.com> Date: Fri, 20 Mar 2026 20:58:24 +0800 Subject: [PATCH] feat: sync project --- .gitignore | 3 + mengyastore-backend/.dockerignore | 9 + mengyastore-backend/Dockerfile | 23 + mengyastore-backend/data/json/orders.json | 119 ++++ mengyastore-backend/data/json/products.json | 137 ++++- mengyastore-backend/data/json/site.json | 3 + mengyastore-backend/docker-compose.yml | 13 + .../internal/auth/sproutgate.go | 64 ++ mengyastore-backend/internal/config/config.go | 1 + .../internal/handlers/admin.go | 43 +- .../internal/handlers/order.go | 183 ++++++ .../internal/handlers/public.go | 37 +- .../internal/handlers/stats.go | 52 ++ mengyastore-backend/internal/models/order.go | 15 + .../internal/models/product.go | 25 +- .../internal/storage/jsonstore.go | 156 ++++- .../internal/storage/orderstore.go | 140 +++++ .../internal/storage/sitestore.go | 128 ++++ mengyastore-backend/main.go | 19 + mengyastore-frontend/index.html | 2 + mengyastore-frontend/src/App.vue | 155 ++++- mengyastore-frontend/src/assets/styles.css | 358 ++++++++++- .../src/modules/admin/AdminPage.vue | 577 ++++++++++++++++-- .../src/modules/auth/AuthCallback.vue | 109 ++++ .../src/modules/shared/api.js | 53 +- .../src/modules/shared/auth.js | 54 ++ .../src/modules/store/CheckoutPage.vue | 325 ++++++++++ .../src/modules/store/ProductDetail.vue | 51 +- .../src/modules/store/StorePage.vue | 329 +++++++++- .../src/modules/user/MyOrdersPage.vue | 200 ++++++ mengyastore-frontend/src/router/index.js | 8 +- sproutgate-API_DOCS.md | 378 ++++++++++++ 32 files changed, 3613 insertions(+), 156 deletions(-) create mode 100644 mengyastore-backend/.dockerignore create mode 100644 mengyastore-backend/Dockerfile create mode 100644 mengyastore-backend/data/json/orders.json create mode 100644 mengyastore-backend/data/json/site.json create mode 100644 mengyastore-backend/docker-compose.yml create mode 100644 mengyastore-backend/internal/auth/sproutgate.go create mode 100644 mengyastore-backend/internal/handlers/order.go create mode 100644 mengyastore-backend/internal/handlers/stats.go create mode 100644 mengyastore-backend/internal/models/order.go create mode 100644 mengyastore-backend/internal/storage/orderstore.go create mode 100644 mengyastore-backend/internal/storage/sitestore.go create mode 100644 mengyastore-frontend/src/modules/auth/AuthCallback.vue create mode 100644 mengyastore-frontend/src/modules/shared/auth.js create mode 100644 mengyastore-frontend/src/modules/store/CheckoutPage.vue create mode 100644 mengyastore-frontend/src/modules/user/MyOrdersPage.vue create mode 100644 sproutgate-API_DOCS.md diff --git a/.gitignore b/.gitignore index 986fb12..8b12b4b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,7 @@ coverage/ .env .env.* *.log +*.exe +*.exe~ +*~ .DS_Store diff --git a/mengyastore-backend/.dockerignore b/mengyastore-backend/.dockerignore new file mode 100644 index 0000000..4c13373 --- /dev/null +++ b/mengyastore-backend/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +node_modules +dist +debug-logs +*.exe +*.exe~ +Dockerfile +docker-compose.yml diff --git a/mengyastore-backend/Dockerfile b/mengyastore-backend/Dockerfile new file mode 100644 index 0000000..9047a3c --- /dev/null +++ b/mengyastore-backend/Dockerfile @@ -0,0 +1,23 @@ +FROM golang:1.24-alpine AS builder + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o /out/mengyastore-backend . + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +COPY --from=builder /out/mengyastore-backend ./mengyastore-backend + +EXPOSE 8080 + +ENV GIN_MODE=release + +CMD ["./mengyastore-backend"] diff --git a/mengyastore-backend/data/json/orders.json b/mengyastore-backend/data/json/orders.json new file mode 100644 index 0000000..8abe671 --- /dev/null +++ b/mengyastore-backend/data/json/orders.json @@ -0,0 +1,119 @@ +[ + { + "id": "0bea9606-51aa-4fe2-a932-ab0e36ee33ca", + "productId": "seed-1", + "productName": "Linux Do 邀请码", + "userAccount": "", + "userName": "", + "quantity": 1, + "deliveredCodes": [ + "LINUX-INVITE-001" + ], + "status": "pending", + "createdAt": "2026-03-19T17:23:46.1743551+08:00" + }, + { + "id": "5be3ecbd-873b-4ea2-9209-e96f6eb528cd", + "productId": "seed-1", + "productName": "Linux Do 邀请码", + "userAccount": "", + "userName": "", + "quantity": 1, + "deliveredCodes": [ + "LINUX-INVITE-002" + ], + "status": "pending", + "createdAt": "2026-03-19T17:24:07.6045189+08:00" + }, + { + "id": "c0cbb6c7-76be-49ef-9e67-8d2ae890e555", + "productId": "seed-1", + "productName": "Linux Do 邀请码", + "userAccount": "", + "userName": "", + "quantity": 1, + "deliveredCodes": [ + "啊伟大伟大伟大我" + ], + "status": "pending", + "createdAt": "2026-03-19T22:28:28.5393405+08:00" + }, + { + "id": "f299bbb4-0de4-4824-84ab-d1ccfb3b35dd", + "productId": "seed-1", + "productName": "Linux Do 邀请码", + "userAccount": "", + "userName": "", + "quantity": 1, + "deliveredCodes": [ + "啊伟大伟大伟大伟大" + ], + "status": "pending", + "createdAt": "2026-03-20T10:32:38.352837+08:00" + }, + { + "id": "413931af-2867-4855-89af-515747d4b5e5", + "productId": "seed-1", + "productName": "Linux Do 邀请码", + "userAccount": "", + "userName": "", + "quantity": 1, + "deliveredCodes": [ + "你是傻逼哈哈哈被骗了吧" + ], + "status": "pending", + "createdAt": "2026-03-20T10:32:55.2785291+08:00" + }, + { + "id": "59ab54e0-8b98-48d3-bf63-a843ef2c95a4", + "productId": "seed-1", + "productName": "Linux Do 邀请码", + "userAccount": "", + "userName": "", + "quantity": 1, + "deliveredCodes": [ + "唐" + ], + "status": "pending", + "createdAt": "2026-03-20T10:39:37.9977301+08:00" + }, + { + "id": "94e82c71-8237-429f-b593-2530314b72af", + "productId": "seed-1", + "productName": "Linux Do 邀请码", + "userAccount": "", + "userName": "", + "quantity": 1, + "deliveredCodes": [ + "原神牛逼" + ], + "status": "completed", + "createdAt": "2026-03-20T10:40:45.3820749+08:00" + }, + { + "id": "058cad17-608c-4108-b012-af42f688a047", + "productId": "seed-1", + "productName": "Linux Do 邀请码", + "userAccount": "shumengya", + "userName": "树萌芽", + "quantity": 1, + "deliveredCodes": [ + "123123123131" + ], + "status": "completed", + "createdAt": "2026-03-20T10:44:21.375082+08:00" + }, + { + "id": "e95f30ab-da4f-4dec-872c-3c9047cd8193", + "productId": "seed-1", + "productName": "Linux Do 邀请码", + "userAccount": "shumengya", + "userName": "树萌芽", + "quantity": 1, + "deliveredCodes": [ + "131231231231231" + ], + "status": "completed", + "createdAt": "2026-03-20T10:57:13.3436565+08:00" + } +] \ No newline at end of file diff --git a/mengyastore-backend/data/json/products.json b/mengyastore-backend/data/json/products.json index d7d626c..d3a9363 100644 --- a/mengyastore-backend/data/json/products.json +++ b/mengyastore-backend/data/json/products.json @@ -3,66 +3,179 @@ "id": "seed-1", "name": "Linux Do 邀请码", "price": 7, - "quantity": 10, + "discountPrice": 4, + "tags": [ + "邀请码", + "LinuxDo" + ], + "quantity": 0, "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png", + "screenshotUrls": [], + "verificationUrl": "", + "codes": [], + "viewCount": 10, "description": "Linux.do论坛邀请码 默认每天可以生成一个,先到先得.", "active": true, "createdAt": "2026-03-15T10:00:00+08:00", - "updatedAt": "2026-03-18T22:04:26.9875796+08:00" + "updatedAt": "2026-03-20T11:37:16.2219815+08:00" }, { "id": "seed-2", "name": "ChatGPT普号", "price": 1, - "quantity": 20, + "discountPrice": 0, + "tags": [], + "quantity": 0, "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png", + "screenshotUrls": [], + "verificationUrl": "", + "codes": [], + "viewCount": 2, "description": "ChatGPT 普号 纯手工注册 数量不多", "active": true, "createdAt": "2026-03-15T10:05:00+08:00", - "updatedAt": "2026-03-18T22:04:44.9242847+08:00" + "updatedAt": "2026-03-20T11:34:54.3522714+08:00" }, { "id": "2b6b6051-bca7-42da-b127-c7b721c50c06", "name": "谷歌账号", "price": 20, - "quantity": 8, + "discountPrice": 0, + "tags": [], + "quantity": 0, "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png", + "screenshotUrls": [], + "verificationUrl": "", + "codes": [], + "viewCount": 1, "description": "谷歌账号 现货 可绑定F2A验证", "active": true, "createdAt": "2026-03-15T20:52:52.0381722+08:00", - "updatedAt": "2026-03-18T21:55:26.3289587+08:00" + "updatedAt": "2026-03-19T19:33:05.6844325+08:00" }, { "id": "b9922892-c197-44be-be87-637ccb6bebeb", "name": "萌芽币", "price": 999999, - "quantity": 888, + "discountPrice": 0, + "tags": [], + "quantity": 0, "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png", + "screenshotUrls": [], + "verificationUrl": "", + "codes": [], + "viewCount": 1, "description": "非买品 仅展示", "active": true, "createdAt": "2026-03-15T21:03:00.0164528+08:00", - "updatedAt": "2026-03-18T22:04:48.6124945+08:00" + "updatedAt": "2026-03-19T19:33:07.508758+08:00" }, { "id": "ee8e0140-221c-4bfa-b10a-13b1f98ea4e5", "name": "Keep校园跑 代刷4公里", "price": 1, - "quantity": 999, + "discountPrice": 0, + "tags": [], + "quantity": 0, "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png", + "screenshotUrls": [], + "verificationUrl": "", + "codes": [], + "viewCount": 1, "description": "keep校园跑带刷 每天4-5公里 下单后直接联系我发账号", "active": true, "createdAt": "2026-03-15T21:06:11.9820102+08:00", - "updatedAt": "2026-03-18T22:04:53.0357081+08:00" + "updatedAt": "2026-03-19T19:33:09.1800225+08:00" }, { "id": "00bbf5db-b99e-4e88-a8ee-e7747b5969fe", - "name": "学习通/慕课挂科脚本", + "name": "学习通/慕课挂课脚本", "price": 25, + "discountPrice": 0, + "tags": [], "quantity": 0, "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png", + "screenshotUrls": [], + "verificationUrl": "", + "codes": [], + "viewCount": 1, "description": "学习通,慕课挂科脚本 手机 电脑都可以挂 不会弄可联系教你", "active": true, "createdAt": "2026-03-15T21:06:45.3807471+08:00", - "updatedAt": "2026-03-18T22:04:56.3154497+08:00" + "updatedAt": "2026-03-19T19:33:02.9673884+08:00" + }, + { + "id": "6c7bf494-ef2c-4221-9bf7-ec3c94070d25", + "name": "smyhub.com后缀域名邮箱", + "price": 5, + "discountPrice": 0, + "tags": [], + "quantity": 0, + "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png", + "screenshotUrls": [], + "verificationUrl": "", + "codes": [], + "viewCount": 1, + "description": "纪念意义,比如我自己的mail@smyhub.com 目前已经续费了5年到2031年", + "active": true, + "createdAt": "2026-03-18T22:17:41.3034538+08:00", + "updatedAt": "2026-03-19T19:32:26.7674929+08:00" + }, + { + "id": "a30a2275-1c9c-49e4-a402-3e446e3e0f5c", + "name": "萌芽账号邀请码", + "price": 10, + "discountPrice": 8, + "tags": [], + "quantity": 1, + "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png", + "screenshotUrls": [], + "verificationUrl": "", + "codes": [ + "原神牛逼" + ], + "viewCount": 0, + "description": "萌芽统一账号登录平台邀请码", + "active": true, + "createdAt": "2026-03-20T11:04:05.5787516+08:00", + "updatedAt": "2026-03-20T11:04:05.5787516+08:00" + }, + { + "id": "bcd5d73b-6ad9-4ed9-8e18-42ea0482ceb3", + "name": "Keep 代跑脚本", + "price": 50, + "discountPrice": 0, + "tags": [], + "quantity": 1, + "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png", + "screenshotUrls": [], + "verificationUrl": "", + "codes": [ + "傻逼" + ], + "viewCount": 0, + "description": "Keep 校园跑脚本", + "active": true, + "createdAt": "2026-03-20T11:17:36.1915376+08:00", + "updatedAt": "2026-03-20T11:17:36.1915376+08:00" + }, + { + "id": "7ab90d55-92c1-49d3-9d0a-01e5b1c08340", + "name": "原神牛逼", + "price": 0, + "discountPrice": 0, + "tags": [], + "quantity": 1, + "coverUrl": "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png", + "screenshotUrls": [], + "verificationUrl": "", + "codes": [ + "原神牛逼" + ], + "viewCount": 0, + "description": "购买后直接发送一句原神牛逼", + "active": true, + "createdAt": "2026-03-20T11:36:36.6726035+08:00", + "updatedAt": "2026-03-20T11:42:05.3303102+08:00" } ] \ No newline at end of file diff --git a/mengyastore-backend/data/json/site.json b/mengyastore-backend/data/json/site.json new file mode 100644 index 0000000..05c3c4a --- /dev/null +++ b/mengyastore-backend/data/json/site.json @@ -0,0 +1,3 @@ +{ + "totalVisits": 3 +} \ No newline at end of file diff --git a/mengyastore-backend/docker-compose.yml b/mengyastore-backend/docker-compose.yml new file mode 100644 index 0000000..6df578c --- /dev/null +++ b/mengyastore-backend/docker-compose.yml @@ -0,0 +1,13 @@ +services: + backend: + build: + context: . + container_name: mengyastore-backend + ports: + - "28081:8080" + environment: + GIN_MODE: release + TZ: Asia/Shanghai + volumes: + - ./data:/app/data + restart: unless-stopped diff --git a/mengyastore-backend/internal/auth/sproutgate.go b/mengyastore-backend/internal/auth/sproutgate.go new file mode 100644 index 0000000..124e671 --- /dev/null +++ b/mengyastore-backend/internal/auth/sproutgate.go @@ -0,0 +1,64 @@ +package auth + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" +) + +const defaultAuthAPIURL = "https://auth.api.shumengya.top" + +type SproutGateClient struct { + apiURL string + httpClient *http.Client +} + +type VerifyResult struct { + Valid bool `json:"valid"` + User *SproutGateUser `json:"user"` +} + +type SproutGateUser struct { + Account string `json:"account"` + Username string `json:"username"` + AvatarURL string `json:"avatarUrl"` +} + +func NewSproutGateClient(apiURL string) *SproutGateClient { + if apiURL == "" { + apiURL = defaultAuthAPIURL + } + return &SproutGateClient{ + apiURL: apiURL, + httpClient: &http.Client{Timeout: 10 * time.Second}, + } +} + +func (c *SproutGateClient) VerifyToken(token string) (*VerifyResult, error) { + body, _ := json.Marshal(map[string]string{"token": token}) + resp, err := c.httpClient.Post(c.apiURL+"/api/auth/verify", "application/json", bytes.NewReader(body)) + if err != nil { + log.Printf("[SproutGate] verify request failed: %v", err) + return nil, fmt.Errorf("verify request failed: %w", err) + } + defer resp.Body.Close() + + rawBody, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("[SproutGate] read response body failed: %v", err) + return nil, fmt.Errorf("read verify response: %w", err) + } + + log.Printf("[SproutGate] verify response status=%d body=%s", resp.StatusCode, string(rawBody)) + + var result VerifyResult + if err := json.Unmarshal(rawBody, &result); err != nil { + log.Printf("[SproutGate] decode response failed: %v", err) + return nil, fmt.Errorf("decode verify response: %w", err) + } + return &result, nil +} diff --git a/mengyastore-backend/internal/config/config.go b/mengyastore-backend/internal/config/config.go index 37b484a..fdc8266 100644 --- a/mengyastore-backend/internal/config/config.go +++ b/mengyastore-backend/internal/config/config.go @@ -8,6 +8,7 @@ import ( type Config struct { AdminToken string `json:"adminToken"` + AuthAPIURL string `json:"authApiUrl"` } func Load(path string) (*Config, error) { diff --git a/mengyastore-backend/internal/handlers/admin.go b/mengyastore-backend/internal/handlers/admin.go index bea0970..c704cf1 100644 --- a/mengyastore-backend/internal/handlers/admin.go +++ b/mengyastore-backend/internal/handlers/admin.go @@ -19,8 +19,10 @@ type AdminHandler struct { type productPayload struct { Name string `json:"name"` Price float64 `json:"price"` - Quantity int `json:"quantity"` + DiscountPrice float64 `json:"discountPrice"` + Tags string `json:"tags"` CoverURL string `json:"coverUrl"` + Codes []string `json:"codes"` ScreenshotURLs []string `json:"screenshotUrls"` Description string `json:"description"` Active *bool `json:"active"` @@ -61,7 +63,7 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) { } screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs) if !valid { - c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 10 or fewer"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 5 or fewer"}) return } active := true @@ -71,8 +73,10 @@ func (h *AdminHandler) CreateProduct(c *gin.Context) { product := models.Product{ Name: payload.Name, Price: payload.Price, - Quantity: payload.Quantity, + DiscountPrice: payload.DiscountPrice, + Tags: normalizeTags(payload.Tags), CoverURL: strings.TrimSpace(payload.CoverURL), + Codes: payload.Codes, ScreenshotURLs: screenshotURLs, Description: payload.Description, Active: active, @@ -97,7 +101,7 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) { } screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs) if !valid { - c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 10 or fewer"}) + c.JSON(http.StatusBadRequest, gin.H{"error": "screenshot urls must be 5 or fewer"}) return } active := false @@ -107,8 +111,10 @@ func (h *AdminHandler) UpdateProduct(c *gin.Context) { patch := models.Product{ Name: payload.Name, Price: payload.Price, - Quantity: payload.Quantity, + DiscountPrice: payload.DiscountPrice, + Tags: normalizeTags(payload.Tags), CoverURL: strings.TrimSpace(payload.CoverURL), + Codes: payload.Codes, ScreenshotURLs: screenshotURLs, Description: payload.Description, Active: active, @@ -171,9 +177,34 @@ func normalizeScreenshotURLs(urls []string) ([]string, bool) { continue } cleaned = append(cleaned, trimmed) - if len(cleaned) > 10 { + if len(cleaned) > 5 { return nil, false } } return cleaned, true } + +func normalizeTags(tagsCSV string) []string { + if tagsCSV == "" { + return []string{} + } + parts := strings.Split(tagsCSV, ",") + clean := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, p := range parts { + t := strings.TrimSpace(p) + if t == "" { + continue + } + key := strings.ToLower(t) + if seen[key] { + continue + } + seen[key] = true + clean = append(clean, t) + if len(clean) >= 20 { + break + } + } + return clean +} diff --git a/mengyastore-backend/internal/handlers/order.go b/mengyastore-backend/internal/handlers/order.go new file mode 100644 index 0000000..32e1d9e --- /dev/null +++ b/mengyastore-backend/internal/handlers/order.go @@ -0,0 +1,183 @@ +package handlers + +import ( + "fmt" + "log" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" + + "mengyastore-backend/internal/auth" + "mengyastore-backend/internal/models" + "mengyastore-backend/internal/storage" +) + +const qrSize = "320x320" + +type OrderHandler struct { + productStore *storage.JSONStore + orderStore *storage.OrderStore + authClient *auth.SproutGateClient +} + +type checkoutPayload struct { + ProductID string `json:"productId"` + Quantity int `json:"quantity"` +} + +func NewOrderHandler(productStore *storage.JSONStore, orderStore *storage.OrderStore, authClient *auth.SproutGateClient) *OrderHandler { + return &OrderHandler{productStore: productStore, orderStore: orderStore, authClient: authClient} +} + +func (h *OrderHandler) tryExtractUser(c *gin.Context) (string, string) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + log.Println("[Order] 无 Authorization header,匿名下单") + return "", "" + } + userToken := strings.TrimPrefix(authHeader, "Bearer ") + log.Printf("[Order] 检测到用户 token,正在验证 (长度=%d)", len(userToken)) + + result, err := h.authClient.VerifyToken(userToken) + if err != nil { + log.Printf("[Order] 验证 token 失败: %v", err) + return "", "" + } + if !result.Valid { + log.Println("[Order] token 验证返回 valid=false") + return "", "" + } + if result.User == nil { + log.Println("[Order] token 验证成功但 user 为空") + return "", "" + } + + log.Printf("[Order] 用户身份验证成功: account=%s username=%s", result.User.Account, result.User.Username) + return result.User.Account, result.User.Username +} + +func (h *OrderHandler) CreateOrder(c *gin.Context) { + userAccount, userName := h.tryExtractUser(c) + + var payload checkoutPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + + payload.ProductID = strings.TrimSpace(payload.ProductID) + if payload.ProductID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields"}) + 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": "product is not available"}) + return + } + if product.Quantity < payload.Quantity { + c.JSON(http.StatusBadRequest, gin.H{"error": "库存不足"}) + return + } + + 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) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + order := models.Order{ + ProductID: updatedProduct.ID, + ProductName: updatedProduct.Name, + UserAccount: userAccount, + UserName: userName, + Quantity: payload.Quantity, + DeliveredCodes: deliveredCodes, + Status: "pending", + } + created, err := h.orderStore.Create(order) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + 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 + } + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "orderId": order.ID, + "status": order.Status, + "deliveredCodes": order.DeliveredCodes, + }, + }) +} + +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 +} diff --git a/mengyastore-backend/internal/handlers/public.go b/mengyastore-backend/internal/handlers/public.go index 55b90af..4224247 100644 --- a/mengyastore-backend/internal/handlers/public.go +++ b/mengyastore-backend/internal/handlers/public.go @@ -2,9 +2,11 @@ package handlers import ( "net/http" + "strings" "github.com/gin-gonic/gin" + "mengyastore-backend/internal/models" "mengyastore-backend/internal/storage" ) @@ -22,5 +24,38 @@ func (h *PublicHandler) ListProducts(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"data": items}) + c.JSON(http.StatusOK, gin.H{"data": sanitizeForPublic(items)}) +} + +func (h *PublicHandler) RecordProductView(c *gin.Context) { + id := c.Param("id") + fingerprint := buildViewerFingerprint(c) + product, counted, err := h.store.IncrementView(id, fingerprint) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "id": product.ID, + "viewCount": product.ViewCount, + "counted": counted, + }, + }) +} + +func buildViewerFingerprint(c *gin.Context) string { + clientIP := strings.TrimSpace(c.ClientIP()) + userAgent := strings.TrimSpace(c.GetHeader("User-Agent")) + language := strings.TrimSpace(c.GetHeader("Accept-Language")) + return clientIP + "|" + userAgent + "|" + language +} + +func sanitizeForPublic(items []models.Product) []models.Product { + out := make([]models.Product, len(items)) + for i, item := range items { + item.Codes = nil + out[i] = item + } + return out } diff --git a/mengyastore-backend/internal/handlers/stats.go b/mengyastore-backend/internal/handlers/stats.go new file mode 100644 index 0000000..97ba25c --- /dev/null +++ b/mengyastore-backend/internal/handlers/stats.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "mengyastore-backend/internal/storage" +) + +type StatsHandler struct { + orderStore *storage.OrderStore + siteStore *storage.SiteStore +} + +func NewStatsHandler(orderStore *storage.OrderStore, siteStore *storage.SiteStore) *StatsHandler { + return &StatsHandler{orderStore: orderStore, siteStore: siteStore} +} + +func (h *StatsHandler) GetStats(c *gin.Context) { + totalOrders, err := h.orderStore.Count() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + totalVisits, err := h.siteStore.GetTotalVisits() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "totalOrders": totalOrders, + "totalVisits": totalVisits, + }, + }) +} + +func (h *StatsHandler) RecordVisit(c *gin.Context) { + fingerprint := buildViewerFingerprint(c) + totalVisits, counted, err := h.siteStore.RecordVisit(fingerprint) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "data": gin.H{ + "totalVisits": totalVisits, + "counted": counted, + }, + }) +} diff --git a/mengyastore-backend/internal/models/order.go b/mengyastore-backend/internal/models/order.go new file mode 100644 index 0000000..12b3a0e --- /dev/null +++ b/mengyastore-backend/internal/models/order.go @@ -0,0 +1,15 @@ +package models + +import "time" + +type Order struct { + ID string `json:"id"` + ProductID string `json:"productId"` + ProductName string `json:"productName"` + UserAccount string `json:"userAccount"` + UserName string `json:"userName"` + Quantity int `json:"quantity"` + DeliveredCodes []string `json:"deliveredCodes"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/mengyastore-backend/internal/models/product.go b/mengyastore-backend/internal/models/product.go index 33975c8..6553f8b 100644 --- a/mengyastore-backend/internal/models/product.go +++ b/mengyastore-backend/internal/models/product.go @@ -3,14 +3,19 @@ package models import "time" type Product struct { - ID string `json:"id"` - Name string `json:"name"` - Price float64 `json:"price"` - Quantity int `json:"quantity"` - CoverURL string `json:"coverUrl"` - ScreenshotURLs []string `json:"screenshotUrls"` - Description string `json:"description"` - Active bool `json:"active"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` + ID string `json:"id"` + Name string `json:"name"` + Price float64 `json:"price"` + DiscountPrice float64 `json:"discountPrice"` + Tags []string `json:"tags"` + Quantity int `json:"quantity"` + CoverURL string `json:"coverUrl"` + ScreenshotURLs []string `json:"screenshotUrls"` + VerificationURL string `json:"verificationUrl"` + Codes []string `json:"codes"` + ViewCount int `json:"viewCount"` + Description string `json:"description"` + Active bool `json:"active"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } diff --git a/mengyastore-backend/internal/storage/jsonstore.go b/mengyastore-backend/internal/storage/jsonstore.go index ee2dca8..273c15b 100644 --- a/mengyastore-backend/internal/storage/jsonstore.go +++ b/mengyastore-backend/internal/storage/jsonstore.go @@ -1,6 +1,7 @@ package storage import ( + "crypto/sha256" "encoding/json" "fmt" "os" @@ -15,20 +16,26 @@ import ( ) const defaultCoverURL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png" +const viewCooldown = 6 * time.Hour +const maxScreenshotURLs = 5 type JSONStore struct { - path string - mu sync.Mutex + path string + mu sync.Mutex + recentViews map[string]time.Time } func NewJSONStore(path string) (*JSONStore, error) { - if err := ensureFile(path); err != nil { + if err := ensureProductsFile(path); err != nil { return nil, err } - return &JSONStore{path: path}, nil + return &JSONStore{ + path: path, + recentViews: make(map[string]time.Time), + }, nil } -func ensureFile(path string) error { +func ensureProductsFile(path string) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("mkdir data dir: %w", err) @@ -72,6 +79,22 @@ func (s *JSONStore) ListActive() ([]models.Product, error) { return active, nil } +func (s *JSONStore) GetByID(id string) (models.Product, error) { + s.mu.Lock() + defer s.mu.Unlock() + + items, err := s.readAll() + if err != nil { + return models.Product{}, err + } + for _, item := range items { + if item.ID == id { + return item, nil + } + } + return models.Product{}, fmt.Errorf("product not found") +} + func (s *JSONStore) Create(p models.Product) (models.Product, error) { s.mu.Lock() defer s.mu.Unlock() @@ -100,13 +123,18 @@ func (s *JSONStore) Update(id string, patch models.Product) (models.Product, err } for i, item := range items { if item.ID == id { - item.Name = patch.Name - item.Price = patch.Price - item.Quantity = patch.Quantity - item.CoverURL = patch.CoverURL - item.ScreenshotURLs = normalizeProduct(patch).ScreenshotURLs - item.Description = patch.Description - item.Active = patch.Active + normalized := normalizeProduct(patch) + item.Name = normalized.Name + item.Price = normalized.Price + item.DiscountPrice = normalized.DiscountPrice + item.Tags = normalized.Tags + item.CoverURL = normalized.CoverURL + item.ScreenshotURLs = normalized.ScreenshotURLs + item.VerificationURL = normalized.VerificationURL + item.Codes = normalized.Codes + item.Quantity = normalized.Quantity + item.Description = normalized.Description + item.Active = normalized.Active item.UpdatedAt = time.Now() items[i] = item if err := s.writeAll(items); err != nil { @@ -139,6 +167,43 @@ func (s *JSONStore) Toggle(id string, active bool) (models.Product, error) { return models.Product{}, fmt.Errorf("product not found") } +func (s *JSONStore) IncrementView(id, fingerprint string) (models.Product, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + items, err := s.readAll() + if err != nil { + return models.Product{}, false, err + } + + now := time.Now() + s.cleanupRecentViews(now) + key := buildViewKey(id, fingerprint) + if lastViewedAt, ok := s.recentViews[key]; ok && now.Sub(lastViewedAt) < viewCooldown { + for _, item := range items { + if item.ID == id { + return item, false, nil + } + } + return models.Product{}, false, fmt.Errorf("product not found") + } + + for i, item := range items { + if item.ID == id { + item.ViewCount++ + item.UpdatedAt = now + items[i] = item + s.recentViews[key] = now + if err := s.writeAll(items); err != nil { + return models.Product{}, false, err + } + return item, true, nil + } + } + + return models.Product{}, false, fmt.Errorf("product not found") +} + func (s *JSONStore) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() @@ -192,8 +257,75 @@ func normalizeProduct(item models.Product) models.Product { if item.CoverURL == "" { item.CoverURL = defaultCoverURL } + if item.Tags == nil { + item.Tags = []string{} + } + item.Tags = sanitizeTags(item.Tags) if item.ScreenshotURLs == nil { item.ScreenshotURLs = []string{} } + if len(item.ScreenshotURLs) > maxScreenshotURLs { + item.ScreenshotURLs = item.ScreenshotURLs[:maxScreenshotURLs] + } + if item.Codes == nil { + item.Codes = []string{} + } + if item.DiscountPrice <= 0 || item.DiscountPrice >= item.Price { + item.DiscountPrice = 0 + } + item.VerificationURL = strings.TrimSpace(item.VerificationURL) + item.Codes = sanitizeCodes(item.Codes) + item.Quantity = len(item.Codes) return item } + +func sanitizeCodes(codes []string) []string { + clean := make([]string, 0, len(codes)) + seen := map[string]bool{} + for _, code := range codes { + trimmed := strings.TrimSpace(code) + if trimmed == "" { + continue + } + if seen[trimmed] { + continue + } + seen[trimmed] = true + clean = append(clean, trimmed) + } + return clean +} + +func sanitizeTags(tags []string) []string { + clean := make([]string, 0, len(tags)) + seen := map[string]bool{} + for _, tag := range tags { + t := strings.TrimSpace(tag) + if t == "" { + continue + } + key := strings.ToLower(t) + if seen[key] { + continue + } + seen[key] = true + clean = append(clean, t) + if len(clean) >= 20 { + break + } + } + return clean +} + +func buildViewKey(id, fingerprint string) string { + sum := sha256.Sum256([]byte(id + "|" + fingerprint)) + return fmt.Sprintf("%x", sum) +} + +func (s *JSONStore) cleanupRecentViews(now time.Time) { + for key, lastViewedAt := range s.recentViews { + if now.Sub(lastViewedAt) >= viewCooldown { + delete(s.recentViews, key) + } + } +} diff --git a/mengyastore-backend/internal/storage/orderstore.go b/mengyastore-backend/internal/storage/orderstore.go new file mode 100644 index 0000000..2df24ec --- /dev/null +++ b/mengyastore-backend/internal/storage/orderstore.go @@ -0,0 +1,140 @@ +package storage + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/google/uuid" + + "mengyastore-backend/internal/models" +) + +type OrderStore struct { + path string + mu sync.Mutex +} + +func NewOrderStore(path string) (*OrderStore, error) { + if err := ensureOrdersFile(path); err != nil { + return nil, err + } + return &OrderStore{path: path}, nil +} + +func (s *OrderStore) Count() (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + items, err := s.readAll() + if err != nil { + return 0, err + } + return len(items), nil +} + +func (s *OrderStore) ListByAccount(account string) ([]models.Order, error) { + s.mu.Lock() + defer s.mu.Unlock() + + items, err := s.readAll() + if err != nil { + return nil, err + } + matched := make([]models.Order, 0) + for i := len(items) - 1; i >= 0; i-- { + if items[i].UserAccount == account { + matched = append(matched, items[i]) + } + } + return matched, nil +} + +func (s *OrderStore) Confirm(id string) (models.Order, error) { + s.mu.Lock() + defer s.mu.Unlock() + + items, err := s.readAll() + if err != nil { + return models.Order{}, err + } + for i, item := range items { + if item.ID == id { + if item.Status == "completed" { + return item, nil + } + items[i].Status = "completed" + if err := s.writeAll(items); err != nil { + return models.Order{}, err + } + return items[i], nil + } + } + return models.Order{}, fmt.Errorf("order not found") +} + +func (s *OrderStore) Create(order models.Order) (models.Order, error) { + s.mu.Lock() + defer s.mu.Unlock() + + items, err := s.readAll() + if err != nil { + return models.Order{}, err + } + + order.ID = uuid.NewString() + order.CreatedAt = time.Now() + items = append(items, order) + if err := s.writeAll(items); err != nil { + return models.Order{}, err + } + return order, nil +} + +func (s *OrderStore) readAll() ([]models.Order, error) { + bytes, err := os.ReadFile(s.path) + if err != nil { + return nil, fmt.Errorf("read orders: %w", err) + } + var items []models.Order + if err := json.Unmarshal(bytes, &items); err != nil { + return nil, fmt.Errorf("parse orders: %w", err) + } + return items, nil +} + +func (s *OrderStore) writeAll(items []models.Order) error { + bytes, err := json.MarshalIndent(items, "", " ") + if err != nil { + return fmt.Errorf("encode orders: %w", err) + } + if err := os.WriteFile(s.path, bytes, 0o644); err != nil { + return fmt.Errorf("write orders: %w", err) + } + return nil +} + +func ensureOrdersFile(path string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir data dir: %w", err) + } + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat data file: %w", err) + } + + initial := []models.Order{} + bytes, err := json.MarshalIndent(initial, "", " ") + if err != nil { + return fmt.Errorf("init json: %w", err) + } + if err := os.WriteFile(path, bytes, 0o644); err != nil { + return fmt.Errorf("write init json: %w", err) + } + return nil +} diff --git a/mengyastore-backend/internal/storage/sitestore.go b/mengyastore-backend/internal/storage/sitestore.go new file mode 100644 index 0000000..984a1e5 --- /dev/null +++ b/mengyastore-backend/internal/storage/sitestore.go @@ -0,0 +1,128 @@ +package storage + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +const visitCooldown = 6 * time.Hour + +type siteData struct { + TotalVisits int `json:"totalVisits"` +} + +type SiteStore struct { + path string + mu sync.Mutex + recentVisits map[string]time.Time +} + +func NewSiteStore(path string) (*SiteStore, error) { + if err := ensureSiteFile(path); err != nil { + return nil, err + } + return &SiteStore{ + path: path, + recentVisits: make(map[string]time.Time), + }, nil +} + +func (s *SiteStore) RecordVisit(fingerprint string) (int, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + s.cleanupRecentVisits(now) + + key := buildSiteVisitKey(fingerprint) + if last, ok := s.recentVisits[key]; ok && now.Sub(last) < visitCooldown { + data, err := s.read() + if err != nil { + return 0, false, err + } + return data.TotalVisits, false, nil + } + + data, err := s.read() + if err != nil { + return 0, false, err + } + data.TotalVisits++ + s.recentVisits[key] = now + if err := s.write(data); err != nil { + return 0, false, err + } + return data.TotalVisits, true, nil +} + +func (s *SiteStore) GetTotalVisits() (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + data, err := s.read() + if err != nil { + return 0, err + } + return data.TotalVisits, nil +} + +func (s *SiteStore) read() (siteData, error) { + bytes, err := os.ReadFile(s.path) + if err != nil { + return siteData{}, fmt.Errorf("read site data: %w", err) + } + var data siteData + if err := json.Unmarshal(bytes, &data); err != nil { + return siteData{}, fmt.Errorf("parse site data: %w", err) + } + return data, nil +} + +func (s *SiteStore) write(data siteData) error { + bytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode site data: %w", err) + } + if err := os.WriteFile(s.path, bytes, 0o644); err != nil { + return fmt.Errorf("write site data: %w", err) + } + return nil +} + +func (s *SiteStore) cleanupRecentVisits(now time.Time) { + for key, last := range s.recentVisits { + if now.Sub(last) >= visitCooldown { + delete(s.recentVisits, key) + } + } +} + +func buildSiteVisitKey(fingerprint string) string { + sum := sha256.Sum256([]byte("site|" + fingerprint)) + return fmt.Sprintf("%x", sum) +} + +func ensureSiteFile(path string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir data dir: %w", err) + } + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return fmt.Errorf("stat site file: %w", err) + } + initial := siteData{TotalVisits: 0} + bytes, err := json.MarshalIndent(initial, "", " ") + if err != nil { + return fmt.Errorf("init site json: %w", err) + } + if err := os.WriteFile(path, bytes, 0o644); err != nil { + return fmt.Errorf("write site json: %w", err) + } + return nil +} diff --git a/mengyastore-backend/main.go b/mengyastore-backend/main.go index 36a3d77..1f519b8 100644 --- a/mengyastore-backend/main.go +++ b/mengyastore-backend/main.go @@ -8,6 +8,7 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + "mengyastore-backend/internal/auth" "mengyastore-backend/internal/config" "mengyastore-backend/internal/handlers" "mengyastore-backend/internal/storage" @@ -23,6 +24,14 @@ func main() { if err != nil { log.Fatalf("init store failed: %v", err) } + orderStore, err := storage.NewOrderStore("data/json/orders.json") + if err != nil { + log.Fatalf("init order store failed: %v", err) + } + siteStore, err := storage.NewSiteStore("data/json/site.json") + if err != nil { + log.Fatalf("init site store failed: %v", err) + } r := gin.Default() r.Use(cors.New(cors.Config{ @@ -38,10 +47,20 @@ func main() { c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) + authClient := auth.NewSproutGateClient(cfg.AuthAPIURL) + publicHandler := handlers.NewPublicHandler(store) adminHandler := handlers.NewAdminHandler(store, cfg) + orderHandler := handlers.NewOrderHandler(store, orderStore, authClient) + statsHandler := handlers.NewStatsHandler(orderStore, siteStore) r.GET("/api/products", publicHandler.ListProducts) + r.POST("/api/checkout", orderHandler.CreateOrder) + r.POST("/api/products/:id/view", publicHandler.RecordProductView) + r.GET("/api/stats", statsHandler.GetStats) + r.POST("/api/site/visit", statsHandler.RecordVisit) + r.GET("/api/orders", orderHandler.ListMyOrders) + r.POST("/api/orders/:id/confirm", orderHandler.ConfirmOrder) r.GET("/api/admin/token", adminHandler.GetAdminToken) r.GET("/api/admin/products", adminHandler.ListAllProducts) diff --git a/mengyastore-frontend/index.html b/mengyastore-frontend/index.html index d2ef675..8c1f5dc 100644 --- a/mengyastore-frontend/index.html +++ b/mengyastore-frontend/index.html @@ -4,6 +4,8 @@ 萌芽小店 + +
diff --git a/mengyastore-frontend/src/App.vue b/mengyastore-frontend/src/App.vue index 63a6c44..074066e 100644 --- a/mengyastore-frontend/src/App.vue +++ b/mengyastore-frontend/src/App.vue @@ -1,45 +1,174 @@ diff --git a/mengyastore-frontend/src/assets/styles.css b/mengyastore-frontend/src/assets/styles.css index 36d95c2..b5bd1b7 100644 --- a/mengyastore-frontend/src/assets/styles.css +++ b/mengyastore-frontend/src/assets/styles.css @@ -45,7 +45,6 @@ p { display: flex; flex-direction: column; min-height: 100vh; - padding: 28px 5vw 36px; } .top-bar { @@ -53,21 +52,160 @@ p { align-items: center; justify-content: space-between; gap: 20px; - padding: 20px 26px; - background: var(--glass); - border-radius: var(--radius); - border: 1px solid var(--line); - box-shadow: var(--shadow); - backdrop-filter: blur(18px); + padding: 16px 5vw; + background: var(--glass-strong); + border-bottom: 1px solid var(--line); + box-shadow: 0 4px 24px rgba(33, 33, 40, 0.08); + backdrop-filter: blur(20px); position: sticky; - top: 20px; - z-index: 10; + top: 0; + z-index: 100; +} + +.app-body { + flex: 1; + display: flex; + flex-direction: column; + padding: 28px 5vw 36px; } .brand { display: flex; gap: 16px; align-items: center; + cursor: pointer; + user-select: none; + -webkit-user-select: none; +} + +.admin-modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(44, 43, 45, 0.35); + backdrop-filter: blur(8px); +} + +.admin-modal { + position: relative; + width: 360px; + max-width: 90vw; + padding: 36px 32px 28px; + background: var(--glass-strong); + border: 1px solid var(--line); + border-radius: 20px; + box-shadow: 0 24px 60px rgba(33, 33, 40, 0.22); + backdrop-filter: blur(20px); + text-align: center; +} + +.admin-modal-close { + position: absolute; + top: 12px; + right: 16px; + background: none; + border: none; + font-size: 22px; + color: var(--muted); + cursor: pointer; + padding: 4px 8px; + border-radius: 8px; + line-height: 1; + transition: color 0.2s ease; +} + +.admin-modal-close:hover { + color: var(--text); + transform: none; +} + +.admin-modal-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 16px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: white; + margin-bottom: 16px; +} + +.admin-modal h3 { + font-size: 20px; + margin-bottom: 6px; +} + +.admin-modal-hint { + font-size: 13px; + color: var(--muted); + margin-bottom: 20px; +} + +.admin-modal-input { + width: 100%; + padding: 12px 16px; + border-radius: 12px; + border: 1px solid var(--line); + background: rgba(255, 255, 255, 0.65); + font-family: 'Source Sans 3', sans-serif; + font-size: 15px; + text-align: center; + letter-spacing: 2px; + outline: none; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.admin-modal-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(180, 154, 203, 0.18); +} + +.admin-modal-error { + font-size: 13px; + color: #d4566a; + margin-top: 8px; +} + +.admin-modal-btn { + width: 100%; + margin-top: 16px; + padding: 12px; + font-size: 15px; +} + +.admin-modal-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.modal-enter-active, +.modal-leave-active { + transition: opacity 0.25s ease; +} + +.modal-enter-active .admin-modal, +.modal-leave-active .admin-modal { + transition: transform 0.25s ease, opacity 0.25s ease; +} + +.modal-enter-from, +.modal-leave-to { + opacity: 0; +} + +.modal-enter-from .admin-modal { + transform: scale(0.92) translateY(10px); + opacity: 0; +} + +.modal-leave-to .admin-modal { + transform: scale(0.95); + opacity: 0; } .brand h1 { @@ -84,13 +222,87 @@ p { width: 46px; height: 46px; border-radius: 16px; - background: linear-gradient(140deg, var(--accent), var(--accent-2)); - box-shadow: inset 0 0 18px rgba(255, 255, 255, 0.6); + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.6); + background: var(--glass-strong); + box-shadow: 0 12px 24px rgba(33, 33, 40, 0.18); + display: flex; + align-items: center; + justify-content: center; +} + +.brand-mark img { + width: 100%; + height: 100%; + object-fit: contain; } .top-actions { display: flex; gap: 12px; + align-items: center; +} + +.user-badge { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px 6px 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.6); + border: 1px solid var(--line); + cursor: pointer; + transition: background 0.2s ease; +} + +.user-badge:hover { + background: rgba(255, 255, 255, 0.85); +} + +.user-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--line); +} + +.user-avatar-placeholder { + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: white; + font-size: 13px; + font-weight: 600; +} + +.user-name { + font-size: 13px; + color: var(--text); + font-weight: 500; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.login-btn { + display: inline-flex; + align-items: center; + padding: 10px 18px; + border-radius: 12px; + background: linear-gradient(135deg, var(--accent), var(--accent-2)); + color: white; + text-decoration: none; + font-family: 'Source Sans 3', sans-serif; + font-size: 14px; + box-shadow: 0 10px 30px rgba(145, 168, 208, 0.35); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.login-btn:hover { + transform: translateY(-1px); } button { @@ -121,23 +333,106 @@ button.ghost { .main-content { flex: 1; padding: 28px 0; + min-height: 0; } .footer { + margin-top: 12px; + padding: 24px 26px; + background: var(--glass); + border: 1px solid var(--line); + border-radius: var(--radius); + backdrop-filter: blur(16px); + box-shadow: 0 -4px 30px rgba(33, 33, 40, 0.06); +} + +.footer-inner { display: flex; - justify-content: space-between; - color: var(--muted); - font-size: 13px; + flex-direction: column; + align-items: center; + gap: 14px; +} + +.footer-brand { + display: flex; + align-items: center; + gap: 10px; +} + +.footer-logo { + width: 28px; + height: 28px; + border-radius: 8px; + object-fit: contain; + border: 1px solid rgba(255, 255, 255, 0.5); + box-shadow: 0 4px 12px rgba(33, 33, 40, 0.1); +} + +.footer-title { + font-family: 'Playfair Display', serif; + font-size: 17px; + font-weight: 600; + color: var(--text); + letter-spacing: 0.5px; +} + +.footer-divider { + width: 40px; + height: 2px; + border-radius: 2px; + background: linear-gradient(90deg, var(--accent), var(--accent-2)); + opacity: 0.5; +} + +.footer-links { + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; + justify-content: center; } .footer-mail { + display: inline-flex; + align-items: center; + gap: 6px; color: var(--accent-2); text-decoration: none; - font-weight: 600; + font-size: 13px; + font-weight: 500; + padding: 5px 12px; + border-radius: 8px; + background: rgba(145, 168, 208, 0.08); + transition: background 0.2s ease, color 0.2s ease; } .footer-mail:hover { - text-decoration: underline; + background: rgba(145, 168, 208, 0.18); + color: var(--text); +} + +.footer-stat { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 500; + color: var(--accent); + padding: 5px 12px; + border-radius: 8px; + background: rgba(180, 154, 203, 0.08); +} + +.footer-stat-visit { + color: var(--accent-2); + background: rgba(145, 168, 208, 0.08); +} + +.footer-copy { + font-size: 12px; + color: var(--muted); + opacity: 0.7; + letter-spacing: 0.3px; } .page-card { @@ -186,6 +481,26 @@ button.ghost { color: var(--accent-2); } +.product-actions { + margin-top: 12px; +} + +.secondary-link { + display: inline-flex; + justify-content: center; + padding: 8px 14px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.6); + background: rgba(255, 255, 255, 0.6); + color: var(--text); + font-size: 13px; + transition: background 0.2s ease; +} + +.secondary-link:hover { + background: rgba(255, 255, 255, 0.85); +} + .badge { padding: 4px 10px; border-radius: 10px; @@ -284,14 +599,15 @@ button.ghost { } @media (max-width: 900px) { - .app { + .app-body { padding: 18px 4vw 28px; } .top-bar { flex-direction: column; align-items: flex-start; - padding: 16px 18px; + padding: 14px 4vw; + gap: 12px; } .grid { @@ -312,8 +628,12 @@ button.ghost { } .footer { + padding: 20px 18px; + } + + .footer-links { flex-direction: column; - gap: 8px; + gap: 10px; } .page-card { diff --git a/mengyastore-frontend/src/modules/admin/AdminPage.vue b/mengyastore-frontend/src/modules/admin/AdminPage.vue index 037924e..598841f 100644 --- a/mengyastore-frontend/src/modules/admin/AdminPage.vue +++ b/mengyastore-frontend/src/modules/admin/AdminPage.vue @@ -3,56 +3,94 @@

管理员后台

-

默认地址:/admin?token=shumengya520

+

共 {{ products.length }} 件商品

-
- - - +
+ +
-
+
- +
+ + +
-

{{ message }}

+

{{ message }}

+

{{ message }}

- - - - - - - - - - - - - - - - - - - -
商品价格库存状态操作
-
{{ item.name }}
-
{{ item.id }}
-
¥ {{ item.price.toFixed(2) }}{{ item.quantity }} - {{ item.active ? '上架' : '下架' }} - -
- - - -
-
+
+ + + + + + + + + + + + + + + + + + + + + +
商品价格库存浏览量状态操作
+
+ +
+ {{ item.name }} + {{ item.id }} +
+
+
+
+ 免费 +
+
+ ¥{{ item.price.toFixed(2) }} + ¥{{ item.discountPrice.toFixed(2) }} +
+ ¥{{ item.price.toFixed(2) }} +
+ + {{ item.quantity }} + + + {{ item.viewCount || 0 }} + + + {{ item.active ? '上架' : '下架' }} + + +
+ + + +
+
+
- +
- - + + +

留空或不小于原价时,将不启用折扣。

@@ -85,7 +124,32 @@
- +
+ + 共 {{ form.inventoryItems.length }} 条 + +
+

每个输入框保存一条可发放内容,购买后会直接展示给用户。

+
+
+ + +
+
+
+
+
商品介绍(Markdown)
+
+ + +

用于前端搜索与筛选,多个标签用英文逗号分开

+
+
+
加载中...
-
- -
-

{{ item.name }}

- ¥ {{ item.price.toFixed(2) }} +
+ +
已售空
+
+
+

{{ item.name }}

+
+ 免费 + + ¥ {{ item.price.toFixed(2) }} + ¥ {{ item.discountPrice.toFixed(2) }} + + ¥ {{ item.price.toFixed(2) }} +
+
+ +
+
+
+ +
+
+
库存:{{ item.quantity }}
+
浏览量:{{ item.viewCount || 0 }}
+
+
+ + {{ tag }} + +
-
库存:{{ item.quantity }}
-
-
+