update: 2026-03-28 21:00

This commit is contained in:
2026-03-28 21:00:22 +08:00
parent c0cff7f7a1
commit f6e150ba97
66 changed files with 18392 additions and 18422 deletions

32
.gitignore vendored
View File

@@ -1,16 +1,16 @@
debug-logs/ debug-logs/
node_modules/ node_modules/
build/ build/
dist/ dist/
coverage/ coverage/
.env .env
.env.* .env.*
*.log *.log
*.exe *.exe
*.exe~ *.exe~
*~ *~
.DS_Store .DS_Store
.cursor/ .cursor/
config.json config.json
mengyastore-backend/data/ mengyastore-backend/data/
mengyastore-frontend/public/logo.png mengyastore-frontend/public/logo.png

View File

@@ -0,0 +1,42 @@
# 代码规范
## 注释语言
所有代码注释必须使用**中文**,禁止使用英文注释。
适用范围Go、Java、JavaScript、TypeScript、Vue、CSS、SQL 等所有文件。
示例Go
```go
// 获取商品列表
func (s *ProductStore) List() ([]models.Product, error) {
// 查询所有激活状态的商品
...
}
```
示例JavaScript/Vue
```js
// 初始化认证状态
const saved = loadAuth()
```
## 域名与环境配置
- 前端生产环境域名:`store.shumengya.top`
- 后端生产环境域名:`store.api.shumengya.top`
- 后端 CORS 策略:宽松模式,允许所有来源(`AllowOrigins: ["*"]`
前端 `.env.production` 示例:
```
VITE_API_BASE_URL=https://store.api.shumengya.top
```
后端 CORS 配置示例Go/Gin
```go
r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Admin-Token"},
}))
```

View File

@@ -1,3 +1,3 @@
{ {
"kiroAgent.configureMCP": "Disabled" "kiroAgent.configureMCP": "Disabled"
} }

File diff suppressed because it is too large Load Diff

526
README.md
View File

@@ -1,263 +1,263 @@
# 萌芽小店 · Mengyastore # 萌芽小店 · Mengyastore
一个前后端分离的轻量级商城,支持自动/手动发货、邮件通知、聊天客服、收藏夹与 PWA 安装。 一个前后端分离的轻量级商城,支持自动/手动发货、邮件通知、聊天客服、收藏夹与 PWA 安装。
--- ---
## 技术栈 ## 技术栈
### 前端 `mengyastore-frontend/` ### 前端 `mengyastore-frontend/`
| 分类 | 技术 | 版本 | 说明 | | 分类 | 技术 | 版本 | 说明 |
|------|------|------|------| |------|------|------|------|
| 框架 | Vue 3 | ^3.4 | Composition API + `<script setup>` | | 框架 | Vue 3 | ^3.4 | Composition API + `<script setup>` |
| 构建工具 | Vite 5 | ^5.2 | 开发服务器 / 生产打包 | | 构建工具 | Vite 5 | ^5.2 | 开发服务器 / 生产打包 |
| 路由 | Vue Router 4 | ^4.3 | Hash / History 模式 | | 路由 | Vue Router 4 | ^4.3 | Hash / History 模式 |
| 状态管理 | Pinia 2 | ^2.1 | 全局 auth 状态 | | 状态管理 | Pinia 2 | ^2.1 | 全局 auth 状态 |
| HTTP 客户端 | Axios | ^1.6 | API 请求封装 | | HTTP 客户端 | Axios | ^1.6 | API 请求封装 |
| Markdown 渲染 | markdown-it | ^14.1 | 商品描述富文本 | | Markdown 渲染 | markdown-it | ^14.1 | 商品描述富文本 |
| PWA | vite-plugin-pwa | ^1.2 | Service Worker + Web Manifest | | PWA | vite-plugin-pwa | ^1.2 | Service Worker + Web Manifest |
| PWA 图标生成 | @vite-pwa/assets-generator | ^1.0 | 从 logo 自动生成各尺寸 PNG | | PWA 图标生成 | @vite-pwa/assets-generator | ^1.0 | 从 logo 自动生成各尺寸 PNG |
| 认证 | SproutGate OAuth | — | 第三方账号系统redirect 回调 | | 认证 | SproutGate OAuth | — | 第三方账号系统redirect 回调 |
| 样式 | 原生 CSS + Scoped | — | CSS 变量、Flexbox、Grid、媒体查询 | | 样式 | 原生 CSS + Scoped | — | CSS 变量、Flexbox、Grid、媒体查询 |
**主要模块** **主要模块**
``` ```
src/ src/
modules/ modules/
admin/ # 管理员后台(商品/订单/聊天/站点设置) admin/ # 管理员后台(商品/订单/聊天/站点设置)
auth/ # OAuth 回调处理 auth/ # OAuth 回调处理
chat/ # 用户端悬浮聊天窗口 chat/ # 用户端悬浮聊天窗口
maintenance/ # 维护模式页面 maintenance/ # 维护模式页面
shared/ # api.js / auth.js / SplashScreen.vue / useWishlist.js shared/ # api.js / auth.js / SplashScreen.vue / useWishlist.js
store/ # 商品列表、详情、结账 store/ # 商品列表、详情、结账
user/ # 我的订单 user/ # 我的订单
wishlist/ # 收藏夹 wishlist/ # 收藏夹
assets/styles.css # 全局样式变量 assets/styles.css # 全局样式变量
router/index.js # 路由定义 + 全局导航守卫 router/index.js # 路由定义 + 全局导航守卫
main.js # 应用入口 main.js # 应用入口
``` ```
**PWA 特性** **PWA 特性**
- `display: standalone` 独立窗口模式,可添加到主屏 - `display: standalone` 独立窗口模式,可添加到主屏
- Service Worker静态资源 `CacheFirst`API 请求 `NetworkFirst`5 分钟缓存) - Service Worker静态资源 `CacheFirst`API 请求 `NetworkFirst`5 分钟缓存)
- 自动检测新版本,底部 toast 提示更新 - 自动检测新版本,底部 toast 提示更新
- 启动画面SplashScreen.vue渐变背景 + Logo 浮动 + 扩散环 + 绿色圆点加载动画 - 启动画面SplashScreen.vue渐变背景 + Logo 浮动 + 扩散环 + 绿色圆点加载动画
--- ---
### 后端 `mengyastore-backend/` ### 后端 `mengyastore-backend/`
| 分类 | 技术 | 版本 | 说明 | | 分类 | 技术 | 版本 | 说明 |
|------|------|------|------| |------|------|------|------|
| 语言 | Go | 1.21+ | 静态编译,单二进制部署 | | 语言 | Go | 1.21+ | 静态编译,单二进制部署 |
| Web 框架 | Gin | ^1.9 | HTTP 路由、中间件、JSON 绑定 | | Web 框架 | Gin | ^1.9 | HTTP 路由、中间件、JSON 绑定 |
| ORM | GORM | ^1.25 | MySQL 数据库操作 | | ORM | GORM | ^1.25 | MySQL 数据库操作 |
| MySQL 驱动 | go-sql-driver/mysql | ^1.9 | GORM MySQL 方言 | | MySQL 驱动 | go-sql-driver/mysql | ^1.9 | GORM MySQL 方言 |
| CORS | gin-contrib/cors | — | 跨域请求支持 | | CORS | gin-contrib/cors | — | 跨域请求支持 |
| UUID | google/uuid | — | 订单 ID 生成 | | UUID | google/uuid | — | 订单 ID 生成 |
| 邮件 | net/smtp + crypto/tls | 标准库 | SMTP 邮件通知,支持 SSL/TLS端口 465 | | 邮件 | net/smtp + crypto/tls | 标准库 | SMTP 邮件通知,支持 SSL/TLS端口 465 |
| 认证 | SproutGate OAuth | — | `/api/auth/verify` Token 验证 | | 认证 | SproutGate OAuth | — | `/api/auth/verify` Token 验证 |
| 数据库 | MySQL 8.x | — | 生产192.168.1.100:3306测试10.1.1.100:3306 | | 数据库 | MySQL 8.x | — | 生产192.168.1.100:3306测试10.1.1.100:3306 |
| 容器化 | Docker + docker-compose | — | 镜像构建与部署 | | 容器化 | Docker + docker-compose | — | 镜像构建与部署 |
**主要模块** **主要模块**
``` ```
internal/ internal/
auth/ # SproutGate OAuth 客户端 auth/ # SproutGate OAuth 客户端
config/ # config.json 读写adminToken、DSN 等) config/ # config.json 读写adminToken、DSN 等)
database/ # GORM 初始化 (db.go) + 表结构定义 (models.go) database/ # GORM 初始化 (db.go) + 表结构定义 (models.go)
email/ # SMTP 邮件发送服务 email/ # SMTP 邮件发送服务
handlers/ # HTTP 处理器(按功能拆分文件) handlers/ # HTTP 处理器(按功能拆分文件)
admin.go # 通用管理员接口 admin.go # 通用管理员接口
admin_chat.go # 聊天管理 admin_chat.go # 聊天管理
admin_orders.go # 订单管理(查看/确认/删除/分页) admin_orders.go # 订单管理(查看/确认/删除/分页)
admin_product.go # 商品 CRUD admin_product.go # 商品 CRUD
admin_site.go # 站点设置维护模式、SMTP 配置) admin_site.go # 站点设置维护模式、SMTP 配置)
chat.go # 用户聊天消息读写 chat.go # 用户聊天消息读写
order.go # 下单 / 自动发货 / 邮件通知 order.go # 下单 / 自动发货 / 邮件通知
stats.go # 访问统计 stats.go # 访问统计
wishlist.go # 收藏夹 wishlist.go # 收藏夹
models/ # 业务模型Product、Order、Chat 等) models/ # 业务模型Product、Order、Chat 等)
storage/ # 数据库 CRUD 封装(每类资源一个文件) storage/ # 数据库 CRUD 封装(每类资源一个文件)
cmd/ cmd/
migrate/main.go # 数据库 Schema 初始化 / 迁移脚本 migrate/main.go # 数据库 Schema 初始化 / 迁移脚本
main.go # 路由注册 + 依赖注入入口 main.go # 路由注册 + 依赖注入入口
``` ```
**数据库表** **数据库表**
| 表名 | 主要字段 | | 表名 | 主要字段 |
|------|---------| |------|---------|
| `products` | id, name, description, price, discount_price, cover_url, screenshot_urls, tags, codes卡密, delivery_mode, require_login, max_per_account, total_sold, view_count, active, created_at | | `products` | id, name, description, price, discount_price, cover_url, screenshot_urls, tags, codes卡密, delivery_mode, require_login, max_per_account, total_sold, view_count, active, created_at |
| `product_codes` | id, product_id, code卡密条目 | | `product_codes` | id, product_id, code卡密条目 |
| `orders` | id, product_id, product_name, user_account, user_name, quantity, status, delivery_mode, delivered_codes, note, contact_phone, contact_email, notify_email, created_at | | `orders` | id, product_id, product_name, user_account, user_name, quantity, status, delivery_mode, delivered_codes, note, contact_phone, contact_email, notify_email, created_at |
| `chat_messages` | id, account_id, account_name, content, sent_at, from_admin | | `chat_messages` | id, account_id, account_name, content, sent_at, from_admin |
| `wishlists` | id, account, product_id | | `wishlists` | id, account, product_id |
| `site_settings` | key, valueKV 存储maintenance、SMTP 配置等) | | `site_settings` | key, valueKV 存储maintenance、SMTP 配置等) |
--- ---
## 功能列表 ## 功能列表
| 功能 | 前端 | 后端 | | 功能 | 前端 | 后端 |
|------|------|------| |------|------|------|
| 商品浏览(分页:桌面 4×5 / 手机 5×2 | ✅ | ✅ | | 商品浏览(分页:桌面 4×5 / 手机 5×2 | ✅ | ✅ |
| 商品详情 + Markdown 描述 | ✅ | ✅ | | 商品详情 + Markdown 描述 | ✅ | ✅ |
| 下单(自动/手动发货) | ✅ | ✅ | | 下单(自动/手动发货) | ✅ | ✅ |
| 订单邮件通知SMTP 可配置) | ✅ | ✅ | | 订单邮件通知SMTP 可配置) | ✅ | ✅ |
| 用户收藏夹 | ✅ | ✅ | | 用户收藏夹 | ✅ | ✅ |
| 我的订单 | ✅ | ✅ | | 我的订单 | ✅ | ✅ |
| SproutGate 账号登录 | ✅ | ✅ | | SproutGate 账号登录 | ✅ | ✅ |
| 用户聊天客服HTTP 轮询) | ✅ | ✅ | | 用户聊天客服HTTP 轮询) | ✅ | ✅ |
| 维护模式 | ✅ | ✅ | | 维护模式 | ✅ | ✅ |
| 管理后台 - 商品 CRUD | ✅ | ✅ | | 管理后台 - 商品 CRUD | ✅ | ✅ |
| 管理后台 - 订单查看/确认/删除/分页 | ✅ | ✅ | | 管理后台 - 订单查看/确认/删除/分页 | ✅ | ✅ |
| 管理后台 - 聊天消息管理 | ✅ | ✅ | | 管理后台 - 聊天消息管理 | ✅ | ✅ |
| 管理后台 - SMTP 邮件配置(含启用/关闭开关) | ✅ | ✅ | | 管理后台 - SMTP 邮件配置(含启用/关闭开关) | ✅ | ✅ |
| 管理后台 - 站点设置(维护模式) | ✅ | ✅ | | 管理后台 - 站点设置(维护模式) | ✅ | ✅ |
| 访问统计 & 订单统计 | ✅ | ✅ | | 访问统计 & 订单统计 | ✅ | ✅ |
| PWA 安装 + 离线缓存 | ✅ | — | | PWA 安装 + 离线缓存 | ✅ | — |
| 移动端响应式 | ✅ | — | | 移动端响应式 | ✅ | — |
--- ---
## 快速开始(从零部署) ## 快速开始(从零部署)
### 1. 准备 MySQL 数据库 ### 1. 准备 MySQL 数据库
> **表结构无需手动创建**,后端启动时 GORM 会自动 `AutoMigrate`。 > **表结构无需手动创建**,后端启动时 GORM 会自动 `AutoMigrate`。
> 只需创建数据库和用户: > 只需创建数据库和用户:
```bash ```bash
mysql -u root -p < mengyastore-backend/init.sql mysql -u root -p < mengyastore-backend/init.sql
``` ```
或手动执行: 或手动执行:
```sql ```sql
CREATE DATABASE IF NOT EXISTS `mengyastore` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE DATABASE IF NOT EXISTS `mengyastore` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'mengyastore'@'%' IDENTIFIED BY 'your_password'; CREATE USER IF NOT EXISTS 'mengyastore'@'%' IDENTIFIED BY 'your_password';
GRANT ALL PRIVILEGES ON `mengyastore`.* TO 'mengyastore'@'%'; GRANT ALL PRIVILEGES ON `mengyastore`.* TO 'mengyastore'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
``` ```
### 2. 配置后端 ### 2. 配置后端
`mengyastore-backend/` 目录创建 `config.json` `mengyastore-backend/` 目录创建 `config.json`
```json ```json
{ {
"adminToken": "your_admin_token_here", "adminToken": "your_admin_token_here",
"databaseDsn": "mengyastore:your_password@tcp(127.0.0.1:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local" "databaseDsn": "mengyastore:your_password@tcp(127.0.0.1:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
} }
``` ```
也可以用环境变量覆盖(优先级更高): 也可以用环境变量覆盖(优先级更高):
```bash ```bash
export DATABASE_DSN="mengyastore:your_password@tcp(127.0.0.1:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local" export DATABASE_DSN="mengyastore:your_password@tcp(127.0.0.1:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
``` ```
### 3. 启动后端 ### 3. 启动后端
```bash ```bash
cd mengyastore-backend cd mengyastore-backend
go run . # http://localhost:8080 go run . # http://localhost:8080
# 首次启动会自动创建全部表结构 # 首次启动会自动创建全部表结构
``` ```
### 4. 启动前端 ### 4. 启动前端
```bash ```bash
cd mengyastore-frontend cd mengyastore-frontend
npm install npm install
npm run dev # http://localhost:5173 npm run dev # http://localhost:5173
``` ```
### 5. 访问管理后台 ### 5. 访问管理后台
在网站 Logo 上快速点击 **5 次**,输入 `config.json` 中的 `adminToken` 进入管理后台。 在网站 Logo 上快速点击 **5 次**,输入 `config.json` 中的 `adminToken` 进入管理后台。
> **安全说明**:后端采用 `POST /api/admin/verify` 接口验证令牌,只返回 `{"valid": true/false}`,不会将令牌明文传输到前端。 > **安全说明**:后端采用 `POST /api/admin/verify` 接口验证令牌,只返回 `{"valid": true/false}`,不会将令牌明文传输到前端。
--- ---
## 生产部署Docker ## 生产部署Docker
```bash ```bash
cd mengyastore-backend cd mengyastore-backend
# 1. 创建 config.json同上 # 1. 创建 config.json同上
# 2. 修改 docker-compose.yml 中的 DATABASE_DSN 指向生产数据库 # 2. 修改 docker-compose.yml 中的 DATABASE_DSN 指向生产数据库
# 3. 启动 # 3. 启动
docker-compose up -d docker-compose up -d
``` ```
`docker-compose.yml` 配置说明: `docker-compose.yml` 配置说明:
| 配置项 | 说明 | | 配置项 | 说明 |
|--------|------| |--------|------|
| `DATABASE_DSN` | 生产 MySQL 连接串(覆盖 config.json | | `DATABASE_DSN` | 生产 MySQL 连接串(覆盖 config.json |
| `./config.json:/app/config.json:ro` | 只读挂载配置文件 | | `./config.json:/app/config.json:ro` | 只读挂载配置文件 |
--- ---
## 本地开发 ## 本地开发
```bash ```bash
# 前端热重载 # 前端热重载
cd mengyastore-frontend cd mengyastore-frontend
npm install npm install
npm run dev # http://localhost:5173 npm run dev # http://localhost:5173
# 后端热重载(需安装 air # 后端热重载(需安装 air
cd mengyastore-backend cd mengyastore-backend
go run . go run .
``` ```
**构建前端** **构建前端**
```bash ```bash
cd mengyastore-frontend cd mengyastore-frontend
npm run build # 输出到 dist/ npm run build # 输出到 dist/
``` ```
**构建后端二进制** **构建后端二进制**
```bash ```bash
cd mengyastore-backend cd mengyastore-backend
go build -o mengyastore-backend . go build -o mengyastore-backend .
./mengyastore-backend ./mengyastore-backend
``` ```
--- ---
## 项目结构 ## 项目结构
``` ```
mengyastore/ mengyastore/
mengyastore-frontend/ # Vue 3 + Vite 前端 mengyastore-frontend/ # Vue 3 + Vite 前端
mengyastore-backend/ # Go + Gin 后端 mengyastore-backend/ # Go + Gin 后端
API_DOCS.md # API 接口文档 API_DOCS.md # API 接口文档
README.md # 本文件 README.md # 本文件
``` ```
--- ---
--- ---
## 工程亮点 ## 工程亮点
- **安全**:管理员令牌通过 `POST /api/admin/verify` 服务端验证,令牌不在网络中明文传输 - **安全**:管理员令牌通过 `POST /api/admin/verify` 服务端验证,令牌不在网络中明文传输
- **防刷**:购买数量限制同时统计 `pending``completed` 状态,防止并发绕过 - **防刷**:购买数量限制同时统计 `pending``completed` 状态,防止并发绕过
- **兼容**:管理员鉴权支持 `X-Admin-Token` 请求头Spring Security 标准)/ `Authorization` / `?token` 三种方式 - **兼容**:管理员鉴权支持 `X-Admin-Token` 请求头Spring Security 标准)/ `Authorization` / `?token` 三种方式
- **PWA**Service Worker 离线缓存 + 启动动画 + 自动更新提示 - **PWA**Service Worker 离线缓存 + 启动动画 + 自动更新提示
- **可移植**API 设计对齐 Spring Boot 风格,便于后端语言迁移 - **可移植**API 设计对齐 Spring Boot 风格,便于后端语言迁移
--- ---
© 2025 2026 萌芽小店 · Powered by Vue 3 + Go + MySQL © 2025 2026 萌芽小店 · Powered by Vue 3 + Go + MySQL

View File

