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

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>