This commit is contained in:
sjk
2025-11-28 15:18:10 +08:00
parent ad4a600af9
commit 5683f35942
188 changed files with 53680 additions and 1062 deletions

239
ENVIRONMENTS.md Normal file
View File

@@ -0,0 +1,239 @@
# 环境配置概览
本项目支持多个环境配置,适用于不同的开发和部署场景。
## 📋 环境列表
### 开发与测试环境
| 环境 | 标识 | 配置文件 | 用途 |
|------|------|---------|------|
| 开发环境 | `development` / `dev` | `config.dev.yaml` | 本地开发 |
| 测试环境 | `test` / `testing` | `config.test.yaml` | 功能测试 |
### 生产环境
| 环境 | 标识 | 配置文件 | 地域 | 用户群 |
|------|------|---------|------|--------|
| 生产-通用 | `production` / `prod` | `config.prod.yaml` | 通用 | 全球 |
| 生产-中国 | `production-cn` / `prod-cn` | `config.prod-cn.yaml` | 中国 | 中国大陆用户 |
| 生产-美国 | `production-us` / `prod-us` | `config.prod-us.yaml` | 美国 | 北美用户 |
| 生产-欧洲 | `production-eu` / `prod-eu` | `config.prod-eu.yaml` | 欧洲 | 欧洲用户 |
## 🚀 快速开始
### 后端启动
**使用脚本**(推荐)
```bash
# Windows
.\start-multi-env.bat
# Linux/Mac
chmod +x start-multi-env.sh
./start-multi-env.sh
```
**使用 Makefile**
```bash
make run-dev # 开发环境
make run-test # 测试环境
make run-prod-cn # 中国区生产
make run-prod-us # 美国区生产
make run-prod-eu # 欧洲区生产
```
**直接运行**
```bash
# Windows PowerShell
$env:GO_ENV="prod-cn"; go run cmd/server/main.go
# Linux/Mac
GO_ENV=prod-cn go run cmd/server/main.go
```
### 前端配置
编辑 `miniprogram/config/index.js`
```javascript
// 修改 CURRENT_ENV 变量
const CURRENT_ENV = 'production-cn'; // 或其他环境
```
## 🌍 各区域配置对比
### 服务器配置
| 配置项 | 中国区 | 美国区 | 欧洲区 |
|--------|--------|--------|--------|
| API域名 | api-cn.your-domain.com | api-us.your-domain.com | api-eu.your-domain.com |
| 端口 | 8060 | 8060 | 8060 |
| 模式 | release | release | release |
### 数据库配置
| 配置项 | 中国区 | 美国区 | 欧洲区 |
|--------|--------|--------|--------|
| 主机 | 8.149.233.36 | us-db.your-domain.com | eu-db.your-domain.com |
| 用户 | ai_dianshang | ai_dianshang_us | ai_dianshang_eu |
| 数据库 | ai_dianshang | ai_dianshang | ai_dianshang |
### 存储配置 (OSS)
| 配置项 | 中国区 | 美国区 | 欧洲区 |
|--------|--------|--------|--------|
| Endpoint | oss-cn-beijing.aliyuncs.com | oss-us-west-1.aliyuncs.com | oss-eu-central-1.aliyuncs.com |
| Bucket | bxmkb-beijing | your-us-bucket | your-eu-bucket |
| 地域 | 北京 | 美国西部 | 欧洲中部 |
## 📁 文件结构
```
dianshang/
├── server/
│ ├── configs/
│ │ ├── config.dev.yaml # 开发环境
│ │ ├── config.test.yaml # 测试环境
│ │ ├── config.prod.yaml # 生产-通用
│ │ ├── config.prod-cn.yaml # 生产-中国
│ │ ├── config.prod-us.yaml # 生产-美国
│ │ └── config.prod-eu.yaml # 生产-欧洲
│ ├── internal/config/config.go # 配置加载逻辑
│ ├── 多环境配置说明.md # 详细文档
│ └── docker-compose-multi-env.md # Docker部署文档
├── miniprogram/
│ ├── config/index.js # 前端环境配置
│ └── utils/env-switcher.js # 环境切换工具
├── start-multi-env.sh # Linux/Mac启动脚本
├── start-multi-env.bat # Windows启动脚本
└── Makefile # Make命令集
```
## 🔧 配置管理
### 环境变量优先级
系统按以下优先级检查环境变量:
1. `GO_ENV` (推荐)
2. `APP_ENV`
3. `ENVIRONMENT`
### 敏感信息管理
⚠️ **重要**: 不要将敏感信息提交到代码仓库
**推荐做法**:
1. 使用环境变量覆盖配置
2. 配置文件使用模板(.example
3. 实际配置添加到 `.gitignore`
4. 生产环境使用密钥管理服务
### 配置覆盖
配置可以通过环境变量覆盖,格式:`大写_下划线分隔`
```bash
# 覆盖数据库密码
export DATABASE_PASSWORD=new-password
# 覆盖 JWT 密钥
export JWT_SECRET=new-secret-key
```
## 🐳 Docker 部署
### 中国区
```bash
docker run -e GO_ENV=prod-cn \
-p 8060:8060 \
-v $(pwd)/configs:/app/configs \
your-image
```
### 美国区
```bash
docker run -e GO_ENV=prod-us \
-p 8060:8060 \
-v $(pwd)/configs:/app/configs \
your-image
```
### 欧洲区
```bash
docker run -e GO_ENV=prod-eu \
-p 8060:8060 \
-v $(pwd)/configs:/app/configs \
your-image
```
## 📚 相关文档
- [多环境配置详细说明](server/多环境配置说明.md) - 完整的配置指南
- [Docker多环境部署](server/docker-compose-multi-env.md) - Docker和K8s部署
- [基础配置说明](server/README_CONFIG.md) - 配置基础知识
## ⚙️ 添加新环境
如需添加新的区域(如亚太区),请参考以下步骤:
1. **创建配置文件**
```bash
cp server/configs/config.prod-cn.yaml server/configs/config.prod-ap.yaml
```
2. **修改配置内容**
- 数据库连接
- API域名
- OSS配置
- 等其他区域特定配置
3. **更新代码** - 在 `config.go` 中添加环境映射
4. **更新前端** - 在 `config/index.js` 中添加环境配置
5. **更新文档** - 更新相关文档
详细步骤参见 [多环境配置说明.md](server/多环境配置说明.md#添加新区域)
## 🔍 故障排查
### 配置文件未加载
**检查项**:
- [ ] 环境变量设置正确
- [ ] 配置文件存在于 `configs/` 目录
- [ ] 文件名与环境匹配
- [ ] YAML语法正确
### 数据库连接失败
**检查项**:
- [ ] 数据库配置正确
- [ ] 网络连接正常
- [ ] 防火墙规则
- [ ] 数据库用户权限
### API调用失败
**检查项**:
- [ ] 前后端环境匹配
- [ ] API域名正确
- [ ] 服务已启动
- [ ] CORS配置
## 💡 最佳实践
1. **本地开发** - 使用 `development` 环境
2. **功能测试** - 使用 `test` 环境
3. **生产部署** - 根据用户地域选择对应的生产环境
4. **配置安全** - 敏感信息使用环境变量或密钥服务
5. **环境隔离** - 各环境使用独立的数据库和存储
6. **日志管理** - 不同环境使用不同的日志文件
## 📞 技术支持
如有问题,请查阅:
- [完整配置文档](server/多环境配置说明.md)
- [常见问题](server/多环境配置说明.md#常见问题)
- 项目 README.md

View File

@@ -125,6 +125,44 @@ deploy-docker: ## 构建并推送 Docker 镜像
# docker push $(DOCKER_IMAGE):$(DOCKER_TAG)
# docker push $(DOCKER_IMAGE):latest
# 多环境运行命令
.PHONY: run-dev
run-dev: ## 运行开发环境
cd $(SERVER_DIR) && GO_ENV=development go run cmd/server/main.go
.PHONY: run-test
run-test: ## 运行测试环境
cd $(SERVER_DIR) && GO_ENV=test go run cmd/server/main.go
.PHONY: run-prod
run-prod: ## 运行生产环境(通用)
cd $(SERVER_DIR) && GO_ENV=production go run cmd/server/main.go
.PHONY: run-prod-cn
run-prod-cn: ## 运行生产环境-中国区
cd $(SERVER_DIR) && GO_ENV=prod-cn go run cmd/server/main.go
.PHONY: run-prod-us
run-prod-us: ## 运行生产环境-美国区
cd $(SERVER_DIR) && GO_ENV=prod-us go run cmd/server/main.go
.PHONY: run-prod-eu
run-prod-eu: ## 运行生产环境-欧洲区
cd $(SERVER_DIR) && GO_ENV=prod-eu go run cmd/server/main.go
# 多环境构建命令
.PHONY: build-prod-cn
build-prod-cn: ## 构建中国区生产版本
cd $(SERVER_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -a -installsuffix cgo -o $(BINARY_NAME)-cn cmd/server/main.go
.PHONY: build-prod-us
build-prod-us: ## 构建美国区生产版本
cd $(SERVER_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -a -installsuffix cgo -o $(BINARY_NAME)-us cmd/server/main.go
.PHONY: build-prod-eu
build-prod-eu: ## 构建欧洲区生产版本
cd $(SERVER_DIR) && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -a -installsuffix cgo -o $(BINARY_NAME)-eu cmd/server/main.go
# 工具命令
.PHONY: install-tools
install-tools: ## 安装开发工具

45
admin/.env.prod-cn Normal file
View File

@@ -0,0 +1,45 @@
# 中国区生产环境配置
NODE_ENV=production
# API配置
VITE_API_BASE_URL=https://tral.cc
VITE_API_TIMEOUT=10000
# 应用配置
VITE_APP_TITLE=电商管理后台
VITE_APP_VERSION=1.0.0
VITE_APP_ENV=production-cn
# 构建配置
VITE_BUILD_COMPRESS=gzip
VITE_BUILD_DROP_CONSOLE=true
VITE_BUILD_DROP_DEBUGGER=true
# 上传配置
VITE_UPLOAD_URL=https://tral.cc/api/upload
VITE_UPLOAD_MAX_SIZE=10485760
# CDN配置
VITE_CDN_URL=https://tral.cc/static
# 监控配置
VITE_SENTRY_DSN=
VITE_ENABLE_ANALYTICS=true
# 安全配置
VITE_ENABLE_HTTPS=true
VITE_SECURE_COOKIES=true
# 缓存配置
VITE_CACHE_VERSION=v1.0.0
VITE_ENABLE_SW=true
# 第三方服务
VITE_MAP_KEY=
VITE_OSS_REGION=oss-cn-hangzhou
VITE_OSS_BUCKET=
# 功能开关
VITE_ENABLE_MOCK=false
VITE_ENABLE_DEVTOOLS=false
VITE_ENABLE_VCONSOLE=false

45
admin/.env.prod-eu Normal file
View File

@@ -0,0 +1,45 @@
# 线上环境配置
NODE_ENV=production
# API配置
VITE_API_BASE_URL=http://104.244.91.212:8060
VITE_API_TIMEOUT=10000
# 应用配置
VITE_APP_TITLE=电商管理后台
VITE_APP_VERSION=1.0.0
VITE_APP_ENV=production
# 构建配置
VITE_BUILD_COMPRESS=gzip
VITE_BUILD_DROP_CONSOLE=true
VITE_BUILD_DROP_DEBUGGER=true
# 上传配置
VITE_UPLOAD_URL=http://104.244.91.212:8060/api/upload
VITE_UPLOAD_MAX_SIZE=10485760
# CDN配置
VITE_CDN_URL=http://104.244.91.212:8060/static
# 监控配置
VITE_SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id
VITE_ENABLE_ANALYTICS=true
# 安全配置
VITE_ENABLE_HTTPS=true
VITE_SECURE_COOKIES=true
# 缓存配置
VITE_CACHE_VERSION=v1.0.0
VITE_ENABLE_SW=true
# 第三方服务
VITE_MAP_KEY=your-map-api-key
VITE_OSS_REGION=oss-cn-hangzhou
VITE_OSS_BUCKET=your-bucket-name
# 功能开关
VITE_ENABLE_MOCK=false
VITE_ENABLE_DEVTOOLS=false
VITE_ENABLE_VCONSOLE=false

45
admin/.env.prod-us Normal file
View File

@@ -0,0 +1,45 @@
# 美国区生产环境配置
NODE_ENV=production
# API配置
VITE_API_BASE_URL=https://us.tral.cc
VITE_API_TIMEOUT=15000
# 应用配置
VITE_APP_TITLE=电商管理后台
VITE_APP_VERSION=1.0.0
VITE_APP_ENV=production-us
# 构建配置
VITE_BUILD_COMPRESS=gzip
VITE_BUILD_DROP_CONSOLE=true
VITE_BUILD_DROP_DEBUGGER=true
# 上传配置
VITE_UPLOAD_URL=https://us.tral.cc/api/upload
VITE_UPLOAD_MAX_SIZE=10485760
# CDN配置
VITE_CDN_URL=https://us-cdn.tral.cc/static
# 监控配置
VITE_SENTRY_DSN=
VITE_ENABLE_ANALYTICS=true
# 安全配置
VITE_ENABLE_HTTPS=true
VITE_SECURE_COOKIES=true
# 缓存配置
VITE_CACHE_VERSION=v1.0.0
VITE_ENABLE_SW=true
# 第三方服务
VITE_MAP_KEY=
VITE_OSS_REGION=us-west-1
VITE_OSS_BUCKET=
# 功能开关
VITE_ENABLE_MOCK=false
VITE_ENABLE_DEVTOOLS=false
VITE_ENABLE_VCONSOLE=false

4
admin/.idea/vcs.xml generated
View File

@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings" defaultProject="true" />
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

134
admin/README_ENV.md Normal file
View File

@@ -0,0 +1,134 @@
# Admin 多环境配置说明
## 概述
Admin 端支持多种生产环境配置,可以在打包时灵活选择目标部署环境。
## 环境配置文件
| 文件 | 环境 | API 地址 | 说明 |
|------|------|----------|------|
| `.env` | 开发环境 | http://localhost:8081 | 本地开发使用 |
| `.env.prod` | 默认生产 | https://tral.cc | 原有生产环境 |
| `.env.prod-cn` | 中国区生产 | https://tral.cc | 中国区域部署 |
| `.env.prod-us` | 美国区生产 | https://us.tral.cc | 美国区域部署 |
| `.env.prod-eu` | 欧洲区生产 | https://eu.tral.cc | 欧洲区域部署 |
## 打包方式
### 方式一:使用 npm 命令
```bash
# 中国区
npm run build:prod-cn
# 美国区
npm run build:prod-us
# 欧洲区
npm run build:prod-eu
# 默认生产环境
npm run build:prod
```
### 方式二:使用交互式脚本 (推荐)
**Windows 系统:**
```bash
# 双击运行或在终端执行
build-select.bat
```
**Linux/Mac 系统:**
```bash
# 赋予执行权限
chmod +x build-select.sh
# 运行脚本
./build-select.sh
```
脚本会显示菜单让你选择要打包的目标环境:
```
========================================
电商管理后台 - 多环境打包工具
========================================
请选择要打包的生产环境:
[1] 中国区 (prod-cn) - https://tral.cc
[2] 美国区 (prod-us) - https://us.tral.cc
[3] 欧洲区 (prod-eu) - https://eu.tral.cc
[4] 默认生产 (prod) - https://tral.cc
请输入选项 (1-4):
```
## 环境变量说明
所有环境配置文件都支持以下变量:
### API 配置
- `VITE_API_BASE_URL`: API 基础地址
- `VITE_API_TIMEOUT`: API 请求超时时间(毫秒)
### 应用配置
- `VITE_APP_TITLE`: 应用标题
- `VITE_APP_VERSION`: 应用版本
- `VITE_APP_ENV`: 当前环境标识
### 构建配置
- `VITE_BUILD_COMPRESS`: 构建压缩方式
- `VITE_BUILD_DROP_CONSOLE`: 是否移除 console
- `VITE_BUILD_DROP_DEBUGGER`: 是否移除 debugger
### 上传配置
- `VITE_UPLOAD_URL`: 上传接口地址
- `VITE_UPLOAD_MAX_SIZE`: 上传文件最大大小(字节)
### CDN 配置
- `VITE_CDN_URL`: CDN 地址
### 其他配置
- OSS、监控、缓存等第三方服务配置
## 如何添加新环境
1. 创建新的环境配置文件,如 `.env.prod-jp`:
```bash
# 日本区生产环境配置
NODE_ENV=production
VITE_API_BASE_URL=https://jp.tral.cc
VITE_APP_ENV=production-jp
# ... 其他配置
```
2.`package.json` 中添加新的构建命令:
```json
{
"scripts": {
"build:prod-jp": "vite build --mode prod-jp"
}
}
```
3. 更新 `build-select.bat``build-select.sh` 脚本,添加新选项
## 注意事项
1. **配置文件命名规则**: `.env.[mode]`mode 要和 npm script 中的 `--mode` 参数一致
2. **敏感信息**: 不要在配置文件中提交真实的密钥和敏感信息
3. **环境变量前缀**: Vite 要求自定义环境变量必须以 `VITE_` 开头才能暴露给客户端
4. **代码中使用**: 通过 `import.meta.env.VITE_XXX` 访问环境变量
## 代码中使用环境变量示例
```javascript
// 获取 API 基础地址
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL
// 判断当前环境
const isProduction = import.meta.env.MODE === 'production'
const isProdCN = import.meta.env.VITE_APP_ENV === 'production-cn'
```

56
admin/build-select.bat Normal file
View File

@@ -0,0 +1,56 @@
@echo off
chcp 65001 > nul
echo ========================================
echo 电商管理后台 - 多环境打包工具
echo ========================================
echo.
echo 请选择要打包的生产环境:
echo.
echo [1] 中国区 (prod-cn) - https://tral.cc
echo [2] 美国区 (prod-us) - https://us.tral.cc
echo [3] 欧洲区 (prod-eu) - https://eu.tral.cc
echo [4] 默认生产 (prod) - https://tral.cc
echo.
set /p choice=请输入选项 (1-4):
if "%choice%"=="1" (
echo.
echo 正在打包中国区生产环境...
call npm run build:prod-cn
echo.
echo ✓ 中国区生产环境打包完成!
echo 输出目录: dist/
) else if "%choice%"=="2" (
echo.
echo 正在打包美国区生产环境...
call npm run build:prod-us
echo.
echo ✓ 美国区生产环境打包完成!
echo 输出目录: dist/
) else if "%choice%"=="3" (
echo.
echo 正在打包欧洲区生产环境...
call npm run build:prod-eu
echo.
echo ✓ 欧洲区生产环境打包完成!
echo 输出目录: dist/
) else if "%choice%"=="4" (
echo.
echo 正在打包默认生产环境...
call npm run build:prod
echo.
echo ✓ 默认生产环境打包完成!
echo 输出目录: dist/
) else (
echo.
echo ✗ 无效的选项,请重新运行脚本
goto :end
)
echo.
echo ========================================
echo 打包流程已完成
echo ========================================
:end
pause

59
admin/build-select.sh Normal file
View File

@@ -0,0 +1,59 @@
#!/bin/bash
echo "========================================"
echo " 电商管理后台 - 多环境打包工具"
echo "========================================"
echo ""
echo "请选择要打包的生产环境:"
echo ""
echo "[1] 中国区 (prod-cn) - https://tral.cc"
echo "[2] 美国区 (prod-us) - https://us.tral.cc"
echo "[3] 欧洲区 (prod-eu) - https://eu.tral.cc"
echo "[4] 默认生产 (prod) - https://tral.cc"
echo ""
read -p "请输入选项 (1-4): " choice
case $choice in
1)
echo ""
echo "正在打包中国区生产环境..."
npm run build:prod-cn
echo ""
echo "✓ 中国区生产环境打包完成!"
echo "输出目录: dist/"
;;
2)
echo ""
echo "正在打包美国区生产环境..."
npm run build:prod-us
echo ""
echo "✓ 美国区生产环境打包完成!"
echo "输出目录: dist/"
;;
3)
echo ""
echo "正在打包欧洲区生产环境..."
npm run build:prod-eu
echo ""
echo "✓ 欧洲区生产环境打包完成!"
echo "输出目录: dist/"
;;
4)
echo ""
echo "正在打包默认生产环境..."
npm run build:prod
echo ""
echo "✓ 默认生产环境打包完成!"
echo "输出目录: dist/"
;;
*)
echo ""
echo "✗ 无效的选项,请重新运行脚本"
exit 1
;;
esac
echo ""
echo "========================================"
echo "打包流程已完成"
echo "========================================"

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.coupon-distribute-container[data-v-83fc93d3]{padding:20px}.page-header[data-v-83fc93d3]{margin-bottom:20px}.page-header h2[data-v-83fc93d3]{margin:0;font-size:24px;font-weight:500}.type-card[data-v-83fc93d3],.form-card[data-v-83fc93d3],.history-card[data-v-83fc93d3]{margin-bottom:20px}.card-header[data-v-83fc93d3]{display:flex;justify-content:space-between;align-items:center;font-weight:500}.distribute-type-group[data-v-83fc93d3]{display:flex;gap:16px}.distribute-type-group[data-v-83fc93d3] .el-radio-button{margin-right:12px}.distribute-type-group[data-v-83fc93d3] .el-radio-button:last-child{margin-right:0}.coupon-option[data-v-83fc93d3]{display:flex;justify-content:space-between;align-items:center;width:100%}.coupon-preview[data-v-83fc93d3]{background:#f5f7fa;padding:16px;border-radius:4px}.coupon-preview .preview-item[data-v-83fc93d3]{margin-bottom:8px}.coupon-preview .preview-item[data-v-83fc93d3]:last-child{margin-bottom:0}.coupon-preview .preview-item .label[data-v-83fc93d3]{color:#606266;font-weight:500;margin-right:8px}.coupon-preview .preview-item .value[data-v-83fc93d3]{color:#303133}.form-tip[data-v-83fc93d3]{font-size:12px;color:#909399;margin-top:4px}.pagination-container[data-v-83fc93d3]{margin-top:20px;display:flex;justify-content:flex-end}

View File

@@ -0,0 +1 @@
.coupon-list-container[data-v-b5e9acf1]{padding:20px}.page-header[data-v-b5e9acf1]{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.page-header h2[data-v-b5e9acf1]{margin:0;font-size:24px;font-weight:500}.filter-card[data-v-b5e9acf1]{margin-bottom:20px}.filter-card .filter-form[data-v-b5e9acf1] .el-form-item{margin-bottom:0}.table-card .table-toolbar[data-v-b5e9acf1]{margin-bottom:16px}.table-card .pagination-container[data-v-b5e9acf1]{margin-top:20px;display:flex;justify-content:flex-end}.value-input-group[data-v-b5e9acf1]{display:flex;align-items:center;gap:8px}.value-input-group .value-unit[data-v-b5e9acf1]{color:#606266}.form-tip[data-v-b5e9acf1]{font-size:12px;color:#909399;margin-top:4px}

File diff suppressed because one or more lines are too long

View File

@@ -1,4 +1,4 @@
import{p as P0,s as wa,q as ba,v as R0,j as _n,x as E0,k as Li,y as Mh,z as yp,A as k0,B as Ah,C as O0,D as B0,G as iu,H as pf,r as or,b as N0,c as Pt,I as F0,o as Se,d as z0,e as K,J as G0,l as Ie,w as rt,f as Z,E as H0,t as $t,K as ks,h as Yt}from"./index-fa7a0ed8.js";import{g as V0}from"./orders-9608cfb6.js";import{_ as W0}from"./_plugin-vue_export-helper-c27b6911.js";/*! *****************************************************************************
import{p as P0,s as wa,q as ba,v as R0,j as _n,x as E0,k as Li,y as Mh,z as yp,A as k0,B as Ah,C as O0,D as B0,G as iu,H as pf,r as or,b as N0,c as Pt,I as F0,o as Se,d as z0,e as K,J as G0,l as Ie,w as rt,f as Z,E as H0,t as $t,K as ks,h as Yt}from"./index-01a32b87.js";import{g as V0}from"./orders-dcb3cc2e.js";import{_ as W0}from"./_plugin-vue_export-helper-c27b6911.js";/*! *****************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any

View File

@@ -1 +1 @@
import{u as V,a as h,r as _,b as k,c as r,o as L,d as R,e as u,f as s,w as a,g as U,h as c,t as z,E as g}from"./index-fa7a0ed8.js";import{_ as B}from"./_plugin-vue_export-helper-c27b6911.js";const C={class:"login-container"},E={class:"login-box"},F={__name:"Login",setup(N){const f=V(),w=h(),d=_(),t=_(!1),o=k({username:"",password:"",remember:!1}),b={username:[{required:!0,message:"请输入用户名",trigger:"blur"},{min:3,max:20,message:"用户名长度在 3 到 20 个字符",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"},{min:6,max:20,message:"密码长度在 6 到 20 个字符",trigger:"blur"}]},m=async()=>{if(d.value)try{await d.value.validate(),t.value=!0,await w.login({username:o.username,password:o.password}),g.success("登录成功"),f.push("/")}catch(i){i.message&&g.error(i.message)}finally{t.value=!1}};return(i,e)=>{const p=r("el-input"),n=r("el-form-item"),v=r("el-checkbox"),x=r("el-button"),y=r("el-form");return L(),R("div",C,[u("div",E,[e[4]||(e[4]=u("div",{class:"login-header"},[u("h1",null,"电商管理系统"),u("p",null,"欢迎登录后台管理系统")],-1)),s(y,{ref_key:"loginFormRef",ref:d,model:o,rules:b,class:"login-form",onKeyup:U(m,["enter"])},{default:a(()=>[s(n,{prop:"username"},{default:a(()=>[s(p,{modelValue:o.username,"onUpdate:modelValue":e[0]||(e[0]=l=>o.username=l),placeholder:"请输入用户名",size:"large","prefix-icon":"User"},null,8,["modelValue"])]),_:1}),s(n,{prop:"password"},{default:a(()=>[s(p,{modelValue:o.password,"onUpdate:modelValue":e[1]||(e[1]=l=>o.password=l),type:"password",placeholder:"请输入密码",size:"large","prefix-icon":"Lock","show-password":""},null,8,["modelValue"])]),_:1}),s(n,null,{default:a(()=>[s(v,{modelValue:o.remember,"onUpdate:modelValue":e[2]||(e[2]=l=>o.remember=l)},{default:a(()=>[...e[3]||(e[3]=[c("记住密码",-1)])]),_:1},8,["modelValue"])]),_:1}),s(n,null,{default:a(()=>[s(x,{type:"primary",size:"large",loading:t.value,onClick:m,style:{width:"100%"}},{default:a(()=>[c(z(t.value?"登录中...":"登录"),1)]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"])])])}}},K=B(F,[["__scopeId","data-v-bad2aed0"]]);export{K as default};
import{u as V,a as h,r as _,b as k,c as r,o as L,d as R,e as u,f as s,w as a,g as U,h as c,t as z,E as g}from"./index-01a32b87.js";import{_ as B}from"./_plugin-vue_export-helper-c27b6911.js";const C={class:"login-container"},E={class:"login-box"},F={__name:"Login",setup(N){const f=V(),w=h(),d=_(),t=_(!1),o=k({username:"",password:"",remember:!1}),b={username:[{required:!0,message:"请输入用户名",trigger:"blur"},{min:3,max:20,message:"用户名长度在 3 到 20 个字符",trigger:"blur"}],password:[{required:!0,message:"请输入密码",trigger:"blur"},{min:6,max:20,message:"密码长度在 6 到 20 个字符",trigger:"blur"}]},m=async()=>{if(d.value)try{await d.value.validate(),t.value=!0,await w.login({username:o.username,password:o.password}),g.success("登录成功"),f.push("/")}catch(i){i.message&&g.error(i.message)}finally{t.value=!1}};return(i,e)=>{const p=r("el-input"),n=r("el-form-item"),v=r("el-checkbox"),x=r("el-button"),y=r("el-form");return L(),R("div",C,[u("div",E,[e[4]||(e[4]=u("div",{class:"login-header"},[u("h1",null,"电商管理系统"),u("p",null,"欢迎登录后台管理系统")],-1)),s(y,{ref_key:"loginFormRef",ref:d,model:o,rules:b,class:"login-form",onKeyup:U(m,["enter"])},{default:a(()=>[s(n,{prop:"username"},{default:a(()=>[s(p,{modelValue:o.username,"onUpdate:modelValue":e[0]||(e[0]=l=>o.username=l),placeholder:"请输入用户名",size:"large","prefix-icon":"User"},null,8,["modelValue"])]),_:1}),s(n,{prop:"password"},{default:a(()=>[s(p,{modelValue:o.password,"onUpdate:modelValue":e[1]||(e[1]=l=>o.password=l),type:"password",placeholder:"请输入密码",size:"large","prefix-icon":"Lock","show-password":""},null,8,["modelValue"])]),_:1}),s(n,null,{default:a(()=>[s(v,{modelValue:o.remember,"onUpdate:modelValue":e[2]||(e[2]=l=>o.remember=l)},{default:a(()=>[...e[3]||(e[3]=[c("记住密码",-1)])]),_:1},8,["modelValue"])]),_:1}),s(n,null,{default:a(()=>[s(x,{type:"primary",size:"large",loading:t.value,onClick:m,style:{width:"100%"}},{default:a(()=>[c(z(t.value?"登录中...":"登录"),1)]),_:1},8,["loading"])]),_:1})]),_:1},8,["model"])])])}}},K=B(F,[["__scopeId","data-v-bad2aed0"]]);export{K as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
.category-form[data-v-29505af6]{padding:20px 0}.icon-upload[data-v-29505af6]{display:flex;align-items:center;gap:10px}.icon-preview[data-v-29505af6]{flex-shrink:0}.image-slot[data-v-29505af6]{display:flex;align-items:center;justify-content:center;width:40px;height:40px;background-color:#f5f7fa;color:#909399;border-radius:4px}.dialog-footer[data-v-29505af6]{text-align:right}[data-v-29505af6] .el-tree-select{width:100%}.category-name[data-v-0eb46cf2]{display:flex;align-items:center}.platform-tags[data-v-0eb46cf2]{display:flex;flex-wrap:wrap;gap:4px;justify-content:center}.page-header[data-v-0eb46cf2]{margin-bottom:20px}.page-header h2[data-v-0eb46cf2]{margin:0 0 8px;font-size:20px;font-weight:600}.page-header p[data-v-0eb46cf2]{margin:0;color:#666;font-size:14px}.el-table .cell{display:flex;align-items:center;min-height:40px}.el-table .el-table__row .el-table__indent{display:inline-flex;align-items:center}.el-table .el-table__row .el-table__expand-icon{vertical-align:middle;margin-right:8px}.el-table__body .el-table__row .el-table__cell:first-child{text-align:left}.el-table__body .el-table__row .el-table__cell:first-child .cell{justify-content:flex-start;padding-left:14px}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.category-form[data-v-3f2c1506]{padding:20px 0}.icon-upload[data-v-3f2c1506]{display:flex;align-items:center;gap:10px}.icon-preview[data-v-3f2c1506]{flex-shrink:0}.image-slot[data-v-3f2c1506]{display:flex;align-items:center;justify-content:center;width:40px;height:40px;background-color:#f5f7fa;color:#909399;border-radius:4px}.dialog-footer[data-v-3f2c1506]{text-align:right}[data-v-3f2c1506] .el-tree-select{width:100%}.category-name[data-v-2ced7953]{display:flex;align-items:center}.page-header[data-v-2ced7953]{margin-bottom:20px}.page-header h2[data-v-2ced7953]{margin:0 0 8px;font-size:20px;font-weight:600}.page-header p[data-v-2ced7953]{margin:0;color:#666;font-size:14px}[data-v-2ced7953] .el-table .cell{display:flex;align-items:center;min-height:40px}[data-v-2ced7953] .el-table .el-table__row .el-table__indent{display:inline-flex;align-items:center}[data-v-2ced7953] .el-table .el-table__row .el-table__expand-icon{vertical-align:middle;margin-right:8px}[data-v-2ced7953] .el-table .el-table__body .el-table__row .el-table__cell:first-child{text-align:left}[data-v-2ced7953] .el-table .el-table__body .el-table__row .el-table__cell:first-child .cell{justify-content:flex-start;padding-left:14px}

1
admin/dist/assets/coupons-fba9fa19.js vendored Normal file
View File

@@ -0,0 +1 @@
import{H as o}from"./index-01a32b87.js";function e(t){return o({url:"/admin/api/v1/coupons",method:"get",params:t})}function a(t){return o({url:"/admin/api/v1/coupons",method:"post",data:t})}function i(t,u){return o({url:`/admin/api/v1/coupons/${t}`,method:"put",data:u})}function r(t){return o({url:`/admin/api/v1/coupons/${t}`,method:"delete"})}function p(t){return o({url:"/admin/api/v1/coupons/batch",method:"delete",data:{ids:t}})}function s(t,u){return o({url:`/admin/api/v1/coupons/${t}/status`,method:"put",data:{status:u}})}function d(t){return o({url:"/admin/api/v1/coupons/distribute",method:"post",data:t})}function c(t){return o({url:"/admin/api/v1/coupons/distribute/history",method:"get",params:t})}export{s as a,p as b,a as c,r as d,c as e,d as f,e as g,i as u};

File diff suppressed because one or more lines are too long

1
admin/dist/assets/index-032ae2cb.css vendored Normal file
View File

@@ -0,0 +1 @@
.upload-container[data-v-0e1ebe9a]{width:100%}.upload-placeholder[data-v-0e1ebe9a]{text-align:center;padding:40px 0;border:2px dashed #dcdfe6;border-radius:6px;cursor:pointer;transition:border-color .3s}.upload-placeholder[data-v-0e1ebe9a]:hover{border-color:#409eff}.upload-icon[data-v-0e1ebe9a]{font-size:28px;color:#8c939d;margin-bottom:16px}.upload-text[data-v-0e1ebe9a]{color:#606266;font-size:14px;margin-bottom:8px}.upload-tip[data-v-0e1ebe9a]{color:#909399;font-size:12px}.upload-preview[data-v-0e1ebe9a]{position:relative;border-radius:6px;overflow:hidden;cursor:pointer}.upload-overlay[data-v-0e1ebe9a]{position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:0;transition:opacity .3s}.upload-preview:hover .upload-overlay[data-v-0e1ebe9a]{opacity:1}.dialog-footer[data-v-0e1ebe9a]{text-align:right}.link-tip[data-v-0e1ebe9a]{margin-top:6px;font-size:12px;color:#909399;line-height:1.5}.banner-detail[data-v-5368e06f]{padding:20px 0}.banner-preview[data-v-5368e06f],.detail-section[data-v-5368e06f]{margin-bottom:30px}.section-title[data-v-5368e06f]{font-size:16px;font-weight:600;color:#303133;margin:0 0 20px;padding-bottom:10px;border-bottom:1px solid #ebeef5}.detail-item[data-v-5368e06f]{margin-bottom:15px;display:flex;align-items:flex-start}.detail-item label[data-v-5368e06f]{font-weight:500;color:#606266;min-width:80px;margin-right:10px}.detail-item span[data-v-5368e06f]{color:#303133;word-break:break-all}.stat-card[data-v-5368e06f]{text-align:center;padding:20px;background:#f8f9fa;border-radius:8px;border:1px solid #ebeef5}.stat-value[data-v-5368e06f]{font-size:24px;font-weight:600;color:#409eff;margin-bottom:8px}.stat-label[data-v-5368e06f]{font-size:14px;color:#909399}.dialog-footer[data-v-5368e06f]{text-align:right}.text-gray[data-v-a7ce7d49]{color:#999}.page-header[data-v-a7ce7d49]{margin-bottom:20px}.page-header h2[data-v-a7ce7d49]{margin:0 0 8px;font-size:20px;font-weight:600}.page-header p[data-v-a7ce7d49]{margin:0;color:#666;font-size:14px}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{i as ee,u as te,a as ne,r as oe,j as b,k as le,c as o,o as r,d as v,f as e,w as t,e as _,l as m,h as l,F as se,m as ae,t as g,n as ue}from"./index-fa7a0ed8.js";import{_ as de}from"./_plugin-vue_export-helper-c27b6911.js";const re={class:"admin-layout"},_e={class:"logo"},ce={key:1},ie={class:"header-left"},me={class:"header-right"},pe={class:"user-info"},fe={style:{"margin-left":"8px"}},xe={__name:"index",setup(be){const p=ee(),C=te(),w=ne(),c=oe(!1),f=b(()=>w.userInfo),D=b(()=>{const{path:a}=p;return a}),E=b(()=>{const a=p.matched.filter(d=>d.meta&&d.meta.title),n=[];return a.forEach(d=>{n.push({title:d.meta.title,path:d.path})}),n}),A=()=>{c.value=!c.value},L=()=>{document.fullscreenElement?document.exitFullscreen():document.documentElement.requestFullscreen()},R=async a=>{switch(a){case"profile":break;case"settings":break;case"logout":try{await ue.confirm("确定要退出登录吗?","提示",{confirmButtonText:"确定",cancelButtonText:"取消",type:"warning"}),await w.logout(),C.push("/login")}catch{}break}};return le(()=>p.meta.title,a=>{a&&(document.title=`${a} - 电商管理系统`)},{immediate:!0}),(a,n)=>{const d=o("Shop"),s=o("el-icon"),z=o("DataBoard"),u=o("el-menu-item"),I=o("User"),N=o("Goods"),y=o("el-sub-menu"),T=o("Document"),V=o("RefreshLeft"),q=o("Picture"),G=o("Setting"),M=o("el-menu"),P=o("el-aside"),U=o("Fold"),j=o("Expand"),k=o("el-button"),$=o("el-breadcrumb-item"),H=o("el-breadcrumb"),J=o("FullScreen"),K=o("el-avatar"),O=o("ArrowDown"),x=o("el-dropdown-item"),Q=o("el-dropdown-menu"),W=o("el-dropdown"),X=o("el-header"),Y=o("router-view"),Z=o("el-main"),h=o("el-container");return r(),v("div",re,[e(h,null,{default:t(()=>[e(P,{width:c.value?"64px":"240px",class:"sidebar"},{default:t(()=>[_("div",_e,[c.value?(r(),m(s,{key:0,size:"24"},{default:t(()=>[e(d)]),_:1})):(r(),v("span",ce,"电商管理系统"))]),e(M,{"default-active":D.value,collapse:c.value,"unique-opened":!0,"background-color":"#001529","text-color":"#fff","active-text-color":"#1890ff",router:""},{default:t(()=>[e(u,{index:"/dashboard"},{title:t(()=>[...n[0]||(n[0]=[l("仪表盘",-1)])]),default:t(()=>[e(s,null,{default:t(()=>[e(z)]),_:1})]),_:1}),e(u,{index:"/users"},{title:t(()=>[...n[1]||(n[1]=[l("用户管理",-1)])]),default:t(()=>[e(s,null,{default:t(()=>[e(I)]),_:1})]),_:1}),e(y,{index:"/products"},{title:t(()=>[e(s,null,{default:t(()=>[e(N)]),_:1}),n[2]||(n[2]=_("span",null,"商品管理",-1))]),default:t(()=>[e(u,{index:"/products"},{default:t(()=>[...n[3]||(n[3]=[l("商品列表",-1)])]),_:1}),e(u,{index:"/products/categories"},{default:t(()=>[...n[4]||(n[4]=[l("商品分类",-1)])]),_:1})]),_:1}),e(u,{index:"/orders"},{title:t(()=>[...n[5]||(n[5]=[l("订单管理",-1)])]),default:t(()=>[e(s,null,{default:t(()=>[e(T)]),_:1})]),_:1}),e(u,{index:"/refunds"},{title:t(()=>[...n[6]||(n[6]=[l("退款管理",-1)])]),default:t(()=>[e(s,null,{default:t(()=>[e(V)]),_:1})]),_:1}),e(u,{index:"/banners"},{title:t(()=>[...n[7]||(n[7]=[l("轮播图管理",-1)])]),default:t(()=>[e(s,null,{default:t(()=>[e(q)]),_:1})]),_:1}),e(y,{index:"/system"},{title:t(()=>[e(s,null,{default:t(()=>[e(G)]),_:1}),n[8]||(n[8]=_("span",null,"系统管理",-1))]),default:t(()=>[e(u,{index:"/system/roles"},{default:t(()=>[...n[9]||(n[9]=[l("角色管理",-1)])]),_:1}),e(u,{index:"/system/permissions"},{default:t(()=>[...n[10]||(n[10]=[l("权限管理",-1)])]),_:1})]),_:1})]),_:1},8,["default-active","collapse"])]),_:1},8,["width"]),e(h,{class:"main-content"},{default:t(()=>[e(X,{class:"header"},{default:t(()=>[_("div",ie,[e(k,{type:"text",onClick:A,style:{"font-size":"18px"}},{default:t(()=>[e(s,null,{default:t(()=>[c.value?(r(),m(j,{key:1})):(r(),m(U,{key:0}))]),_:1})]),_:1}),e(H,{class:"breadcrumb",separator:"/"},{default:t(()=>[(r(!0),v(se,null,ae(E.value,i=>(r(),m($,{key:i.path,to:i.path},{default:t(()=>[l(g(i.title),1)]),_:2},1032,["to"]))),128))]),_:1})]),_("div",me,[e(k,{type:"text",onClick:L},{default:t(()=>[e(s,null,{default:t(()=>[e(J)]),_:1})]),_:1}),e(W,{onCommand:R},{dropdown:t(()=>[e(Q,null,{default:t(()=>[e(x,{command:"profile"},{default:t(()=>[...n[11]||(n[11]=[l("个人中心",-1)])]),_:1}),e(x,{command:"settings"},{default:t(()=>[...n[12]||(n[12]=[l("系统设置",-1)])]),_:1}),e(x,{divided:"",command:"logout"},{default:t(()=>[...n[13]||(n[13]=[l("退出登录",-1)])]),_:1})]),_:1})]),default:t(()=>{var i,B;return[_("span",pe,[e(K,{size:32,src:(i=f.value)==null?void 0:i.avatar},{default:t(()=>{var F,S;return[l(g((S=(F=f.value)==null?void 0:F.name)==null?void 0:S.charAt(0)),1)]}),_:1},8,["src"]),_("span",fe,g((B=f.value)==null?void 0:B.name),1),e(s,null,{default:t(()=>[e(O)]),_:1})])]}),_:1})])]),_:1}),e(Z,{class:"content"},{default:t(()=>[e(Y)]),_:1})]),_:1})]),_:1})])}}},we=de(xe,[["__scopeId","data-v-dbfb3303"]]);export{we as default};

1
admin/dist/assets/index-1ba66765.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
.admin-layout[data-v-dbfb3303]{height:100vh}.sidebar[data-v-dbfb3303]{background:#001529;transition:width .3s}.logo[data-v-dbfb3303]{height:64px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px;font-weight:700;border-bottom:1px solid #1f1f1f}.main-content[data-v-dbfb3303]{background:#f0f2f5}.header[data-v-dbfb3303]{background:#fff;padding:0 24px;box-shadow:0 1px 4px #00152914;display:flex;justify-content:space-between;align-items:center}.header-left[data-v-dbfb3303]{display:flex;align-items:center}.breadcrumb[data-v-dbfb3303]{margin-left:16px}.header-right[data-v-dbfb3303]{display:flex;align-items:center;gap:16px}.user-info[data-v-dbfb3303]{display:flex;align-items:center;cursor:pointer;padding:8px;border-radius:4px;transition:background-color .3s}.user-info[data-v-dbfb3303]:hover{background-color:#f5f5f5}.content[data-v-dbfb3303]{padding:16px;min-height:calc(100vh - 64px)}

View File

@@ -1 +1 @@
import{_ as o}from"./_plugin-vue_export-helper-c27b6911.js";import{c as t,o as _,d as c,f as s}from"./index-fa7a0ed8.js";const n={class:"system-container"},r={__name:"index",setup(a){return(p,d)=>{const e=t("router-view");return _(),c("div",n,[s(e)])}}},l=o(r,[["__scopeId","data-v-ec5c7e97"]]);export{l as default};
import{_ as o}from"./_plugin-vue_export-helper-c27b6911.js";import{c as t,o as _,d as c,f as s}from"./index-01a32b87.js";const n={class:"system-container"},r={__name:"index",setup(a){return(p,d)=>{const e=t("router-view");return _(),c("div",n,[s(e)])}}},l=o(r,[["__scopeId","data-v-ec5c7e97"]]);export{l as default};

View File

@@ -1 +0,0 @@
.upload-container[data-v-3798b55f]{width:100%}.upload-placeholder[data-v-3798b55f]{text-align:center;padding:40px 0;border:2px dashed #dcdfe6;border-radius:6px;cursor:pointer;transition:border-color .3s}.upload-placeholder[data-v-3798b55f]:hover{border-color:#409eff}.upload-icon[data-v-3798b55f]{font-size:28px;color:#8c939d;margin-bottom:16px}.upload-text[data-v-3798b55f]{color:#606266;font-size:14px;margin-bottom:8px}.upload-tip[data-v-3798b55f]{color:#909399;font-size:12px}.upload-preview[data-v-3798b55f]{position:relative;border-radius:6px;overflow:hidden;cursor:pointer}.upload-overlay[data-v-3798b55f]{position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:0;transition:opacity .3s}.upload-preview:hover .upload-overlay[data-v-3798b55f]{opacity:1}.dialog-footer[data-v-3798b55f]{text-align:right}.banner-detail[data-v-2059dded]{padding:20px 0}.banner-preview[data-v-2059dded],.detail-section[data-v-2059dded]{margin-bottom:30px}.section-title[data-v-2059dded]{font-size:16px;font-weight:600;color:#303133;margin:0 0 20px;padding-bottom:10px;border-bottom:1px solid #ebeef5}.detail-item[data-v-2059dded]{margin-bottom:15px;display:flex;align-items:flex-start}.detail-item label[data-v-2059dded]{font-weight:500;color:#606266;min-width:80px;margin-right:10px}.detail-item span[data-v-2059dded]{color:#303133;word-break:break-all}.stat-card[data-v-2059dded]{text-align:center;padding:20px;background:#f8f9fa;border-radius:8px;border:1px solid #ebeef5}.stat-value[data-v-2059dded]{font-size:24px;font-weight:600;color:#409eff;margin-bottom:8px}.stat-label[data-v-2059dded]{font-size:14px;color:#909399}.dialog-footer[data-v-2059dded]{text-align:right}.text-gray[data-v-7d7660dd]{color:#999}.page-header[data-v-7d7660dd]{margin-bottom:20px}.page-header h2[data-v-7d7660dd]{margin:0 0 8px;font-size:20px;font-weight:600}.page-header p[data-v-7d7660dd]{margin:0;color:#666;font-size:14px}

1
admin/dist/assets/index-4e0ab2d0.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
admin/dist/assets/index-6974c019.css vendored Normal file
View File

@@ -0,0 +1 @@
.admin-layout[data-v-4293d600]{height:100vh}.sidebar[data-v-4293d600]{background:#001529;transition:width .3s}.logo[data-v-4293d600]{height:64px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:18px;font-weight:700;border-bottom:1px solid #1f1f1f}.main-content[data-v-4293d600]{background:#f0f2f5}.header[data-v-4293d600]{background:#fff;padding:0 24px;box-shadow:0 1px 4px #00152914;display:flex;justify-content:space-between;align-items:center}.header-left[data-v-4293d600]{display:flex;align-items:center}.breadcrumb[data-v-4293d600]{margin-left:16px}.header-right[data-v-4293d600]{display:flex;align-items:center;gap:16px}.user-info[data-v-4293d600]{display:flex;align-items:center;cursor:pointer;padding:8px;border-radius:4px;transition:background-color .3s}.user-info[data-v-4293d600]:hover{background-color:#f5f5f5}.content[data-v-4293d600]{padding:16px;min-height:calc(100vh - 64px)}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{H as e}from"./index-fa7a0ed8.js";const a=r=>e({url:"/admin/api/v1/orders",method:"get",params:r}),d=r=>e({url:`/admin/api/v1/orders/${r}`,method:"get"}),o=(r,t)=>e({url:`/admin/api/v1/orders/${r}/ship`,method:"post",data:t}),n=(r,t)=>e({url:`/admin/api/v1/orders/${r}/cancel`,method:"post",data:{reason:t}}),i=(r,t)=>e({url:`/admin/api/v1/orders/${r}/refund`,method:"post",data:t}),u=()=>e({url:"/admin/api/v1/orders/statistics",method:"get"});export{u as a,d as b,n as c,a as g,i as r,o as s};
import{H as e}from"./index-01a32b87.js";const a=r=>e({url:"/admin/api/v1/orders",method:"get",params:r}),d=r=>e({url:`/admin/api/v1/orders/${r}`,method:"get"}),o=(r,t)=>e({url:`/admin/api/v1/orders/${r}/ship`,method:"post",data:t}),n=(r,t)=>e({url:`/admin/api/v1/orders/${r}/cancel`,method:"post",data:{reason:t}}),i=(r,t)=>e({url:`/admin/api/v1/orders/${r}/refund`,method:"post",data:t}),u=()=>e({url:"/admin/api/v1/orders/statistics",method:"get"});export{u as a,d as b,n as c,a as g,i as r,o as s};

View File

@@ -0,0 +1 @@
import{H as a}from"./index-01a32b87.js";const o=t=>a({url:"/admin/api/v1/platforms",method:"get",params:t}),m=t=>a({url:"/admin/api/v1/platforms",method:"post",data:t}),s=(t,r)=>a({url:`/admin/api/v1/platforms/${t}`,method:"put",data:r}),l=t=>a({url:`/admin/api/v1/platforms/${t}`,method:"delete"}),n=()=>a({url:"/admin/api/v1/platforms/all/active",method:"get"});export{o as a,m as c,l as d,n as g,s as u};

View File

@@ -0,0 +1 @@
.platform-form[data-v-431bf2a8]{padding:20px 0}.icon-selector[data-v-431bf2a8]{width:100%}.icon-quick-select[data-v-431bf2a8]{display:flex;gap:8px;margin-top:10px;flex-wrap:wrap}.icon-item[data-v-431bf2a8]{width:36px;height:36px;display:flex;align-items:center;justify-content:center;font-size:20px;border:1px solid #dcdfe6;border-radius:4px;cursor:pointer;transition:all .3s}.icon-item[data-v-431bf2a8]:hover{border-color:#409eff;background-color:#ecf5ff}.icon-item.active[data-v-431bf2a8]{border-color:#409eff;background-color:#409eff;color:#fff}.dialog-footer[data-v-431bf2a8]{text-align:right}.page-header[data-v-b3f453fc]{margin-bottom:20px}.page-header h2[data-v-b3f453fc]{margin:0 0 8px;font-size:20px;font-weight:600}.page-header p[data-v-b3f453fc]{margin:0;color:#666;font-size:14px}.platform-icon[data-v-b3f453fc]{font-size:24px}.pagination[data-v-b3f453fc]{margin-top:20px;display:flex;justify-content:flex-end}

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
import{H as e}from"./index-fa7a0ed8.js";const o=t=>e({url:"/admin/api/v1/products",method:"get",params:t}),d=t=>e({url:`/admin/api/v1/products/${t}`,method:"get"}),u=t=>e({url:"/admin/api/v1/products",method:"post",data:t}),s=(t,r)=>e({url:`/admin/api/v1/products/${t}`,method:"put",data:r}),c=t=>e({url:`/admin/api/v1/products/${t}`,method:"delete"}),n=t=>e({url:"/admin/api/v1/products/batch",method:"delete",data:{ids:t}}),i=(t,r)=>e({url:`/admin/api/v1/products/${t}`,method:"put",data:{status:r}}),p=t=>e({url:"/admin/api/v1/categories",method:"get",params:t}),m=t=>e({url:"/admin/api/v1/categories",method:"post",data:t}),l=(t,r)=>e({url:`/admin/api/v1/categories/${t}`,method:"put",data:r}),g=t=>e({url:`/admin/api/v1/categories/${t}`,method:"delete"});export{o as a,d as b,u as c,c as d,i as e,n as f,p as g,l as h,m as i,g as j,s as u};
import{H as e}from"./index-01a32b87.js";const o=t=>e({url:"/admin/api/v1/products",method:"get",params:t}),d=t=>e({url:`/admin/api/v1/products/${t}`,method:"get"}),u=t=>e({url:"/admin/api/v1/products",method:"post",data:t}),s=(t,r)=>e({url:`/admin/api/v1/products/${t}`,method:"put",data:r}),c=t=>e({url:`/admin/api/v1/products/${t}`,method:"delete"}),n=t=>e({url:"/admin/api/v1/products/batch",method:"delete",data:{ids:t}}),i=(t,r)=>e({url:`/admin/api/v1/products/${t}`,method:"put",data:{status:r}}),p=t=>e({url:"/admin/api/v1/categories",method:"get",params:t}),m=t=>e({url:"/admin/api/v1/categories",method:"post",data:t}),l=(t,r)=>e({url:`/admin/api/v1/categories/${t}`,method:"put",data:r}),g=t=>e({url:`/admin/api/v1/categories/${t}`,method:"delete"});export{o as a,d as b,u as c,c as d,i as e,n as f,p as g,l as h,m as i,g as j,s as u};

View File

@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>电商后台管理系统</title>
<script type="module" crossorigin src="/assets/index-fa7a0ed8.js"></script>
<script type="module" crossorigin src="/assets/index-01a32b87.js"></script>
<link rel="stylesheet" href="/assets/index-8e8d8ff2.css">
</head>
<body>

View File

@@ -6,6 +6,9 @@
"dev": "vite",
"build": "vite build",
"build:prod": "vite build --mode production",
"build:prod-cn": "vite build --mode prod-cn",
"build:prod-us": "vite build --mode prod-us",
"build:prod-eu": "vite build --mode prod-eu",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},

98
admin/src/api/coupons.js Normal file
View File

@@ -0,0 +1,98 @@
import request from './request'
// 获取优惠券列表
export function getCouponList(params) {
return request({
url: '/admin/api/v1/coupons',
method: 'get',
params
})
}
// 获取优惠券详情
export function getCouponDetail(id) {
return request({
url: `/admin/api/v1/coupons/${id}`,
method: 'get'
})
}
// 创建优惠券
export function createCoupon(data) {
return request({
url: '/admin/api/v1/coupons',
method: 'post',
data
})
}
// 更新优惠券
export function updateCoupon(id, data) {
return request({
url: `/admin/api/v1/coupons/${id}`,
method: 'put',
data
})
}
// 删除优惠券
export function deleteCoupon(id) {
return request({
url: `/admin/api/v1/coupons/${id}`,
method: 'delete'
})
}
// 批量删除优惠券
export function batchDeleteCoupons(ids) {
return request({
url: '/admin/api/v1/coupons/batch',
method: 'delete',
data: { ids }
})
}
// 更新优惠券状态
export function updateCouponStatus(id, status) {
return request({
url: `/admin/api/v1/coupons/${id}/status`,
method: 'put',
data: { status }
})
}
// 获取优惠券统计
export function getCouponStatistics(params) {
return request({
url: '/admin/api/v1/coupons/statistics',
method: 'get',
params
})
}
// 获取用户优惠券列表
export function getUserCouponList(params) {
return request({
url: '/admin/api/v1/coupons/user-coupons',
method: 'get',
params
})
}
// 发放优惠券
export function distributeCoupon(data) {
return request({
url: '/admin/api/v1/coupons/distribute',
method: 'post',
data
})
}
// 获取发放历史
export function getDistributeHistory(params) {
return request({
url: '/admin/api/v1/coupons/distribute/history',
method: 'get',
params
})
}

View File

@@ -0,0 +1,62 @@
import request from './request'
// 获取投流源列表
export const getLiveStreamList = (params) => {
return request({
url: '/admin/api/v1/livestreams',
method: 'get',
params
})
}
// 获取投流源详情
export const getLiveStreamDetail = (id) => {
return request({
url: `/admin/api/v1/livestreams/${id}`,
method: 'get'
})
}
// 创建投流源
export const createLiveStream = (data) => {
return request({
url: '/admin/api/v1/livestreams',
method: 'post',
data
})
}
// 更新投流源
export const updateLiveStream = (id, data) => {
return request({
url: `/admin/api/v1/livestreams/${id}`,
method: 'put',
data
})
}
// 删除投流源
export const deleteLiveStream = (id) => {
return request({
url: `/admin/api/v1/livestreams/${id}`,
method: 'delete'
})
}
// 批量删除投流源
export const batchDeleteLiveStreams = (ids) => {
return request({
url: '/admin/api/v1/livestreams/batch',
method: 'delete',
data: { ids }
})
}
// 更新投流源状态
export const updateLiveStreamStatus = (id, status) => {
return request({
url: `/admin/api/v1/livestreams/${id}/status`,
method: 'put',
data: { status }
})
}

52
admin/src/api/platform.js Normal file
View File

@@ -0,0 +1,52 @@
import request from './request'
// 获取平台列表
export const getPlatformList = (params) => {
return request({
url: '/admin/api/v1/platforms',
method: 'get',
params
})
}
// 获取平台详情
export const getPlatformDetail = (id) => {
return request({
url: `/admin/api/v1/platforms/${id}`,
method: 'get'
})
}
// 创建平台
export const createPlatform = (data) => {
return request({
url: '/admin/api/v1/platforms',
method: 'post',
data
})
}
// 更新平台
export const updatePlatform = (id, data) => {
return request({
url: `/admin/api/v1/platforms/${id}`,
method: 'put',
data
})
}
// 删除平台
export const deletePlatform = (id) => {
return request({
url: `/admin/api/v1/platforms/${id}`,
method: 'delete'
})
}
// 获取所有启用的平台(用于分类选择)
export const getActivePlatforms = () => {
return request({
url: '/admin/api/v1/platforms/all/active',
method: 'get'
})
}

View File

@@ -259,4 +259,13 @@ export const deleteProductSKU = (id) => {
url: `/admin/api/v1/products/skus/${id}`,
method: 'delete'
})
}
// 更新分类平台关联
export const updateCategoryPlatforms = (id, platformIds) => {
return request({
url: `/admin/api/v1/categories/${id}/platforms`,
method: 'put',
data: { platform_ids: platformIds }
})
}

View File

@@ -51,11 +51,26 @@
<template #title>轮播图管理</template>
</el-menu-item>
<el-menu-item index="/livestreams">
<el-icon><VideoCamera /></el-icon>
<template #title>直播投流源</template>
</el-menu-item>
<el-sub-menu index="/coupons">
<template #title>
<el-icon><Tickets /></el-icon>
<span>优惠券管理</span>
</template>
<el-menu-item index="/coupons">优惠券列表</el-menu-item>
<el-menu-item index="/coupons/distribute">优惠券发放</el-menu-item>
</el-sub-menu>
<el-sub-menu index="/system">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item index="/system/platforms">平台管理</el-menu-item>
<el-menu-item index="/system/roles">角色管理</el-menu-item>
<el-menu-item index="/system/permissions">权限管理</el-menu-item>
</el-sub-menu>

View File

@@ -56,12 +56,36 @@ const routes = [
component: () => import('@/views/banners/index.vue'),
meta: { title: '轮播图管理', icon: 'Picture' }
},
{
path: 'livestreams',
name: 'LiveStreams',
component: () => import('@/views/livestreams/index.vue'),
meta: { title: '直播投流源', icon: 'VideoCamera' }
},
{
path: 'coupons',
name: 'Coupons',
component: () => import('@/views/coupons/CouponList.vue'),
meta: { title: '优惠券管理', icon: 'Tickets' }
},
{
path: 'coupons/distribute',
name: 'CouponDistribute',
component: () => import('@/views/coupons/CouponDistribute.vue'),
meta: { title: '优惠券发放', icon: 'Present' }
},
{
path: 'system',
name: 'System',
component: () => import('@/views/system/index.vue'),
meta: { title: '系统管理', icon: 'Setting' },
children: [
{
path: 'platforms',
name: 'Platforms',
component: () => import('@/views/system/platforms.vue'),
meta: { title: '平台管理' }
},
{
path: 'roles',
name: 'Roles',

View File

@@ -72,32 +72,6 @@
</div>
</div>
<!-- 有效期信息 -->
<div class="detail-section">
<h3 class="section-title">有效期信息</h3>
<el-row :gutter="20">
<el-col :span="12">
<div class="detail-item">
<label>开始时间</label>
<span>{{ bannerData.start_time ? formatDate(bannerData.start_time) : '无限制' }}</span>
</div>
</el-col>
<el-col :span="12">
<div class="detail-item">
<label>结束时间</label>
<span>{{ bannerData.end_time ? formatDate(bannerData.end_time) : '无限制' }}</span>
</div>
</el-col>
</el-row>
<div class="detail-item">
<label>有效状态</label>
<el-tag :type="getValidStatusType()">
{{ getValidStatusText() }}
</el-tag>
</div>
</div>
<!-- 统计信息 -->
<div class="detail-section">
<h3 class="section-title">统计信息</h3>
@@ -140,11 +114,6 @@
</div>
</el-col>
</el-row>
<div v-if="bannerData.remark" class="detail-item">
<label>备注</label>
<span>{{ bannerData.remark }}</span>
</div>
</div>
</div>
@@ -178,53 +147,14 @@ const emit = defineEmits(['update:visible', 'edit'])
// 获取链接类型文本
const getLinkTypeText = (type) => {
const typeMap = {
'none': '无链接',
'external': '外部链接',
'product': '商品详情',
'category': '分类页面',
'activity': '活动页面'
1: '无链接',
2: '商品详情',
3: '分类页面',
4: '外部链接'
}
return typeMap[type] || '未知'
}
// 获取有效状态类型
const getValidStatusType = () => {
if (!props.bannerData?.start_time || !props.bannerData?.end_time) {
return 'success'
}
const now = new Date()
const startTime = new Date(props.bannerData.start_time)
const endTime = new Date(props.bannerData.end_time)
if (now < startTime) {
return 'warning'
} else if (now > endTime) {
return 'danger'
} else {
return 'success'
}
}
// 获取有效状态文本
const getValidStatusText = () => {
if (!props.bannerData?.start_time || !props.bannerData?.end_time) {
return '永久有效'
}
const now = new Date()
const startTime = new Date(props.bannerData.start_time)
const endTime = new Date(props.bannerData.end_time)
if (now < startTime) {
return '未开始'
} else if (now > endTime) {
return '已过期'
} else {
return '进行中'
}
}
// 计算点击率
const getClickRate = () => {
if (!props.bannerData?.view_count || props.bannerData.view_count === 0) {

View File

@@ -92,11 +92,65 @@
</el-form-item>
<el-form-item label="链接地址" prop="link_value">
<el-input
<!-- 商品详情下拉选择商品 -->
<el-select
v-if="formData.link_type === 2"
v-model="formData.link_value"
placeholder="请输入点击跳转的链接地址(可选)"
placeholder="请选择商品"
filterable
remote
:remote-method="searchProducts"
:loading="productLoading"
style="width: 100%"
clearable
>
<el-option
v-for="product in productList"
:key="product.id"
:label="`${product.name} (ID: ${product.id})`"
:value="String(product.id)"
/>
</el-select>
<!-- 分类页面下拉选择分类 -->
<el-select
v-else-if="formData.link_type === 3"
v-model="formData.link_value"
placeholder="请选择分类"
filterable
:loading="categoryLoading"
style="width: 100%"
clearable
>
<el-option
v-for="category in categoryList"
:key="category.id"
:label="`${category.name} (ID: ${category.id})`"
:value="String(category.id)"
/>
</el-select>
<!-- 外部链接输入框 -->
<el-input
v-else-if="formData.link_type === 4"
v-model="formData.link_value"
:placeholder="getLinkValuePlaceholder"
maxlength="500"
/>
<!-- 无链接禁用的输入框 -->
<el-input
v-else
v-model="formData.link_value"
:placeholder="getLinkValuePlaceholder"
disabled
/>
<div v-if="formData.link_type !== 1" class="link-tip">
<span v-if="formData.link_type === 2">请从列表中选择商品支持搜索商品名称</span>
<span v-else-if="formData.link_type === 3">请从列表中选择分类</span>
<span v-else-if="formData.link_type === 4">请输入完整URL例如https://example.com</span>
</div>
</el-form-item>
<el-row :gutter="20">
@@ -107,11 +161,10 @@
placeholder="请选择链接类型"
style="width: 100%"
>
<el-option label="无链接" value="none" />
<el-option label="外部链接" value="external" />
<el-option label="商品详情" value="product" />
<el-option label="分类页面" value="category" />
<el-option label="活动页面" value="activity" />
<el-option label="无链接" :value="1" />
<el-option label="商品详情" :value="2" />
<el-option label="分类页面" :value="3" />
<el-option label="外部链接" :value="4" />
</el-select>
</el-form-item>
</el-col>
@@ -125,37 +178,6 @@
</el-form-item>
</el-col>
</el-row>
<el-form-item label="有效期">
<el-checkbox v-model="hasValidPeriod" @change="handleValidPeriodChange">
设置有效期
</el-checkbox>
<div v-if="hasValidPeriod" style="margin-top: 10px;">
<el-date-picker
v-model="validPeriod"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
@change="handleValidPeriodSelect"
/>
</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="2"
placeholder="请输入备注信息(可选)"
maxlength="200"
show-word-limit
/>
</el-form-item>
</el-form>
<template #footer>
@@ -170,10 +192,11 @@
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Edit } from '@element-plus/icons-vue'
import { createBanner, updateBanner, uploadImage } from '@/api/banners'
import { getProductList, getCategoryList } from '@/api/products'
import { useAuthStore } from '@/stores/auth'
// Props
@@ -198,8 +221,10 @@ const authStore = useAuthStore()
const formRef = ref()
const uploadRef = ref()
const submitting = ref(false)
const hasValidPeriod = ref(false)
const validPeriod = ref([])
const productList = ref([])
const categoryList = ref([])
const productLoading = ref(false)
const categoryLoading = ref(false)
// 表单数据
const formData = reactive({
@@ -209,10 +234,7 @@ const formData = reactive({
link_value: '',
link_type: 1,
sort: 0,
status: 1,
start_time: null,
end_time: null,
remark: ''
status: 1
})
// 表单验证规则
@@ -228,12 +250,28 @@ const formRules = {
link_value: [
{
validator: (rule, value, callback) => {
if (formData.link_type === 4 && value) {
// 外部链接类型需要验证URL格式
if (formData.link_type === 4) {
if (!value) {
callback(new Error('请输入外部链接地址'))
return
}
const urlPattern = /^https?:\/\/.+/
if (!urlPattern.test(value)) {
callback(new Error('请输入有效的URL地址'))
callback(new Error('请输入有效的URL地址必须以http://或https://开头'))
return
}
}
// 商品详情类型需要选择商品
if (formData.link_type === 2 && !value) {
callback(new Error('请选择商品'))
return
}
// 分类页面类型需要选择分类
if (formData.link_type === 3 && !value) {
callback(new Error('请选择分类'))
return
}
callback()
},
trigger: 'blur'
@@ -254,6 +292,22 @@ const uploadHeaders = computed(() => {
}
})
// 链接地址输入提示
const getLinkValuePlaceholder = computed(() => {
switch (formData.link_type) {
case 1:
return '无需填写链接地址'
case 2:
return '请选择商品'
case 3:
return '请选择分类'
case 4:
return '请输入完整的URL地址例如https://example.com'
default:
return '请输入链接地址'
}
})
// 监听弹窗显示状态
watch(() => props.visible, (visible) => {
if (visible) {
@@ -261,15 +315,32 @@ watch(() => props.visible, (visible) => {
if (props.bannerData) {
Object.assign(formData, props.bannerData)
// 处理有效期
if (formData.start_time && formData.end_time) {
hasValidPeriod.value = true
validPeriod.value = [formData.start_time, formData.end_time]
// 如果是编辑且有链接值,加载对应数据
if (formData.link_value) {
if (formData.link_type === 2) {
// 加载选中的商品信息
loadSelectedProduct(formData.link_value)
} else if (formData.link_type === 3) {
// 分类列表在组件挂载时已加载
}
}
}
}
})
// 监听链接类型变化
watch(() => formData.link_type, (newType, oldType) => {
// 切换类型时清空链接地址
if (newType !== oldType) {
formData.link_value = ''
}
// 切换到分类类型时,如果分类列表为空则加载
if (newType === 3 && categoryList.value.length === 0) {
loadCategories()
}
})
// 重置表单
const resetForm = () => {
Object.assign(formData, {
@@ -279,39 +350,14 @@ const resetForm = () => {
link_value: '',
link_type: 1,
sort: 0,
status: 1,
start_time: null,
end_time: null,
remark: ''
status: 1
})
hasValidPeriod.value = false
validPeriod.value = []
if (formRef.value) {
formRef.value.clearValidate()
}
}
// 处理有效期变更
const handleValidPeriodChange = (checked) => {
if (!checked) {
validPeriod.value = []
formData.start_time = null
formData.end_time = null
}
}
// 处理有效期选择
const handleValidPeriodSelect = (dates) => {
if (dates && dates.length === 2) {
formData.start_time = dates[0]
formData.end_time = dates[1]
} else {
formData.start_time = null
formData.end_time = null
}
}
// 上传前验证
const beforeUpload = (file) => {
const isImage = file.type.startsWith('image/')
@@ -383,6 +429,98 @@ const handleSubmit = async () => {
const handleClose = () => {
emit('update:visible', false)
}
// 搜索商品(远程搜索)
const searchProducts = async (query) => {
if (!query) {
// 如果没有搜索词,加载默认列表
loadProducts()
return
}
try {
productLoading.value = true
const res = await getProductList({
page: 1,
size: 20,
keyword: query,
status: 1 // 只显示上架的商品
})
if (res.code === 200) {
productList.value = res.data.list || []
}
} catch (error) {
console.error('搜索商品失败:', error)
} finally {
productLoading.value = false
}
}
// 加载商品列表(默认)
const loadProducts = async () => {
try {
productLoading.value = true
const res = await getProductList({
page: 1,
size: 20,
status: 1 // 只显示上架的商品
})
if (res.code === 200) {
productList.value = res.data.list || []
}
} catch (error) {
console.error('加载商品列表失败:', error)
} finally {
productLoading.value = false
}
}
// 加载选中的商品(编辑时使用)
const loadSelectedProduct = async (productId) => {
try {
productLoading.value = true
const res = await getProductList({
page: 1,
size: 20,
status: 1
})
if (res.code === 200) {
productList.value = res.data.list || []
}
} catch (error) {
console.error('加载商品失败:', error)
} finally {
productLoading.value = false
}
}
// 加载分类列表
const loadCategories = async () => {
try {
categoryLoading.value = true
const res = await getCategoryList({
page: 1,
size: 100,
status: 1 // 只显示启用的分类
})
if (res.code === 200) {
categoryList.value = res.data.list || []
}
} catch (error) {
console.error('加载分类列表失败:', error)
} finally {
categoryLoading.value = false
}
}
// 组件挂载时加载分类列表
onMounted(() => {
loadCategories()
})
</script>
<style scoped>
@@ -450,4 +588,11 @@ const handleClose = () => {
.dialog-footer {
text-align: right;
}
.link-tip {
margin-top: 6px;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
</style>

View File

@@ -103,16 +103,6 @@
</template>
</el-table-column>
<el-table-column label="有效期" width="180">
<template #default="{ row }">
<div v-if="row.start_time && row.end_time">
<div>{{ formatDate(row.start_time) }}</div>
<div>{{ formatDate(row.end_time) }}</div>
</div>
<span v-else class="text-gray">永久有效</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}

View File

@@ -0,0 +1,504 @@
<template>
<div class="coupon-distribute-container">
<!-- 页面头部 -->
<div class="page-header">
<h2>优惠券发放</h2>
</div>
<!-- 发放类型选择卡片 -->
<el-card class="type-card" shadow="never">
<template #header>
<div class="card-header">
<span>选择发放方式</span>
</div>
</template>
<el-radio-group v-model="distributeType" class="distribute-type-group">
<el-radio-button label="single">单个用户发放</el-radio-button>
<el-radio-button label="batch">批量用户发放</el-radio-button>
<el-radio-button label="all">全员发放</el-radio-button>
</el-radio-group>
</el-card>
<!-- 发放表单 -->
<el-card class="form-card" shadow="never">
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="120px"
>
<el-form-item label="选择优惠券" prop="coupon_id">
<el-select
v-model="formData.coupon_id"
placeholder="请选择要发放的优惠券"
filterable
style="width: 100%"
@change="handleCouponChange"
>
<el-option
v-for="coupon in couponList"
:key="coupon.id"
:label="`${coupon.name} (${getCouponTypeText(coupon.type)})`"
:value="coupon.id"
>
<div class="coupon-option">
<span>{{ coupon.name }}</span>
<el-tag :type="getCouponTypeTag(coupon.type)" size="small">
{{ getCouponTypeText(coupon.type) }}
</el-tag>
</div>
</el-option>
</el-select>
</el-form-item>
<!-- 优惠券详情预览 -->
<el-form-item label="优惠券详情" v-if="selectedCoupon">
<div class="coupon-preview">
<div class="preview-item">
<span class="label">优惠内容</span>
<span class="value">{{ getCouponValueText(selectedCoupon) }}</span>
</div>
<div class="preview-item">
<span class="label">有效期</span>
<span class="value">{{ formatDateTime(selectedCoupon.start_time) }} {{ formatDateTime(selectedCoupon.end_time) }}</span>
</div>
<div class="preview-item">
<span class="label">库存</span>
<span class="value">
{{ selectedCoupon.total_count === 0 ? '不限量' : `${selectedCoupon.total_count - selectedCoupon.used_count} / ${selectedCoupon.total_count}` }}
</span>
</div>
</div>
</el-form-item>
<!-- 单个用户发放 -->
<template v-if="distributeType === 'single'">
<el-form-item label="用户ID" prop="user_id">
<el-input
v-model.number="formData.user_id"
placeholder="请输入用户ID"
type="number"
clearable
/>
</el-form-item>
</template>
<!-- 批量用户发放 -->
<template v-if="distributeType === 'batch'">
<el-form-item label="用户ID列表" prop="user_ids">
<el-input
v-model="formData.user_ids_text"
type="textarea"
:rows="5"
placeholder="请输入用户ID每行一个或用英文逗号分隔&#10;例如:&#10;1&#10;2&#10;3&#10;或1,2,3"
/>
<div class="form-tip">
支持两种格式每行一个ID 用英文逗号分隔
</div>
</el-form-item>
</template>
<!-- 全员发放 -->
<template v-if="distributeType === 'all'">
<el-alert
title="注意"
type="warning"
:closable="false"
show-icon
>
全员发放将给系统中所有用户发放该优惠券请谨慎操作
</el-alert>
</template>
<el-form-item label="发放数量" prop="quantity" v-if="distributeType !== 'all'">
<el-input-number
v-model="formData.quantity"
:min="1"
:max="100"
:step="1"
/>
<div class="form-tip">每个用户发放的优惠券数量</div>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
<el-icon><Present /></el-icon>
确认发放
</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 发放历史 -->
<el-card class="history-card" shadow="never">
<template #header>
<div class="card-header">
<span>最近发放记录</span>
<el-button type="text" @click="fetchDistributeHistory">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</template>
<el-table :data="historyList" v-loading="historyLoading" style="width: 100%">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="优惠券" min-width="200">
<template #default="{ row }">
{{ row.coupon_name }}
</template>
</el-table-column>
<el-table-column label="发放方式" width="120">
<template #default="{ row }">
<el-tag v-if="row.distribute_type === 'single'">单个用户</el-tag>
<el-tag v-else-if="row.distribute_type === 'batch'" type="warning">批量用户</el-tag>
<el-tag v-else-if="row.distribute_type === 'all'" type="danger">全员发放</el-tag>
</template>
</el-table-column>
<el-table-column label="发放数量" width="120">
<template #default="{ row }">
{{ row.total_count }}
</template>
</el-table-column>
<el-table-column label="成功/失败" width="120">
<template #default="{ row }">
<span style="color: #67c23a">{{ row.success_count }}</span>
/
<span style="color: #f56c6c">{{ row.fail_count }}</span>
</template>
</el-table-column>
<el-table-column label="操作人" width="120">
<template #default="{ row }">
{{ row.admin_name || '-' }}
</template>
</el-table-column>
<el-table-column label="发放时间" width="180">
<template #default="{ row }">
{{ formatDateTime(row.created_at) }}
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
:current-page="historyPage"
:page-size="historyPageSize"
:page-sizes="[10, 20, 50]"
:total="historyTotal"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleHistorySizeChange"
@current-change="handleHistoryPageChange"
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Present, Refresh } from '@element-plus/icons-vue'
import { getCouponList, distributeCoupon, getDistributeHistory } from '@/api/coupons'
// 响应式数据
const distributeType = ref('single')
const formRef = ref(null)
const submitLoading = ref(false)
const couponList = ref([])
const selectedCoupon = ref(null)
const formData = reactive({
coupon_id: '',
user_id: null,
user_ids_text: '',
quantity: 1
})
const formRules = {
coupon_id: [{ required: true, message: '请选择优惠券', trigger: 'change' }],
user_id: [
{ required: true, message: '请输入用户ID', trigger: 'blur' },
{ type: 'number', message: '用户ID必须为数字', trigger: 'blur' }
],
user_ids_text: [{ required: true, message: '请输入用户ID列表', trigger: 'blur' }],
quantity: [
{ required: true, message: '请输入发放数量', trigger: 'blur' },
{ type: 'number', min: 1, max: 100, message: '发放数量必须在1-100之间', trigger: 'blur' }
]
}
// 发放历史
const historyList = ref([])
const historyLoading = ref(false)
const historyPage = ref(1)
const historyPageSize = ref(10)
const historyTotal = ref(0)
// 获取优惠券列表
const fetchCouponList = async () => {
try {
const { data } = await getCouponList({ page: 1, page_size: 1000, status: 1 })
couponList.value = data.list || []
} catch (error) {
ElMessage.error('获取优惠券列表失败')
}
}
// 优惠券选择变化
const handleCouponChange = (couponId) => {
selectedCoupon.value = couponList.value.find(c => c.id === couponId)
}
// 获取优惠券类型文本
const getCouponTypeText = (type) => {
const typeMap = { 1: '满减券', 2: '折扣券', 3: '免邮券' }
return typeMap[type] || '未知'
}
// 获取优惠券类型标签
const getCouponTypeTag = (type) => {
const tagMap = { 1: 'success', 2: 'warning', 3: 'info' }
return tagMap[type] || ''
}
// 获取优惠券面值文本
const getCouponValueText = (coupon) => {
if (coupon.type === 1) {
return `${(coupon.min_amount / 100).toFixed(2)}元减${(coupon.value / 100).toFixed(2)}`
} else if (coupon.type === 2) {
return `${(coupon.value / 10).toFixed(1)}`
} else if (coupon.type === 3) {
return '免邮'
}
return '-'
}
// 格式化日期时间
const formatDateTime = (dateTime) => {
if (!dateTime) return '-'
return new Date(dateTime).toLocaleString('zh-CN')
}
// 解析用户ID列表
const parseUserIds = (text) => {
if (!text) return []
// 先按换行符分割,再按逗号分割
const ids = text
.split(/[\n,]/)
.map(id => id.trim())
.filter(id => id !== '')
.map(id => parseInt(id))
.filter(id => !isNaN(id))
return [...new Set(ids)] // 去重
}
// 提交发放
const handleSubmit = async () => {
if (!formRef.value) return
// 根据发放类型验证不同的字段
const rules = { coupon_id: formRules.coupon_id }
if (distributeType.value === 'single') {
rules.user_id = formRules.user_id
rules.quantity = formRules.quantity
} else if (distributeType.value === 'batch') {
rules.user_ids_text = formRules.user_ids_text
rules.quantity = formRules.quantity
}
try {
await formRef.value.validate()
} catch (error) {
return
}
let confirmMessage = ''
let requestData = {
coupon_id: formData.coupon_id,
quantity: formData.quantity
}
if (distributeType.value === 'single') {
confirmMessage = `确定要给用户 ${formData.user_id} 发放 ${formData.quantity} 张优惠券吗?`
requestData.user_ids = [formData.user_id]
} else if (distributeType.value === 'batch') {
const userIds = parseUserIds(formData.user_ids_text)
if (userIds.length === 0) {
ElMessage.error('请输入有效的用户ID')
return
}
confirmMessage = `确定要给 ${userIds.length} 个用户发放优惠券吗?每人 ${formData.quantity}`
requestData.user_ids = userIds
} else if (distributeType.value === 'all') {
confirmMessage = '确定要给所有用户发放优惠券吗?此操作不可撤销!'
requestData.distribute_all = true
requestData.quantity = 1
}
try {
await ElMessageBox.confirm(confirmMessage, '确认发放', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
submitLoading.value = true
try {
await distributeCoupon(requestData)
ElMessage.success('发放成功')
handleReset()
fetchDistributeHistory()
} catch (error) {
ElMessage.error(error.message || '发放失败')
} finally {
submitLoading.value = false
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '发放失败')
}
} finally {
submitLoading.value = false
}
}
// 重置表单
const handleReset = () => {
formData.coupon_id = ''
formData.user_id = null
formData.user_ids_text = ''
formData.quantity = 1
selectedCoupon.value = null
formRef.value?.clearValidate()
}
// 获取发放历史
const fetchDistributeHistory = async () => {
historyLoading.value = true
try {
const { data } = await getDistributeHistory({
page: historyPage.value,
page_size: historyPageSize.value
})
historyList.value = data.list || []
historyTotal.value = data.total || 0
} catch (error) {
ElMessage.error('获取发放历史失败')
} finally {
historyLoading.value = false
}
}
// 处理分页页码变化
const handleHistoryPageChange = (page) => {
historyPage.value = page
fetchDistributeHistory()
}
// 处理分页大小变化
const handleHistorySizeChange = (size) => {
historyPageSize.value = size
historyPage.value = 1
fetchDistributeHistory()
}
// 监听发放类型变化,清除验证
watch(distributeType, () => {
formRef.value?.clearValidate()
})
// 页面加载
onMounted(() => {
fetchCouponList()
fetchDistributeHistory()
})
</script>
<style scoped lang="scss">
.coupon-distribute-container {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 500;
}
}
.type-card,
.form-card,
.history-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 500;
}
.distribute-type-group {
display: flex;
gap: 16px;
:deep(.el-radio-button) {
margin-right: 12px;
&:last-child {
margin-right: 0;
}
}
}
.coupon-option {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.coupon-preview {
background: #f5f7fa;
padding: 16px;
border-radius: 4px;
.preview-item {
margin-bottom: 8px;
&:last-child {
margin-bottom: 0;
}
.label {
color: #606266;
font-weight: 500;
margin-right: 8px;
}
.value {
color: #303133;
}
}
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,569 @@
<template>
<div class="coupon-list-container">
<!-- 页面头部 -->
<div class="page-header">
<h2>优惠券管理</h2>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
创建优惠券
</el-button>
</div>
<!-- 搜索筛选 -->
<el-card class="filter-card" shadow="never">
<el-form :inline="true" :model="queryParams" class="filter-form">
<el-form-item label="关键词">
<el-input
v-model="queryParams.keyword"
placeholder="优惠券名称/描述"
clearable
@clear="handleQuery"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="queryParams.type" placeholder="全部类型" clearable>
<el-option label="满减券" :value="1" />
<el-option label="折扣券" :value="2" />
<el-option label="免邮券" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryParams.status" placeholder="全部状态" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>
查询
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 数据表格 -->
<el-card class="table-card" shadow="never">
<!-- 批量操作按钮 -->
<div class="table-toolbar" v-if="selectedIds.length > 0">
<el-button type="danger" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>
批量删除 ({{ selectedIds.length }})
</el-button>
</div>
<el-table
:data="tableData"
style="width: 100%"
v-loading="loading"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="优惠券名称" min-width="200" show-overflow-tooltip />
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag :type="getCouponTypeTag(row.type)">
{{ getCouponTypeName(row.type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="优惠信息" width="150">
<template #default="{ row }">
<span v-if="row.type === 1">{{ (row.min_amount / 100).toFixed(2) }}{{ (row.value / 100).toFixed(2) }}</span>
<span v-else-if="row.type === 2">{{ (row.value / 10).toFixed(1) }}</span>
<span v-else-if="row.type === 3">免邮</span>
</template>
</el-table-column>
<el-table-column label="有效期" min-width="300">
<template #default="{ row }">
{{ formatDateTime(row.start_time) }} ~ {{ formatDateTime(row.end_time) }}
</template>
</el-table-column>
<el-table-column label="库存/使用" width="120">
<template #default="{ row }">
<div>
<div>总量: {{ row.total_count || '无限' }}</div>
<div>已领: {{ row.received_count || 0 }}</div>
<div>已用: {{ row.used_count || 0 }}</div>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
:current-page="queryParams.page"
:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleQuery"
@current-change="handleQuery"
/>
</div>
</el-card>
<!-- 创建/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="优惠券名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入优惠券名称" maxlength="100" show-word-limit />
</el-form-item>
<el-form-item label="优惠券类型" prop="type">
<el-radio-group v-model="formData.type">
<el-radio :label="1">满减券</el-radio>
<el-radio :label="2">折扣券</el-radio>
<el-radio :label="3">免邮券</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="优惠额度" prop="value" v-if="formData.type !== 3">
<div class="value-input-group">
<el-input-number
v-model="formData.valueDisplay"
:min="0.01"
:max="formData.type === 2 ? 9.9 : 99999"
:precision="2"
:step="formData.type === 2 ? 0.1 : 1"
/>
<span class="value-unit">{{ formData.type === 1 ? '元' : '折' }}</span>
</div>
<div class="form-tip" v-if="formData.type === 2">
折扣范围0.1折 - 9.9折85 = 8.5折
</div>
</el-form-item>
<el-form-item label="最低消费" prop="min_amount">
<div class="value-input-group">
<el-input-number
v-model="formData.minAmountDisplay"
:min="0"
:max="99999"
:precision="2"
:step="1"
/>
<span class="value-unit"></span>
</div>
<div class="form-tip">0表示无门槛</div>
</el-form-item>
<el-form-item label="有效期" prop="timeRange">
<el-date-picker
v-model="formData.timeRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DDTHH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="发放数量" prop="total_count">
<el-input-number
v-model="formData.total_count"
:min="0"
:max="999999"
:step="1"
/>
<div class="form-tip">0表示不限量</div>
</el-form-item>
<el-form-item label="优惠券描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入优惠券描述"
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitLoading">
确定
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, Delete } from '@element-plus/icons-vue'
import {
getCouponList,
createCoupon,
updateCoupon,
deleteCoupon,
batchDeleteCoupons,
updateCouponStatus
} from '@/api/coupons'
// 响应式数据
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const selectedIds = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('创建优惠券')
const submitLoading = ref(false)
const formRef = ref(null)
// 查询参数
const queryParams = reactive({
page: 1,
page_size: 10,
keyword: '',
type: '',
status: ''
})
// 表单数据
const formData = reactive({
id: null,
name: '',
type: 1,
value: 0,
valueDisplay: 0,
min_amount: 0,
minAmountDisplay: 0,
description: '',
timeRange: [],
start_time: '',
end_time: '',
total_count: 0,
status: 1
})
// 表单验证规则
const formRules = {
name: [{ required: true, message: '请输入优惠券名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择优惠券类型', trigger: 'change' }],
value: [{ required: true, message: '请输入优惠额度', trigger: 'blur' }],
timeRange: [{ required: true, message: '请选择有效期', trigger: 'change' }]
}
// 监听类型变化,重置优惠额度
watch(() => formData.type, () => {
formData.valueDisplay = 0
formData.value = 0
})
// 监听展示值变化,转换为后端需要的值
watch(() => formData.valueDisplay, (val) => {
if (formData.type === 1) {
// 满减券:元转分
formData.value = Math.round(val * 100)
} else if (formData.type === 2) {
// 折扣券:折扣*108.5折 -> 85
formData.value = Math.round(val * 10)
}
})
watch(() => formData.minAmountDisplay, (val) => {
// 元转分
formData.min_amount = Math.round(val * 100)
})
// 获取优惠券类型名称
const getCouponTypeName = (type) => {
const typeMap = { 1: '满减券', 2: '折扣券', 3: '免邮券' }
return typeMap[type] || '未知'
}
// 获取优惠券类型标签
const getCouponTypeTag = (type) => {
const tagMap = { 1: 'success', 2: 'warning', 3: 'info' }
return tagMap[type] || ''
}
// 格式化日期时间
const formatDateTime = (dateTime) => {
if (!dateTime) return '-'
return new Date(dateTime).toLocaleString('zh-CN')
}
// 获取列表
const fetchList = async () => {
loading.value = true
try {
const { data } = await getCouponList(queryParams)
tableData.value = data.list || []
total.value = data.total || 0
} catch (error) {
ElMessage.error('获取优惠券列表失败')
} finally {
loading.value = false
}
}
// 查询
const handleQuery = () => {
queryParams.page = 1
fetchList()
}
// 重置
const handleReset = () => {
queryParams.keyword = ''
queryParams.type = ''
queryParams.status = ''
handleQuery()
}
// 选择变化
const handleSelectionChange = (selection) => {
selectedIds.value = selection.map(item => item.id)
}
// 创建
const handleCreate = () => {
dialogTitle.value = '创建优惠券'
resetForm()
dialogVisible.value = true
}
// 编辑
const handleEdit = (row) => {
dialogTitle.value = '编辑优惠券'
formData.id = row.id
formData.name = row.name
formData.type = row.type
formData.description = row.description
formData.total_count = row.total_count
formData.status = row.status
// 转换值用于显示
if (row.type === 1) {
// 满减券:分转元
formData.valueDisplay = row.value / 100
} else if (row.type === 2) {
// 折扣券除以10
formData.valueDisplay = row.value / 10
}
formData.minAmountDisplay = row.min_amount / 100
formData.timeRange = [row.start_time, row.end_time]
dialogVisible.value = true
}
// 提交
const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
// 设置时间
formData.start_time = formData.timeRange[0]
formData.end_time = formData.timeRange[1]
submitLoading.value = true
try {
const data = {
name: formData.name,
type: formData.type,
value: formData.value,
min_amount: formData.min_amount,
description: formData.description,
start_time: formData.start_time,
end_time: formData.end_time,
total_count: formData.total_count,
status: formData.status
}
if (formData.id) {
await updateCoupon(formData.id, data)
ElMessage.success('更新成功')
} else {
await createCoupon(data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchList()
} catch (error) {
ElMessage.error(error.message || '操作失败')
} finally {
submitLoading.value = false
}
})
}
// 重置表单
const resetForm = () => {
formData.id = null
formData.name = ''
formData.type = 1
formData.value = 0
formData.valueDisplay = 0
formData.min_amount = 0
formData.minAmountDisplay = 0
formData.description = ''
formData.timeRange = []
formData.start_time = ''
formData.end_time = ''
formData.total_count = 0
formData.status = 1
formRef.value?.resetFields()
}
// 删除
const handleDelete = (row) => {
ElMessageBox.confirm(
`确定要删除优惠券"${row.name}"吗?`,
'删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await deleteCoupon(row.id)
ElMessage.success('删除成功')
fetchList()
} catch (error) {
ElMessage.error(error.message || '删除失败')
}
}).catch(() => {})
}
// 批量删除
const handleBatchDelete = () => {
ElMessageBox.confirm(
`确定要删除选中的 ${selectedIds.value.length} 个优惠券吗?`,
'批量删除确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
).then(async () => {
try {
await batchDeleteCoupons(selectedIds.value)
ElMessage.success('批量删除成功')
fetchList()
} catch (error) {
ElMessage.error(error.message || '批量删除失败')
}
}).catch(() => {})
}
// 状态变化
const handleStatusChange = async (row) => {
try {
await updateCouponStatus(row.id, row.status)
ElMessage.success('状态更新成功')
} catch (error) {
row.status = row.status === 1 ? 0 : 1
ElMessage.error(error.message || '状态更新失败')
}
}
// 查看统计
const handleViewStats = (row) => {
// TODO: 跳转到统计页面或显示统计对话框
ElMessage.info('统计功能开发中')
}
// 页面加载时获取列表
onMounted(() => {
fetchList()
})
</script>
<style scoped lang="scss">
.coupon-list-container {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 24px;
font-weight: 500;
}
}
.filter-card {
margin-bottom: 20px;
.filter-form {
:deep(.el-form-item) {
margin-bottom: 0;
}
}
}
.table-card {
.table-toolbar {
margin-bottom: 16px;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
}
.value-input-group {
display: flex;
align-items: center;
gap: 8px;
.value-unit {
color: #606266;
}
}
.form-tip {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
</style>

View File

@@ -0,0 +1,150 @@
<template>
<el-dialog
v-model="dialogVisible"
title="投流源详情"
width="700px"
@close="handleClose"
>
<div v-if="streamData" class="detail-container">
<el-descriptions :column="2" border>
<el-descriptions-item label="标题" :span="2">
{{ streamData.title }}
</el-descriptions-item>
<el-descriptions-item label="平台">
<el-tag :type="getPlatformTagType(streamData.platform)">
{{ streamData.platform }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="streamData.status === 1 ? 'success' : 'info'">
{{ streamData.status === 1 ? '启用' : '禁用' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="投流URL" :span="2">
<el-link :href="streamData.stream_url" target="_blank" type="primary">
{{ streamData.stream_url }}
</el-link>
</el-descriptions-item>
<el-descriptions-item label="封面图片" :span="2">
<el-image
v-if="streamData.cover_image"
:src="streamData.cover_image"
:preview-src-list="[streamData.cover_image]"
style="width: 200px; height: 112px; border-radius: 4px;"
fit="cover"
/>
<span v-else class="text-gray">无封面</span>
</el-descriptions-item>
<el-descriptions-item label="描述" :span="2">
{{ streamData.description || '-' }}
</el-descriptions-item>
<el-descriptions-item label="观看次数">
{{ streamData.view_count || 0 }}
</el-descriptions-item>
<el-descriptions-item label="排序">
{{ streamData.sort }}
</el-descriptions-item>
<el-descriptions-item label="开始时间" :span="2">
{{ formatDate(streamData.start_time) }}
</el-descriptions-item>
<el-descriptions-item label="结束时间" :span="2">
{{ formatDate(streamData.end_time) }}
</el-descriptions-item>
<el-descriptions-item label="创建时间" :span="2">
{{ formatDate(streamData.created_at) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">
{{ formatDate(streamData.updated_at) }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">关闭</el-button>
<el-button type="primary" @click="handleEdit">编辑</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
streamData: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:visible', 'edit'])
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
const getPlatformTagType = (platform) => {
const typeMap = {
'抖音': 'danger',
'快手': 'warning',
'淘宝': 'success',
'京东': '',
'小红书': 'danger',
'视频号': 'success'
}
return typeMap[platform] || 'info'
}
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
const handleClose = () => {
dialogVisible.value = false
}
const handleEdit = () => {
emit('edit', props.streamData)
}
</script>
<style scoped>
.detail-container {
padding: 10px 0;
}
.text-gray {
color: #999;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,287 @@
<template>
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑投流源' : '添加投流源'"
width="800px"
@close="handleClose"
>
<el-form
ref="formRef"
:model="formData"
:rules="formRules"
label-width="100px"
>
<el-form-item label="标题" prop="title">
<el-input
v-model="formData.title"
placeholder="请输入投流源标题"
clearable
/>
</el-form-item>
<el-form-item label="平台" prop="platform">
<el-select
v-model="formData.platform"
placeholder="请选择平台"
style="width: 100%"
>
<el-option
v-for="platform in platformList"
:key="platform.id"
:label="platform.name"
:value="platform.name"
/>
</el-select>
<div style="color: #909399; font-size: 12px; margin-top: 5px;">
提示一个平台只能设置一个投流源
</div>
</el-form-item>
<el-form-item label="投流URL" prop="stream_url">
<el-input
v-model="formData.stream_url"
placeholder="请输入投流URL地址"
clearable
/>
</el-form-item>
<el-form-item label="封面图片" prop="cover_image">
<el-input
v-model="formData.cover_image"
placeholder="请输入封面图片URL"
clearable
>
<template #append>
<el-button @click="handleUploadCover">上传</el-button>
</template>
</el-input>
<div v-if="formData.cover_image" style="margin-top: 10px;">
<el-image
:src="formData.cover_image"
style="width: 200px; height: 112px; border-radius: 4px;"
fit="cover"
/>
</div>
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="请输入描述信息"
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number
v-model="formData.sort"
:min="0"
:max="9999"
placeholder="数值越大越靠前"
/>
</el-form-item>
<el-form-item label="状态">
<el-radio-group v-model="formData.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="有效期">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 100%"
format="YYYY-MM-DD HH:mm:ss"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
确定
</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { createLiveStream, updateLiveStream } from '@/api/livestreams'
import { getActivePlatforms } from '@/api/platform'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
streamData: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:visible', 'success'])
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val)
})
const formRef = ref(null)
const submitting = ref(false)
const dateRange = ref(null)
const platformList = ref([]) // 平台列表
const isEdit = computed(() => !!props.streamData?.id)
const formData = reactive({
title: '',
platform: '',
stream_url: '',
cover_image: '',
description: '',
sort: 0,
status: 1,
start_time: null,
end_time: null
})
const formRules = {
title: [
{ required: true, message: '请输入投流源标题', trigger: 'blur' }
],
platform: [
{ required: true, message: '请选择平台', trigger: 'change' }
],
stream_url: [
{ required: true, message: '请输入投流URL地址', trigger: 'blur' }
]
}
// 加载平台列表
const loadPlatforms = async () => {
try {
const response = await getActivePlatforms()
platformList.value = response.data || []
} catch (error) {
console.error('加载平台列表失败:', error)
}
}
// 初始化加载平台
onMounted(() => {
loadPlatforms()
})
// 重置表单函数需要在watch之前定义
const resetForm = () => {
formRef.value?.resetFields()
Object.assign(formData, {
title: '',
platform: '',
stream_url: '',
cover_image: '',
description: '',
sort: 0,
status: 1,
start_time: null,
end_time: null
})
dateRange.value = null
}
// 监听props变化
watch(() => props.streamData, (newVal) => {
if (newVal) {
Object.assign(formData, {
title: newVal.title || '',
platform: newVal.platform || '',
stream_url: newVal.stream_url || '',
cover_image: newVal.cover_image || '',
description: newVal.description || '',
sort: newVal.sort || 0,
status: newVal.status ?? 1,
start_time: newVal.start_time || null,
end_time: newVal.end_time || null
})
// 设置日期范围
if (newVal.start_time || newVal.end_time) {
dateRange.value = [newVal.start_time, newVal.end_time]
} else {
dateRange.value = null
}
} else {
resetForm()
}
}, { immediate: true })
// 监听日期范围变化
watch(dateRange, (newVal) => {
if (newVal && Array.isArray(newVal)) {
formData.start_time = newVal[0]
formData.end_time = newVal[1]
} else {
formData.start_time = null
formData.end_time = null
}
})
const handleUploadCover = () => {
ElMessage.info('请使用图片上传功能')
// 这里可以集成图片上传组件
}
const handleSubmit = async () => {
try {
await formRef.value.validate()
submitting.value = true
const submitData = { ...formData }
if (isEdit.value) {
await updateLiveStream(props.streamData.id, submitData)
ElMessage.success('更新成功')
} else {
await createLiveStream(submitData)
ElMessage.success('创建成功')
}
emit('success')
handleClose()
} catch (error) {
if (error !== false) {
// 显示后端返回的具体错误信息
const errorMessage = error.message || (isEdit.value ? '更新失败' : '创建失败')
ElMessage.error(errorMessage)
console.error(error)
}
} finally {
submitting.value = false
}
}
const handleClose = () => {
dialogVisible.value = false
resetForm()
}
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,442 @@
<template>
<div class="page-container">
<!-- 页面标题 -->
<div class="page-header">
<h2>直播投流源管理</h2>
<p>管理各个平台的直播投流源注意一个平台只能设置一个投流源</p>
</div>
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加投流源
</el-button>
<el-button
type="danger"
:disabled="selectedStreams.length === 0"
@click="handleBatchDelete"
>
<el-icon><Delete /></el-icon>
批量删除
</el-button>
</div>
<div class="toolbar-right">
<el-input
v-model="searchForm.title"
placeholder="搜索标题"
style="width: 200px; margin-right: 10px;"
clearable
@keyup.enter="handleSearch"
/>
<el-select
v-model="searchForm.platform"
placeholder="平台"
style="width: 150px; margin-right: 10px;"
clearable
>
<el-option
v-for="platform in platformList"
:key="platform.id"
:label="platform.name"
:value="platform.name"
/>
</el-select>
<el-select
v-model="searchForm.status"
placeholder="状态"
style="width: 120px; margin-right: 10px;"
clearable
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
</div>
<!-- 数据表格 -->
<div class="data-table">
<el-table
:data="streamList"
v-loading="loading"
@selection-change="handleSelectionChange"
row-key="id"
>
<el-table-column type="selection" width="55" />
<el-table-column label="封面" width="120">
<template #default="{ row }">
<el-image
v-if="row.cover_image"
:src="row.cover_image"
:preview-src-list="[row.cover_image]"
style="width: 80px; height: 45px; border-radius: 4px;"
fit="cover"
:z-index="9999"
preview-teleported
/>
<span v-else class="text-gray">无封面</span>
</template>
</el-table-column>
<el-table-column prop="title" label="标题" min-width="150" />
<el-table-column prop="platform" label="平台" width="100">
<template #default="{ row }">
<el-tag :type="getPlatformTagType(row.platform)">
{{ row.platform }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="投流URL" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<el-link :href="row.stream_url" target="_blank" type="primary">
{{ row.stream_url }}
</el-link>
</template>
</el-table-column>
<el-table-column prop="view_count" label="观看次数" width="100" sortable />
<el-table-column prop="sort" label="排序" width="80" sortable />
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column label="有效期" width="180">
<template #default="{ row }">
<div v-if="row.start_time || row.end_time" style="font-size: 12px;">
<div v-if="row.start_time">开始: {{ formatDate(row.start_time) }}</div>
<div v-if="row.end_time">结束: {{ formatDate(row.end_time) }}</div>
</div>
<span v-else class="text-gray">永久有效</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="160">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleView(row)">查看</el-button>
<el-button size="small" type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
<!-- 投流源表单弹窗 -->
<LiveStreamForm
v-model:visible="formVisible"
:stream-data="currentStream"
@success="handleFormSuccess"
/>
<!-- 投流源详情弹窗 -->
<LiveStreamDetail
v-model:visible="detailVisible"
:stream-data="currentStream"
@edit="handleEdit"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Search } from '@element-plus/icons-vue'
import {
getLiveStreamList,
deleteLiveStream,
batchDeleteLiveStreams,
updateLiveStreamStatus
} from '@/api/livestreams'
import { getActivePlatforms } from '@/api/platform'
import LiveStreamForm from './components/LiveStreamForm.vue'
import LiveStreamDetail from './components/LiveStreamDetail.vue'
// 响应式数据
const loading = ref(false)
const streamList = ref([])
const selectedStreams = ref([])
const formVisible = ref(false)
const detailVisible = ref(false)
const currentStream = ref(null)
const platformList = ref([]) // 平台列表
// 搜索表单
const searchForm = reactive({
title: '',
platform: '',
status: null
})
// 分页数据
const pagination = reactive({
page: 1,
size: 20,
total: 0
})
// 加载平台列表
const loadPlatforms = async () => {
try {
const response = await getActivePlatforms()
platformList.value = response.data || []
} catch (error) {
console.error('加载平台列表失败:', error)
}
}
// 获取投流源列表
const fetchStreamList = async () => {
try {
loading.value = true
const params = {
page: pagination.page,
page_size: pagination.size,
...searchForm
}
const response = await getLiveStreamList(params)
streamList.value = response.data.list || []
pagination.total = response.data.total || 0
} catch (error) {
ElMessage.error('获取投流源列表失败')
console.error(error)
} finally {
loading.value = false
}
}
// 获取平台标签类型
const getPlatformTagType = (platform) => {
const typeMap = {
'抖音': 'danger',
'快手': 'warning',
'淘宝': 'success',
'京东': '',
'小红书': 'danger',
'视频号': 'success'
}
return typeMap[platform] || 'info'
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
// 添加投流源
const handleAdd = () => {
currentStream.value = null
formVisible.value = true
}
// 查看详情
const handleView = (row) => {
currentStream.value = row
detailVisible.value = true
}
// 编辑投流源
const handleEdit = (row) => {
currentStream.value = row
formVisible.value = true
detailVisible.value = false
}
// 删除投流源
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除这个投流源吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await deleteLiveStream(row.id)
ElMessage.success('删除成功')
fetchStreamList()
} catch (error) {
if (error !== 'cancel') {
const errorMessage = error.message || '删除失败'
ElMessage.error(errorMessage)
console.error(error)
}
}
}
// 批量删除
const handleBatchDelete = async () => {
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedStreams.value.length} 个投流源吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const ids = selectedStreams.value.map(item => item.id)
await batchDeleteLiveStreams(ids)
ElMessage.success('批量删除成功')
fetchStreamList()
} catch (error) {
if (error !== 'cancel') {
const errorMessage = error.message || '批量删除失败'
ElMessage.error(errorMessage)
console.error(error)
}
}
}
// 更新状态
const handleStatusChange = async (row) => {
try {
await updateLiveStreamStatus(row.id, row.status)
ElMessage.success('状态更新成功')
} catch (error) {
const errorMessage = error.message || '状态更新失败'
ElMessage.error(errorMessage)
// 恢复原状态
row.status = row.status === 1 ? 0 : 1
console.error(error)
}
}
// 选择变化
const handleSelectionChange = (selection) => {
selectedStreams.value = selection
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchStreamList()
}
// 重置
const handleReset = () => {
searchForm.title = ''
searchForm.platform = ''
searchForm.status = null
pagination.page = 1
fetchStreamList()
}
// 分页变化
const handleSizeChange = () => {
pagination.page = 1
fetchStreamList()
}
const handleCurrentChange = () => {
fetchStreamList()
}
// 表单提交成功
const handleFormSuccess = () => {
formVisible.value = false
fetchStreamList()
}
// 初始化
onMounted(() => {
loadPlatforms()
fetchStreamList()
})
</script>
<style scoped>
.page-container {
padding: 20px;
}
.page-header {
margin-bottom: 20px;
}
.page-header h2 {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: 600;
}
.page-header p {
margin: 0;
color: #666;
font-size: 14px;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.data-table {
margin-bottom: 20px;
}
.pagination {
display: flex;
justify-content: flex-end;
}
.text-gray {
color: #999;
font-size: 12px;
}
.text-info {
color: #409eff;
font-size: 12px;
}
</style>

View File

@@ -69,6 +69,23 @@
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip align="center" />
<el-table-column label="适用平台" min-width="180" align="center">
<template #default="{ row }">
<div v-if="row.platform && row.platform.length > 0" class="platform-tags">
<el-tag
v-for="(code, index) in row.platform"
:key="index"
size="small"
style="margin-right: 4px;"
>
<span style="margin-right: 2px;">{{ getPlatformIcon(code) }}</span>
{{ getPlatformName(code) }}
</el-tag>
</div>
<span v-else style="color: #999;">未设置</span>
</template>
</el-table-column>
<el-table-column prop="sort" label="排序" width="80" sortable align="center" />
<el-table-column label="商品数量" width="100" align="center">
@@ -113,9 +130,10 @@
<!-- 分类表单弹窗 -->
<CategoryForm
v-model:visible="formVisible"
:visible="formVisible"
:category-data="currentCategory"
:parent-category="parentCategory"
@update:visible="formVisible = $event"
@success="handleFormSuccess"
/>
</div>
@@ -130,8 +148,37 @@ import {
deleteCategory,
updateCategory
} from '@/api/products'
import { getActivePlatforms } from '@/api/platform'
import CategoryForm from './components/CategoryForm.vue'
// 平台信息映射
const platformMap = ref({})
// 加载平台列表
const loadPlatforms = async () => {
try {
const response = await getActivePlatforms()
const platforms = response.data || []
// 构建代码到平台信息的映射
platformMap.value = platforms.reduce((map, platform) => {
map[platform.code] = platform
return map
}, {})
} catch (error) {
console.error('加载平台列表失败:', error)
}
}
// 根据平台代码获取平台名称
const getPlatformName = (code) => {
return platformMap.value[code]?.name || code
}
// 根据平台代码获取平台图标
const getPlatformIcon = (code) => {
return platformMap.value[code]?.icon || '📦'
}
// 响应式数据
const loading = ref(false)
const categoryTree = ref([])
@@ -287,6 +334,7 @@ const formatDate = (date) => {
// 组件挂载时获取数据
onMounted(() => {
loadPlatforms() // 先加载平台列表
fetchCategoryList()
})
</script>
@@ -297,6 +345,13 @@ onMounted(() => {
align-items: center;
}
.platform-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
}
.page-header {
margin-bottom: 20px;
}
@@ -312,38 +367,34 @@ onMounted(() => {
color: #666;
font-size: 14px;
}
</style>
<style>
/* 树形表格展开键和内容对齐 */
:deep(.el-table) {
/* 所有单元格垂直居中 */
.cell {
display: flex;
align-items: center;
min-height: 40px;
}
/* 树形表格行样式 */
.el-table__row {
/* 展开图标与内容对齐 */
.el-table__indent {
display: inline-flex;
align-items: center;
}
.el-table__expand-icon {
vertical-align: middle;
margin-right: 8px;
}
}
/* 分类名称列左对齐 */
.el-table__body .el-table__row .el-table__cell:first-child {
text-align: left;
.cell {
justify-content: flex-start;
padding-left: 14px;
}
}
.el-table .cell {
display: flex;
align-items: center;
min-height: 40px;
}
/* 树形表格行样式 */
.el-table .el-table__row .el-table__indent {
display: inline-flex;
align-items: center;
}
.el-table .el-table__row .el-table__expand-icon {
vertical-align: middle;
margin-right: 8px;
}
/* 分类名称列左对齐 */
.el-table__body .el-table__row .el-table__cell:first-child {
text-align: left;
}
.el-table__body .el-table__row .el-table__cell:first-child .cell {
justify-content: flex-start;
padding-left: 14px;
}
</style>

View File

@@ -57,6 +57,29 @@
</div>
</el-form-item>
<el-form-item label="适用平台" prop="platform">
<el-select
v-model="form.platform"
multiple
placeholder="请选择适用平台"
style="width: 100%"
clearable
>
<el-option
v-for="platform in platformList"
:key="platform.id"
:label="platform.name"
:value="platform.code"
>
<span style="font-size: 18px; margin-right: 8px;">{{ platform.icon || '📦' }}</span>
<span>{{ platform.name }}</span>
</el-option>
</el-select>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
请选择该分类适用的平台未选择任何平台将不在任何平台显示
</div>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="排序" prop="sort">
@@ -80,20 +103,6 @@
</el-form-item>
</el-col>
</el-row>
<el-form-item label="SEO关键词" prop="keywords">
<el-input v-model="form.keywords" placeholder="请输入SEO关键词多个用逗号分隔" />
</el-form-item>
<el-form-item label="是否显示" prop="is_show">
<el-switch
v-model="form.is_show"
:active-value="1"
:inactive-value="0"
active-text="显示"
inactive-text="隐藏"
/>
</el-form-item>
</el-form>
<template #footer>
@@ -108,10 +117,11 @@
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { ref, reactive, computed, watch, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Picture } from '@element-plus/icons-vue'
import { createCategory, updateCategory, getCategoryList } from '@/api/products'
import { getActivePlatforms } from '@/api/platform'
const props = defineProps({
visible: {
@@ -133,6 +143,7 @@ const emit = defineEmits(['update:visible', 'success'])
const formRef = ref()
const loading = ref(false)
const categoryOptions = ref([])
const platformList = ref([])
const dialogVisible = computed({
get: () => props.visible,
@@ -148,8 +159,7 @@ const form = reactive({
icon: '',
sort: 0,
status: 1,
keywords: '',
is_show: 1
platform: [] // 平台代码数组,如: ['web', 'miniprogram']
})
const rules = {
@@ -162,6 +172,22 @@ const rules = {
]
}
// 加载平台列表
const loadPlatformList = async () => {
try {
const response = await getActivePlatforms()
// 新接口直接返回数组:{ code: 200, data: [...] }
platformList.value = response.data || []
} catch (error) {
console.error('加载平台列表失败:', error)
}
}
// 组件挂载时加载平台列表
onMounted(() => {
loadPlatformList()
})
// 监听弹窗显示状态
watch(() => props.visible, (visible) => {
if (visible) {
@@ -170,7 +196,8 @@ watch(() => props.visible, (visible) => {
// 编辑模式,填充表单数据
Object.assign(form, {
...props.categoryData,
parent_id: props.categoryData.parent_id || null
parent_id: props.categoryData.parent_id || null,
platform: props.categoryData.platform || [] // 平台代码数组
})
} else {
// 新增模式,重置表单
@@ -230,8 +257,7 @@ const resetForm = () => {
icon: '',
sort: 0,
status: 1,
keywords: '',
is_show: 1
platform: [] // 平台代码数组
})
formRef.value?.clearValidate()
}

View File

@@ -26,10 +26,28 @@
:render-after-expand="false"
:props="{ label: 'name', value: 'id', children: 'children' }"
placeholder="请选择分类"
check-strictly
multiple
clearable
collapse-tags
collapse-tags-tooltip
style="width: 100%"
/>
>
<template #default="{ data }">
<span style="display: flex; align-items: center; justify-content: space-between; width: 100%;">
<span>{{ data.name }}</span>
<span v-if="data.platform && data.platform.length > 0" style="margin-left: 8px;">
<el-tag
v-for="(code, index) in data.platform"
:key="index"
size="small"
style="margin-left: 4px;"
>
{{ getPlatformIcon(code) }} {{ getPlatformName(code) }}
</el-tag>
</span>
</span>
</template>
</el-tree-select>
</el-form-item>
</el-col>
</el-row>
@@ -292,6 +310,7 @@ import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Edit, InfoFilled } from '@element-plus/icons-vue'
import { createProduct, updateProduct, getCategoryList } from '@/api/products'
import { getActivePlatforms } from '@/api/platform'
import { uploadImage } from '@/api/banners'
import { useAuthStore } from '@/stores/auth'
import MultiImageUpload from '@/components/MultiImageUpload.vue'
@@ -312,6 +331,7 @@ const emit = defineEmits(['update:visible', 'success'])
const formRef = ref()
const loading = ref(false)
const categoryOptions = ref([])
const platformMap = ref({}) // 平台信息映射
const authStore = useAuthStore()
const dialogVisible = computed({
@@ -329,7 +349,7 @@ const uploadHeaders = computed(() => ({
const form = reactive({
name: '',
category_id: '',
category_id: [], // 改为数组支持多选
price: '',
orig_price: 0,
stock: '',
@@ -411,9 +431,36 @@ watch(() => props.visible, (visible) => {
}
})
// 加载平台列表
const loadPlatforms = async () => {
try {
const response = await getActivePlatforms()
const platforms = response.data || []
// 构建平台映射
platformMap.value = platforms.reduce((map, platform) => {
map[platform.code] = platform
return map
}, {})
} catch (error) {
console.error('加载平台列表失败:', error)
}
}
// 获取平台名称
const getPlatformName = (code) => {
return platformMap.value[code]?.name || code
}
// 获取平台图标
const getPlatformIcon = (code) => {
return platformMap.value[code]?.icon || '📦'
}
// 加载分类列表并构建树形数据
const loadCategories = async () => {
try {
// 并行加载平台和分类
await loadPlatforms()
const response = await getCategoryList()
const categories = response.data || []
categoryOptions.value = buildCategoryTree(categories)
@@ -451,7 +498,7 @@ const buildCategoryTree = (categories) => {
const resetForm = () => {
Object.assign(form, {
name: '',
category_id: '',
category_id: [], // 重置为空数组
price: '',
orig_price: 0,
stock: '',

View File

@@ -99,14 +99,6 @@
<el-table-column prop="name" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column label="分类" width="120">
<template #default="{ row }">
{{ row.category_name || '未分类' }}
</template>
</el-table-column>
<el-table-column label="价格" width="100" sortable>
<template #default="{ row }">
<span class="price">¥{{ (row.price / 100).toFixed(2) }}</span>
@@ -203,6 +195,7 @@ import BatchOperationDialog from './components/BatchOperationDialog.vue'
const loading = ref(false)
const productList = ref([])
const categoryList = ref([])
const categoryMap = ref({}) // 分类映射
const selectedProducts = ref([])
const formVisible = ref(false)
const batchDialogVisible = ref(false)
@@ -250,12 +243,34 @@ const fetchProductList = async () => {
const fetchCategoryList = async () => {
try {
const response = await getCategoryList({ status: 1 })
categoryList.value = response.data.list || []
// 后端直接返回数组,而不是 { list: [...] } 格式
const categories = response.data || []
categoryList.value = categories
// 扁平化树形结构,构建分类映射(包括所有层级)
buildCategoryMap(categories)
console.log('分类映射构建完成:', categoryMap.value)
} catch (error) {
console.error('获取分类列表失败:', error)
}
}
// 递归构建分类映射(扁平化处理)
const buildCategoryMap = (categories) => {
categories.forEach(cat => {
// 将当前分类添加到映射
categoryMap.value[cat.id] = cat
// 递归处理子分类
if (cat.children && cat.children.length > 0) {
buildCategoryMap(cat.children)
}
})
}
// 根据分类ID获取分类名称
const getCategoryName = (categoryId) => {
return categoryMap.value[categoryId]?.name || '未知分类'
}
// 搜索
const handleSearch = () => {
pagination.page = 1

View File

@@ -0,0 +1,265 @@
<template>
<el-dialog
:model-value="visible"
:title="isEdit ? '编辑平台' : '添加平台'"
width="600px"
:before-close="handleClose"
@update:model-value="$emit('update:visible', $event)"
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
class="platform-form"
>
<el-form-item label="平台代码" prop="code">
<el-input
v-model="form.code"
placeholder="请输入平台代码web, miniprogram, app"
:disabled="isEdit"
>
<template #append>
<span style="font-size: 12px; color: #999;">唯一标识</span>
</template>
</el-input>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
建议使用小写字母webminiprogramapph5等
</div>
</el-form-item>
<el-form-item label="平台名称" prop="name">
<el-input v-model="form.name" placeholder="请输入平台名称Web端、小程序" />
</el-form-item>
<el-form-item label="平台图标" prop="icon">
<div class="icon-selector">
<el-input v-model="form.icon" placeholder="请输入Emoji图标或图片URL">
<template #prepend>
<span style="font-size: 20px;">{{ form.icon || '📦' }}</span>
</template>
</el-input>
<div class="icon-quick-select">
<span
v-for="icon in commonIcons"
:key="icon"
class="icon-item"
:class="{ active: form.icon === icon }"
@click="form.icon = icon"
>
{{ icon }}
</span>
</div>
</div>
</el-form-item>
<el-form-item label="平台描述" prop="description">
<el-input
v-model="form.description"
type="textarea"
:rows="3"
placeholder="请输入平台描述"
/>
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="排序值" prop="sort">
<el-input-number
v-model="form.sort"
:min="0"
:max="9999"
style="width: 100%"
placeholder="数值越大越靠前"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="状态" prop="status">
<el-switch
v-model="form.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { createPlatform, updatePlatform } from '@/api/platform'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
platformData: {
type: Object,
default: null
}
})
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref()
const loading = ref(false)
// 常用图标
const commonIcons = ['🌐', '📱', '📲', '💻', '🖥️', '⌚', '📺', '🎮', '🌟', '📦']
const isEdit = computed(() => props.platformData && props.platformData.id)
const form = reactive({
code: '',
name: '',
icon: '',
description: '',
sort: 0,
status: 1
})
const rules = {
code: [
{ required: true, message: '请输入平台代码', trigger: 'blur' },
{
pattern: /^[a-z0-9_]+$/,
message: '平台代码只能包含小写字母、数字和下划线',
trigger: 'blur'
},
{ min: 1, max: 50, message: '平台代码长度在 1 到 50 个字符', trigger: 'blur' }
],
name: [
{ required: true, message: '请输入平台名称', trigger: 'blur' },
{ min: 1, max: 100, message: '平台名称长度在 1 到 100 个字符', trigger: 'blur' }
],
sort: [
{ required: true, message: '请输入排序值', trigger: 'blur' }
]
}
// 监听弹窗显示状态
watch(() => props.visible, (visible) => {
if (visible) {
if (props.platformData) {
// 编辑模式,填充表单数据
Object.assign(form, {
code: props.platformData.code || '',
name: props.platformData.name || '',
icon: props.platformData.icon || '',
description: props.platformData.description || '',
sort: props.platformData.sort || 0,
status: props.platformData.status ?? 1
})
} else {
// 新增模式,重置表单
resetForm()
}
}
})
// 重置表单
const resetForm = () => {
Object.assign(form, {
code: '',
name: '',
icon: '',
description: '',
sort: 0,
status: 1
})
formRef.value?.clearValidate()
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value.validate()
loading.value = true
const submitData = { ...form }
if (isEdit.value) {
await updatePlatform(props.platformData.id, submitData)
ElMessage.success('平台更新成功')
} else {
await createPlatform(submitData)
ElMessage.success('平台创建成功')
}
emit('success')
handleClose()
} catch (error) {
console.error('提交失败:', error)
ElMessage.error(error.message || '操作失败')
} finally {
loading.value = false
}
}
// 关闭弹窗
const handleClose = () => {
emit('update:visible', false)
resetForm()
}
</script>
<style scoped>
.platform-form {
padding: 20px 0;
}
.icon-selector {
width: 100%;
}
.icon-quick-select {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
.icon-item {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
}
.icon-item:hover {
border-color: #409eff;
background-color: #ecf5ff;
}
.icon-item.active {
border-color: #409eff;
background-color: #409eff;
color: #fff;
}
.dialog-footer {
text-align: right;
}
</style>

View File

@@ -0,0 +1,305 @@
<template>
<div class="page-container">
<!-- 页面标题 -->
<div class="page-header">
<h2>平台管理</h2>
<p>管理系统所有平台配置Web端小程序APP等</p>
</div>
<!-- 工具栏 -->
<div class="toolbar">
<div class="toolbar-left">
<el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>
添加平台
</el-button>
</div>
<div class="toolbar-right">
<el-input
v-model="searchForm.name"
placeholder="搜索平台名称或代码"
style="width: 220px; margin-right: 10px;"
clearable
@keyup.enter="handleSearch"
/>
<el-select
v-model="searchForm.status"
placeholder="状态"
style="width: 120px; margin-right: 10px;"
clearable
>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button @click="handleReset">重置</el-button>
</div>
</div>
<!-- 平台表格 -->
<div class="data-table">
<el-table
:data="platformList"
v-loading="loading"
row-key="id"
>
<el-table-column prop="id" label="ID" width="80" align="center" />
<el-table-column label="图标" width="80" align="center">
<template #default="{ row }">
<div class="platform-icon">
{{ row.icon || '📦' }}
</div>
</template>
</el-table-column>
<el-table-column prop="code" label="平台代码" width="150" align="center">
<template #default="{ row }">
<el-tag type="info">{{ row.code }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="平台名称" min-width="150" align="center" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip align="center" />
<el-table-column prop="sort" label="排序" width="100" sortable align="center" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
@change="handleStatusChange(row)"
/>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="180" align="center">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right" align="center">
<template #default="{ row }">
<el-button size="small" type="primary" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<!-- 空状态插槽 -->
<template #empty>
<div style="padding: 40px; text-align: center; color: #909399;">
<span>暂无平台数据</span>
</div>
</template>
</el-table>
<!-- 分页 -->
<div class="pagination" v-if="total > 0">
<el-pagination
:current-page="pagination.page"
:page-size="pagination.pageSize"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
<!-- 平台表单弹窗 -->
<PlatformForm
:visible="formVisible"
:platform-data="currentPlatform"
@update:visible="formVisible = $event"
@success="handleFormSuccess"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search } from '@element-plus/icons-vue'
import {
getPlatformList,
deletePlatform,
updatePlatform
} from '@/api/platform'
import PlatformForm from './components/PlatformForm.vue'
// 响应式数据
const loading = ref(false)
const platformList = ref([])
const total = ref(0)
const formVisible = ref(false)
const currentPlatform = ref(null)
// 搜索表单
const searchForm = reactive({
name: '',
status: null
})
// 分页
const pagination = reactive({
page: 1,
pageSize: 10
})
// 获取平台列表
const fetchPlatformList = async () => {
try {
loading.value = true
const params = {
page: pagination.page,
page_size: pagination.pageSize,
...searchForm
}
const response = await getPlatformList(params)
// 后端返回的数据格式:{ code: 200, data: { list: [], total: 0, page: 1, page_size: 10 } }
if (response.data && response.data.list) {
platformList.value = response.data.list || []
total.value = response.data.total || 0
} else {
// 兼容不分页的情况
platformList.value = response.data || []
total.value = response.data?.length || 0
}
} catch (error) {
console.log('获取平台列表失败:', error)
ElMessage.error(error.message || '获取平台列表失败')
} finally {
loading.value = false
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
fetchPlatformList()
}
// 重置搜索
const handleReset = () => {
Object.assign(searchForm, {
name: '',
status: null
})
pagination.page = 1
fetchPlatformList()
}
// 添加平台
const handleAdd = () => {
currentPlatform.value = null
formVisible.value = true
}
// 编辑平台
const handleEdit = (platform) => {
currentPlatform.value = { ...platform }
formVisible.value = true
}
// 删除平台
const handleDelete = async (platform) => {
try {
await ElMessageBox.confirm(
`确定要删除平台"${platform.name}"吗?删除后,该平台关联的分类将不再显示在该平台。`,
'确认删除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await deletePlatform(platform.id)
ElMessage.success('删除成功')
fetchPlatformList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error(error.message || '删除失败')
}
}
}
// 状态变更
const handleStatusChange = async (platform) => {
try {
await updatePlatform(platform.id, { status: platform.status })
ElMessage.success('状态更新成功')
} catch (error) {
// 恢复原状态
platform.status = platform.status === 1 ? 0 : 1
ElMessage.error('状态更新失败')
}
}
// 分页变更
const handlePageChange = (page) => {
pagination.page = page
fetchPlatformList()
}
const handleSizeChange = (pageSize) => {
pagination.pageSize = pageSize
pagination.page = 1
fetchPlatformList()
}
// 表单提交成功
const handleFormSuccess = () => {
formVisible.value = false
fetchPlatformList()
}
// 格式化日期
const formatDate = (date) => {
if (!date) return ''
return new Date(date).toLocaleString('zh-CN')
}
// 组件挂载时获取数据
onMounted(() => {
fetchPlatformList()
})
</script>
<style scoped>
.page-header {
margin-bottom: 20px;
}
.page-header h2 {
margin: 0 0 8px 0;
font-size: 20px;
font-weight: 600;
}
.page-header p {
margin: 0;
color: #666;
font-size: 14px;
}
.platform-icon {
font-size: 24px;
}
.pagination {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>

34
deploy.bat Normal file
View File

@@ -0,0 +1,34 @@
@echo off
chcp 65001 >nul
REM ==========================================
REM 一键部署脚本 - 电商系统
REM Windows -> Ubuntu Server
REM 创建时间: 2025-11-27
REM ==========================================
echo ========================================
echo 电商系统一键部署工具
echo 目标服务器: 8.140.194.184 (Ubuntu)
echo ========================================
echo.
REM 检查 PowerShell 是否可用
where pwsh >nul 2>&1
if %ERRORLEVEL% EQU 0 (
REM 使用 PowerShell 7
pwsh.exe -ExecutionPolicy Bypass -File "%~dp0deploy.ps1" %*
) else (
REM 使用 Windows PowerShell
powershell.exe -ExecutionPolicy Bypass -File "%~dp0deploy.ps1" %*
)
if %ERRORLEVEL% NEQ 0 (
echo.
echo [错误] 部署失败,请检查错误信息
pause
exit /b 1
)
echo.
echo 按任意键退出...
pause >nul

305
deploy.ps1 Normal file
View File

@@ -0,0 +1,305 @@
# ==========================================
# 一键部署脚本 - 电商系统
# 本地: Windows
# 远程: Ubuntu Server (8.140.194.184)
# 创建时间: 2025-11-27
# ==========================================
param(
[switch]$SkipWeb,
[switch]$SkipAdmin,
[switch]$SkipServer
)
# 配置信息
$SSH_HOST = "8.140.194.184"
$SSH_USER = "root"
$SSH_PASSWORD = "7aK_H2yvokVuZ5HtL5Qrwl19m7L"
$SSH_PORT = 22
# 本地路径
$LOCAL_WEB = "web"
$LOCAL_ADMIN = "admin"
$LOCAL_SERVER = "server"
# 远程路径
$REMOTE_WEB = "/home/work/ai_vizee"
$REMOTE_ADMIN = "/home/work/ai_mis"
$REMOTE_SERVER = "/home/work/ai_dianshang"
# 临时文件夹
$TEMP_DIR = Join-Path $env:TEMP "dianshang_deploy"
if (Test-Path $TEMP_DIR) {
Remove-Item $TEMP_DIR -Recurse -Force
}
New-Item -ItemType Directory -Path $TEMP_DIR | Out-Null
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 电商系统一键部署工具" -ForegroundColor Cyan
Write-Host " 目标服务器: $SSH_HOST (Ubuntu)" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# 检查工具函数
function Test-Command {
param($Command)
try {
if (Get-Command $Command -ErrorAction Stop) {
return $true
}
} catch {
return $false
}
}
Write-Host "[检查] 检查必要工具..." -ForegroundColor Yellow
# 检查 SSH 工具 (优先使用 plink其次使用 ssh)
$USE_PLINK = $false
$USE_PSCP = $false
if (Test-Command "plink") {
Write-Host "[成功] 找到 PuTTY plink" -ForegroundColor Green
$USE_PLINK = $true
} elseif (Test-Command "ssh") {
Write-Host "[成功] 找到 OpenSSH" -ForegroundColor Green
$USE_PLINK = $false
} else {
Write-Host "[错误] 未找到 SSH 工具 (plink 或 ssh)" -ForegroundColor Red
Write-Host "请安装以下任一工具:" -ForegroundColor Yellow
Write-Host " 1. PuTTY (推荐): https://www.putty.org/" -ForegroundColor Yellow
Write-Host " 2. OpenSSH: 设置 -> 应用 -> 可选功能 -> OpenSSH 客户端" -ForegroundColor Yellow
exit 1
}
if (Test-Command "pscp") {
Write-Host "[成功] 找到 PuTTY pscp" -ForegroundColor Green
$USE_PSCP = $true
} elseif (Test-Command "scp") {
Write-Host "[成功] 找到 OpenSSH scp" -ForegroundColor Green
$USE_PSCP = $false
} else {
Write-Host "[错误] 未找到 SCP 工具 (pscp 或 scp)" -ForegroundColor Red
Write-Host "请安装以下任一工具:" -ForegroundColor Yellow
Write-Host " 1. PuTTY (推荐): https://www.putty.org/" -ForegroundColor Yellow
Write-Host " 2. OpenSSH: 设置 -> 应用 -> 可选功能 -> OpenSSH 客户端" -ForegroundColor Yellow
exit 1
}
Write-Host ""
# SSH 执行命令函数
function Invoke-SSHCommand {
param($Command)
Write-Host "[SSH] 执行命令: $Command" -ForegroundColor Cyan
if ($USE_PLINK) {
# 使用 PuTTY plink
echo y | plink -ssh -pw $SSH_PASSWORD ${SSH_USER}@${SSH_HOST} -P $SSH_PORT $Command 2>&1
} else {
# 使用 OpenSSH (需要配置 sshpass 或手动输入密码)
# 创建临时的 SSH 配置来避免主机密钥检查
$env:SSH_ASKPASS = ""
ssh -o "StrictHostKeyChecking=no" -o "UserKnownHostsFile=/dev/null" -p $SSH_PORT ${SSH_USER}@${SSH_HOST} $Command 2>&1
}
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne $null) {
Write-Host "[警告] SSH 命令返回码: $LASTEXITCODE" -ForegroundColor Yellow
}
}
# SCP 上传文件函数
function Copy-ToRemote {
param($LocalFile, $RemotePath)
Write-Host "[上传] $LocalFile" -ForegroundColor Cyan
Write-Host " -> ${SSH_HOST}:${RemotePath}" -ForegroundColor Cyan
if ($USE_PSCP) {
# 使用 PuTTY pscp
echo y | pscp -pw $SSH_PASSWORD -P $SSH_PORT $LocalFile ${SSH_USER}@${SSH_HOST}:${RemotePath} 2>&1
} else {
# 使用 OpenSSH scp
scp -o "StrictHostKeyChecking=no" -o "UserKnownHostsFile=/dev/null" -P $SSH_PORT $LocalFile ${SSH_USER}@${SSH_HOST}:${RemotePath} 2>&1
}
if ($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne $null) {
Write-Host "[错误] 文件上传失败 (退出码: $LASTEXITCODE)" -ForegroundColor Red
return $false
}
Write-Host "[成功] 文件上传完成" -ForegroundColor Green
return $true
}
# ==========================================
# 1. 部署 Web 项目
# ==========================================
if (-not $SkipWeb) {
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " [1/3] 部署 Web 项目" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
if (-not (Test-Path $LOCAL_WEB)) {
Write-Host "[错误] 未找到 web 文件夹" -ForegroundColor Red
exit 1
}
# 压缩 web 文件夹
$webZip = Join-Path $TEMP_DIR "web.zip"
Write-Host "[压缩] 压缩 web 文件夹..." -ForegroundColor Yellow
# 使用 PowerShell 内置压缩
Compress-Archive -Path "$LOCAL_WEB\*" -DestinationPath $webZip -Force
if (-not (Test-Path $webZip)) {
Write-Host "[错误] web 压缩失败" -ForegroundColor Red
exit 1
}
$fileSize = [math]::Round((Get-Item $webZip).Length / 1MB, 2)
Write-Host "[成功] web 压缩完成 ($fileSize MB)" -ForegroundColor Green
# 上传到服务器
if (-not (Copy-ToRemote $webZip "/tmp/web.zip")) {
exit 1
}
# 解压并覆盖
Write-Host "[解压] 解压 web 到 $REMOTE_WEB..." -ForegroundColor Yellow
Invoke-SSHCommand "mkdir -p $REMOTE_WEB && cd $REMOTE_WEB && rm -rf * && unzip -o /tmp/web.zip && rm -f /tmp/web.zip"
Write-Host "[完成] Web 项目部署完成!" -ForegroundColor Green
Write-Host ""
} else {
Write-Host "[跳过] 跳过 Web 项目部署" -ForegroundColor Yellow
Write-Host ""
}
# ==========================================
# 2. 部署 Admin 项目
# ==========================================
if (-not $SkipAdmin) {
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " [2/3] 部署 Admin 项目" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
if (-not (Test-Path $LOCAL_ADMIN)) {
Write-Host "[错误] 未找到 admin 文件夹" -ForegroundColor Red
exit 1
}
# 执行 npm build
Write-Host "[构建] 执行 npm run build:prod..." -ForegroundColor Yellow
Push-Location $LOCAL_ADMIN
# 检测 npm 命令
$npmCommand = "npm"
if (Test-Command "npm.cmd") {
$npmCommand = "npm.cmd"
}
& $npmCommand run build:prod
if ($LASTEXITCODE -ne 0) {
Write-Host "[错误] npm build 失败" -ForegroundColor Red
Pop-Location
exit 1
}
Pop-Location
Write-Host "[成功] Admin 构建完成" -ForegroundColor Green
# 检查 dist 文件夹
$distPath = Join-Path $LOCAL_ADMIN "dist"
if (-not (Test-Path $distPath)) {
Write-Host "[错误] 未找到 dist 文件夹" -ForegroundColor Red
exit 1
}
# 压缩 dist 文件夹
$adminZip = Join-Path $TEMP_DIR "admin.zip"
Write-Host "[压缩] 压缩 dist 文件夹..." -ForegroundColor Yellow
Compress-Archive -Path "$distPath\*" -DestinationPath $adminZip -Force
if (-not (Test-Path $adminZip)) {
Write-Host "[错误] dist 压缩失败" -ForegroundColor Red
exit 1
}
$fileSize = [math]::Round((Get-Item $adminZip).Length / 1MB, 2)
Write-Host "[成功] dist 压缩完成 ($fileSize MB)" -ForegroundColor Green
# 上传到服务器
if (-not (Copy-ToRemote $adminZip "/tmp/admin.zip")) {
exit 1
}
# 解压并覆盖
Write-Host "[解压] 解压 dist 到 $REMOTE_ADMIN..." -ForegroundColor Yellow
Invoke-SSHCommand "mkdir -p $REMOTE_ADMIN && cd $REMOTE_ADMIN && rm -rf * && unzip -o /tmp/admin.zip && rm -f /tmp/admin.zip"
Write-Host "[完成] Admin 项目部署完成!" -ForegroundColor Green
Write-Host ""
} else {
Write-Host "[跳过] 跳过 Admin 项目部署" -ForegroundColor Yellow
Write-Host ""
}
# ==========================================
# 3. 部署 Server 项目
# ==========================================
if (-not $SkipServer) {
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " [3/3] 部署 Server 项目" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
if (-not (Test-Path $LOCAL_SERVER)) {
Write-Host "[错误] 未找到 server 文件夹" -ForegroundColor Red
exit 1
}
# 压缩 server 文件夹
$serverZip = Join-Path $TEMP_DIR "server.zip"
Write-Host "[压缩] 压缩 server 文件夹..." -ForegroundColor Yellow
Compress-Archive -Path "$LOCAL_SERVER\*" -DestinationPath $serverZip -Force
if (-not (Test-Path $serverZip)) {
Write-Host "[错误] server 压缩失败" -ForegroundColor Red
exit 1
}
$fileSize = [math]::Round((Get-Item $serverZip).Length / 1MB, 2)
Write-Host "[成功] server 压缩完成 ($fileSize MB)" -ForegroundColor Green
# 上传到服务器
if (-not (Copy-ToRemote $serverZip "/tmp/server.zip")) {
exit 1
}
# 解压并覆盖
Write-Host "[解压] 解压 server 到 $REMOTE_SERVER..." -ForegroundColor Yellow
Invoke-SSHCommand "mkdir -p $REMOTE_SERVER && cd $REMOTE_SERVER && rm -rf * && unzip -o /tmp/server.zip && rm -f /tmp/server.zip"
# 执行 run.sh
Write-Host "[启动] 执行 run.sh..." -ForegroundColor Yellow
Invoke-SSHCommand "cd $REMOTE_SERVER && chmod +x run.sh && ./run.sh"
Write-Host "[完成] Server 项目部署完成!" -ForegroundColor Green
Write-Host ""
} else {
Write-Host "[跳过] 跳过 Server 项目部署" -ForegroundColor Yellow
Write-Host ""
}
# 清理临时文件
Write-Host "[清理] 清理临时文件..." -ForegroundColor Yellow
Remove-Item $TEMP_DIR -Recurse -Force
Write-Host "========================================" -ForegroundColor Green
Write-Host " 🎉 部署完成!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "服务访问地址:" -ForegroundColor Cyan
Write-Host " Web前端: http://$SSH_HOST" -ForegroundColor White
Write-Host " Admin后台: http://$SSH_HOST/admin" -ForegroundColor White
Write-Host " API服务: http://$SSH_HOST/api" -ForegroundColor White
Write-Host ""

View File

@@ -5,11 +5,26 @@ const ENV = {
apiBase: 'http://localhost:8081/api/v1',
useMock: false,
},
// 生产环境 - 线上后端
// 生产环境 - 线上后端(中国区)
production: {
apiBase: 'https://tral.cc/api/v1', // 替换为你的线上域名
useMock: false,
},
// 生产环境 - 中国区
'production-cn': {
apiBase: 'https://api-cn.your-domain.com/api/v1', // 中国区API地址
useMock: false,
},
// 生产环境 - 美国区
'production-us': {
apiBase: 'https://api-us.your-domain.com/api/v1', // 美国区API地址
useMock: false,
},
// 生产环境 - 欧洲区
'production-eu': {
apiBase: 'https://api-eu.your-domain.com/api/v1', // 欧洲区API地址
useMock: false,
},
// 测试环境
test: {
apiBase: 'https://test-api.your-domain.com/api/v1', // 替换为你的测试环境域名
@@ -23,7 +38,7 @@ const ENV = {
};
// 当前环境设置 - 修改这里来切换环境
const CURRENT_ENV = 'development'; // 可选值: 'development', 'production', 'test', 'mock'
const CURRENT_ENV = 'development'; // 可选值: 'development', 'production', 'production-cn', 'production-us', 'production-eu', 'test', 'mock'
// 导出当前环境的配置
const config = {

View File

@@ -55,6 +55,9 @@ export function getCategoryList() {
wx.request({
url: `${config.apiBase}/products/categories`,
method: 'GET',
data: {
platform: 'miniprogram' // 指定平台为小程序
},
success: (res) => {
if (res.statusCode === 200 && res.data.code === 200) {
console.log('原始分类数据:', res.data.data);

View File

@@ -14,11 +14,32 @@ const ENV_CONFIG = {
},
production: {
name: '生产环境',
description: '线上正式服务器',
description: '线上正式服务器(中国区)',
apiBase: 'https://tral.cc/api/v1',
useMock: false,
color: '#f5222d'
},
'production-cn': {
name: '生产环境-中国区',
description: '中国区生产服务器',
apiBase: 'https://api-cn.your-domain.com/api/v1',
useMock: false,
color: '#f5222d'
},
'production-us': {
name: '生产环境-美国区',
description: '美国区生产服务器',
apiBase: 'https://api-us.your-domain.com/api/v1',
useMock: false,
color: '#fa8c16'
},
'production-eu': {
name: '生产环境-欧洲区',
description: '欧洲区生产服务器',
apiBase: 'https://api-eu.your-domain.com/api/v1',
useMock: false,
color: '#13c2c2'
},
test: {
name: '测试环境',
description: '测试服务器',
@@ -101,7 +122,13 @@ export function checkNetworkConnection() {
console.error('❌ 后端服务连接失败:', error);
wx.showModal({
title: '连接失败',
content: `无法连接到后端服务\n${envInfo.apiBase}\n\n请检查:\n1. 后端服务是否启动\n2. 网络连接是否正常\n3. 环境配置是否正确`,
content: `无法连接到后端服务
${envInfo.apiBase}
请检查:
1. 后端服务是否启动
2. 网络连接是否正常
3. 环境配置是否正确`,
showCancel: false
});
}

97
nginx-frontend-proxy.conf Normal file
View File

@@ -0,0 +1,97 @@
# Nginx 前端代理配置
# 部署在 gvizee.com 服务器上
# 将 /api/ 请求代理到后端 HTTP 服务
server {
listen 80;
server_name gvizee.com www.gvizee.com;
# 重定向到 HTTPS
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name gvizee.com www.gvizee.com;
# SSL 证书配置(使用你现有的证书)
ssl_certificate /etc/letsencrypt/live/gvizee.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gvizee.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# 静态文件根目录
root /var/www/gvizee.com;
index index.html;
# 前端静态文件
location / {
try_files $uri $uri/ /index.html;
}
# API 反向代理到后端 HTTP 服务
location /api/ {
# 代理到后端服务器
proxy_pass http://104.244.91.212:8060;
# 设置代理头
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# CORS 头(如果后端已配置可以删除)
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
# 处理 OPTIONS 预检请求
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
# health 端点代理
location /health {
proxy_pass http://104.244.91.212:8060;
proxy_set_header Host $host;
}
}
# ====================================
# 部署步骤:
# ====================================
# 1. 上传此配置到 gvizee.com 服务器
# scp nginx-frontend-proxy.conf user@gvizee.com:/tmp/
#
# 2. SSH 登录服务器
# ssh user@gvizee.com
#
# 3. 复制配置文件
# sudo cp /tmp/nginx-frontend-proxy.conf /etc/nginx/sites-available/gvizee.com
# sudo ln -sf /etc/nginx/sites-available/gvizee.com /etc/nginx/sites-enabled/
#
# 4. 测试配置
# sudo nginx -t
#
# 5. 重启 Nginx
# sudo systemctl restart nginx
#
# ====================================
# 注意事项:
# ====================================
# - SSL 证书路径需要根据实际情况修改
# - 如果没有 SSL 证书,使用 Let's Encrypt 免费申请:
# sudo certbot --nginx -d gvizee.com -d www.gvizee.com
# - 确保后端服务器允许来自 gvizee.com 的请求

111
nginx-https-proxy.conf Normal file
View File

@@ -0,0 +1,111 @@
# Nginx HTTPS 反向代理配置
# 将此配置放到服务器的 /etc/nginx/sites-available/ 目录下
# HTTP 重定向到 HTTPS
server {
listen 80;
server_name 104.244.91.212;
# 重定向所有 HTTP 请求到 HTTPS
return 301 https://$server_name$request_uri;
}
# HTTPS 服务器
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name 104.244.91.212;
# SSL 证书配置(需要先生成证书)
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
# SSL 配置
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# 日志
access_log /var/log/nginx/api_access.log;
error_log /var/log/nginx/api_error.log;
# CORS 头(可选,因为后端已经处理了 CORS
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization' always;
# 处理预检请求
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
# API 代理(端口 8060
location /api/ {
proxy_pass http://127.0.0.1:8060;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 支持(如果需要)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# 超时配置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# 健康检查
location /health {
proxy_pass http://127.0.0.1:8060;
proxy_set_header Host $host;
}
# 根路径
location / {
proxy_pass http://127.0.0.1:8060;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# ====================================
# 部署步骤:
# ====================================
# 1. 生成自签名证书(开发/测试环境):
# sudo mkdir -p /etc/nginx/ssl
# sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
# -keyout /etc/nginx/ssl/server.key \
# -out /etc/nginx/ssl/server.crt \
# -subj "/C=US/ST=State/L=City/O=Organization/CN=104.244.91.212"
#
# 2. 复制此配置文件:
# sudo cp nginx-https-proxy.conf /etc/nginx/sites-available/api-https
# sudo ln -s /etc/nginx/sites-available/api-https /etc/nginx/sites-enabled/
#
# 3. 测试配置:
# sudo nginx -t
#
# 4. 重启 Nginx
# sudo systemctl restart nginx
#
# 5. 确保防火墙允许 443 端口:
# sudo ufw allow 443/tcp
#
# ====================================
# 注意事项:
# ====================================
# - 自签名证书会在浏览器中显示安全警告
# - 生产环境建议使用 Let's Encrypt 免费证书
# - 如果使用域名,将 server_name 改为域名

39
run.sh Normal file
View File

@@ -0,0 +1,39 @@
#!/bin/bash
PORT=8060
echo "=== 停止端口 $PORT 上的 Go 服务 ==="
# 直接查找监听 8060 端口的 Go 进程
GO_PID=$(ps aux | grep "go run cmd/main.go" | grep -v grep | awk '{print $2}')
if [ -n "$GO_PID" ]; then
echo "找到 Go 服务进程: $GO_PID"
sudo kill -9 $GO_PID
echo "Go 服务进程已终止"
sleep 2
fi
# 无论是否找到 Go 进程,都强制杀死占用端口的任何进程
echo "强制清理端口 $PORT..."
sudo fuser -k $PORT/tcp 2>/dev/null || true
# 使用多种方法确保端口释放
sudo pkill -f ":$PORT" 2>/dev/null || true
# 等待端口释放
sleep 3
echo "=== 启动新的 Go 服务 ==="
nohup go run cmd/main.go > ai_dianshang.log 2>&1 &
# 验证启动
sleep 5
if ps aux | grep "go run cmd/main.go" | grep -v grep > /dev/null; then
echo "✅ Go 服务启动成功"
echo "📋 日志文件: ai_dianshang.log"
echo "👀 查看日志: tail -f ai_dianshang.log"
echo "🌐 服务地址: http://localhost:$PORT"
else
echo "❌ Go 服务启动失败,请检查日志"
tail -n 20 ai_dianshang.log
fi

View File

@@ -1,37 +0,0 @@
# 开发环境变量配置
GO_ENV=development
# 数据库配置
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=JKjk20011115
DB_NAME=ai_dianshang_dev
# Redis配置
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT配置
JWT_SECRET=dev-jwt-secret-key-not-for-production
JWT_EXPIRE=7200
# 日志配置
LOG_LEVEL=debug
# 微信小程序配置
WECHAT_APP_ID=wx430b70d696b4dbd7
WECHAT_APP_SECRET=147751f8789272e56a43f748bec4b56b
# 微信支付配置(开发环境)
WECHAT_PAY_APP_ID=wx430b70d696b4dbd7
WECHAT_PAY_MCH_ID=
WECHAT_PAY_API_V3_KEY=
WECHAT_PAY_CERT_PATH=
WECHAT_PAY_KEY_PATH=
WECHAT_PAY_SERIAL_NO=
WECHAT_PAY_NOTIFY_URL=http://localhost:8080/api/payment/notify
WECHAT_PAY_REFUND_NOTIFY_URL=http://localhost:8080/api/refunds/callback
WECHAT_PAY_ENVIRONMENT=sandbox

View File

@@ -1,36 +0,0 @@
# 环境变量配置示例文件
# 复制此文件为 .env 并根据实际情况修改配置
# 应用环境 (development/dev, test/testing, production/prod)
GO_ENV=development
# 或者使用以下任一变量名
# APP_ENV=development
# ENVIRONMENT=development
# 数据库配置 (生产环境使用)
DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=your_password_here
DB_NAME=ai_dianshang
# Redis配置 (生产环境使用)
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# JWT配置 (生产环境使用)
JWT_SECRET=your-super-secret-jwt-key-for-production
JWT_EXPIRE=7200
# 日志配置 (生产环境使用)
LOG_LEVEL=info
# 微信小程序配置 (生产环境使用)
WECHAT_APP_ID=wx430b70d696b4dbd7
WECHAT_APP_SECRET=your_wechat_app_secret_here
# 服务器配置
SERVER_PORT=8080
SERVER_MODE=release

View File

@@ -1,26 +0,0 @@
# 测试环境变量配置
GO_ENV=test
# 数据库配置
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=test_password_123
DB_NAME=ai_dianshang_test
# Redis配置
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=1
# JWT配置
JWT_SECRET=test-jwt-secret-key-for-testing-only
JWT_EXPIRE=3600
# 日志配置
LOG_LEVEL=info
# 微信小程序配置
WECHAT_APP_ID=wx_test_app_id
WECHAT_APP_SECRET=test_app_secret_for_testing

6
server/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -4,13 +4,93 @@
<option name="autoReloadType" value="ALL" />
</component>
<component name="ChangeListManager">
<list default="true" id="7ea8f074-aff1-4978-8a35-3734e098b780" name="更改" comment="" />
<list default="true" id="7ea8f074-aff1-4978-8a35-3734e098b780" name="更改" comment="">
<change beforePath="$PROJECT_DIR$/../Makefile" beforeDir="false" afterPath="$PROJECT_DIR$/../Makefile" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/Dashboard-c6aa87b9.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/Login-369dc207.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/Permissions-4b3b2679.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/ProductList-e03d6e50.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/ProductList-ea349e38.css" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/Roles-a22d3b28.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/categories-80fde5d4.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/categories-fb0f0b81.css" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/index-0a06735a.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/index-12c2b32b.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/index-20ceadcc.css" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/index-3cdcc6af.css" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/index-60dd7d88.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/index-92073a1b.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/index-d81478ed.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/index-eacf7336.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/index-fa7a0ed8.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/orders-9608cfb6.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/assets/products-abc7ab38.js" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/dist/index.html" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/dist/index.html" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/src/api/products.js" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/src/api/products.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/src/layout/index.vue" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/src/layout/index.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/src/router/index.js" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/src/router/index.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/src/views/banners/components/BannerDetail.vue" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/src/views/banners/components/BannerDetail.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/src/views/banners/components/BannerForm.vue" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/src/views/banners/components/BannerForm.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/src/views/banners/index.vue" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/src/views/banners/index.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/src/views/products/categories.vue" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/src/views/products/categories.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/src/views/products/components/CategoryForm.vue" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/src/views/products/components/CategoryForm.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/src/views/products/components/ProductForm.vue" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/src/views/products/components/ProductForm.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../admin/src/views/products/index.vue" beforeDir="false" afterPath="$PROJECT_DIR$/../admin/src/views/products/index.vue" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../miniprogram/config/index.js" beforeDir="false" afterPath="$PROJECT_DIR$/../miniprogram/config/index.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../miniprogram/services/good/fetchCategoryList.js" beforeDir="false" afterPath="$PROJECT_DIR$/../miniprogram/services/good/fetchCategoryList.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/../miniprogram/utils/env-switcher.js" beforeDir="false" afterPath="$PROJECT_DIR$/../miniprogram/utils/env-switcher.js" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.env.dev" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.env.example" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.env.test" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/Dockerfile" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/configs/config.dev.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/configs/config.dev.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/configs/config.oss.example.yaml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/configs/config.prod.example.yaml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/configs/config.prod.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/configs/config.prod.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/config/config.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/config/config.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/handler/admin_product.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/handler/admin_product.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/handler/cart.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/handler/cart.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/handler/comment.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/handler/comment.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/handler/frontend.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/handler/frontend.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/handler/order.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/handler/order.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/handler/payment.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/handler/payment.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/handler/product.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/handler/product.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/handler/user.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/handler/user.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/middleware/cors.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/middleware/cors.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/model/product.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/model/product.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/model/user.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/model/user.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/repository/comment.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/repository/comment.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/repository/coupon.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/repository/coupon.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/repository/order.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/repository/order.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/repository/product.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/repository/product.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/repository/user.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/repository/user.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/router/router.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/router/router.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/service/cart.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/service/cart.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/service/comment.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/service/comment.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/service/coupon.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/service/coupon.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/service/product.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/service/product.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/service/user.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/service/user.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/internal/service/wechat_pay.go" beforeDir="false" afterPath="$PROJECT_DIR$/internal/service/wechat_pay.go" afterDir="false" />
<change beforePath="$PROJECT_DIR$/logs/app.dev.log" beforeDir="false" afterPath="$PROJECT_DIR$/logs/app.dev.log" afterDir="false" />
<change beforePath="$PROJECT_DIR$/migrations/001_create_refund_table.sql" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/migrations/001_create_refund_table_fixed.sql" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/migrations/002_fix_ai_products_id.sql" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/migrations/003_add_refunded_at_to_orders.sql" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/migrations/add_comment_tables.sql" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/migrations/add_wechat_fields_to_users.sql" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/start.sh" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/../start.txt" beforeDir="false" afterPath="$PROJECT_DIR$/../start.txt" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="GOROOT" url="file://$PROJECT_DIR$/../../../environment/go" />
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$/.." />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 2
}</component>
@@ -26,9 +106,11 @@
&quot;RunOnceActivity.GoLinterPluginOnboarding&quot;: &quot;true&quot;,
&quot;RunOnceActivity.GoLinterPluginStorageMigration&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;RunOnceActivity.go.formatter.settings.were.checked&quot;: &quot;true&quot;,
&quot;RunOnceActivity.go.migrated.go.modules.settings&quot;: &quot;true&quot;,
&quot;RunOnceActivity.go.modules.go.list.on.any.changes.was.set&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;master&quot;,
&quot;go.import.settings.migrated&quot;: &quot;true&quot;,
&quot;go.sdk.automatically.set&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;D:/project/Work/dianshang/server&quot;,

949
server/API_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,949 @@
# 后端API接口文档
## 1. 接口概述
### 1.1 基本信息
- **项目名称**: vizee电商后端服务
- **技术栈**: Go + Gin + GORM + MySQL
- **API版本**: v1
- **Base URL**:
- 开发环境: `http://localhost:8080/api/v1`
- 测试环境: `配置中`
- 生产环境: `配置中`
### 1.2 通用说明
- **数据格式**: JSON
- **字符编码**: UTF-8
- **认证方式**: JWT Token
- **时间格式**: ISO 8601 (YYYY-MM-DDTHH:mm:ss.SSSZ)
### 1.3 通用响应格式
**成功响应**:
```json
{
"code": 200,
"message": "success",
"data": {}
}
```
**错误响应**:
```json
{
"code": 400,
"message": "错误信息描述",
"data": null
}
```
### 1.4 状态码说明
| 状态码 | 说明 |
|--------|------|
| 200 | 成功 |
| 400 | 请求参数错误 |
| 401 | 未授权,需要登录 |
| 403 | 禁止访问 |
| 404 | 资源不存在 |
| 500 | 服务器内部错误 |
---
## 2. 认证接口
### 2.1 用户登录
**接口地址**: `POST /auth/login`
**请求参数**:
```json
{
"email": "user@example.com",
"password": "password123"
}
```
**响应数据**:
```json
{
"code": 200,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 1,
"email": "user@example.com",
"nickname": "用户昵称",
"avatar": "头像URL",
"created_at": "2024-01-01T00:00:00Z"
}
}
}
```
### 2.2 用户注册
**接口地址**: `POST /auth/register`
**请求参数**:
```json
{
"email": "user@example.com",
"password": "password123",
"nickname": "用户昵称"
}
```
**响应数据**:
```json
{
"code": 200,
"message": "注册成功",
"data": {
"user_id": 1
}
}
```
### 2.3 微信登录
**接口地址**: `POST /auth/wechat/login`
**请求参数**:
```json
{
"code": "微信登录code"
}
```
**响应数据**:
```json
{
"code": 200,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIs...",
"user": {
"id": 1,
"openid": "微信openid",
"nickname": "微信昵称",
"avatar": "微信头像"
}
}
}
```
---
## 3. 用户接口
### 3.1 获取用户信息
**接口地址**: `GET /users/profile`
**请求头**:
```
Authorization: Bearer {token}
```
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": {
"id": 1,
"email": "user@example.com",
"nickname": "用户昵称",
"avatar": "头像URL",
"phone": "手机号",
"gender": 1,
"points": 100,
"created_at": "2024-01-01T00:00:00Z"
}
}
```
### 3.2 更新用户信息
**接口地址**: `PUT /users/profile`
**请求头**:
```
Authorization: Bearer {token}
```
**请求参数**:
```json
{
"nickname": "新昵称",
"avatar": "新头像URL",
"phone": "手机号",
"gender": 1
}
```
**响应数据**:
```json
{
"code": 200,
"message": "更新成功",
"data": null
}
```
### 3.3 获取用户地址列表
**接口地址**: `GET /users/addresses`
**请求头**:
```
Authorization: Bearer {token}
```
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": [
{
"id": 1,
"name": "收货人",
"phone": "13800138000",
"province": "广东省",
"city": "深圳市",
"district": "南山区",
"detail": "详细地址",
"is_default": true
}
]
}
```
### 3.4 添加收货地址
**接口地址**: `POST /users/addresses`
**请求参数**:
```json
{
"name": "收货人",
"phone": "13800138000",
"province": "广东省",
"city": "深圳市",
"district": "南山区",
"detail": "详细地址",
"is_default": false
}
```
---
## 4. 商品接口
### 4.1 获取商品列表
**接口地址**: `GET /products`
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码默认1 |
| page_size | int | 否 | 每页数量默认20 |
| category_id | int | 否 | 分类ID |
| keyword | string | 否 | 搜索关键词 |
| sort | string | 否 | 排序方式price_asc/price_desc/sales/new |
| min_price | float | 否 | 最低价格 |
| max_price | float | 否 | 最高价格 |
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": {
"list": [
{
"id": 1,
"name": "商品名称",
"cover": "封面图URL",
"price": 99.00,
"original_price": 199.00,
"sales": 1000,
"stock": 500,
"rating": 4.8,
"comment_count": 100
}
],
"pagination": {
"page": 1,
"page_size": 20,
"total": 100
}
}
}
```
### 4.2 获取商品详情
**接口地址**: `GET /products/:id`
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": {
"id": 1,
"name": "商品名称",
"cover": "封面图URL",
"images": ["图片1", "图片2"],
"price": 99.00,
"original_price": 199.00,
"sales": 1000,
"stock": 500,
"rating": 4.8,
"comment_count": 100,
"description": "商品描述",
"detail": "商品详情HTML",
"category": {
"id": 1,
"name": "分类名称"
},
"skus": [
{
"id": 1,
"name": "规格名称",
"price": 99.00,
"stock": 100,
"image": "规格图片"
}
],
"specs": [
{
"name": "颜色",
"values": ["红色", "蓝色"]
}
]
}
}
```
### 4.3 获取商品评价
**接口地址**: `GET /products/:id/comments`
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码 |
| page_size | int | 否 | 每页数量 |
| rating | int | 否 | 评分筛选1-5 |
| has_image | bool | 否 | 是否有图 |
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": {
"stats": {
"total": 100,
"rating_5": 80,
"rating_4": 15,
"rating_3": 3,
"rating_2": 1,
"rating_1": 1,
"avg_rating": 4.8
},
"list": [
{
"id": 1,
"user": {
"nickname": "用户昵称",
"avatar": "头像URL"
},
"rating": 5,
"content": "评价内容",
"images": ["图片1", "图片2"],
"created_at": "2024-01-01T00:00:00Z"
}
],
"pagination": {
"page": 1,
"page_size": 10,
"total": 100
}
}
}
```
---
## 5. 分类接口
### 5.1 获取分类列表
**接口地址**: `GET /categories`
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": [
{
"id": 1,
"name": "艺术手工",
"icon": "图标URL",
"sort": 1,
"children": [
{
"id": 11,
"name": "陶艺",
"parent_id": 1
}
]
}
]
}
```
---
## 6. 购物车接口
### 6.1 获取购物车
**接口地址**: `GET /cart`
**请求头**:
```
Authorization: Bearer {token}
```
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": {
"items": [
{
"id": 1,
"product_id": 1,
"sku_id": 1,
"product_name": "商品名称",
"sku_name": "规格名称",
"image": "图片URL",
"price": 99.00,
"quantity": 2,
"stock": 100,
"selected": true
}
],
"total_price": 198.00,
"total_count": 2
}
}
```
### 6.2 添加到购物车
**接口地址**: `POST /cart`
**请求参数**:
```json
{
"product_id": 1,
"sku_id": 1,
"quantity": 1
}
```
### 6.3 更新购物车数量
**接口地址**: `PUT /cart/:id`
**请求参数**:
```json
{
"quantity": 2
}
```
### 6.4 删除购物车商品
**接口地址**: `DELETE /cart/:id`
### 6.5 清空购物车
**接口地址**: `DELETE /cart/clear`
---
## 7. 订单接口
### 7.1 创建订单
**接口地址**: `POST /orders`
**请求参数**:
```json
{
"address_id": 1,
"items": [
{
"product_id": 1,
"sku_id": 1,
"quantity": 2
}
],
"coupon_id": 1,
"remark": "备注信息"
}
```
**响应数据**:
```json
{
"code": 200,
"message": "下单成功",
"data": {
"order_id": "ORD20240101123456",
"total_amount": 198.00,
"pay_amount": 188.00
}
}
```
### 7.2 获取订单列表
**接口地址**: `GET /orders`
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码 |
| page_size | int | 否 | 每页数量 |
| status | string | 否 | 订单状态pending/paid/shipped/completed/cancelled |
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": {
"list": [
{
"id": "ORD20240101123456",
"status": "paid",
"status_text": "已支付",
"total_amount": 198.00,
"pay_amount": 188.00,
"items": [
{
"product_name": "商品名称",
"sku_name": "规格名称",
"image": "图片URL",
"price": 99.00,
"quantity": 2
}
],
"created_at": "2024-01-01T00:00:00Z"
}
],
"pagination": {
"page": 1,
"page_size": 10,
"total": 50
}
}
}
```
### 7.3 获取订单详情
**接口地址**: `GET /orders/:id`
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": {
"id": "ORD20240101123456",
"status": "paid",
"status_text": "已支付",
"total_amount": 198.00,
"pay_amount": 188.00,
"shipping_fee": 10.00,
"discount_amount": 20.00,
"address": {
"name": "收货人",
"phone": "13800138000",
"province": "广东省",
"city": "深圳市",
"district": "南山区",
"detail": "详细地址"
},
"items": [
{
"product_name": "商品名称",
"sku_name": "规格名称",
"image": "图片URL",
"price": 99.00,
"quantity": 2
}
],
"created_at": "2024-01-01T00:00:00Z",
"paid_at": "2024-01-01T00:05:00Z"
}
}
```
### 7.4 取消订单
**接口地址**: `POST /orders/:id/cancel`
**请求参数**:
```json
{
"reason": "取消原因"
}
```
### 7.5 确认收货
**接口地址**: `POST /orders/:id/confirm`
---
## 8. 支付接口
### 8.1 创建支付
**接口地址**: `POST /pay/create`
**请求参数**:
```json
{
"order_id": "ORD20240101123456",
"pay_type": "wechat"
}
```
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": {
"pay_params": {
"timeStamp": "1234567890",
"nonceStr": "random_string",
"package": "prepay_id=xxx",
"signType": "MD5",
"paySign": "signature"
}
}
}
```
### 8.2 支付回调
**接口地址**: `POST /pay/callback`
**说明**: 由支付平台调用,处理支付结果
---
## 9. 退款接口
### 9.1 申请退款
**接口地址**: `POST /refunds`
**请求参数**:
```json
{
"order_id": "ORD20240101123456",
"reason": "退款原因",
"description": "详细说明",
"images": ["图片1", "图片2"]
}
```
### 9.2 获取退款列表
**接口地址**: `GET /refunds`
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": {
"list": [
{
"id": 1,
"order_id": "ORD20240101123456",
"status": "pending",
"status_text": "待审核",
"amount": 188.00,
"reason": "退款原因",
"created_at": "2024-01-01T00:00:00Z"
}
]
}
}
```
### 9.3 取消退款申请
**接口地址**: `POST /refunds/:id/cancel`
---
## 10. Banner接口
### 10.1 获取Banner列表
**接口地址**: `GET /banners`
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| position | string | 否 | 位置home/category |
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": [
{
"id": 1,
"title": "Banner标题",
"image": "图片URL",
"link": "跳转链接",
"sort": 1
}
]
}
```
---
## 11. 优惠券接口
### 11.1 获取优惠券列表
**接口地址**: `GET /coupons`
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": [
{
"id": 1,
"name": "优惠券名称",
"type": "discount",
"discount": 0.9,
"min_amount": 100.00,
"max_discount": 50.00,
"start_time": "2024-01-01T00:00:00Z",
"end_time": "2024-12-31T23:59:59Z",
"total_count": 1000,
"received_count": 500,
"is_received": false
}
]
}
```
### 11.2 领取优惠券
**接口地址**: `POST /coupons/:id/receive`
### 11.3 获取我的优惠券
**接口地址**: `GET /coupons/my`
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| status | string | 否 | unused/used/expired |
---
## 12. 文件上传接口
### 12.1 上传图片
**接口地址**: `POST /upload/image`
**请求头**:
```
Content-Type: multipart/form-data
Authorization: Bearer {token}
```
**请求参数**:
- file: 图片文件支持jpg、png、gif最大5MB
**响应数据**:
```json
{
"code": 200,
"message": "上传成功",
"data": {
"url": "https://cdn.example.com/images/xxx.jpg"
}
}
```
**说明**:
- 图片自动压缩优化
- 自动生成缩略图
- 设置正确的Content-Type和Content-Disposition
- 返回OSS访问URL
---
## 13. 统计接口(管理端)
### 13.1 获取销售统计
**接口地址**: `GET /admin/stats/sales`
**请求头**:
```
Authorization: Bearer {admin_token}
```
**请求参数**:
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| start_date | string | 是 | 开始日期 |
| end_date | string | 是 | 结束日期 |
**响应数据**:
```json
{
"code": 200,
"message": "success",
"data": {
"total_amount": 100000.00,
"total_orders": 1000,
"avg_order_amount": 100.00,
"daily_stats": [
{
"date": "2024-01-01",
"amount": 1000.00,
"orders": 10
}
]
}
}
```
---
## 14. 错误码说明
| 错误码 | 说明 |
|--------|------|
| 10001 | 参数错误 |
| 10002 | 用户不存在 |
| 10003 | 密码错误 |
| 10004 | Token无效 |
| 10005 | Token过期 |
| 20001 | 商品不存在 |
| 20002 | 库存不足 |
| 20003 | 商品已下架 |
| 30001 | 订单不存在 |
| 30002 | 订单状态错误 |
| 30003 | 订单已取消 |
| 40001 | 支付失败 |
| 40002 | 退款失败 |
| 50001 | 文件上传失败 |
| 50002 | 文件类型不支持 |
| 50003 | 文件大小超限 |
---
## 15. 开发调试
### 15.1 本地环境配置
**配置文件**: `server/configs/config.yaml`
```yaml
server:
port: 8080
mode: debug
database:
host: localhost
port: 3306
username: root
password: password
database: dianshang
jwt:
secret: your-secret-key
expire: 7200
oss:
endpoint: oss-cn-shenzhen.aliyuncs.com
access_key_id: your-access-key-id
access_key_secret: your-access-key-secret
bucket: your-bucket-name
```
### 15.2 API调试工具
- Postman
- Apifox
- Swagger UI (开发中)
### 15.3 测试账号
- **普通用户**: test@example.com / 123456
- **管理员**: admin@example.com / admin123
---
## 16. 注意事项
### 16.1 请求限制
- 同一IP每分钟最多60次请求
- 文件上传大小限制5MB
- 批量操作最多100条
### 16.2 数据安全
- 敏感信息传输使用HTTPS
- 密码使用bcrypt加密
- Token有效期2小时
- 软删除数据需显式过滤
### 16.3 性能优化
- 列表接口支持分页
- 使用Redis缓存热点数据
- 数据库连接池优化
- 图片CDN加速
### 16.4 数据库规范
- GORM预加载需显式过滤软删除数据
- SKU价格优先级SKU价格 > 商品价格
- 订单数据按用户隔离存储
- 所有时间字段使用UTC时间
---
**文档版本**: v1.0
**创建日期**: 2024-11-19
**最后更新**: 2024-11-19
**维护团队**: 后端开发组

View File

@@ -1,58 +0,0 @@
# 构建阶段
FROM golang:1.21-alpine AS builder
# 设置工作目录
WORKDIR /app
# 安装必要的包
RUN apk add --no-cache git
# 复制 go mod 文件
COPY go.mod go.sum ./
# 下载依赖
RUN go mod download
# 复制源代码
COPY . .
# 构建应用
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go
# 运行阶段
FROM alpine:latest
# 安装必要的包
RUN apk --no-cache add ca-certificates tzdata
# 设置时区
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
# 创建非root用户
RUN addgroup -g 1001 -S appgroup && \
adduser -u 1001 -S appuser -G appgroup
# 设置工作目录
WORKDIR /app
# 从构建阶段复制二进制文件
COPY --from=builder /app/main .
# 复制配置文件
COPY --from=builder /app/configs ./configs
# 创建日志目录
RUN mkdir -p logs && chown -R appuser:appgroup /app
# 切换到非root用户
USER appuser
# 暴露端口
EXPOSE 8080
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
# 启动应用
CMD ["./main"]

BIN
server/bin/server.exe Normal file

Binary file not shown.

View File

@@ -58,8 +58,8 @@ wechatPay:
certPath: "certs/apiclient_cert.pem" # 商户证书路径
keyPath: "certs/apiclient_key.pem" # 商户私钥路径
serialNo: "26DA8C2BC03B796222DA3FCFC6825B236A8C7538" # 证书序列号
notifyUrl: "http://192.168.10.109:8081/api/v1/payment/notify" # 支付回调地址
refundNotifyUrl: "http://192.168.10.109:8081/api/refunds/callback" # 退款回调地址
notifyUrl: "https://tral.cc/api/v1/payment/notify" # 支付回调地址
refundNotifyUrl: "https://tral.cc/api/refunds/callback" # 退款回调地址
upload:
maxImageSize: 5242880 # 5MB (5 * 1024 * 1024)

View File

@@ -1,132 +0,0 @@
# 阿里云OSS配置示例
# 将此文件复制为 config.yaml 或 config.prod.yaml并填入你的实际配置信息
server:
port: 8080
mode: release
database:
driver: mysql
host: 127.0.0.1
port: 3306
username: root
password: "your-password"
dbname: ai_dianshang
charset: utf8mb4
parseTime: true
loc: Local
redis:
host: localhost
port: 6379
password: ""
db: 0
jwt:
secret: "your-jwt-secret-key-change-this-in-production"
expire: 7200
log:
level: info
filename: logs/app.log
maxSize: 100
maxAge: 30
maxBackups: 5
enableConsole: true
enableFile: true
format: json
enableCaller: true
enableOperation: true
enablePerf: true
perfThreshold: 1000
wechat:
appId: "your-wechat-appid"
appSecret: "your-wechat-appsecret"
wechatPay:
environment: "production"
appId: "your-wechat-appid"
mchId: "your-merchant-id"
apiV3Key: "your-api-v3-key-32-characters"
certPath: "certs/apiclient_cert.pem"
keyPath: "certs/apiclient_key.pem"
serialNo: "your-certificate-serial-number"
notifyUrl: "https://yourdomain.com/api/v1/payment/notify"
refundNotifyUrl: "https://yourdomain.com/api/refunds/callback"
# ========== 文件上传配置 ==========
upload:
maxImageSize: 5242880 # 5MB
maxFileSize: 10485760 # 10MB
imageTypes: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]
# 存储类型选择:
# - local: 本地存储(默认)
# - oss: 阿里云OSS
storageType: "oss"
# 本地存储配置storageType为local时使用
staticPath: "./static"
baseUrl: "https://yourdomain.com"
# 阿里云OSS配置storageType为oss时使用
oss:
# OSS访问域名Endpoint
# 根据你的Bucket所在地域选择
# - 华东1杭州oss-cn-hangzhou.aliyuncs.com
# - 华东2上海oss-cn-shanghai.aliyuncs.com
# - 华北1青岛oss-cn-qingdao.aliyuncs.com
# - 华北2北京oss-cn-beijing.aliyuncs.com
# - 华南1深圳oss-cn-shenzhen.aliyuncs.com
# 更多地域请参考https://help.aliyun.com/document_detail/31837.html
endpoint: "oss-cn-hangzhou.aliyuncs.com"
# AccessKey ID在阿里云控制台获取
# 建议使用RAM子账号的AccessKey并授予最小权限
accessKeyId: "your-access-key-id"
# AccessKey Secret在阿里云控制台获取
accessKeySecret: "your-access-key-secret"
# Bucket名称需要提前在OSS控制台创建
bucketName: "your-bucket-name"
# 文件存储基础路径(可选,建议设置以便文件分类管理)
# 例如dianshang/ 表示所有文件都存储在 dianshang/ 目录下
basePath: "dianshang/"
# 自定义域名(可选)
# 如果你配置了CDN加速域名或自定义域名填写这里
# 例如https://cdn.yourdomain.com
# 如果不填写将使用默认的OSS域名https://bucket-name.endpoint/
domain: ""
# ========== OSS配置说明 ==========
#
# 1. 创建OSS Bucket
# - 登录阿里云OSS控制台https://oss.console.aliyun.com/
# - 创建Bucket选择合适的地域和存储类型
# - 设置读写权限为"公共读"(如果需要直接访问)或"私有"使用签名URL访问
#
# 2. 获取AccessKey
# - 访问https://ram.console.aliyun.com/manage/ak
# - 建议创建RAM子账号只授予OSS相关权限AliyunOSSFullAccess
# - 获取AccessKey ID和AccessKey Secret
#
# 3. 配置跨域CORS
# - 在OSS控制台的Bucket设置中配置CORS规则
# - 允许的来源:* 或你的域名
# - 允许的方法GET, POST, PUT, DELETE, HEAD
# - 允许的Headers*
#
# 4. CDN加速可选但推荐
# - 在OSS控制台绑定自定义域名
# - 开启CDN加速以提升访问速度
# - 配置HTTPS证书
# - 将domain设置为你的CDN域名
#
# 5. 切换存储方式:
# - 修改 storageType 为 "local" 使用本地存储
# - 修改 storageType 为 "oss" 使用阿里云OSS
# - 系统支持降级OSS上传失败时自动切换到本地存储

View File

@@ -0,0 +1,80 @@
# 生产环境配置 - 中国区
server:
port: 8060
mode: release # debug, release, test
# 数据库配置 - 中国区生产环境
database:
driver: mysql
host: 8.149.233.36
port: 3306
username: ai_dianshang
password: "7aK_H2yvokVumr84lLNDt8fDBp6P"
dbname: ai_dianshang
charset: utf8mb4
parseTime: true
loc: Local
autoMigrate: false # 生产环境禁用自动迁移
logLevel: silent # 生产环境关闭GORM SQL日志
# Redis配置 - 中国区生产环境
redis:
host: localhost
port: 6379
password: ""
db: 0
# JWT配置 - 中国区生产环境
jwt:
secret: "prod-cn-jwt-secret-key-change-this"
expire: 7200
# 日志配置 - 中国区生产环境
log:
level: info # debug, info, warn, error
filename: logs/app.prod-cn.log
maxSize: 200 # MB
maxAge: 30 # 天
maxBackups: 10 # 保留文件数
enableConsole: true # 启用控制台输出
enableFile: true
format: text # 使用text格式便于查看
enableCaller: true
enableOperation: true
enablePerf: true
perfThreshold: 2000 # 生产环境更宽松的性能阈值
# 微信小程序配置 - 中国区生产环境
wechat:
appId: "wxccc7018b3bfff234"
appSecret: "fa5802a24e7dca8a3cf91ad1e2f288e8"
# 微信支付配置 - 中国区生产环境
wechatPay:
environment: "production" # sandbox(沙箱) 或 production(生产)
appId: "wxccc7018b3bfff234" # 您的真实微信小程序AppID
mchId: "1726717114" # 您的真实微信支付商户号
apiV3Key: "M2nB4vCxZ7qW8eKrDtA1jHlP5gF3sN9y" # 您的真实APIv3密钥(32位)
certPath: "certs/apiclient_cert.pem" # 商户证书路径
keyPath: "certs/apiclient_key.pem" # 商户私钥路径
serialNo: "26DA8C2BC03B796222DA3FCFC6825B236A8C7538" # 证书序列号
notifyUrl: "https://api-cn.your-domain.com/api/v1/payment/notify" # 中国区支付回调地址
refundNotifyUrl: "https://api-cn.your-domain.com/api/refunds/callback" # 中国区退款回调地址
# 文件上传配置 - 中国区生产环境
upload:
maxImageSize: 5242880 # 5MB (5 * 1024 * 1024)
maxFileSize: 10485760 # 10MB (10 * 1024 * 1024)
imageTypes: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]
staticPath: "./static"
baseUrl: "https://api-cn.your-domain.com"
storageType: "oss" # local(本地存储) 或 oss(阿里云OSS)
# 阿里云OSS配置中国区
oss:
endpoint: "oss-cn-beijing.aliyuncs.com" # 中国北京地域
accessKeyId: "LTAI5tNesdhDH4ErqEUZmEg2" # 你的AccessKey ID
accessKeySecret: "xZn7WUkTW76TqOLTh01zZATnU6p3Tf" # 你的AccessKey Secret
bucketName: "bxmkb-beijing" # 你的Bucket名称
basePath: "dianshang/" # 文件存储基础路径
domain: "" # 自定义域名可选如果有CDN加速域名

View File

@@ -0,0 +1,80 @@
# 生产环境配置 - 欧洲区
server:
port: 8060
mode: release # debug, release, test
# 数据库配置 - 欧洲区生产环境
database:
driver: mysql
host: eu-db.your-domain.com # 欧洲区数据库地址
port: 3306
username: ai_dianshang_eu
password: "your-eu-db-password" # 请替换为实际密码
dbname: ai_dianshang
charset: utf8mb4
parseTime: true
loc: Local
autoMigrate: false # 生产环境禁用自动迁移
logLevel: silent # 生产环境关闭GORM SQL日志
# Redis配置 - 欧洲区生产环境
redis:
host: localhost
port: 6379
password: ""
db: 0
# JWT配置 - 欧洲区生产环境
jwt:
secret: "prod-eu-jwt-secret-key-change-this"
expire: 7200
# 日志配置 - 欧洲区生产环境
log:
level: info # debug, info, warn, error
filename: logs/app.prod-eu.log
maxSize: 200 # MB
maxAge: 30 # 天
maxBackups: 10 # 保留文件数
enableConsole: true # 启用控制台输出
enableFile: true
format: text # 使用text格式便于查看
enableCaller: true
enableOperation: true
enablePerf: true
perfThreshold: 2000 # 生产环境更宽松的性能阈值
# 微信小程序配置 - 欧洲区生产环境
wechat:
appId: "wx_eu_app_id" # 欧洲区小程序AppID
appSecret: "eu_app_secret" # 欧洲区小程序AppSecret
# 微信支付配置 - 欧洲区生产环境
wechatPay:
environment: "production"
appId: "wx_eu_app_id"
mchId: "eu_merchant_id"
apiV3Key: "eu_api_v3_key"
certPath: "certs/eu_apiclient_cert.pem"
keyPath: "certs/eu_apiclient_key.pem"
serialNo: "eu_serial_no"
notifyUrl: "https://api-eu.your-domain.com/api/v1/payment/notify"
refundNotifyUrl: "https://api-eu.your-domain.com/api/refunds/callback"
# 文件上传配置 - 欧洲区生产环境
upload:
maxImageSize: 5242880 # 5MB
maxFileSize: 10485760 # 10MB
imageTypes: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]
staticPath: "./static"
baseUrl: "https://api-eu.your-domain.com"
storageType: "oss"
# 阿里云OSS配置欧洲区
oss:
endpoint: "oss-eu-central-1.aliyuncs.com" # 欧洲中部地域
accessKeyId: "your-eu-access-key-id"
accessKeySecret: "your-eu-access-key-secret"
bucketName: "your-eu-bucket"
basePath: "dianshang/"
domain: ""

View File

@@ -0,0 +1,80 @@
# 生产环境配置 - 美国区
server:
port: 8060
mode: release # debug, release, test
# 数据库配置 - 美国区生产环境
database:
driver: mysql
host: 104.244.91.212 # 美国区数据库地址
port: 3306
username: ai_dianshang
password: "7aK_H2yvokVumr84lLNDt8fDBp6P" # 请替换为实际密码
dbname: ai_dianshang
charset: utf8mb4
parseTime: true
loc: Local
autoMigrate: false # 生产环境禁用自动迁移
logLevel: silent # 生产环境关闭GORM SQL日志
# Redis配置 - 美国区生产环境
redis:
host: localhost
port: 6379
password: ""
db: 0
# JWT配置 - 美国区生产环境
jwt:
secret: "prod-us-jwt-secret-key-change-this"
expire: 7200
# 日志配置 - 美国区生产环境
log:
level: info # debug, info, warn, error
filename: logs/app.prod-us.log
maxSize: 200 # MB
maxAge: 30 # 天
maxBackups: 10 # 保留文件数
enableConsole: true # 启用控制台输出
enableFile: true
format: text # 使用text格式便于查看
enableCaller: true
enableOperation: true
enablePerf: true
perfThreshold: 2000 # 生产环境更宽松的性能阈值
# 微信小程序配置 - 生产环境
wechat:
appId: "wxccc7018b3bfff234"
appSecret: "fa5802a24e7dca8a3cf91ad1e2f288e8"
# 微信支付配置 - 开发环境
wechatPay:
environment: "production" # sandbox(沙箱) 或 production(生产)
appId: "wxccc7018b3bfff234" # 您的真实微信小程序AppID
mchId: "1726717114" # 您的真实微信支付商户号
apiV3Key: "M2nB4vCxZ7qW8eKrDtA1jHlP5gF3sN9y" # 您的真实APIv3密钥(32位)
certPath: "certs/apiclient_cert.pem" # 商户证书路径
keyPath: "certs/apiclient_key.pem" # 商户私钥路径
serialNo: "26DA8C2BC03B796222DA3FCFC6825B236A8C7538" # 证书序列号
notifyUrl: "https://vizee.shop/api/v1/payment/notify" # 支付回调地址
refundNotifyUrl: "https://vizee.shop/api/refunds/callback" # 退款回调地址
# 文件上传配置 - 生产环境
upload:
maxImageSize: 5242880 # 5MB (5 * 1024 * 1024)
maxFileSize: 10485760 # 10MB (10 * 1024 * 1024)
imageTypes: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]
staticPath: "./static"
baseUrl: "http://vizee.shop"
storageType: "oss" # local(本地存储) 或 oss(阿里云OSS)
# 阿里云OSS配置当storageType为oss时生效
oss:
endpoint: "oss-cn-beijing.aliyuncs.com" # OSS访问域名根据你的地域修改
accessKeyId: "LTAI5tNesdhDH4ErqEUZmEg2" # 你的AccessKey ID
accessKeySecret: "xZn7WUkTW76TqOLTh01zZATnU6p3Tf" # 你的AccessKey Secret
bucketName: "bxmkb-beijing" # 你的Bucket名称
basePath: "dianshang/" # 文件存储基础路径
domain: "" # 自定义域名可选如果有CDN加速域名

View File

@@ -1,65 +0,0 @@
# 生产环境微信支付配置示例
# 请复制此文件为 config.prod.yaml 并填入真实参数
server:
port: 8080
mode: release
# 数据库配置 - 生产环境
database:
driver: mysql
host: your_db_host
port: 3306
username: your_db_user
password: "your_db_password"
dbname: ai_dianshang
charset: utf8mb4
parseTime: true
loc: Local
# Redis配置 - 生产环境
redis:
host: your_redis_host
port: 6379
password: "your_redis_password"
db: 0
# JWT配置 - 生产环境
jwt:
secret: "your-production-jwt-secret-key-must-be-complex"
expire: 7200
# 日志配置 - 生产环境
log:
level: info
filename: logs/app.log
maxSize: 100
maxAge: 30
maxBackups: 10
enableConsole: true
enableFile: true
format: json
enableCaller: false
enableOperation: true
enablePerf: true
perfThreshold: 1000
# 微信小程序配置 - 生产环境
wechat:
appId: "wx1234567890abcdef" # 替换为您的真实AppID
appSecret: "your_real_app_secret" # 替换为您的真实AppSecret
# 微信支付配置 - 生产环境
wechatPay:
appId: "wx1234567890abcdef" # 您的真实微信小程序AppID
mchId: "1600000000" # 您的真实微信支付商户号
apiKey: "your_real_32_character_api_v3_key_here" # 您的真实APIv3密钥(32位)
certPath: "certs/apiclient_cert.pem" # 商户证书路径
keyPath: "certs/apiclient_key.pem" # 商户私钥路径
notifyUrl: "https://yourdomain.com/api/v1/payment/notify" # 您的真实支付回调URL(必须HTTPS)
# 重要提醒:
# 1. 所有以 "your_" 开头的值都需要替换为真实值
# 2. 证书文件需要从微信支付商户平台下载
# 3. 回调URL必须是可公网访问的HTTPS地址
# 4. 商户号需要通过微信支付商户资质审核获得

View File

@@ -36,10 +36,10 @@ log:
maxSize: 200 # MB
maxAge: 30 # 天
maxBackups: 10 # 保留文件数
enableConsole: true # 生产环境不输出到控制台
enableConsole: true # 启用控制台输出
enableFile: true
format: json
enableCaller: true # 生产环境关闭调用者信息
format: text # 使用text格式便于查看
enableCaller: true
enableOperation: true
enablePerf: true
perfThreshold: 2000 # 生产环境更宽松的性能阈值

View File

@@ -187,6 +187,15 @@ func getConfigName(env string) string {
return "config.test"
case "production", "prod":
return "config.prod"
// 生产环境 - 中国区
case "prod-cn", "production-cn":
return "config.prod-cn"
// 生产环境 - 美国区
case "prod-us", "production-us":
return "config.prod-us"
// 生产环境 - 欧洲区
case "prod-eu", "production-eu":
return "config.prod-eu"
default:
// 如果环境不匹配,尝试使用默认配置文件
if _, err := os.Stat("./configs/config.yaml"); err == nil {

View File

@@ -0,0 +1,466 @@
package handler
import (
"dianshang/internal/service"
"dianshang/pkg/logger"
"dianshang/pkg/response"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type AdminCouponHandler struct {
couponService *service.CouponService
}
func NewAdminCouponHandler(couponService *service.CouponService) *AdminCouponHandler {
return &AdminCouponHandler{
couponService: couponService,
}
}
// GetCouponList 获取优惠券列表
// @Summary 获取优惠券列表
// @Description 管理员获取所有优惠券(分页)
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param status query int false "状态筛选1-启用 0-禁用)"
// @Param type query int false "类型筛选1-满减 2-折扣 3-免邮)"
// @Param keyword query string false "搜索关键词"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons [get]
func (h *AdminCouponHandler) GetCouponList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
status := c.Query("status")
couponType := c.Query("type")
keyword := c.Query("keyword")
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
coupons, total, err := h.couponService.GetCouponListForAdmin(page, pageSize, status, couponType, keyword)
if err != nil {
logger.Error("获取优惠券列表失败", "error", err)
response.ErrorWithMessage(c, response.ERROR, "获取优惠券列表失败")
return
}
response.Page(c, coupons, total, page, pageSize)
}
// GetCouponDetail 获取优惠券详情
// @Summary 获取优惠券详情
// @Description 获取指定优惠券的详细信息
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param id path int true "优惠券ID"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons/{id} [get]
func (h *AdminCouponHandler) GetCouponDetail(c *gin.Context) {
couponIDStr := c.Param("id")
couponID, err := strconv.ParseUint(couponIDStr, 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "无效的优惠券ID")
return
}
coupon, err := h.couponService.GetCouponDetailForAdmin(uint(couponID))
if err != nil {
logger.Error("获取优惠券详情失败", "error", err, "couponID", couponID)
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, coupon)
}
// CreateCoupon 创建优惠券
// @Summary 创建优惠券
// @Description 创建新的优惠券
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param request body CreateCouponRequest true "创建优惠券请求"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons [post]
func (h *AdminCouponHandler) CreateCoupon(c *gin.Context) {
var req CreateCouponRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error("绑定创建优惠券参数失败", "error", err)
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "参数错误: "+err.Error())
return
}
// 获取管理员ID
adminID, exists := c.Get("user_id")
if !exists {
response.Error(c, response.ERROR_UNAUTHORIZED)
return
}
coupon, err := h.couponService.CreateCoupon(&req, adminID.(uint))
if err != nil {
logger.Error("创建优惠券失败", "error", err)
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
logger.Info("优惠券创建成功", "couponID", coupon.ID, "name", coupon.Name, "adminID", adminID)
response.Success(c, coupon)
}
// UpdateCoupon 更新优惠券
// @Summary 更新优惠券
// @Description 更新优惠券信息
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param id path int true "优惠券ID"
// @Param request body UpdateCouponRequest true "更新优惠券请求"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons/{id} [put]
func (h *AdminCouponHandler) UpdateCoupon(c *gin.Context) {
couponIDStr := c.Param("id")
couponID, err := strconv.ParseUint(couponIDStr, 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "无效的优惠券ID")
return
}
var req UpdateCouponRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error("绑定更新优惠券参数失败", "error", err)
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "参数错误: "+err.Error())
return
}
err = h.couponService.UpdateCoupon(uint(couponID), &req)
if err != nil {
logger.Error("更新优惠券失败", "error", err, "couponID", couponID)
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
logger.Info("优惠券更新成功", "couponID", couponID)
response.Success(c, "更新成功")
}
// DeleteCoupon 删除优惠券
// @Summary 删除优惠券
// @Description 删除指定优惠券
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param id path int true "优惠券ID"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons/{id} [delete]
func (h *AdminCouponHandler) DeleteCoupon(c *gin.Context) {
couponIDStr := c.Param("id")
couponID, err := strconv.ParseUint(couponIDStr, 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "无效的优惠券ID")
return
}
err = h.couponService.DeleteCoupon(uint(couponID))
if err != nil {
logger.Error("删除优惠券失败", "error", err, "couponID", couponID)
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
logger.Info("优惠券删除成功", "couponID", couponID)
response.Success(c, "删除成功")
}
// UpdateCouponStatus 更新优惠券状态
// @Summary 更新优惠券状态
// @Description 启用或禁用优惠券
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param id path int true "优惠券ID"
// @Param request body UpdateStatusRequest true "状态更新请求"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons/{id}/status [put]
func (h *AdminCouponHandler) UpdateCouponStatus(c *gin.Context) {
couponIDStr := c.Param("id")
couponID, err := strconv.ParseUint(couponIDStr, 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "无效的优惠券ID")
return
}
var req UpdateStatusRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error("绑定状态更新参数失败", "error", err)
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "参数错误: "+err.Error())
return
}
err = h.couponService.UpdateCouponStatus(uint(couponID), req.Status)
if err != nil {
logger.Error("更新优惠券状态失败", "error", err, "couponID", couponID)
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
logger.Info("优惠券状态更新成功", "couponID", couponID, "status", req.Status)
response.Success(c, "状态更新成功")
}
// BatchDeleteCoupons 批量删除优惠券
// @Summary 批量删除优惠券
// @Description 批量删除多个优惠券
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param request body BatchDeleteRequest true "批量删除请求"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons/batch [delete]
func (h *AdminCouponHandler) BatchDeleteCoupons(c *gin.Context) {
var req BatchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error("绑定批量删除参数失败", "error", err)
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "参数错误: "+err.Error())
return
}
if len(req.IDs) == 0 {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "请选择要删除的优惠券")
return
}
err := h.couponService.BatchDeleteCoupons(req.IDs)
if err != nil {
logger.Error("批量删除优惠券失败", "error", err)
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
logger.Info("批量删除优惠券成功", "count", len(req.IDs))
response.Success(c, "批量删除成功")
}
// GetCouponStatistics 获取优惠券统计
// @Summary 获取优惠券统计
// @Description 获取优惠券使用统计数据
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param start_date query string false "开始日期"
// @Param end_date query string false "结束日期"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons/statistics [get]
func (h *AdminCouponHandler) GetCouponStatistics(c *gin.Context) {
startDate := c.Query("start_date")
endDate := c.Query("end_date")
// 如果没有提供日期默认查询最近30天
if startDate == "" || endDate == "" {
now := time.Now()
endDate = now.Format("2006-01-02")
startDate = now.AddDate(0, 0, -30).Format("2006-01-02")
}
// 解析日期
startTime, err := time.Parse("2006-01-02", startDate)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "开始日期格式错误")
return
}
endTime, err := time.Parse("2006-01-02", endDate)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "结束日期格式错误")
return
}
endTime = endTime.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
stats, err := h.couponService.GetCouponStatistics(startTime, endTime)
if err != nil {
logger.Error("获取优惠券统计失败", "error", err)
response.ErrorWithMessage(c, response.ERROR, "获取优惠券统计失败")
return
}
response.Success(c, stats)
}
// GetUserCouponList 获取用户优惠券列表
// @Summary 获取用户优惠券列表
// @Description 查看指定用户的优惠券领取记录
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param user_id query int true "用户ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons/user-coupons [get]
func (h *AdminCouponHandler) GetUserCouponList(c *gin.Context) {
userIDStr := c.Query("user_id")
if userIDStr == "" {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "用户ID不能为空")
return
}
userID, err := strconv.ParseUint(userIDStr, 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "无效的用户ID")
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
coupons, total, err := h.couponService.GetUserCouponListForAdmin(uint(userID), page, pageSize)
if err != nil {
logger.Error("获取用户优惠券列表失败", "error", err)
response.ErrorWithMessage(c, response.ERROR, "获取用户优惠券列表失败")
return
}
response.Page(c, coupons, total, page, pageSize)
}
// DistributeCoupon 发放优惠券
// @Summary 发放优惠券
// @Description 给用户发放优惠券(单个/批量/全员)
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param request body DistributeCouponRequest true "发放请求"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons/distribute [post]
func (h *AdminCouponHandler) DistributeCoupon(c *gin.Context) {
var req DistributeCouponRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Error("绑定发放优惠券参数失败", "error", err)
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "参数错误: "+err.Error())
return
}
// 验证参数
if !req.DistributeAll && len(req.UserIDs) == 0 {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "请选择用户或全员发放")
return
}
// 获取管理员ID
adminID, exists := c.Get("user_id")
if !exists {
response.Error(c, response.ERROR_UNAUTHORIZED)
return
}
// 调用服务层发放优惠券
result, err := h.couponService.DistributeCoupon(req.CouponID, req.UserIDs, req.DistributeAll, req.Quantity, adminID.(uint))
if err != nil {
logger.Error("发放优惠券失败", "error", err)
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
logger.Info("优惠券发放成功", "couponID", req.CouponID, "totalCount", result["total_count"], "successCount", result["success_count"], "adminID", adminID)
response.Success(c, result)
}
// GetDistributeHistory 获取发放历史
// @Summary 获取发放历史
// @Description 获取优惠券发放记录
// @Tags 管理员-优惠券管理
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Router /admin/coupons/distribute/history [get]
func (h *AdminCouponHandler) GetDistributeHistory(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
history, total, err := h.couponService.GetDistributeHistory(page, pageSize)
if err != nil {
logger.Error("获取发放历史失败", "error", err)
response.ErrorWithMessage(c, response.ERROR, "获取发放历史失败")
return
}
response.Page(c, history, total, page, pageSize)
}
// 请求结构体
type CreateCouponRequest struct {
Name string `json:"name" binding:"required,max=100"`
Type int `json:"type" binding:"required,oneof=1 2 3"`
Value int64 `json:"value" binding:"required,min=1"`
MinAmount int64 `json:"min_amount" binding:"min=0"`
Description string `json:"description" binding:"max=500"`
StartTime time.Time `json:"start_time" binding:"required"`
EndTime time.Time `json:"end_time" binding:"required"`
TotalCount int `json:"total_count" binding:"min=0"`
Status int `json:"status" binding:"oneof=0 1"`
}
type UpdateCouponRequest struct {
Name string `json:"name" binding:"max=100"`
Type int `json:"type" binding:"oneof=1 2 3"`
Value int64 `json:"value" binding:"min=1"`
MinAmount int64 `json:"min_amount" binding:"min=0"`
Description string `json:"description" binding:"max=500"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
TotalCount int `json:"total_count" binding:"min=0"`
Status int `json:"status" binding:"oneof=0 1"`
}
type UpdateStatusRequest struct {
Status int `json:"status" binding:"required,oneof=0 1"`
}
type BatchDeleteRequest struct {
IDs []uint `json:"ids" binding:"required,min=1"`
}
type DistributeCouponRequest struct {
CouponID uint `json:"coupon_id" binding:"required"`
UserIDs []uint `json:"user_ids"`
DistributeAll bool `json:"distribute_all"`
Quantity int `json:"quantity" binding:"required,min=1,max=100"`
}

View File

@@ -109,6 +109,25 @@ func (h *AdminProductHandler) UpdateProduct(c *gin.Context) {
response.BadRequest(c, "请求参数错误")
return
}
// 处理 category_id 字段:转换为 JSONUintSlice 类型
if categoryIDRaw, ok := updates["category_id"]; ok {
switch v := categoryIDRaw.(type) {
case []interface{}:
var categoryIDs []uint
for _, item := range v {
switch id := item.(type) {
case float64:
categoryIDs = append(categoryIDs, uint(id))
case int:
categoryIDs = append(categoryIDs, uint(id))
}
}
updates["category_id"] = model.JSONUintSlice(categoryIDs)
case []uint:
updates["category_id"] = model.JSONUintSlice(v)
}
}
// 商品更新时间会自动设置
@@ -212,6 +231,7 @@ func (h *AdminProductHandler) CreateCategory(c *gin.Context) {
return
}
// 创建分类
if err := h.productService.CreateCategory(&category); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
@@ -235,6 +255,40 @@ func (h *AdminProductHandler) UpdateCategory(c *gin.Context) {
return
}
// 处理 platform 字段:转换为 JSONSlice 类型
if platformRaw, ok := updates["platform"]; ok {
switch v := platformRaw.(type) {
case []interface{}:
// 前端传来的是数组
var platforms []string
for _, item := range v {
if str, ok := item.(string); ok {
platforms = append(platforms, str)
}
}
updates["platform"] = model.JSONSlice(platforms)
case string:
// 前端传来的是单个字符串,转为数组
updates["platform"] = model.JSONSlice([]string{v})
default:
// 其他类型,删除该字段
delete(updates, "platform")
}
}
// 删除只读字段,避免更新时出错
readonlyFields := []string{"id", "created_at", "updated_at", "children", "hasChildren", "level"}
for _, field := range readonlyFields {
delete(updates, field)
}
// 删除不存在的字段
nonExistFields := []string{"is_show", "keywords"}
for _, field := range nonExistFields {
delete(updates, field)
}
// 更新分类基本信息
if err := h.productService.UpdateCategory(uint(id), updates); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return

View File

@@ -36,9 +36,16 @@ func (h *CartHandler) GetCart(c *gin.Context) {
return
}
// 计算购物车统计信息
count, _ := h.cartService.GetCartCount(userID.(uint))
total, _ := h.cartService.GetCartTotal(userID.(uint))
// 优化: 在一次查询结果基础上计算统计信息,避免重复查询
var count int
var total float64
for _, item := range cart {
count += item.Quantity
if item.Product.ID != 0 {
// 将价格从分转换为元
total += (float64(item.Product.Price) / 100) * float64(item.Quantity)
}
}
result := map[string]interface{}{
"items": cart,

View File

@@ -137,6 +137,22 @@ func (h *CommentHandler) GetCommentStats(c *gin.Context) {
response.Success(c, stats)
}
// GetHighRatingComments 获取高分评论(用于首页展示)
func (h *CommentHandler) GetHighRatingComments(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "6"))
comments, err := h.commentService.GetHighRatingComments(limit)
if err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
// 转换为响应格式
result := h.convertToResponseList(comments)
response.Success(c, result)
}
// GetCommentDetail 获取评论详情
func (h *CommentHandler) GetCommentDetail(c *gin.Context) {
idStr := c.Param("id")

View File

@@ -57,14 +57,14 @@ func (h *FrontendHandler) GetHomeData(c *gin.Context) {
}
// 获取推荐商品
recommendProducts, _, err := h.productService.GetProductList(1, 10, 0, "", 0, 0, "default", "desc")
recommendProducts, _, err := h.productService.GetProductList(1, 10, 0, "", 0, 0, nil, "default", "desc")
if err != nil {
response.ErrorWithMessage(c, response.ERROR, "获取推荐商品失败: "+err.Error())
return
}
// 获取热门商品
hotProducts, _, err := h.productService.GetProductList(1, 10, 0, "", 0, 0, "sales", "desc")
hotProducts, _, err := h.productService.GetProductList(1, 10, 0, "", 0, 0, nil, "sales", "desc")
if err != nil {
response.ErrorWithMessage(c, response.ERROR, "获取热门商品失败: "+err.Error())
return
@@ -91,7 +91,7 @@ func (h *FrontendHandler) GetProductsRecommend(c *gin.Context) {
page := utils.StringToInt(c.DefaultQuery("page", "1"))
pageSize := utils.StringToInt(c.DefaultQuery("page_size", "10"))
products, pagination, err := h.productService.GetProductList(page, pageSize, 0, "", 0, 0, "default", "desc")
products, pagination, err := h.productService.GetProductList(page, pageSize, 0, "", 0, 0, nil, "default", "desc")
if err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
@@ -106,7 +106,7 @@ func (h *FrontendHandler) GetProductsHot(c *gin.Context) {
page := utils.StringToInt(c.DefaultQuery("page", "1"))
pageSize := utils.StringToInt(c.DefaultQuery("page_size", "10"))
products, pagination, err := h.productService.GetProductList(page, pageSize, 0, "", 0, 0, "sales", "desc")
products, pagination, err := h.productService.GetProductList(page, pageSize, 0, "", 0, 0, nil, "sales", "desc")
if err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
@@ -474,7 +474,13 @@ func (h *FrontendHandler) convertProductToFrontend(product *model.Product) model
SpuStockQuantity: product.Stock,
SoldNum: product.Sales,
IsPutOnSale: 1,
CategoryIds: []string{strconv.Itoa(int(product.CategoryID))},
CategoryIds: func() []string {
ids := make([]string, len(product.CategoryID))
for i, id := range product.CategoryID {
ids[i] = strconv.Itoa(int(id))
}
return ids
}(),
SpecList: specList,
SkuList: skuList,
SpuTagList: spuTagList,

View File

@@ -0,0 +1,243 @@
package handler
import (
"dianshang/internal/model"
"dianshang/internal/service"
"dianshang/pkg/response"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type LiveStreamHandler struct {
liveStreamService service.LiveStreamService
}
func NewLiveStreamHandler(liveStreamService service.LiveStreamService) *LiveStreamHandler {
return &LiveStreamHandler{
liveStreamService: liveStreamService,
}
}
// GetLiveStreamList 获取投流源列表
func (h *LiveStreamHandler) GetLiveStreamList(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
title := c.Query("title")
platform := c.Query("platform")
var status *int
if statusStr := c.Query("status"); statusStr != "" {
statusVal, err := strconv.Atoi(statusStr)
if err == nil {
status = &statusVal
}
}
streams, total, err := h.liveStreamService.GetLiveStreamList(page, pageSize, title, platform, status)
if err != nil {
response.Error(c, response.ERROR)
return
}
response.Success(c, gin.H{
"list": streams,
"total": total,
"page": page,
"size": pageSize,
})
}
// GetLiveStreamDetail 获取投流源详情
func (h *LiveStreamHandler) GetLiveStreamDetail(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "投流源ID格式错误")
return
}
stream, err := h.liveStreamService.GetLiveStreamByID(uint(id))
if err != nil {
response.Error(c, response.ERROR)
return
}
response.Success(c, stream)
}
// GetActiveLiveStreams 获取启用的投流源(前台接口)
func (h *LiveStreamHandler) GetActiveLiveStreams(c *gin.Context) {
streams, err := h.liveStreamService.GetActiveLiveStreams()
if err != nil {
response.Error(c, response.ERROR)
return
}
response.Success(c, streams)
}
// CreateLiveStream 创建投流源
func (h *LiveStreamHandler) CreateLiveStream(c *gin.Context) {
var req struct {
Title string `json:"title" binding:"required"`
Platform string `json:"platform" binding:"required"`
StreamURL string `json:"stream_url" binding:"required"`
CoverImage string `json:"cover_image"`
Description string `json:"description"`
Status int `json:"status"`
Sort int `json:"sort"`
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, err.Error())
return
}
stream := &model.LiveStream{
Title: req.Title,
Platform: req.Platform,
StreamURL: req.StreamURL,
CoverImage: req.CoverImage,
Description: req.Description,
Status: req.Status,
Sort: req.Sort,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if err := h.liveStreamService.CreateLiveStream(stream); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, stream)
}
// UpdateLiveStream 更新投流源
func (h *LiveStreamHandler) UpdateLiveStream(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "投流源ID格式错误")
return
}
var req struct {
Title string `json:"title"`
Platform string `json:"platform"`
StreamURL string `json:"stream_url"`
CoverImage string `json:"cover_image"`
Description string `json:"description"`
Status int `json:"status"`
Sort int `json:"sort"`
StartTime *time.Time `json:"start_time"`
EndTime *time.Time `json:"end_time"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, err.Error())
return
}
stream := &model.LiveStream{
Title: req.Title,
Platform: req.Platform,
StreamURL: req.StreamURL,
CoverImage: req.CoverImage,
Description: req.Description,
Status: req.Status,
Sort: req.Sort,
StartTime: req.StartTime,
EndTime: req.EndTime,
}
if err := h.liveStreamService.UpdateLiveStream(uint(id), stream); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, stream)
}
// UpdateLiveStreamStatus 更新投流源状态
func (h *LiveStreamHandler) UpdateLiveStreamStatus(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "投流源ID格式错误")
return
}
var req struct {
Status int `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, err.Error())
return
}
if err := h.liveStreamService.UpdateLiveStreamStatus(uint(id), req.Status); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, nil)
}
// DeleteLiveStream 删除投流源
func (h *LiveStreamHandler) DeleteLiveStream(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "投流源ID格式错误")
return
}
if err := h.liveStreamService.DeleteLiveStream(uint(id)); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, nil)
}
// BatchDeleteLiveStreams 批量删除投流源
func (h *LiveStreamHandler) BatchDeleteLiveStreams(c *gin.Context) {
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, err.Error())
return
}
if err := h.liveStreamService.BatchDeleteLiveStreams(req.IDs); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, nil)
}
// IncrementViewCount 增加观看次数
func (h *LiveStreamHandler) IncrementViewCount(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "投流源ID格式错误")
return
}
if err := h.liveStreamService.IncrementViewCount(uint(id)); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, nil)
}

View File

@@ -11,19 +11,21 @@ import (
"github.com/gin-gonic/gin"
)
// OrderHandler 璁㈠崟澶勭悊鍣?
// OrderHandler 订单处理器
type OrderHandler struct {
orderService *service.OrderService
orderService *service.OrderService
wechatPayService *service.WeChatPayService
}
// NewOrderHandler 鍒涘缓璁㈠崟澶勭悊鍣?
func NewOrderHandler(orderService *service.OrderService) *OrderHandler {
// NewOrderHandler 创建订单处理器
func NewOrderHandler(orderService *service.OrderService, wechatPayService *service.WeChatPayService) *OrderHandler {
return &OrderHandler{
orderService: orderService,
orderService: orderService,
wechatPayService: wechatPayService,
}
}
// CreateOrder 鍒涘缓璁㈠崟
// CreateOrder 创建订单
func (h *OrderHandler) CreateOrder(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
@@ -50,7 +52,7 @@ func (h *OrderHandler) CreateOrder(c *gin.Context) {
response.Success(c, order)
}
// GetUserOrders 鑾峰彇鐢ㄦ埛璁㈠崟鍒楄〃
// GetUserOrders 获取用户订单列表
func (h *OrderHandler) GetUserOrders(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
@@ -422,7 +424,7 @@ func (h *OrderHandler) formatOrderDetail(order *model.Order) *OrderDetailRespons
}
}
// PayOrder 鏀粯璁㈠崟
// PayOrder 支付订单
func (h *OrderHandler) PayOrder(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
@@ -431,13 +433,66 @@ func (h *OrderHandler) PayOrder(c *gin.Context) {
}
// 从URL路径参数获取订单号
orderID := c.Param("id")
if orderID == "" {
orderNo := c.Param("id")
if orderNo == "" {
response.BadRequest(c, "订单号不能为空")
return
}
if err := h.orderService.PayOrder(userID.(uint), orderID); err != nil {
// 解析请求体获取支付方式
var req struct {
PaymentMethod string `json:"payment_method"`
}
if err := c.ShouldBindJSON(&req); err != nil {
// 如果没有提供支付方式,默认使用微信支付
req.PaymentMethod = "wechat"
}
// 获取订单详情
order, err := h.orderService.GetOrderByOrderNo(orderNo)
if err != nil {
response.ErrorWithMessage(c, response.ERROR, "订单不存在")
return
}
// 验证订单归属
if order.UserID != userID.(uint) {
response.ErrorWithMessage(c, response.ERROR, "无权限操作此订单")
return
}
// 验证订单状态
if order.Status != 1 { // 1 = 待付款
response.ErrorWithMessage(c, response.ERROR, "订单状态不允许支付")
return
}
// 如果是微信支付,返回支付二维码
if req.PaymentMethod == "wechat" {
// 调用微信Native扫码支付
if h.wechatPayService != nil {
paymentResp, err := h.wechatPayService.CreateNativeOrder(c.Request.Context(), order)
if err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, paymentResp.Data)
return
} else {
// 如果没有微信支付服务,返回模拟数据
response.Success(c, gin.H{
"qrcode_url": "https://api.example.com/qrcode/" + orderNo,
"order_no": orderNo,
"amount": order.TotalAmount,
"sandbox": true,
})
return
}
}
// 其他支付方式,直接标记为已支付
if err := h.orderService.PayOrder(userID.(uint), orderNo); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
@@ -445,7 +500,7 @@ func (h *OrderHandler) PayOrder(c *gin.Context) {
response.Success(c, nil)
}
// CancelOrder 鍙栨秷璁㈠崟
// CancelOrder 取消订单
func (h *OrderHandler) CancelOrder(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
@@ -534,7 +589,7 @@ func (h *OrderHandler) RefundOrder(c *gin.Context) {
response.Success(c, gin.H{"message": "退款申请已提交"})
}
// ConfirmReceive 纭鏀惰揣纭鏀惰揣
// ConfirmReceive 确认收货
func (h *OrderHandler) ConfirmReceive(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
@@ -558,7 +613,7 @@ func (h *OrderHandler) ConfirmReceive(c *gin.Context) {
response.Success(c, nil)
}
// GetOrderList 鑾峰彇璁㈠崟鍒楄〃锛堢鐞嗗憳锟?
// GetOrderList 获取订单列表(支持条件查询)
func (h *OrderHandler) GetOrderList(c *gin.Context) {
page := utils.StringToInt(c.DefaultQuery("page", "1"))
pageSize := utils.StringToInt(c.DefaultQuery("page_size", "20"))
@@ -589,7 +644,7 @@ func (h *OrderHandler) GetOrderList(c *gin.Context) {
response.Page(c, orders, pagination.Total, pagination.Page, pagination.PageSize)
}
// ShipOrder 鍙戣揣锛堢鐞嗗憳锟?
// ShipOrder 发货
func (h *OrderHandler) ShipOrder(c *gin.Context) {
var req struct {
OrderNo string `json:"order_no" binding:"required"`
@@ -609,7 +664,7 @@ func (h *OrderHandler) ShipOrder(c *gin.Context) {
response.Success(c, nil)
}
// GetOrderStatistics 鑾峰彇璁㈠崟缁熻
// GetOrderStatistics 获取订单统计
func (h *OrderHandler) GetOrderStatistics(c *gin.Context) {
statistics, err := h.orderService.GetOrderStatistics()
if err != nil {
@@ -620,7 +675,7 @@ func (h *OrderHandler) GetOrderStatistics(c *gin.Context) {
response.Success(c, statistics)
}
// GetDailyOrderStatistics 鑾峰彇姣忔棩璁㈠崟缁熻
// GetDailyOrderStatistics 获取每日订单统计
func (h *OrderHandler) GetDailyOrderStatistics(c *gin.Context) {
days := utils.StringToInt(c.DefaultQuery("days", "30"))

View File

@@ -0,0 +1,51 @@
package handler
import (
"github.com/gin-gonic/gin"
"dianshang/pkg/response"
)
// GetPaymentStatus 获取订单支付状态
func (h *OrderHandler) GetPaymentStatus(c *gin.Context) {
userID, exists := c.Get("user_id")
if !exists {
response.Unauthorized(c)
return
}
// 从URL路径参数获取订单号
orderNo := c.Param("id")
if orderNo == "" {
response.BadRequest(c, "订单号不能为空")
return
}
// 获取订单详情
order, err := h.orderService.GetOrderByOrderNo(orderNo)
if err != nil {
response.ErrorWithMessage(c, response.ERROR, "订单不存在")
return
}
// 验证订单归属
if order.UserID != userID.(uint) {
response.ErrorWithMessage(c, response.ERROR, "无权限操作此订单")
return
}
// 返回支付状态
status := "unpaid"
if order.Status == 2 || order.Status == 3 || order.Status == 4 || order.Status == 5 || order.Status == 6 {
status = "paid"
} else if order.Status == 7 {
status = "canceled"
} else if order.Status == 9 {
status = "refunded"
}
response.Success(c, gin.H{
"status": status,
"order_no": order.OrderNo,
"order_status": order.Status,
})
}

View File

@@ -3,6 +3,7 @@ package handler
import (
"dianshang/internal/service"
"dianshang/pkg/response"
"log"
"strconv"
"github.com/gin-gonic/gin"
@@ -169,37 +170,51 @@ func (h *PaymentHandler) CancelPayment(c *gin.Context) {
// PaymentNotify 支付回调通知
func (h *PaymentHandler) PaymentNotify(c *gin.Context) {
log.Printf("[=== 微信支付回调 ===] 收到回调请求")
log.Printf("[回调请求] 请求方法: %s", c.Request.Method)
log.Printf("[回调请求] 请求路径: %s", c.Request.URL.Path)
log.Printf("[回调请求] 客户端IP: %s", c.ClientIP())
// 读取回调数据
body, err := c.GetRawData()
if err != nil {
log.Printf("[回调错误] 读取回调数据失败: %v", err)
response.ErrorWithMessage(c, response.ERROR, "读取回调数据失败")
return
}
log.Printf("[回调数据] 数据长度: %d bytes", len(body))
// 获取请求头
headers := make(map[string]string)
for key, values := range c.Request.Header {
if len(values) > 0 {
headers[key] = values[0]
log.Printf("[回调请求头] %s: %s", key, values[0])
}
}
// 处理微信支付回调
log.Printf("[回调处理] 开始验证签名并解析数据...")
notify, err := h.wechatPayService.HandleNotify(c.Request.Context(), body, headers)
if err != nil {
log.Printf("[回调错误] 处理支付回调失败: %v", err)
response.ErrorWithMessage(c, response.ERROR, "处理支付回调失败: "+err.Error())
return
}
log.Printf("[回调数据] 事件类型: %s", notify.EventType)
// 根据回调类型处理
if notify.EventType == "TRANSACTION.SUCCESS" {
log.Printf("[支付成功] 开始处理支付成功回调...")
// 支付成功,更新订单状态
err = h.wechatPayService.ProcessPaymentSuccess(c.Request.Context(), notify)
if err != nil {
log.Printf("[回调错误] 处理支付成功回调失败: %v", err)
response.ErrorWithMessage(c, response.ERROR, "处理支付成功回调失败: "+err.Error())
return
}
log.Printf("[支付成功] 订单状态更新成功")
response.Success(c, gin.H{
"code": "SUCCESS",
"message": "处理成功",
@@ -207,6 +222,7 @@ func (h *PaymentHandler) PaymentNotify(c *gin.Context) {
return
}
log.Printf("[回调处理] 非支付成功事件,仅记录")
response.Success(c, gin.H{
"code": "SUCCESS",
"message": "回调已接收",

View File

@@ -0,0 +1,139 @@
package handler
import (
"dianshang/internal/model"
"dianshang/internal/service"
"dianshang/pkg/response"
"github.com/gin-gonic/gin"
"strconv"
)
// PlatformHandler 平台处理器
type PlatformHandler struct {
platformService *service.PlatformService
}
// NewPlatformHandler 创建平台处理器
func NewPlatformHandler(platformService *service.PlatformService) *PlatformHandler {
return &PlatformHandler{
platformService: platformService,
}
}
// GetPlatforms 获取平台列表
func (h *PlatformHandler) GetPlatforms(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
name := c.Query("name")
var status *int
if statusStr := c.Query("status"); statusStr != "" {
if s, err := strconv.Atoi(statusStr); err == nil {
status = &s
}
}
platforms, pagination, err := h.platformService.GetPlatformList(page, pageSize, status, name)
if err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
if pagination != nil {
response.Page(c, platforms, pagination.Total, pagination.Page, pagination.PageSize)
} else {
response.Success(c, platforms)
}
}
// GetPlatform 获取平台详情
func (h *PlatformHandler) GetPlatform(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "无效的平台ID")
return
}
platform, err := h.platformService.GetPlatformByID(uint(id))
if err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, platform)
}
// CreatePlatform 创建平台
func (h *PlatformHandler) CreatePlatform(c *gin.Context) {
var platform model.Platform
if err := c.ShouldBindJSON(&platform); err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, err.Error())
return
}
// 验证必填字段
if platform.Code == "" {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "平台代码不能为空")
return
}
if platform.Name == "" {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "平台名称不能为空")
return
}
if err := h.platformService.CreatePlatform(&platform); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, platform)
}
// UpdatePlatform 更新平台
func (h *PlatformHandler) UpdatePlatform(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "无效的平台ID")
return
}
var updates map[string]interface{}
if err := c.ShouldBindJSON(&updates); err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, err.Error())
return
}
if err := h.platformService.UpdatePlatform(uint(id), updates); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.SuccessWithMessage(c, "平台更新成功", nil)
}
// DeletePlatform 删除平台
func (h *PlatformHandler) DeletePlatform(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
response.ErrorWithMessage(c, response.ERROR_INVALID_PARAMS, "无效的平台ID")
return
}
if err := h.platformService.DeletePlatform(uint(id)); err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.SuccessWithMessage(c, "平台删除成功", nil)
}
// GetAllActivePlatforms 获取所有启用的平台(用于下拉选择)
func (h *PlatformHandler) GetAllActivePlatforms(c *gin.Context) {
platforms, err := h.platformService.GetAllActivePlatforms()
if err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
}
response.Success(c, platforms)
}

View File

@@ -6,6 +6,7 @@ import (
"dianshang/pkg/response"
"dianshang/pkg/utils"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
@@ -26,11 +27,35 @@ func NewProductHandler(productService *service.ProductService) *ProductHandler {
func (h *ProductHandler) GetProductList(c *gin.Context) {
page := utils.StringToInt(c.DefaultQuery("page", "1"))
pageSize := utils.StringToInt(c.DefaultQuery("page_size", "20"))
categoryID := utils.StringToUint(c.Query("category_id"))
// 支持 category_ids (逗号分隔)或 category_id
var categoryID uint
if categoryIDsStr := c.Query("category_ids"); categoryIDsStr != "" {
// 解析第一个分类ID
ids := strings.Split(categoryIDsStr, ",")
if len(ids) > 0 {
categoryID = utils.StringToUint(strings.TrimSpace(ids[0]))
}
} else {
categoryID = utils.StringToUint(c.Query("category_id"))
}
keyword := c.Query("keyword")
minPrice, _ := strconv.ParseFloat(c.Query("min_price"), 64)
maxPrice, _ := strconv.ParseFloat(c.Query("max_price"), 64)
// 库存筛选参数
var inStock *bool
if inStockStr := c.Query("in_stock"); inStockStr != "" {
if inStockStr == "true" {
trueVal := true
inStock = &trueVal
} else if inStockStr == "false" {
falseVal := false
inStock = &falseVal
}
}
// 处理排序参数:将前端传递的数字参数转换为后端期望的字符串参数
sortParam := c.Query("sort")
sortTypeParam := c.Query("sortType")
@@ -52,7 +77,7 @@ func (h *ProductHandler) GetProductList(c *gin.Context) {
sortType = "desc"
}
products, pagination, err := h.productService.GetProductList(page, pageSize, categoryID, keyword, minPrice, maxPrice, sort, sortType)
products, pagination, err := h.productService.GetProductList(page, pageSize, categoryID, keyword, minPrice, maxPrice, inStock, sort, sortType)
if err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return
@@ -137,7 +162,20 @@ func (h *ProductHandler) DeleteProduct(c *gin.Context) {
// GetCategories 鑾峰彇鍒嗙被鍒楄〃
func (h *ProductHandler) GetCategories(c *gin.Context) {
categories, err := h.productService.GetCategories()
// 支持平台筛选参数platform=web 或 platform=miniprogram
platform := c.Query("platform")
var categories []model.Category
var err error
if platform != "" {
// 根据平台获取分类
categories, err = h.productService.GetCategoriesByPlatform(platform)
} else {
// 获取所有分类
categories, err = h.productService.GetCategories()
}
if err != nil {
response.ErrorWithMessage(c, response.ERROR, err.Error())
return

Some files were not shown because too many files have changed in this diff Show More