feat: sync project
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,4 +6,7 @@ coverage/
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
*.exe
|
||||
*.exe~
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
9
mengyastore-backend/.dockerignore
Normal file
9
mengyastore-backend/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
dist
|
||||
debug-logs
|
||||
*.exe
|
||||
*.exe~
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
23
mengyastore-backend/Dockerfile
Normal file
23
mengyastore-backend/Dockerfile
Normal file
@@ -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"]
|
||||
119
mengyastore-backend/data/json/orders.json
Normal file
119
mengyastore-backend/data/json/orders.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
3
mengyastore-backend/data/json/site.json
Normal file
3
mengyastore-backend/data/json/site.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"totalVisits": 3
|
||||
}
|
||||
13
mengyastore-backend/docker-compose.yml
Normal file
13
mengyastore-backend/docker-compose.yml
Normal file
@@ -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
|
||||
64
mengyastore-backend/internal/auth/sproutgate.go
Normal file
64
mengyastore-backend/internal/auth/sproutgate.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
AdminToken string `json:"adminToken"`
|
||||
AuthAPIURL string `json:"authApiUrl"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
183
mengyastore-backend/internal/handlers/order.go
Normal file
183
mengyastore-backend/internal/handlers/order.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
52
mengyastore-backend/internal/handlers/stats.go
Normal file
52
mengyastore-backend/internal/handlers/stats.go
Normal file
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
15
mengyastore-backend/internal/models/order.go
Normal file
15
mengyastore-backend/internal/models/order.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
140
mengyastore-backend/internal/storage/orderstore.go
Normal file
140
mengyastore-backend/internal/storage/orderstore.go
Normal file
@@ -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
|
||||
}
|
||||
128
mengyastore-backend/internal/storage/sitestore.go
Normal file
128
mengyastore-backend/internal/storage/sitestore.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>萌芽小店</title>
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,45 +1,174 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="top-bar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark"></div>
|
||||
<div class="brand" @click="onLogoClick">
|
||||
<div class="brand-mark">
|
||||
<img src="/logo.png" alt="萌芽小店" draggable="false" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>萌芽小店</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button class="ghost" @click="goHome">商店</button>
|
||||
<button class="primary" @click="goAdmin">管理员后台</button>
|
||||
<template v-if="loggedIn">
|
||||
<button class="ghost" @click="goMyOrders">我的订单</button>
|
||||
<div class="user-badge" @click="goProfile">
|
||||
<img
|
||||
v-if="authState.avatarUrl"
|
||||
class="user-avatar"
|
||||
:src="authState.avatarUrl"
|
||||
:alt="authState.username"
|
||||
/>
|
||||
<div v-else class="user-avatar user-avatar-placeholder">
|
||||
{{ (authState.username || authState.account || '?').charAt(0) }}
|
||||
</div>
|
||||
<span class="user-name">{{ authState.username || authState.account }}</span>
|
||||
</div>
|
||||
<button class="ghost" @click="logout">退出</button>
|
||||
</template>
|
||||
<a v-else class="login-btn" :href="loginUrl">萌芽账号登录</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">×</button>
|
||||
<div class="admin-modal-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"><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>
|
||||
<form @submit.prevent="submitAdminToken">
|
||||
<input
|
||||
ref="tokenInputRef"
|
||||
v-model="tokenInput"
|
||||
type="password"
|
||||
class="admin-modal-input"
|
||||
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()">
|
||||
进入后台
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div class="app-body">
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<span>萌芽小店 · @2025-2026</span>
|
||||
<a class="footer-mail" href="mailto:mail@smyhub.com">邮箱售后联系: mail@smyhub.com</a>
|
||||
<div class="footer-inner">
|
||||
<div class="footer-brand">
|
||||
<img src="/logo.png" alt="萌芽小店" class="footer-logo" />
|
||||
<span class="footer-title">萌芽小店</span>
|
||||
</div>
|
||||
<div class="footer-divider"></div>
|
||||
<div class="footer-links">
|
||||
<a class="footer-mail" 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">
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchAdminToken } from './modules/shared/api'
|
||||
import { fetchAdminToken, fetchStats, recordSiteVisit } from './modules/shared/api'
|
||||
import { authState, isLoggedIn, clearAuth, getLoginUrl } from './modules/shared/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const stats = reactive({ totalOrders: 0, totalVisits: 0 })
|
||||
const statsLoaded = ref(false)
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
const loginUrl = computed(() => getLoginUrl())
|
||||
|
||||
const showAdminModal = ref(false)
|
||||
const tokenInput = ref('')
|
||||
const tokenError = ref('')
|
||||
const tokenInputRef = ref(null)
|
||||
|
||||
let logoClickCount = 0
|
||||
let logoClickTimer = null
|
||||
const CLICK_THRESHOLD = 5
|
||||
const CLICK_WINDOW_MS = 2000
|
||||
|
||||
const onLogoClick = () => {
|
||||
logoClickCount++
|
||||
clearTimeout(logoClickTimer)
|
||||
if (logoClickCount >= CLICK_THRESHOLD) {
|
||||
logoClickCount = 0
|
||||
tokenInput.value = ''
|
||||
tokenError.value = ''
|
||||
showAdminModal.value = true
|
||||
nextTick(() => tokenInputRef.value?.focus())
|
||||
return
|
||||
}
|
||||
logoClickTimer = setTimeout(() => { logoClickCount = 0 }, CLICK_WINDOW_MS)
|
||||
}
|
||||
|
||||
const goAdmin = async () => {
|
||||
const submitAdminToken = async () => {
|
||||
const input = tokenInput.value.trim()
|
||||
if (!input) return
|
||||
tokenError.value = ''
|
||||
try {
|
||||
const token = await fetchAdminToken()
|
||||
router.push(`/admin?token=${encodeURIComponent(token)}`)
|
||||
} catch (error) {
|
||||
router.push('/admin')
|
||||
const correctToken = await fetchAdminToken()
|
||||
if (input === correctToken) {
|
||||
showAdminModal.value = false
|
||||
router.push(`/admin?token=${encodeURIComponent(correctToken)}`)
|
||||
} else {
|
||||
tokenError.value = '令牌错误,请重试'
|
||||
}
|
||||
} catch {
|
||||
tokenError.value = '验证失败,无法连接服务器'
|
||||
}
|
||||
}
|
||||
|
||||
const goHome = () => { router.push('/') }
|
||||
const goMyOrders = () => { router.push('/my/orders') }
|
||||
|
||||
const goProfile = () => {
|
||||
if (authState.account) {
|
||||
window.open(`https://auth.shumengya.top/user/${authState.account}`, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => { clearAuth() }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
recordSiteVisit().then((result) => {
|
||||
if (result.totalVisits) stats.totalVisits = result.totalVisits
|
||||
}).catch(() => {})
|
||||
const data = await fetchStats()
|
||||
stats.totalOrders = data.totalOrders || 0
|
||||
stats.totalVisits = data.totalVisits || 0
|
||||
statsLoaded.value = true
|
||||
} catch (e) {
|
||||
statsLoaded.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -3,56 +3,94 @@
|
||||
<div class="hero">
|
||||
<div>
|
||||
<h2>管理员后台</h2>
|
||||
<p class="tag">默认地址:/admin?token=shumengya520</p>
|
||||
<p class="tag">共 {{ products.length }} 件商品</p>
|
||||
</div>
|
||||
<div class="admin-actions">
|
||||
<button class="primary" @click="openCreate">添加新商品</button>
|
||||
<button class="ghost" @click="refresh">刷新列表</button>
|
||||
<button class="primary" @click="autoGetToken">自动获取 Token</button>
|
||||
<div class="hero-actions">
|
||||
<button class="ghost" @click="refresh">
|
||||
<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="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="primary" @click="openCreate">
|
||||
<svg width="14" height="14" 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>
|
||||
|
||||
<div class="token-row">
|
||||
<div class="token-row" v-if="!token || message">
|
||||
<div class="form-field token-field">
|
||||
<label>管理 Token</label>
|
||||
<input v-model="token" placeholder="请输入 token" />
|
||||
<div class="token-input-wrap">
|
||||
<input v-model="token" placeholder="粘贴 token 后自动加载…" />
|
||||
<button class="ghost small" @click="autoGetToken">自动获取</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="tag" v-if="message">{{ message }}</p>
|
||||
<p class="msg-tag" :class="{ error: message.includes('失败') || message.includes('错误') }" v-if="message">{{ message }}</p>
|
||||
</div>
|
||||
<p v-if="message && token" class="msg-inline" :class="{ error: message.includes('失败') || message.includes('错误') }">{{ message }}</p>
|
||||
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>商品</th>
|
||||
<th>价格</th>
|
||||
<th>库存</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in products" :key="item.id">
|
||||
<td>
|
||||
<div>{{ item.name }}</div>
|
||||
<div class="tag">{{ item.id }}</div>
|
||||
</td>
|
||||
<td>¥ {{ item.price.toFixed(2) }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>
|
||||
<span class="badge">{{ item.active ? '上架' : '下架' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="admin-actions">
|
||||
<button class="ghost" @click="openEdit(item)">编辑</button>
|
||||
<button class="ghost" @click="toggle(item)">
|
||||
{{ item.active ? '下架' : '上架' }}
|
||||
</button>
|
||||
<button class="ghost" @click="remove(item)">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>商品</th>
|
||||
<th>价格</th>
|
||||
<th class="col-num">库存</th>
|
||||
<th class="col-num">浏览量</th>
|
||||
<th>状态</th>
|
||||
<th>操作</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"
|
||||
>
|
||||
<span class="price-original">¥{{ item.price.toFixed(2) }}</span>
|
||||
<span class="price-discount">¥{{ item.discountPrice.toFixed(2) }}</span>
|
||||
</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>
|
||||
<td class="col-num">
|
||||
<span class="view-count">{{ item.viewCount || 0 }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :class="['status-badge', item.active ? 'status-on' : 'status-off']">
|
||||
{{ item.active ? '上架' : '下架' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="act-edit" @click="openEdit(item)">编辑</button>
|
||||
<button class="act-toggle" @click="toggle(item)">
|
||||
{{ item.active ? '下架' : '上架' }}
|
||||
</button>
|
||||
<button class="act-delete" @click="remove(item)">删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="editorOpen" class="modal-mask" @click.self="closeEditor">
|
||||
@@ -60,7 +98,7 @@
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h3>{{ form.id ? '编辑商品' : '添加新商品' }}</h3>
|
||||
<p class="tag">封面图和最多 10 张商品截图共用这一套编辑表单。</p>
|
||||
<p class="tag">封面图和最多 5 张商品截图共用这一套编辑表单。</p>
|
||||
</div>
|
||||
<button class="ghost" @click="closeEditor">关闭</button>
|
||||
</div>
|
||||
@@ -72,12 +110,13 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label>价格(元)</label>
|
||||
<label>原价(元)</label>
|
||||
<input v-model.number="form.price" type="number" step="0.01" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>库存数量</label>
|
||||
<input v-model.number="form.quantity" type="number" />
|
||||
<label>折扣价(元,可选)</label>
|
||||
<input v-model.number="form.discountPrice" type="number" step="0.01" />
|
||||
<p class="tag">留空或不小于原价时,将不启用折扣。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
@@ -85,7 +124,32 @@
|
||||
<input v-model="form.coverUrl" placeholder="http://..." />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>商品截图链接(最多 10 个)</label>
|
||||
<div class="field-head">
|
||||
<label>商品库存</label>
|
||||
<span class="tag">共 {{ form.inventoryItems.length }} 条</span>
|
||||
<button class="ghost small" type="button" @click="addInventoryItem">添加商品库存</button>
|
||||
</div>
|
||||
<p class="tag">每个输入框保存一条可发放内容,购买后会直接展示给用户。</p>
|
||||
<div class="inventory-list">
|
||||
<div v-for="(item, index) in form.inventoryItems" :key="index" class="inventory-row">
|
||||
<input
|
||||
:ref="(el) => setInventoryInputRef(el, index)"
|
||||
v-model="form.inventoryItems[index]"
|
||||
placeholder="库存内容,可填卡密、下载链接等"
|
||||
/>
|
||||
<button
|
||||
v-if="form.inventoryItems.length > 1"
|
||||
class="ghost small"
|
||||
type="button"
|
||||
@click="removeInventoryItem(index)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>商品截图链接(最多 5 个)</label>
|
||||
<div class="screenshot-grid">
|
||||
<input
|
||||
v-for="(_, index) in screenshotInputSlots"
|
||||
@@ -99,6 +163,11 @@
|
||||
<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">
|
||||
@@ -117,7 +186,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
fetchAdminProducts,
|
||||
@@ -134,12 +203,28 @@ const token = ref(route.query.token || '')
|
||||
const products = ref([])
|
||||
const message = ref('')
|
||||
const editorOpen = ref(false)
|
||||
const MAX_SCREENSHOT_URLS = 10
|
||||
const inventoryInputRefs = ref([])
|
||||
const MAX_SCREENSHOT_URLS = 5
|
||||
const screenshotInputSlots = Array.from({ length: MAX_SCREENSHOT_URLS })
|
||||
const MAX_INVENTORY_ITEMS = 500
|
||||
|
||||
const createScreenshotSlots = (values = []) =>
|
||||
Array.from({ length: MAX_SCREENSHOT_URLS }, (_, index) => values[index] || '')
|
||||
|
||||
const createInventoryItems = (values = []) => {
|
||||
const items = values
|
||||
.map((value) => (value || '').trim())
|
||||
.filter((value) => value)
|
||||
.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)
|
||||
|
||||
const normalizeScreenshotUrls = (values = []) =>
|
||||
values.map((item) => item.trim()).filter(Boolean).slice(0, MAX_SCREENSHOT_URLS)
|
||||
|
||||
@@ -147,9 +232,11 @@ const emptyForm = () => ({
|
||||
id: '',
|
||||
name: '',
|
||||
price: 0,
|
||||
quantity: 0,
|
||||
discountPrice: 0,
|
||||
tagsText: '',
|
||||
coverUrl: '',
|
||||
screenshotUrls: createScreenshotSlots(),
|
||||
inventoryItems: createInventoryItems(),
|
||||
description: '',
|
||||
active: true
|
||||
})
|
||||
@@ -192,8 +279,10 @@ const submit = async () => {
|
||||
await updateProduct(token.value, form.id, {
|
||||
name: form.name,
|
||||
price: form.price,
|
||||
quantity: form.quantity,
|
||||
discountPrice: form.discountPrice || 0,
|
||||
tags: form.tagsText,
|
||||
coverUrl: form.coverUrl,
|
||||
codes: normalizeInventoryItems(form.inventoryItems),
|
||||
screenshotUrls: normalizeScreenshotUrls(form.screenshotUrls),
|
||||
description: form.description,
|
||||
active: form.active
|
||||
@@ -203,8 +292,10 @@ const submit = async () => {
|
||||
await createProduct(token.value, {
|
||||
name: form.name,
|
||||
price: form.price,
|
||||
quantity: form.quantity,
|
||||
discountPrice: form.discountPrice || 0,
|
||||
tags: form.tagsText,
|
||||
coverUrl: form.coverUrl,
|
||||
codes: normalizeInventoryItems(form.inventoryItems),
|
||||
screenshotUrls: normalizeScreenshotUrls(form.screenshotUrls),
|
||||
description: form.description,
|
||||
active: form.active
|
||||
@@ -224,9 +315,11 @@ const fillForm = (item) => {
|
||||
id: item?.id || '',
|
||||
name: item?.name || '',
|
||||
price: item?.price || 0,
|
||||
quantity: item?.quantity || 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
|
||||
})
|
||||
@@ -246,6 +339,41 @@ const closeEditor = () => {
|
||||
editorOpen.value = false
|
||||
}
|
||||
|
||||
const setInventoryInputRef = (el, index) => {
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
inventoryInputRefs.value[index] = el
|
||||
}
|
||||
|
||||
const addInventoryItem = async () => {
|
||||
form.inventoryItems.push('')
|
||||
await nextTick()
|
||||
const lastIndex = form.inventoryItems.length - 1
|
||||
const input = inventoryInputRefs.value[lastIndex]
|
||||
if (input?.focus) {
|
||||
input.focus()
|
||||
}
|
||||
if (input?.scrollIntoView) {
|
||||
input.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const removeInventoryItem = async (index) => {
|
||||
if (form.inventoryItems.length <= 1) {
|
||||
form.inventoryItems[0] = ''
|
||||
return
|
||||
}
|
||||
form.inventoryItems.splice(index, 1)
|
||||
inventoryInputRefs.value.splice(index, 1)
|
||||
await nextTick()
|
||||
const targetIndex = Math.min(index, form.inventoryItems.length - 1)
|
||||
const input = inventoryInputRefs.value[targetIndex]
|
||||
if (input?.focus) {
|
||||
input.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (item) => {
|
||||
if (!token.value) {
|
||||
message.value = '请先输入 token'
|
||||
@@ -280,31 +408,298 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Header ── */
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 22px;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Token row ── */
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.token-field {
|
||||
width: min(420px, 100%);
|
||||
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: 13px;
|
||||
color: var(--accent-2);
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
background: rgba(145, 168, 208, 0.1);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.msg-inline {
|
||||
font-size: 13px;
|
||||
color: var(--accent-2);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.msg-tag.error,
|
||||
.msg-inline.error {
|
||||
color: #c95a6a;
|
||||
background: rgba(201, 90, 106, 0.08);
|
||||
}
|
||||
|
||||
/* ── Table ── */
|
||||
.table-wrap {
|
||||
border-radius: 14px;
|
||||
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: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
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;
|
||||
}
|
||||
|
||||
/* ── Product cell ── */
|
||||
.admin-product-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-product-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
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: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.product-id {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* ── Price ── */
|
||||
.price-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.price-original {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.price-discount {
|
||||
font-weight: 700;
|
||||
color: #e8826a;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.price-free {
|
||||
font-weight: 900;
|
||||
color: #3a9a68;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.price-normal {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* ── Stock badge ── */
|
||||
.stock-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
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 ── */
|
||||
.view-count {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Status badge ── */
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
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 action buttons ── */
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.act-edit,
|
||||
.act-toggle,
|
||||
.act-delete {
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
font-family: 'Source Sans 3', sans-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);
|
||||
}
|
||||
|
||||
/* ── Form / Modal ── */
|
||||
select {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
padding: 10px 12px;
|
||||
font-family: 'Source Sans 3', sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
@@ -313,12 +708,41 @@ select {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.small {
|
||||
padding: 7px 13px;
|
||||
font-size: 13px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -335,11 +759,11 @@ select {
|
||||
width: min(920px, 100%);
|
||||
max-height: calc(100vh - 48px);
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
padding: 28px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 24px 60px rgba(33, 33, 40, 0.18);
|
||||
box-shadow: 0 24px 60px rgba(33, 33, 40, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
@@ -347,7 +771,9 @@ select {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
margin-bottom: 22px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@@ -360,22 +786,42 @@ select {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 16px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.token-row,
|
||||
.modal-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.token-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.token-input-wrap {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-row,
|
||||
.screenshot-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.inventory-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modal-mask {
|
||||
padding: 12px;
|
||||
}
|
||||
@@ -384,5 +830,10 @@ select {
|
||||
padding: 18px;
|
||||
max-height: calc(100vh - 24px);
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
109
mengyastore-frontend/src/modules/auth/AuthCallback.vue
Normal file
109
mengyastore-frontend/src/modules/auth/AuthCallback.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<section class="page-card">
|
||||
<div class="auth-callback">
|
||||
<div v-if="status === 'loading'" class="auth-status">
|
||||
<h2>正在验证登录...</h2>
|
||||
<p class="tag">请稍候,正在与萌芽认证中心确认身份</p>
|
||||
</div>
|
||||
<div v-else-if="status === 'success'" class="auth-status">
|
||||
<h2>登录成功</h2>
|
||||
<p class="tag">欢迎回来,{{ displayName }}!正在跳转...</p>
|
||||
</div>
|
||||
<div v-else class="auth-status">
|
||||
<h2>登录失败</h2>
|
||||
<p class="tag">{{ errorMessage }}</p>
|
||||
<button class="primary" @click="goHome">返回商店</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { setAuth } from '../shared/auth'
|
||||
import { verifySproutGateToken, fetchSproutGateUser } from '../shared/api'
|
||||
|
||||
const router = useRouter()
|
||||
const status = ref('loading')
|
||||
const displayName = ref('')
|
||||
const errorMessage = ref('')
|
||||
|
||||
const goHome = () => router.push('/')
|
||||
|
||||
const parseFragment = () => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
const params = new URLSearchParams(hash)
|
||||
return {
|
||||
token: params.get('token') || '',
|
||||
account: params.get('account') || '',
|
||||
username: params.get('username') || ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { token, account, username } = parseFragment()
|
||||
|
||||
if (!token) {
|
||||
status.value = 'error'
|
||||
errorMessage.value = '未获取到登录令牌,请重试'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const verifyData = await verifySproutGateToken(token)
|
||||
if (!verifyData.valid) {
|
||||
status.value = 'error'
|
||||
errorMessage.value = '令牌验证失败,请重新登录'
|
||||
return
|
||||
}
|
||||
|
||||
let avatarUrl = verifyData.user?.avatarUrl || ''
|
||||
let finalAccount = verifyData.user?.account || account
|
||||
let finalUsername = verifyData.user?.username || username
|
||||
|
||||
if (!avatarUrl) {
|
||||
try {
|
||||
const meData = await fetchSproutGateUser(token)
|
||||
avatarUrl = meData.user?.avatarUrl || ''
|
||||
finalAccount = meData.user?.account || finalAccount
|
||||
finalUsername = meData.user?.username || finalUsername
|
||||
} catch {
|
||||
// /me failed, proceed with what we have
|
||||
}
|
||||
}
|
||||
|
||||
setAuth({
|
||||
token,
|
||||
account: finalAccount,
|
||||
username: finalUsername,
|
||||
avatarUrl
|
||||
})
|
||||
|
||||
displayName.value = finalUsername || finalAccount
|
||||
status.value = 'success'
|
||||
setTimeout(() => router.push('/'), 1000)
|
||||
} catch {
|
||||
setAuth({ token, account, username, avatarUrl: '' })
|
||||
displayName.value = username || account
|
||||
status.value = 'success'
|
||||
setTimeout(() => router.push('/'), 1000)
|
||||
}
|
||||
})
|
||||
</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>
|
||||
@@ -1,7 +1,13 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: 'http://localhost:8080'
|
||||
baseURL: apiBaseURL
|
||||
})
|
||||
|
||||
const authApi = axios.create({
|
||||
baseURL: 'https://auth.api.shumengya.top'
|
||||
})
|
||||
|
||||
export const fetchProducts = async () => {
|
||||
@@ -9,6 +15,51 @@ export const fetchProducts = async () => {
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
export const recordProductView = async (id) => {
|
||||
const { data } = await api.post(`/api/products/${id}/view`)
|
||||
return data.data || {}
|
||||
}
|
||||
|
||||
export const createOrder = async (payload, authToken) => {
|
||||
const headers = authToken ? { Authorization: `Bearer ${authToken}` } : {}
|
||||
const { data } = await api.post('/api/checkout', payload, { headers })
|
||||
return data.data || {}
|
||||
}
|
||||
|
||||
export const confirmOrder = async (orderId) => {
|
||||
const { data } = await api.post(`/api/orders/${orderId}/confirm`)
|
||||
return data.data || {}
|
||||
}
|
||||
|
||||
export const fetchStats = async () => {
|
||||
const { data } = await api.get('/api/stats')
|
||||
return data.data || {}
|
||||
}
|
||||
|
||||
export const recordSiteVisit = async () => {
|
||||
const { data } = await api.post('/api/site/visit')
|
||||
return data.data || {}
|
||||
}
|
||||
|
||||
export const verifySproutGateToken = async (token) => {
|
||||
const { data } = await authApi.post('/api/auth/verify', { token })
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchSproutGateUser = async (token) => {
|
||||
const { data } = await authApi.get('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
export const fetchMyOrders = async (authToken) => {
|
||||
const { data } = await api.get('/api/orders', {
|
||||
headers: { Authorization: `Bearer ${authToken}` }
|
||||
})
|
||||
return data.data || []
|
||||
}
|
||||
|
||||
export const fetchAdminToken = async () => {
|
||||
const { data } = await api.get('/api/admin/token')
|
||||
return data.token || ''
|
||||
|
||||
54
mengyastore-frontend/src/modules/shared/auth.js
Normal file
54
mengyastore-frontend/src/modules/shared/auth.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const AUTH_KEY = 'mengyastore_auth'
|
||||
|
||||
const loadAuth = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTH_KEY)
|
||||
if (!raw) return null
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const saved = loadAuth()
|
||||
|
||||
export const authState = reactive({
|
||||
token: saved?.token || '',
|
||||
account: saved?.account || '',
|
||||
username: saved?.username || '',
|
||||
avatarUrl: saved?.avatarUrl || ''
|
||||
})
|
||||
|
||||
export const isLoggedIn = () => !!authState.token
|
||||
|
||||
export const setAuth = (info) => {
|
||||
authState.token = info.token || ''
|
||||
authState.account = info.account || ''
|
||||
authState.username = info.username || ''
|
||||
authState.avatarUrl = info.avatarUrl || ''
|
||||
localStorage.setItem(
|
||||
AUTH_KEY,
|
||||
JSON.stringify({
|
||||
token: authState.token,
|
||||
account: authState.account,
|
||||
username: authState.username,
|
||||
avatarUrl: authState.avatarUrl
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export const clearAuth = () => {
|
||||
authState.token = ''
|
||||
authState.account = ''
|
||||
authState.username = ''
|
||||
authState.avatarUrl = ''
|
||||
localStorage.removeItem(AUTH_KEY)
|
||||
}
|
||||
|
||||
export const getLoginUrl = () => {
|
||||
const redirectUri = `${window.location.origin}/auth/callback`
|
||||
const state = Math.random().toString(36).slice(2)
|
||||
return `https://auth.shumengya.top/?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`
|
||||
}
|
||||
325
mengyastore-frontend/src/modules/store/CheckoutPage.vue
Normal file
325
mengyastore-frontend/src/modules/store/CheckoutPage.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<template>
|
||||
<section class="page-card" v-if="!loading && product">
|
||||
<div class="checkout-header">
|
||||
<div>
|
||||
<h2>立即下单 · {{ product.name }}</h2>
|
||||
<p class="tag">一个扫码流程即可完成订单</p>
|
||||
</div>
|
||||
<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">
|
||||
<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>
|
||||
</span>
|
||||
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
|
||||
</p>
|
||||
<p class="markdown" v-html="renderMarkdown(product.description)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="checkout-form" @submit.prevent="submitOrder" v-if="!orderResult">
|
||||
<div class="form-field">
|
||||
<label>下单数量</label>
|
||||
<input v-model.number="form.quantity" min="1" type="number" />
|
||||
</div>
|
||||
<p class="tag">预计总价:¥ {{ totalPrice.toFixed(2) }}</p>
|
||||
<p class="tag login-hint" v-if="!loggedIn">
|
||||
提示:<a :href="loginUrl">登录萌芽账号</a> 后下单可查看历史订单记录
|
||||
</p>
|
||||
<button class="primary" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '生成中...' : '生成二维码下单' }}
|
||||
</button>
|
||||
<p class="error" v-if="error">{{ error }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section class="order-result" v-if="orderResult">
|
||||
<h4>订单 {{ orderResult.orderId }} 已创建</h4>
|
||||
|
||||
<template v-if="!confirmed">
|
||||
<p class="tag">请扫描下方二维码完成付款</p>
|
||||
<img :src="orderResult.qrCodeUrl" alt="下单二维码" />
|
||||
<div class="confirm-area">
|
||||
<button class="primary" :disabled="confirming" @click="doConfirm">
|
||||
{{ confirming ? '确认中...' : '我已完成扫码付款' }}
|
||||
</button>
|
||||
<p class="tag">扫码完成后点击上方按钮获取购买内容</p>
|
||||
</div>
|
||||
<p class="error" v-if="confirmError">{{ confirmError }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<p class="tag confirmed-badge">订单已完成</p>
|
||||
<div class="result-content" v-if="deliveredCodes.length">
|
||||
<p class="tag">购买后内容</p>
|
||||
<textarea class="delivery-box" readonly :value="deliveredCodes.join('\n')"></textarea>
|
||||
</div>
|
||||
<p class="tag">感谢购买!如有问题请联系售后邮箱。</p>
|
||||
</template>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else-if="!loading">
|
||||
<h2>未找到该商品</h2>
|
||||
<p class="tag">该商品无法下单,请返回商店页。</p>
|
||||
<div class="detail-actions">
|
||||
<button class="ghost" @click="goBack">返回商品</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else>
|
||||
<div class="status">加载中...</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { fetchProducts, createOrder, confirmOrder } from '../shared/api'
|
||||
import { authState, isLoggedIn, getLoginUrl } from '../shared/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const md = new MarkdownIt()
|
||||
const product = ref(null)
|
||||
const loading = ref(true)
|
||||
const submitting = ref(false)
|
||||
const error = ref('')
|
||||
const orderResult = ref(null)
|
||||
const confirmed = ref(false)
|
||||
const confirming = ref(false)
|
||||
const confirmError = ref('')
|
||||
const deliveredCodes = ref([])
|
||||
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
const loginUrl = computed(() => getLoginUrl())
|
||||
|
||||
const form = reactive({
|
||||
quantity: 1
|
||||
})
|
||||
|
||||
const renderMarkdown = (content) => md.render(content || '')
|
||||
|
||||
const getPayPrice = (p) => {
|
||||
if (!p) return 0
|
||||
if (p.price === 0) return 0
|
||||
if (p.discountPrice > 0 && p.discountPrice < p.price) return p.discountPrice
|
||||
return p.price
|
||||
}
|
||||
|
||||
const unitPrice = computed(() => getPayPrice(product.value))
|
||||
const isFree = computed(() => unitPrice.value === 0)
|
||||
|
||||
const totalPrice = computed(() => {
|
||||
if (!product.value) {
|
||||
return 0
|
||||
}
|
||||
const qty = form.quantity && form.quantity > 0 ? form.quantity : 1
|
||||
return unitPrice.value * qty
|
||||
})
|
||||
|
||||
const goBack = () => router.push('/')
|
||||
|
||||
const submitOrder = async () => {
|
||||
if (!product.value) {
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
submitting.value = true
|
||||
orderResult.value = null
|
||||
confirmed.value = false
|
||||
deliveredCodes.value = []
|
||||
try {
|
||||
const payload = {
|
||||
productId: product.value.id,
|
||||
quantity: form.quantity
|
||||
}
|
||||
const token = isLoggedIn() ? authState.token : null
|
||||
const response = await createOrder(payload, token)
|
||||
orderResult.value = response
|
||||
} catch (err) {
|
||||
error.value = err?.response?.data?.error || '下单失败,请稍候再试'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const doConfirm = async () => {
|
||||
if (!orderResult.value?.orderId) {
|
||||
return
|
||||
}
|
||||
confirmError.value = ''
|
||||
confirming.value = true
|
||||
try {
|
||||
const result = await confirmOrder(orderResult.value.orderId)
|
||||
deliveredCodes.value = result.deliveredCodes || []
|
||||
confirmed.value = true
|
||||
} catch (err) {
|
||||
confirmError.value = err?.response?.data?.error || '确认失败,请重试'
|
||||
} finally {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const list = await fetchProducts()
|
||||
product.value = list.find((item) => item.id === route.params.id) || null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</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: 12px;
|
||||
}
|
||||
|
||||
.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: 14px;
|
||||
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: 13px;
|
||||
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: 14px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d64848;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 24px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.free-price {
|
||||
color: #3a9a68;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,17 @@
|
||||
<div class="detail-summary">
|
||||
<h2>{{ product.name }}</h2>
|
||||
<p class="tag">库存:{{ product.quantity }}</p>
|
||||
<p class="product-price">¥ {{ product.price.toFixed(2) }}</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>
|
||||
</span>
|
||||
<span v-else>¥ {{ product.price.toFixed(2) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="markdown detail-description" v-html="renderMarkdown(product.description)"></div>
|
||||
@@ -57,7 +67,7 @@
|
||||
</div>
|
||||
|
||||
<div class="detail-actions">
|
||||
<button class="buy-button" @click="buy">立即购买</button>
|
||||
<button class="buy-button" @click="goCheckout">立即下单</button>
|
||||
<button class="ghost" @click="goBack">返回商店首页</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,7 +90,7 @@
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { fetchProducts } from '../shared/api'
|
||||
import { fetchProducts, recordProductView } from '../shared/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -89,6 +99,16 @@ const loading = ref(true)
|
||||
const product = ref(null)
|
||||
const currentImageIndex = ref(0)
|
||||
|
||||
const getPayPrice = (p) => {
|
||||
if (!p) return 0
|
||||
if (p.price === 0) return 0
|
||||
if (p.discountPrice > 0 && p.discountPrice < p.price) return p.discountPrice
|
||||
return p.price
|
||||
}
|
||||
|
||||
const unitPrice = computed(() => getPayPrice(product.value))
|
||||
const isFree = computed(() => unitPrice.value === 0)
|
||||
|
||||
const renderMarkdown = (content) => md.render(content || '')
|
||||
|
||||
const galleryImages = computed(() => {
|
||||
@@ -124,8 +144,11 @@ const goBack = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const buy = () => {
|
||||
alert('已加入购买清单(示例)')
|
||||
const goCheckout = () => {
|
||||
if (!product.value) {
|
||||
return
|
||||
}
|
||||
router.push(`/checkout/${product.value.id}`)
|
||||
}
|
||||
|
||||
const prevImage = () => {
|
||||
@@ -157,6 +180,19 @@ onMounted(async () => {
|
||||
try {
|
||||
const list = await fetchProducts()
|
||||
product.value = list.find((item) => item.id === route.params.id) || null
|
||||
if (product.value) {
|
||||
try {
|
||||
const result = await recordProductView(product.value.id)
|
||||
if (typeof result.viewCount === 'number') {
|
||||
product.value = {
|
||||
...product.value,
|
||||
viewCount: result.viewCount
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('record product view failed', error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -269,6 +305,11 @@ onMounted(async () => {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.free-price {
|
||||
color: #3a9a68;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.detail-thumb {
|
||||
padding: 6px;
|
||||
|
||||
@@ -3,28 +3,83 @@
|
||||
<div class="hero">
|
||||
<div>
|
||||
<h2>所有商品</h2>
|
||||
<div class="filter-search-row">
|
||||
<div class="filters">
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: filter === 'all' }"
|
||||
type="button"
|
||||
@click="setFilter('all')"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: filter === 'free' }"
|
||||
type="button"
|
||||
@click="setFilter('free')"
|
||||
>
|
||||
免费
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-row">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
class="search-input"
|
||||
type="text"
|
||||
placeholder="搜索商品/标签"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="status">加载中...</div>
|
||||
<div v-else>
|
||||
<div class="grid">
|
||||
<router-link
|
||||
<div
|
||||
v-for="item in pagedProducts"
|
||||
:key="item.id"
|
||||
:to="`/product/${item.id}`"
|
||||
class="product-link"
|
||||
:class="['product-link', { 'is-disabled': isSoldOut(item) }]"
|
||||
@click="handleCardClick(item)"
|
||||
>
|
||||
<article class="product-card">
|
||||
<img :src="item.coverUrl" :alt="item.name" />
|
||||
<div class="product-meta">
|
||||
<h3>{{ item.name }}</h3>
|
||||
<span class="product-price">¥ {{ item.price.toFixed(2) }}</span>
|
||||
<div class="cover-wrap">
|
||||
<img :src="item.coverUrl" :alt="item.name" />
|
||||
<div v-if="isSoldOut(item)" class="soldout-badge">已售空</div>
|
||||
</div>
|
||||
<div class="card-top">
|
||||
<h3 class="card-name">{{ item.name }}</h3>
|
||||
<div class="card-price">
|
||||
<span v-if="isFree(item)" 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>
|
||||
</span>
|
||||
<span v-else class="product-price">¥ {{ item.price.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-mid">
|
||||
<div class="markdown" v-html="renderMarkdown(item.description)"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-bottom">
|
||||
<div class="meta-row">
|
||||
<div class="tag">库存:{{ item.quantity }}</div>
|
||||
<div class="tag">浏览量:{{ item.viewCount || 0 }}</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag">库存:{{ item.quantity }}</div>
|
||||
<div class="markdown" v-html="renderMarkdown(item.description)"></div>
|
||||
</article>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
@@ -37,15 +92,20 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { fetchProducts } from '../shared/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const products = ref([])
|
||||
const loading = ref(true)
|
||||
const md = new MarkdownIt()
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
const filter = ref('all')
|
||||
const searchQuery = ref('')
|
||||
|
||||
const renderMarkdown = (content) => md.render(content || '')
|
||||
|
||||
@@ -54,15 +114,57 @@ const updatePerPage = () => {
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
const getPayPrice = (item) => {
|
||||
if (!item) return 0
|
||||
// 折扣规则:
|
||||
// - 未填/无效折扣:discountPrice <= 0 或 >= price => 不启用折扣
|
||||
// - 只有当 discountPrice > 0 且小于原价,才用折扣价
|
||||
// - 只有 price = 0(实付价为 0)才显示“免费”
|
||||
if (item.price === 0) return 0
|
||||
if (item.discountPrice > 0 && item.discountPrice < item.price) return item.discountPrice
|
||||
return item.price
|
||||
}
|
||||
|
||||
const isFree = (item) => getPayPrice(item) === 0
|
||||
|
||||
const matchesSearch = (item) => {
|
||||
const q = (searchQuery.value || '').trim().toLowerCase()
|
||||
if (!q) return true
|
||||
const name = (item?.name || '').toLowerCase()
|
||||
const tags = (item?.tags || []).join(',').toLowerCase()
|
||||
return name.includes(q) || tags.includes(q)
|
||||
}
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
let list = products.value
|
||||
if (filter.value === 'free') list = list.filter((p) => isFree(p))
|
||||
if ((searchQuery.value || '').trim()) list = list.filter((p) => matchesSearch(p))
|
||||
return list
|
||||
})
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(products.value.length / perPage.value))
|
||||
Math.max(1, Math.ceil(filteredProducts.value.length / perPage.value))
|
||||
)
|
||||
|
||||
const pagedProducts = computed(() => {
|
||||
const start = (page.value - 1) * perPage.value
|
||||
return products.value.slice(start, start + perPage.value)
|
||||
return filteredProducts.value.slice(start, start + perPage.value)
|
||||
})
|
||||
|
||||
const isSoldOut = (item) => item && item.quantity === 0
|
||||
|
||||
const handleCardClick = (item) => {
|
||||
if (!item || isSoldOut(item)) {
|
||||
return
|
||||
}
|
||||
router.push(`/product/${item.id}`)
|
||||
}
|
||||
|
||||
const setFilter = (next) => {
|
||||
filter.value = next
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updatePerPage()
|
||||
window.addEventListener('resize', updatePerPage)
|
||||
@@ -73,6 +175,14 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(filter, () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
watch(searchQuery, () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updatePerPage)
|
||||
})
|
||||
@@ -86,6 +196,87 @@ onUnmounted(() => {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.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: 'Source Sans 3', sans-serif;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(180, 154, 203, 0.18);
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-2);
|
||||
background: rgba(145, 168, 208, 0.08);
|
||||
border: 1px solid rgba(145, 168, 208, 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: 13px;
|
||||
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);
|
||||
}
|
||||
|
||||
.free-price {
|
||||
color: #3a9a68;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.product-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
@@ -93,6 +284,106 @@ onUnmounted(() => {
|
||||
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: 16px;
|
||||
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;
|
||||
}
|
||||
|
||||
.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: 12px;
|
||||
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: 'Playfair Display', serif;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.price-original {
|
||||
text-decoration: line-through;
|
||||
color: var(--muted);
|
||||
margin-right: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.price-discount {
|
||||
color: var(--accent-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 24px 0;
|
||||
color: var(--muted);
|
||||
@@ -105,4 +396,16 @@ onUnmounted(() => {
|
||||
gap: 12px;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.filter-search-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
200
mengyastore-frontend/src/modules/user/MyOrdersPage.vue
Normal file
200
mengyastore-frontend/src/modules/user/MyOrdersPage.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<section class="page-card" v-if="!loggedIn">
|
||||
<div class="auth-prompt">
|
||||
<h2>请先登录</h2>
|
||||
<p class="tag">登录萌芽账号后即可查看你的历史订单记录。</p>
|
||||
<a class="login-btn" :href="loginUrl">使用萌芽账号登录</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="page-card" v-else>
|
||||
<div class="orders-header">
|
||||
<div>
|
||||
<h2>我的订单</h2>
|
||||
<p class="tag">
|
||||
已登录:{{ authState.username || authState.account }}
|
||||
<span v-if="orders.length">· 共 {{ orders.length }} 笔订单</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="orders-actions">
|
||||
<button class="ghost" @click="refresh">刷新</button>
|
||||
<button class="ghost" @click="goHome">返回商店</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="status">加载中...</div>
|
||||
|
||||
<div v-else-if="orders.length === 0" class="status">暂无订单记录</div>
|
||||
|
||||
<div v-else class="orders-list">
|
||||
<div v-for="order in orders" :key="order.id" class="order-card">
|
||||
<div class="order-head">
|
||||
<div>
|
||||
<h4>{{ order.productName }}</h4>
|
||||
<span class="tag">订单号:{{ order.id }}</span>
|
||||
</div>
|
||||
<div class="order-meta">
|
||||
<span class="badge">数量 {{ order.quantity }}</span>
|
||||
<span class="tag">{{ formatTime(order.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-codes" v-if="order.deliveredCodes?.length">
|
||||
<p class="tag">购买内容</p>
|
||||
<textarea
|
||||
class="codes-box"
|
||||
readonly
|
||||
:value="order.deliveredCodes.join('\n')"
|
||||
:rows="Math.min(order.deliveredCodes.length, 5)"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { fetchMyOrders } from '../shared/api'
|
||||
import { authState, isLoggedIn, getLoginUrl } from '../shared/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const orders = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const loggedIn = computed(() => isLoggedIn())
|
||||
const loginUrl = computed(() => getLoginUrl())
|
||||
|
||||
const goHome = () => router.push('/')
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
if (!isLoggedIn()) return
|
||||
loading.value = true
|
||||
try {
|
||||
orders.value = await fetchMyOrders(authState.token)
|
||||
} catch {
|
||||
orders.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isLoggedIn()) {
|
||||
refresh()
|
||||
} else {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-prompt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 48px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.orders-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.orders-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.orders-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
padding: 18px;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid var(--line);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.order-codes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.codes-box {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 24px 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.orders-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.order-head {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.order-meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,13 +2,19 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import StorePage from '../modules/store/StorePage.vue'
|
||||
import AdminPage from '../modules/admin/AdminPage.vue'
|
||||
import ProductDetail from '../modules/store/ProductDetail.vue'
|
||||
import CheckoutPage from '../modules/store/CheckoutPage.vue'
|
||||
import AuthCallback from '../modules/auth/AuthCallback.vue'
|
||||
import MyOrdersPage from '../modules/user/MyOrdersPage.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'store', component: StorePage },
|
||||
{ path: '/product/:id', name: 'product-detail', component: ProductDetail },
|
||||
{ path: '/admin', name: 'admin', component: AdminPage }
|
||||
{ path: '/checkout/:id', name: 'checkout', component: CheckoutPage },
|
||||
{ path: '/admin', name: 'admin', component: AdminPage },
|
||||
{ path: '/auth/callback', name: 'auth-callback', component: AuthCallback },
|
||||
{ path: '/my/orders', name: 'my-orders', component: MyOrdersPage }
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
378
sproutgate-API_DOCS.md
Normal file
378
sproutgate-API_DOCS.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# 萌芽账户认证中心 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>'
|
||||
```
|
||||
Reference in New Issue
Block a user