@@ -1,256 +1,256 @@
# 萌芽小店 · 后端 # 萌芽小店 · 后端
基于 **Go + Gin + GORM** 构建的 RESTful API 服务,负责商品管理、订单处理、用户认证、聊天消息等核心业务。 基于 **Go + Gin + GORM** 构建的 RESTful API 服务,负责商品管理、订单处理、用户认证、聊天消息等核心业务。
## 技术依赖 ## 技术依赖
| 包 | 版本 | 用途 | | 包 | 版本 | 用途 |
|----|------|------| |----|------|------|
| gin | v1.9 | HTTP 路由框架 | | gin | v1.9 | HTTP 路由框架 |
| gorm | v1.31 | ORM | | gorm | v1.31 | ORM |
| gorm/driver/mysql | v1.6 | MySQL 驱动 | | gorm/driver/mysql | v1.6 | MySQL 驱动 |
| go-sql-driver/mysql | v1.9 | 底层 MySQL 连接 | | go-sql-driver/mysql | v1.9 | 底层 MySQL 连接 |
| gin-contrib/cors | latest | CORS 中间件 | | gin-contrib/cors | latest | CORS 中间件 |
| google/uuid | latest | UUID 生成 | | google/uuid | latest | UUID 生成 |
## 目录结构 ## 目录结构
``` ```
mengyastore-backend/ mengyastore-backend/
├── main.go # 程序入口,路由注册 ├── main.go # 程序入口,路由注册
├── cmd/ ├── cmd/
│ └── migrate/ │ └── migrate/
│ └── main.go # 一次性 JSON→MySQL 数据迁移脚本 │ └── main.go # 一次性 JSON→MySQL 数据迁移脚本
├── data/ ├── data/
│ └── json/ │ └── json/
│ └── settings.json # 服务配置adminToken、DSN 等) │ └── settings.json # 服务配置adminToken、DSN 等)
├── internal/ ├── internal/
│ ├── config/ │ ├── config/
│ │ └── config.go # 配置加载 │ │ └── config.go # 配置加载
│ ├── database/ │ ├── database/
│ │ ├── db.go # GORM 初始化 + AutoMigrate │ │ ├── db.go # GORM 初始化 + AutoMigrate
│ │ └── models.go # 数据库行结构体GORM 模型) │ │ └── models.go # 数据库行结构体GORM 模型)
│ ├── models/ │ ├── models/
│ │ ├── product.go # 业务模型 Product │ │ ├── product.go # 业务模型 Product
│ │ ├── order.go # 业务模型 Order │ │ ├── order.go # 业务模型 Order
│ │ └── chat.go # 业务模型 ChatMessage │ │ └── chat.go # 业务模型 ChatMessage
│ ├── storage/ │ ├── storage/
│ │ ├── jsonstore.go # 商品存储GORM 实现) │ │ ├── jsonstore.go # 商品存储GORM 实现)
│ │ ├── orderstore.go # 订单存储 │ │ ├── orderstore.go # 订单存储
│ │ ├── sitestore.go # 站点设置存储 │ │ ├── sitestore.go # 站点设置存储
│ │ ├── wishliststore.go # 收藏夹存储 │ │ ├── wishliststore.go # 收藏夹存储
│ │ └── chatstore.go # 聊天消息存储(含内存级频率限制) │ │ └── chatstore.go # 聊天消息存储(含内存级频率限制)
│ ├── handlers/ │ ├── handlers/
│ │ ├── admin.go # AdminHandler 结构体 + requireAdmin │ │ ├── admin.go # AdminHandler 结构体 + requireAdmin
│ │ ├── admin_product.go # 商品 CRUD 接口 │ │ ├── admin_product.go # 商品 CRUD 接口
│ │ ├── admin_site.go # 维护模式接口 │ │ ├── admin_site.go # 维护模式接口
│ │ ├── admin_orders.go # 订单管理接口 │ │ ├── admin_orders.go # 订单管理接口
│ │ ├── admin_chat.go # 管理员聊天接口 │ │ ├── admin_chat.go # 管理员聊天接口
│ │ ├── public.go # 公开接口(商品列表、浏览量) │ │ ├── public.go # 公开接口(商品列表、浏览量)
│ │ ├── order.go # 下单、确认订单接口 │ │ ├── order.go # 下单、确认订单接口
│ │ ├── stats.go # 统计信息接口 │ │ ├── stats.go # 统计信息接口
│ │ ├── wishlist.go # 收藏夹接口(用户) │ │ ├── wishlist.go # 收藏夹接口(用户)
│ │ └── chat.go # 聊天接口(用户) │ │ └── chat.go # 聊天接口(用户)
│ └── auth/ │ └── auth/
│ └── sproutgate.go # SproutGate OAuth 客户端 │ └── sproutgate.go # SproutGate OAuth 客户端
``` ```
## API 路由一览 ## API 路由一览
### 公开接口 ### 公开接口
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| GET | `/api/health` | 健康检查 | | GET | `/api/health` | 健康检查 |
| GET | `/api/products` | 获取商品列表(仅 active | | GET | `/api/products` | 获取商品列表(仅 active |
| POST | `/api/products/:id/view` | 记录商品浏览量 | | POST | `/api/products/:id/view` | 记录商品浏览量 |
| GET | `/api/stats` | 获取总订单数和总访问量 | | GET | `/api/stats` | 获取总订单数和总访问量 |
| POST | `/api/site/visit` | 记录站点访问 | | POST | `/api/site/visit` | 记录站点访问 |
| GET | `/api/site/maintenance` | 获取维护状态 | | GET | `/api/site/maintenance` | 获取维护状态 |
| POST | `/api/checkout` | 创建订单(生成支付二维码) | | POST | `/api/checkout` | 创建订单(生成支付二维码) |
| GET | `/api/orders` | 获取当前用户订单(需 Bearer token | | GET | `/api/orders` | 获取当前用户订单(需 Bearer token |
| POST | `/api/orders/:id/confirm` | 确认付款(触发发货) | | POST | `/api/orders/:id/confirm` | 确认付款(触发发货) |
### 收藏夹(需登录) ### 收藏夹(需登录)
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| GET | `/api/wishlist` | 获取收藏商品 ID 列表 | | GET | `/api/wishlist` | 获取收藏商品 ID 列表 |
| POST | `/api/wishlist` | 添加收藏 | | POST | `/api/wishlist` | 添加收藏 |
| DELETE | `/api/wishlist/:id` | 取消收藏 | | DELETE | `/api/wishlist/:id` | 取消收藏 |
### 聊天(需登录) ### 聊天(需登录)
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| GET | `/api/chat/messages` | 获取自己的聊天记录 | | GET | `/api/chat/messages` | 获取自己的聊天记录 |
| POST | `/api/chat/messages` | 发送消息1 秒频率限制) | | POST | `/api/chat/messages` | 发送消息1 秒频率限制) |
### 管理员接口(需 `?token=xxx` 或 `Authorization: <token>` ### 管理员接口(需 `?token=xxx` 或 `Authorization: <token>`
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
|------|------|------| |------|------|------|
| GET | `/api/admin/token` | 获取令牌(用于验证) | | GET | `/api/admin/token` | 获取令牌(用于验证) |
| GET | `/api/admin/products` | 获取全部商品(含卡密) | | GET | `/api/admin/products` | 获取全部商品(含卡密) |
| POST | `/api/admin/products` | 创建商品 | | POST | `/api/admin/products` | 创建商品 |
| PUT | `/api/admin/products/:id` | 编辑商品 | | PUT | `/api/admin/products/:id` | 编辑商品 |
| PATCH | `/api/admin/products/:id/status` | 切换上下架 | | PATCH | `/api/admin/products/:id/status` | 切换上下架 |
| DELETE | `/api/admin/products/:id` | 删除商品 | | DELETE | `/api/admin/products/:id` | 删除商品 |
| POST | `/api/admin/site/maintenance` | 设置维护模式 | | POST | `/api/admin/site/maintenance` | 设置维护模式 |
| GET | `/api/admin/orders` | 获取全部订单 | | GET | `/api/admin/orders` | 获取全部订单 |
| DELETE | `/api/admin/orders/:id` | 删除订单 | | DELETE | `/api/admin/orders/:id` | 删除订单 |
| GET | `/api/admin/chat` | 获取全部用户对话 | | GET | `/api/admin/chat` | 获取全部用户对话 |
| GET | `/api/admin/chat/:account` | 获取指定用户对话 | | GET | `/api/admin/chat/:account` | 获取指定用户对话 |
| POST | `/api/admin/chat/:account` | 管理员回复 | | POST | `/api/admin/chat/:account` | 管理员回复 |
| DELETE | `/api/admin/chat/:account` | 清除对话 | | DELETE | `/api/admin/chat/:account` | 清除对话 |
## 数据库表结构 ## 数据库表结构
### products ### products
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| id | varchar(36) | UUID 主键 | | id | varchar(36) | UUID 主键 |
| name | varchar(255) | 商品名称 | | name | varchar(255) | 商品名称 |
| price | double | 原价 | | price | double | 原价 |
| discount_price | double | 折扣价0 = 无折扣)| | discount_price | double | 折扣价0 = 无折扣)|
| tags | json | 标签数组 | | tags | json | 标签数组 |
| cover_url | varchar(500) | 封面图 URL | | cover_url | varchar(500) | 封面图 URL |
| screenshot_urls | json | 截图 URL 数组(最多 5 张)| | screenshot_urls | json | 截图 URL 数组(最多 5 张)|
| verification_url | varchar(500) | 验证链接 | | verification_url | varchar(500) | 验证链接 |
| description | text | Markdown 描述 | | description | text | Markdown 描述 |
| active | tinyint(1) | 是否上架 | | active | tinyint(1) | 是否上架 |
| require_login | tinyint(1) | 是否必须登录购买 | | require_login | tinyint(1) | 是否必须登录购买 |
| max_per_account | bigint | 每账户最大购买数0=不限)| | max_per_account | bigint | 每账户最大购买数0=不限)|
| total_sold | bigint | 累计销量 | | total_sold | bigint | 累计销量 |
| view_count | bigint | 累计浏览量 | | view_count | bigint | 累计浏览量 |
| delivery_mode | varchar(20) | 发货模式:`auto` / `manual` | | delivery_mode | varchar(20) | 发货模式:`auto` / `manual` |
| show_note | tinyint(1) | 下单时显示备注输入框 | | show_note | tinyint(1) | 下单时显示备注输入框 |
| show_contact | tinyint(1) | 下单时显示联系方式输入框 | | show_contact | tinyint(1) | 下单时显示联系方式输入框 |
| created_at | datetime(3) | 创建时间 | | created_at | datetime(3) | 创建时间 |
### product_codes ### product_codes
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| id | bigint unsigned | 自增主键 | | id | bigint unsigned | 自增主键 |
| product_id | varchar(36) | 关联商品 ID索引| | product_id | varchar(36) | 关联商品 ID索引|
| code | text | 卡密内容 | | code | text | 卡密内容 |
### orders ### orders
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| id | varchar(36) | UUID 主键 | | id | varchar(36) | UUID 主键 |
| product_id | varchar(36) | 商品 ID索引| | product_id | varchar(36) | 商品 ID索引|
| product_name | varchar(255) | 商品名称快照 | | product_name | varchar(255) | 商品名称快照 |
| user_account | varchar(255) | 用户账号(可空,匿名)| | user_account | varchar(255) | 用户账号(可空,匿名)|
| user_name | varchar(255) | 用户昵称 | | user_name | varchar(255) | 用户昵称 |
| quantity | bigint | 购买数量 | | quantity | bigint | 购买数量 |
| delivered_codes | json | 已发放卡密 | | delivered_codes | json | 已发放卡密 |
| status | varchar(20) | `pending` / `completed` | | status | varchar(20) | `pending` / `completed` |
| delivery_mode | varchar(20) | `auto` / `manual` | | delivery_mode | varchar(20) | `auto` / `manual` |
| note | text | 用户备注 | | note | text | 用户备注 |
| contact_phone | varchar(50) | 联系手机号 | | contact_phone | varchar(50) | 联系手机号 |
| contact_email | varchar(255) | 联系邮箱 | | contact_email | varchar(255) | 联系邮箱 |
| created_at | datetime(3) | 下单时间 | | created_at | datetime(3) | 下单时间 |
### site_settings ### site_settings
键值对存储,当前使用的键: 键值对存储,当前使用的键:
| Key | 说明 | | Key | 说明 |
|-----|------| |-----|------|
| `totalVisits` | 总访问量 | | `totalVisits` | 总访问量 |
| `maintenance` | 维护模式(`true` / `false`| | `maintenance` | 维护模式(`true` / `false`|
| `maintenanceReason` | 维护原因文本 | | `maintenanceReason` | 维护原因文本 |
### wishlists ### wishlists
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| id | bigint unsigned | 自增主键 | | id | bigint unsigned | 自增主键 |
| account_id | varchar(255) | 用户账号(唯一索引)| | account_id | varchar(255) | 用户账号(唯一索引)|
| product_id | varchar(36) | 商品 ID联合唯一| | product_id | varchar(36) | 商品 ID联合唯一|
### chat_messages ### chat_messages
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| id | varchar(36) | UUID 主键 | | id | varchar(36) | UUID 主键 |
| account_id | varchar(255) | 用户账号(索引)| | account_id | varchar(255) | 用户账号(索引)|
| account_name | varchar(255) | 用户昵称 | | account_name | varchar(255) | 用户昵称 |
| content | text | 消息内容 | | content | text | 消息内容 |
| sent_at | datetime(3) | 发送时间 | | sent_at | datetime(3) | 发送时间 |
| from_admin | tinyint(1) | 是否来自管理员 | | from_admin | tinyint(1) | 是否来自管理员 |
## 配置文件 ## 配置文件
`data/json/settings.json` `data/json/settings.json`
```json ```json
{ {
"adminToken": "你的管理员令牌", "adminToken": "你的管理员令牌",
"authApiUrl": "https://auth.api.shumengya.top", "authApiUrl": "https://auth.api.shumengya.top",
"databaseDsn": "" "databaseDsn": ""
} }
``` ```
`databaseDsn` 为空时自动使用测试数据库。也可以通过环境变量 `DATABASE_DSN` 覆盖。 `databaseDsn` 为空时自动使用测试数据库。也可以通过环境变量 `DATABASE_DSN` 覆盖。
## 发货逻辑 ## 发货逻辑
### 自动发货(`deliveryMode = "auto"` ### 自动发货(`deliveryMode = "auto"`
1. `POST /api/checkout` → 从 `product_codes` 提取指定数量的卡密 1. `POST /api/checkout` → 从 `product_codes` 提取指定数量的卡密
2. 商品 `quantity` 减少,卡密从数据库删除 2. 商品 `quantity` 减少,卡密从数据库删除
3. 卡密保存到订单 `delivered_codes` 3. 卡密保存到订单 `delivered_codes`
4. 用户 `POST /api/orders/:id/confirm` 确认付款后,订单状态变为 `completed`,响应中返回卡密内容 4. 用户 `POST /api/orders/:id/confirm` 确认付款后,订单状态变为 `completed`,响应中返回卡密内容
5. 同时调用 `IncrementSold` 增加销量统计 5. 同时调用 `IncrementSold` 增加销量统计
### 手动发货(`deliveryMode = "manual"` ### 手动发货(`deliveryMode = "manual"`
1. `POST /api/checkout` → 创建订单,不提取卡密 1. `POST /api/checkout` → 创建订单,不提取卡密
2. 用户 `POST /api/orders/:id/confirm` 后,订单变为 `completed`,但 `delivered_codes` 为空 2. 用户 `POST /api/orders/:id/confirm` 后,订单变为 `completed`,但 `delivered_codes` 为空
3. 管理员在后台查看订单的备注、手机号、邮箱后手动发货 3. 管理员在后台查看订单的备注、手机号、邮箱后手动发货
## 本地开发 ## 本地开发
```bash ```bash
go run . # 启动服务(默认 :8080 go run . # 启动服务(默认 :8080
go build -o mengyastore-backend.exe . # 构建可执行文件 go build -o mengyastore-backend.exe . # 构建可执行文件
go run ./cmd/migrate/main.go # 迁移旧 JSON 数据到数据库 go run ./cmd/migrate/main.go # 迁移旧 JSON 数据到数据库
``` ```
### 切换数据库 ### 切换数据库
```bash ```bash
# 测试库(默认) # 测试库(默认)
# host: 10.1.1.100:3306 / db: mengyastore-test # host: 10.1.1.100:3306 / db: mengyastore-test
# 生产库 # 生产库
set DATABASE_DSN=mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local set DATABASE_DSN=mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local
./mengyastore-backend.exe ./mengyastore-backend.exe
``` ```
## 认证说明 ## 认证说明
### 用户认证 ### 用户认证
通过 SproutGate OAuth 服务验证 Bearer Token 通过 SproutGate OAuth 服务验证 Bearer Token
```go ```go
result, err := authClient.VerifyToken(token) result, err := authClient.VerifyToken(token)
// result.Valid, result.User.Account, result.User.Username // result.Valid, result.User.Account, result.User.Username
``` ```
### 管理员认证 ### 管理员认证
管理员令牌通过查询参数或 Authorization 头传入: 管理员令牌通过查询参数或 Authorization 头传入:
``` ```
GET /api/admin/products?token=xxx GET /api/admin/products?token=xxx
Authorization: xxx Authorization: xxx
``` ```
令牌与 `settings.json` 中的 `adminToken` 比对。 令牌与 `settings.json` 中的 `adminToken` 比对。

View File

@@ -1,304 +1,304 @@
// migrate imports existing JSON data files into the MySQL database. // migrate imports existing JSON data files into the MySQL database.
// Run once after switching to DB storage: // Run once after switching to DB storage:
// //
// go run ./cmd/migrate/main.go // go run ./cmd/migrate/main.go
package main package main
import ( import (
"encoding/json" "encoding/json"
"log" "log"
"os" "os"
"strconv" "strconv"
"time" "time"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"mengyastore-backend/internal/config" "mengyastore-backend/internal/config"
"mengyastore-backend/internal/database" "mengyastore-backend/internal/database"
) )
func main() { func main() {
cfg, err := config.Load("../../config.json") cfg, err := config.Load("../../config.json")
if err != nil { if err != nil {
log.Fatalf("load config: %v", err) log.Fatalf("load config: %v", err)
} }
db, err := gorm.Open(mysql.Open(cfg.DatabaseDSN), &gorm.Config{ db, err := gorm.Open(mysql.Open(cfg.DatabaseDSN), &gorm.Config{
Logger: logger.Default.LogMode(logger.Info), Logger: logger.Default.LogMode(logger.Info),
}) })
if err != nil { if err != nil {
log.Fatalf("open db: %v", err) log.Fatalf("open db: %v", err)
} }
// Ensure tables exist // Ensure tables exist
if err := db.AutoMigrate( if err := db.AutoMigrate(
&database.ProductRow{}, &database.ProductRow{},
&database.ProductCodeRow{}, &database.ProductCodeRow{},
&database.OrderRow{}, &database.OrderRow{},
&database.SiteSettingRow{}, &database.SiteSettingRow{},
&database.WishlistRow{}, &database.WishlistRow{},
&database.ChatMessageRow{}, &database.ChatMessageRow{},
); err != nil { ); err != nil {
log.Fatalf("auto migrate: %v", err) log.Fatalf("auto migrate: %v", err)
} }
log.Println("数据库连接成功,开始导入...") log.Println("数据库连接成功,开始导入...")
migrateProducts(db) migrateProducts(db)
migrateOrders(db) migrateOrders(db)
migrateWishlists(db) migrateWishlists(db)
migrateChats(db) migrateChats(db)
migrateSite(db) migrateSite(db)
log.Println("✅ 数据导入完成!") log.Println("✅ 数据导入完成!")
} }
// ─── Products ───────────────────────────────────────────────────────────────── // ─── Products ─────────────────────────────────────────────────────────────────
type jsonProduct struct { type jsonProduct struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Price float64 `json:"price"` Price float64 `json:"price"`
DiscountPrice float64 `json:"discountPrice"` DiscountPrice float64 `json:"discountPrice"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
CoverURL string `json:"coverUrl"` CoverURL string `json:"coverUrl"`
ScreenshotURLs []string `json:"screenshotUrls"` ScreenshotURLs []string `json:"screenshotUrls"`
Description string `json:"description"` Description string `json:"description"`
Active bool `json:"active"` Active bool `json:"active"`
RequireLogin bool `json:"requireLogin"` RequireLogin bool `json:"requireLogin"`
MaxPerAccount int `json:"maxPerAccount"` MaxPerAccount int `json:"maxPerAccount"`
TotalSold int `json:"totalSold"` TotalSold int `json:"totalSold"`
ViewCount int `json:"viewCount"` ViewCount int `json:"viewCount"`
DeliveryMode string `json:"deliveryMode"` DeliveryMode string `json:"deliveryMode"`
ShowNote bool `json:"showNote"` ShowNote bool `json:"showNote"`
ShowContact bool `json:"showContact"` ShowContact bool `json:"showContact"`
Codes []string `json:"codes"` Codes []string `json:"codes"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
func migrateProducts(db *gorm.DB) { func migrateProducts(db *gorm.DB) {
data, err := os.ReadFile("data/json/products.json") data, err := os.ReadFile("data/json/products.json")
if err != nil { if err != nil {
log.Printf("[products] 文件不存在,跳过: %v", err) log.Printf("[products] 文件不存在,跳过: %v", err)
return return
} }
var products []jsonProduct var products []jsonProduct
if err := json.Unmarshal(data, &products); err != nil { if err := json.Unmarshal(data, &products); err != nil {
log.Printf("[products] JSON 解析失败: %v", err) log.Printf("[products] JSON 解析失败: %v", err)
return return
} }
for _, p := range products { for _, p := range products {
if p.ID == "" { if p.ID == "" {
continue continue
} }
if p.DeliveryMode == "" { if p.DeliveryMode == "" {
p.DeliveryMode = "auto" p.DeliveryMode = "auto"
} }
row := database.ProductRow{ row := database.ProductRow{
ID: p.ID, ID: p.ID,
Name: p.Name, Name: p.Name,
Price: p.Price, Price: p.Price,
DiscountPrice: p.DiscountPrice, DiscountPrice: p.DiscountPrice,
Tags: database.StringSlice(p.Tags), Tags: database.StringSlice(p.Tags),
CoverURL: p.CoverURL, CoverURL: p.CoverURL,
ScreenshotURLs: database.StringSlice(p.ScreenshotURLs), ScreenshotURLs: database.StringSlice(p.ScreenshotURLs),
Description: p.Description, Description: p.Description,
Active: p.Active, Active: p.Active,
RequireLogin: p.RequireLogin, RequireLogin: p.RequireLogin,
MaxPerAccount: p.MaxPerAccount, MaxPerAccount: p.MaxPerAccount,
TotalSold: p.TotalSold, TotalSold: p.TotalSold,
ViewCount: p.ViewCount, ViewCount: p.ViewCount,
DeliveryMode: p.DeliveryMode, DeliveryMode: p.DeliveryMode,
ShowNote: p.ShowNote, ShowNote: p.ShowNote,
ShowContact: p.ShowContact, ShowContact: p.ShowContact,
CreatedAt: p.CreatedAt, CreatedAt: p.CreatedAt,
} }
if row.CreatedAt.IsZero() { if row.CreatedAt.IsZero() {
row.CreatedAt = time.Now() row.CreatedAt = time.Now()
} }
result := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row) result := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row)
if result.Error != nil { if result.Error != nil {
log.Printf("[products] 导入 %s 失败: %v", p.ID, result.Error) log.Printf("[products] 导入 %s 失败: %v", p.ID, result.Error)
continue continue
} }
// Codes → product_codes // Codes → product_codes
for _, code := range p.Codes { for _, code := range p.Codes {
if code == "" { if code == "" {
continue continue
} }
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.ProductCodeRow{ db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.ProductCodeRow{
ProductID: p.ID, ProductID: p.ID,
Code: code, Code: code,
}) })
} }
} }
log.Printf("[products] 导入 %d 条商品", len(products)) log.Printf("[products] 导入 %d 条商品", len(products))
} }
// ─── Orders ─────────────────────────────────────────────────────────────────── // ─── Orders ───────────────────────────────────────────────────────────────────
type jsonOrder struct { type jsonOrder struct {
ID string `json:"id"` ID string `json:"id"`
ProductID string `json:"productId"` ProductID string `json:"productId"`
ProductName string `json:"productName"` ProductName string `json:"productName"`
UserAccount string `json:"userAccount"` UserAccount string `json:"userAccount"`
UserName string `json:"userName"` UserName string `json:"userName"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
DeliveredCodes []string `json:"deliveredCodes"` DeliveredCodes []string `json:"deliveredCodes"`
Status string `json:"status"` Status string `json:"status"`
DeliveryMode string `json:"deliveryMode"` DeliveryMode string `json:"deliveryMode"`
Note string `json:"note"` Note string `json:"note"`
ContactPhone string `json:"contactPhone"` ContactPhone string `json:"contactPhone"`
ContactEmail string `json:"contactEmail"` ContactEmail string `json:"contactEmail"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
func migrateOrders(db *gorm.DB) { func migrateOrders(db *gorm.DB) {
data, err := os.ReadFile("data/json/orders.json") data, err := os.ReadFile("data/json/orders.json")
if err != nil { if err != nil {
log.Printf("[orders] 文件不存在,跳过: %v", err) log.Printf("[orders] 文件不存在,跳过: %v", err)
return return
} }
var orders []jsonOrder var orders []jsonOrder
if err := json.Unmarshal(data, &orders); err != nil { if err := json.Unmarshal(data, &orders); err != nil {
log.Printf("[orders] JSON 解析失败: %v", err) log.Printf("[orders] JSON 解析失败: %v", err)
return return
} }
for _, o := range orders { for _, o := range orders {
if o.ID == "" { if o.ID == "" {
continue continue
} }
if o.DeliveryMode == "" { if o.DeliveryMode == "" {
o.DeliveryMode = "auto" o.DeliveryMode = "auto"
} }
if o.DeliveredCodes == nil { if o.DeliveredCodes == nil {
o.DeliveredCodes = []string{} o.DeliveredCodes = []string{}
} }
row := database.OrderRow{ row := database.OrderRow{
ID: o.ID, ID: o.ID,
ProductID: o.ProductID, ProductID: o.ProductID,
ProductName: o.ProductName, ProductName: o.ProductName,
UserAccount: o.UserAccount, UserAccount: o.UserAccount,
UserName: o.UserName, UserName: o.UserName,
Quantity: o.Quantity, Quantity: o.Quantity,
DeliveredCodes: database.StringSlice(o.DeliveredCodes), DeliveredCodes: database.StringSlice(o.DeliveredCodes),
Status: o.Status, Status: o.Status,
DeliveryMode: o.DeliveryMode, DeliveryMode: o.DeliveryMode,
Note: o.Note, Note: o.Note,
ContactPhone: o.ContactPhone, ContactPhone: o.ContactPhone,
ContactEmail: o.ContactEmail, ContactEmail: o.ContactEmail,
CreatedAt: o.CreatedAt, CreatedAt: o.CreatedAt,
} }
if row.CreatedAt.IsZero() { if row.CreatedAt.IsZero() {
row.CreatedAt = time.Now() row.CreatedAt = time.Now()
} }
if result := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row); result.Error != nil { if result := db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row); result.Error != nil {
log.Printf("[orders] 导入 %s 失败: %v", o.ID, result.Error) log.Printf("[orders] 导入 %s 失败: %v", o.ID, result.Error)
} }
} }
log.Printf("[orders] 导入 %d 条订单", len(orders)) log.Printf("[orders] 导入 %d 条订单", len(orders))
} }
// ─── Wishlists ──────────────────────────────────────────────────────────────── // ─── Wishlists ────────────────────────────────────────────────────────────────
func migrateWishlists(db *gorm.DB) { func migrateWishlists(db *gorm.DB) {
data, err := os.ReadFile("data/json/wishlists.json") data, err := os.ReadFile("data/json/wishlists.json")
if err != nil { if err != nil {
log.Printf("[wishlists] 文件不存在,跳过: %v", err) log.Printf("[wishlists] 文件不存在,跳过: %v", err)
return return
} }
var wl map[string][]string var wl map[string][]string
if err := json.Unmarshal(data, &wl); err != nil { if err := json.Unmarshal(data, &wl); err != nil {
log.Printf("[wishlists] JSON 解析失败: %v", err) log.Printf("[wishlists] JSON 解析失败: %v", err)
return return
} }
count := 0 count := 0
for account, productIDs := range wl { for account, productIDs := range wl {
for _, pid := range productIDs { for _, pid := range productIDs {
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.WishlistRow{ db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.WishlistRow{
AccountID: account, AccountID: account,
ProductID: pid, ProductID: pid,
}) })
count++ count++
} }
} }
log.Printf("[wishlists] 导入 %d 条收藏记录", count) log.Printf("[wishlists] 导入 %d 条收藏记录", count)
} }
// ─── Chats ──────────────────────────────────────────────────────────────────── // ─── Chats ────────────────────────────────────────────────────────────────────
type jsonChatMsg struct { type jsonChatMsg struct {
ID string `json:"id"` ID string `json:"id"`
AccountID string `json:"accountId"` AccountID string `json:"accountId"`
AccountName string `json:"accountName"` AccountName string `json:"accountName"`
Content string `json:"content"` Content string `json:"content"`
SentAt time.Time `json:"sentAt"` SentAt time.Time `json:"sentAt"`
FromAdmin bool `json:"fromAdmin"` FromAdmin bool `json:"fromAdmin"`
} }
func migrateChats(db *gorm.DB) { func migrateChats(db *gorm.DB) {
data, err := os.ReadFile("data/json/chats.json") data, err := os.ReadFile("data/json/chats.json")
if err != nil { if err != nil {
log.Printf("[chats] 文件不存在,跳过: %v", err) log.Printf("[chats] 文件不存在,跳过: %v", err)
return return
} }
var convs map[string][]jsonChatMsg var convs map[string][]jsonChatMsg
if err := json.Unmarshal(data, &convs); err != nil { if err := json.Unmarshal(data, &convs); err != nil {
log.Printf("[chats] JSON 解析失败: %v", err) log.Printf("[chats] JSON 解析失败: %v", err)
return return
} }
count := 0 count := 0
for _, msgs := range convs { for _, msgs := range convs {
for _, m := range msgs { for _, m := range msgs {
if m.ID == "" { if m.ID == "" {
continue continue
} }
db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.ChatMessageRow{ db.Clauses(clause.OnConflict{DoNothing: true}).Create(&database.ChatMessageRow{
ID: m.ID, ID: m.ID,
AccountID: m.AccountID, AccountID: m.AccountID,
AccountName: m.AccountName, AccountName: m.AccountName,
Content: m.Content, Content: m.Content,
SentAt: m.SentAt, SentAt: m.SentAt,
FromAdmin: m.FromAdmin, FromAdmin: m.FromAdmin,
}) })
count++ count++
} }
} }
log.Printf("[chats] 导入 %d 条聊天消息", count) log.Printf("[chats] 导入 %d 条聊天消息", count)
} }
// ─── Site settings ──────────────────────────────────────────────────────────── // ─── Site settings ────────────────────────────────────────────────────────────
type jsonSite struct { type jsonSite struct {
TotalVisits int `json:"totalVisits"` TotalVisits int `json:"totalVisits"`
Maintenance bool `json:"maintenance"` Maintenance bool `json:"maintenance"`
MaintenanceReason string `json:"maintenanceReason"` MaintenanceReason string `json:"maintenanceReason"`
} }
func migrateSite(db *gorm.DB) { func migrateSite(db *gorm.DB) {
data, err := os.ReadFile("data/json/site.json") data, err := os.ReadFile("data/json/site.json")
if err != nil { if err != nil {
log.Printf("[site] 文件不存在,跳过: %v", err) log.Printf("[site] 文件不存在,跳过: %v", err)
return return
} }
var site jsonSite var site jsonSite
if err := json.Unmarshal(data, &site); err != nil { if err := json.Unmarshal(data, &site); err != nil {
log.Printf("[site] JSON 解析失败: %v", err) log.Printf("[site] JSON 解析失败: %v", err)
return return
} }
upsert := func(key, value string) { upsert := func(key, value string) {
db.Clauses(clause.OnConflict{ db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}}, Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}), DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&database.SiteSettingRow{Key: key, Value: value}) }).Create(&database.SiteSettingRow{Key: key, Value: value})
} }
upsert("totalVisits", strconv.Itoa(site.TotalVisits)) upsert("totalVisits", strconv.Itoa(site.TotalVisits))
maintenance := "false" maintenance := "false"
if site.Maintenance { if site.Maintenance {
maintenance = "true" maintenance = "true"
} }
upsert("maintenance", maintenance) upsert("maintenance", maintenance)
upsert("maintenanceReason", site.MaintenanceReason) upsert("maintenanceReason", site.MaintenanceReason)
log.Printf("[site] 站点设置导入完成(访问量: %d", site.TotalVisits) log.Printf("[site] 站点设置导入完成(访问量: %d", site.TotalVisits)
} }

View File

@@ -1,17 +1,17 @@
services: services:
backend: backend:
build: build:
context: . context: .
container_name: mengyastore-backend container_name: mengyastore-backend
ports: ports:
- "28081:8080" - "28081:8080"
environment: environment:
GIN_MODE: release GIN_MODE: release
TZ: Asia/Shanghai TZ: Asia/Shanghai
# 生产环境 MySQL DSN使用内网地址。 # 生产环境 MySQL DSN使用内网地址。
# 本地开发请修改为测试库地址,或通过 .env 文件覆盖。 # 本地开发请修改为测试库地址,或通过 .env 文件覆盖。
#mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local #mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local
DATABASE_DSN: "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local" DATABASE_DSN: "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
volumes: volumes:
- ./config.json:/app/config.json:ro - ./config.json:/app/config.json:ro
restart: unless-stopped restart: unless-stopped

View File

@@ -1,45 +1,45 @@
module mengyastore-backend module mengyastore-backend
go 1.21.0 go 1.21.0
require ( require (
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.1 // indirect github.com/pelletier/go-toml/v2 v2.2.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.7.0 // indirect golang.org/x/arch v0.7.0 // indirect
golang.org/x/crypto v0.22.0 // indirect golang.org/x/crypto v0.22.0 // indirect
golang.org/x/net v0.24.0 // indirect golang.org/x/net v0.24.0 // indirect
golang.org/x/sys v0.19.0 // indirect golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.20.0 // indirect golang.org/x/text v0.20.0 // indirect
google.golang.org/protobuf v1.34.0 // indirect google.golang.org/protobuf v1.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/mysql v1.6.0 // indirect gorm.io/driver/mysql v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect gorm.io/gorm v1.31.1 // indirect
) )

View File

@@ -1,115 +1,115 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw= github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E= github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg= github.com/pelletier/go-toml/v2 v2.2.1 h1:9TA9+T8+8CUCO2+WYnDLCgrYi9+omqKXyjDtosvtEhg=
github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.1/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -1,66 +1,66 @@
package auth package auth
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log"
"net/http" "net/http"
"time" "time"
) )
const defaultAuthAPIURL = "https://auth.api.shumengya.top" const defaultAuthAPIURL = "https://auth.api.shumengya.top"
type SproutGateClient struct { type SproutGateClient struct {
apiURL string apiURL string
httpClient *http.Client httpClient *http.Client
} }
type VerifyResult struct { type VerifyResult struct {
Valid bool `json:"valid"` Valid bool `json:"valid"`
User *SproutGateUser `json:"user"` User *SproutGateUser `json:"user"`
} }
type SproutGateUser struct { type SproutGateUser struct {
Account string `json:"account"` Account string `json:"account"`
Username string `json:"username"` Username string `json:"username"`
AvatarURL string `json:"avatarUrl"` AvatarURL string `json:"avatarUrl"`
Level int `json:"level"` Level int `json:"level"`
Email string `json:"email"` Email string `json:"email"`
} }
func NewSproutGateClient(apiURL string) *SproutGateClient { func NewSproutGateClient(apiURL string) *SproutGateClient {
if apiURL == "" { if apiURL == "" {
apiURL = defaultAuthAPIURL apiURL = defaultAuthAPIURL
} }
return &SproutGateClient{ return &SproutGateClient{
apiURL: apiURL, apiURL: apiURL,
httpClient: &http.Client{Timeout: 10 * time.Second}, httpClient: &http.Client{Timeout: 10 * time.Second},
} }
} }
func (c *SproutGateClient) VerifyToken(token string) (*VerifyResult, error) { func (c *SproutGateClient) VerifyToken(token string) (*VerifyResult, error) {
body, _ := json.Marshal(map[string]string{"token": token}) body, _ := json.Marshal(map[string]string{"token": token})
resp, err := c.httpClient.Post(c.apiURL+"/api/auth/verify", "application/json", bytes.NewReader(body)) resp, err := c.httpClient.Post(c.apiURL+"/api/auth/verify", "application/json", bytes.NewReader(body))
if err != nil { if err != nil {
log.Printf("[SproutGate] 验证请求失败: %v", err) log.Printf("[SproutGate] 验证请求失败: %v", err)
return nil, fmt.Errorf("验证请求失败: %w", err) return nil, fmt.Errorf("验证请求失败: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
rawBody, err := io.ReadAll(resp.Body) rawBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
log.Printf("[SproutGate] 读取响应体失败: %v", err) log.Printf("[SproutGate] 读取响应体失败: %v", err)
return nil, fmt.Errorf("读取验证响应失败: %w", err) return nil, fmt.Errorf("读取验证响应失败: %w", err)
} }
log.Printf("[SproutGate] verify response status=%d body=%s", resp.StatusCode, string(rawBody)) log.Printf("[SproutGate] verify response status=%d body=%s", resp.StatusCode, string(rawBody))
var result VerifyResult var result VerifyResult
if err := json.Unmarshal(rawBody, &result); err != nil { if err := json.Unmarshal(rawBody, &result); err != nil {
log.Printf("[SproutGate] 解析响应失败: %v", err) log.Printf("[SproutGate] 解析响应失败: %v", err)
return nil, fmt.Errorf("解析验证响应失败: %w", err) return nil, fmt.Errorf("解析验证响应失败: %w", err)
} }
return &result, nil return &result, nil
} }

View File

@@ -1,45 +1,45 @@
package config package config
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
) )
type Config struct { type Config struct {
AdminToken string `json:"adminToken"` AdminToken string `json:"adminToken"`
AuthAPIURL string `json:"authApiUrl"` AuthAPIURL string `json:"authApiUrl"`
// 数据库 DSN为空时回退到测试数据库。 // 数据库 DSN为空时回退到测试数据库。
// 格式user:pass@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local // 格式user:pass@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local
DatabaseDSN string `json:"databaseDsn"` DatabaseDSN string `json:"databaseDsn"`
} }
// 各环境默认 DSN。 // 各环境默认 DSN。
const ( const (
TestDSN = "mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local" TestDSN = "mengyastore-test:mengyastore-test@tcp(10.1.1.100:3306)/mengyastore-test?charset=utf8mb4&parseTime=True&loc=Local"
ProdDSN = "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local" ProdDSN = "mengyastore:mengyastore@tcp(192.168.1.100:3306)/mengyastore?charset=utf8mb4&parseTime=True&loc=Local"
) )
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
var cfg Config var cfg Config
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err == nil { if err == nil {
if jsonErr := json.Unmarshal(data, &cfg); jsonErr != nil { if jsonErr := json.Unmarshal(data, &cfg); jsonErr != nil {
return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, jsonErr) return nil, fmt.Errorf("解析配置文件 %s 失败: %w", path, jsonErr)
} }
} }
// 文件不存在时使用默认值,环境变量在下方仍优先生效。 // 文件不存在时使用默认值,环境变量在下方仍优先生效。
if cfg.AdminToken == "" { if cfg.AdminToken == "" {
cfg.AdminToken = "changeme" cfg.AdminToken = "changeme"
} }
// DATABASE_DSN 环境变量优先于配置文件。 // DATABASE_DSN 环境变量优先于配置文件。
if dsn := os.Getenv("DATABASE_DSN"); dsn != "" { if dsn := os.Getenv("DATABASE_DSN"); dsn != "" {
cfg.DatabaseDSN = dsn cfg.DatabaseDSN = dsn
} }
if cfg.DatabaseDSN == "" { if cfg.DatabaseDSN == "" {
cfg.DatabaseDSN = TestDSN cfg.DatabaseDSN = TestDSN
} }
return &cfg, nil return &cfg, nil
} }

View File

@@ -1,45 +1,45 @@
package database package database
import ( import (
"log" "log"
"time" "time"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
) )
// Open 初始化 GORM 数据库连接并自动同步所有表结构。 // Open 初始化 GORM 数据库连接并自动同步所有表结构。
func Open(dsn string) (*gorm.DB, error) { func Open(dsn string) (*gorm.DB, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn), Logger: logger.Default.LogMode(logger.Warn),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
sqlDB, err := db.DB() sqlDB, err := db.DB()
if err != nil { if err != nil {
return nil, err return nil, err
} }
sqlDB.SetMaxIdleConns(5) sqlDB.SetMaxIdleConns(5)
sqlDB.SetMaxOpenConns(20) sqlDB.SetMaxOpenConns(20)
sqlDB.SetConnMaxLifetime(time.Hour) sqlDB.SetConnMaxLifetime(time.Hour)
if err := autoMigrate(db); err != nil { if err := autoMigrate(db); err != nil {
return nil, err return nil, err
} }
log.Println("[DB] 数据库连接成功,表结构已同步") log.Println("[DB] 数据库连接成功,表结构已同步")
return db, nil return db, nil
} }
func autoMigrate(db *gorm.DB) error { func autoMigrate(db *gorm.DB) error {
return db.AutoMigrate( return db.AutoMigrate(
&ProductRow{}, &ProductRow{},
&ProductCodeRow{}, &ProductCodeRow{},
&OrderRow{}, &OrderRow{},
&SiteSettingRow{}, &SiteSettingRow{},
&WishlistRow{}, &WishlistRow{},
&ChatMessageRow{}, &ChatMessageRow{},
) )
} }

View File

@@ -1,121 +1,121 @@
package database package database
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"fmt" "fmt"
"time" "time"
) )
// StringSlice is a JSON-serialized string slice stored as a MySQL TEXT/JSON column. // StringSlice is a JSON-serialized string slice stored as a MySQL TEXT/JSON column.
type StringSlice []string type StringSlice []string
func (s StringSlice) Value() (driver.Value, error) { func (s StringSlice) Value() (driver.Value, error) {
if s == nil { if s == nil {
return "[]", nil return "[]", nil
} }
b, err := json.Marshal(s) b, err := json.Marshal(s)
return string(b), err return string(b), err
} }
func (s *StringSlice) Scan(src any) error { func (s *StringSlice) Scan(src any) error {
var raw []byte var raw []byte
switch v := src.(type) { switch v := src.(type) {
case string: case string:
raw = []byte(v) raw = []byte(v)
case []byte: case []byte:
raw = v raw = v
default: default:
return fmt.Errorf("StringSlice: unsupported type %T", src) return fmt.Errorf("StringSlice: unsupported type %T", src)
} }
return json.Unmarshal(raw, s) return json.Unmarshal(raw, s)
} }
// ─── Products ──────────────────────────────────────────────────────────────── // ─── Products ────────────────────────────────────────────────────────────────
// ProductRow is the GORM model for the `products` table. // ProductRow is the GORM model for the `products` table.
type ProductRow struct { type ProductRow struct {
ID string `gorm:"primaryKey;size:36"` ID string `gorm:"primaryKey;size:36"`
Name string `gorm:"size:255;not null"` Name string `gorm:"size:255;not null"`
Price float64 `gorm:"not null;default:0"` Price float64 `gorm:"not null;default:0"`
DiscountPrice float64 `gorm:"default:0"` DiscountPrice float64 `gorm:"default:0"`
Tags StringSlice `gorm:"type:json"` Tags StringSlice `gorm:"type:json"`
CoverURL string `gorm:"size:500"` CoverURL string `gorm:"size:500"`
ScreenshotURLs StringSlice `gorm:"type:json"` ScreenshotURLs StringSlice `gorm:"type:json"`
VerificationURL string `gorm:"size:500;default:''"` VerificationURL string `gorm:"size:500;default:''"`
Description string `gorm:"type:text"` Description string `gorm:"type:text"`
Active bool `gorm:"default:true;index"` Active bool `gorm:"default:true;index"`
RequireLogin bool `gorm:"default:false"` RequireLogin bool `gorm:"default:false"`
MaxPerAccount int `gorm:"default:0"` MaxPerAccount int `gorm:"default:0"`
TotalSold int `gorm:"default:0"` TotalSold int `gorm:"default:0"`
ViewCount int `gorm:"default:0"` ViewCount int `gorm:"default:0"`
DeliveryMode string `gorm:"size:20;default:'auto'"` DeliveryMode string `gorm:"size:20;default:'auto'"`
ShowNote bool `gorm:"default:false"` ShowNote bool `gorm:"default:false"`
ShowContact bool `gorm:"default:false"` ShowContact bool `gorm:"default:false"`
CreatedAt time.Time `gorm:"index"` CreatedAt time.Time `gorm:"index"`
} }
func (ProductRow) TableName() string { return "products" } func (ProductRow) TableName() string { return "products" }
// ProductCodeRow stores individual codes for a product (one row per code). // ProductCodeRow stores individual codes for a product (one row per code).
type ProductCodeRow struct { type ProductCodeRow struct {
ID uint `gorm:"primaryKey;autoIncrement"` ID uint `gorm:"primaryKey;autoIncrement"`
ProductID string `gorm:"size:36;not null;index"` ProductID string `gorm:"size:36;not null;index"`
Code string `gorm:"type:text;not null"` Code string `gorm:"type:text;not null"`
} }
func (ProductCodeRow) TableName() string { return "product_codes" } func (ProductCodeRow) TableName() string { return "product_codes" }
// ─── Orders ────────────────────────────────────────────────────────────────── // ─── Orders ──────────────────────────────────────────────────────────────────
type OrderRow struct { type OrderRow struct {
ID string `gorm:"primaryKey;size:36"` ID string `gorm:"primaryKey;size:36"`
ProductID string `gorm:"size:36;not null;index"` ProductID string `gorm:"size:36;not null;index"`
ProductName string `gorm:"size:255;not null"` ProductName string `gorm:"size:255;not null"`
UserAccount string `gorm:"size:255;index"` UserAccount string `gorm:"size:255;index"`
UserName string `gorm:"size:255"` UserName string `gorm:"size:255"`
Quantity int `gorm:"not null;default:1"` Quantity int `gorm:"not null;default:1"`
DeliveredCodes StringSlice `gorm:"type:json"` DeliveredCodes StringSlice `gorm:"type:json"`
Status string `gorm:"size:20;not null;default:'pending';index"` Status string `gorm:"size:20;not null;default:'pending';index"`
DeliveryMode string `gorm:"size:20;default:'auto'"` DeliveryMode string `gorm:"size:20;default:'auto'"`
Note string `gorm:"type:text"` Note string `gorm:"type:text"`
ContactPhone string `gorm:"size:50"` ContactPhone string `gorm:"size:50"`
ContactEmail string `gorm:"size:255"` ContactEmail string `gorm:"size:255"`
NotifyEmail string `gorm:"size:255"` NotifyEmail string `gorm:"size:255"`
CreatedAt time.Time CreatedAt time.Time
} }
func (OrderRow) TableName() string { return "orders" } func (OrderRow) TableName() string { return "orders" }
// ─── Site settings ─────────────────────────────────────────────────────────── // ─── Site settings ───────────────────────────────────────────────────────────
// SiteSettingRow stores arbitrary key-value pairs for site-wide settings. // SiteSettingRow stores arbitrary key-value pairs for site-wide settings.
type SiteSettingRow struct { type SiteSettingRow struct {
Key string `gorm:"primaryKey;size:64"` Key string `gorm:"primaryKey;size:64"`
Value string `gorm:"type:text"` Value string `gorm:"type:text"`
} }
func (SiteSettingRow) TableName() string { return "site_settings" } func (SiteSettingRow) TableName() string { return "site_settings" }
// ─── Wishlists ─────────────────────────────────────────────────────────────── // ─── Wishlists ───────────────────────────────────────────────────────────────
type WishlistRow struct { type WishlistRow struct {
ID uint `gorm:"primaryKey;autoIncrement"` ID uint `gorm:"primaryKey;autoIncrement"`
AccountID string `gorm:"size:255;not null;index:idx_wishlist,unique"` AccountID string `gorm:"size:255;not null;index:idx_wishlist,unique"`
ProductID string `gorm:"size:36;not null;index:idx_wishlist,unique"` ProductID string `gorm:"size:36;not null;index:idx_wishlist,unique"`
} }
func (WishlistRow) TableName() string { return "wishlists" } func (WishlistRow) TableName() string { return "wishlists" }
// ─── Chat messages ─────────────────────────────────────────────────────────── // ─── Chat messages ───────────────────────────────────────────────────────────
type ChatMessageRow struct { type ChatMessageRow struct {
ID string `gorm:"primaryKey;size:36"` ID string `gorm:"primaryKey;size:36"`
AccountID string `gorm:"size:255;not null;index"` AccountID string `gorm:"size:255;not null;index"`
AccountName string `gorm:"size:255"` AccountName string `gorm:"size:255"`
Content string `gorm:"type:text;not null"` Content string `gorm:"type:text;not null"`
SentAt time.Time `gorm:"not null"` SentAt time.Time `gorm:"not null"`
FromAdmin bool `gorm:"default:false"` FromAdmin bool `gorm:"default:false"`
} }
func (ChatMessageRow) TableName() string { return "chat_messages" } func (ChatMessageRow) TableName() string { return "chat_messages" }

View File

@@ -1,193 +1,193 @@
package email package email
import ( import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"net/smtp" "net/smtp"
"strings" "strings"
"time" "time"
) )
// Config 存储 SMTP 发件配置。 // Config 存储 SMTP 发件配置。
type Config struct { type Config struct {
SMTPHost string // 例smtp.qq.com SMTPHost string // 例smtp.qq.com
SMTPPort string // 例465SSL或 587STARTTLS SMTPPort string // 例465SSL或 587STARTTLS
From string // 发件人邮箱地址 From string // 发件人邮箱地址
Password string // SMTP 密码或授权码 Password string // SMTP 密码或授权码
FromName string // 显示名称,例:"萌芽小店" FromName string // 显示名称,例:"萌芽小店"
} }
// IsConfigured 判断配置是否充足,可以尝试发送邮件。 // IsConfigured 判断配置是否充足,可以尝试发送邮件。
func (c *Config) IsConfigured() bool { func (c *Config) IsConfigured() bool {
return c.From != "" && c.Password != "" && c.SMTPHost != "" return c.From != "" && c.Password != "" && c.SMTPHost != ""
} }
// OrderNotifyData 包含发送订单通知邮件所需的数据。 // OrderNotifyData 包含发送订单通知邮件所需的数据。
type OrderNotifyData struct { type OrderNotifyData struct {
ToEmail string ToEmail string
ToName string ToName string
ProductName string ProductName string
OrderID string OrderID string
Quantity int Quantity int
Codes []string // 手动发货时为空 Codes []string // 手动发货时为空
IsManual bool IsManual bool
} }
// SendOrderNotify 发送订单发货通知邮件。 // SendOrderNotify 发送订单发货通知邮件。
// 若配置不完整或收件人为空,则静默跳过,返回 nil。 // 若配置不完整或收件人为空,则静默跳过,返回 nil。
func SendOrderNotify(cfg Config, data OrderNotifyData) error { func SendOrderNotify(cfg Config, data OrderNotifyData) error {
if !cfg.IsConfigured() || data.ToEmail == "" { if !cfg.IsConfigured() || data.ToEmail == "" {
return nil return nil
} }
if cfg.SMTPPort == "" { if cfg.SMTPPort == "" {
cfg.SMTPPort = "465" cfg.SMTPPort = "465"
} }
if cfg.SMTPHost == "" { if cfg.SMTPHost == "" {
cfg.SMTPHost = "smtp.qq.com" cfg.SMTPHost = "smtp.qq.com"
} }
fromName := cfg.FromName fromName := cfg.FromName
if fromName == "" { if fromName == "" {
fromName = "萌芽小店" fromName = "萌芽小店"
} }
subject := "【萌芽小店】您的订单已发货" subject := "【萌芽小店】您的订单已发货"
if data.IsManual { if data.IsManual {
subject = "【萌芽小店】您的订单正在处理中" subject = "【萌芽小店】您的订单正在处理中"
} }
body := buildBody(data) body := buildBody(data)
msg := buildMIMEMessage(cfg.From, fromName, data.ToEmail, subject, body) msg := buildMIMEMessage(cfg.From, fromName, data.ToEmail, subject, body)
addr := fmt.Sprintf("%s:%s", cfg.SMTPHost, cfg.SMTPPort) addr := fmt.Sprintf("%s:%s", cfg.SMTPHost, cfg.SMTPPort)
auth := smtp.PlainAuth("", cfg.From, cfg.Password, cfg.SMTPHost) auth := smtp.PlainAuth("", cfg.From, cfg.Password, cfg.SMTPHost)
// QQ 邮箱使用 SSL端口 465需直接 TLS 拨号。 // QQ 邮箱使用 SSL端口 465需直接 TLS 拨号。
if cfg.SMTPPort == "465" { if cfg.SMTPPort == "465" {
return sendSSL(addr, cfg.SMTPHost, auth, cfg.From, data.ToEmail, msg) return sendSSL(addr, cfg.SMTPHost, auth, cfg.From, data.ToEmail, msg)
} }
return smtp.SendMail(addr, auth, cfg.From, []string{data.ToEmail}, []byte(msg)) return smtp.SendMail(addr, auth, cfg.From, []string{data.ToEmail}, []byte(msg))
} }
func buildBody(data OrderNotifyData) string { func buildBody(data OrderNotifyData) string {
var sb strings.Builder var sb strings.Builder
now := time.Now().Format("2006 年 01 月 02 日 15:04:05") now := time.Now().Format("2006 年 01 月 02 日 15:04:05")
recipient := data.ToName recipient := data.ToName
if recipient == "" { if recipient == "" {
recipient = "用户" recipient = "用户"
} }
sb.WriteString("尊敬的 ") sb.WriteString("尊敬的 ")
sb.WriteString(recipient) sb.WriteString(recipient)
sb.WriteString("\n\n") sb.WriteString("\n\n")
sb.WriteString(" 您好!感谢您在萌芽小店的支持与购买。\n\n") sb.WriteString(" 您好!感谢您在萌芽小店的支持与购买。\n\n")
sb.WriteString("────────────────────────────────\n") sb.WriteString("────────────────────────────────\n")
sb.WriteString(" 订单信息\n") sb.WriteString(" 订单信息\n")
sb.WriteString("────────────────────────────────\n") sb.WriteString("────────────────────────────────\n")
sb.WriteString(fmt.Sprintf(" 商品名称:%s\n", data.ProductName)) sb.WriteString(fmt.Sprintf(" 商品名称:%s\n", data.ProductName))
sb.WriteString(fmt.Sprintf(" 订单编号:%s\n", data.OrderID)) sb.WriteString(fmt.Sprintf(" 订单编号:%s\n", data.OrderID))
sb.WriteString(fmt.Sprintf(" 购买数量:%d 件\n", data.Quantity)) sb.WriteString(fmt.Sprintf(" 购买数量:%d 件\n", data.Quantity))
sb.WriteString(fmt.Sprintf(" 通知时间:%s\n", now)) sb.WriteString(fmt.Sprintf(" 通知时间:%s\n", now))
sb.WriteString("────────────────────────────────\n\n") sb.WriteString("────────────────────────────────\n\n")
if data.IsManual { if data.IsManual {
sb.WriteString(" 您的订单已成功提交,目前正在等待人工审核与处理。\n") sb.WriteString(" 您的订单已成功提交,目前正在等待人工审核与处理。\n")
sb.WriteString(" 工作人员将尽快为您安排发货,请耐心等候。\n") sb.WriteString(" 工作人员将尽快为您安排发货,请耐心等候。\n")
sb.WriteString(" 发货完成后,我们将另行发送邮件通知。\n\n") sb.WriteString(" 发货完成后,我们将另行发送邮件通知。\n\n")
} else { } else {
sb.WriteString(" 您的订单已完成自动发货,发货内容如下:\n\n") sb.WriteString(" 您的订单已完成自动发货,发货内容如下:\n\n")
if len(data.Codes) > 0 { if len(data.Codes) > 0 {
for i, code := range data.Codes { for i, code := range data.Codes {
sb.WriteString(fmt.Sprintf(" [%d] %s\n", i+1, code)) sb.WriteString(fmt.Sprintf(" [%d] %s\n", i+1, code))
} }
sb.WriteString("\n") sb.WriteString("\n")
} }
sb.WriteString(" 请妥善保管以上发货内容,切勿泄露给他人。\n\n") sb.WriteString(" 请妥善保管以上发货内容,切勿泄露给他人。\n\n")
} }
sb.WriteString(" 如有任何疑问,请联系在线客服,我们将竭诚为您服务。\n\n") sb.WriteString(" 如有任何疑问,请联系在线客服,我们将竭诚为您服务。\n\n")
sb.WriteString("────────────────────────────────\n") sb.WriteString("────────────────────────────────\n")
sb.WriteString(" 此邮件由系统自动发送,请勿直接回复。\n") sb.WriteString(" 此邮件由系统自动发送,请勿直接回复。\n")
sb.WriteString("────────────────────────────────\n") sb.WriteString("────────────────────────────────\n")
return sb.String() return sb.String()
} }
// buildMIMEMessage 构建符合 MIME 规范的邮件报文,支持 UTF-8 中文。 // buildMIMEMessage 构建符合 MIME 规范的邮件报文,支持 UTF-8 中文。
func buildMIMEMessage(from, fromName, to, subject, body string) string { func buildMIMEMessage(from, fromName, to, subject, body string) string {
encodedFromName := fmt.Sprintf("=?UTF-8?B?%s?=", encodeBase64(fromName)) encodedFromName := fmt.Sprintf("=?UTF-8?B?%s?=", encodeBase64(fromName))
encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", encodeBase64(subject)) encodedSubject := fmt.Sprintf("=?UTF-8?B?%s?=", encodeBase64(subject))
return fmt.Sprintf( return fmt.Sprintf(
"From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n%s", "From: %s <%s>\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n%s",
encodedFromName, from, to, encodedSubject, encodeBase64(body), encodedFromName, from, to, encodedSubject, encodeBase64(body),
) )
} }
func encodeBase64(s string) string { func encodeBase64(s string) string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
b := []byte(s) b := []byte(s)
var buf strings.Builder var buf strings.Builder
for i := 0; i < len(b); i += 3 { for i := 0; i < len(b); i += 3 {
remaining := len(b) - i remaining := len(b) - i
b0 := b[i] b0 := b[i]
b1 := byte(0) b1 := byte(0)
b2 := byte(0) b2 := byte(0)
if remaining > 1 { if remaining > 1 {
b1 = b[i+1] b1 = b[i+1]
} }
if remaining > 2 { if remaining > 2 {
b2 = b[i+2] b2 = b[i+2]
} }
buf.WriteByte(chars[b0>>2]) buf.WriteByte(chars[b0>>2])
buf.WriteByte(chars[((b0&0x03)<<4)|(b1>>4)]) buf.WriteByte(chars[((b0&0x03)<<4)|(b1>>4)])
if remaining > 1 { if remaining > 1 {
buf.WriteByte(chars[((b1&0x0f)<<2)|(b2>>6)]) buf.WriteByte(chars[((b1&0x0f)<<2)|(b2>>6)])
} else { } else {
buf.WriteByte('=') buf.WriteByte('=')
} }
if remaining > 2 { if remaining > 2 {
buf.WriteByte(chars[b2&0x3f]) buf.WriteByte(chars[b2&0x3f])
} else { } else {
buf.WriteByte('=') buf.WriteByte('=')
} }
} }
return buf.String() return buf.String()
} }
// sendSSL 通过 TLS 直接拨号发送邮件(适用于 465 端口 SSL 连接,如 QQ 邮箱)。 // sendSSL 通过 TLS 直接拨号发送邮件(适用于 465 端口 SSL 连接,如 QQ 邮箱)。
func sendSSL(addr, host string, auth smtp.Auth, from, to string, msg string) error { func sendSSL(addr, host string, auth smtp.Auth, from, to string, msg string) error {
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
ServerName: host, ServerName: host,
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
} }
conn, err := tls.Dial("tcp", addr, tlsConfig) conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil { if err != nil {
return fmt.Errorf("tls 拨号失败: %w", err) return fmt.Errorf("tls 拨号失败: %w", err)
} }
defer conn.Close() defer conn.Close()
client, err := smtp.NewClient(conn, host) client, err := smtp.NewClient(conn, host)
if err != nil { if err != nil {
return fmt.Errorf("创建 SMTP 客户端失败: %w", err) return fmt.Errorf("创建 SMTP 客户端失败: %w", err)
} }
defer client.Quit() //nolint:errcheck defer client.Quit() //nolint:errcheck
if err = client.Auth(auth); err != nil { if err = client.Auth(auth); err != nil {
return fmt.Errorf("SMTP 认证失败: %w", err) return fmt.Errorf("SMTP 认证失败: %w", err)
} }
if err = client.Mail(from); err != nil { if err = client.Mail(from); err != nil {
return fmt.Errorf("SMTP MAIL FROM 失败: %w", err) return fmt.Errorf("SMTP MAIL FROM 失败: %w", err)
} }
if err = client.Rcpt(to); err != nil { if err = client.Rcpt(to); err != nil {
return fmt.Errorf("SMTP RCPT TO 失败: %w", err) return fmt.Errorf("SMTP RCPT TO 失败: %w", err)
} }
w, err := client.Data() w, err := client.Data()
if err != nil { if err != nil {
return fmt.Errorf("SMTP DATA 失败: %w", err) return fmt.Errorf("SMTP DATA 失败: %w", err)
} }
if _, err = fmt.Fprint(w, msg); err != nil { if _, err = fmt.Fprint(w, msg); err != nil {
return fmt.Errorf("写入邮件内容失败: %w", err) return fmt.Errorf("写入邮件内容失败: %w", err)
} }
return w.Close() return w.Close()
} }

View File

@@ -1,41 +1,41 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"mengyastore-backend/internal/config" "mengyastore-backend/internal/config"
"mengyastore-backend/internal/storage" "mengyastore-backend/internal/storage"
) )
// AdminHandler 持有所有管理员路由所需的依赖。 // AdminHandler 持有所有管理员路由所需的依赖。
type AdminHandler struct { type AdminHandler struct {
store *storage.ProductStore store *storage.ProductStore
cfg *config.Config cfg *config.Config
siteStore *storage.SiteStore siteStore *storage.SiteStore
orderStore *storage.OrderStore orderStore *storage.OrderStore
chatStore *storage.ChatStore chatStore *storage.ChatStore
} }
func NewAdminHandler(store *storage.ProductStore, cfg *config.Config, siteStore *storage.SiteStore, orderStore *storage.OrderStore, chatStore *storage.ChatStore) *AdminHandler { func NewAdminHandler(store *storage.ProductStore, cfg *config.Config, siteStore *storage.SiteStore, orderStore *storage.OrderStore, chatStore *storage.ChatStore) *AdminHandler {
return &AdminHandler{store: store, cfg: cfg, siteStore: siteStore, orderStore: orderStore, chatStore: chatStore} return &AdminHandler{store: store, cfg: cfg, siteStore: siteStore, orderStore: orderStore, chatStore: chatStore}
} }
// requireAdmin 校验管理员令牌。 // requireAdmin 校验管理员令牌。
// 优先级X-Admin-Token 请求头 > Authorization 请求头 > ?token 查询参数(旧版兼容)。 // 优先级X-Admin-Token 请求头 > Authorization 请求头 > ?token 查询参数(旧版兼容)。
func (h *AdminHandler) requireAdmin(c *gin.Context) bool { func (h *AdminHandler) requireAdmin(c *gin.Context) bool {
token := c.GetHeader("X-Admin-Token") token := c.GetHeader("X-Admin-Token")
if token == "" { if token == "" {
token = c.GetHeader("Authorization") token = c.GetHeader("Authorization")
} }
if token == "" { if token == "" {
// 兼容旧版客户端的 URL 查询参数回退 // 兼容旧版客户端的 URL 查询参数回退
token = c.Query("token") token = c.Query("token")
} }
if token != "" && token == h.cfg.AdminToken { if token != "" && token == h.cfg.AdminToken {
return true return true
} }
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return false return false
} }

View File

@@ -1,88 +1,88 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// GetAllConversations 返回所有用户会话列表。 // GetAllConversations 返回所有用户会话列表。
func (h *AdminHandler) GetAllConversations(c *gin.Context) { func (h *AdminHandler) GetAllConversations(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
convs, err := h.chatStore.ListConversations() convs, err := h.chatStore.ListConversations()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": gin.H{"conversations": convs}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"conversations": convs}})
} }
// GetConversation 返回指定账号的全部消息记录。 // GetConversation 返回指定账号的全部消息记录。
func (h *AdminHandler) GetConversation(c *gin.Context) { func (h *AdminHandler) GetConversation(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
accountID := c.Param("account") accountID := c.Param("account")
if accountID == "" { if accountID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少账号参数"}) c.JSON(http.StatusBadRequest, gin.H{"error": "缺少账号参数"})
return return
} }
msgs, err := h.chatStore.GetMessages(accountID) msgs, err := h.chatStore.GetMessages(accountID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}})
} }
type adminChatPayload struct { type adminChatPayload struct {
Content string `json:"content"` Content string `json:"content"`
} }
// AdminReply 向指定用户发送管理员回复。 // AdminReply 向指定用户发送管理员回复。
func (h *AdminHandler) AdminReply(c *gin.Context) { func (h *AdminHandler) AdminReply(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
accountID := c.Param("account") accountID := c.Param("account")
if accountID == "" { if accountID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少账号参数"}) c.JSON(http.StatusBadRequest, gin.H{"error": "缺少账号参数"})
return return
} }
var payload adminChatPayload var payload adminChatPayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
} }
content := strings.TrimSpace(payload.Content) content := strings.TrimSpace(payload.Content)
if content == "" { if content == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "消息不能为空"}) c.JSON(http.StatusBadRequest, gin.H{"error": "消息不能为空"})
return return
} }
msg, err := h.chatStore.SendAdminMessage(accountID, content) msg, err := h.chatStore.SendAdminMessage(accountID, content)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": msg}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": msg}})
} }
// ClearConversation 清除与指定用户的全部消息记录。 // ClearConversation 清除与指定用户的全部消息记录。
func (h *AdminHandler) ClearConversation(c *gin.Context) { func (h *AdminHandler) ClearConversation(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
accountID := c.Param("account") accountID := c.Param("account")
if accountID == "" { if accountID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少账号参数"}) c.JSON(http.StatusBadRequest, gin.H{"error": "缺少账号参数"})
return return
} }
if err := h.chatStore.ClearConversation(accountID); err != nil { if err := h.chatStore.ClearConversation(accountID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
} }

View File

@@ -1,35 +1,35 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func (h *AdminHandler) ListAllOrders(c *gin.Context) { func (h *AdminHandler) ListAllOrders(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
orders, err := h.orderStore.ListAll() orders, err := h.orderStore.ListAll()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": orders}) c.JSON(http.StatusOK, gin.H{"data": orders})
} }
func (h *AdminHandler) DeleteOrder(c *gin.Context) { func (h *AdminHandler) DeleteOrder(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
orderID := c.Param("id") orderID := c.Param("id")
if orderID == "" { if orderID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少订单 ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "缺少订单 ID"})
return return
} }
if err := h.orderStore.Delete(orderID); err != nil { if err := h.orderStore.Delete(orderID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
} }

View File

@@ -1,210 +1,210 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"mengyastore-backend/internal/models" "mengyastore-backend/internal/models"
) )
type productPayload struct { type productPayload struct {
Name string `json:"name"` Name string `json:"name"`
Price float64 `json:"price"` Price float64 `json:"price"`
DiscountPrice float64 `json:"discountPrice"` DiscountPrice float64 `json:"discountPrice"`
Tags string `json:"tags"` Tags string `json:"tags"`
CoverURL string `json:"coverUrl"` CoverURL string `json:"coverUrl"`
Codes []string `json:"codes"` Codes []string `json:"codes"`
ScreenshotURLs []string `json:"screenshotUrls"` ScreenshotURLs []string `json:"screenshotUrls"`
Description string `json:"description"` Description string `json:"description"`
Active *bool `json:"active"` Active *bool `json:"active"`
RequireLogin bool `json:"requireLogin"` RequireLogin bool `json:"requireLogin"`
MaxPerAccount int `json:"maxPerAccount"` MaxPerAccount int `json:"maxPerAccount"`
DeliveryMode string `json:"deliveryMode"` DeliveryMode string `json:"deliveryMode"`
ShowNote bool `json:"showNote"` ShowNote bool `json:"showNote"`
ShowContact bool `json:"showContact"` ShowContact bool `json:"showContact"`
} }
type togglePayload struct { type togglePayload struct {
Active bool `json:"active"` Active bool `json:"active"`
} }
// VerifyAdminToken 验证管理员令牌是否正确,返回 {"valid": true/false},不暴露实际令牌值。 // VerifyAdminToken 验证管理员令牌是否正确,返回 {"valid": true/false},不暴露实际令牌值。
func (h *AdminHandler) VerifyAdminToken(c *gin.Context) { func (h *AdminHandler) VerifyAdminToken(c *gin.Context) {
var payload struct { var payload struct {
Token string `json:"token"` Token string `json:"token"`
} }
if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" { if err := c.ShouldBindJSON(&payload); err != nil || payload.Token == "" {
c.JSON(http.StatusOK, gin.H{"valid": false}) c.JSON(http.StatusOK, gin.H{"valid": false})
return return
} }
c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken}) c.JSON(http.StatusOK, gin.H{"valid": payload.Token == h.cfg.AdminToken})
} }
func (h *AdminHandler) ListAllProducts(c *gin.Context) { func (h *AdminHandler) ListAllProducts(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
items, err := h.store.ListAll() items, err := h.store.ListAll()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": items}) c.JSON(http.StatusOK, gin.H{"data": items})
} }
func (h *AdminHandler) CreateProduct(c *gin.Context) { func (h *AdminHandler) CreateProduct(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
var payload productPayload var payload productPayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
} }
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs) screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
if !valid { if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"}) c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"})
return return
} }
active := true active := true
if payload.Active != nil { if payload.Active != nil {
active = *payload.Active active = *payload.Active
} }
product := models.Product{ product := models.Product{
Name: payload.Name, Name: payload.Name,
Price: payload.Price, Price: payload.Price,
DiscountPrice: payload.DiscountPrice, DiscountPrice: payload.DiscountPrice,
Tags: normalizeTags(payload.Tags), Tags: normalizeTags(payload.Tags),
CoverURL: strings.TrimSpace(payload.CoverURL), CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes, Codes: payload.Codes,
ScreenshotURLs: screenshotURLs, ScreenshotURLs: screenshotURLs,
Description: payload.Description, Description: payload.Description,
Active: active, Active: active,
RequireLogin: payload.RequireLogin, RequireLogin: payload.RequireLogin,
MaxPerAccount: payload.MaxPerAccount, MaxPerAccount: payload.MaxPerAccount,
DeliveryMode: payload.DeliveryMode, DeliveryMode: payload.DeliveryMode,
ShowNote: payload.ShowNote, ShowNote: payload.ShowNote,
ShowContact: payload.ShowContact, ShowContact: payload.ShowContact,
} }
created, err := h.store.Create(product) created, err := h.store.Create(product)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": created}) c.JSON(http.StatusOK, gin.H{"data": created})
} }
func (h *AdminHandler) UpdateProduct(c *gin.Context) { func (h *AdminHandler) UpdateProduct(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
id := c.Param("id") id := c.Param("id")
var payload productPayload var payload productPayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
} }
screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs) screenshotURLs, valid := normalizeScreenshotURLs(payload.ScreenshotURLs)
if !valid { if !valid {
c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"}) c.JSON(http.StatusBadRequest, gin.H{"error": "截图链接最多 5 条"})
return return
} }
active := false active := false
if payload.Active != nil { if payload.Active != nil {
active = *payload.Active active = *payload.Active
} }
patch := models.Product{ patch := models.Product{
Name: payload.Name, Name: payload.Name,
Price: payload.Price, Price: payload.Price,
DiscountPrice: payload.DiscountPrice, DiscountPrice: payload.DiscountPrice,
Tags: normalizeTags(payload.Tags), Tags: normalizeTags(payload.Tags),
CoverURL: strings.TrimSpace(payload.CoverURL), CoverURL: strings.TrimSpace(payload.CoverURL),
Codes: payload.Codes, Codes: payload.Codes,
ScreenshotURLs: screenshotURLs, ScreenshotURLs: screenshotURLs,
Description: payload.Description, Description: payload.Description,
Active: active, Active: active,
RequireLogin: payload.RequireLogin, RequireLogin: payload.RequireLogin,
MaxPerAccount: payload.MaxPerAccount, MaxPerAccount: payload.MaxPerAccount,
DeliveryMode: payload.DeliveryMode, DeliveryMode: payload.DeliveryMode,
ShowNote: payload.ShowNote, ShowNote: payload.ShowNote,
ShowContact: payload.ShowContact, ShowContact: payload.ShowContact,
} }
updated, err := h.store.Update(id, patch) updated, err := h.store.Update(id, patch)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": updated}) c.JSON(http.StatusOK, gin.H{"data": updated})
} }
func (h *AdminHandler) ToggleProduct(c *gin.Context) { func (h *AdminHandler) ToggleProduct(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
id := c.Param("id") id := c.Param("id")
var payload togglePayload var payload togglePayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
} }
updated, err := h.store.Toggle(id, payload.Active) updated, err := h.store.Toggle(id, payload.Active)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": updated}) c.JSON(http.StatusOK, gin.H{"data": updated})
} }
func (h *AdminHandler) DeleteProduct(c *gin.Context) { func (h *AdminHandler) DeleteProduct(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
id := c.Param("id") id := c.Param("id")
if err := h.store.Delete(id); err != nil { if err := h.store.Delete(id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
} }
func normalizeScreenshotURLs(urls []string) ([]string, bool) { func normalizeScreenshotURLs(urls []string) ([]string, bool) {
cleaned := make([]string, 0, len(urls)) cleaned := make([]string, 0, len(urls))
for _, url := range urls { for _, url := range urls {
trimmed := strings.TrimSpace(url) trimmed := strings.TrimSpace(url)
if trimmed == "" { if trimmed == "" {
continue continue
} }
cleaned = append(cleaned, trimmed) cleaned = append(cleaned, trimmed)
if len(cleaned) > 5 { if len(cleaned) > 5 {
return nil, false return nil, false
} }
} }
return cleaned, true return cleaned, true
} }
func normalizeTags(tagsCSV string) []string { func normalizeTags(tagsCSV string) []string {
if tagsCSV == "" { if tagsCSV == "" {
return []string{} return []string{}
} }
parts := strings.Split(tagsCSV, ",") parts := strings.Split(tagsCSV, ",")
clean := make([]string, 0, len(parts)) clean := make([]string, 0, len(parts))
seen := map[string]bool{} seen := map[string]bool{}
for _, p := range parts { for _, p := range parts {
t := strings.TrimSpace(p) t := strings.TrimSpace(p)
if t == "" { if t == "" {
continue continue
} }
key := strings.ToLower(t) key := strings.ToLower(t)
if seen[key] { if seen[key] {
continue continue
} }
seen[key] = true seen[key] = true
clean = append(clean, t) clean = append(clean, t)
if len(clean) >= 20 { if len(clean) >= 20 {
break break
} }
} }
return clean return clean
} }

View File

@@ -1,73 +1,73 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"mengyastore-backend/internal/storage" "mengyastore-backend/internal/storage"
) )
type maintenancePayload struct { type maintenancePayload struct {
Maintenance bool `json:"maintenance"` Maintenance bool `json:"maintenance"`
Reason string `json:"reason"` Reason string `json:"reason"`
} }
func (h *AdminHandler) SetMaintenance(c *gin.Context) { func (h *AdminHandler) SetMaintenance(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
var payload maintenancePayload var payload maintenancePayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
} }
if err := h.siteStore.SetMaintenance(payload.Maintenance, payload.Reason); err != nil { if err := h.siteStore.SetMaintenance(payload.Maintenance, payload.Reason); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"data": gin.H{ "data": gin.H{
"maintenance": payload.Maintenance, "maintenance": payload.Maintenance,
"reason": payload.Reason, "reason": payload.Reason,
}, },
}) })
} }
func (h *AdminHandler) GetSMTPConfig(c *gin.Context) { func (h *AdminHandler) GetSMTPConfig(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
cfg, err := h.siteStore.GetSMTPConfig() cfg, err := h.siteStore.GetSMTPConfig()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
// 响应中对密码脱敏处理 // 响应中对密码脱敏处理
masked := cfg masked := cfg
if masked.Password != "" { if masked.Password != "" {
masked.Password = "••••••••" masked.Password = "••••••••"
} }
c.JSON(http.StatusOK, gin.H{"data": masked}) c.JSON(http.StatusOK, gin.H{"data": masked})
} }
func (h *AdminHandler) SetSMTPConfig(c *gin.Context) { func (h *AdminHandler) SetSMTPConfig(c *gin.Context) {
if !h.requireAdmin(c) { if !h.requireAdmin(c) {
return return
} }
var payload storage.SMTPConfig var payload storage.SMTPConfig
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
} }
// 若密码为脱敏占位符,则保留数据库中原有的密码 // 若密码为脱敏占位符,则保留数据库中原有的密码
if payload.Password == "••••••••" { if payload.Password == "••••••••" {
existing, _ := h.siteStore.GetSMTPConfig() existing, _ := h.siteStore.GetSMTPConfig()
payload.Password = existing.Password payload.Password = existing.Password
} }
if err := h.siteStore.SetSMTPConfig(payload); err != nil { if err := h.siteStore.SetSMTPConfig(payload); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": "ok"}) c.JSON(http.StatusOK, gin.H{"data": "ok"})
} }

View File

@@ -1,82 +1,82 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"mengyastore-backend/internal/auth" "mengyastore-backend/internal/auth"
"mengyastore-backend/internal/storage" "mengyastore-backend/internal/storage"
) )
type ChatHandler struct { type ChatHandler struct {
chatStore *storage.ChatStore chatStore *storage.ChatStore
authClient *auth.SproutGateClient authClient *auth.SproutGateClient
} }
func NewChatHandler(chatStore *storage.ChatStore, authClient *auth.SproutGateClient) *ChatHandler { func NewChatHandler(chatStore *storage.ChatStore, authClient *auth.SproutGateClient) *ChatHandler {
return &ChatHandler{chatStore: chatStore, authClient: authClient} return &ChatHandler{chatStore: chatStore, authClient: authClient}
} }
func (h *ChatHandler) requireChatUser(c *gin.Context) (account, name string, ok bool) { func (h *ChatHandler) requireChatUser(c *gin.Context) (account, name string, ok bool) {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return "", "", false return "", "", false
} }
token := strings.TrimPrefix(authHeader, "Bearer ") token := strings.TrimPrefix(authHeader, "Bearer ")
result, err := h.authClient.VerifyToken(token) result, err := h.authClient.VerifyToken(token)
if err != nil || !result.Valid || result.User == nil { if err != nil || !result.Valid || result.User == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return "", "", false return "", "", false
} }
return result.User.Account, result.User.Username, true return result.User.Account, result.User.Username, true
} }
// GetMyMessages 返回当前登录用户的全部聊天消息。 // GetMyMessages 返回当前登录用户的全部聊天消息。
func (h *ChatHandler) GetMyMessages(c *gin.Context) { func (h *ChatHandler) GetMyMessages(c *gin.Context) {
account, _, ok := h.requireChatUser(c) account, _, ok := h.requireChatUser(c)
if !ok { if !ok {
return return
} }
msgs, err := h.chatStore.GetMessages(account) msgs, err := h.chatStore.GetMessages(account)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"messages": msgs}})
} }
type chatMessagePayload struct { type chatMessagePayload struct {
Content string `json:"content"` Content string `json:"content"`
} }
// SendMyMessage 向管理员发送一条用户消息。 // SendMyMessage 向管理员发送一条用户消息。
func (h *ChatHandler) SendMyMessage(c *gin.Context) { func (h *ChatHandler) SendMyMessage(c *gin.Context) {
account, name, ok := h.requireChatUser(c) account, name, ok := h.requireChatUser(c)
if !ok { if !ok {
return return
} }
var payload chatMessagePayload var payload chatMessagePayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
} }
content := strings.TrimSpace(payload.Content) content := strings.TrimSpace(payload.Content)
if content == "" { if content == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "消息不能为空"}) c.JSON(http.StatusBadRequest, gin.H{"error": "消息不能为空"})
return return
} }
msg, rateLimited, err := h.chatStore.SendUserMessage(account, name, content) msg, rateLimited, err := h.chatStore.SendUserMessage(account, name, content)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if rateLimited { if rateLimited {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "发送太频繁,请稍候"}) c.JSON(http.StatusTooManyRequests, gin.H{"error": "发送太频繁,请稍候"})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": msg}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": msg}})
} }

View File

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

View File

@@ -59,4 +59,4 @@ func sanitizeForPublic(items []models.Product) []models.Product {
} }
return out return out
} }

View File

@@ -1,66 +1,66 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"mengyastore-backend/internal/storage" "mengyastore-backend/internal/storage"
) )
type StatsHandler struct { type StatsHandler struct {
orderStore *storage.OrderStore orderStore *storage.OrderStore
siteStore *storage.SiteStore siteStore *storage.SiteStore
} }
func NewStatsHandler(orderStore *storage.OrderStore, siteStore *storage.SiteStore) *StatsHandler { func NewStatsHandler(orderStore *storage.OrderStore, siteStore *storage.SiteStore) *StatsHandler {
return &StatsHandler{orderStore: orderStore, siteStore: siteStore} return &StatsHandler{orderStore: orderStore, siteStore: siteStore}
} }
func (h *StatsHandler) GetStats(c *gin.Context) { func (h *StatsHandler) GetStats(c *gin.Context) {
totalOrders, err := h.orderStore.Count() totalOrders, err := h.orderStore.Count()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
totalVisits, err := h.siteStore.GetTotalVisits() totalVisits, err := h.siteStore.GetTotalVisits()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"data": gin.H{ "data": gin.H{
"totalOrders": totalOrders, "totalOrders": totalOrders,
"totalVisits": totalVisits, "totalVisits": totalVisits,
}, },
}) })
} }
func (h *StatsHandler) RecordVisit(c *gin.Context) { func (h *StatsHandler) RecordVisit(c *gin.Context) {
fingerprint := buildViewerFingerprint(c) fingerprint := buildViewerFingerprint(c)
totalVisits, counted, err := h.siteStore.RecordVisit(fingerprint) totalVisits, counted, err := h.siteStore.RecordVisit(fingerprint)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"data": gin.H{ "data": gin.H{
"totalVisits": totalVisits, "totalVisits": totalVisits,
"counted": counted, "counted": counted,
}, },
}) })
} }
func (h *StatsHandler) GetMaintenance(c *gin.Context) { func (h *StatsHandler) GetMaintenance(c *gin.Context) {
enabled, reason, err := h.siteStore.GetMaintenance() enabled, reason, err := h.siteStore.GetMaintenance()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"data": gin.H{ "data": gin.H{
"maintenance": enabled, "maintenance": enabled,
"reason": reason, "reason": reason,
}, },
}) })
} }

View File

@@ -1,88 +1,88 @@
package handlers package handlers
import ( import (
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"mengyastore-backend/internal/auth" "mengyastore-backend/internal/auth"
"mengyastore-backend/internal/storage" "mengyastore-backend/internal/storage"
) )
type WishlistHandler struct { type WishlistHandler struct {
wishlistStore *storage.WishlistStore wishlistStore *storage.WishlistStore
authClient *auth.SproutGateClient authClient *auth.SproutGateClient
} }
func NewWishlistHandler(wishlistStore *storage.WishlistStore, authClient *auth.SproutGateClient) *WishlistHandler { func NewWishlistHandler(wishlistStore *storage.WishlistStore, authClient *auth.SproutGateClient) *WishlistHandler {
return &WishlistHandler{wishlistStore: wishlistStore, authClient: authClient} return &WishlistHandler{wishlistStore: wishlistStore, authClient: authClient}
} }
func (h *WishlistHandler) requireUser(c *gin.Context) (string, bool) { func (h *WishlistHandler) requireUser(c *gin.Context) (string, bool) {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return "", false return "", false
} }
token := strings.TrimPrefix(authHeader, "Bearer ") token := strings.TrimPrefix(authHeader, "Bearer ")
result, err := h.authClient.VerifyToken(token) result, err := h.authClient.VerifyToken(token)
if err != nil || !result.Valid || result.User == nil { if err != nil || !result.Valid || result.User == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "登录已过期,请重新登录"})
return "", false return "", false
} }
return result.User.Account, true return result.User.Account, true
} }
func (h *WishlistHandler) GetWishlist(c *gin.Context) { func (h *WishlistHandler) GetWishlist(c *gin.Context) {
account, ok := h.requireUser(c) account, ok := h.requireUser(c)
if !ok { if !ok {
return return
} }
ids, err := h.wishlistStore.Get(account) ids, err := h.wishlistStore.Get(account)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
} }
type wishlistItemPayload struct { type wishlistItemPayload struct {
ProductID string `json:"productId"` ProductID string `json:"productId"`
} }
func (h *WishlistHandler) AddToWishlist(c *gin.Context) { func (h *WishlistHandler) AddToWishlist(c *gin.Context) {
account, ok := h.requireUser(c) account, ok := h.requireUser(c)
if !ok { if !ok {
return return
} }
var payload wishlistItemPayload var payload wishlistItemPayload
if err := c.ShouldBindJSON(&payload); err != nil || payload.ProductID == "" { if err := c.ShouldBindJSON(&payload); err != nil || payload.ProductID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数有误"})
return return
} }
if err := h.wishlistStore.Add(account, payload.ProductID); err != nil { if err := h.wishlistStore.Add(account, payload.ProductID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
ids, _ := h.wishlistStore.Get(account) ids, _ := h.wishlistStore.Get(account)
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
} }
func (h *WishlistHandler) RemoveFromWishlist(c *gin.Context) { func (h *WishlistHandler) RemoveFromWishlist(c *gin.Context) {
account, ok := h.requireUser(c) account, ok := h.requireUser(c)
if !ok { if !ok {
return return
} }
productID := c.Param("id") productID := c.Param("id")
if productID == "" { if productID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少商品 ID"}) c.JSON(http.StatusBadRequest, gin.H{"error": "缺少商品 ID"})
return return
} }
if err := h.wishlistStore.Remove(account, productID); err != nil { if err := h.wishlistStore.Remove(account, productID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
ids, _ := h.wishlistStore.Get(account) ids, _ := h.wishlistStore.Get(account)
c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}}) c.JSON(http.StatusOK, gin.H{"data": gin.H{"items": ids}})
} }

View File

@@ -1,12 +1,12 @@
package models package models
import "time" import "time"
type ChatMessage struct { type ChatMessage struct {
ID string `json:"id"` ID string `json:"id"`
AccountID string `json:"accountId"` AccountID string `json:"accountId"`
AccountName string `json:"accountName"` AccountName string `json:"accountName"`
Content string `json:"content"` Content string `json:"content"`
SentAt time.Time `json:"sentAt"` SentAt time.Time `json:"sentAt"`
FromAdmin bool `json:"fromAdmin"` FromAdmin bool `json:"fromAdmin"`
} }

View File

@@ -1,20 +1,20 @@
package models package models
import "time" import "time"
type Order struct { type Order struct {
ID string `json:"id"` ID string `json:"id"`
ProductID string `json:"productId"` ProductID string `json:"productId"`
ProductName string `json:"productName"` ProductName string `json:"productName"`
UserAccount string `json:"userAccount"` UserAccount string `json:"userAccount"`
UserName string `json:"userName"` UserName string `json:"userName"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
DeliveredCodes []string `json:"deliveredCodes"` DeliveredCodes []string `json:"deliveredCodes"`
Status string `json:"status"` Status string `json:"status"`
DeliveryMode string `json:"deliveryMode"` DeliveryMode string `json:"deliveryMode"`
Note string `json:"note"` Note string `json:"note"`
ContactPhone string `json:"contactPhone"` ContactPhone string `json:"contactPhone"`
ContactEmail string `json:"contactEmail"` ContactEmail string `json:"contactEmail"`
NotifyEmail string `json:"notifyEmail"` NotifyEmail string `json:"notifyEmail"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }

View File

@@ -1,27 +1,27 @@
package models package models
import "time" import "time"
type Product struct { type Product struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Price float64 `json:"price"` Price float64 `json:"price"`
DiscountPrice float64 `json:"discountPrice"` DiscountPrice float64 `json:"discountPrice"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
CoverURL string `json:"coverUrl"` CoverURL string `json:"coverUrl"`
ScreenshotURLs []string `json:"screenshotUrls"` ScreenshotURLs []string `json:"screenshotUrls"`
VerificationURL string `json:"verificationUrl"` VerificationURL string `json:"verificationUrl"`
Codes []string `json:"codes"` Codes []string `json:"codes"`
ViewCount int `json:"viewCount"` ViewCount int `json:"viewCount"`
Description string `json:"description"` Description string `json:"description"`
Active bool `json:"active"` Active bool `json:"active"`
RequireLogin bool `json:"requireLogin"` RequireLogin bool `json:"requireLogin"`
MaxPerAccount int `json:"maxPerAccount"` MaxPerAccount int `json:"maxPerAccount"`
TotalSold int `json:"totalSold"` TotalSold int `json:"totalSold"`
DeliveryMode string `json:"deliveryMode"` DeliveryMode string `json:"deliveryMode"`
ShowNote bool `json:"showNote"` ShowNote bool `json:"showNote"`
ShowContact bool `json:"showContact"` ShowContact bool `json:"showContact"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }

View File

@@ -1,99 +1,99 @@
package storage package storage
import ( import (
"sync" "sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"mengyastore-backend/internal/database" "mengyastore-backend/internal/database"
"mengyastore-backend/internal/models" "mengyastore-backend/internal/models"
) )
type ChatStore struct { type ChatStore struct {
db *gorm.DB db *gorm.DB
mu sync.Mutex mu sync.Mutex
lastSent map[string]time.Time lastSent map[string]time.Time
} }
func NewChatStore(db *gorm.DB) (*ChatStore, error) { func NewChatStore(db *gorm.DB) (*ChatStore, error) {
return &ChatStore{db: db, lastSent: make(map[string]time.Time)}, nil return &ChatStore{db: db, lastSent: make(map[string]time.Time)}, nil
} }
func chatRowToModel(row database.ChatMessageRow) models.ChatMessage { func chatRowToModel(row database.ChatMessageRow) models.ChatMessage {
return models.ChatMessage{ return models.ChatMessage{
ID: row.ID, ID: row.ID,
AccountID: row.AccountID, AccountID: row.AccountID,
AccountName: row.AccountName, AccountName: row.AccountName,
Content: row.Content, Content: row.Content,
SentAt: row.SentAt, SentAt: row.SentAt,
FromAdmin: row.FromAdmin, FromAdmin: row.FromAdmin,
} }
} }
func (s *ChatStore) GetMessages(accountID string) ([]models.ChatMessage, error) { func (s *ChatStore) GetMessages(accountID string) ([]models.ChatMessage, error) {
var rows []database.ChatMessageRow var rows []database.ChatMessageRow
if err := s.db.Where("account_id = ?", accountID).Order("sent_at ASC").Find(&rows).Error; err != nil { if err := s.db.Where("account_id = ?", accountID).Order("sent_at ASC").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
msgs := make([]models.ChatMessage, len(rows)) msgs := make([]models.ChatMessage, len(rows))
for i, r := range rows { for i, r := range rows {
msgs[i] = chatRowToModel(r) msgs[i] = chatRowToModel(r)
} }
return msgs, nil return msgs, nil
} }
func (s *ChatStore) ListConversations() (map[string][]models.ChatMessage, error) { func (s *ChatStore) ListConversations() (map[string][]models.ChatMessage, error) {
var rows []database.ChatMessageRow var rows []database.ChatMessageRow
if err := s.db.Order("account_id, sent_at ASC").Find(&rows).Error; err != nil { if err := s.db.Order("account_id, sent_at ASC").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
result := make(map[string][]models.ChatMessage) result := make(map[string][]models.ChatMessage)
for _, r := range rows { for _, r := range rows {
result[r.AccountID] = append(result[r.AccountID], chatRowToModel(r)) result[r.AccountID] = append(result[r.AccountID], chatRowToModel(r))
} }
return result, nil return result, nil
} }
func (s *ChatStore) SendUserMessage(accountID, accountName, content string) (models.ChatMessage, bool, error) { func (s *ChatStore) SendUserMessage(accountID, accountName, content string) (models.ChatMessage, bool, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if last, ok := s.lastSent[accountID]; ok && time.Since(last) < time.Second { if last, ok := s.lastSent[accountID]; ok && time.Since(last) < time.Second {
return models.ChatMessage{}, true, nil return models.ChatMessage{}, true, nil
} }
s.lastSent[accountID] = time.Now() s.lastSent[accountID] = time.Now()
row := database.ChatMessageRow{ row := database.ChatMessageRow{
ID: uuid.New().String(), ID: uuid.New().String(),
AccountID: accountID, AccountID: accountID,
AccountName: accountName, AccountName: accountName,
Content: content, Content: content,
SentAt: time.Now(), SentAt: time.Now(),
FromAdmin: false, FromAdmin: false,
} }
if err := s.db.Create(&row).Error; err != nil { if err := s.db.Create(&row).Error; err != nil {
return models.ChatMessage{}, false, err return models.ChatMessage{}, false, err
} }
return chatRowToModel(row), false, nil return chatRowToModel(row), false, nil
} }
func (s *ChatStore) SendAdminMessage(accountID, content string) (models.ChatMessage, error) { func (s *ChatStore) SendAdminMessage(accountID, content string) (models.ChatMessage, error) {
row := database.ChatMessageRow{ row := database.ChatMessageRow{
ID: uuid.New().String(), ID: uuid.New().String(),
AccountID: accountID, AccountID: accountID,
AccountName: "管理员", AccountName: "管理员",
Content: content, Content: content,
SentAt: time.Now(), SentAt: time.Now(),
FromAdmin: true, FromAdmin: true,
} }
if err := s.db.Create(&row).Error; err != nil { if err := s.db.Create(&row).Error; err != nil {
return models.ChatMessage{}, err return models.ChatMessage{}, err
} }
return chatRowToModel(row), nil return chatRowToModel(row), nil
} }
func (s *ChatStore) ClearConversation(accountID string) error { func (s *ChatStore) ClearConversation(accountID string) error {
return s.db.Where("account_id = ?", accountID).Delete(&database.ChatMessageRow{}).Error return s.db.Where("account_id = ?", accountID).Delete(&database.ChatMessageRow{}).Error
} }

View File

@@ -1,140 +1,140 @@
package storage package storage
import ( import (
"fmt" "fmt"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"mengyastore-backend/internal/database" "mengyastore-backend/internal/database"
"mengyastore-backend/internal/models" "mengyastore-backend/internal/models"
) )
type OrderStore struct { type OrderStore struct {
db *gorm.DB db *gorm.DB
} }
func NewOrderStore(db *gorm.DB) (*OrderStore, error) { func NewOrderStore(db *gorm.DB) (*OrderStore, error) {
return &OrderStore{db: db}, nil return &OrderStore{db: db}, nil
} }
func orderRowToModel(row database.OrderRow) models.Order { func orderRowToModel(row database.OrderRow) models.Order {
return models.Order{ return models.Order{
ID: row.ID, ID: row.ID,
ProductID: row.ProductID, ProductID: row.ProductID,
ProductName: row.ProductName, ProductName: row.ProductName,
UserAccount: row.UserAccount, UserAccount: row.UserAccount,
UserName: row.UserName, UserName: row.UserName,
Quantity: row.Quantity, Quantity: row.Quantity,
DeliveredCodes: row.DeliveredCodes, DeliveredCodes: row.DeliveredCodes,
Status: row.Status, Status: row.Status,
DeliveryMode: row.DeliveryMode, DeliveryMode: row.DeliveryMode,
Note: row.Note, Note: row.Note,
ContactPhone: row.ContactPhone, ContactPhone: row.ContactPhone,
ContactEmail: row.ContactEmail, ContactEmail: row.ContactEmail,
NotifyEmail: row.NotifyEmail, NotifyEmail: row.NotifyEmail,
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
} }
} }
func (s *OrderStore) Create(order models.Order) (models.Order, error) { func (s *OrderStore) Create(order models.Order) (models.Order, error) {
if order.ID == "" { if order.ID == "" {
order.ID = uuid.NewString() order.ID = uuid.NewString()
} }
if len(order.DeliveredCodes) == 0 { if len(order.DeliveredCodes) == 0 {
order.DeliveredCodes = []string{} order.DeliveredCodes = []string{}
} }
row := database.OrderRow{ row := database.OrderRow{
ID: order.ID, ID: order.ID,
ProductID: order.ProductID, ProductID: order.ProductID,
ProductName: order.ProductName, ProductName: order.ProductName,
UserAccount: order.UserAccount, UserAccount: order.UserAccount,
UserName: order.UserName, UserName: order.UserName,
Quantity: order.Quantity, Quantity: order.Quantity,
DeliveredCodes: database.StringSlice(order.DeliveredCodes), DeliveredCodes: database.StringSlice(order.DeliveredCodes),
Status: order.Status, Status: order.Status,
DeliveryMode: order.DeliveryMode, DeliveryMode: order.DeliveryMode,
Note: order.Note, Note: order.Note,
ContactPhone: order.ContactPhone, ContactPhone: order.ContactPhone,
ContactEmail: order.ContactEmail, ContactEmail: order.ContactEmail,
NotifyEmail: order.NotifyEmail, NotifyEmail: order.NotifyEmail,
} }
if err := s.db.Create(&row).Error; err != nil { if err := s.db.Create(&row).Error; err != nil {
return models.Order{}, err return models.Order{}, err
} }
order.CreatedAt = row.CreatedAt order.CreatedAt = row.CreatedAt
return order, nil return order, nil
} }
func (s *OrderStore) GetByID(id string) (models.Order, error) { func (s *OrderStore) GetByID(id string) (models.Order, error) {
var row database.OrderRow var row database.OrderRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil { if err := s.db.First(&row, "id = ?", id).Error; err != nil {
return models.Order{}, fmt.Errorf("order not found") return models.Order{}, fmt.Errorf("order not found")
} }
return orderRowToModel(row), nil return orderRowToModel(row), nil
} }
func (s *OrderStore) Confirm(id string) (models.Order, error) { func (s *OrderStore) Confirm(id string) (models.Order, error) {
var row database.OrderRow var row database.OrderRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil { if err := s.db.First(&row, "id = ?", id).Error; err != nil {
return models.Order{}, fmt.Errorf("order not found") return models.Order{}, fmt.Errorf("order not found")
} }
if err := s.db.Model(&row).Update("status", "completed").Error; err != nil { if err := s.db.Model(&row).Update("status", "completed").Error; err != nil {
return models.Order{}, err return models.Order{}, err
} }
row.Status = "completed" row.Status = "completed"
return orderRowToModel(row), nil return orderRowToModel(row), nil
} }
func (s *OrderStore) ListByAccount(account string) ([]models.Order, error) { func (s *OrderStore) ListByAccount(account string) ([]models.Order, error) {
var rows []database.OrderRow var rows []database.OrderRow
if err := s.db.Where("user_account = ?", account).Order("created_at DESC").Find(&rows).Error; err != nil { if err := s.db.Where("user_account = ?", account).Order("created_at DESC").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
orders := make([]models.Order, len(rows)) orders := make([]models.Order, len(rows))
for i, r := range rows { for i, r := range rows {
orders[i] = orderRowToModel(r) orders[i] = orderRowToModel(r)
} }
return orders, nil return orders, nil
} }
func (s *OrderStore) ListAll() ([]models.Order, error) { func (s *OrderStore) ListAll() ([]models.Order, error) {
var rows []database.OrderRow var rows []database.OrderRow
if err := s.db.Order("created_at DESC").Find(&rows).Error; err != nil { if err := s.db.Order("created_at DESC").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
orders := make([]models.Order, len(rows)) orders := make([]models.Order, len(rows))
for i, r := range rows { for i, r := range rows {
orders[i] = orderRowToModel(r) orders[i] = orderRowToModel(r)
} }
return orders, nil return orders, nil
} }
func (s *OrderStore) CountPurchasedByAccount(account, productID string) (int, error) { func (s *OrderStore) CountPurchasedByAccount(account, productID string) (int, error) {
var total int64 var total int64
// 统计 pending手动待发货和 completed 两种状态,防止用户快速下单绕过购买数量限制。 // 统计 pending手动待发货和 completed 两种状态,防止用户快速下单绕过购买数量限制。
err := s.db.Model(&database.OrderRow{}). err := s.db.Model(&database.OrderRow{}).
Where("user_account = ? AND product_id = ? AND status IN ?", account, productID, []string{"pending", "completed"}). Where("user_account = ? AND product_id = ? AND status IN ?", account, productID, []string{"pending", "completed"}).
Select("COALESCE(SUM(quantity), 0)").Scan(&total).Error Select("COALESCE(SUM(quantity), 0)").Scan(&total).Error
return int(total), err return int(total), err
} }
// Count 返回所有订单的总数量。 // Count 返回所有订单的总数量。
func (s *OrderStore) Count() (int, error) { func (s *OrderStore) Count() (int, error) {
var count int64 var count int64
if err := s.db.Model(&database.OrderRow{}).Count(&count).Error; err != nil { if err := s.db.Model(&database.OrderRow{}).Count(&count).Error; err != nil {
return 0, err return 0, err
} }
return int(count), nil return int(count), nil
} }
// Delete 根据 ID 删除单条订单。 // Delete 根据 ID 删除单条订单。
func (s *OrderStore) Delete(id string) error { func (s *OrderStore) Delete(id string) error {
return s.db.Delete(&database.OrderRow{}, "id = ?", id).Error return s.db.Delete(&database.OrderRow{}, "id = ?", id).Error
} }
// UpdateCodes 更新订单的已发货卡密列表。 // UpdateCodes 更新订单的已发货卡密列表。
func (s *OrderStore) UpdateCodes(id string, codes []string) error { func (s *OrderStore) UpdateCodes(id string, codes []string) error {
return s.db.Model(&database.OrderRow{}).Where("id = ?", id). return s.db.Model(&database.OrderRow{}).Where("id = ?", id).
Update("delivered_codes", database.StringSlice(codes)).Error Update("delivered_codes", database.StringSlice(codes)).Error
} }

View File

@@ -1,328 +1,328 @@
package storage package storage
import ( import (
"crypto/sha256" "crypto/sha256"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm" "gorm.io/gorm"
"mengyastore-backend/internal/database" "mengyastore-backend/internal/database"
"mengyastore-backend/internal/models" "mengyastore-backend/internal/models"
) )
const defaultCoverURL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png" const defaultCoverURL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png"
const viewCooldown = 6 * time.Hour const viewCooldown = 6 * time.Hour
const maxScreenshotURLs = 5 const maxScreenshotURLs = 5
type ProductStore struct { type ProductStore struct {
db *gorm.DB db *gorm.DB
mu sync.Mutex mu sync.Mutex
recentViews map[string]time.Time recentViews map[string]time.Time
} }
func NewProductStore(db *gorm.DB) (*ProductStore, error) { func NewProductStore(db *gorm.DB) (*ProductStore, error) {
return &ProductStore{ return &ProductStore{
db: db, db: db,
recentViews: make(map[string]time.Time), recentViews: make(map[string]time.Time),
}, nil }, nil
} }
// rowToModel 将数据库行(含卡密)转换为业务模型。 // rowToModel 将数据库行(含卡密)转换为业务模型。
func rowToModel(row database.ProductRow, codes []string) models.Product { func rowToModel(row database.ProductRow, codes []string) models.Product {
return models.Product{ return models.Product{
ID: row.ID, ID: row.ID,
Name: row.Name, Name: row.Name,
Price: row.Price, Price: row.Price,
DiscountPrice: row.DiscountPrice, DiscountPrice: row.DiscountPrice,
Tags: row.Tags, Tags: row.Tags,
CoverURL: row.CoverURL, CoverURL: row.CoverURL,
ScreenshotURLs: row.ScreenshotURLs, ScreenshotURLs: row.ScreenshotURLs,
VerificationURL: row.VerificationURL, VerificationURL: row.VerificationURL,
Description: row.Description, Description: row.Description,
Active: row.Active, Active: row.Active,
RequireLogin: row.RequireLogin, RequireLogin: row.RequireLogin,
MaxPerAccount: row.MaxPerAccount, MaxPerAccount: row.MaxPerAccount,
TotalSold: row.TotalSold, TotalSold: row.TotalSold,
ViewCount: row.ViewCount, ViewCount: row.ViewCount,
DeliveryMode: row.DeliveryMode, DeliveryMode: row.DeliveryMode,
ShowNote: row.ShowNote, ShowNote: row.ShowNote,
ShowContact: row.ShowContact, ShowContact: row.ShowContact,
Codes: codes, Codes: codes,
Quantity: len(codes), Quantity: len(codes),
CreatedAt: row.CreatedAt, CreatedAt: row.CreatedAt,
} }
} }
func (s *ProductStore) loadCodes(productID string) ([]string, error) { func (s *ProductStore) loadCodes(productID string) ([]string, error) {
var rows []database.ProductCodeRow var rows []database.ProductCodeRow
if err := s.db.Where("product_id = ?", productID).Find(&rows).Error; err != nil { if err := s.db.Where("product_id = ?", productID).Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
codes := make([]string, len(rows)) codes := make([]string, len(rows))
for i, r := range rows { for i, r := range rows {
codes[i] = r.Code codes[i] = r.Code
} }
return codes, nil return codes, nil
} }
func (s *ProductStore) replaceCodes(productID string, codes []string) error { func (s *ProductStore) replaceCodes(productID string, codes []string) error {
if err := s.db.Where("product_id = ?", productID).Delete(&database.ProductCodeRow{}).Error; err != nil { if err := s.db.Where("product_id = ?", productID).Delete(&database.ProductCodeRow{}).Error; err != nil {
return err return err
} }
if len(codes) == 0 { if len(codes) == 0 {
return nil return nil
} }
rows := make([]database.ProductCodeRow, 0, len(codes)) rows := make([]database.ProductCodeRow, 0, len(codes))
for _, code := range codes { for _, code := range codes {
rows = append(rows, database.ProductCodeRow{ProductID: productID, Code: code}) rows = append(rows, database.ProductCodeRow{ProductID: productID, Code: code})
} }
return s.db.CreateInBatches(rows, 100).Error return s.db.CreateInBatches(rows, 100).Error
} }
func (s *ProductStore) ListAll() ([]models.Product, error) { func (s *ProductStore) ListAll() ([]models.Product, error) {
var rows []database.ProductRow var rows []database.ProductRow
if err := s.db.Order("created_at DESC").Find(&rows).Error; err != nil { if err := s.db.Order("created_at DESC").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
products := make([]models.Product, 0, len(rows)) products := make([]models.Product, 0, len(rows))
for _, row := range rows { for _, row := range rows {
codes, _ := s.loadCodes(row.ID) codes, _ := s.loadCodes(row.ID)
products = append(products, rowToModel(row, codes)) products = append(products, rowToModel(row, codes))
} }
return products, nil return products, nil
} }
func (s *ProductStore) ListActive() ([]models.Product, error) { func (s *ProductStore) ListActive() ([]models.Product, error) {
var rows []database.ProductRow var rows []database.ProductRow
if err := s.db.Where("active = ?", true).Order("created_at DESC").Find(&rows).Error; err != nil { if err := s.db.Where("active = ?", true).Order("created_at DESC").Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
products := make([]models.Product, 0, len(rows)) products := make([]models.Product, 0, len(rows))
for _, row := range rows { for _, row := range rows {
// 公开接口不暴露卡密,但需要统计剩余库存数量 // 公开接口不暴露卡密,但需要统计剩余库存数量
var count int64 var count int64
s.db.Model(&database.ProductCodeRow{}).Where("product_id = ?", row.ID).Count(&count) s.db.Model(&database.ProductCodeRow{}).Where("product_id = ?", row.ID).Count(&count)
row.Active = true row.Active = true
p := rowToModel(row, nil) p := rowToModel(row, nil)
p.Quantity = int(count) p.Quantity = int(count)
p.Codes = nil p.Codes = nil
products = append(products, p) products = append(products, p)
} }
return products, nil return products, nil
} }
func (s *ProductStore) GetByID(id string) (models.Product, error) { func (s *ProductStore) GetByID(id string) (models.Product, error) {
var row database.ProductRow var row database.ProductRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil { if err := s.db.First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, fmt.Errorf("product not found") return models.Product{}, fmt.Errorf("product not found")
} }
codes, _ := s.loadCodes(id) codes, _ := s.loadCodes(id)
return rowToModel(row, codes), nil return rowToModel(row, codes), nil
} }
func (s *ProductStore) Create(p models.Product) (models.Product, error) { func (s *ProductStore) Create(p models.Product) (models.Product, error) {
p = normalizeProduct(p) p = normalizeProduct(p)
p.ID = uuid.NewString() p.ID = uuid.NewString()
now := time.Now() now := time.Now()
p.CreatedAt = now p.CreatedAt = now
row := database.ProductRow{ row := database.ProductRow{
ID: p.ID, ID: p.ID,
Name: p.Name, Name: p.Name,
Price: p.Price, Price: p.Price,
DiscountPrice: p.DiscountPrice, DiscountPrice: p.DiscountPrice,
Tags: database.StringSlice(p.Tags), Tags: database.StringSlice(p.Tags),
CoverURL: p.CoverURL, CoverURL: p.CoverURL,
ScreenshotURLs: database.StringSlice(p.ScreenshotURLs), ScreenshotURLs: database.StringSlice(p.ScreenshotURLs),
VerificationURL: p.VerificationURL, VerificationURL: p.VerificationURL,
Description: p.Description, Description: p.Description,
Active: p.Active, Active: p.Active,
RequireLogin: p.RequireLogin, RequireLogin: p.RequireLogin,
MaxPerAccount: p.MaxPerAccount, MaxPerAccount: p.MaxPerAccount,
TotalSold: p.TotalSold, TotalSold: p.TotalSold,
ViewCount: p.ViewCount, ViewCount: p.ViewCount,
DeliveryMode: p.DeliveryMode, DeliveryMode: p.DeliveryMode,
ShowNote: p.ShowNote, ShowNote: p.ShowNote,
ShowContact: p.ShowContact, ShowContact: p.ShowContact,
CreatedAt: now, CreatedAt: now,
} }
if err := s.db.Create(&row).Error; err != nil { if err := s.db.Create(&row).Error; err != nil {
return models.Product{}, err return models.Product{}, err
} }
if err := s.replaceCodes(p.ID, p.Codes); err != nil { if err := s.replaceCodes(p.ID, p.Codes); err != nil {
return models.Product{}, err return models.Product{}, err
} }
p.Quantity = len(p.Codes) p.Quantity = len(p.Codes)
return p, nil return p, nil
} }
func (s *ProductStore) Update(id string, patch models.Product) (models.Product, error) { func (s *ProductStore) Update(id string, patch models.Product) (models.Product, error) {
var row database.ProductRow var row database.ProductRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil { if err := s.db.First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, fmt.Errorf("product not found") return models.Product{}, fmt.Errorf("product not found")
} }
normalized := normalizeProduct(patch) normalized := normalizeProduct(patch)
if err := s.db.Model(&row).Updates(map[string]interface{}{ if err := s.db.Model(&row).Updates(map[string]interface{}{
"name": normalized.Name, "name": normalized.Name,
"price": normalized.Price, "price": normalized.Price,
"discount_price": normalized.DiscountPrice, "discount_price": normalized.DiscountPrice,
"tags": database.StringSlice(normalized.Tags), "tags": database.StringSlice(normalized.Tags),
"cover_url": normalized.CoverURL, "cover_url": normalized.CoverURL,
"screenshot_urls": database.StringSlice(normalized.ScreenshotURLs), "screenshot_urls": database.StringSlice(normalized.ScreenshotURLs),
"verification_url": normalized.VerificationURL, "verification_url": normalized.VerificationURL,
"description": normalized.Description, "description": normalized.Description,
"active": normalized.Active, "active": normalized.Active,
"require_login": normalized.RequireLogin, "require_login": normalized.RequireLogin,
"max_per_account": normalized.MaxPerAccount, "max_per_account": normalized.MaxPerAccount,
"delivery_mode": normalized.DeliveryMode, "delivery_mode": normalized.DeliveryMode,
"show_note": normalized.ShowNote, "show_note": normalized.ShowNote,
"show_contact": normalized.ShowContact, "show_contact": normalized.ShowContact,
}).Error; err != nil { }).Error; err != nil {
return models.Product{}, err return models.Product{}, err
} }
if err := s.replaceCodes(id, normalized.Codes); err != nil { if err := s.replaceCodes(id, normalized.Codes); err != nil {
return models.Product{}, err return models.Product{}, err
} }
var updated database.ProductRow var updated database.ProductRow
s.db.First(&updated, "id = ?", id) s.db.First(&updated, "id = ?", id)
codes, _ := s.loadCodes(id) codes, _ := s.loadCodes(id)
return rowToModel(updated, codes), nil return rowToModel(updated, codes), nil
} }
func (s *ProductStore) Toggle(id string, active bool) (models.Product, error) { func (s *ProductStore) Toggle(id string, active bool) (models.Product, error) {
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).Update("active", active).Error; err != nil { if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).Update("active", active).Error; err != nil {
return models.Product{}, err return models.Product{}, err
} }
var row database.ProductRow var row database.ProductRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil { if err := s.db.First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, fmt.Errorf("product not found") return models.Product{}, fmt.Errorf("product not found")
} }
codes, _ := s.loadCodes(id) codes, _ := s.loadCodes(id)
return rowToModel(row, codes), nil return rowToModel(row, codes), nil
} }
func (s *ProductStore) IncrementSold(id string, count int) error { func (s *ProductStore) IncrementSold(id string, count int) error {
return s.db.Model(&database.ProductRow{}).Where("id = ?", id). return s.db.Model(&database.ProductRow{}).Where("id = ?", id).
UpdateColumn("total_sold", gorm.Expr("total_sold + ?", count)).Error UpdateColumn("total_sold", gorm.Expr("total_sold + ?", count)).Error
} }
func (s *ProductStore) IncrementView(id, fingerprint string) (models.Product, bool, error) { func (s *ProductStore) IncrementView(id, fingerprint string) (models.Product, bool, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
now := time.Now() now := time.Now()
s.cleanupRecentViews(now) s.cleanupRecentViews(now)
key := buildViewKey(id, fingerprint) key := buildViewKey(id, fingerprint)
if lastViewedAt, ok := s.recentViews[key]; ok && now.Sub(lastViewedAt) < viewCooldown { if lastViewedAt, ok := s.recentViews[key]; ok && now.Sub(lastViewedAt) < viewCooldown {
var row database.ProductRow var row database.ProductRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil { if err := s.db.First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, false, fmt.Errorf("product not found") return models.Product{}, false, fmt.Errorf("product not found")
} }
return rowToModel(row, nil), false, nil return rowToModel(row, nil), false, nil
} }
if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id). if err := s.db.Model(&database.ProductRow{}).Where("id = ?", id).
UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error; err != nil { UpdateColumn("view_count", gorm.Expr("view_count + 1")).Error; err != nil {
return models.Product{}, false, err return models.Product{}, false, err
} }
s.recentViews[key] = now s.recentViews[key] = now
var row database.ProductRow var row database.ProductRow
if err := s.db.First(&row, "id = ?", id).Error; err != nil { if err := s.db.First(&row, "id = ?", id).Error; err != nil {
return models.Product{}, false, fmt.Errorf("product not found") return models.Product{}, false, fmt.Errorf("product not found")
} }
return rowToModel(row, nil), true, nil return rowToModel(row, nil), true, nil
} }
func (s *ProductStore) Delete(id string) error { func (s *ProductStore) Delete(id string) error {
if err := s.db.Where("product_id = ?", id).Delete(&database.ProductCodeRow{}).Error; err != nil { if err := s.db.Where("product_id = ?", id).Delete(&database.ProductCodeRow{}).Error; err != nil {
return err return err
} }
return s.db.Delete(&database.ProductRow{}, "id = ?", id).Error return s.db.Delete(&database.ProductRow{}, "id = ?", id).Error
} }
// normalizeProduct 对商品字段进行规范化处理(清理空白、设默认值等)。 // normalizeProduct 对商品字段进行规范化处理(清理空白、设默认值等)。
func normalizeProduct(item models.Product) models.Product { func normalizeProduct(item models.Product) models.Product {
item.CoverURL = strings.TrimSpace(item.CoverURL) item.CoverURL = strings.TrimSpace(item.CoverURL)
if item.CoverURL == "" { if item.CoverURL == "" {
item.CoverURL = defaultCoverURL item.CoverURL = defaultCoverURL
} }
if item.Tags == nil { if item.Tags == nil {
item.Tags = []string{} item.Tags = []string{}
} }
item.Tags = sanitizeTags(item.Tags) item.Tags = sanitizeTags(item.Tags)
if item.ScreenshotURLs == nil { if item.ScreenshotURLs == nil {
item.ScreenshotURLs = []string{} item.ScreenshotURLs = []string{}
} }
if len(item.ScreenshotURLs) > maxScreenshotURLs { if len(item.ScreenshotURLs) > maxScreenshotURLs {
item.ScreenshotURLs = item.ScreenshotURLs[:maxScreenshotURLs] item.ScreenshotURLs = item.ScreenshotURLs[:maxScreenshotURLs]
} }
if item.Codes == nil { if item.Codes == nil {
item.Codes = []string{} item.Codes = []string{}
} }
if item.DiscountPrice <= 0 || item.DiscountPrice >= item.Price { if item.DiscountPrice <= 0 || item.DiscountPrice >= item.Price {
item.DiscountPrice = 0 item.DiscountPrice = 0
} }
item.VerificationURL = strings.TrimSpace(item.VerificationURL) item.VerificationURL = strings.TrimSpace(item.VerificationURL)
item.Codes = sanitizeCodes(item.Codes) item.Codes = sanitizeCodes(item.Codes)
item.Quantity = len(item.Codes) item.Quantity = len(item.Codes)
if item.DeliveryMode == "" { if item.DeliveryMode == "" {
item.DeliveryMode = "auto" item.DeliveryMode = "auto"
} }
return item return item
} }
func sanitizeCodes(codes []string) []string { func sanitizeCodes(codes []string) []string {
clean := make([]string, 0, len(codes)) clean := make([]string, 0, len(codes))
seen := map[string]bool{} seen := map[string]bool{}
for _, code := range codes { for _, code := range codes {
trimmed := strings.TrimSpace(code) trimmed := strings.TrimSpace(code)
if trimmed == "" || seen[trimmed] { if trimmed == "" || seen[trimmed] {
continue continue
} }
seen[trimmed] = true seen[trimmed] = true
clean = append(clean, trimmed) clean = append(clean, trimmed)
} }
return clean return clean
} }
func sanitizeTags(tags []string) []string { func sanitizeTags(tags []string) []string {
clean := make([]string, 0, len(tags)) clean := make([]string, 0, len(tags))
seen := map[string]bool{} seen := map[string]bool{}
for _, tag := range tags { for _, tag := range tags {
t := strings.TrimSpace(tag) t := strings.TrimSpace(tag)
if t == "" { if t == "" {
continue continue
} }
key := strings.ToLower(t) key := strings.ToLower(t)
if seen[key] { if seen[key] {
continue continue
} }
seen[key] = true seen[key] = true
clean = append(clean, t) clean = append(clean, t)
if len(clean) >= 20 { if len(clean) >= 20 {
break break
} }
} }
return clean return clean
} }
func buildViewKey(id, fingerprint string) string { func buildViewKey(id, fingerprint string) string {
sum := sha256.Sum256([]byte(id + "|" + fingerprint)) sum := sha256.Sum256([]byte(id + "|" + fingerprint))
return fmt.Sprintf("%x", sum) return fmt.Sprintf("%x", sum)
} }
func (s *ProductStore) cleanupRecentViews(now time.Time) { func (s *ProductStore) cleanupRecentViews(now time.Time) {
for key, lastViewedAt := range s.recentViews { for key, lastViewedAt := range s.recentViews {
if now.Sub(lastViewedAt) >= viewCooldown { if now.Sub(lastViewedAt) >= viewCooldown {
delete(s.recentViews, key) delete(s.recentViews, key)
} }
} }
} }

View File

@@ -1,146 +1,146 @@
package storage package storage
import ( import (
"strconv" "strconv"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"mengyastore-backend/internal/database" "mengyastore-backend/internal/database"
) )
type SiteStore struct { type SiteStore struct {
db *gorm.DB db *gorm.DB
} }
func NewSiteStore(db *gorm.DB) (*SiteStore, error) { func NewSiteStore(db *gorm.DB) (*SiteStore, error) {
return &SiteStore{db: db}, nil return &SiteStore{db: db}, nil
} }
func (s *SiteStore) get(key string) (string, error) { func (s *SiteStore) get(key string) (string, error) {
var row database.SiteSettingRow var row database.SiteSettingRow
// `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。 // `key` 是 MySQL 保留字,需用反引号转义以避免 SQL 语法错误。
if err := s.db.Where("`key` = ?", key).First(&row).Error; err != nil { if err := s.db.Where("`key` = ?", key).First(&row).Error; err != nil {
return "", nil // 键不存在时返回零值 return "", nil // 键不存在时返回零值
} }
return row.Value, nil return row.Value, nil
} }
func (s *SiteStore) set(key, value string) error { func (s *SiteStore) set(key, value string) error {
return s.db.Clauses(clause.OnConflict{ return s.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}}, Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}), DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).Create(&database.SiteSettingRow{Key: key, Value: value}).Error }).Create(&database.SiteSettingRow{Key: key, Value: value}).Error
} }
func (s *SiteStore) GetTotalVisits() (int, error) { func (s *SiteStore) GetTotalVisits() (int, error) {
v, err := s.get("totalVisits") v, err := s.get("totalVisits")
if err != nil || v == "" { if err != nil || v == "" {
return 0, err return 0, err
} }
n, _ := strconv.Atoi(v) n, _ := strconv.Atoi(v)
return n, nil return n, nil
} }
func (s *SiteStore) IncrementVisits() (int, error) { func (s *SiteStore) IncrementVisits() (int, error) {
current, err := s.GetTotalVisits() current, err := s.GetTotalVisits()
if err != nil { if err != nil {
return 0, err return 0, err
} }
current++ current++
if err := s.set("totalVisits", strconv.Itoa(current)); err != nil { if err := s.set("totalVisits", strconv.Itoa(current)); err != nil {
return 0, err return 0, err
} }
return current, nil return current, nil
} }
func (s *SiteStore) GetMaintenance() (enabled bool, reason string, err error) { func (s *SiteStore) GetMaintenance() (enabled bool, reason string, err error) {
v, err := s.get("maintenance") v, err := s.get("maintenance")
if err != nil { if err != nil {
return false, "", err return false, "", err
} }
enabled = v == "true" enabled = v == "true"
reason, err = s.get("maintenanceReason") reason, err = s.get("maintenanceReason")
return enabled, reason, err return enabled, reason, err
} }
func (s *SiteStore) SetMaintenance(enabled bool, reason string) error { func (s *SiteStore) SetMaintenance(enabled bool, reason string) error {
v := "false" v := "false"
if enabled { if enabled {
v = "true" v = "true"
} }
if err := s.set("maintenance", v); err != nil { if err := s.set("maintenance", v); err != nil {
return err return err
} }
return s.set("maintenanceReason", reason) return s.set("maintenanceReason", reason)
} }
// RecordVisit 递增访问计数,返回 (总访问量, 是否计入, 错误)。 // RecordVisit 递增访问计数,返回 (总访问量, 是否计入, 错误)。
// 去重逻辑由上层 handler 的内存指纹完成,此处无条件累加。 // 去重逻辑由上层 handler 的内存指纹完成,此处无条件累加。
func (s *SiteStore) RecordVisit(_ string) (int, bool, error) { func (s *SiteStore) RecordVisit(_ string) (int, bool, error) {
total, err := s.IncrementVisits() total, err := s.IncrementVisits()
return total, true, err return total, true, err
} }
// SMTPConfig 存储数据库中的发件 SMTP 配置。 // SMTPConfig 存储数据库中的发件 SMTP 配置。
type SMTPConfig struct { type SMTPConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
FromName string `json:"fromName"` FromName string `json:"fromName"`
Host string `json:"host"` Host string `json:"host"`
Port string `json:"port"` Port string `json:"port"`
} }
// IsConfiguredEmail 判断邮件通知是否已启用且 SMTP 配置完整。 // IsConfiguredEmail 判断邮件通知是否已启用且 SMTP 配置完整。
func (c SMTPConfig) IsConfiguredEmail() bool { func (c SMTPConfig) IsConfiguredEmail() bool {
return c.Enabled && c.Email != "" && c.Password != "" && c.Host != "" return c.Enabled && c.Email != "" && c.Password != "" && c.Host != ""
} }
func (s *SiteStore) GetSMTPConfig() (SMTPConfig, error) { func (s *SiteStore) GetSMTPConfig() (SMTPConfig, error) {
cfg := SMTPConfig{ cfg := SMTPConfig{
Enabled: true, // 默认启用 Enabled: true, // 默认启用
Host: "smtp.qq.com", Host: "smtp.qq.com",
Port: "465", Port: "465",
} }
if v, _ := s.get("smtpEnabled"); v == "false" { if v, _ := s.get("smtpEnabled"); v == "false" {
cfg.Enabled = false cfg.Enabled = false
} }
if v, _ := s.get("smtpEmail"); v != "" { if v, _ := s.get("smtpEmail"); v != "" {
cfg.Email = v cfg.Email = v
} }
if v, _ := s.get("smtpPassword"); v != "" { if v, _ := s.get("smtpPassword"); v != "" {
cfg.Password = v cfg.Password = v
} }
if v, _ := s.get("smtpFromName"); v != "" { if v, _ := s.get("smtpFromName"); v != "" {
cfg.FromName = v cfg.FromName = v
} }
if v, _ := s.get("smtpHost"); v != "" { if v, _ := s.get("smtpHost"); v != "" {
cfg.Host = v cfg.Host = v
} }
if v, _ := s.get("smtpPort"); v != "" { if v, _ := s.get("smtpPort"); v != "" {
cfg.Port = v cfg.Port = v
} }
return cfg, nil return cfg, nil
} }
func (s *SiteStore) SetSMTPConfig(cfg SMTPConfig) error { func (s *SiteStore) SetSMTPConfig(cfg SMTPConfig) error {
enabledVal := "true" enabledVal := "true"
if !cfg.Enabled { if !cfg.Enabled {
enabledVal = "false" enabledVal = "false"
} }
pairs := [][2]string{ pairs := [][2]string{
{"smtpEnabled", enabledVal}, {"smtpEnabled", enabledVal},
{"smtpEmail", cfg.Email}, {"smtpEmail", cfg.Email},
{"smtpPassword", cfg.Password}, {"smtpPassword", cfg.Password},
{"smtpFromName", cfg.FromName}, {"smtpFromName", cfg.FromName},
{"smtpHost", cfg.Host}, {"smtpHost", cfg.Host},
{"smtpPort", cfg.Port}, {"smtpPort", cfg.Port},
} }
for _, p := range pairs { for _, p := range pairs {
if err := s.set(p[0], p[1]); err != nil { if err := s.set(p[0], p[1]); err != nil {
return err return err
} }
} }
return nil return nil
} }

View File

@@ -1,38 +1,38 @@
package storage package storage
import ( import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"mengyastore-backend/internal/database" "mengyastore-backend/internal/database"
) )
type WishlistStore struct { type WishlistStore struct {
db *gorm.DB db *gorm.DB
} }
func NewWishlistStore(db *gorm.DB) (*WishlistStore, error) { func NewWishlistStore(db *gorm.DB) (*WishlistStore, error) {
return &WishlistStore{db: db}, nil return &WishlistStore{db: db}, nil
} }
func (s *WishlistStore) Get(accountID string) ([]string, error) { func (s *WishlistStore) Get(accountID string) ([]string, error) {
var rows []database.WishlistRow var rows []database.WishlistRow
if err := s.db.Where("account_id = ?", accountID).Find(&rows).Error; err != nil { if err := s.db.Where("account_id = ?", accountID).Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
ids := make([]string, len(rows)) ids := make([]string, len(rows))
for i, r := range rows { for i, r := range rows {
ids[i] = r.ProductID ids[i] = r.ProductID
} }
return ids, nil return ids, nil
} }
func (s *WishlistStore) Add(accountID, productID string) error { func (s *WishlistStore) Add(accountID, productID string) error {
return s.db.Clauses(clause.OnConflict{DoNothing: true}). return s.db.Clauses(clause.OnConflict{DoNothing: true}).
Create(&database.WishlistRow{AccountID: accountID, ProductID: productID}).Error Create(&database.WishlistRow{AccountID: accountID, ProductID: productID}).Error
} }
func (s *WishlistStore) Remove(accountID, productID string) error { func (s *WishlistStore) Remove(accountID, productID string) error {
return s.db.Where("account_id = ? AND product_id = ?", accountID, productID). return s.db.Where("account_id = ? AND product_id = ?", accountID, productID).
Delete(&database.WishlistRow{}).Error Delete(&database.WishlistRow{}).Error
} }

View File

@@ -1,114 +1,114 @@
package main package main
import ( import (
"log" "log"
"net/http" "net/http"
"time" "time"
"github.com/gin-contrib/cors" "github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"mengyastore-backend/internal/auth" "mengyastore-backend/internal/auth"
"mengyastore-backend/internal/config" "mengyastore-backend/internal/config"
"mengyastore-backend/internal/database" "mengyastore-backend/internal/database"
"mengyastore-backend/internal/handlers" "mengyastore-backend/internal/handlers"
"mengyastore-backend/internal/storage" "mengyastore-backend/internal/storage"
) )
func main() { func main() {
cfg, err := config.Load("config.json") cfg, err := config.Load("config.json")
if err != nil { if err != nil {
log.Fatalf("加载配置失败: %v", err) log.Fatalf("加载配置失败: %v", err)
} }
// 初始化数据库连接 // 初始化数据库连接
db, err := database.Open(cfg.DatabaseDSN) db, err := database.Open(cfg.DatabaseDSN)
if err != nil { if err != nil {
log.Fatalf("初始化数据库失败: %v", err) log.Fatalf("初始化数据库失败: %v", err)
} }
store, err := storage.NewProductStore(db) store, err := storage.NewProductStore(db)
if err != nil { if err != nil {
log.Fatalf("初始化商品存储失败: %v", err) log.Fatalf("初始化商品存储失败: %v", err)
} }
orderStore, err := storage.NewOrderStore(db) orderStore, err := storage.NewOrderStore(db)
if err != nil { if err != nil {
log.Fatalf("初始化订单存储失败: %v", err) log.Fatalf("初始化订单存储失败: %v", err)
} }
siteStore, err := storage.NewSiteStore(db) siteStore, err := storage.NewSiteStore(db)
if err != nil { if err != nil {
log.Fatalf("初始化站点存储失败: %v", err) log.Fatalf("初始化站点存储失败: %v", err)
} }
wishlistStore, err := storage.NewWishlistStore(db) wishlistStore, err := storage.NewWishlistStore(db)
if err != nil { if err != nil {
log.Fatalf("初始化收藏夹存储失败: %v", err) log.Fatalf("初始化收藏夹存储失败: %v", err)
} }
chatStore, err := storage.NewChatStore(db) chatStore, err := storage.NewChatStore(db)
if err != nil { if err != nil {
log.Fatalf("初始化聊天存储失败: %v", err) log.Fatalf("初始化聊天存储失败: %v", err)
} }
r := gin.Default() r := gin.Default()
r.Use(cors.New(cors.Config{ r.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"}, AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Admin-Token"}, AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Admin-Token"},
ExposeHeaders: []string{"Content-Length"}, ExposeHeaders: []string{"Content-Length"},
AllowCredentials: false, AllowCredentials: false,
MaxAge: 12 * time.Hour, MaxAge: 12 * time.Hour,
})) }))
r.GET("/api/health", func(c *gin.Context) { r.GET("/api/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
}) })
authClient := auth.NewSproutGateClient(cfg.AuthAPIURL) authClient := auth.NewSproutGateClient(cfg.AuthAPIURL)
publicHandler := handlers.NewPublicHandler(store) publicHandler := handlers.NewPublicHandler(store)
adminHandler := handlers.NewAdminHandler(store, cfg, siteStore, orderStore, chatStore) adminHandler := handlers.NewAdminHandler(store, cfg, siteStore, orderStore, chatStore)
orderHandler := handlers.NewOrderHandler(store, orderStore, siteStore, authClient) orderHandler := handlers.NewOrderHandler(store, orderStore, siteStore, authClient)
statsHandler := handlers.NewStatsHandler(orderStore, siteStore) statsHandler := handlers.NewStatsHandler(orderStore, siteStore)
wishlistHandler := handlers.NewWishlistHandler(wishlistStore, authClient) wishlistHandler := handlers.NewWishlistHandler(wishlistStore, authClient)
chatHandler := handlers.NewChatHandler(chatStore, authClient) chatHandler := handlers.NewChatHandler(chatStore, authClient)
r.GET("/api/products", publicHandler.ListProducts) r.GET("/api/products", publicHandler.ListProducts)
r.POST("/api/checkout", orderHandler.CreateOrder) r.POST("/api/checkout", orderHandler.CreateOrder)
r.POST("/api/products/:id/view", publicHandler.RecordProductView) r.POST("/api/products/:id/view", publicHandler.RecordProductView)
r.GET("/api/stats", statsHandler.GetStats) r.GET("/api/stats", statsHandler.GetStats)
r.POST("/api/site/visit", statsHandler.RecordVisit) r.POST("/api/site/visit", statsHandler.RecordVisit)
r.GET("/api/site/maintenance", statsHandler.GetMaintenance) r.GET("/api/site/maintenance", statsHandler.GetMaintenance)
r.GET("/api/orders", orderHandler.ListMyOrders) r.GET("/api/orders", orderHandler.ListMyOrders)
r.POST("/api/orders/:id/confirm", orderHandler.ConfirmOrder) r.POST("/api/orders/:id/confirm", orderHandler.ConfirmOrder)
r.POST("/api/admin/verify", adminHandler.VerifyAdminToken) r.POST("/api/admin/verify", adminHandler.VerifyAdminToken)
r.GET("/api/admin/products", adminHandler.ListAllProducts) r.GET("/api/admin/products", adminHandler.ListAllProducts)
r.POST("/api/admin/products", adminHandler.CreateProduct) r.POST("/api/admin/products", adminHandler.CreateProduct)
r.PUT("/api/admin/products/:id", adminHandler.UpdateProduct) r.PUT("/api/admin/products/:id", adminHandler.UpdateProduct)
r.PATCH("/api/admin/products/:id/status", adminHandler.ToggleProduct) r.PATCH("/api/admin/products/:id/status", adminHandler.ToggleProduct)
r.DELETE("/api/admin/products/:id", adminHandler.DeleteProduct) r.DELETE("/api/admin/products/:id", adminHandler.DeleteProduct)
r.POST("/api/admin/site/maintenance", adminHandler.SetMaintenance) r.POST("/api/admin/site/maintenance", adminHandler.SetMaintenance)
r.GET("/api/admin/site/smtp", adminHandler.GetSMTPConfig) r.GET("/api/admin/site/smtp", adminHandler.GetSMTPConfig)
r.POST("/api/admin/site/smtp", adminHandler.SetSMTPConfig) r.POST("/api/admin/site/smtp", adminHandler.SetSMTPConfig)
r.GET("/api/admin/orders", adminHandler.ListAllOrders) r.GET("/api/admin/orders", adminHandler.ListAllOrders)
r.DELETE("/api/admin/orders/:id", adminHandler.DeleteOrder) r.DELETE("/api/admin/orders/:id", adminHandler.DeleteOrder)
r.GET("/api/wishlist", wishlistHandler.GetWishlist) r.GET("/api/wishlist", wishlistHandler.GetWishlist)
r.POST("/api/wishlist", wishlistHandler.AddToWishlist) r.POST("/api/wishlist", wishlistHandler.AddToWishlist)
r.DELETE("/api/wishlist/:id", wishlistHandler.RemoveFromWishlist) r.DELETE("/api/wishlist/:id", wishlistHandler.RemoveFromWishlist)
// 用户聊天路由 // 用户聊天路由
r.GET("/api/chat/messages", chatHandler.GetMyMessages) r.GET("/api/chat/messages", chatHandler.GetMyMessages)
r.POST("/api/chat/messages", chatHandler.SendMyMessage) r.POST("/api/chat/messages", chatHandler.SendMyMessage)
// 管理员聊天路由 // 管理员聊天路由
r.GET("/api/admin/chat", adminHandler.GetAllConversations) r.GET("/api/admin/chat", adminHandler.GetAllConversations)
r.GET("/api/admin/chat/:account", adminHandler.GetConversation) r.GET("/api/admin/chat/:account", adminHandler.GetConversation)
r.POST("/api/admin/chat/:account", adminHandler.AdminReply) r.POST("/api/admin/chat/:account", adminHandler.AdminReply)
r.DELETE("/api/admin/chat/:account", adminHandler.ClearConversation) r.DELETE("/api/admin/chat/:account", adminHandler.ClearConversation)
log.Println("萌芽小店后端启动于 http://localhost:8080") log.Println("萌芽小店后端启动于 http://localhost:8080")
if err := r.Run(":8080"); err != nil { if err := r.Run(":8080"); err != nil {
log.Fatalf("服务器启动失败: %v", err) log.Fatalf("服务器启动失败: %v", err)
} }
} }

View File

@@ -1,167 +1,167 @@
# 萌芽小店 · 前端 # 萌芽小店 · 前端
基于 **Vue 3 + Vite** 构建的数字商品销售平台前端,采用组件化模块架构。 基于 **Vue 3 + Vite** 构建的数字商品销售平台前端,采用组件化模块架构。
## 技术依赖 ## 技术依赖
| 包 | 版本 | 用途 | | 包 | 版本 | 用途 |
|----|------|------| |----|------|------|
| vue | ^3.x | 核心框架 | | vue | ^3.x | 核心框架 |
| vite | ^5.x | 构建工具 | | vite | ^5.x | 构建工具 |
| vue-router | ^4.x | 路由管理 | | vue-router | ^4.x | 路由管理 |
| pinia | ^2.x | 状态管理 | | pinia | ^2.x | 状态管理 |
| axios | ^1.6 | HTTP 请求 | | axios | ^1.6 | HTTP 请求 |
| markdown-it | ^14 | Markdown 渲染 | | markdown-it | ^14 | Markdown 渲染 |
## 目录结构 ## 目录结构
``` ```
src/ src/
├── assets/ ├── assets/
│ └── styles.css # 全局 CSS 变量与公共样式 │ └── styles.css # 全局 CSS 变量与公共样式
├── router/ ├── router/
│ └── index.js # 路由配置(含维护模式守卫) │ └── index.js # 路由配置(含维护模式守卫)
├── modules/ ├── modules/
│ ├── shared/ │ ├── shared/
│ │ ├── api.js # 所有 API 请求封装 │ │ ├── api.js # 所有 API 请求封装
│ │ ├── auth.js # 认证状态Pinia store │ │ ├── auth.js # 认证状态Pinia store
│ │ └── useWishlist.js # 收藏夹 Composable │ │ └── useWishlist.js # 收藏夹 Composable
│ ├── store/ │ ├── store/
│ │ ├── StorePage.vue # 商品列表页 │ │ ├── StorePage.vue # 商品列表页
│ │ ├── ProductDetail.vue # 商品详情页 │ │ ├── ProductDetail.vue # 商品详情页
│ │ ├── CheckoutPage.vue # 结算页 │ │ ├── CheckoutPage.vue # 结算页
│ │ └── components/ │ │ └── components/
│ │ └── ProductCard.vue # 商品卡片组件 │ │ └── ProductCard.vue # 商品卡片组件
│ ├── admin/ │ ├── admin/
│ │ ├── AdminPage.vue # 管理后台(编排层) │ │ ├── AdminPage.vue # 管理后台(编排层)
│ │ └── components/ │ │ └── components/
│ │ ├── AdminTokenRow.vue # 令牌输入 │ │ ├── AdminTokenRow.vue # 令牌输入
│ │ ├── AdminMaintenanceRow.vue # 维护模式开关 │ │ ├── AdminMaintenanceRow.vue # 维护模式开关
│ │ ├── AdminProductTable.vue # 商品列表表格 │ │ ├── AdminProductTable.vue # 商品列表表格
│ │ ├── AdminProductModal.vue # 商品编辑弹窗 │ │ ├── AdminProductModal.vue # 商品编辑弹窗
│ │ ├── AdminOrderTable.vue # 订单记录表格 │ │ ├── AdminOrderTable.vue # 订单记录表格
│ │ └── AdminChatPanel.vue # 用户聊天管理 │ │ └── AdminChatPanel.vue # 用户聊天管理
│ ├── user/ │ ├── user/
│ │ └── MyOrdersPage.vue # 我的订单 │ │ └── MyOrdersPage.vue # 我的订单
│ ├── wishlist/ │ ├── wishlist/
│ │ └── WishlistPage.vue # 收藏夹页面 │ │ └── WishlistPage.vue # 收藏夹页面
│ ├── maintenance/ │ ├── maintenance/
│ │ └── MaintenancePage.vue # 站点维护页面 │ │ └── MaintenancePage.vue # 站点维护页面
│ └── chat/ │ └── chat/
│ └── ChatWidget.vue # 用户悬浮聊天窗口 │ └── ChatWidget.vue # 用户悬浮聊天窗口
└── App.vue # 根组件(全局布局 + 导航) └── App.vue # 根组件(全局布局 + 导航)
``` ```
## 路由列表 ## 路由列表
| 路径 | 组件 | 说明 | | 路径 | 组件 | 说明 |
|------|------|------| |------|------|------|
| `/` | `StorePage` | 商品列表 | | `/` | `StorePage` | 商品列表 |
| `/product/:id` | `ProductDetail` | 商品详情 | | `/product/:id` | `ProductDetail` | 商品详情 |
| `/product/:id/checkout` | `CheckoutPage` | 结算页 | | `/product/:id/checkout` | `CheckoutPage` | 结算页 |
| `/my/orders` | `MyOrdersPage` | 我的订单(需登录)| | `/my/orders` | `MyOrdersPage` | 我的订单(需登录)|
| `/wishlist` | `WishlistPage` | 收藏夹(需登录)| | `/wishlist` | `WishlistPage` | 收藏夹(需登录)|
| `/admin` | `AdminPage` | 管理后台(需令牌)| | `/admin` | `AdminPage` | 管理后台(需令牌)|
| `/maintenance` | `MaintenancePage` | 维护中页面 | | `/maintenance` | `MaintenancePage` | 维护中页面 |
### 路由守卫 ### 路由守卫
- **维护模式**`beforeEach` 检查 `GET /api/site/maintenance`,若站点维护中则重定向到 `/maintenance`(管理员访问 `/admin` 时豁免) - **维护模式**`beforeEach` 检查 `GET /api/site/maintenance`,若站点维护中则重定向到 `/maintenance`(管理员访问 `/admin` 时豁免)
- 豁免路径:`/maintenance``/wishlist``/admin` - 豁免路径:`/maintenance``/wishlist``/admin`
## 认证流程 ## 认证流程
使用 SproutGate OAuth 服务: 使用 SproutGate OAuth 服务:
1. 未登录用户点击「萌芽账号登录」,跳转到 `https://auth.shumengya.top/login?callback=<当前页>` 1. 未登录用户点击「萌芽账号登录」,跳转到 `https://auth.shumengya.top/login?callback=<当前页>`
2. 登录成功后回调携带 token存入 localStorage 2. 登录成功后回调携带 token存入 localStorage
3. `authState`reactive 对象)全局共享 `token``account``username``avatarUrl` 3. `authState`reactive 对象)全局共享 `token``account``username``avatarUrl`
4. 所有需要认证的 API 请求在 `Authorization: Bearer <token>` 头部携带 token 4. 所有需要认证的 API 请求在 `Authorization: Bearer <token>` 头部携带 token
## 主要功能模块 ## 主要功能模块
### 商品列表StorePage ### 商品列表StorePage
- 视图模式(`viewMode`):全部 / 免费 / 新上架 / 最多购买 / 最多浏览 / 价格最高 / 价格最低 - 视图模式(`viewMode`):全部 / 免费 / 新上架 / 最多购买 / 最多浏览 / 价格最高 / 价格最低
- 搜索:实时过滤商品名称 - 搜索:实时过滤商品名称
- 分页:桌面 20 条/页4×5手机 10 条/页5×2 - 分页:桌面 20 条/页4×5手机 10 条/页5×2
- 响应式布局:`window.innerWidth <= 900` 切换为双列 - 响应式布局:`window.innerWidth <= 900` 切换为双列
### 结算页CheckoutPage ### 结算页CheckoutPage
- 展示商品信息与价格 - 展示商品信息与价格
- 数量输入,最大值受 `product.maxPerAccount` 限制 - 数量输入,最大值受 `product.maxPerAccount` 限制
- 若商品开启 `showNote`:展示备注文本框 - 若商品开启 `showNote`:展示备注文本框
- 若商品开启 `showContact`:展示手机号和邮箱输入框 - 若商品开启 `showContact`:展示手机号和邮箱输入框
- 手动发货商品:展示蓝色提示条,确认后显示"等待发货中" - 手动发货商品:展示蓝色提示条,确认后显示"等待发货中"
- 自动发货商品:确认后展示卡密内容 - 自动发货商品:确认后展示卡密内容
### 收藏夹useWishlist Composable ### 收藏夹useWishlist Composable
```js ```js
import { wishlistIds, wishlistCount, inWishlist, loadWishlist, toggleWishlist } from './useWishlist' import { wishlistIds, wishlistCount, inWishlist, loadWishlist, toggleWishlist } from './useWishlist'
``` ```
- 数据存储在后端,通过 API 同步 - 数据存储在后端,通过 API 同步
- `App.vue` 在登录状态变化时自动调用 `loadWishlist()` - `App.vue` 在登录状态变化时自动调用 `loadWishlist()`
- 收藏状态通过 `wishlistSet`computed Set提供 O(1) 查找 - 收藏状态通过 `wishlistSet`computed Set提供 O(1) 查找
### 客服聊天ChatWidget ### 客服聊天ChatWidget
- 仅已登录用户显示(右下角悬浮按钮) - 仅已登录用户显示(右下角悬浮按钮)
- 每 5 秒轮询新消息 - 每 5 秒轮询新消息
- 客户端和服务端均限制每秒最多发送 1 条消息 - 客户端和服务端均限制每秒最多发送 1 条消息
## 开发启动 ## 开发启动
```bash ```bash
npm install npm install
npm run dev # 开发服务器 :5173 npm run dev # 开发服务器 :5173
npm run build # 生产构建 → dist/ npm run build # 生产构建 → dist/
npm run preview # 预览构建结果 npm run preview # 预览构建结果
``` ```
### 环境变量 ### 环境变量
在项目根目录创建 `.env.local` 在项目根目录创建 `.env.local`
```env ```env
VITE_API_BASE_URL=http://localhost:8080 VITE_API_BASE_URL=http://localhost:8080
``` ```
生产部署时设置实际 API 地址。 生产部署时设置实际 API 地址。
## 全局样式 ## 全局样式
`src/assets/styles.css` 定义了全局 CSS 变量: `src/assets/styles.css` 定义了全局 CSS 变量:
```css ```css
:root { :root {
--accent: #91a8d0; /* 主题色 */ --accent: #91a8d0; /* 主题色 */
--accent-2: #b8c8e4; /* 渐变色 */ --accent-2: #b8c8e4; /* 渐变色 */
--text: #2c2c3a; /* 文字色 */ --text: #2c2c3a; /* 文字色 */
--muted: #8e8e9e; /* 次要文字 */ --muted: #8e8e9e; /* 次要文字 */
--line: rgba(0,0,0,0.08); /* 边框色 */ --line: rgba(0,0,0,0.08); /* 边框色 */
--radius: 8px; /* 圆角 */ --radius: 8px; /* 圆角 */
} }
``` ```
字体使用楷体KaiTi / STKaitifallback 到系统衬线字体。 字体使用楷体KaiTi / STKaitifallback 到系统衬线字体。
## 构建与部署 ## 构建与部署
```bash ```bash
npm run build npm run build
``` ```
产物在 `dist/` 目录,为静态文件,部署到 Nginx / CDN 时需配置: 产物在 `dist/` 目录,为静态文件,部署到 Nginx / CDN 时需配置:
```nginx ```nginx
location / { location / {
try_files $uri $uri/ /index.html; # SPA 路由支持 try_files $uri $uri/ /index.html; # SPA 路由支持
} }
location /api/ { location /api/ {
proxy_pass http://localhost:8080; # 反向代理到后端 proxy_pass http://localhost:8080; # 反向代理到后端
} }
``` ```

View File

@@ -1,28 +1,28 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>萌芽小店</title> <title>萌芽小店</title>
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" href="/favicon.ico" sizes="any" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" href="/pwa-192x192.png" type="image/png" /> <link rel="icon" href="/pwa-192x192.png" type="image/png" />
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
<!-- PWA / Theme --> <!-- PWA / Theme -->
<meta name="theme-color" content="#1a1a1a" /> <meta name="theme-color" content="#1a1a1a" />
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" /> <meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="萌芽小店" /> <meta name="apple-mobile-web-app-title" content="萌芽小店" />
<!-- SEO --> <!-- SEO -->
<meta name="description" content="萌芽小店 — 精选商品,一键购买" /> <meta name="description" content="萌芽小店 — 精选商品,一键购买" />
<meta name="keywords" content="萌芽小店,网上购物,精选商品" /> <meta name="keywords" content="萌芽小店,网上购物,精选商品" />
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,24 @@
{ {
"name": "mengyastore-frontend", "name": "mengyastore-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"axios": "^1.6.8", "axios": "^1.6.8",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.21", "vue": "^3.4.21",
"vue-router": "^4.3.0" "vue-router": "^4.3.0"
}, },
"devDependencies": { "devDependencies": {
"@vite-pwa/assets-generator": "^1.0.2", "@vite-pwa/assets-generator": "^1.0.2",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"vite": "^5.2.7", "vite": "^5.2.7",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0"
} }
} }

View File

@@ -1,18 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" fill="#1a1a1a"/> <rect width="512" height="512" rx="80" fill="#1a1a1a"/>
<!-- Sprouting seedling icon --> <!-- Sprouting seedling icon -->
<g transform="translate(256,280)"> <g transform="translate(256,280)">
<!-- Stem --> <!-- Stem -->
<line x1="0" y1="60" x2="0" y2="-20" stroke="#4ade80" stroke-width="18" stroke-linecap="round"/> <line x1="0" y1="60" x2="0" y2="-20" stroke="#4ade80" stroke-width="18" stroke-linecap="round"/>
<!-- Left leaf --> <!-- Left leaf -->
<path d="M0,10 Q-80,-30 -60,-100 Q-20,-50 0,10" fill="#4ade80" opacity="0.85"/> <path d="M0,10 Q-80,-30 -60,-100 Q-20,-50 0,10" fill="#4ade80" opacity="0.85"/>
<!-- Right leaf --> <!-- Right leaf -->
<path d="M0,10 Q80,-30 60,-100 Q20,-50 0,10" fill="#4ade80"/> <path d="M0,10 Q80,-30 60,-100 Q20,-50 0,10" fill="#4ade80"/>
<!-- Center leaf / bud --> <!-- Center leaf / bud -->
<path d="M0,-20 Q-20,-80 0,-120 Q20,-80 0,-20" fill="#86efac"/> <path d="M0,-20 Q-20,-80 0,-120 Q20,-80 0,-20" fill="#86efac"/>
<!-- Ground dots --> <!-- Ground dots -->
<circle cx="-50" cy="75" r="7" fill="#4ade80" opacity="0.4"/> <circle cx="-50" cy="75" r="7" fill="#4ade80" opacity="0.4"/>
<circle cx="50" cy="75" r="7" fill="#4ade80" opacity="0.4"/> <circle cx="50" cy="75" r="7" fill="#4ade80" opacity="0.4"/>
<circle cx="0" cy="80" r="7" fill="#4ade80" opacity="0.4"/> <circle cx="0" cy="80" r="7" fill="#4ade80" opacity="0.4"/>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 842 B

After

Width:  |  Height:  |  Size: 860 B

View File

@@ -1,226 +1,226 @@
<template> <template>
<div class="app"> <div class="app">
<!-- PWA Splash Screen --> <!-- PWA Splash Screen -->
<SplashScreen :visible="showSplash" /> <SplashScreen :visible="showSplash" />
<header class="top-bar"> <header class="top-bar">
<div class="brand" @click="onLogoClick"> <div class="brand" @click="onLogoClick">
<div class="brand-mark"> <div class="brand-mark">
<img src="/logo.png" alt="萌芽小店" draggable="false" /> <img src="/logo.png" alt="萌芽小店" draggable="false" />
</div> </div>
<div> <div>
<h1>萌芽小店</h1> <h1>萌芽小店</h1>
</div> </div>
</div> </div>
<div class="top-actions"> <div class="top-actions">
<button class="ghost" @click="goHome">商店</button> <button class="ghost" @click="goHome">商店</button>
<template v-if="loggedIn"> <template v-if="loggedIn">
<button class="ghost wishlist-nav" @click="goWishlist"> <button class="ghost wishlist-nav" @click="goWishlist">
<span class="wishlist-icon"></span> <span class="wishlist-icon"></span>
收藏夹 收藏夹
<span v-if="wishlistCount > 0" class="wishlist-badge">{{ wishlistCount }}</span> <span v-if="wishlistCount > 0" class="wishlist-badge">{{ wishlistCount }}</span>
</button> </button>
<button class="ghost" @click="goMyOrders">我的订单</button> <button class="ghost" @click="goMyOrders">我的订单</button>
<div class="user-badge" @click="goProfile"> <div class="user-badge" @click="goProfile">
<img <img
v-if="authState.avatarUrl" v-if="authState.avatarUrl"
class="user-avatar" class="user-avatar"
:src="authState.avatarUrl" :src="authState.avatarUrl"
:alt="authState.username" :alt="authState.username"
/> />
<div v-else class="user-avatar user-avatar-placeholder"> <div v-else class="user-avatar user-avatar-placeholder">
{{ (authState.username || authState.account || '?').charAt(0) }} {{ (authState.username || authState.account || '?').charAt(0) }}
</div> </div>
<span class="user-name">{{ authState.username || authState.account }}</span> <span class="user-name">{{ authState.username || authState.account }}</span>
</div> </div>
<button class="ghost" @click="logout">退出</button> <button class="ghost" @click="logout">退出</button>
</template> </template>
<a v-else class="login-btn" :href="loginUrl">萌芽账号登录</a> <a v-else class="login-btn" :href="loginUrl">萌芽账号登录</a>
</div> </div>
</header> </header>
<Teleport to="body"> <Teleport to="body">
<Transition name="modal"> <Transition name="modal">
<div v-if="showAdminModal" class="admin-modal-overlay" @click.self="showAdminModal = false"> <div v-if="showAdminModal" class="admin-modal-overlay" @click.self="showAdminModal = false">
<div class="admin-modal"> <div class="admin-modal">
<button class="admin-modal-close" @click="showAdminModal = false">&times;</button> <button class="admin-modal-close" @click="showAdminModal = false">&times;</button>
<div class="admin-modal-icon"> <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> <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> </div>
<h3>管理员验证</h3> <h3>管理员验证</h3>
<p class="admin-modal-hint">请输入管理员令牌以访问后台</p> <p class="admin-modal-hint">请输入管理员令牌以访问后台</p>
<form @submit.prevent="submitAdminToken"> <form @submit.prevent="submitAdminToken">
<input <input
ref="tokenInputRef" ref="tokenInputRef"
v-model="tokenInput" v-model="tokenInput"
type="password" type="password"
class="admin-modal-input" class="admin-modal-input"
placeholder="输入 Token…" placeholder="输入 Token…"
autocomplete="off" autocomplete="off"
/> />
<p v-if="tokenError" class="admin-modal-error">{{ tokenError }}</p> <p v-if="tokenError" class="admin-modal-error">{{ tokenError }}</p>
<button type="submit" class="primary admin-modal-btn" :disabled="!tokenInput.trim()"> <button type="submit" class="primary admin-modal-btn" :disabled="!tokenInput.trim()">
进入后台 进入后台
</button> </button>
</form> </form>
</div> </div>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
<div class="app-body"> <div class="app-body">
<main class="main-content"> <main class="main-content">
<router-view /> <router-view />
</main> </main>
<footer class="footer"> <footer class="footer">
<div class="footer-inner"> <div class="footer-inner">
<div class="footer-brand"> <div class="footer-brand">
<img src="/logo.png" alt="萌芽小店" class="footer-logo" /> <img src="/logo.png" alt="萌芽小店" class="footer-logo" />
<span class="footer-title">萌芽小店</span> <span class="footer-title">萌芽小店</span>
</div> </div>
<div class="footer-divider"></div> <div class="footer-divider"></div>
<div class="footer-links"> <div class="footer-links">
<a class="footer-mail" href="mailto:mail@smyhub.com"> <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> <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 mail@smyhub.com
</a> </a>
<span v-if="statsLoaded" class="footer-stat"> <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> <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 }} 累计下单 {{ stats.totalOrders }}
</span> </span>
<span v-if="statsLoaded" class="footer-stat footer-stat-visit"> <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> <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 }} 累计访问 {{ stats.totalVisits }}
</span> </span>
</div> </div>
<p class="footer-copy">© 2025 2026 萌芽小店 · All rights reserved</p> <p class="footer-copy">© 2025 2026 萌芽小店 · All rights reserved</p>
</div> </div>
</footer> </footer>
</div> </div>
<!-- Floating chat widget (visible to logged-in users only) --> <!-- Floating chat widget (visible to logged-in users only) -->
<ChatWidget <ChatWidget
v-if="loggedIn && authState.token" v-if="loggedIn && authState.token"
:user-token="authState.token" :user-token="authState.token"
:user-name="authState.username || authState.account" :user-name="authState.username || authState.account"
:user-avatar="authState.avatarUrl" :user-avatar="authState.avatarUrl"
/> />
<!-- PWA update toast --> <!-- PWA update toast -->
<Transition name="pwa-toast"> <Transition name="pwa-toast">
<div v-if="needRefresh" class="pwa-update-toast"> <div v-if="needRefresh" class="pwa-update-toast">
<span>发现新版本</span> <span>发现新版本</span>
<button @click="updateServiceWorker(true)">立即更新</button> <button @click="updateServiceWorker(true)">立即更新</button>
</div> </div>
</Transition> </Transition>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { verifyAdminToken, fetchStats, recordSiteVisit } from './modules/shared/api' import { verifyAdminToken, fetchStats, recordSiteVisit } from './modules/shared/api'
import { authState, isLoggedIn, clearAuth, getLoginUrl } from './modules/shared/auth' import { authState, isLoggedIn, clearAuth, getLoginUrl } from './modules/shared/auth'
import { wishlistCount, loadWishlist } from './modules/shared/useWishlist' import { wishlistCount, loadWishlist } from './modules/shared/useWishlist'
import ChatWidget from './modules/chat/ChatWidget.vue' import ChatWidget from './modules/chat/ChatWidget.vue'
import SplashScreen from './modules/shared/SplashScreen.vue' import SplashScreen from './modules/shared/SplashScreen.vue'
import { useRegisterSW } from 'virtual:pwa-register/vue' import { useRegisterSW } from 'virtual:pwa-register/vue'
const { needRefresh, updateServiceWorker } = useRegisterSW({ const { needRefresh, updateServiceWorker } = useRegisterSW({
onRegisteredSW(swUrl, r) { onRegisteredSW(swUrl, r) {
if (r) setInterval(() => r.update(), 1000 * 60 * 60) if (r) setInterval(() => r.update(), 1000 * 60 * 60)
} }
}) })
const showSplash = ref(true) const showSplash = ref(true)
const router = useRouter() const router = useRouter()
const stats = reactive({ totalOrders: 0, totalVisits: 0 }) const stats = reactive({ totalOrders: 0, totalVisits: 0 })
const statsLoaded = ref(false) const statsLoaded = ref(false)
const loggedIn = computed(() => isLoggedIn()) const loggedIn = computed(() => isLoggedIn())
const loginUrl = computed(() => getLoginUrl()) const loginUrl = computed(() => getLoginUrl())
const showAdminModal = ref(false) const showAdminModal = ref(false)
const tokenInput = ref('') const tokenInput = ref('')
const tokenError = ref('') const tokenError = ref('')
const tokenInputRef = ref(null) const tokenInputRef = ref(null)
let logoClickCount = 0 let logoClickCount = 0
let logoClickTimer = null let logoClickTimer = null
const CLICK_THRESHOLD = 5 const CLICK_THRESHOLD = 5
const CLICK_WINDOW_MS = 2000 const CLICK_WINDOW_MS = 2000
const onLogoClick = () => { const onLogoClick = () => {
logoClickCount++ logoClickCount++
clearTimeout(logoClickTimer) clearTimeout(logoClickTimer)
if (logoClickCount >= CLICK_THRESHOLD) { if (logoClickCount >= CLICK_THRESHOLD) {
logoClickCount = 0 logoClickCount = 0
tokenInput.value = '' tokenInput.value = ''
tokenError.value = '' tokenError.value = ''
showAdminModal.value = true showAdminModal.value = true
nextTick(() => tokenInputRef.value?.focus()) nextTick(() => tokenInputRef.value?.focus())
return return
} }
logoClickTimer = setTimeout(() => { logoClickCount = 0 }, CLICK_WINDOW_MS) logoClickTimer = setTimeout(() => { logoClickCount = 0 }, CLICK_WINDOW_MS)
} }
const submitAdminToken = async () => { const submitAdminToken = async () => {
const input = tokenInput.value.trim() const input = tokenInput.value.trim()
if (!input) return if (!input) return
tokenError.value = '' tokenError.value = ''
try { try {
const valid = await verifyAdminToken(input) const valid = await verifyAdminToken(input)
if (valid) { if (valid) {
showAdminModal.value = false showAdminModal.value = false
router.push(`/admin?token=${encodeURIComponent(input)}`) router.push(`/admin?token=${encodeURIComponent(input)}`)
} else { } else {
tokenError.value = '令牌错误,请重试' tokenError.value = '令牌错误,请重试'
} }
} catch { } catch {
tokenError.value = '验证失败,无法连接服务器' tokenError.value = '验证失败,无法连接服务器'
} }
} }
const goHome = () => { router.push('/') } const goHome = () => { router.push('/') }
const goMyOrders = () => { router.push('/my/orders') } const goMyOrders = () => { router.push('/my/orders') }
const goWishlist = () => { router.push('/wishlist') } const goWishlist = () => { router.push('/wishlist') }
const goProfile = () => { const goProfile = () => {
if (authState.account) { if (authState.account) {
window.open(`https://auth.shumengya.top/user/${authState.account}`, '_blank') window.open(`https://auth.shumengya.top/user/${authState.account}`, '_blank')
} }
} }
const logout = () => { clearAuth() } const logout = () => { clearAuth() }
const SPLASH_MIN_MS = 1400 const SPLASH_MIN_MS = 1400
onMounted(async () => { onMounted(async () => {
const splashStart = Date.now() const splashStart = Date.now()
await loadWishlist() await loadWishlist()
try { try {
recordSiteVisit().then((result) => { recordSiteVisit().then((result) => {
if (result.totalVisits) stats.totalVisits = result.totalVisits if (result.totalVisits) stats.totalVisits = result.totalVisits
}).catch(() => {}) }).catch(() => {})
const data = await fetchStats() const data = await fetchStats()
stats.totalOrders = data.totalOrders || 0 stats.totalOrders = data.totalOrders || 0
stats.totalVisits = data.totalVisits || 0 stats.totalVisits = data.totalVisits || 0
statsLoaded.value = true statsLoaded.value = true
} catch (e) { } catch (e) {
statsLoaded.value = false statsLoaded.value = false
} }
const elapsed = Date.now() - splashStart const elapsed = Date.now() - splashStart
const remaining = SPLASH_MIN_MS - elapsed const remaining = SPLASH_MIN_MS - elapsed
setTimeout(() => { showSplash.value = false }, remaining > 0 ? remaining : 0) setTimeout(() => { showSplash.value = false }, remaining > 0 ? remaining : 0)
}) })
watch(() => authState.token, async (newToken) => { watch(() => authState.token, async (newToken) => {
if (newToken) { if (newToken) {
await loadWishlist() await loadWishlist()
} }
}) })
</script> </script>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,97 +1,97 @@
<template> <template>
<section class="page-card"> <section class="page-card">
<div class="auth-callback"> <div class="auth-callback">
<div v-if="status === 'loading'" class="auth-status"> <div v-if="status === 'loading'" class="auth-status">
<h2>正在验证登录...</h2> <h2>正在验证登录...</h2>
<p class="tag">请稍候正在与萌芽认证中心确认身份</p> <p class="tag">请稍候正在与萌芽认证中心确认身份</p>
</div> </div>
<div v-else-if="status === 'success'" class="auth-status"> <div v-else-if="status === 'success'" class="auth-status">
<h2>登录成功</h2> <h2>登录成功</h2>
<p class="tag">欢迎回来{{ displayName }}正在跳转...</p> <p class="tag">欢迎回来{{ displayName }}正在跳转...</p>
</div> </div>
<div v-else class="auth-status"> <div v-else class="auth-status">
<h2>登录失败</h2> <h2>登录失败</h2>
<p class="tag">{{ errorMessage }}</p> <p class="tag">{{ errorMessage }}</p>
<button class="primary" @click="goHome">返回商店</button> <button class="primary" @click="goHome">返回商店</button>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { setAuth } from '../shared/auth' import { setAuth } from '../shared/auth'
import { verifySproutGateToken } from '../shared/api' import { verifySproutGateToken } from '../shared/api'
const router = useRouter() const router = useRouter()
const status = ref('loading') const status = ref('loading')
const displayName = ref('') const displayName = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const goHome = () => router.push('/') const goHome = () => router.push('/')
const parseFragment = () => { const parseFragment = () => {
const hash = window.location.hash.slice(1) const hash = window.location.hash.slice(1)
const params = new URLSearchParams(hash) const params = new URLSearchParams(hash)
return { return {
token: params.get('token') || '', token: params.get('token') || '',
account: params.get('account') || '', account: params.get('account') || '',
username: params.get('username') || '', username: params.get('username') || '',
avatarUrl: params.get('avatarUrl') || '' avatarUrl: params.get('avatarUrl') || ''
} }
} }
onMounted(async () => { onMounted(async () => {
const { token, account, username, avatarUrl: fragmentAvatar } = parseFragment() const { token, account, username, avatarUrl: fragmentAvatar } = parseFragment()
if (!token) { if (!token) {
status.value = 'error' status.value = 'error'
errorMessage.value = '未获取到登录令牌,请重试' errorMessage.value = '未获取到登录令牌,请重试'
return return
} }
try { try {
// 验证 token 并从 SproutGate 获取最新用户信息 // 验证 token 并从 SproutGate 获取最新用户信息
const verifyData = await verifySproutGateToken(token) const verifyData = await verifySproutGateToken(token)
if (!verifyData.valid) { if (!verifyData.valid) {
status.value = 'error' status.value = 'error'
errorMessage.value = '令牌验证失败,请重新登录' errorMessage.value = '令牌验证失败,请重新登录'
return return
} }
const user = verifyData.user || {} const user = verifyData.user || {}
const finalAccount = user.account || account const finalAccount = user.account || account
const finalUsername = user.username || username const finalUsername = user.username || username
const finalAvatarUrl = user.avatarUrl || fragmentAvatar const finalAvatarUrl = user.avatarUrl || fragmentAvatar
const finalEmail = user.email || '' const finalEmail = user.email || ''
setAuth({ token, account: finalAccount, username: finalUsername, avatarUrl: finalAvatarUrl, email: finalEmail }) setAuth({ token, account: finalAccount, username: finalUsername, avatarUrl: finalAvatarUrl, email: finalEmail })
displayName.value = finalUsername || finalAccount displayName.value = finalUsername || finalAccount
status.value = 'success' status.value = 'success'
setTimeout(() => router.push('/'), 1000) setTimeout(() => router.push('/'), 1000)
} catch { } catch {
// 验证失败(如网络异常)时,回退使用 URL fragment 中的数据 // 验证失败(如网络异常)时,回退使用 URL fragment 中的数据
setAuth({ token, account, username, avatarUrl: fragmentAvatar }) setAuth({ token, account, username, avatarUrl: fragmentAvatar })
displayName.value = username || account displayName.value = username || account
status.value = 'success' status.value = 'success'
setTimeout(() => router.push('/'), 1000) setTimeout(() => router.push('/'), 1000)
} }
}) })
</script> </script>
<style scoped> <style scoped>
.auth-callback { .auth-callback {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding: 48px 0; padding: 48px 0;
} }
.auth-status { .auth-status {
text-align: center; text-align: center;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
</style> </style>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,202 +1,202 @@
import axios from 'axios' import axios from 'axios'
const apiBaseURL = const apiBaseURL =
import.meta.env.VITE_API_BASE_URL || import.meta.env.VITE_API_BASE_URL ||
import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_BASE ||
(typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080') (typeof window !== 'undefined' ? window.location.origin : 'http://localhost:8080')
const api = axios.create({ const api = axios.create({
baseURL: apiBaseURL baseURL: apiBaseURL
}) })
// 管理员请求头构建辅助函数,使用 X-Admin-Token 请求头。 // 管理员请求头构建辅助函数,使用 X-Admin-Token 请求头。
// 后端保留 ?token= 查询参数作为旧版兼容,新请求统一使用请求头以兼容 Spring Security。 // 后端保留 ?token= 查询参数作为旧版兼容,新请求统一使用请求头以兼容 Spring Security。
const adminHeaders = (token) => ({ 'X-Admin-Token': token }) const adminHeaders = (token) => ({ 'X-Admin-Token': token })
const authApi = axios.create({ const authApi = axios.create({
baseURL: 'https://auth.api.shumengya.top' baseURL: 'https://auth.api.shumengya.top'
}) })
export const fetchProducts = async () => { export const fetchProducts = async () => {
const { data } = await api.get('/api/products') const { data } = await api.get('/api/products')
return data.data || [] return data.data || []
} }
export const recordProductView = async (id) => { export const recordProductView = async (id) => {
const { data } = await api.post(`/api/products/${id}/view`) const { data } = await api.post(`/api/products/${id}/view`)
return data.data || {} return data.data || {}
} }
export const createOrder = async (payload, authToken) => { export const createOrder = async (payload, authToken) => {
const headers = authToken ? { Authorization: `Bearer ${authToken}` } : {} const headers = authToken ? { Authorization: `Bearer ${authToken}` } : {}
const { data } = await api.post('/api/checkout', payload, { headers }) const { data } = await api.post('/api/checkout', payload, { headers })
return data.data || {} return data.data || {}
} }
export const confirmOrder = async (orderId) => { export const confirmOrder = async (orderId) => {
const { data } = await api.post(`/api/orders/${orderId}/confirm`) const { data } = await api.post(`/api/orders/${orderId}/confirm`)
return data.data || {} return data.data || {}
} }
export const fetchStats = async () => { export const fetchStats = async () => {
const { data } = await api.get('/api/stats') const { data } = await api.get('/api/stats')
return data.data || {} return data.data || {}
} }
export const recordSiteVisit = async () => { export const recordSiteVisit = async () => {
const { data } = await api.post('/api/site/visit') const { data } = await api.post('/api/site/visit')
return data.data || {} return data.data || {}
} }
export const verifySproutGateToken = async (token) => { export const verifySproutGateToken = async (token) => {
const { data } = await authApi.post('/api/auth/verify', { token }) const { data } = await authApi.post('/api/auth/verify', { token })
return data return data
} }
export const fetchSproutGateUser = async (token) => { export const fetchSproutGateUser = async (token) => {
const { data } = await authApi.get('/api/auth/me', { const { data } = await authApi.get('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
return data return data
} }
export const fetchMyOrders = async (authToken) => { export const fetchMyOrders = async (authToken) => {
const { data } = await api.get('/api/orders', { const { data } = await api.get('/api/orders', {
headers: { Authorization: `Bearer ${authToken}` } headers: { Authorization: `Bearer ${authToken}` }
}) })
return data.data || [] return data.data || []
} }
export const verifyAdminToken = async (token) => { export const verifyAdminToken = async (token) => {
const { data } = await api.post('/api/admin/verify', { token }) const { data } = await api.post('/api/admin/verify', { token })
return data.valid === true return data.valid === true
} }
export const fetchAdminProducts = async (token) => { export const fetchAdminProducts = async (token) => {
const { data } = await api.get('/api/admin/products', { headers: adminHeaders(token) }) const { data } = await api.get('/api/admin/products', { headers: adminHeaders(token) })
return data.data || [] return data.data || []
} }
export const createProduct = async (token, payload) => { export const createProduct = async (token, payload) => {
const { data } = await api.post('/api/admin/products', payload, { const { data } = await api.post('/api/admin/products', payload, {
headers: adminHeaders(token) headers: adminHeaders(token)
}) })
return data.data return data.data
} }
export const updateProduct = async (token, id, payload) => { export const updateProduct = async (token, id, payload) => {
const { data } = await api.put(`/api/admin/products/${id}`, payload, { const { data } = await api.put(`/api/admin/products/${id}`, payload, {
headers: adminHeaders(token) headers: adminHeaders(token)
}) })
return data.data return data.data
} }
export const toggleProduct = async (token, id, active) => { export const toggleProduct = async (token, id, active) => {
const { data } = await api.patch(`/api/admin/products/${id}/status`, { active }, { const { data } = await api.patch(`/api/admin/products/${id}/status`, { active }, {
headers: adminHeaders(token) headers: adminHeaders(token)
}) })
return data.data return data.data
} }
export const deleteProduct = async (token, id) => { export const deleteProduct = async (token, id) => {
const { data } = await api.delete(`/api/admin/products/${id}`, { const { data } = await api.delete(`/api/admin/products/${id}`, {
headers: adminHeaders(token) headers: adminHeaders(token)
}) })
return data return data
} }
export const fetchWishlist = async (token) => { export const fetchWishlist = async (token) => {
const { data } = await api.get('/api/wishlist', { const { data } = await api.get('/api/wishlist', {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
return data.data?.items || [] return data.data?.items || []
} }
export const addToWishlist = async (token, productId) => { export const addToWishlist = async (token, productId) => {
const { data } = await api.post('/api/wishlist', { productId }, { const { data } = await api.post('/api/wishlist', { productId }, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
return data.data?.items || [] return data.data?.items || []
} }
export const removeFromWishlist = async (token, productId) => { export const removeFromWishlist = async (token, productId) => {
const { data } = await api.delete(`/api/wishlist/${productId}`, { const { data } = await api.delete(`/api/wishlist/${productId}`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}) })
return data.data?.items || [] return data.data?.items || []
} }
export const fetchAdminOrders = async (token) => { export const fetchAdminOrders = async (token) => {
const { data } = await api.get('/api/admin/orders', { headers: adminHeaders(token) }) const { data } = await api.get('/api/admin/orders', { headers: adminHeaders(token) })
return data.data || [] return data.data || []
} }
export const deleteAdminOrder = async (token, orderId) => { export const deleteAdminOrder = async (token, orderId) => {
await api.delete(`/api/admin/orders/${orderId}`, { headers: adminHeaders(token) }) await api.delete(`/api/admin/orders/${orderId}`, { headers: adminHeaders(token) })
} }
export const fetchSiteMaintenance = async () => { export const fetchSiteMaintenance = async () => {
const { data } = await api.get('/api/site/maintenance') const { data } = await api.get('/api/site/maintenance')
return data.data || { maintenance: false, reason: '' } return data.data || { maintenance: false, reason: '' }
} }
export const setSiteMaintenance = async (token, maintenance, reason) => { export const setSiteMaintenance = async (token, maintenance, reason) => {
const { data } = await api.post('/api/admin/site/maintenance', { maintenance, reason }, { const { data } = await api.post('/api/admin/site/maintenance', { maintenance, reason }, {
headers: adminHeaders(token) headers: adminHeaders(token)
}) })
return data.data || {} return data.data || {}
} }
// ---- SMTP Config ---- // ---- SMTP Config ----
export const fetchSMTPConfig = async (token) => { export const fetchSMTPConfig = async (token) => {
const { data } = await api.get('/api/admin/site/smtp', { headers: adminHeaders(token) }) const { data } = await api.get('/api/admin/site/smtp', { headers: adminHeaders(token) })
return data.data || {} return data.data || {}
} }
export const setSMTPConfig = async (token, cfg) => { export const setSMTPConfig = async (token, cfg) => {
const { data } = await api.post('/api/admin/site/smtp', cfg, { headers: adminHeaders(token) }) const { data } = await api.post('/api/admin/site/smtp', cfg, { headers: adminHeaders(token) })
return data.data return data.data
} }
// ---- Chat (user) ---- // ---- Chat (user) ----
export const fetchMyChatMessages = async (userToken) => { export const fetchMyChatMessages = async (userToken) => {
const { data } = await api.get('/api/chat/messages', { const { data } = await api.get('/api/chat/messages', {
headers: { Authorization: `Bearer ${userToken}` } headers: { Authorization: `Bearer ${userToken}` }
}) })
return data.data?.messages || [] return data.data?.messages || []
} }
export const sendChatMessage = async (userToken, content) => { export const sendChatMessage = async (userToken, content) => {
const { data } = await api.post('/api/chat/messages', { content }, { const { data } = await api.post('/api/chat/messages', { content }, {
headers: { Authorization: `Bearer ${userToken}` } headers: { Authorization: `Bearer ${userToken}` }
}) })
return data.data?.message || null return data.data?.message || null
} }
// ---- Chat (admin) ---- // ---- Chat (admin) ----
export const fetchAdminAllConversations = async (adminToken) => { export const fetchAdminAllConversations = async (adminToken) => {
const { data } = await api.get('/api/admin/chat', { headers: adminHeaders(adminToken) }) const { data } = await api.get('/api/admin/chat', { headers: adminHeaders(adminToken) })
return data.data?.conversations || {} return data.data?.conversations || {}
} }
export const fetchAdminConversation = async (adminToken, account) => { export const fetchAdminConversation = async (adminToken, account) => {
const { data } = await api.get(`/api/admin/chat/${encodeURIComponent(account)}`, { const { data } = await api.get(`/api/admin/chat/${encodeURIComponent(account)}`, {
headers: adminHeaders(adminToken) headers: adminHeaders(adminToken)
}) })
return data.data?.messages || [] return data.data?.messages || []
} }
export const adminSendChatReply = async (adminToken, account, content) => { export const adminSendChatReply = async (adminToken, account, content) => {
const { data } = await api.post( const { data } = await api.post(
`/api/admin/chat/${encodeURIComponent(account)}`, `/api/admin/chat/${encodeURIComponent(account)}`,
{ content }, { content },
{ headers: adminHeaders(adminToken) } { headers: adminHeaders(adminToken) }
) )
return data.data?.message || null return data.data?.message || null
} }
export const adminClearConversation = async (adminToken, account) => { export const adminClearConversation = async (adminToken, account) => {
await api.delete(`/api/admin/chat/${encodeURIComponent(account)}`, { await api.delete(`/api/admin/chat/${encodeURIComponent(account)}`, {
headers: adminHeaders(adminToken) headers: adminHeaders(adminToken)
}) })
} }

View File

@@ -1,58 +1,58 @@
import { reactive } from 'vue' import { reactive } from 'vue'
const AUTH_KEY = 'mengyastore_auth' const AUTH_KEY = 'mengyastore_auth'
const loadAuth = () => { const loadAuth = () => {
try { try {
const raw = localStorage.getItem(AUTH_KEY) const raw = localStorage.getItem(AUTH_KEY)
if (!raw) return null if (!raw) return null
return JSON.parse(raw) return JSON.parse(raw)
} catch { } catch {
return null return null
} }
} }
const saved = loadAuth() const saved = loadAuth()
export const authState = reactive({ export const authState = reactive({
token: saved?.token || '', token: saved?.token || '',
account: saved?.account || '', account: saved?.account || '',
username: saved?.username || '', username: saved?.username || '',
avatarUrl: saved?.avatarUrl || '', avatarUrl: saved?.avatarUrl || '',
email: saved?.email || '' email: saved?.email || ''
}) })
export const isLoggedIn = () => !!authState.token export const isLoggedIn = () => !!authState.token
export const setAuth = (info) => { export const setAuth = (info) => {
authState.token = info.token || '' authState.token = info.token || ''
authState.account = info.account || '' authState.account = info.account || ''
authState.username = info.username || '' authState.username = info.username || ''
authState.avatarUrl = info.avatarUrl || '' authState.avatarUrl = info.avatarUrl || ''
authState.email = info.email || '' authState.email = info.email || ''
localStorage.setItem( localStorage.setItem(
AUTH_KEY, AUTH_KEY,
JSON.stringify({ JSON.stringify({
token: authState.token, token: authState.token,
account: authState.account, account: authState.account,
username: authState.username, username: authState.username,
avatarUrl: authState.avatarUrl, avatarUrl: authState.avatarUrl,
email: authState.email email: authState.email
}) })
) )
} }
export const clearAuth = () => { export const clearAuth = () => {
authState.token = '' authState.token = ''
authState.account = '' authState.account = ''
authState.username = '' authState.username = ''
authState.avatarUrl = '' authState.avatarUrl = ''
authState.email = '' authState.email = ''
localStorage.removeItem(AUTH_KEY) localStorage.removeItem(AUTH_KEY)
} }
export const getLoginUrl = () => { export const getLoginUrl = () => {
const redirectUri = `${window.location.origin}/auth/callback` const redirectUri = `${window.location.origin}/auth/callback`
const state = Math.random().toString(36).slice(2) const state = Math.random().toString(36).slice(2)
return `https://auth.shumengya.top/?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}` return `https://auth.shumengya.top/?redirect_uri=${encodeURIComponent(redirectUri)}&state=${state}`
} }

View File

@@ -1,66 +1,66 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { authState, isLoggedIn } from './auth' import { authState, isLoggedIn } from './auth'
import { import {
fetchWishlist as apiFetchWishlist, fetchWishlist as apiFetchWishlist,
addToWishlist as apiAddToWishlist, addToWishlist as apiAddToWishlist,
removeFromWishlist as apiRemoveFromWishlist removeFromWishlist as apiRemoveFromWishlist
} from './api' } from './api'
const wishlistIds = ref([]) const wishlistIds = ref([])
const wishlistSet = computed(() => new Set(wishlistIds.value)) const wishlistSet = computed(() => new Set(wishlistIds.value))
const wishlistCount = computed(() => wishlistIds.value.length) const wishlistCount = computed(() => wishlistIds.value.length)
const loadWishlist = async () => { const loadWishlist = async () => {
if (!isLoggedIn()) { if (!isLoggedIn()) {
wishlistIds.value = [] wishlistIds.value = []
return return
} }
try { try {
wishlistIds.value = await apiFetchWishlist(authState.token) wishlistIds.value = await apiFetchWishlist(authState.token)
} catch { } catch {
wishlistIds.value = [] wishlistIds.value = []
} }
} }
const isInWishlist = (productId) => wishlistSet.value.has(productId) const isInWishlist = (productId) => wishlistSet.value.has(productId)
const addToWishlist = async (productId) => { const addToWishlist = async (productId) => {
if (!isLoggedIn()) return if (!isLoggedIn()) return
try { try {
wishlistIds.value = await apiAddToWishlist(authState.token, productId) wishlistIds.value = await apiAddToWishlist(authState.token, productId)
} catch { } catch {
// 忽略错误 // 忽略错误
} }
} }
const removeFromWishlist = async (productId) => { const removeFromWishlist = async (productId) => {
if (!isLoggedIn()) return if (!isLoggedIn()) return
try { try {
wishlistIds.value = await apiRemoveFromWishlist(authState.token, productId) wishlistIds.value = await apiRemoveFromWishlist(authState.token, productId)
} catch { } catch {
// 忽略错误 // 忽略错误
} }
} }
const toggleWishlist = async (productId) => { const toggleWishlist = async (productId) => {
if (isInWishlist(productId)) { if (isInWishlist(productId)) {
await removeFromWishlist(productId) await removeFromWishlist(productId)
} else { } else {
await addToWishlist(productId) await addToWishlist(productId)
} }
} }
const getWishlistProducts = (allProducts) => { const getWishlistProducts = (allProducts) => {
const idSet = wishlistSet.value const idSet = wishlistSet.value
return allProducts.filter((p) => idSet.has(p.id)) return allProducts.filter((p) => idSet.has(p.id))
} }
export { export {
wishlistCount, wishlistCount,
isInWishlist, isInWishlist,
addToWishlist, addToWishlist,
removeFromWishlist, removeFromWishlist,
toggleWishlist, toggleWishlist,
getWishlistProducts, getWishlistProducts,
loadWishlist loadWishlist
} }

View File

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

View File

@@ -1,345 +1,345 @@
<template> <template>
<section class="page-card" v-if="!loading && product"> <section class="page-card" v-if="!loading && product">
<div class="detail-body"> <div class="detail-body">
<div class="detail-summary"> <div class="detail-summary">
<h2>{{ product.name }}</h2> <h2>{{ product.name }}</h2>
<p class="tag">库存{{ product.quantity }}</p> <p class="tag">库存{{ product.quantity }}</p>
<p class="tag">浏览量{{ product.viewCount || 0 }}</p> <p class="tag">浏览量{{ product.viewCount || 0 }}</p>
<p class="product-price"> <p class="product-price">
<span v-if="isFree" class="free-price">免费</span> <span v-if="isFree" class="free-price">免费</span>
<span <span
v-else-if="product.discountPrice > 0 && product.discountPrice < product.price" v-else-if="product.discountPrice > 0 && product.discountPrice < product.price"
> >
<span class="price-original">¥ {{ product.price.toFixed(2) }}</span> <span class="price-original">¥ {{ product.price.toFixed(2) }}</span>
<span class="price-discount">¥ {{ product.discountPrice.toFixed(2) }}</span> <span class="price-discount">¥ {{ product.discountPrice.toFixed(2) }}</span>
</span> </span>
<span v-else>¥ {{ product.price.toFixed(2) }}</span> <span v-else>¥ {{ product.price.toFixed(2) }}</span>
</p> </p>
</div> </div>
<div class="markdown detail-description" v-html="renderMarkdown(product.description)"></div> <div class="markdown detail-description" v-html="renderMarkdown(product.description)"></div>
<div class="detail-gallery" v-if="galleryImages.length"> <div class="detail-gallery" v-if="galleryImages.length">
<div class="detail-gallery-main"> <div class="detail-gallery-main">
<button <button
v-if="galleryImages.length > 1" v-if="galleryImages.length > 1"
class="gallery-nav prev" class="gallery-nav prev"
type="button" type="button"
aria-label="上一张" aria-label="上一张"
@click="prevImage" @click="prevImage"
> >
</button> </button>
<img <img
class="detail-image" class="detail-image"
:src="galleryImages[currentImageIndex].url" :src="galleryImages[currentImageIndex].url"
:alt="galleryImages[currentImageIndex].label" :alt="galleryImages[currentImageIndex].label"
/> />
<button <button
v-if="galleryImages.length > 1" v-if="galleryImages.length > 1"
class="gallery-nav next" class="gallery-nav next"
type="button" type="button"
aria-label="下一张" aria-label="下一张"
@click="nextImage" @click="nextImage"
> >
</button> </button>
</div> </div>
<div class="detail-gallery-meta"> <div class="detail-gallery-meta">
<span class="badge">{{ galleryImages[currentImageIndex].label }}</span> <span class="badge">{{ galleryImages[currentImageIndex].label }}</span>
<span class="tag">{{ currentImageIndex + 1 }} / {{ galleryImages.length }}</span> <span class="tag">{{ currentImageIndex + 1 }} / {{ galleryImages.length }}</span>
</div> </div>
<div class="detail-thumbs" v-if="galleryImages.length > 1"> <div class="detail-thumbs" v-if="galleryImages.length > 1">
<button <button
v-for="(image, index) in galleryImages" v-for="(image, index) in galleryImages"
:key="`${image.url}-${index}`" :key="`${image.url}-${index}`"
class="detail-thumb" class="detail-thumb"
:class="{ active: index === currentImageIndex }" :class="{ active: index === currentImageIndex }"
type="button" type="button"
@click="currentImageIndex = index" @click="currentImageIndex = index"
> >
<img :src="image.url" :alt="image.label" /> <img :src="image.url" :alt="image.label" />
<span>{{ image.label }}</span> <span>{{ image.label }}</span>
</button> </button>
</div> </div>
</div> </div>
<div class="detail-actions"> <div class="detail-actions">
<button class="buy-button" @click="goCheckout">立即下单</button> <button class="buy-button" @click="goCheckout">立即下单</button>
<button <button
v-if="loggedIn" v-if="loggedIn"
class="ghost wishlist-action" class="ghost wishlist-action"
:class="{ 'wishlist-active': inWishlist }" :class="{ 'wishlist-active': inWishlist }"
type="button" type="button"
@click="onToggleWishlist" @click="onToggleWishlist"
> >
{{ inWishlist ? '★ 已收藏' : '☆ 加入收藏夹' }} {{ inWishlist ? '★ 已收藏' : '☆ 加入收藏夹' }}
</button> </button>
<button class="ghost" @click="goBack">返回商店首页</button> <button class="ghost" @click="goBack">返回商店首页</button>
</div> </div>
</div> </div>
</section> </section>
<section class="page-card" v-else-if="!loading && !product"> <section class="page-card" v-else-if="!loading && !product">
<h2>未找到该商品</h2> <h2>未找到该商品</h2>
<p class="tag">请返回商店查看其他商品</p> <p class="tag">请返回商店查看其他商品</p>
<div class="detail-actions"> <div class="detail-actions">
<button class="ghost" @click="goBack">返回商店首页</button> <button class="ghost" @click="goBack">返回商店首页</button>
</div> </div>
</section> </section>
<section class="page-card" v-else> <section class="page-card" v-else>
<div class="status">加载中...</div> <div class="status">加载中...</div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { fetchProducts, recordProductView } from '../shared/api' import { fetchProducts, recordProductView } from '../shared/api'
import { isLoggedIn } from '../shared/auth' import { isLoggedIn } from '../shared/auth'
import { isInWishlist, toggleWishlist } from '../shared/useWishlist' import { isInWishlist, toggleWishlist } from '../shared/useWishlist'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const md = new MarkdownIt() const md = new MarkdownIt()
const loading = ref(true) const loading = ref(true)
const product = ref(null) const product = ref(null)
const currentImageIndex = ref(0) const currentImageIndex = ref(0)
const getPayPrice = (p) => { const getPayPrice = (p) => {
if (!p) return 0 if (!p) return 0
if (p.price === 0) return 0 if (p.price === 0) return 0
if (p.discountPrice > 0 && p.discountPrice < p.price) return p.discountPrice if (p.discountPrice > 0 && p.discountPrice < p.price) return p.discountPrice
return p.price return p.price
} }
const unitPrice = computed(() => getPayPrice(product.value)) const unitPrice = computed(() => getPayPrice(product.value))
const isFree = computed(() => unitPrice.value === 0) const isFree = computed(() => unitPrice.value === 0)
const renderMarkdown = (content) => md.render(content || '') const renderMarkdown = (content) => md.render(content || '')
const galleryImages = computed(() => { const galleryImages = computed(() => {
if (!product.value) { if (!product.value) {
return [] return []
} }
const images = [] const images = []
const seen = new Set() const seen = new Set()
const appendImage = (url, label) => { const appendImage = (url, label) => {
const trimmed = (url || '').trim() const trimmed = (url || '').trim()
if (!trimmed || seen.has(trimmed)) { if (!trimmed || seen.has(trimmed)) {
return return
} }
seen.add(trimmed) seen.add(trimmed)
images.push({ url: trimmed, label }) images.push({ url: trimmed, label })
} }
appendImage(product.value.coverUrl, '商品封面') appendImage(product.value.coverUrl, '商品封面')
const screenshots = Array.isArray(product.value.screenshotUrls) const screenshots = Array.isArray(product.value.screenshotUrls)
? product.value.screenshotUrls ? product.value.screenshotUrls
: [] : []
screenshots.forEach((url, index) => { screenshots.forEach((url, index) => {
appendImage(url, `商品截图 ${index + 1}`) appendImage(url, `商品截图 ${index + 1}`)
}) })
return images return images
}) })
const loggedIn = computed(() => isLoggedIn()) const loggedIn = computed(() => isLoggedIn())
const inWishlist = computed(() => product.value ? isInWishlist(product.value.id) : false) const inWishlist = computed(() => product.value ? isInWishlist(product.value.id) : false)
const onToggleWishlist = () => { const onToggleWishlist = () => {
if (product.value) toggleWishlist(product.value.id) if (product.value) toggleWishlist(product.value.id)
} }
const goBack = () => { const goBack = () => {
router.push('/') router.push('/')
} }
const goCheckout = () => { const goCheckout = () => {
if (!product.value) { if (!product.value) {
return return
} }
router.push(`/checkout/${product.value.id}`) router.push(`/checkout/${product.value.id}`)
} }
const prevImage = () => { const prevImage = () => {
if (!galleryImages.value.length) { if (!galleryImages.value.length) {
return return
} }
currentImageIndex.value = currentImageIndex.value =
(currentImageIndex.value - 1 + galleryImages.value.length) % galleryImages.value.length (currentImageIndex.value - 1 + galleryImages.value.length) % galleryImages.value.length
} }
const nextImage = () => { const nextImage = () => {
if (!galleryImages.value.length) { if (!galleryImages.value.length) {
return return
} }
currentImageIndex.value = (currentImageIndex.value + 1) % galleryImages.value.length currentImageIndex.value = (currentImageIndex.value + 1) % galleryImages.value.length
} }
watch( watch(
galleryImages, galleryImages,
(images) => { (images) => {
if (!images.length || currentImageIndex.value >= images.length) { if (!images.length || currentImageIndex.value >= images.length) {
currentImageIndex.value = 0 currentImageIndex.value = 0
} }
}, },
{ immediate: true } { immediate: true }
) )
onMounted(async () => { onMounted(async () => {
try { try {
const list = await fetchProducts() const list = await fetchProducts()
product.value = list.find((item) => item.id === route.params.id) || null product.value = list.find((item) => item.id === route.params.id) || null
if (product.value) { if (product.value) {
try { try {
const result = await recordProductView(product.value.id) const result = await recordProductView(product.value.id)
if (typeof result.viewCount === 'number') { if (typeof result.viewCount === 'number') {
product.value = { product.value = {
...product.value, ...product.value,
viewCount: result.viewCount viewCount: result.viewCount
} }
} }
} catch (error) { } catch (error) {
console.warn('record product view failed', error) console.warn('record product view failed', error)
} }
} }
} finally { } finally {
loading.value = false loading.value = false
} }
}) })
</script> </script>
<style scoped> <style scoped>
.detail-body { .detail-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
} }
.detail-summary { .detail-summary {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.detail-description { .detail-description {
display: block; display: block;
color: var(--text); color: var(--text);
font-size: 17px; font-size: 17px;
line-height: 1.8; line-height: 1.8;
-webkit-line-clamp: initial; -webkit-line-clamp: initial;
-webkit-box-orient: initial; -webkit-box-orient: initial;
overflow: visible; overflow: visible;
} }
.detail-gallery { .detail-gallery {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 14px; gap: 14px;
} }
.detail-gallery-main { .detail-gallery-main {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border-radius: 10px; border-radius: 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.45); background: rgba(255, 255, 255, 0.45);
} }
.gallery-nav { .gallery-nav {
position: absolute; position: absolute;
top: 50%; top: 50%;
z-index: 1; z-index: 1;
width: 42px; width: 42px;
height: 42px; height: 42px;
border-radius: 999px; border-radius: 999px;
padding: 0; padding: 0;
background: rgba(255, 255, 255, 0.88); background: rgba(255, 255, 255, 0.88);
color: var(--text); color: var(--text);
font-size: 28px; font-size: 28px;
line-height: 1; line-height: 1;
transform: translateY(-50%); transform: translateY(-50%);
box-shadow: 0 10px 20px rgba(33, 33, 40, 0.12); box-shadow: 0 10px 20px rgba(33, 33, 40, 0.12);
} }
.gallery-nav.prev { .gallery-nav.prev {
left: 16px; left: 16px;
} }
.gallery-nav.next { .gallery-nav.next {
right: 16px; right: 16px;
} }
.detail-gallery-meta { .detail-gallery-meta {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.detail-thumbs { .detail-thumbs {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px; gap: 10px;
} }
.detail-thumb { .detail-thumb {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
padding: 8px; padding: 8px;
text-align: left; text-align: left;
background: rgba(255, 255, 255, 0.58); background: rgba(255, 255, 255, 0.58);
border: 1px solid transparent; border: 1px solid transparent;
} }
.detail-thumb.active { .detail-thumb.active {
border-color: var(--accent-2); border-color: var(--accent-2);
box-shadow: 0 10px 24px rgba(145, 168, 208, 0.16); box-shadow: 0 10px 24px rgba(145, 168, 208, 0.16);
} }
.detail-thumb img { .detail-thumb img {
width: 100%; width: 100%;
height: 76px; height: 76px;
object-fit: cover; object-fit: cover;
border-radius: 6px; border-radius: 6px;
} }
.detail-thumb span { .detail-thumb span {
font-size: 14px; font-size: 14px;
color: var(--muted); color: var(--muted);
} }
.status { .status {
padding: 24px 0; padding: 24px 0;
color: var(--muted); color: var(--muted);
} }
.free-price { .free-price {
color: #3a9a68; color: #3a9a68;
font-weight: 900; font-weight: 900;
} }
.wishlist-action { .wishlist-action {
color: var(--muted); color: var(--muted);
} }
.wishlist-action.wishlist-active { .wishlist-action.wishlist-active {
color: #e8826a; color: #e8826a;
border-color: rgba(232, 130, 106, 0.35); border-color: rgba(232, 130, 106, 0.35);
background: rgba(255, 240, 235, 0.6); background: rgba(255, 240, 235, 0.6);
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.detail-thumb { .detail-thumb {
padding: 6px; padding: 6px;
} }
} }
</style> </style>

View File

@@ -1,292 +1,292 @@
<template> <template>
<section class="page-card"> <section class="page-card">
<div class="hero"> <div class="hero">
<h2>所有商品</h2> <h2>所有商品</h2>
<div class="filters"> <div class="filters">
<button <button
v-for="opt in VIEW_OPTIONS" v-for="opt in VIEW_OPTIONS"
:key="opt.value" :key="opt.value"
class="filter-btn" class="filter-btn"
:class="{ active: viewMode === opt.value }" :class="{ active: viewMode === opt.value }"
type="button" type="button"
@click="setViewMode(opt.value)" @click="setViewMode(opt.value)"
> >
{{ opt.label }} {{ opt.label }}
</button> </button>
</div> </div>
<input <input
v-model="searchQuery" v-model="searchQuery"
class="search-input" class="search-input"
type="text" type="text"
placeholder="搜索商品/标签" placeholder="搜索商品/标签"
/> />
</div> </div>
<div v-if="loading" class="status">加载中...</div> <div v-if="loading" class="status">加载中...</div>
<div v-else> <div v-else>
<div class="grid"> <div class="grid">
<ProductCard <ProductCard
v-for="item in pagedProducts" v-for="item in pagedProducts"
:key="item.id" :key="item.id"
:item="item" :item="item"
@click="handleCardClick" @click="handleCardClick"
/> />
</div> </div>
<div class="pagination" v-if="totalPages > 1"> <div class="pagination" v-if="totalPages > 1">
<button class="ghost pg-btn" :disabled="page === 1" @click="page = 1">首页</button> <button class="ghost pg-btn" :disabled="page === 1" @click="page = 1">首页</button>
<button class="ghost pg-btn" :disabled="page === 1" @click="page--">上一页</button> <button class="ghost pg-btn" :disabled="page === 1" @click="page--">上一页</button>
<span class="pg-info"> {{ page }} / {{ totalPages }} </span> <span class="pg-info"> {{ page }} / {{ totalPages }} </span>
<button class="ghost pg-btn" :disabled="page === totalPages" @click="page++">下一页</button> <button class="ghost pg-btn" :disabled="page === totalPages" @click="page++">下一页</button>
<button class="ghost pg-btn" :disabled="page === totalPages" @click="page = totalPages">末页</button> <button class="ghost pg-btn" :disabled="page === totalPages" @click="page = totalPages">末页</button>
</div> </div>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { fetchProducts } from '../shared/api' import { fetchProducts } from '../shared/api'
import ProductCard from './components/ProductCard.vue' import ProductCard from './components/ProductCard.vue'
const router = useRouter() const router = useRouter()
const VIEW_OPTIONS = [ const VIEW_OPTIONS = [
{ value: 'all', label: '全部' }, { value: 'all', label: '全部' },
{ value: 'free', label: '免费' }, { value: 'free', label: '免费' },
{ value: 'newest', label: '新上架' }, { value: 'newest', label: '新上架' },
{ value: 'most-sold', label: '最多购买' }, { value: 'most-sold', label: '最多购买' },
{ value: 'most-viewed', label: '最多浏览' }, { value: 'most-viewed', label: '最多浏览' },
{ value: 'price-high', label: '价格最高' }, { value: 'price-high', label: '价格最高' },
{ value: 'price-low', label: '价格最低' } { value: 'price-low', label: '价格最低' }
] ]
const products = ref([]) const products = ref([])
const loading = ref(true) const loading = ref(true)
const page = ref(1) const page = ref(1)
const perPage = ref(20) const perPage = ref(20)
const viewMode = ref('all') const viewMode = ref('all')
const searchQuery = ref('') const searchQuery = ref('')
const updatePerPage = () => { const updatePerPage = () => {
perPage.value = window.innerWidth <= 900 ? 10 : 20 perPage.value = window.innerWidth <= 900 ? 10 : 20
page.value = 1 page.value = 1
} }
const getPayPrice = (item) => { const getPayPrice = (item) => {
if (!item) return 0 if (!item) return 0
// 折扣规则discountPrice > 0 且小于原价时启用price = 0 时显示"免费" // 折扣规则discountPrice > 0 且小于原价时启用price = 0 时显示"免费"
if (item.price === 0) return 0 if (item.price === 0) return 0
if (item.discountPrice > 0 && item.discountPrice < item.price) return item.discountPrice if (item.discountPrice > 0 && item.discountPrice < item.price) return item.discountPrice
return item.price return item.price
} }
const isFree = (item) => getPayPrice(item) === 0 const isFree = (item) => getPayPrice(item) === 0
const isSoldOut = (item) => item && item.quantity === 0 const isSoldOut = (item) => item && item.quantity === 0
const matchesSearch = (item) => { const matchesSearch = (item) => {
const q = (searchQuery.value || '').trim().toLowerCase() const q = (searchQuery.value || '').trim().toLowerCase()
if (!q) return true if (!q) return true
const name = (item?.name || '').toLowerCase() const name = (item?.name || '').toLowerCase()
const tags = (item?.tags || []).join(',').toLowerCase() const tags = (item?.tags || []).join(',').toLowerCase()
return name.includes(q) || tags.includes(q) return name.includes(q) || tags.includes(q)
} }
const filteredProducts = computed(() => { const filteredProducts = computed(() => {
let list = [...products.value] let list = [...products.value]
if (viewMode.value === 'free') list = list.filter((p) => isFree(p)) if (viewMode.value === 'free') list = list.filter((p) => isFree(p))
if ((searchQuery.value || '').trim()) list = list.filter((p) => matchesSearch(p)) if ((searchQuery.value || '').trim()) list = list.filter((p) => matchesSearch(p))
switch (viewMode.value) { switch (viewMode.value) {
case 'newest': case 'newest':
list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)) list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
break break
case 'most-sold': case 'most-sold':
list.sort((a, b) => (b.totalSold || 0) - (a.totalSold || 0)) list.sort((a, b) => (b.totalSold || 0) - (a.totalSold || 0))
break break
case 'most-viewed': case 'most-viewed':
list.sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0)) list.sort((a, b) => (b.viewCount || 0) - (a.viewCount || 0))
break break
case 'price-high': case 'price-high':
list.sort((a, b) => getPayPrice(b) - getPayPrice(a)) list.sort((a, b) => getPayPrice(b) - getPayPrice(a))
break break
case 'price-low': case 'price-low':
list.sort((a, b) => getPayPrice(a) - getPayPrice(b)) list.sort((a, b) => getPayPrice(a) - getPayPrice(b))
break break
default: default:
break break
} }
return list return list
}) })
const totalPages = computed(() => const totalPages = computed(() =>
Math.max(1, Math.ceil(filteredProducts.value.length / perPage.value)) Math.max(1, Math.ceil(filteredProducts.value.length / perPage.value))
) )
const pagedProducts = computed(() => { const pagedProducts = computed(() => {
const start = (page.value - 1) * perPage.value const start = (page.value - 1) * perPage.value
return filteredProducts.value.slice(start, start + perPage.value) return filteredProducts.value.slice(start, start + perPage.value)
}) })
const handleCardClick = (item) => { const handleCardClick = (item) => {
if (!item || isSoldOut(item)) return if (!item || isSoldOut(item)) return
router.push(`/product/${item.id}`) router.push(`/product/${item.id}`)
} }
const setViewMode = (next) => { const setViewMode = (next) => {
viewMode.value = next viewMode.value = next
page.value = 1 page.value = 1
} }
onMounted(async () => { onMounted(async () => {
updatePerPage() updatePerPage()
window.addEventListener('resize', updatePerPage) window.addEventListener('resize', updatePerPage)
try { try {
products.value = await fetchProducts() products.value = await fetchProducts()
} finally { } finally {
loading.value = false loading.value = false
} }
}) })
watch(viewMode, () => { page.value = 1 }) watch(viewMode, () => { page.value = 1 })
watch(searchQuery, () => { page.value = 1 }) watch(searchQuery, () => { page.value = 1 })
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', updatePerPage) window.removeEventListener('resize', updatePerPage)
}) })
</script> </script>
<style scoped> <style scoped>
.hero { .hero {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 20px; margin-bottom: 20px;
} }
.filters { .filters {
display: flex; display: flex;
gap: 12px; gap: 12px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.search-input { .search-input {
width: 320px; width: 320px;
max-width: 100%; max-width: 100%;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.7); background: rgba(255, 255, 255, 0.7);
padding: 10px 14px; padding: 10px 14px;
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif; font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
font-size: 16px; font-size: 16px;
outline: none; outline: none;
} }
.search-input:focus { .search-input:focus {
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(180, 154, 203, 0.18); box-shadow: 0 0 0 3px rgba(180, 154, 203, 0.18);
} }
.filter-btn { .filter-btn {
padding: 8px 14px; padding: 8px 14px;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--line); border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.6); background: rgba(255, 255, 255, 0.6);
color: var(--text); color: var(--text);
font-size: 15px; font-size: 15px;
font-weight: 700; font-weight: 700;
transition: background 0.2s ease, color 0.2s ease, transform 0.15s ease; transition: background 0.2s ease, color 0.2s ease, transform 0.15s ease;
} }
.filter-btn:hover { .filter-btn:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
.filter-btn.active { .filter-btn.active {
background: linear-gradient(135deg, var(--accent), var(--accent-2)); background: linear-gradient(135deg, var(--accent), var(--accent-2));
border-color: transparent; border-color: transparent;
color: #fff; color: #fff;
box-shadow: 0 10px 30px rgba(145, 168, 208, 0.35); box-shadow: 0 10px 30px rgba(145, 168, 208, 0.35);
} }
.status { .status {
padding: 24px 0; padding: 24px 0;
color: var(--muted); color: var(--muted);
} }
.tag { .tag {
font-size: 14px; font-size: 14px;
color: var(--muted); color: var(--muted);
} }
.pagination { .pagination {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
margin-top: 22px; margin-top: 22px;
flex-wrap: wrap; flex-wrap: wrap;
} }
.pg-btn { .pg-btn {
padding: 6px 14px; padding: 6px 14px;
font-size: 14px; font-size: 14px;
border-radius: 999px; border-radius: 999px;
min-width: 60px; min-width: 60px;
} }
.pg-btn:disabled { .pg-btn:disabled {
opacity: 0.38; opacity: 0.38;
cursor: not-allowed; cursor: not-allowed;
} }
.pg-info { .pg-info {
font-size: 14px; font-size: 14px;
color: var(--muted); color: var(--muted);
padding: 0 6px; padding: 0 6px;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.hero { .hero {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 10px; gap: 10px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.hero h2 { .hero h2 {
font-size: 20px; font-size: 20px;
} }
/* Filter buttons: scrollable horizontal row, no wrap */ /* Filter buttons: scrollable horizontal row, no wrap */
.filters { .filters {
flex-wrap: nowrap; flex-wrap: nowrap;
overflow-x: auto; overflow-x: auto;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
padding-bottom: 4px; padding-bottom: 4px;
scrollbar-width: none; scrollbar-width: none;
} }
.filters::-webkit-scrollbar { .filters::-webkit-scrollbar {
display: none; display: none;
} }
.filter-btn { .filter-btn {
flex-shrink: 0; flex-shrink: 0;
padding: 6px 10px; padding: 6px 10px;
font-size: 13px; font-size: 13px;
} }
.search-input { .search-input {
width: 100%; width: 100%;
font-size: 14px; font-size: 14px;
padding: 8px 12px; padding: 8px 12px;
} }
} }
</style> </style>

View File

@@ -1,342 +1,342 @@
<template> <template>
<div <div
:class="['product-link', { 'is-disabled': isSoldOut }]" :class="['product-link', { 'is-disabled': isSoldOut }]"
@click="$emit('click', item)" @click="$emit('click', item)"
> >
<article class="product-card"> <article class="product-card">
<div class="cover-wrap"> <div class="cover-wrap">
<img :src="item.coverUrl" :alt="item.name" /> <img :src="item.coverUrl" :alt="item.name" />
<div v-if="isSoldOut" class="soldout-badge">已售空</div> <div v-if="isSoldOut" class="soldout-badge">已售空</div>
<div v-else-if="item.requireLogin" class="require-login-badge">需登录</div> <div v-else-if="item.requireLogin" class="require-login-badge">需登录</div>
<button <button
v-if="loggedIn" v-if="loggedIn"
class="wishlist-btn" class="wishlist-btn"
:class="{ 'in-wishlist': inWishlist }" :class="{ 'in-wishlist': inWishlist }"
type="button" type="button"
@click="onWishlistClick" @click="onWishlistClick"
:title="inWishlist ? '取消收藏' : '加入收藏'" :title="inWishlist ? '取消收藏' : '加入收藏'"
> >
{{ inWishlist ? '★' : '☆' }} {{ inWishlist ? '★' : '☆' }}
</button> </button>
</div> </div>
<div class="card-top"> <div class="card-top">
<h3 class="card-name">{{ item.name }}</h3> <h3 class="card-name">{{ item.name }}</h3>
<div class="card-price"> <div class="card-price">
<span v-if="isFree" class="product-price free-price">免费</span> <span v-if="isFree" class="product-price free-price">免费</span>
<span <span
v-else-if="item.discountPrice > 0 && item.discountPrice < item.price" v-else-if="item.discountPrice > 0 && item.discountPrice < item.price"
class="product-price" class="product-price"
> >
<span class="price-original">¥ {{ item.price.toFixed(2) }}</span> <span class="price-original">¥ {{ item.price.toFixed(2) }}</span>
<span class="price-discount">¥ {{ item.discountPrice.toFixed(2) }}</span> <span class="price-discount">¥ {{ item.discountPrice.toFixed(2) }}</span>
</span> </span>
<span v-else class="product-price">¥ {{ item.price.toFixed(2) }}</span> <span v-else class="product-price">¥ {{ item.price.toFixed(2) }}</span>
</div> </div>
</div> </div>
<div class="card-mid"> <div class="card-mid">
<div class="markdown" v-html="renderedDescription"></div> <div class="markdown" v-html="renderedDescription"></div>
</div> </div>
<div class="card-bottom"> <div class="card-bottom">
<div v-if="item.tags && item.tags.length" class="tag-row"> <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> <span v-for="tag in item.tags" :key="tag" class="tag-chip">{{ tag }}</span>
</div> </div>
<div class="meta-row"> <div class="meta-row">
<div class="tag">库存{{ item.quantity }}</div> <div class="tag">库存{{ item.quantity }}</div>
<div class="tag">浏览量{{ item.viewCount || 0 }}</div> <div class="tag">浏览量{{ item.viewCount || 0 }}</div>
</div> </div>
</div> </div>
</article> </article>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import { isLoggedIn } from '../../shared/auth' import { isLoggedIn } from '../../shared/auth'
import { isInWishlist, toggleWishlist } from '../../shared/useWishlist' import { isInWishlist, toggleWishlist } from '../../shared/useWishlist'
const md = new MarkdownIt() const md = new MarkdownIt()
const props = defineProps({ const props = defineProps({
item: { type: Object, required: true } item: { type: Object, required: true }
}) })
const emit = defineEmits(['click']) const emit = defineEmits(['click'])
const inWishlist = computed(() => isInWishlist(props.item.id)) const inWishlist = computed(() => isInWishlist(props.item.id))
const loggedIn = computed(() => isLoggedIn()) const loggedIn = computed(() => isLoggedIn())
const onWishlistClick = (e) => { const onWishlistClick = (e) => {
e.stopPropagation() e.stopPropagation()
toggleWishlist(props.item.id) toggleWishlist(props.item.id)
} }
const payPrice = computed(() => { const payPrice = computed(() => {
if (!props.item) return 0 if (!props.item) return 0
if (props.item.price === 0) return 0 if (props.item.price === 0) return 0
if (props.item.discountPrice > 0 && props.item.discountPrice < props.item.price) { if (props.item.discountPrice > 0 && props.item.discountPrice < props.item.price) {
return props.item.discountPrice return props.item.discountPrice
} }
return props.item.price return props.item.price
}) })
const isFree = computed(() => payPrice.value === 0) const isFree = computed(() => payPrice.value === 0)
const isSoldOut = computed(() => props.item && props.item.quantity === 0) const isSoldOut = computed(() => props.item && props.item.quantity === 0)
const renderedDescription = computed(() => md.render(props.item.description || '')) const renderedDescription = computed(() => md.render(props.item.description || ''))
</script> </script>
<style scoped> <style scoped>
.product-link { .product-link {
display: block; display: block;
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
height: 100%; height: 100%;
} }
.product-link.is-disabled { .product-link.is-disabled {
opacity: 0.6; opacity: 0.6;
cursor: not-allowed; cursor: not-allowed;
} }
.product-card { .product-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
gap: 12px; gap: 12px;
height: 100%; height: 100%;
} }
.card-top { .card-top {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
} }
.card-name { .card-name {
font-size: 18px; font-size: 18px;
font-weight: 700; font-weight: 700;
color: var(--text); color: var(--text);
line-height: 1.2; line-height: 1.2;
} }
.card-price { .card-price {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
} }
.card-mid { .card-mid {
flex: 1; flex: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
text-align: center; text-align: center;
} }
.card-mid .markdown { .card-mid .markdown {
text-align: center; text-align: center;
font-size: 15px; font-size: 15px;
color: var(--muted); color: var(--muted);
line-height: 1.6; line-height: 1.6;
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
overflow: hidden; overflow: hidden;
} }
.card-bottom { .card-bottom {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
align-items: flex-start; align-items: flex-start;
} }
.meta-row { .meta-row {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
} }
.cover-wrap { .cover-wrap {
position: relative; position: relative;
border-radius: 6px; border-radius: 6px;
overflow: hidden; overflow: hidden;
} }
.cover-wrap img { .cover-wrap img {
width: 100%; width: 100%;
height: 140px; height: 140px;
object-fit: cover; object-fit: cover;
display: block; display: block;
} }
.soldout-badge { .soldout-badge {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: rgba(20, 18, 22, 0.52); background: rgba(20, 18, 22, 0.52);
backdrop-filter: blur(3px); backdrop-filter: blur(3px);
color: #fff; color: #fff;
font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif; font-family: 'KaiTi', 'STKaiti', '楷体', '楷体_GB2312', serif;
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
letter-spacing: 4px; letter-spacing: 4px;
pointer-events: none; pointer-events: none;
} }
.require-login-badge { .require-login-badge {
position: absolute; position: absolute;
top: 8px; top: 8px;
right: 8px; right: 8px;
padding: 3px 10px; padding: 3px 10px;
border-radius: 999px; border-radius: 999px;
background: rgba(180, 154, 203, 0.85); background: rgba(180, 154, 203, 0.85);
color: #fff; color: #fff;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
pointer-events: none; pointer-events: none;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.wishlist-btn { .wishlist-btn {
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
right: 8px; right: 8px;
width: auto; width: auto;
height: auto; height: auto;
border-radius: 0; border-radius: 0;
border: none; border: none;
background: none; background: none;
backdrop-filter: none; backdrop-filter: none;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 20px; font-size: 20px;
line-height: 1; line-height: 1;
color: rgba(255, 255, 255, 0.9); color: rgba(255, 255, 255, 0.9);
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
transition: color 0.2s ease, transform 0.15s ease; transition: color 0.2s ease, transform 0.15s ease;
padding: 0; padding: 0;
box-shadow: none; box-shadow: none;
} }
.wishlist-btn:hover { .wishlist-btn:hover {
transform: scale(1.2); transform: scale(1.2);
} }
.wishlist-btn.in-wishlist { .wishlist-btn.in-wishlist {
color: #ffb347; color: #ffb347;
text-shadow: 0 1px 6px rgba(255, 140, 0, 0.6); text-shadow: 0 1px 6px rgba(255, 140, 0, 0.6);
} }
.tag-row { .tag-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
} }
.tag-chip { .tag-chip {
padding: 4px 10px; padding: 4px 10px;
border-radius: 999px; border-radius: 999px;
font-size: 14px; font-size: 14px;
font-weight: 700; font-weight: 700;
color: var(--accent-2); color: var(--accent-2);
background: rgba(145, 168, 208, 0.08); background: rgba(145, 168, 208, 0.08);
border: 1px solid rgba(145, 168, 208, 0.18); border: 1px solid rgba(145, 168, 208, 0.18);
} }
.tag { .tag {
font-size: 14px; font-size: 14px;
color: var(--muted); color: var(--muted);
} }
.free-price { .free-price {
color: #3a9a68; color: #3a9a68;
font-weight: 900; font-weight: 900;
} }
.price-original { .price-original {
text-decoration: line-through; text-decoration: line-through;
color: var(--muted); color: var(--muted);
margin-right: 6px; margin-right: 6px;
font-size: 15px; font-size: 15px;
} }
.price-discount { .price-discount {
color: var(--accent-2); color: var(--accent-2);
font-weight: 600; font-weight: 600;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
.cover-wrap img { .cover-wrap img {
height: 100px; height: 100px;
} }
/* Name + price on one line, compressed */ /* Name + price on one line, compressed */
.card-top { .card-top {
flex-direction: row; flex-direction: row;
align-items: flex-start; align-items: flex-start;
gap: 4px; gap: 4px;
} }
.card-name { .card-name {
font-size: 14px; font-size: 14px;
line-height: 1.3; line-height: 1.3;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
word-break: break-all; word-break: break-all;
} }
.card-price { .card-price {
flex-shrink: 0; flex-shrink: 0;
font-size: 13px; font-size: 13px;
} }
.price-original { .price-original {
font-size: 11px; font-size: 11px;
margin-right: 2px; margin-right: 2px;
} }
.free-price { .free-price {
font-size: 13px; font-size: 13px;
} }
.card-mid { .card-mid {
display: flex; display: flex;
} }
.card-mid .markdown { .card-mid .markdown {
font-size: 12px; font-size: 12px;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
} }
.card-bottom { .card-bottom {
gap: 4px; gap: 4px;
font-size: 12px; font-size: 12px;
} }
.tag-row { .tag-row {
display: flex; display: flex;
gap: 4px; gap: 4px;
margin-top: 4px; margin-top: 4px;
} }
.tag-chip { .tag-chip {
font-size: 11px; font-size: 11px;
padding: 2px 7px; padding: 2px 7px;
} }
.tag { .tag {
font-size: 11px; font-size: 11px;
} }
} }
</style> </style>

View File

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

View File

@@ -1,108 +1,108 @@
<template> <template>
<section class="page-card"> <section class="page-card">
<div class="hero"> <div class="hero">
<h2>我的收藏夹</h2> <h2>我的收藏夹</h2>
<p class="tag"> {{ wishlistItems.length }} 件商品</p> <p class="tag"> {{ wishlistItems.length }} 件商品</p>
</div> </div>
<div v-if="loading" class="status">加载中...</div> <div v-if="loading" class="status">加载中...</div>
<div v-else-if="wishlistItems.length === 0" class="empty"> <div v-else-if="wishlistItems.length === 0" class="empty">
<div class="empty-icon"></div> <div class="empty-icon"></div>
<p class="empty-title">收藏夹是空的</p> <p class="empty-title">收藏夹是空的</p>
<p class="tag">在商品卡片上点击 即可加入收藏</p> <p class="tag">在商品卡片上点击 即可加入收藏</p>
<button class="ghost" @click="$router.push('/')">去逛逛</button> <button class="ghost" @click="$router.push('/')">去逛逛</button>
</div> </div>
<div v-else class="grid"> <div v-else class="grid">
<ProductCard <ProductCard
v-for="item in wishlistItems" v-for="item in wishlistItems"
:key="item.id" :key="item.id"
:item="item" :item="item"
:class="{ 'card-dimmed': !item.active || item.quantity === 0 }" :class="{ 'card-dimmed': !item.active || item.quantity === 0 }"
@click="handleClick" @click="handleClick"
/> />
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { fetchProducts } from '../shared/api' import { fetchProducts } from '../shared/api'
import { getWishlistProducts, loadWishlist } from '../shared/useWishlist' import { getWishlistProducts, loadWishlist } from '../shared/useWishlist'
import ProductCard from '../store/components/ProductCard.vue' import ProductCard from '../store/components/ProductCard.vue'
const router = useRouter() const router = useRouter()
const allProducts = ref([]) const allProducts = ref([])
const loading = ref(true) const loading = ref(true)
const wishlistItems = computed(() => { const wishlistItems = computed(() => {
const items = getWishlistProducts(allProducts.value) const items = getWishlistProducts(allProducts.value)
return items.sort((a, b) => { return items.sort((a, b) => {
const aOk = a.active && a.quantity > 0 ? 0 : 1 const aOk = a.active && a.quantity > 0 ? 0 : 1
const bOk = b.active && b.quantity > 0 ? 0 : 1 const bOk = b.active && b.quantity > 0 ? 0 : 1
return aOk - bOk return aOk - bOk
}) })
}) })
const handleClick = (item) => { const handleClick = (item) => {
if (!item) return if (!item) return
if (!item.active || item.quantity === 0) return if (!item.active || item.quantity === 0) return
router.push(`/product/${item.id}`) router.push(`/product/${item.id}`)
} }
onMounted(async () => { onMounted(async () => {
try { try {
const [prods] = await Promise.all([fetchProducts(), loadWishlist()]) const [prods] = await Promise.all([fetchProducts(), loadWishlist()])
allProducts.value = prods allProducts.value = prods
} finally { } finally {
loading.value = false loading.value = false
} }
}) })
</script> </script>
<style scoped> <style scoped>
.hero { .hero {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 12px; gap: 12px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.status { .status {
padding: 24px 0; padding: 24px 0;
color: var(--muted); color: var(--muted);
} }
.tag { .tag {
font-size: 14px; font-size: 14px;
color: var(--muted); color: var(--muted);
} }
.empty { .empty {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 60px 0; padding: 60px 0;
text-align: center; text-align: center;
} }
.empty-icon { .empty-icon {
font-size: 52px; font-size: 52px;
color: var(--muted); color: var(--muted);
opacity: 0.4; opacity: 0.4;
line-height: 1; line-height: 1;
} }
.empty-title { .empty-title {
font-size: 20px; font-size: 20px;
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
} }
.card-dimmed { .card-dimmed {
opacity: 0.5; opacity: 0.5;
} }
</style> </style>

View File

@@ -1,48 +1,48 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import StorePage from '../modules/store/StorePage.vue' import StorePage from '../modules/store/StorePage.vue'
import AdminPage from '../modules/admin/AdminPage.vue' import AdminPage from '../modules/admin/AdminPage.vue'
import ProductDetail from '../modules/store/ProductDetail.vue' import ProductDetail from '../modules/store/ProductDetail.vue'
import CheckoutPage from '../modules/store/CheckoutPage.vue' import CheckoutPage from '../modules/store/CheckoutPage.vue'
import AuthCallback from '../modules/auth/AuthCallback.vue' import AuthCallback from '../modules/auth/AuthCallback.vue'
import MyOrdersPage from '../modules/user/MyOrdersPage.vue' import MyOrdersPage from '../modules/user/MyOrdersPage.vue'
import MaintenancePage from '../modules/maintenance/MaintenancePage.vue' import MaintenancePage from '../modules/maintenance/MaintenancePage.vue'
import WishlistPage from '../modules/wishlist/WishlistPage.vue' import WishlistPage from '../modules/wishlist/WishlistPage.vue'
import { fetchSiteMaintenance } from '../modules/shared/api' import { fetchSiteMaintenance } from '../modules/shared/api'
const BYPASS_ROUTES = ['/admin', '/auth/callback', '/maintenance', '/wishlist'] const BYPASS_ROUTES = ['/admin', '/auth/callback', '/maintenance', '/wishlist']
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
{ path: '/', name: 'store', component: StorePage }, { path: '/', name: 'store', component: StorePage },
{ path: '/product/:id', name: 'product-detail', component: ProductDetail }, { path: '/product/:id', name: 'product-detail', component: ProductDetail },
{ path: '/checkout/:id', name: 'checkout', component: CheckoutPage }, { path: '/checkout/:id', name: 'checkout', component: CheckoutPage },
{ path: '/admin', name: 'admin', component: AdminPage }, { path: '/admin', name: 'admin', component: AdminPage },
{ path: '/auth/callback', name: 'auth-callback', component: AuthCallback }, { path: '/auth/callback', name: 'auth-callback', component: AuthCallback },
{ path: '/my/orders', name: 'my-orders', component: MyOrdersPage }, { path: '/my/orders', name: 'my-orders', component: MyOrdersPage },
{ {
path: '/maintenance', path: '/maintenance',
name: 'maintenance', name: 'maintenance',
component: MaintenancePage, component: MaintenancePage,
props: (route) => ({ reason: route.query.reason || '' }) props: (route) => ({ reason: route.query.reason || '' })
}, },
{ path: '/wishlist', name: 'wishlist', component: WishlistPage } { path: '/wishlist', name: 'wishlist', component: WishlistPage }
] ]
}) })
router.beforeEach(async (to) => { router.beforeEach(async (to) => {
if (BYPASS_ROUTES.some((p) => to.path.startsWith(p))) { if (BYPASS_ROUTES.some((p) => to.path.startsWith(p))) {
return true return true
} }
try { try {
const { maintenance, reason } = await fetchSiteMaintenance() const { maintenance, reason } = await fetchSiteMaintenance()
if (maintenance) { if (maintenance) {
return { name: 'maintenance', query: { reason } } return { name: 'maintenance', query: { reason } }
} }
} catch { } catch {
// 接口异常时放行,不阻断用户 // 接口异常时放行,不阻断用户
} }
return true return true
}) })
export default router export default router

View File

@@ -1,58 +1,58 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'logo.png', 'apple-touch-icon-180x180.png'], includeAssets: ['favicon.ico', 'logo.png', 'apple-touch-icon-180x180.png'],
manifest: { manifest: {
name: '萌芽小店', name: '萌芽小店',
short_name: '萌芽小店', short_name: '萌芽小店',
description: '萌芽小店 — 精选商品,一键购买', description: '萌芽小店 — 精选商品,一键购买',
theme_color: '#1a1a1a', theme_color: '#1a1a1a',
background_color: '#ffffff', background_color: '#ffffff',
display: 'standalone', display: 'standalone',
orientation: 'portrait', orientation: 'portrait',
scope: '/', scope: '/',
start_url: '/', start_url: '/',
lang: 'zh-CN', lang: 'zh-CN',
icons: [ icons: [
{ src: 'pwa-64x64.png', sizes: '64x64', type: 'image/png' }, { src: 'pwa-64x64.png', sizes: '64x64', type: 'image/png' },
{ src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' }, { src: 'pwa-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' }, { src: 'pwa-512x512.png', sizes: '512x512', type: 'image/png' },
{ src: 'maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } { src: 'maskable-icon-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
] ]
}, },
workbox: { workbox: {
maximumFileSizeToCacheInBytes: 8 * 1024 * 1024, maximumFileSizeToCacheInBytes: 8 * 1024 * 1024,
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\/.*/i, urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\/.*/i,
handler: 'CacheFirst', handler: 'CacheFirst',
options: { options: {
cacheName: 'google-fonts-cache', cacheName: 'google-fonts-cache',
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 }, expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 },
cacheableResponse: { statuses: [0, 200] } cacheableResponse: { statuses: [0, 200] }
} }
}, },
{ {
urlPattern: /^\/api\//, urlPattern: /^\/api\//,
handler: 'NetworkFirst', handler: 'NetworkFirst',
options: { options: {
cacheName: 'api-cache', cacheName: 'api-cache',
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 }, expiration: { maxEntries: 50, maxAgeSeconds: 60 * 5 },
cacheableResponse: { statuses: [0, 200] } cacheableResponse: { statuses: [0, 200] }
} }
} }
] ]
} }
}) })
], ],
server: { server: {
port: 5173 port: 5173
} }
}) })

View File

@@ -1,12 +1,12 @@
项目名称:萌芽小店项目-支持网关登录商品售卖平台 项目名称:萌芽小店项目-支持网关登录商品售卖平台
时间2026.1 - 2026.03 时间2026.1 - 2026.03
技术栈Golang,Gin,MySQL,Redis,Vue 技术栈Golang,Gin,MySQL,Redis,Vue
描述:前后端分离的轻量级商城平台,支持登录、商品展示与下单、自动/手动发货、邮件通知、收藏夹、客服聊天、维护模式与 PWA 离线缓存。 描述:前后端分离的轻量级商城平台,支持登录、商品展示与下单、自动/手动发货、邮件通知、收藏夹、客服聊天、维护模式与 PWA 离线缓存。
商品与订单管理、发货与订单状态联动、SMTP 邮件通知、后台运维(商品/订单/对话/站点设置)、用户聊天客服与维护模式能力。 商品与订单管理、发货与订单状态联动、SMTP 邮件通知、后台运维(商品/订单/对话/站点设置)、用户聊天客服与维护模式能力。
通过限购与订单状态约束降低并发超卖风险;通过公开/用户/管理员分层实现权限边界隔离;通过分层架构与可运维配置提升可维护性。 通过限购与订单状态约束降低并发超卖风险;通过公开/用户/管理员分层实现权限边界隔离;通过分层架构与可运维配置提升可维护性。
后端采用分层解耦与 ORM 自动建表,支持多环境配置与容器化部署;前端路由守卫与接口封装增强可用性与一致性。 后端采用分层解耦与 ORM 自动建表,支持多环境配置与容器化部署;前端路由守卫与接口封装增强可用性与一致性。
上线地址https://store.shumengya.top 上线地址https://store.shumengya.top
开源地址https://github.com/shumengya/mengyastore 开源地址https://github.com/shumengya/mengyastore

View File

@@ -1,72 +0,0 @@
# 萌芽小店Mengyastore面试经历要点
## 1. 项目一句话
一个前后端分离的轻量级商城系统,支持商品浏览、下单与发货(自动/手动)、邮件通知、聊天客服、收藏夹,以及 PWA 安装与离线缓存。
## 2. 技术栈(可直接复述)
- 前端Vue 3Composition API、Vite、Vue Router 4、Pinia、Axios、`markdown-it`商品描述渲染、PWA`vite-plugin-pwa`
- 后端Go 1.21+、Gin、GORM、MySQL、SMTP 邮件发送net/smtp + TLS
- 部署Docker / docker-compose后端二进制容器化 + 挂载只读配置)
- 认证SproutGate OAuth用户侧 Bearer token 校验);管理员使用 `adminToken``/api/admin/verify` + 请求头/查询参数校验)
## 3. 整体架构
- 前端:按功能拆分模块(`admin/` 管理后台、`store/` 商品/结账、`chat/` 客服、`wishlist/` 收藏、`maintenance/` 维护页),并在路由层做维护模式守卫。
- 后端:使用 `internal/handlers` 按业务拆路由与处理逻辑;使用 `internal/storage` 把数据访问封装成独立存储层;通过 `cmd/migrate` 做旧数据到 MySQL 的迁移/初始化。
- 数据库:以 `products / product_codes / orders / chat_messages / wishlists / site_settings` 等核心表承载业务状态与统计。
## 4. 关键业务链路(面试重点)
### 4.1 商品浏览与下单
- 商品列表:`GET /api/products`
- 记录浏览量:`POST /api/products/:id/view`
- 下单(创建订单与支付流程):`POST /api/checkout`
- 确认付款/触发发货:`POST /api/orders/:id/confirm`
### 4.2 自动发货 vs 手动发货
- 自动发货(`deliveryMode = "auto"`):在下单/确认流程中从 `product_codes` 取出卡密,写入订单 `delivered_codes`,订单完成后返回卡密内容,并更新销量统计。
- 手动发货(`deliveryMode = "manual"`):下单不提取卡密,确认后订单状态变为完成但不返回卡密;管理员在后台查看订单信息后进行发货相关操作。
### 4.3 邮件通知SMTP 可配置)
- 由管理员在后台配置 SMTP`GET/POST /api/admin/site/smtp`
- 下单或状态变更时触发邮件通知(例如订单确认/发货相关通知)。
### 4.4 聊天客服HTTP 轮询)
- 用户端拉取消息:`GET /api/chat/messages`
- 用户端发送消息(有频率限制):`POST /api/chat/messages`
- 管理员端会话管理:
- 获取全部会话:`GET /api/admin/chat`
- 获取指定用户会话:`GET /api/admin/chat/:account`
- 管理员回复:`POST /api/admin/chat/:account`
- 清除对话:`DELETE /api/admin/chat/:account`
### 4.5 维护模式与管理后台鉴权
- 维护模式:前端在路由守卫里调用 `GET /api/site/maintenance`,站点维护时重定向到 `/maintenance`(管理员可豁免)。
- 管理后台鉴权:`POST /api/admin/verify` 校验 token后续管理员接口通过 `X-Admin-Token` 头部携带。
## 5. PWA 体验亮点(可讲“工程化”)
- 支持 `display: standalone` 的安装到桌面体验
- Service Worker静态资源 `CacheFirst`API 请求 `NetworkFirst`API 有短时缓存策略)
- 自动检测新版本并提示更新
- 启动动画与引导SplashScreen 过渡体验)
## 6. 数据治理/防刷点(面试可用)
- 针对购买并发/绕过:通过统计 `pending``completed` 等订单状态来限制每账户购买数量,减少并发下的超额风险。
- 对外接口与管理员接口分离:公开接口与需要登录/需要管理员 token 的接口分层,降低权限误用概率。
## 7. 部署与环境配置(可讲“落地”)
- 后端容器通过 `DATABASE_DSN` 环境变量覆盖配置文件里的 DSN
- `config.json` 作为只读挂载到容器内,减少运行时误写配置的风险
- 端口映射示例:`28081:8080`
## 8. 面试常问我怎么回答(可直接拿来用)
1. 为什么不使用 WebSocket
- 该项目选择 HTTP 轮询实现客服,降低复杂度;在并发不极端的场景下更易落地、便于维护,并且配合频率限制即可控制滥用。
2. 自动发货如何保证数据一致?
- 关键状态更新与卡密提取/写入集中在后端的“创建订单/确认订单”链路内,确保业务流程按接口语义串联执行(如订单完成状态与卡密归属同步)。
3. PWA 离线策略怎么选?
- 静态资源走 `CacheFirst` 提升加载速度与离线能力API 走 `NetworkFirst` 保持业务数据的新鲜度,必要时做短时缓存降抖。
## 9. 你可以按自己的贡献再补一句(占位)
- 个人在项目中主要负责:前端模块/接口封装/后端某模块实现(按实际替换)
- 自己解决过的一个难点:例如“接口鉴权边界/订单发货逻辑/SMTP 配置联调/PWA 缓存策略”等(按实际替换)