first commit: 初始化1818-admin项目
This commit is contained in:
2
.env.production
Normal file
2
.env.production
Normal file
@@ -0,0 +1,2 @@
|
||||
# 生产环境配置
|
||||
VITE_API_BASE_URL=/api
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
*.local
|
||||
*.log
|
||||
29
README.md
Normal file
29
README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 1818AI 管理后台
|
||||
|
||||
基于 Vue3 + Vite + Ant Design Vue + Axios 构建的企业级管理后台。
|
||||
|
||||
## 功能模块
|
||||
|
||||
- 用户管理:用户列表、状态管理、积分调整
|
||||
- 作品管理:作品列表、审核、分类管理
|
||||
- 系统配置:奖励语句、VIP套餐、积分套餐、Banner、公告
|
||||
- 系统管理:管理员、角色、权限(RBAC)
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 权限控制
|
||||
|
||||
- 路由级别:通过 `meta.permission` 控制菜单显示
|
||||
- 按钮级别:通过 `v-permission` 指令控制按钮显示
|
||||
- 角色级别:通过 `v-role` 指令控制
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>1818AI 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
4195
package-lock.json
generated
Normal file
4195
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "1818-admin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .js,.jsx,.vue --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@antv/g2": "^5.4.8",
|
||||
"ant-design-vue": "^4.1.2",
|
||||
"axios": "^1.6.8",
|
||||
"dayjs": "^1.11.10",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-vue": "^9.23.0",
|
||||
"less": "^4.2.0",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
9
src/App.vue
Normal file
9
src/App.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<a-config-provider :locale="zhCN">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
</script>
|
||||
165
src/api/ai.js
Normal file
165
src/api/ai.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ==================== AI厂商管理 ====================
|
||||
|
||||
export function getAiProviders(params = {}) {
|
||||
return request({
|
||||
url: '/admin/ai/providers',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getActiveAiProviders() {
|
||||
return request({
|
||||
url: '/admin/ai/providers/active',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function getAiProvider(id) {
|
||||
return request({
|
||||
url: `/admin/ai/providers/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createAiProvider(data) {
|
||||
return request({
|
||||
url: '/admin/ai/providers',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAiProvider(data) {
|
||||
return request({
|
||||
url: '/admin/ai/providers',
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAiProvider(id) {
|
||||
return request({
|
||||
url: `/admin/ai/providers/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAiProviderStatus(id, status) {
|
||||
return request({
|
||||
url: `/admin/ai/providers/${id}/status`,
|
||||
method: 'put',
|
||||
params: { status }
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== AI模型管理 ====================
|
||||
|
||||
export function getAiModels(params = {}) {
|
||||
return request({
|
||||
url: '/admin/ai/models',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getActiveAiModels(type) {
|
||||
return request({
|
||||
url: '/admin/ai/models/active',
|
||||
method: 'get',
|
||||
params: { type }
|
||||
})
|
||||
}
|
||||
|
||||
export function getAiModel(id) {
|
||||
return request({
|
||||
url: `/admin/ai/models/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createAiModel(data) {
|
||||
return request({
|
||||
url: '/admin/ai/models',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAiModel(data) {
|
||||
return request({
|
||||
url: '/admin/ai/models',
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAiModel(id) {
|
||||
return request({
|
||||
url: `/admin/ai/models/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAiModelStatus(id, status) {
|
||||
return request({
|
||||
url: `/admin/ai/models/${id}/status`,
|
||||
method: 'put',
|
||||
params: { status }
|
||||
})
|
||||
}
|
||||
|
||||
export function getAiModelTypes() {
|
||||
return request({
|
||||
url: '/admin/ai/models/types',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== AI任务管理 ====================
|
||||
|
||||
export function getAiTasks(params = {}) {
|
||||
return request({
|
||||
url: '/admin/ai/tasks',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getAiTask(id) {
|
||||
return request({
|
||||
url: `/admin/ai/tasks/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function processTaskQueue() {
|
||||
return request({
|
||||
url: '/admin/ai/tasks/process',
|
||||
method: 'post'
|
||||
})
|
||||
}
|
||||
|
||||
export function debugAiModel(id, params) {
|
||||
return request({
|
||||
url: `/admin/ai/models/${id}/debug`,
|
||||
method: 'post',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
|
||||
// 上传模型图片(图标/封面)
|
||||
export function uploadModelImage(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request({
|
||||
url: '/admin/ai/models/upload',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
31
src/api/auth.js
Normal file
31
src/api/auth.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 管理员登录(用户名密码)
|
||||
export function login(data) {
|
||||
return request.post('/admin/auth/login', data)
|
||||
}
|
||||
|
||||
// 发送邮箱验证码
|
||||
export function sendEmailCode(data) {
|
||||
return request.post('/admin/auth/send-code', data)
|
||||
}
|
||||
|
||||
// 管理员邮箱验证码登录
|
||||
export function loginByEmail(data) {
|
||||
return request.post('/admin/auth/login/email', data)
|
||||
}
|
||||
|
||||
// 管理员登出
|
||||
export function logout() {
|
||||
return request.post('/admin/auth/logout')
|
||||
}
|
||||
|
||||
// 获取管理员信息(含权限)
|
||||
export function getAdminInfo() {
|
||||
return request.get('/admin/auth/info')
|
||||
}
|
||||
|
||||
// 刷新Token
|
||||
export function refreshToken() {
|
||||
return request.post('/admin/auth/refresh')
|
||||
}
|
||||
114
src/api/config.js
Normal file
114
src/api/config.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ========== 积分奖励配置 ==========
|
||||
export function getRewardConfig() {
|
||||
return request.get('/admin/config/reward')
|
||||
}
|
||||
|
||||
export function updateRewardConfig(data) {
|
||||
return request.put('/admin/config/reward', data)
|
||||
}
|
||||
|
||||
// ========== VIP套餐 ==========
|
||||
export function getVipPackageList(params) {
|
||||
return request.get('/admin/config/vip-package/list', { params })
|
||||
}
|
||||
|
||||
export function createVipPackage(data) {
|
||||
return request.post('/admin/config/vip-package', data)
|
||||
}
|
||||
|
||||
export function updateVipPackage(id, data) {
|
||||
return request.put(`/admin/config/vip-package/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteVipPackage(id) {
|
||||
return request.delete(`/admin/config/vip-package/${id}`)
|
||||
}
|
||||
|
||||
// ========== 积分套餐 ==========
|
||||
export function getPointsPackageList(params) {
|
||||
return request.get('/admin/config/points-package/list', { params })
|
||||
}
|
||||
|
||||
export function createPointsPackage(data) {
|
||||
return request.post('/admin/config/points-package', data)
|
||||
}
|
||||
|
||||
export function updatePointsPackage(id, data) {
|
||||
return request.put(`/admin/config/points-package/${id}`, data)
|
||||
}
|
||||
|
||||
export function deletePointsPackage(id) {
|
||||
return request.delete(`/admin/config/points-package/${id}`)
|
||||
}
|
||||
|
||||
// ========== Banner管理 ==========
|
||||
export function getBannerList(params) {
|
||||
return request.get('/admin/config/banner/list', { params })
|
||||
}
|
||||
|
||||
export function createBanner(data) {
|
||||
return request.post('/admin/config/banner', data)
|
||||
}
|
||||
|
||||
export function updateBanner(id, data) {
|
||||
return request.put(`/admin/config/banner/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteBanner(id) {
|
||||
return request.delete(`/admin/config/banner/${id}`)
|
||||
}
|
||||
|
||||
export function uploadBannerImage(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return request.post('/admin/config/banner/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 公告管理 ==========
|
||||
export function getNoticeList(params) {
|
||||
return request.get('/admin/config/notice/list', { params })
|
||||
}
|
||||
|
||||
export function createNotice(data) {
|
||||
return request.post('/admin/config/notice', data)
|
||||
}
|
||||
|
||||
export function updateNotice(id, data) {
|
||||
return request.put(`/admin/config/notice/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteNotice(id) {
|
||||
return request.delete(`/admin/config/notice/${id}`)
|
||||
}
|
||||
|
||||
|
||||
// ========== 兑换码管理 ==========
|
||||
export function getRedeemCodeList(params) {
|
||||
return request.get('/admin/redeem-code/list', { params })
|
||||
}
|
||||
|
||||
export function getRedeemCodeDetail(id) {
|
||||
return request.get(`/admin/redeem-code/${id}`)
|
||||
}
|
||||
|
||||
export function generateRedeemCodes(data) {
|
||||
return request.post('/admin/redeem-code/generate', data)
|
||||
}
|
||||
|
||||
export function updateRedeemCode(id, data) {
|
||||
return request.put(`/admin/redeem-code/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteRedeemCode(id) {
|
||||
return request.delete(`/admin/redeem-code/${id}`)
|
||||
}
|
||||
|
||||
export function toggleRedeemCodeStatus(id, status) {
|
||||
return request.put(`/admin/redeem-code/${id}/status`, { status })
|
||||
}
|
||||
16
src/api/order.js
Normal file
16
src/api/order.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取订单列表
|
||||
export function getOrderList(params) {
|
||||
return request.get('/admin/order/list', { params })
|
||||
}
|
||||
|
||||
// 获取订单详情
|
||||
export function getOrderDetail(id, type) {
|
||||
return request.get(`/admin/order/${id}`, { params: { type } })
|
||||
}
|
||||
|
||||
// 获取最近订单
|
||||
export function getRecentOrders() {
|
||||
return request.get('/admin/dashboard/recent-orders')
|
||||
}
|
||||
52
src/api/points.js
Normal file
52
src/api/points.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取套餐列表
|
||||
export function getPointsPackages() {
|
||||
return request({
|
||||
url: '/admin/points/packages',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取套餐详情
|
||||
export function getPointsPackage(id) {
|
||||
return request({
|
||||
url: `/admin/points/packages/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 创建套餐
|
||||
export function createPointsPackage(data) {
|
||||
return request({
|
||||
url: '/admin/points/packages',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 更新套餐
|
||||
export function updatePointsPackage(data) {
|
||||
return request({
|
||||
url: '/admin/points/packages',
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 删除套餐
|
||||
export function deletePointsPackage(id) {
|
||||
return request({
|
||||
url: `/admin/points/packages/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新套餐状态
|
||||
export function updatePointsPackageStatus(id, status) {
|
||||
return request({
|
||||
url: `/admin/points/packages/${id}/status`,
|
||||
method: 'put',
|
||||
params: { status }
|
||||
})
|
||||
}
|
||||
64
src/api/system.js
Normal file
64
src/api/system.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ========== 管理员管理 ==========
|
||||
export function getAdminList(params) {
|
||||
return request.get('/admin/system/admin/list', { params })
|
||||
}
|
||||
|
||||
export function createAdmin(data) {
|
||||
return request.post('/admin/system/admin', data)
|
||||
}
|
||||
|
||||
export function updateAdmin(id, data) {
|
||||
return request.put(`/admin/system/admin/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteAdmin(id) {
|
||||
return request.delete(`/admin/system/admin/${id}`)
|
||||
}
|
||||
|
||||
// ========== 角色管理 ==========
|
||||
export function getRoleList(params) {
|
||||
return request.get('/admin/system/role/list', { params })
|
||||
}
|
||||
|
||||
export function getAllRoles() {
|
||||
return request.get('/admin/system/role/all')
|
||||
}
|
||||
|
||||
export function createRole(data) {
|
||||
return request.post('/admin/system/role', data)
|
||||
}
|
||||
|
||||
export function updateRole(id, data) {
|
||||
return request.put(`/admin/system/role/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteRole(id) {
|
||||
return request.delete(`/admin/system/role/${id}`)
|
||||
}
|
||||
|
||||
export function getRolePermissions(id) {
|
||||
return request.get(`/admin/system/role/${id}/permissions`)
|
||||
}
|
||||
|
||||
export function updateRolePermissions(id, permissionIds) {
|
||||
return request.put(`/admin/system/role/${id}/permissions`, { permissionIds })
|
||||
}
|
||||
|
||||
// ========== 权限管理 ==========
|
||||
export function getPermissionTree() {
|
||||
return request.get('/admin/system/permission/tree')
|
||||
}
|
||||
|
||||
export function createPermission(data) {
|
||||
return request.post('/admin/system/permission', data)
|
||||
}
|
||||
|
||||
export function updatePermission(id, data) {
|
||||
return request.put(`/admin/system/permission/${id}`, data)
|
||||
}
|
||||
|
||||
export function deletePermission(id) {
|
||||
return request.delete(`/admin/system/permission/${id}`)
|
||||
}
|
||||
31
src/api/upload.js
Normal file
31
src/api/upload.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function uploadImage(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return request({
|
||||
url: '/admin/upload/image',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadImages(files) {
|
||||
const formData = new FormData()
|
||||
files.forEach(file => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
return request({
|
||||
url: '/admin/upload/images',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
26
src/api/user.js
Normal file
26
src/api/user.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取用户列表
|
||||
export function getUserList(params) {
|
||||
return request.get('/admin/user/list', { params })
|
||||
}
|
||||
|
||||
// 获取用户详情
|
||||
export function getUserDetail(id) {
|
||||
return request.get(`/admin/user/${id}`)
|
||||
}
|
||||
|
||||
// 更新用户状态
|
||||
export function updateUserStatus(id, status) {
|
||||
return request.put(`/admin/user/${id}/status`, { status })
|
||||
}
|
||||
|
||||
// 更新用户VIP
|
||||
export function updateUserVip(id, data) {
|
||||
return request.put(`/admin/user/${id}/vip`, data)
|
||||
}
|
||||
|
||||
// 调整用户积分
|
||||
export function adjustUserPoints(id, data) {
|
||||
return request.post(`/admin/user/${id}/points`, data)
|
||||
}
|
||||
57
src/api/work.js
Normal file
57
src/api/work.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 获取作品列表
|
||||
export function getWorkList(params) {
|
||||
return request.get('/admin/work/list', { params })
|
||||
}
|
||||
|
||||
// 获取作品统计
|
||||
export function getWorkStats() {
|
||||
return request.get('/admin/work/stats')
|
||||
}
|
||||
|
||||
// 获取作品详情
|
||||
export function getWorkDetail(id) {
|
||||
return request.get(`/admin/work/${id}`)
|
||||
}
|
||||
|
||||
// 审核作品
|
||||
export function auditWork(id, data) {
|
||||
return request.put(`/admin/work/${id}/audit`, data)
|
||||
}
|
||||
|
||||
// 设置精选
|
||||
export function setWorkFeatured(id, data) {
|
||||
return request.put(`/admin/work/${id}/featured`, data)
|
||||
}
|
||||
|
||||
// 上架/下架作品
|
||||
export function setWorkStatus(id, data) {
|
||||
return request.put(`/admin/work/${id}/status`, data)
|
||||
}
|
||||
|
||||
// 删除作品
|
||||
export function deleteWork(id) {
|
||||
return request.delete(`/admin/work/${id}`)
|
||||
}
|
||||
|
||||
// ========== 分类管理 ==========
|
||||
export function getCategoryList(params) {
|
||||
return request.get('/admin/category/list', { params })
|
||||
}
|
||||
|
||||
export function getCategoryTree() {
|
||||
return request.get('/admin/category/tree')
|
||||
}
|
||||
|
||||
export function createCategory(data) {
|
||||
return request.post('/admin/category', data)
|
||||
}
|
||||
|
||||
export function updateCategory(id, data) {
|
||||
return request.put(`/admin/category/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteCategory(id) {
|
||||
return request.delete(`/admin/category/${id}`)
|
||||
}
|
||||
0
src/assets/icons/.gitkeep
Normal file
0
src/assets/icons/.gitkeep
Normal file
0
src/assets/images/.gitkeep
Normal file
0
src/assets/images/.gitkeep
Normal file
1
src/assets/images/loginbg.svg
Normal file
1
src/assets/images/loginbg.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 15 KiB |
10
src/assets/logo.svg
Normal file
10
src/assets/logo.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#1890ff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#722ed1;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100" height="100" rx="20" fill="url(#grad)"/>
|
||||
<text x="50" y="65" font-family="Arial, sans-serif" font-size="40" font-weight="bold" fill="white" text-anchor="middle">AI</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 510 B |
225
src/components/JsonPathPicker/JsonTreeNode.vue
Normal file
225
src/components/JsonPathPicker/JsonTreeNode.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="tree-node">
|
||||
<template v-if="isObject">
|
||||
<div class="node-bracket">{</div>
|
||||
<div class="node-children">
|
||||
<div v-for="(value, key) in data" :key="key" class="node-item">
|
||||
<span class="node-key">"{{ key }}"</span>
|
||||
<span class="node-colon">:</span>
|
||||
<JsonTreeNode
|
||||
:data="value"
|
||||
:path="buildPath(key)"
|
||||
:fields="fields"
|
||||
:selected-paths="selectedPaths"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
<span v-if="!isLastKey(key)" class="node-comma">,</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-bracket">}</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isArray">
|
||||
<div class="node-bracket">[</div>
|
||||
<div class="node-children">
|
||||
<div v-for="(item, index) in data" :key="index" class="node-item">
|
||||
<span class="array-index">[{{ index }}]</span>
|
||||
<JsonTreeNode
|
||||
:data="item"
|
||||
:path="buildArrayPath(index)"
|
||||
:fields="fields"
|
||||
:selected-paths="selectedPaths"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
<span v-if="index < data.length - 1" class="node-comma">,</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="node-bracket">]</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span
|
||||
class="node-value"
|
||||
:class="[valueType, { selected: isSelected, clickable: true }]"
|
||||
@click="handleClick"
|
||||
:title="'点击选择: ' + path"
|
||||
>
|
||||
<template v-if="isString">"{{ data }}"</template>
|
||||
<template v-else-if="isNull">null</template>
|
||||
<template v-else>{{ data }}</template>
|
||||
<span v-if="isSelected" class="selected-badge">{{ selectedFieldLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: [Object, Array, String, Number, Boolean, null],
|
||||
default: null
|
||||
},
|
||||
path: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
selectedPaths: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const isObject = computed(() => {
|
||||
return props.data !== null && typeof props.data === 'object' && !Array.isArray(props.data)
|
||||
})
|
||||
|
||||
const isArray = computed(() => {
|
||||
return Array.isArray(props.data)
|
||||
})
|
||||
|
||||
const isString = computed(() => {
|
||||
return typeof props.data === 'string'
|
||||
})
|
||||
|
||||
const isNull = computed(() => {
|
||||
return props.data === null
|
||||
})
|
||||
|
||||
const valueType = computed(() => {
|
||||
if (props.data === null) return 'null'
|
||||
if (typeof props.data === 'string') return 'string'
|
||||
if (typeof props.data === 'number') return 'number'
|
||||
if (typeof props.data === 'boolean') return 'boolean'
|
||||
return 'unknown'
|
||||
})
|
||||
|
||||
const isSelected = computed(() => {
|
||||
return Object.values(props.selectedPaths).includes(props.path)
|
||||
})
|
||||
|
||||
const selectedFieldLabel = computed(() => {
|
||||
for (const [key, value] of Object.entries(props.selectedPaths)) {
|
||||
if (value === props.path) {
|
||||
const field = props.fields.find(f => f.key === key)
|
||||
return field ? field.label.split(' ')[0] : key
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const buildPath = (key) => {
|
||||
return props.path ? `${props.path}.${key}` : key
|
||||
}
|
||||
|
||||
const buildArrayPath = (index) => {
|
||||
return `${props.path}[${index}]`
|
||||
}
|
||||
|
||||
const isLastKey = (key) => {
|
||||
const keys = Object.keys(props.data)
|
||||
return keys[keys.length - 1] === key
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.path) {
|
||||
emit('select', { path: props.path, value: props.data })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-node {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.node-bracket {
|
||||
color: #8c8c8c;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.node-children {
|
||||
padding-left: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.node-item {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.node-key {
|
||||
color: #9254de;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.node-colon {
|
||||
color: #8c8c8c;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.node-comma {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.array-index {
|
||||
color: #8c8c8c;
|
||||
font-size: 11px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.node-value {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.node-value.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-value.clickable:hover {
|
||||
background: #e6f7ff;
|
||||
outline: 1px solid #1890ff;
|
||||
}
|
||||
|
||||
.node-value.string {
|
||||
color: #389e0d;
|
||||
}
|
||||
|
||||
.node-value.number {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.node-value.boolean {
|
||||
color: #eb2f96;
|
||||
}
|
||||
|
||||
.node-value.null {
|
||||
color: #8c8c8c;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.node-value.selected {
|
||||
background: #bae7ff;
|
||||
outline: 2px solid #1890ff;
|
||||
}
|
||||
|
||||
.selected-badge {
|
||||
font-size: 10px;
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
545
src/components/JsonPathPicker/index.vue
Normal file
545
src/components/JsonPathPicker/index.vue
Normal file
@@ -0,0 +1,545 @@
|
||||
<template>
|
||||
<div class="json-path-picker">
|
||||
<!-- JSON输入区域 -->
|
||||
<div class="json-input-section">
|
||||
<div class="section-label">{{ label }}</div>
|
||||
<a-textarea
|
||||
v-model:value="jsonInput"
|
||||
:placeholder="placeholder"
|
||||
:rows="4"
|
||||
@change="parseJson"
|
||||
/>
|
||||
<div class="input-actions">
|
||||
<a-button size="small" @click="parseJson">解析JSON</a-button>
|
||||
<a-button size="small" @click="formatJson">格式化</a-button>
|
||||
<a-button size="small" danger @click="clearJson">清空</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 解析错误提示 -->
|
||||
<a-alert v-if="parseError" type="error" :message="parseError" show-icon class="parse-error" />
|
||||
|
||||
<!-- JSON对象树 -->
|
||||
<div v-if="parsedJson && !parseError" class="json-tree-section">
|
||||
<div class="section-label">
|
||||
点击节点选择字段映射
|
||||
<a-tag color="blue" class="tip-tag">点击叶子节点进行标记</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="json-tree">
|
||||
<JsonTreeNode
|
||||
:data="parsedJson"
|
||||
:path="''"
|
||||
:fields="fields"
|
||||
:selected-paths="selectedPaths"
|
||||
@select="handleNodeSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 已选择的映射 -->
|
||||
<div class="selected-mappings" v-if="Object.keys(selectedPaths).length > 0">
|
||||
<div class="section-label">已配置的字段映射</div>
|
||||
<div class="mapping-list">
|
||||
<div v-for="(path, key) in selectedPaths" :key="key" class="mapping-item">
|
||||
<span class="mapping-key">{{ getFieldLabel(key) }}</span>
|
||||
<span class="mapping-arrow">→</span>
|
||||
<code class="mapping-path">{{ path }}</code>
|
||||
<a-button type="link" size="small" danger @click="removeMapping(key)">删除</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态值配置(仅当选择了status字段时显示) -->
|
||||
<div v-if="selectedPaths.status && showStatusConfig" class="status-config-section">
|
||||
<div class="section-label">
|
||||
配置状态值映射
|
||||
<a-tooltip title="配置API返回的状态值对应的业务状态">
|
||||
<QuestionCircleOutlined />
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="status-value-hint" v-if="statusFieldValue">
|
||||
<InfoCircleOutlined />
|
||||
当前响应中的状态值: <code>{{ statusFieldValue }}</code>
|
||||
</div>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="6">
|
||||
<div class="status-group">
|
||||
<div class="status-label">
|
||||
<span class="status-dot queued"></span>
|
||||
排队中 (queued)
|
||||
</div>
|
||||
<a-select
|
||||
v-model:value="statusValues.queued"
|
||||
mode="tags"
|
||||
placeholder="输入状态值,回车添加"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<div class="status-group">
|
||||
<div class="status-label">
|
||||
<span class="status-dot processing"></span>
|
||||
处理中 (processing)
|
||||
</div>
|
||||
<a-select
|
||||
v-model:value="statusValues.processing"
|
||||
mode="tags"
|
||||
placeholder="输入状态值,回车添加"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<div class="status-group">
|
||||
<div class="status-label">
|
||||
<span class="status-dot success"></span>
|
||||
成功 (success)
|
||||
</div>
|
||||
<a-select
|
||||
v-model:value="statusValues.success"
|
||||
mode="tags"
|
||||
placeholder="输入状态值,回车添加"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<div class="status-group">
|
||||
<div class="status-label">
|
||||
<span class="status-dot failed"></span>
|
||||
失败 (failed)
|
||||
</div>
|
||||
<a-select
|
||||
v-model:value="statusValues.failed"
|
||||
mode="tags"
|
||||
placeholder="输入状态值,回车添加"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<div class="quick-add-status" v-if="statusFieldValue">
|
||||
<span>快速添加当前值到:</span>
|
||||
<a-button size="small" @click="addStatusValue('queued')">排队中</a-button>
|
||||
<a-button size="small" @click="addStatusValue('processing')">处理中</a-button>
|
||||
<a-button size="small" type="primary" @click="addStatusValue('success')">成功</a-button>
|
||||
<a-button size="small" danger @click="addStatusValue('failed')">失败</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 字段选择弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="selectModalVisible"
|
||||
title="选择字段用途"
|
||||
@ok="confirmFieldSelect"
|
||||
@cancel="cancelFieldSelect"
|
||||
width="400px"
|
||||
>
|
||||
<div class="field-select-modal">
|
||||
<p>选择的路径: <code>{{ pendingPath }}</code></p>
|
||||
<p>当前值: <code>{{ pendingValue }}</code></p>
|
||||
<a-divider />
|
||||
<div class="field-options">
|
||||
<a-radio-group v-model:value="pendingField">
|
||||
<a-radio v-for="field in fields" :key="field.key" :value="field.key" class="field-radio">
|
||||
{{ field.label }}
|
||||
</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { QuestionCircleOutlined, InfoCircleOutlined } from '@ant-design/icons-vue'
|
||||
import JsonTreeNode from './JsonTreeNode.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'JSON响应'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '粘贴API响应的JSON数据...'
|
||||
},
|
||||
fields: {
|
||||
type: Array,
|
||||
default: () => [
|
||||
{ key: 'status', label: '状态字段 (status)' },
|
||||
{ key: 'task_id', label: '任务ID (task_id)' },
|
||||
{ key: 'result', label: '结果 (result)' },
|
||||
{ key: 'progress', label: '进度 (progress)' },
|
||||
{ key: 'error', label: '错误信息 (error)' }
|
||||
]
|
||||
},
|
||||
showStatusConfig: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
statusMapping: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'statusMappingChange'])
|
||||
|
||||
const jsonInput = ref('')
|
||||
const parsedJson = ref(null)
|
||||
const parseError = ref('')
|
||||
const selectedPaths = ref({})
|
||||
const selectModalVisible = ref(false)
|
||||
const pendingPath = ref('')
|
||||
const pendingValue = ref('')
|
||||
const pendingField = ref('')
|
||||
|
||||
// 状态值配置
|
||||
const statusValues = reactive({
|
||||
queued: ['start', 'queued', 'waiting', 'queue'],
|
||||
processing: ['processing', 'running', 'pending', 'in_progress'],
|
||||
success: ['success', 'completed', 'done', 'finished', 'complete'],
|
||||
failed: ['failed', 'error', 'failure', 'timeout', 'cancelled']
|
||||
})
|
||||
|
||||
// 计算当前status字段的值
|
||||
const statusFieldValue = computed(() => {
|
||||
if (!parsedJson.value || !selectedPaths.value.status) return null
|
||||
return getValueByPath(parsedJson.value, selectedPaths.value.status)
|
||||
})
|
||||
|
||||
// 初始化
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val && Object.keys(val).length > 0) {
|
||||
selectedPaths.value = { ...val }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(() => props.statusMapping, (val) => {
|
||||
if (val && Object.keys(val).length > 0) {
|
||||
if (val.queued) statusValues.queued = Array.isArray(val.queued) ? val.queued : val.queued.split(',')
|
||||
if (val.processing) statusValues.processing = Array.isArray(val.processing) ? val.processing : val.processing.split(',')
|
||||
if (val.success) statusValues.success = Array.isArray(val.success) ? val.success : val.success.split(',')
|
||||
if (val.failed) statusValues.failed = Array.isArray(val.failed) ? val.failed : val.failed.split(',')
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听状态值变化
|
||||
watch(statusValues, () => {
|
||||
emit('statusMappingChange', {
|
||||
queued: statusValues.queued.join(','),
|
||||
processing: statusValues.processing.join(','),
|
||||
success: statusValues.success.join(','),
|
||||
failed: statusValues.failed.join(',')
|
||||
})
|
||||
}, { deep: true })
|
||||
|
||||
const parseJson = () => {
|
||||
if (!jsonInput.value.trim()) {
|
||||
parsedJson.value = null
|
||||
parseError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
parsedJson.value = JSON.parse(jsonInput.value)
|
||||
parseError.value = ''
|
||||
} catch (e) {
|
||||
parseError.value = 'JSON解析失败: ' + e.message
|
||||
parsedJson.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const formatJson = () => {
|
||||
if (!jsonInput.value.trim()) return
|
||||
try {
|
||||
const obj = JSON.parse(jsonInput.value)
|
||||
jsonInput.value = JSON.stringify(obj, null, 2)
|
||||
parseError.value = ''
|
||||
} catch (e) {
|
||||
parseError.value = 'JSON格式化失败: ' + e.message
|
||||
}
|
||||
}
|
||||
|
||||
const clearJson = () => {
|
||||
jsonInput.value = ''
|
||||
parsedJson.value = null
|
||||
parseError.value = ''
|
||||
}
|
||||
|
||||
const handleNodeSelect = ({ path, value }) => {
|
||||
pendingPath.value = path
|
||||
pendingValue.value = typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
pendingField.value = ''
|
||||
selectModalVisible.value = true
|
||||
}
|
||||
|
||||
const confirmFieldSelect = () => {
|
||||
if (pendingField.value && pendingPath.value) {
|
||||
selectedPaths.value[pendingField.value] = pendingPath.value
|
||||
emitChange()
|
||||
}
|
||||
selectModalVisible.value = false
|
||||
}
|
||||
|
||||
const cancelFieldSelect = () => {
|
||||
selectModalVisible.value = false
|
||||
pendingPath.value = ''
|
||||
pendingValue.value = ''
|
||||
pendingField.value = ''
|
||||
}
|
||||
|
||||
const removeMapping = (key) => {
|
||||
delete selectedPaths.value[key]
|
||||
emitChange()
|
||||
}
|
||||
|
||||
const emitChange = () => {
|
||||
emit('update:modelValue', { ...selectedPaths.value })
|
||||
emit('change', { ...selectedPaths.value })
|
||||
}
|
||||
|
||||
const getFieldLabel = (key) => {
|
||||
const field = props.fields.find(f => f.key === key)
|
||||
return field ? field.label : key
|
||||
}
|
||||
|
||||
const getValueByPath = (obj, path) => {
|
||||
if (!path) return null
|
||||
const parts = path.split('.')
|
||||
let current = obj
|
||||
|
||||
for (const part of parts) {
|
||||
if (current === null || current === undefined) return null
|
||||
|
||||
// 处理数组索引
|
||||
if (part.includes('[')) {
|
||||
const match = part.match(/^(\w+)\[(\d+)\]$/)
|
||||
if (match) {
|
||||
current = current[match[1]]
|
||||
if (Array.isArray(current)) {
|
||||
current = current[parseInt(match[2])]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
current = current[part]
|
||||
}
|
||||
}
|
||||
|
||||
return current
|
||||
}
|
||||
|
||||
const addStatusValue = (type) => {
|
||||
if (statusFieldValue.value && !statusValues[type].includes(statusFieldValue.value)) {
|
||||
statusValues[type].push(statusFieldValue.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.json-path-picker {
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #595959;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tip-tag {
|
||||
font-weight: normal;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.json-input-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.parse-error {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.json-tree-section {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.json-tree {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
padding: 8px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.selected-mappings {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
}
|
||||
|
||||
.mapping-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mapping-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #e6f7ff;
|
||||
border: 1px solid #91d5ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mapping-key {
|
||||
font-weight: 500;
|
||||
color: #1890ff;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.mapping-path {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
background: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.status-config-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed #e8e8e8;
|
||||
}
|
||||
|
||||
.status-value-hint {
|
||||
margin-bottom: 12px;
|
||||
padding: 8px 12px;
|
||||
background: #fffbe6;
|
||||
border: 1px solid #ffe58f;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: #ad8b00;
|
||||
}
|
||||
|
||||
.status-value-hint code {
|
||||
background: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #595959;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.queued {
|
||||
background: #faad14;
|
||||
}
|
||||
|
||||
.status-dot.processing {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.status-dot.success {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.status-dot.failed {
|
||||
background: #ff4d4f;
|
||||
}
|
||||
|
||||
.quick-add-status {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: #f0f5ff;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-select-modal p {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.field-select-modal code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.field-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field-radio {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 4px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field-radio:hover {
|
||||
border-color: #1890ff;
|
||||
background: #e6f7ff;
|
||||
}
|
||||
</style>
|
||||
32
src/directives/permission.js
Normal file
32
src/directives/permission.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { hasPermission, hasRole } from '@/utils/permission'
|
||||
|
||||
/**
|
||||
* 权限指令 v-permission="'user:add'" 或 v-permission="['user:add', 'user:edit']"
|
||||
* 如果用户没有指定权限,则移除元素
|
||||
* 超级管理员拥有所有权限
|
||||
*/
|
||||
const permissionDirective = {
|
||||
mounted(el, binding) {
|
||||
const { value } = binding
|
||||
if (value && !hasPermission(value)) {
|
||||
el.parentNode?.removeChild(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色指令 v-role="'admin'" 或 v-role="['admin', 'editor']"
|
||||
*/
|
||||
const roleDirective = {
|
||||
mounted(el, binding) {
|
||||
const { value } = binding
|
||||
if (value && !hasRole(value)) {
|
||||
el.parentNode?.removeChild(el)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setupPermission(app) {
|
||||
app.directive('permission', permissionDirective)
|
||||
app.directive('role', roleDirective)
|
||||
}
|
||||
262
src/layouts/BasicLayout.vue
Normal file
262
src/layouts/BasicLayout.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<a-layout class="basic-layout">
|
||||
<a-layout-sider
|
||||
v-model:collapsed="collapsed"
|
||||
:trigger="null"
|
||||
collapsible
|
||||
theme="light"
|
||||
:width="220"
|
||||
:collapsed-width="64"
|
||||
class="sider"
|
||||
>
|
||||
<div class="logo">
|
||||
<div class="logo-icon">
|
||||
<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%231890ff'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z'/%3E%3C/svg%3E" alt="logo" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<span v-if="!collapsed" class="logo-text">1818AI</span>
|
||||
</transition>
|
||||
</div>
|
||||
<SideMenu :collapsed="collapsed" />
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout class="main-layout">
|
||||
<a-layout-header class="header">
|
||||
<div class="header-left">
|
||||
<div class="trigger-wrapper" @click="toggleCollapsed">
|
||||
<MenuUnfoldOutlined v-if="collapsed" class="trigger" />
|
||||
<MenuFoldOutlined v-else class="trigger" />
|
||||
</div>
|
||||
<a-breadcrumb class="breadcrumb">
|
||||
<a-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
|
||||
{{ item.title }}
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-dropdown placement="bottomRight">
|
||||
<div class="user-info">
|
||||
<a-avatar :size="32" class="user-avatar">
|
||||
{{ userInfo?.realName?.[0] || userInfo?.username?.[0] || 'A' }}
|
||||
</a-avatar>
|
||||
<span class="username">{{ userInfo?.realName || userInfo?.username || '管理员' }}</span>
|
||||
<DownOutlined class="dropdown-icon" />
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu>
|
||||
<a-menu-item key="logout" @click="handleLogout">
|
||||
<LogoutOutlined />
|
||||
<span style="margin-left: 8px">退出登录</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout-content class="content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
LogoutOutlined,
|
||||
DownOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { Modal } from 'ant-design-vue'
|
||||
import { useAppStore } from '@/store/modules/app'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import SideMenu from './components/SideMenu.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const collapsed = computed(() => appStore.collapsed)
|
||||
const userInfo = computed(() => userStore.userInfo)
|
||||
const breadcrumbs = computed(() => appStore.breadcrumbs)
|
||||
|
||||
const toggleCollapsed = () => appStore.toggleCollapsed()
|
||||
|
||||
watch(
|
||||
() => route.matched,
|
||||
matched => {
|
||||
const crumbs = matched
|
||||
.filter(item => item.meta?.title)
|
||||
.map(item => ({ path: item.path, title: item.meta.title }))
|
||||
appStore.setBreadcrumbs(crumbs)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const handleLogout = () => {
|
||||
Modal.confirm({
|
||||
title: '确认退出',
|
||||
content: '确定要退出登录吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.basic-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sider {
|
||||
background: #fff;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
|
||||
:deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-menu) {
|
||||
border-right: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
gap: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.header {
|
||||
background: #fff;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
line-height: 56px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.trigger-wrapper {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.trigger {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
:deep(.ant-breadcrumb-link) {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
:deep(li:last-child .ant-breadcrumb-link) {
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
background: #1890ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
min-height: calc(100vh - 56px);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
143
src/layouts/components/SideMenu.vue
Normal file
143
src/layouts/components/SideMenu.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<template>
|
||||
<a-menu
|
||||
v-model:selectedKeys="selectedKeys"
|
||||
v-model:openKeys="openKeys"
|
||||
mode="inline"
|
||||
theme="light"
|
||||
:inline-collapsed="collapsed"
|
||||
>
|
||||
<template v-for="route in menuRoutes" :key="route.path">
|
||||
<template v-if="!route.meta?.hidden">
|
||||
<a-sub-menu v-if="hasVisibleChildren(route)" :key="route.path">
|
||||
<template #icon>
|
||||
<component :is="getIcon(route.meta?.icon)" />
|
||||
</template>
|
||||
<template #title>{{ route.meta?.title }}</template>
|
||||
<a-menu-item
|
||||
v-for="child in getVisibleChildren(route)"
|
||||
:key="getFullPath(route.path, child.path)"
|
||||
@click="handleMenuClick(route.path, child.path)"
|
||||
>
|
||||
{{ child.meta?.title }}
|
||||
</a-menu-item>
|
||||
</a-sub-menu>
|
||||
<a-menu-item
|
||||
v-else
|
||||
:key="route.redirect || getFullPath(route.path, route.children?.[0]?.path)"
|
||||
@click="handleMenuClick(route.path, route.children?.[0]?.path)"
|
||||
>
|
||||
<template #icon>
|
||||
<component :is="getIcon(route.meta?.icon || route.children?.[0]?.meta?.icon)" />
|
||||
</template>
|
||||
<span>{{ route.children?.[0]?.meta?.title || route.meta?.title }}</span>
|
||||
</a-menu-item>
|
||||
</template>
|
||||
</template>
|
||||
</a-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
PictureOutlined,
|
||||
SettingOutlined,
|
||||
ToolOutlined,
|
||||
TeamOutlined,
|
||||
SafetyOutlined,
|
||||
KeyOutlined,
|
||||
GiftOutlined,
|
||||
CrownOutlined,
|
||||
StarOutlined,
|
||||
NotificationOutlined,
|
||||
AppstoreOutlined,
|
||||
FileImageOutlined,
|
||||
AuditOutlined,
|
||||
UnorderedListOutlined,
|
||||
ShoppingOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { constantRoutes, asyncRoutes } from '@/router'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
|
||||
defineProps({
|
||||
collapsed: Boolean
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const selectedKeys = ref([])
|
||||
const openKeys = ref([])
|
||||
|
||||
const iconMap = {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
PictureOutlined,
|
||||
SettingOutlined,
|
||||
ToolOutlined,
|
||||
TeamOutlined,
|
||||
SafetyOutlined,
|
||||
KeyOutlined,
|
||||
GiftOutlined,
|
||||
CrownOutlined,
|
||||
StarOutlined,
|
||||
NotificationOutlined,
|
||||
AppstoreOutlined,
|
||||
FileImageOutlined,
|
||||
AuditOutlined,
|
||||
UnorderedListOutlined,
|
||||
ShoppingOutlined
|
||||
}
|
||||
|
||||
const getIcon = (iconName) => {
|
||||
if (!iconName) return null
|
||||
return iconMap[iconName] || DashboardOutlined
|
||||
}
|
||||
|
||||
const menuRoutes = computed(() => {
|
||||
// 合并静态路由和动态路由,过滤掉隐藏的路由
|
||||
const allRoutes = [...constantRoutes, ...asyncRoutes]
|
||||
return allRoutes.filter(route => {
|
||||
if (route.meta?.hidden) return false
|
||||
if (route.path === '/login' || route.path === '/403' || route.path === '/404') return false
|
||||
if (route.path.includes(':pathMatch')) return false
|
||||
if (route.meta?.permission && !hasPermission(route.meta.permission)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const hasVisibleChildren = (route) => {
|
||||
if (!route.children) return false
|
||||
const visibleChildren = route.children.filter(child => !child.meta?.hidden)
|
||||
return visibleChildren.length > 1
|
||||
}
|
||||
|
||||
const getVisibleChildren = (route) => {
|
||||
if (!route.children) return []
|
||||
return route.children.filter(child => !child.meta?.hidden)
|
||||
}
|
||||
|
||||
const getFullPath = (parentPath, childPath) => {
|
||||
if (!childPath) return parentPath
|
||||
return `${parentPath}/${childPath}`.replace(/\/+/g, '/')
|
||||
}
|
||||
|
||||
const handleMenuClick = (parentPath, childPath) => {
|
||||
const path = getFullPath(parentPath, childPath)
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
path => {
|
||||
selectedKeys.value = [path]
|
||||
const parentPath = '/' + path.split('/')[1]
|
||||
if (!openKeys.value.includes(parentPath)) {
|
||||
openKeys.value = [...openKeys.value, parentPath]
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
18
src/main.js
Normal file
18
src/main.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import './styles/index.less'
|
||||
import { setupPermission } from './directives/permission'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
setupPermission(app)
|
||||
|
||||
app.mount('#app')
|
||||
282
src/router/index.js
Normal file
282
src/router/index.js
Normal file
@@ -0,0 +1,282 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import NProgress from 'nprogress'
|
||||
import 'nprogress/nprogress.css'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
NProgress.configure({ showSpinner: false })
|
||||
|
||||
// 静态路由
|
||||
export const constantRoutes = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/BasicLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
meta: { title: '首页', icon: 'DashboardOutlined' },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: { title: '控制台', icon: 'DashboardOutlined' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { title: '登录', hidden: true }
|
||||
},
|
||||
{
|
||||
path: '/403',
|
||||
name: '403',
|
||||
component: () => import('@/views/error/403.vue'),
|
||||
meta: { title: '无权限', hidden: true }
|
||||
},
|
||||
{
|
||||
path: '/404',
|
||||
name: '404',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
meta: { title: '页面不存在', hidden: true }
|
||||
}
|
||||
]
|
||||
|
||||
// 动态路由(根据权限加载)
|
||||
export const asyncRoutes = [
|
||||
{
|
||||
path: '/user',
|
||||
component: () => import('@/layouts/BasicLayout.vue'),
|
||||
redirect: '/user/list',
|
||||
meta: { title: '用户管理', icon: 'TeamOutlined' },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'UserList',
|
||||
component: () => import('@/views/user/list.vue'),
|
||||
meta: { title: '用户列表', icon: 'UnorderedListOutlined' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/order',
|
||||
component: () => import('@/layouts/BasicLayout.vue'),
|
||||
redirect: '/order/list',
|
||||
meta: { title: '订单管理', icon: 'ShoppingOutlined' },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'OrderList',
|
||||
component: () => import('@/views/order/list.vue'),
|
||||
meta: { title: '订单列表', icon: 'UnorderedListOutlined' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/work',
|
||||
component: () => import('@/layouts/BasicLayout.vue'),
|
||||
redirect: '/work/list',
|
||||
meta: { title: '作品管理', icon: 'PictureOutlined' },
|
||||
children: [
|
||||
{
|
||||
path: 'list',
|
||||
name: 'WorkList',
|
||||
component: () => import('@/views/work/list.vue'),
|
||||
meta: { title: '作品列表', icon: 'FileImageOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'audit',
|
||||
name: 'WorkAudit',
|
||||
component: () => import('@/views/work/audit.vue'),
|
||||
meta: { title: '作品审核', icon: 'AuditOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'category',
|
||||
name: 'WorkCategory',
|
||||
component: () => import('@/views/work/category.vue'),
|
||||
meta: { title: '分类管理', icon: 'AppstoreOutlined' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/ai',
|
||||
component: () => import('@/layouts/BasicLayout.vue'),
|
||||
redirect: '/ai/provider',
|
||||
meta: { title: 'AI管理', icon: 'RobotOutlined' },
|
||||
children: [
|
||||
{
|
||||
path: 'provider',
|
||||
name: 'AiProvider',
|
||||
component: () => import('@/views/ai/provider.vue'),
|
||||
meta: { title: 'AI厂商', icon: 'CloudServerOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'model',
|
||||
name: 'AiModel',
|
||||
component: () => import('@/views/ai/model.vue'),
|
||||
meta: { title: 'AI模型', icon: 'ApiOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'task',
|
||||
name: 'AiTask',
|
||||
component: () => import('@/views/ai/task.vue'),
|
||||
meta: { title: 'AI任务', icon: 'ScheduleOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'debug',
|
||||
name: 'AiDebug',
|
||||
component: () => import('@/views/ai/debug.vue'),
|
||||
meta: { title: 'AI调试', icon: 'BugOutlined' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/config',
|
||||
component: () => import('@/layouts/BasicLayout.vue'),
|
||||
redirect: '/config/reward',
|
||||
meta: { title: '系统配置', icon: 'SettingOutlined' },
|
||||
children: [
|
||||
{
|
||||
path: 'reward',
|
||||
name: 'RewardConfig',
|
||||
component: () => import('@/views/config/reward.vue'),
|
||||
meta: { title: '奖励配置', icon: 'GiftOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'redeem',
|
||||
name: 'RedeemConfig',
|
||||
component: () => import('@/views/config/redeem.vue'),
|
||||
meta: { title: '兑换码管理', icon: 'KeyOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'vip',
|
||||
name: 'VipConfig',
|
||||
component: () => import('@/views/config/vip.vue'),
|
||||
meta: { title: 'VIP套餐', icon: 'CrownOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'points',
|
||||
name: 'PointsConfig',
|
||||
component: () => import('@/views/config/points.vue'),
|
||||
meta: { title: '积分套餐', icon: 'StarOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'banner',
|
||||
name: 'BannerConfig',
|
||||
component: () => import('@/views/config/banner.vue'),
|
||||
meta: { title: 'Banner管理', icon: 'FileImageOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'notice',
|
||||
name: 'NoticeConfig',
|
||||
component: () => import('@/views/config/notice.vue'),
|
||||
meta: { title: '公告管理', icon: 'NotificationOutlined' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/system',
|
||||
component: () => import('@/layouts/BasicLayout.vue'),
|
||||
redirect: '/system/admin',
|
||||
meta: { title: '系统管理', icon: 'ToolOutlined' },
|
||||
children: [
|
||||
{
|
||||
path: 'admin',
|
||||
name: 'AdminList',
|
||||
component: () => import('@/views/system/admin.vue'),
|
||||
meta: { title: '管理员管理', icon: 'UserOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'role',
|
||||
name: 'RoleList',
|
||||
component: () => import('@/views/system/role.vue'),
|
||||
meta: { title: '角色管理', icon: 'SafetyOutlined' }
|
||||
},
|
||||
{
|
||||
path: 'permission',
|
||||
name: 'PermissionList',
|
||||
component: () => import('@/views/system/permission.vue'),
|
||||
meta: { title: '权限管理', icon: 'KeyOutlined' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', name: 'NotFound', redirect: '/404', meta: { hidden: true } }
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: constantRoutes
|
||||
})
|
||||
|
||||
// 白名单
|
||||
const whiteList = ['/login', '/403', '/404']
|
||||
|
||||
// 标记是否已添加动态路由
|
||||
let dynamicRoutesAdded = false
|
||||
|
||||
// 添加动态路由
|
||||
function addDynamicRoutes() {
|
||||
if (dynamicRoutesAdded) return
|
||||
asyncRoutes.forEach(route => router.addRoute(route))
|
||||
dynamicRoutesAdded = true
|
||||
}
|
||||
|
||||
// 重置路由(登出时调用)
|
||||
export function resetRouter() {
|
||||
dynamicRoutesAdded = false
|
||||
// 移除动态添加的路由
|
||||
asyncRoutes.forEach(route => {
|
||||
if (route.name && router.hasRoute(route.name)) {
|
||||
router.removeRoute(route.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
NProgress.start()
|
||||
const token = getToken()
|
||||
|
||||
if (token) {
|
||||
if (to.path === '/login') {
|
||||
next({ path: '/' })
|
||||
} else {
|
||||
// 确保动态路由已添加
|
||||
addDynamicRoutes()
|
||||
|
||||
const userStore = useUserStore()
|
||||
// 确保用户信息已获取(包含权限和角色)
|
||||
if (!userStore.userInfo || !userStore.roles.length) {
|
||||
try {
|
||||
await userStore.getUserInfo()
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败', error)
|
||||
// Token可能已失效,跳转登录
|
||||
userStore.resetState()
|
||||
next(`/login?redirect=${to.path}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 检查路由是否存在
|
||||
if (router.hasRoute(to.name) || to.matched.length > 0) {
|
||||
next()
|
||||
} else {
|
||||
// 路由刚添加,需要重新导航
|
||||
next({ ...to, replace: true })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (whiteList.includes(to.path)) {
|
||||
next()
|
||||
} else {
|
||||
next(`/login?redirect=${to.path}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach(() => {
|
||||
NProgress.done()
|
||||
})
|
||||
|
||||
export default router
|
||||
2
src/store/index.js
Normal file
2
src/store/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useUserStore } from './modules/user'
|
||||
export { useAppStore } from './modules/app'
|
||||
18
src/store/modules/app.js
Normal file
18
src/store/modules/app.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
collapsed: false,
|
||||
breadcrumbs: []
|
||||
}),
|
||||
|
||||
actions: {
|
||||
toggleCollapsed() {
|
||||
this.collapsed = !this.collapsed
|
||||
},
|
||||
|
||||
setBreadcrumbs(breadcrumbs) {
|
||||
this.breadcrumbs = breadcrumbs
|
||||
}
|
||||
}
|
||||
})
|
||||
62
src/store/modules/user.js
Normal file
62
src/store/modules/user.js
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { login, loginByEmail, logout, getAdminInfo } from '@/api/auth'
|
||||
import { getToken, setToken, removeToken, setUser, getUser } from '@/utils/auth'
|
||||
import { resetRouter } from '@/router'
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
token: getToken(),
|
||||
userInfo: getUser(),
|
||||
permissions: [],
|
||||
roles: [],
|
||||
menus: []
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isLoggedIn: state => !!state.token
|
||||
},
|
||||
|
||||
actions: {
|
||||
async login(loginForm) {
|
||||
const data = await login(loginForm)
|
||||
this.token = data.token
|
||||
setToken(data.token)
|
||||
return data
|
||||
},
|
||||
|
||||
async loginByEmail(emailForm) {
|
||||
const data = await loginByEmail(emailForm)
|
||||
this.token = data.token
|
||||
setToken(data.token)
|
||||
return data
|
||||
},
|
||||
|
||||
async getUserInfo() {
|
||||
const data = await getAdminInfo()
|
||||
this.userInfo = data.admin
|
||||
this.permissions = data.permissions || []
|
||||
this.roles = data.roles || []
|
||||
this.menus = data.menus || []
|
||||
setUser(data.admin)
|
||||
return data
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await logout()
|
||||
} finally {
|
||||
this.resetState()
|
||||
}
|
||||
},
|
||||
|
||||
resetState() {
|
||||
this.token = null
|
||||
this.userInfo = null
|
||||
this.permissions = []
|
||||
this.roles = []
|
||||
this.menus = []
|
||||
removeToken()
|
||||
resetRouter()
|
||||
}
|
||||
}
|
||||
})
|
||||
33
src/styles/index.less
Normal file
33
src/styles/index.less
Normal file
@@ -0,0 +1,33 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
}
|
||||
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.card-container {
|
||||
background: #fff;
|
||||
padding: 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.table-toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
114
src/styles/page-common.less
Normal file
114
src/styles/page-common.less
Normal file
@@ -0,0 +1,114 @@
|
||||
// 页面通用样式
|
||||
|
||||
.page-container {
|
||||
.stat-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mini-stat-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px 20px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid #f0f0f0;
|
||||
|
||||
.stat-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-num {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
|
||||
&.green { color: #52c41a; }
|
||||
&.orange { color: #fa8c16; }
|
||||
&.red { color: #ff4d4f; }
|
||||
&.blue { color: #1890ff; }
|
||||
}
|
||||
}
|
||||
|
||||
.stat-bg-icon {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 48px;
|
||||
color: #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.filter-left {
|
||||
.filter-result {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
|
||||
b {
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-card) {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
:deep(.ant-table) {
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr > td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr:hover > td {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式
|
||||
@media (max-width: 768px) {
|
||||
.page-container {
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.filter-right {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
24
src/utils/auth.js
Normal file
24
src/utils/auth.js
Normal file
@@ -0,0 +1,24 @@
|
||||
const TOKEN_KEY = 'admin_token'
|
||||
const USER_KEY = 'admin_user'
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function removeToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
export function getUser() {
|
||||
const user = localStorage.getItem(USER_KEY)
|
||||
return user ? JSON.parse(user) : null
|
||||
}
|
||||
|
||||
export function setUser(user) {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user))
|
||||
}
|
||||
51
src/utils/permission.js
Normal file
51
src/utils/permission.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
|
||||
/**
|
||||
* 检查是否有权限
|
||||
* @param {string|string[]} permission 权限码
|
||||
*/
|
||||
export function hasPermission(permission) {
|
||||
const userStore = useUserStore()
|
||||
const permissions = userStore.permissions || []
|
||||
const roles = userStore.roles || []
|
||||
|
||||
// 超级管理员拥有所有权限
|
||||
if (roles.includes('SUPER_ADMIN')) return true
|
||||
|
||||
if (!permission) return true
|
||||
|
||||
if (Array.isArray(permission)) {
|
||||
return permission.some(p => permissions.includes(p))
|
||||
}
|
||||
|
||||
return permissions.includes(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否有角色
|
||||
* @param {string|string[]} role 角色码
|
||||
*/
|
||||
export function hasRole(role) {
|
||||
const userStore = useUserStore()
|
||||
const roles = userStore.roles || []
|
||||
|
||||
// 超级管理员拥有所有角色权限
|
||||
if (roles.includes('SUPER_ADMIN')) return true
|
||||
|
||||
if (!role) return true
|
||||
|
||||
if (Array.isArray(role)) {
|
||||
return role.some(r => roles.includes(r))
|
||||
}
|
||||
|
||||
return roles.includes(role)
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否是超级管理员
|
||||
*/
|
||||
export function isSuperAdmin() {
|
||||
const userStore = useUserStore()
|
||||
const roles = userStore.roles || []
|
||||
return roles.includes('SUPER_ADMIN')
|
||||
}
|
||||
49
src/utils/request.js
Normal file
49
src/utils/request.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import axios from 'axios'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getToken, removeToken } from './auth'
|
||||
import router from '@/router'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
timeout: 30000
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
request.interceptors.request.use(
|
||||
config => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => Promise.reject(error)
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
request.interceptors.response.use(
|
||||
response => {
|
||||
const { code, message: msg, data } = response.data
|
||||
// 后端返回 code: 0 表示成功
|
||||
if (code === 0) {
|
||||
return data
|
||||
}
|
||||
message.error(msg || '请求失败')
|
||||
return Promise.reject(new Error(msg))
|
||||
},
|
||||
error => {
|
||||
const { status, data } = error.response || {}
|
||||
if (status === 401) {
|
||||
removeToken()
|
||||
router.push('/login')
|
||||
message.error('登录已过期,请重新登录')
|
||||
} else if (status === 403) {
|
||||
message.error('没有权限访问')
|
||||
} else {
|
||||
message.error(data?.message || error.message || '网络错误')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
788
src/views/ai/debug.vue
Normal file
788
src/views/ai/debug.vue
Normal file
@@ -0,0 +1,788 @@
|
||||
<template>
|
||||
<div class="debug-page">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<a-button type="text" @click="$router.back()">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
</a-button>
|
||||
<h1>AI接口调试</h1>
|
||||
<span class="subtitle">在线测试AI模型接口</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<a-button @click="handleClearAll">
|
||||
<template #icon><ClearOutlined /></template>
|
||||
重置
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<div class="main-content">
|
||||
<!-- 模型选择区 -->
|
||||
<div class="section">
|
||||
<div class="section-title">选择模型</div>
|
||||
<div class="model-selector">
|
||||
<a-select
|
||||
v-model:value="selectedModelId"
|
||||
placeholder="请选择要调试的AI模型"
|
||||
show-search
|
||||
size="large"
|
||||
style="width: 400px;"
|
||||
:filter-option="filterOption"
|
||||
@change="handleModelChange"
|
||||
>
|
||||
<a-select-opt-group v-for="group in groupedModels" :key="group.name">
|
||||
<template #label>{{ group.name }}</template>
|
||||
<a-select-option v-for="model in group.models" :key="model.id" :value="model.id">
|
||||
<span>{{ model.name }}</span>
|
||||
<span class="model-code">{{ model.code }}</span>
|
||||
</a-select-option>
|
||||
</a-select-opt-group>
|
||||
</a-select>
|
||||
|
||||
<div v-if="selectedModel" class="model-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">类型</span>
|
||||
<span class="meta-value">{{ getTypeLabel(selectedModel.type) }}</span>
|
||||
</span>
|
||||
<span class="meta-divider">|</span>
|
||||
<span class="meta-item">
|
||||
<span class="meta-label">积分</span>
|
||||
<span class="meta-value">{{ selectedModel.pointsCost }}/次</span>
|
||||
</span>
|
||||
<span v-if="selectedModel.workflowType !== 'direct'" class="meta-divider">|</span>
|
||||
<span v-if="selectedModel.workflowType !== 'direct'" class="meta-item">
|
||||
<span class="meta-label">工作流</span>
|
||||
<span class="meta-value">{{ selectedModel.workflowType }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 参数配置区 -->
|
||||
<div class="section">
|
||||
<div class="section-title">输入参数</div>
|
||||
<div v-if="selectedModel && inputParams.length > 0" class="params-form">
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="24">
|
||||
<template v-for="param in inputParams" :key="param.name">
|
||||
<!-- 文本输入 -->
|
||||
<a-col :span="param.type === 'textarea' ? 24 : 12" v-if="param.type === 'text' || param.type === 'input'">
|
||||
<a-form-item :required="param.required">
|
||||
<template #label><span class="param-label">{{ param.label }}</span></template>
|
||||
<a-input v-model:value="formData[param.name]" :placeholder="param.placeholder || `请输入${param.label}`" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<!-- 文本域 -->
|
||||
<a-col :span="24" v-else-if="param.type === 'textarea'">
|
||||
<a-form-item :required="param.required">
|
||||
<template #label><span class="param-label">{{ param.label }}</span></template>
|
||||
<a-textarea v-model:value="formData[param.name]" :placeholder="param.placeholder || `请输入${param.label}`" :rows="4" show-count :maxlength="2000" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<!-- 下拉选择 -->
|
||||
<a-col :span="12" v-else-if="param.type === 'select'">
|
||||
<a-form-item :required="param.required">
|
||||
<template #label><span class="param-label">{{ param.label }}</span></template>
|
||||
<a-select v-model:value="formData[param.name]" :placeholder="param.placeholder || `请选择${param.label}`">
|
||||
<a-select-option v-for="opt in param.options" :key="opt" :value="opt">{{ opt }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<!-- 数字输入 -->
|
||||
<a-col :span="12" v-else-if="param.type === 'number'">
|
||||
<a-form-item :required="param.required">
|
||||
<template #label><span class="param-label">{{ param.label }}</span></template>
|
||||
<a-input-number v-model:value="formData[param.name]" :min="param.min" :max="param.max" style="width: 100%;" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<!-- 单图上传 -->
|
||||
<a-col :span="12" v-else-if="param.type === 'image'">
|
||||
<a-form-item :required="param.required">
|
||||
<template #label><span class="param-label">{{ param.label }}</span></template>
|
||||
<div class="image-upload-area">
|
||||
<a-upload :file-list="getImageFileList(param.name)" list-type="picture-card" :before-upload="(file) => handleBeforeUpload(file, param.name, false)" @remove="() => handleRemoveImage(param.name)" :max-count="1">
|
||||
<div v-if="!formData[param.name]" class="upload-placeholder"><PlusOutlined /><span>上传</span></div>
|
||||
</a-upload>
|
||||
<span class="upload-hint">JPG/PNG/GIF, 最大10MB</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<!-- 多图上传 -->
|
||||
<a-col :span="24" v-else-if="param.type === 'images'">
|
||||
<a-form-item :required="param.required">
|
||||
<template #label><span class="param-label">{{ param.label }}</span></template>
|
||||
<div class="image-upload-area">
|
||||
<a-upload :file-list="getImagesFileList(param.name)" list-type="picture-card" :before-upload="(file) => handleBeforeUpload(file, param.name, true)" @remove="(file) => handleRemoveImages(param.name, file)" :max-count="param.maxCount || 5" multiple>
|
||||
<div class="upload-placeholder"><PlusOutlined /><span>上传</span></div>
|
||||
</a-upload>
|
||||
<span class="upload-hint">最多{{ param.maxCount || 5 }}张</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</template>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
<div v-else class="empty-params">
|
||||
<span class="empty-text">请先选择模型</span>
|
||||
</div>
|
||||
|
||||
<!-- 发送按钮 -->
|
||||
<div class="submit-area">
|
||||
<a-button type="primary" size="large" :loading="debugging" @click="handleDebug" :disabled="!canSubmit">
|
||||
<template #icon><SendOutlined /></template>
|
||||
{{ debugging ? '请求中...' : '发送请求' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果展示区 - 始终显示 -->
|
||||
<div class="section result-section">
|
||||
<div class="section-header">
|
||||
<div class="section-title">调试结果</div>
|
||||
<div v-if="debugResult" class="section-actions">
|
||||
<a-button size="small" @click="copyToClipboard(JSON.stringify(debugResult.response, null, 2))">
|
||||
<template #icon><CopyOutlined /></template>复制
|
||||
</a-button>
|
||||
<a-button size="small" @click="debugResult = null">
|
||||
<template #icon><DeleteOutlined /></template>清除
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 有结果时显示 -->
|
||||
<template v-if="debugResult">
|
||||
<!-- 状态指示 -->
|
||||
<div class="result-status" :class="debugResult.success ? 'success' : 'error'">
|
||||
<span class="status-icon">
|
||||
<CheckCircleOutlined v-if="debugResult.success" />
|
||||
<CloseCircleOutlined v-else />
|
||||
</span>
|
||||
<span class="status-text">{{ debugResult.success ? '请求成功' : '请求失败' }}</span>
|
||||
<span v-if="debugResult.duration" class="status-duration">耗时 {{ debugResult.duration }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 提取的结果数据 -->
|
||||
<div v-if="debugResult.success && extractedResult" class="extracted-result">
|
||||
<div class="extracted-label">提取结果</div>
|
||||
<div class="extracted-content">{{ extractedResult }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果标签页 -->
|
||||
<a-tabs v-model:activeKey="activeTab" class="result-tabs">
|
||||
<a-tab-pane key="response" tab="响应数据">
|
||||
<div class="code-block"><pre>{{ JSON.stringify(debugResult.response, null, 2) }}</pre></div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="request" tab="请求详情">
|
||||
<div class="request-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">URL</span>
|
||||
<span class="info-value url">{{ debugResult.request?.url }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Method</span>
|
||||
<span class="info-value method">{{ debugResult.request?.method }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Headers</span>
|
||||
<div class="code-block small"><pre>{{ JSON.stringify(debugResult.request?.headers, null, 2) }}</pre></div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Body</span>
|
||||
<div class="code-block small"><pre>{{ JSON.stringify(debugResult.request?.body, null, 2) }}</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="raw" tab="原始响应">
|
||||
<div class="code-block"><pre>{{ debugResult.rawResponse }}</pre></div>
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane v-if="!debugResult.success" key="error" tab="错误信息">
|
||||
<div class="error-info">
|
||||
<div class="error-message">{{ debugResult.error }}</div>
|
||||
<div v-if="debugResult.stackTrace" class="code-block"><pre>{{ debugResult.stackTrace }}</pre></div>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</template>
|
||||
|
||||
<!-- 无结果时显示空状态 -->
|
||||
<div v-else class="empty-result">
|
||||
<div class="empty-icon"><ExperimentOutlined /></div>
|
||||
<p>暂无调试结果</p>
|
||||
<span class="empty-hint">配置参数后点击发送请求</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowLeftOutlined, ClearOutlined, SendOutlined, PlusOutlined, DeleteOutlined, CopyOutlined, ExperimentOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
|
||||
import { getAiModels, debugAiModel, getAiModelTypes } from '@/api/ai'
|
||||
import { uploadImage } from '@/api/upload'
|
||||
|
||||
const route = useRoute()
|
||||
const selectedModelId = ref(null)
|
||||
const selectedModel = ref(null)
|
||||
const models = ref([])
|
||||
const modelTypes = ref([])
|
||||
const formData = reactive({})
|
||||
const debugging = ref(false)
|
||||
const debugResult = ref(null)
|
||||
const activeTab = ref('response')
|
||||
|
||||
// 按厂商分组模型
|
||||
const groupedModels = computed(() => {
|
||||
const groups = {}
|
||||
models.value.forEach(model => {
|
||||
const providerName = model.providerName || '未知厂商'
|
||||
if (!groups[providerName]) {
|
||||
groups[providerName] = { name: providerName, models: [] }
|
||||
}
|
||||
groups[providerName].models.push(model)
|
||||
})
|
||||
return Object.values(groups)
|
||||
})
|
||||
|
||||
// 解析输入参数配置
|
||||
const inputParams = computed(() => {
|
||||
if (!selectedModel.value || !selectedModel.value.inputParams) return []
|
||||
try {
|
||||
return JSON.parse(selectedModel.value.inputParams)
|
||||
} catch (e) {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
// 检查是否可以提交
|
||||
const canSubmit = computed(() => {
|
||||
if (!selectedModel.value) return false
|
||||
const missingParams = inputParams.value.filter(p => p.required && !formData[p.name])
|
||||
return missingParams.length === 0
|
||||
})
|
||||
|
||||
// 提取的结果数据
|
||||
const extractedResult = computed(() => {
|
||||
if (!debugResult.value || !debugResult.value.success || !debugResult.value.response) return null
|
||||
if (!selectedModel.value || !selectedModel.value.responseMapping) return null
|
||||
|
||||
try {
|
||||
const mapping = JSON.parse(selectedModel.value.responseMapping)
|
||||
const resultPath = mapping.result
|
||||
if (!resultPath) return null
|
||||
|
||||
// 解析路径提取数据,如 "data" 或 "choices[0].message.content"
|
||||
const response = debugResult.value.response
|
||||
return extractByPath(response, resultPath)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// 根据路径提取数据
|
||||
const extractByPath = (obj, path) => {
|
||||
if (!obj || !path) return null
|
||||
const parts = path.replace(/\[(\d+)\]/g, '.$1').split('.')
|
||||
let result = obj
|
||||
for (const part of parts) {
|
||||
if (result === null || result === undefined) return null
|
||||
result = result[part]
|
||||
}
|
||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : result
|
||||
}
|
||||
|
||||
const getTypeLabel = (typeValue) => {
|
||||
const type = modelTypes.value.find(t => t.value === typeValue)
|
||||
return type ? type.label : typeValue
|
||||
}
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const res = await getAiModels({ status: 1 })
|
||||
models.value = res.records || []
|
||||
} catch (e) {
|
||||
message.error('获取模型列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadModelTypes = async () => {
|
||||
try {
|
||||
const res = await getAiModelTypes()
|
||||
modelTypes.value = res || []
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const filterOption = (input, option) => {
|
||||
const text = option.children?.[0]?.children || ''
|
||||
return text.toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
|
||||
const handleModelChange = (modelId) => {
|
||||
selectedModel.value = models.value.find(m => m.id === modelId)
|
||||
Object.keys(formData).forEach(key => delete formData[key])
|
||||
debugResult.value = null
|
||||
inputParams.value.forEach(param => {
|
||||
if (param.default) formData[param.name] = param.default
|
||||
else if (param.type === 'images') formData[param.name] = []
|
||||
})
|
||||
}
|
||||
|
||||
const getImageFileList = (paramName) => {
|
||||
const url = formData[paramName]
|
||||
if (!url) return []
|
||||
return [{ uid: '-1', name: 'image.png', status: 'done', url }]
|
||||
}
|
||||
|
||||
const getImagesFileList = (paramName) => {
|
||||
const urls = formData[paramName] || []
|
||||
return urls.map((url, index) => ({ uid: `-${index}`, name: `image-${index}.png`, status: 'done', url }))
|
||||
}
|
||||
|
||||
const handleBeforeUpload = async (file, paramName, isMultiple) => {
|
||||
if (!file.type.startsWith('image/')) { message.error('只能上传图片'); return false }
|
||||
if (file.size / 1024 / 1024 > 10) { message.error('图片不能超过10MB'); return false }
|
||||
try {
|
||||
message.loading({ content: '上传中...', key: 'upload' })
|
||||
const url = await uploadImage(file)
|
||||
if (isMultiple) {
|
||||
if (!formData[paramName]) formData[paramName] = []
|
||||
formData[paramName].push(url)
|
||||
} else {
|
||||
formData[paramName] = url
|
||||
}
|
||||
message.success({ content: '上传成功', key: 'upload' })
|
||||
} catch (e) {
|
||||
message.error({ content: '上传失败', key: 'upload' })
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const handleRemoveImage = (paramName) => { delete formData[paramName] }
|
||||
const handleRemoveImages = (paramName, file) => {
|
||||
const urls = formData[paramName] || []
|
||||
const index = urls.findIndex(url => url === file.url)
|
||||
if (index > -1) urls.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleDebug = async () => {
|
||||
const missingParams = inputParams.value.filter(p => p.required && !formData[p.name]).map(p => p.label)
|
||||
if (missingParams.length > 0) { message.warning(`请填写: ${missingParams.join(', ')}`); return }
|
||||
|
||||
debugging.value = true
|
||||
debugResult.value = null
|
||||
try {
|
||||
const result = await debugAiModel(selectedModelId.value, formData)
|
||||
debugResult.value = result
|
||||
activeTab.value = result.success ? 'response' : 'error'
|
||||
message[result.success ? 'success' : 'error'](result.success ? '请求成功' : '请求失败')
|
||||
} catch (e) {
|
||||
message.error('请求失败: ' + e.message)
|
||||
debugResult.value = { success: false, error: e.message, stackTrace: e.toString() }
|
||||
} finally {
|
||||
debugging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text).then(() => message.success('已复制')).catch(() => message.error('复制失败'))
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
selectedModelId.value = null
|
||||
selectedModel.value = null
|
||||
Object.keys(formData).forEach(key => delete formData[key])
|
||||
debugResult.value = null
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadModels(), loadModelTypes()])
|
||||
const modelIdFromQuery = route.query.modelId
|
||||
if (modelIdFromQuery) {
|
||||
const modelId = parseInt(modelIdFromQuery)
|
||||
if (models.value.find(m => m.id === modelId)) {
|
||||
selectedModelId.value = modelId
|
||||
handleModelChange(modelId)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.debug-page {
|
||||
min-height: 100vh;
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #8c8c8c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.model-selector {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.model-code {
|
||||
margin-left: 8px;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.model-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
color: #1a1a1a;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-divider {
|
||||
color: #d9d9d9;
|
||||
}
|
||||
|
||||
.params-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.param-label {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.empty-params {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
color: #bfbfbf;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.image-upload-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-hint {
|
||||
font-size: 12px;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.submit-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.result-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.result-status.success {
|
||||
background: #f6ffed;
|
||||
border: 1px solid #b7eb8f;
|
||||
}
|
||||
|
||||
.result-status.error {
|
||||
background: #fff2f0;
|
||||
border: 1px solid #ffccc7;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.result-status.success .status-icon {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.result-status.error .status-icon {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.status-duration {
|
||||
margin-left: auto;
|
||||
color: #8c8c8c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 提取结果样式 */
|
||||
.extracted-result {
|
||||
background: #f0f9eb;
|
||||
border: 1px solid #c2e7b0;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.extracted-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #67c23a;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.extracted-content {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty-result {
|
||||
text-align: center;
|
||||
padding: 48px 0;
|
||||
color: #bfbfbf;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-hint {
|
||||
font-size: 12px;
|
||||
color: #d9d9d9;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.code-block.small {
|
||||
max-height: 200px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: #262626;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.request-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #8c8c8c;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.info-value.url {
|
||||
font-family: 'SF Mono', monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-value.method {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.error-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 12px 16px;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #ffccc7;
|
||||
border-radius: 6px;
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-upload-list-picture-card-container),
|
||||
:deep(.ant-upload-select-picture-card) {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select-selector),
|
||||
:deep(.ant-input-number) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.ant-input:focus),
|
||||
:deep(.ant-select-focused .ant-select-selector) {
|
||||
border-color: #595959;
|
||||
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary) {
|
||||
background: #262626;
|
||||
border-color: #262626;
|
||||
border-radius: 6px;
|
||||
height: 40px;
|
||||
padding: 0 32px;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary:hover) {
|
||||
background: #434343;
|
||||
border-color: #434343;
|
||||
}
|
||||
|
||||
:deep(.ant-btn-primary:disabled) {
|
||||
background: #d9d9d9;
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.code-block::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.code-block::-webkit-scrollbar-track {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.code-block::-webkit-scrollbar-thumb {
|
||||
background: #d9d9d9;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
1392
src/views/ai/model.vue
Normal file
1392
src/views/ai/model.vue
Normal file
File diff suppressed because it is too large
Load Diff
201
src/views/ai/provider.vue
Normal file
201
src/views/ai/provider.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<div class="provider-page">
|
||||
<div class="page-header">
|
||||
<h2>AI厂商管理</h2>
|
||||
<a-button type="primary" @click="handleAdd">新增厂商</a-button>
|
||||
</div>
|
||||
|
||||
<a-table :dataSource="providers" :loading="loading" :columns="columns" rowKey="id">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'default'">
|
||||
{{ record.status === 1 ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a @click="handleEdit(record)">编辑</a>
|
||||
<a @click="handleToggleStatus(record)">{{ record.status === 1 ? '禁用' : '启用' }}</a>
|
||||
<a-popconfirm title="确定删除该厂商?" @confirm="handleDelete(record.id)">
|
||||
<a style="color: #ff4d4f">删除</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑厂商' : '新增厂商'"
|
||||
:confirmLoading="submitLoading"
|
||||
@ok="handleSubmit"
|
||||
width="800px"
|
||||
>
|
||||
<a-form :model="form" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<a-form-item label="厂商名称" required>
|
||||
<a-input v-model:value="form.name" placeholder="请输入厂商名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="厂商编码" required>
|
||||
<a-input v-model:value="form.code" placeholder="请输入厂商编码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="厂商描述">
|
||||
<a-textarea v-model:value="form.description" placeholder="请输入厂商描述" />
|
||||
</a-form-item>
|
||||
<a-form-item label="基础URL">
|
||||
<a-input v-model:value="form.baseUrl" placeholder="请输入API基础URL" />
|
||||
</a-form-item>
|
||||
<a-form-item label="API密钥">
|
||||
<a-input v-model:value="form.apiKey" placeholder="请输入API密钥" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密钥">
|
||||
<a-input v-model:value="form.secretKey" placeholder="请输入密钥" />
|
||||
</a-form-item>
|
||||
<a-form-item label="额外配置">
|
||||
<a-textarea v-model:value="form.extraConfig" placeholder="JSON格式的额外配置" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="form.status">
|
||||
<a-radio :value="1">启用</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
getAiProviders,
|
||||
createAiProvider,
|
||||
updateAiProvider,
|
||||
deleteAiProvider,
|
||||
updateAiProviderStatus
|
||||
} from '@/api/ai'
|
||||
|
||||
const loading = ref(false)
|
||||
const providers = ref([])
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
|
||||
const defaultForm = {
|
||||
id: null,
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
secretKey: '',
|
||||
extraConfig: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
}
|
||||
const form = ref({ ...defaultForm })
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '厂商名称', dataIndex: 'name', width: 150 },
|
||||
{ title: '厂商编码', dataIndex: 'code', width: 120 },
|
||||
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
|
||||
{ title: '基础URL', dataIndex: 'baseUrl', width: 200, ellipsis: true },
|
||||
{ title: '排序', dataIndex: 'sort', width: 60 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 150 },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
const loadProviders = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAiProviders()
|
||||
providers.value = res.records || []
|
||||
} catch (e) {
|
||||
message.error('获取厂商列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
form.value = { ...defaultForm }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
isEdit.value = true
|
||||
form.value = { ...record }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.value.name || !form.value.code) {
|
||||
message.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateAiProvider(form.value)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createAiProvider(form.value)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
loadProviders()
|
||||
} catch (e) {
|
||||
message.error(e.message || '操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deleteAiProvider(id)
|
||||
message.success('删除成功')
|
||||
loadProviders()
|
||||
} catch (e) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (record) => {
|
||||
const newStatus = record.status === 1 ? 0 : 1
|
||||
try {
|
||||
await updateAiProviderStatus(record.id, newStatus)
|
||||
message.success(newStatus === 1 ? '已启用' : '已禁用')
|
||||
loadProviders()
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-page {
|
||||
padding: 20px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
225
src/views/ai/task.vue
Normal file
225
src/views/ai/task.vue
Normal file
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<div class="task-page">
|
||||
<div class="page-header">
|
||||
<h2>AI任务管理</h2>
|
||||
<a-space>
|
||||
<a-button @click="handleProcessQueue" type="primary">处理队列</a-button>
|
||||
<a-button @click="loadTasks">刷新</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<a-space>
|
||||
<a-input v-model:value="filters.userId" placeholder="用户ID" style="width: 120px" />
|
||||
<a-select v-model:value="filters.status" placeholder="状态" style="width: 120px" allowClear>
|
||||
<a-select-option :value="0">队列中</a-select-option>
|
||||
<a-select-option :value="1">处理中</a-select-option>
|
||||
<a-select-option :value="2">成功</a-select-option>
|
||||
<a-select-option :value="3">失败</a-select-option>
|
||||
<a-select-option :value="4">已取消</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="loadTasks">查询</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table :dataSource="tasks" :loading="loading" :columns="columns" rowKey="id" :pagination="pagination" @change="handleTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'taskNo'">
|
||||
<a-typography-text copyable>{{ record.taskNo }}</a-typography-text>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="getStatusColor(record.status)">
|
||||
{{ record.statusText }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'progress'">
|
||||
<a-progress :percent="record.progress" size="small" />
|
||||
</template>
|
||||
<template v-if="column.key === 'duration'">
|
||||
{{ formatDuration(record.duration) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a @click="handleViewDetail(record)">详情</a>
|
||||
<a v-if="record.status === 0 || record.status === 1" @click="handleCancelTask(record)" style="color: #ff4d4f">取消</a>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 任务详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="detailVisible"
|
||||
title="任务详情"
|
||||
width="800px"
|
||||
:footer="null"
|
||||
>
|
||||
<div v-if="currentTask">
|
||||
<a-descriptions :column="2" bordered>
|
||||
<a-descriptions-item label="任务编号">{{ currentTask.taskNo }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户">{{ currentTask.userNickname }}</a-descriptions-item>
|
||||
<a-descriptions-item label="模型">{{ currentTask.modelName }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="getStatusColor(currentTask.status)">{{ currentTask.statusText }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="进度">
|
||||
<a-progress :percent="currentTask.progress" size="small" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="积分消耗">{{ currentTask.pointsCost }}</a-descriptions-item>
|
||||
<a-descriptions-item label="优先级">{{ currentTask.priority }}</a-descriptions-item>
|
||||
<a-descriptions-item label="重试次数">{{ currentTask.retryCount }}/{{ currentTask.maxRetry }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ currentTask.createdAt }}</a-descriptions-item>
|
||||
<a-descriptions-item label="开始时间">{{ currentTask.startTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="结束时间">{{ currentTask.endTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="耗时">{{ formatDuration(currentTask.duration) }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-divider>输入参数</a-divider>
|
||||
<pre style="background: #f5f5f5; padding: 12px; border-radius: 4px; max-height: 200px; overflow-y: auto;">{{ formatJson(currentTask.inputParams) }}</pre>
|
||||
|
||||
<a-divider v-if="currentTask.outputResult">输出结果</a-divider>
|
||||
<pre v-if="currentTask.outputResult" style="background: #f5f5f5; padding: 12px; border-radius: 4px; max-height: 200px; overflow-y: auto;">{{ formatJson(currentTask.outputResult) }}</pre>
|
||||
|
||||
<a-divider v-if="currentTask.errorMessage">错误信息</a-divider>
|
||||
<a-alert v-if="currentTask.errorMessage" :message="currentTask.errorMessage" type="error" />
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getAiTasks, getAiTask, processTaskQueue } from '@/api/ai'
|
||||
|
||||
const loading = ref(false)
|
||||
const tasks = ref([])
|
||||
const detailVisible = ref(false)
|
||||
const currentTask = ref(null)
|
||||
|
||||
const filters = reactive({
|
||||
userId: null,
|
||||
status: null
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条记录`
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '任务编号', key: 'taskNo', width: 200 },
|
||||
{ title: '用户', dataIndex: 'userNickname', width: 120 },
|
||||
{ title: '模型', dataIndex: 'modelName', width: 150 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '进度', key: 'progress', width: 120 },
|
||||
{ title: '积分', dataIndex: 'pointsCost', width: 80 },
|
||||
{ title: '优先级', dataIndex: 'priority', width: 80 },
|
||||
{ title: '耗时', key: 'duration', width: 100 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 150 },
|
||||
{ title: '操作', key: 'action', width: 120 }
|
||||
]
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
const colorMap = {
|
||||
0: 'default', // 队列中
|
||||
1: 'processing', // 处理中
|
||||
2: 'success', // 成功
|
||||
3: 'error', // 失败
|
||||
4: 'warning' // 已取消
|
||||
}
|
||||
return colorMap[status] || 'default'
|
||||
}
|
||||
|
||||
const formatDuration = (duration) => {
|
||||
if (!duration) return '-'
|
||||
if (duration < 1000) return `${duration}ms`
|
||||
return `${(duration / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const formatJson = (jsonStr) => {
|
||||
if (!jsonStr) return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(jsonStr), null, 2)
|
||||
} catch (e) {
|
||||
return jsonStr
|
||||
}
|
||||
}
|
||||
|
||||
const loadTasks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
...filters
|
||||
}
|
||||
const res = await getAiTasks(params)
|
||||
tasks.value = res.records || []
|
||||
pagination.total = res.total || 0
|
||||
} catch (e) {
|
||||
message.error('获取任务列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const handleViewDetail = async (record) => {
|
||||
try {
|
||||
const res = await getAiTask(record.id)
|
||||
currentTask.value = res
|
||||
detailVisible.value = true
|
||||
} catch (e) {
|
||||
message.error('获取任务详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelTask = (record) => {
|
||||
// 这里应该调用取消任务的API
|
||||
message.info('取消任务功能待实现')
|
||||
}
|
||||
|
||||
const handleProcessQueue = async () => {
|
||||
try {
|
||||
await processTaskQueue()
|
||||
message.success('队列处理已启动')
|
||||
loadTasks()
|
||||
} catch (e) {
|
||||
message.error('处理队列失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.task-page {
|
||||
padding: 20px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.filter-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
328
src/views/config/banner.vue
Normal file
328
src/views/config/banner.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-card>
|
||||
<div class="table-toolbar">
|
||||
<span>Banner管理</span>
|
||||
<a-button type="primary" v-permission="'config:banner:add'" @click="handleAdd">
|
||||
新增Banner
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'imageUrl'">
|
||||
<a-image :src="record.imageUrl" :width="120" :height="60" style="object-fit: cover" />
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '上架' : '下架' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a v-permission="'config:banner:edit'" @click="handleEdit(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确定删除该Banner吗?" @confirm="handleDelete(record)">
|
||||
<a v-permission="'config:banner:delete'" style="color: #ff4d4f">删除</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑Banner' : '新增Banner'"
|
||||
@ok="handleSubmit"
|
||||
:confirm-loading="submitting"
|
||||
width="600px"
|
||||
>
|
||||
<a-form :model="formData" :label-col="{ span: 5 }">
|
||||
<a-form-item label="标题" required>
|
||||
<a-input v-model:value="formData.title" placeholder="请输入标题" />
|
||||
</a-form-item>
|
||||
<a-form-item label="图片" required>
|
||||
<div class="upload-container">
|
||||
<a-upload
|
||||
v-model:file-list="fileList"
|
||||
name="file"
|
||||
list-type="picture-card"
|
||||
class="banner-uploader"
|
||||
:show-upload-list="false"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="handleUpload"
|
||||
accept="image/*"
|
||||
>
|
||||
<div v-if="formData.imageUrl" class="uploaded-image">
|
||||
<img :src="formData.imageUrl" alt="banner" style="width: 100%; height: 100%; object-fit: cover;" />
|
||||
<div class="upload-overlay">
|
||||
<a-button type="text" size="small" @click.stop="removeImage">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="upload-placeholder">
|
||||
<LoadingOutlined v-if="uploading" />
|
||||
<PlusOutlined v-else />
|
||||
<div class="ant-upload-text">{{ uploading ? '上传中' : '上传图片' }}</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div class="upload-tips">
|
||||
<p>建议尺寸:750x300px,支持JPG、PNG格式,文件大小不超过10MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="链接类型">
|
||||
<a-select v-model:value="formData.linkType">
|
||||
<a-select-option :value="0">无</a-select-option>
|
||||
<a-select-option :value="1">内部链接</a-select-option>
|
||||
<a-select-option :value="2">外部链接</a-select-option>
|
||||
<a-select-option :value="3">小程序</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formData.linkType > 0" label="跳转链接">
|
||||
<a-input v-model:value="formData.linkUrl" placeholder="请输入跳转链接" />
|
||||
</a-form-item>
|
||||
<a-form-item label="展示位置">
|
||||
<a-input v-model:value="formData.position" placeholder="home" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model:value="formData.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">上架</a-radio>
|
||||
<a-radio :value="0">下架</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, LoadingOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
import { getBannerList, createBanner, updateBanner, deleteBanner, uploadBannerImage } from '@/api/config'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const modalVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
const uploading = ref(false)
|
||||
const fileList = ref([])
|
||||
|
||||
const formData = reactive({
|
||||
title: '',
|
||||
imageUrl: '',
|
||||
linkType: 0,
|
||||
linkUrl: '',
|
||||
position: 'home',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '图片', key: 'imageUrl', width: 140 },
|
||||
{ title: '标题', dataIndex: 'title' },
|
||||
{ title: '位置', dataIndex: 'position', width: 100 },
|
||||
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||
{ title: '点击量', dataIndex: 'clickCount', width: 80 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getBannerList()
|
||||
tableData.value = data || []
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
title: '',
|
||||
imageUrl: '',
|
||||
linkType: 0,
|
||||
linkUrl: '',
|
||||
position: 'home',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
fileList.value = []
|
||||
}
|
||||
|
||||
const beforeUpload = (file) => {
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件!')
|
||||
return false
|
||||
}
|
||||
const isLt10M = file.size / 1024 / 1024 < 10
|
||||
if (!isLt10M) {
|
||||
message.error('图片大小不能超过10MB!')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleUpload = async (options) => {
|
||||
const { file } = options
|
||||
uploading.value = true
|
||||
|
||||
try {
|
||||
const url = await uploadBannerImage(file)
|
||||
formData.imageUrl = url
|
||||
message.success('图片上传成功')
|
||||
} catch (error) {
|
||||
message.error('图片上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const removeImage = () => {
|
||||
formData.imageUrl = ''
|
||||
fileList.value = []
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
currentId.value = null
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = record => {
|
||||
isEdit.value = true
|
||||
currentId.value = record.id
|
||||
Object.assign(formData, record)
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.title || !formData.imageUrl) {
|
||||
message.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateBanner(currentId.value, formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createBanner(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async record => {
|
||||
try {
|
||||
await deleteBanner(record.id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.banner-uploader .ant-upload {
|
||||
width: 200px;
|
||||
height: 100px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.banner-uploader .ant-upload:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.upload-placeholder .anticon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.uploaded-image {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.upload-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.uploaded-image:hover .upload-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.upload-overlay .ant-btn {
|
||||
color: white;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.upload-tips {
|
||||
margin-top: 8px;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upload-tips p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
200
src/views/config/notice.vue
Normal file
200
src/views/config/notice.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-card>
|
||||
<div class="table-toolbar">
|
||||
<span>公告管理</span>
|
||||
<a-button type="primary" v-permission="'config:notice:add'" @click="handleAdd">
|
||||
新增公告
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="typeMap[record.type]?.color">
|
||||
{{ typeMap[record.type]?.text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '上架' : '下架' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a v-permission="'config:notice:edit'" @click="handleEdit(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确定删除该公告吗?" @confirm="handleDelete(record)">
|
||||
<a v-permission="'config:notice:delete'" style="color: #ff4d4f">删除</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑公告' : '新增公告'"
|
||||
@ok="handleSubmit"
|
||||
:confirm-loading="submitting"
|
||||
width="700px"
|
||||
>
|
||||
<a-form :model="formData" :label-col="{ span: 4 }">
|
||||
<a-form-item label="标题" required>
|
||||
<a-input v-model:value="formData.title" placeholder="请输入标题" />
|
||||
</a-form-item>
|
||||
<a-form-item label="类型">
|
||||
<a-select v-model:value="formData.type">
|
||||
<a-select-option :value="1">通知</a-select-option>
|
||||
<a-select-option :value="2">公告</a-select-option>
|
||||
<a-select-option :value="3">活动</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="内容" required>
|
||||
<a-textarea v-model:value="formData.content" :rows="6" placeholder="请输入公告内容" />
|
||||
</a-form-item>
|
||||
<a-form-item label="是否置顶">
|
||||
<a-switch v-model:checked="formData.isTop" :checked-value="1" :un-checked-value="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="是否弹窗">
|
||||
<a-switch v-model:checked="formData.isPopup" :checked-value="1" :un-checked-value="0" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">上架</a-radio>
|
||||
<a-radio :value="0">下架</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getNoticeList, createNotice, updateNotice, deleteNotice } from '@/api/config'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
|
||||
const modalVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
|
||||
const typeMap = {
|
||||
1: { text: '通知', color: 'blue' },
|
||||
2: { text: '公告', color: 'green' },
|
||||
3: { text: '活动', color: 'orange' }
|
||||
}
|
||||
|
||||
const formData = reactive({
|
||||
title: '',
|
||||
type: 1,
|
||||
content: '',
|
||||
isTop: 0,
|
||||
isPopup: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '类型', key: 'type', width: 80 },
|
||||
{ title: '阅读量', dataIndex: 'viewCount', width: 80 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getNoticeList({
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
tableData.value = data.list || []
|
||||
pagination.total = data.total || 0
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = pag => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
title: '',
|
||||
type: 1,
|
||||
content: '',
|
||||
isTop: 0,
|
||||
isPopup: 0,
|
||||
status: 1
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
currentId.value = null
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = record => {
|
||||
isEdit.value = true
|
||||
currentId.value = record.id
|
||||
Object.assign(formData, record)
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.title || !formData.content) {
|
||||
message.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateNotice(currentId.value, formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createNotice(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async record => {
|
||||
try {
|
||||
await deleteNotice(record.id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
222
src/views/config/points.vue
Normal file
222
src/views/config/points.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<div class="points-page">
|
||||
<div class="page-header">
|
||||
<h2>积分套餐管理</h2>
|
||||
<a-button type="primary" @click="handleAdd">新增套餐</a-button>
|
||||
</div>
|
||||
|
||||
<a-alert
|
||||
message="套餐名称格式说明"
|
||||
description="套餐名称使用逗号分隔,第一个为Tab显示名称,后面的为描述内容。例如:体验版,新手体验,小额充值,有效期365天"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
/>
|
||||
|
||||
<a-table :dataSource="packages" :loading="loading" :columns="columns" rowKey="id">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div>
|
||||
<div style="font-weight: bold">{{ getTabName(record.name) }}</div>
|
||||
<div style="color: #999; font-size: 12px">{{ getDescriptions(record.name) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'default'">
|
||||
{{ record.status === 1 ? '上架' : '下架' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a @click="handleEdit(record)">编辑</a>
|
||||
<a @click="handleToggleStatus(record)">{{ record.status === 1 ? '下架' : '上架' }}</a>
|
||||
<a-popconfirm title="确定删除该套餐?" @confirm="handleDelete(record.id)">
|
||||
<a style="color: #ff4d4f">删除</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑套餐' : '新增套餐'"
|
||||
:confirmLoading="submitLoading"
|
||||
@ok="handleSubmit"
|
||||
width="600px"
|
||||
>
|
||||
<a-form :model="form" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
||||
<a-form-item label="套餐名称" required>
|
||||
<a-input v-model:value="form.name" placeholder="格式:套餐名,描述1,描述2..." />
|
||||
</a-form-item>
|
||||
<a-form-item label="积分数量" required>
|
||||
<a-input-number v-model:value="form.points" :min="1" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="价格(元)" required>
|
||||
<a-input-number v-model:value="form.price" :min="0" :precision="2" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="原价(元)">
|
||||
<a-input-number v-model:value="form.originalPrice" :min="0" :precision="2" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="赠送积分">
|
||||
<a-input-number v-model:value="form.bonusPoints" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="有效期(天)">
|
||||
<a-input-number v-model:value="form.validDays" :min="1" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="form.status">
|
||||
<a-radio :value="1">上架</a-radio>
|
||||
<a-radio :value="0">下架</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
getPointsPackages,
|
||||
createPointsPackage,
|
||||
updatePointsPackage,
|
||||
deletePointsPackage,
|
||||
updatePointsPackageStatus
|
||||
} from '@/api/points'
|
||||
|
||||
const loading = ref(false)
|
||||
const packages = ref([])
|
||||
const modalVisible = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const isEdit = ref(false)
|
||||
|
||||
const defaultForm = {
|
||||
id: null,
|
||||
name: '',
|
||||
points: 1000,
|
||||
price: 10,
|
||||
originalPrice: null,
|
||||
bonusPoints: 0,
|
||||
validDays: 365,
|
||||
sort: 0,
|
||||
status: 1
|
||||
}
|
||||
const form = ref({ ...defaultForm })
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 60 },
|
||||
{ title: '套餐名称', key: 'name', width: 250 },
|
||||
{ title: '积分', dataIndex: 'points', width: 80 },
|
||||
{ title: '赠送', dataIndex: 'bonusPoints', width: 80 },
|
||||
{ title: '价格', dataIndex: 'price', width: 80 },
|
||||
{ title: '原价', dataIndex: 'originalPrice', width: 80 },
|
||||
{ title: '排序', dataIndex: 'sort', width: 60 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
const getTabName = (name) => {
|
||||
if (!name) return '-'
|
||||
return name.split(/[,,]+/)[0]
|
||||
}
|
||||
|
||||
const getDescriptions = (name) => {
|
||||
if (!name) return ''
|
||||
const parts = name.split(/[,,]+/)
|
||||
return parts.length > 1 ? parts.slice(1).join(' | ') : ''
|
||||
}
|
||||
|
||||
const loadPackages = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getPointsPackages()
|
||||
packages.value = res || []
|
||||
} catch (e) {
|
||||
message.error('获取套餐列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
form.value = { ...defaultForm }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
isEdit.value = true
|
||||
form.value = { ...record }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.value.name || !form.value.points || !form.value.price) {
|
||||
message.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
submitLoading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updatePointsPackage(form.value)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createPointsPackage(form.value)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
loadPackages()
|
||||
} catch (e) {
|
||||
message.error(e.message || '操作失败')
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await deletePointsPackage(id)
|
||||
message.success('删除成功')
|
||||
loadPackages()
|
||||
} catch (e) {
|
||||
message.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleStatus = async (record) => {
|
||||
const newStatus = record.status === 1 ? 0 : 1
|
||||
try {
|
||||
await updatePointsPackageStatus(record.id, newStatus)
|
||||
message.success(newStatus === 1 ? '已上架' : '已下架')
|
||||
loadPackages()
|
||||
} catch (e) {
|
||||
message.error('操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPackages()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.points-page {
|
||||
padding: 20px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
</style>
|
||||
284
src/views/config/redeem.vue
Normal file
284
src/views/config/redeem.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 总览卡片 -->
|
||||
<a-row :gutter="[16, 16]" class="stat-row">
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">兑换码总数</span>
|
||||
<span class="stat-num">{{ pagination.total }}</span>
|
||||
</div>
|
||||
<GiftOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">积分码</span>
|
||||
<span class="stat-num blue">{{ stats.pointsCount }}</span>
|
||||
</div>
|
||||
<StarOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">VIP码</span>
|
||||
<span class="stat-num orange">{{ stats.vipCount }}</span>
|
||||
</div>
|
||||
<CrownOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">已用完</span>
|
||||
<span class="stat-num red">{{ stats.exhaustedCount }}</span>
|
||||
</div>
|
||||
<CheckCircleOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<div class="filter-bar">
|
||||
<div class="filter-left">
|
||||
<span class="filter-result">共 <b>{{ pagination.total }}</b> 条记录</span>
|
||||
</div>
|
||||
<div class="filter-right">
|
||||
<a-input v-model:value="searchForm.code" placeholder="搜索兑换码" allow-clear style="width: 160px" @input="handleSearch">
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-select v-model:value="searchForm.type" placeholder="类型" allow-clear style="width: 100px" @change="handleSearch">
|
||||
<a-select-option :value="1">积分</a-select-option>
|
||||
<a-select-option :value="2">VIP会员</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 100px" @change="handleSearch">
|
||||
<a-select-option :value="1">启用</a-select-option>
|
||||
<a-select-option :value="0">禁用</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
<a-button type="primary" @click="handleGenerate"><PlusOutlined /> 生成兑换码</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="tableData" :loading="loading" :pagination="pagination" row-key="id" :scroll="{ x: 1200 }" @change="handleTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'code'">
|
||||
<a-typography-text copyable>{{ record.code }}</a-typography-text>
|
||||
</template>
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="record.type === 1 ? 'blue' : 'orange'">{{ record.typeName }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'rewardValue'">
|
||||
<span v-if="record.type === 1">{{ record.rewardValue }} 积分</span>
|
||||
<span v-else>{{ record.rewardValue }} 天 <a-tag v-if="record.vipLevel" size="small">VIP{{ record.vipLevel }}</a-tag></span>
|
||||
</template>
|
||||
<template v-if="column.key === 'usage'">
|
||||
<a-progress :percent="Math.round(record.usedCount / record.totalCount * 100)" :status="record.usedCount >= record.totalCount ? 'success' : 'active'" size="small" />
|
||||
<span class="usage-text">{{ record.usedCount }}/{{ record.totalCount }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'expireTime'">
|
||||
<span :class="{ 'text-red': isExpired(record.expireTime) }">{{ record.expireTime || '永久有效' }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '启用' : '禁用'" />
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space :size="0" split>
|
||||
<a-button type="link" size="small" @click="handleToggleStatus(record)">{{ record.status === 1 ? '禁用' : '启用' }}</a-button>
|
||||
<a-popconfirm title="确定删除该兑换码吗?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger>删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 生成兑换码弹窗 -->
|
||||
<a-modal v-model:open="generateVisible" title="生成兑换码" @ok="handleGenerateSubmit" :confirm-loading="generating" width="520px">
|
||||
<a-form :model="generateForm" :label-col="{ span: 6 }" style="margin-top: 16px">
|
||||
<a-form-item label="兑换类型" required>
|
||||
<a-radio-group v-model:value="generateForm.type">
|
||||
<a-radio :value="1">积分</a-radio>
|
||||
<a-radio :value="2">VIP会员</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="generateForm.type === 1" label="积分数量" required>
|
||||
<a-input-number v-model:value="generateForm.rewardValue" :min="1" style="width: 200px" />
|
||||
<span style="margin-left: 8px; color: #999">积分</span>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="generateForm.type === 2" label="VIP天数" required>
|
||||
<a-input-number v-model:value="generateForm.rewardValue" :min="1" style="width: 200px" />
|
||||
<span style="margin-left: 8px; color: #999">天</span>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="generateForm.type === 2" label="VIP等级">
|
||||
<a-select v-model:value="generateForm.vipLevel" placeholder="选择VIP等级" style="width: 200px">
|
||||
<a-select-option :value="1">VIP1</a-select-option>
|
||||
<a-select-option :value="2">VIP2</a-select-option>
|
||||
<a-select-option :value="3">VIP3</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="生成数量" required>
|
||||
<a-input-number v-model:value="generateForm.count" :min="1" :max="1000" style="width: 200px" />
|
||||
<span style="margin-left: 8px; color: #999">个(最多1000)</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="每码可用次数">
|
||||
<a-input-number v-model:value="generateForm.totalCount" :min="1" style="width: 200px" />
|
||||
<span style="margin-left: 8px; color: #999">次</span>
|
||||
</a-form-item>
|
||||
<a-form-item label="有效期">
|
||||
<a-range-picker v-model:value="generateForm.dateRange" show-time style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="兑换码前缀">
|
||||
<a-input v-model:value="generateForm.prefix" placeholder="可选,如 VIP、GIFT" style="width: 200px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="generateForm.remark" :rows="2" placeholder="可选" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 生成结果弹窗 -->
|
||||
<a-modal v-model:open="resultVisible" title="生成成功" :footer="null" width="500px">
|
||||
<a-alert message="兑换码已生成成功,请妥善保管" type="success" show-icon style="margin-bottom: 16px" />
|
||||
<div class="code-list">
|
||||
<div v-for="code in generatedCodes" :key="code" class="code-item">
|
||||
<a-typography-text copyable>{{ code }}</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: right; margin-top: 16px">
|
||||
<a-button type="primary" @click="handleCopyAll">复制全部</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { GiftOutlined, StarOutlined, CrownOutlined, CheckCircleOutlined, SearchOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { getRedeemCodeList, generateRedeemCodes, deleteRedeemCode, toggleRedeemCodeStatus } from '@/api/config'
|
||||
import { debounce } from 'lodash-es'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const searchForm = reactive({ code: '', type: undefined, status: undefined })
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showQuickJumper: true })
|
||||
const stats = ref({ pointsCount: 0, vipCount: 0, exhaustedCount: 0 })
|
||||
|
||||
const generateVisible = ref(false)
|
||||
const generating = ref(false)
|
||||
const generateForm = reactive({
|
||||
type: 1,
|
||||
rewardValue: 100,
|
||||
vipLevel: undefined,
|
||||
count: 10,
|
||||
totalCount: 1,
|
||||
dateRange: null,
|
||||
prefix: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const resultVisible = ref(false)
|
||||
const generatedCodes = ref([])
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '兑换码', key: 'code', width: 160 },
|
||||
{ title: '类型', key: 'type', width: 80 },
|
||||
{ title: '奖励', key: 'rewardValue', width: 120 },
|
||||
{ title: '使用情况', key: 'usage', width: 140 },
|
||||
{ title: '过期时间', key: 'expireTime', width: 160 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 120, fixed: 'right' }
|
||||
]
|
||||
|
||||
const isExpired = (expireTime) => {
|
||||
if (!expireTime) return false
|
||||
return new Date(expireTime) < new Date()
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getRedeemCodeList({ ...searchForm, page: pagination.current, pageSize: pagination.pageSize })
|
||||
tableData.value = data?.list || []
|
||||
pagination.total = data?.total || 0
|
||||
// 计算统计
|
||||
const list = tableData.value
|
||||
stats.value = {
|
||||
pointsCount: list.filter(c => c.type === 1).length,
|
||||
vipCount: list.filter(c => c.type === 2).length,
|
||||
exhaustedCount: list.filter(c => c.usedCount >= c.totalCount).length
|
||||
}
|
||||
} catch (e) { console.error(e) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = debounce(() => { pagination.current = 1; fetchData() }, 300)
|
||||
const handleReset = () => { Object.assign(searchForm, { code: '', type: undefined, status: undefined }); pagination.current = 1; fetchData() }
|
||||
const handleTableChange = pag => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchData() }
|
||||
|
||||
const handleGenerate = () => {
|
||||
Object.assign(generateForm, { type: 1, rewardValue: 100, vipLevel: undefined, count: 10, totalCount: 1, dateRange: null, prefix: '', remark: '' })
|
||||
generateVisible.value = true
|
||||
}
|
||||
|
||||
const handleGenerateSubmit = async () => {
|
||||
if (!generateForm.rewardValue) { message.warning('请输入奖励值'); return }
|
||||
generating.value = true
|
||||
try {
|
||||
const params = {
|
||||
type: generateForm.type,
|
||||
rewardValue: generateForm.rewardValue,
|
||||
vipLevel: generateForm.type === 2 ? generateForm.vipLevel : undefined,
|
||||
count: generateForm.count,
|
||||
totalCount: generateForm.totalCount,
|
||||
startTime: generateForm.dateRange?.[0]?.format('YYYY-MM-DD HH:mm:ss'),
|
||||
expireTime: generateForm.dateRange?.[1]?.format('YYYY-MM-DD HH:mm:ss'),
|
||||
prefix: generateForm.prefix,
|
||||
remark: generateForm.remark
|
||||
}
|
||||
const codes = await generateRedeemCodes(params)
|
||||
generatedCodes.value = codes || []
|
||||
generateVisible.value = false
|
||||
resultVisible.value = true
|
||||
fetchData()
|
||||
} catch (e) { console.error(e) } finally { generating.value = false }
|
||||
}
|
||||
|
||||
const handleCopyAll = () => {
|
||||
const text = generatedCodes.value.join('\n')
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
message.success('已复制到剪贴板')
|
||||
})
|
||||
}
|
||||
|
||||
const handleToggleStatus = async record => {
|
||||
try {
|
||||
await toggleRedeemCodeStatus(record.id, record.status === 1 ? 0 : 1)
|
||||
message.success('操作成功')
|
||||
fetchData()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
const handleDelete = async record => {
|
||||
try {
|
||||
await deleteRedeemCode(record.id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '@/styles/page-common.less';
|
||||
.usage-text { margin-left: 8px; font-size: 12px; color: #8c8c8c; }
|
||||
.text-red { color: #ff4d4f; }
|
||||
.code-list { max-height: 300px; overflow-y: auto; }
|
||||
.code-item { padding: 8px 12px; background: #f5f5f5; border-radius: 4px; margin-bottom: 8px; font-family: monospace; }
|
||||
</style>
|
||||
108
src/views/config/reward.vue
Normal file
108
src/views/config/reward.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-card title="积分奖励配置">
|
||||
<a-alert
|
||||
message="配置说明"
|
||||
description="设置用户完成对应操作后获得的固定积分奖励数量"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-bottom: 24px"
|
||||
/>
|
||||
|
||||
<a-form
|
||||
:model="formData"
|
||||
:label-col="{ span: 6 }"
|
||||
:wrapper-col="{ span: 12 }"
|
||||
>
|
||||
<a-form-item label="首次注册奖励">
|
||||
<a-input-number
|
||||
v-model:value="formData.registerReward"
|
||||
:min="0"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span style="margin-left: 8px; color: #999">积分</span>
|
||||
<div class="form-tip">新用户首次注册成功后获得的积分奖励</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="首次充值奖励">
|
||||
<a-input-number
|
||||
v-model:value="formData.firstChargeReward"
|
||||
:min="0"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span style="margin-left: 8px; color: #999">积分</span>
|
||||
<div class="form-tip">用户首次充值成功后额外获得的积分奖励</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="拉新成功奖励">
|
||||
<a-input-number
|
||||
v-model:value="formData.inviteReward"
|
||||
:min="0"
|
||||
style="width: 200px"
|
||||
/>
|
||||
<span style="margin-left: 8px; color: #999">积分</span>
|
||||
<div class="form-tip">成功邀请新用户注册后,邀请人获得的积分奖励</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :wrapper-col="{ offset: 6 }">
|
||||
<a-button type="primary" :loading="saving" @click="handleSave">
|
||||
保存配置
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getRewardConfig, updateRewardConfig } from '@/api/config'
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
registerReward: 100,
|
||||
firstChargeReward: 200,
|
||||
inviteReward: 50
|
||||
})
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const data = await getRewardConfig()
|
||||
if (data) {
|
||||
formData.registerReward = data.registerReward || 100
|
||||
formData.firstChargeReward = data.firstChargeReward || 200
|
||||
formData.inviteReward = data.inviteReward || 50
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取奖励配置失败:', error)
|
||||
// 使用默认值
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await updateRewardConfig(formData)
|
||||
message.success('保存成功')
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
197
src/views/config/vip.vue
Normal file
197
src/views/config/vip.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-card>
|
||||
<div class="table-toolbar">
|
||||
<span>VIP套餐管理</span>
|
||||
<a-button type="primary" v-permission="'config:vip:add'" @click="handleAdd">
|
||||
新增套餐
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'price'">
|
||||
<span style="color: #f5222d; font-weight: bold">¥{{ record.price }}</span>
|
||||
<span v-if="record.originalPrice" style="color: #999; text-decoration: line-through; margin-left: 8px">
|
||||
¥{{ record.originalPrice }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '上架' : '下架' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a v-permission="'config:vip:edit'" @click="handleEdit(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确定删除该套餐吗?" @confirm="handleDelete(record)">
|
||||
<a v-permission="'config:vip:delete'" style="color: #ff4d4f">删除</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑套餐' : '新增套餐'"
|
||||
@ok="handleSubmit"
|
||||
:confirm-loading="submitting"
|
||||
width="600px"
|
||||
>
|
||||
<a-form :model="formData" :label-col="{ span: 5 }">
|
||||
<a-form-item label="套餐名称" required>
|
||||
<a-input v-model:value="formData.name" placeholder="请输入套餐名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="VIP等级" required>
|
||||
<a-input-number v-model:value="formData.level" :min="1" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="价格" required>
|
||||
<a-input-number v-model:value="formData.price" :min="0" :precision="2" prefix="¥" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="原价">
|
||||
<a-input-number v-model:value="formData.originalPrice" :min="0" :precision="2" prefix="¥" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="时长(天)" required>
|
||||
<a-input-number v-model:value="formData.duration" :min="1" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="赠送积分">
|
||||
<a-input-number v-model:value="formData.pointsGift" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="每日限制">
|
||||
<a-input-number v-model:value="formData.dailyUsageLimit" :min="-1" style="width: 100%" />
|
||||
<div style="color: #999; font-size: 12px">-1表示无限制</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model:value="formData.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">上架</a-radio>
|
||||
<a-radio :value="0">下架</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getVipPackageList, createVipPackage, updateVipPackage, deleteVipPackage } from '@/api/config'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const modalVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
level: 1,
|
||||
price: 0,
|
||||
originalPrice: null,
|
||||
duration: 30,
|
||||
pointsGift: 0,
|
||||
dailyUsageLimit: -1,
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '套餐名称', dataIndex: 'name' },
|
||||
{ title: 'VIP等级', dataIndex: 'level', width: 100 },
|
||||
{ title: '价格', key: 'price', width: 150 },
|
||||
{ title: '时长(天)', dataIndex: 'duration', width: 100 },
|
||||
{ title: '赠送积分', dataIndex: 'pointsGift', width: 100 },
|
||||
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getVipPackageList()
|
||||
tableData.value = data || []
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
name: '',
|
||||
level: 1,
|
||||
price: 0,
|
||||
originalPrice: null,
|
||||
duration: 30,
|
||||
pointsGift: 0,
|
||||
dailyUsageLimit: -1,
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
currentId.value = null
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = record => {
|
||||
isEdit.value = true
|
||||
currentId.value = record.id
|
||||
Object.assign(formData, record)
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || !formData.price) {
|
||||
message.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateVipPackage(currentId.value, formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createVipPackage(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async record => {
|
||||
try {
|
||||
await deleteVipPackage(record.id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
661
src/views/dashboard/index.vue
Normal file
661
src/views/dashboard/index.vue
Normal file
@@ -0,0 +1,661 @@
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="[16, 16]">
|
||||
<a-col :xs="12" :sm="8" :md="8" :lg="4">
|
||||
<div class="stat-card card-blue" @click="$router.push('/user/list')">
|
||||
<div class="card-content">
|
||||
<div class="stat-info">
|
||||
<span class="stat-title">用户总数</span>
|
||||
<span class="stat-value">{{ stats.userCount }}</span>
|
||||
</div>
|
||||
<div class="stat-icon">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span>查看详情</span>
|
||||
<RightOutlined />
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="8" :lg="4">
|
||||
<div class="stat-card card-green" @click="$router.push('/user/list')">
|
||||
<div class="card-content">
|
||||
<div class="stat-info">
|
||||
<span class="stat-title">今日新增</span>
|
||||
<span class="stat-value">{{ stats.todayNewUsers }}</span>
|
||||
</div>
|
||||
<div class="stat-icon">
|
||||
<UserAddOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span>查看详情</span>
|
||||
<RightOutlined />
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="8" :lg="4">
|
||||
<div class="stat-card card-purple" @click="$router.push('/order/list')">
|
||||
<div class="card-content">
|
||||
<div class="stat-info">
|
||||
<span class="stat-title">今日订单</span>
|
||||
<span class="stat-value">{{ stats.todayOrderCount }}</span>
|
||||
</div>
|
||||
<div class="stat-icon">
|
||||
<ShoppingOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span>查看详情</span>
|
||||
<RightOutlined />
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="8" :lg="4">
|
||||
<div class="stat-card card-orange" @click="$router.push('/order/list')">
|
||||
<div class="card-content">
|
||||
<div class="stat-info">
|
||||
<span class="stat-title">今日金额</span>
|
||||
<span class="stat-value">¥{{ stats.todayOrderAmount?.toFixed(2) || '0.00' }}</span>
|
||||
</div>
|
||||
<div class="stat-icon">
|
||||
<DollarOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span>查看详情</span>
|
||||
<RightOutlined />
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="8" :lg="4">
|
||||
<div class="stat-card card-cyan" @click="$router.push('/work/list')">
|
||||
<div class="card-content">
|
||||
<div class="stat-info">
|
||||
<span class="stat-title">作品总数</span>
|
||||
<span class="stat-value">{{ stats.workCount }}</span>
|
||||
</div>
|
||||
<div class="stat-icon">
|
||||
<PictureOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span>查看详情</span>
|
||||
<RightOutlined />
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="8" :md="8" :lg="4">
|
||||
<div class="stat-card card-pink" @click="$router.push('/work/audit')">
|
||||
<div class="card-content">
|
||||
<div class="stat-info">
|
||||
<span class="stat-title">待审核</span>
|
||||
<span class="stat-value">{{ stats.pendingAudit }}</span>
|
||||
</div>
|
||||
<div class="stat-icon">
|
||||
<AuditOutlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span>立即处理</span>
|
||||
<RightOutlined />
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 图表和快捷操作 -->
|
||||
<a-row :gutter="[16, 16]" style="margin-top: 16px">
|
||||
<a-col :xs="24" :lg="16">
|
||||
<a-card title="近7天数据趋势" :bordered="false" class="chart-card" :loading="chartLoading">
|
||||
<div v-if="hasChartData" ref="chartRef" class="chart-container"></div>
|
||||
<a-empty v-else description="暂无数据" class="empty-chart" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="8">
|
||||
<a-card title="快捷操作" :bordered="false" class="action-card">
|
||||
<div class="quick-actions">
|
||||
<div class="action-item" @click="$router.push('/order/list')">
|
||||
<div class="action-icon order-bg">
|
||||
<ShoppingOutlined />
|
||||
</div>
|
||||
<div class="action-text">
|
||||
<span class="action-title">订单管理</span>
|
||||
<span class="action-desc">查看所有订单</span>
|
||||
</div>
|
||||
<RightOutlined class="action-arrow" />
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/work/audit')">
|
||||
<div class="action-icon audit-bg">
|
||||
<AuditOutlined />
|
||||
</div>
|
||||
<div class="action-text">
|
||||
<span class="action-title">作品审核</span>
|
||||
<span class="action-desc">{{ stats.pendingAudit }}条待审核</span>
|
||||
</div>
|
||||
<RightOutlined class="action-arrow" />
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/user/list')">
|
||||
<div class="action-icon user-bg">
|
||||
<TeamOutlined />
|
||||
</div>
|
||||
<div class="action-text">
|
||||
<span class="action-title">用户管理</span>
|
||||
<span class="action-desc">管理所有用户</span>
|
||||
</div>
|
||||
<RightOutlined class="action-arrow" />
|
||||
</div>
|
||||
<div class="action-item" @click="$router.push('/config/reward')">
|
||||
<div class="action-icon reward-bg">
|
||||
<GiftOutlined />
|
||||
</div>
|
||||
<div class="action-text">
|
||||
<span class="action-title">奖励配置</span>
|
||||
<span class="action-desc">设置推广奖励</span>
|
||||
</div>
|
||||
<RightOutlined class="action-arrow" />
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 最近订单和系统信息 -->
|
||||
<a-row :gutter="[16, 16]" style="margin-top: 16px">
|
||||
<a-col :xs="24" :lg="14">
|
||||
<a-card title="最近订单" :bordered="false" class="order-card" :loading="orderLoading">
|
||||
<template #extra>
|
||||
<a @click="$router.push('/order/list')">查看全部 <RightOutlined /></a>
|
||||
</template>
|
||||
<a-table
|
||||
v-if="recentOrders.length"
|
||||
:columns="orderColumns"
|
||||
:data-source="recentOrders"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
:scroll="{ x: 500 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="record.type === 1 ? 'blue' : 'green'" size="small">
|
||||
{{ record.type === 1 ? 'VIP' : '积分' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'amount'">
|
||||
<span class="amount">¥{{ (record.amount || 0).toFixed(2) }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="getOrderStatusBadge(record.status)" :text="getOrderStatusText(record.status)" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
<a-empty v-else description="暂无订单数据" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :lg="10">
|
||||
<a-card title="系统状态" :bordered="false" class="system-card">
|
||||
<template #extra>
|
||||
<a-button type="link" size="small" @click="checkHealth" :loading="healthLoading">
|
||||
<ReloadOutlined /> 刷新
|
||||
</a-button>
|
||||
</template>
|
||||
<div class="system-info">
|
||||
<div class="info-row">
|
||||
<span class="info-label">系统版本</span>
|
||||
<a-tag color="blue">v1.0.0</a-tag>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">前端框架</span>
|
||||
<span class="info-value">Vue 3 + Vite</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">后端框架</span>
|
||||
<span class="info-value">Spring Boot 3.2</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">数据库</span>
|
||||
<span class="info-value">MySQL 8.0</span>
|
||||
</div>
|
||||
<a-divider style="margin: 12px 0" />
|
||||
<div class="health-section">
|
||||
<div class="health-title">
|
||||
<ApiOutlined /> 接口健康检测
|
||||
</div>
|
||||
<div class="health-list">
|
||||
<div class="health-item" v-for="item in healthChecks" :key="item.name">
|
||||
<span class="health-name">{{ item.name }}</span>
|
||||
<span class="health-status">
|
||||
<a-badge v-if="item.status === 'checking'" status="processing" text="检测中" />
|
||||
<a-badge v-else-if="item.status === 'ok'" status="success" :text="`正常 ${item.time}ms`" />
|
||||
<a-badge v-else status="error" text="异常" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { Chart } from '@antv/g2'
|
||||
import {
|
||||
ShoppingOutlined,
|
||||
AuditOutlined,
|
||||
TeamOutlined,
|
||||
GiftOutlined,
|
||||
UserAddOutlined,
|
||||
DollarOutlined,
|
||||
PictureOutlined,
|
||||
RightOutlined,
|
||||
ReloadOutlined,
|
||||
ApiOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const chartRef = ref(null)
|
||||
let chart = null
|
||||
|
||||
const loading = ref(false)
|
||||
const chartLoading = ref(false)
|
||||
const orderLoading = ref(false)
|
||||
const healthLoading = ref(false)
|
||||
|
||||
const stats = ref({
|
||||
userCount: 0,
|
||||
todayNewUsers: 0,
|
||||
todayOrderCount: 0,
|
||||
todayOrderAmount: 0,
|
||||
workCount: 0,
|
||||
pendingAudit: 0
|
||||
})
|
||||
|
||||
const trendData = ref([])
|
||||
const recentOrders = ref([])
|
||||
|
||||
const healthChecks = ref([
|
||||
{ name: 'Dashboard API', url: '/admin/dashboard/stats', status: 'checking', time: 0 },
|
||||
{ name: '订单服务', url: '/admin/order/list?page=1&pageSize=1', status: 'checking', time: 0 },
|
||||
{ name: '用户服务', url: '/admin/system/admin/list?page=1&pageSize=1', status: 'checking', time: 0 }
|
||||
])
|
||||
|
||||
const hasChartData = computed(() => trendData.value.length > 0)
|
||||
|
||||
const orderColumns = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 150, ellipsis: true },
|
||||
{ title: '用户', dataIndex: 'username', width: 80, ellipsis: true },
|
||||
{ title: '类型', key: 'type', width: 70, align: 'center' },
|
||||
{ title: '金额', key: 'amount', width: 90, align: 'right' },
|
||||
{ title: '状态', key: 'status', width: 90 }
|
||||
]
|
||||
|
||||
const getOrderStatusBadge = (status) => {
|
||||
const map = { 0: 'default', 1: 'success', 2: 'error', 3: 'warning' }
|
||||
return map[status] || 'default'
|
||||
}
|
||||
|
||||
const getOrderStatusText = (status) => {
|
||||
const map = { 0: '待支付', 1: '已支付', 2: '已取消', 3: '已退款' }
|
||||
return map[status] || '未知'
|
||||
}
|
||||
|
||||
const initChart = () => {
|
||||
if (!chartRef.value || !hasChartData.value) return
|
||||
|
||||
chart = new Chart({
|
||||
container: chartRef.value,
|
||||
autoFit: true,
|
||||
padding: [20, 20, 40, 40]
|
||||
})
|
||||
|
||||
chart
|
||||
.data(trendData.value)
|
||||
.encode('x', 'date')
|
||||
.encode('y', 'value')
|
||||
.encode('color', 'type')
|
||||
.scale('color', { range: ['#1890ff', '#52c41a'] })
|
||||
.axis('y', { title: false, grid: true })
|
||||
.axis('x', { title: false })
|
||||
.legend({ position: 'top-right' })
|
||||
|
||||
chart.line().encode('shape', 'smooth').style('lineWidth', 2)
|
||||
chart.point().encode('shape', 'circle').style('fill', '#fff').style('lineWidth', 2)
|
||||
chart.area().encode('shape', 'smooth').style('fillOpacity', 0.1)
|
||||
|
||||
chart.render()
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await request.get('/admin/dashboard/stats')
|
||||
if (data) stats.value = { ...stats.value, ...data }
|
||||
} catch (e) {
|
||||
console.error('获取统计数据失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTrend = async () => {
|
||||
chartLoading.value = true
|
||||
try {
|
||||
const data = await request.get('/admin/dashboard/trend')
|
||||
if (data?.length) trendData.value = data
|
||||
} catch (e) {
|
||||
console.error('获取趋势数据失败', e)
|
||||
} finally {
|
||||
chartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRecentOrders = async () => {
|
||||
orderLoading.value = true
|
||||
try {
|
||||
const data = await request.get('/admin/dashboard/recent-orders')
|
||||
if (data?.length) recentOrders.value = data
|
||||
} catch (e) {
|
||||
console.error('获取最近订单失败', e)
|
||||
} finally {
|
||||
orderLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const checkHealth = async () => {
|
||||
healthLoading.value = true
|
||||
healthChecks.value.forEach(item => item.status = 'checking')
|
||||
|
||||
for (const item of healthChecks.value) {
|
||||
const start = Date.now()
|
||||
try {
|
||||
await request.get(item.url)
|
||||
item.time = Date.now() - start
|
||||
item.status = 'ok'
|
||||
} catch (e) {
|
||||
item.time = Date.now() - start
|
||||
item.status = 'error'
|
||||
}
|
||||
}
|
||||
healthLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchStats(), fetchTrend(), fetchRecentOrders()])
|
||||
await nextTick()
|
||||
initChart()
|
||||
checkHealth()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.dashboard {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
// 统计卡片样式
|
||||
.stat-card {
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.card-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 36px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.15);
|
||||
padding-top: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
&.card-blue { background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); }
|
||||
&.card-green { background: linear-gradient(135deg, #52c41a 0%, #389e0d 100%); }
|
||||
&.card-purple { background: linear-gradient(135deg, #722ed1 0%, #531dab 100%); }
|
||||
&.card-orange { background: linear-gradient(135deg, #fa8c16 0%, #d46b08 100%); }
|
||||
&.card-cyan { background: linear-gradient(135deg, #13c2c2 0%, #08979c 100%); }
|
||||
&.card-pink { background: linear-gradient(135deg, #eb2f96 0%, #c41d7f 100%); }
|
||||
}
|
||||
|
||||
// 图表卡片
|
||||
.chart-card {
|
||||
.chart-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.empty-chart {
|
||||
height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷操作卡片
|
||||
.action-card {
|
||||
height: 100%;
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: #f8f9fc;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s ease;
|
||||
|
||||
&:hover {
|
||||
background: #f0f5ff;
|
||||
transform: translateX(4px);
|
||||
|
||||
.action-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #fff;
|
||||
margin-right: 14px;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.order-bg { background: linear-gradient(135deg, #722ed1, #531dab); }
|
||||
&.audit-bg { background: linear-gradient(135deg, #eb2f96, #c41d7f); }
|
||||
&.user-bg { background: linear-gradient(135deg, #1890ff, #096dd9); }
|
||||
&.reward-bg { background: linear-gradient(135deg, #fa8c16, #d46b08); }
|
||||
}
|
||||
|
||||
.action-text {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.action-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.action-desc {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-arrow {
|
||||
color: #bfbfbf;
|
||||
opacity: 0;
|
||||
transform: translateX(-8px);
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 订单卡片
|
||||
.order-card {
|
||||
:deep(.ant-card-extra) {
|
||||
a {
|
||||
color: #1890ff;
|
||||
font-size: 13px;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #f5222d;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 系统信息卡片
|
||||
.system-card {
|
||||
height: 100%;
|
||||
|
||||
.system-info {
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #8c8c8c;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #262626;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.health-section {
|
||||
.health-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.health-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.health-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #fafafa;
|
||||
border-radius: 6px;
|
||||
|
||||
.health-name {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.health-status {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.stat-card {
|
||||
height: 120px;
|
||||
|
||||
.stat-value {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-card, .action-card, .order-card, .system-card {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
13
src/views/error/403.vue
Normal file
13
src/views/error/403.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<a-result
|
||||
status="403"
|
||||
title="403"
|
||||
sub-title="抱歉,您没有权限访问此页面"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="$router.push('/')">
|
||||
返回首页
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</template>
|
||||
13
src/views/error/404.vue
Normal file
13
src/views/error/404.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<a-result
|
||||
status="404"
|
||||
title="404"
|
||||
sub-title="抱歉,您访问的页面不存在"
|
||||
>
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="$router.push('/')">
|
||||
返回首页
|
||||
</a-button>
|
||||
</template>
|
||||
</a-result>
|
||||
</template>
|
||||
402
src/views/login/index.vue
Normal file
402
src/views/login/index.vue
Normal file
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<!-- 左侧展示区 -->
|
||||
<div class="login-left">
|
||||
<div class="left-content">
|
||||
<div class="illustration">
|
||||
<img src="@/assets/images/loginbg.svg" alt="login background" class="login-bg-img" />
|
||||
</div>
|
||||
<h1 class="system-title">1818AI 管理系统</h1>
|
||||
<p class="system-desc">智能、高效的AI创作平台管理解决方案</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧登录区 -->
|
||||
<div class="login-right">
|
||||
<div class="login-box">
|
||||
<h2 class="welcome-title">欢迎回来</h2>
|
||||
<p class="welcome-desc">请登录您的账户</p>
|
||||
|
||||
<!-- 登录方式切换 -->
|
||||
<div class="login-tabs">
|
||||
<a-button
|
||||
:type="loginType === 'account' ? 'primary' : 'default'"
|
||||
@click="loginType = 'account'"
|
||||
>
|
||||
账号登录
|
||||
</a-button>
|
||||
<a-button
|
||||
:type="loginType === 'email' ? 'primary' : 'default'"
|
||||
@click="loginType = 'email'"
|
||||
>
|
||||
邮箱登录
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<!-- 账号密码登录 -->
|
||||
<a-form
|
||||
v-if="loginType === 'account'"
|
||||
:model="loginForm"
|
||||
:rules="rules"
|
||||
@finish="handleLogin"
|
||||
class="login-form"
|
||||
>
|
||||
<a-form-item name="username">
|
||||
<a-input
|
||||
v-model:value="loginForm.username"
|
||||
size="large"
|
||||
placeholder="请输入用户名"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserOutlined style="color: #bfbfbf" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="password">
|
||||
<a-input-password
|
||||
v-model:value="loginForm.password"
|
||||
size="large"
|
||||
placeholder="请输入密码"
|
||||
>
|
||||
<template #prefix>
|
||||
<LockOutlined style="color: #bfbfbf" />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<div class="form-options">
|
||||
<a-checkbox v-model:checked="rememberMe">记住我</a-checkbox>
|
||||
<a class="forgot-link">忘记密码?</a>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
class="login-btn"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<!-- 邮箱验证码登录 -->
|
||||
<a-form
|
||||
v-else
|
||||
:model="emailForm"
|
||||
:rules="emailRules"
|
||||
@finish="handleEmailLogin"
|
||||
class="login-form"
|
||||
>
|
||||
<a-form-item name="email">
|
||||
<a-input
|
||||
v-model:value="emailForm.email"
|
||||
size="large"
|
||||
placeholder="请输入邮箱地址"
|
||||
>
|
||||
<template #prefix>
|
||||
<MailOutlined style="color: #bfbfbf" />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="code">
|
||||
<div class="code-input">
|
||||
<a-input
|
||||
v-model:value="emailForm.code"
|
||||
size="large"
|
||||
placeholder="请输入验证码"
|
||||
>
|
||||
<template #prefix>
|
||||
<SafetyOutlined style="color: #bfbfbf" />
|
||||
</template>
|
||||
</a-input>
|
||||
<a-button
|
||||
size="large"
|
||||
:disabled="countdown > 0 || sendingCode"
|
||||
:loading="sendingCode"
|
||||
@click="sendCode"
|
||||
>
|
||||
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
size="large"
|
||||
block
|
||||
:loading="loading"
|
||||
class="login-btn"
|
||||
>
|
||||
登录
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>© 2024 1818AI. All rights reserved.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
MailOutlined,
|
||||
SafetyOutlined
|
||||
} from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { sendEmailCode } from '@/api/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const sendingCode = ref(false)
|
||||
const loginType = ref('email')
|
||||
const rememberMe = ref(true)
|
||||
const countdown = ref(0)
|
||||
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const emailForm = reactive({
|
||||
email: '',
|
||||
code: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名' }],
|
||||
password: [{ required: true, message: '请输入密码' }]
|
||||
}
|
||||
|
||||
const emailRules = {
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址' },
|
||||
{ type: 'email', message: '邮箱格式不正确' }
|
||||
],
|
||||
code: [{ required: true, message: '请输入验证码' }]
|
||||
}
|
||||
|
||||
const handleLogin = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(loginForm)
|
||||
message.success('登录成功')
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.push(redirect)
|
||||
} catch (error) {
|
||||
// 错误已在request中处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEmailLogin = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.loginByEmail(emailForm)
|
||||
message.success('登录成功')
|
||||
const redirect = route.query.redirect || '/'
|
||||
router.push(redirect)
|
||||
} catch (error) {
|
||||
// 错误已在request中处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const sendCode = async () => {
|
||||
if (!emailForm.email) {
|
||||
message.warning('请输入邮箱地址')
|
||||
return
|
||||
}
|
||||
// 简单邮箱格式验证
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(emailForm.email)) {
|
||||
message.warning('邮箱格式不正确')
|
||||
return
|
||||
}
|
||||
|
||||
sendingCode.value = true
|
||||
try {
|
||||
await sendEmailCode({ email: emailForm.email })
|
||||
message.success('验证码已发送,请查收邮件')
|
||||
countdown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
// 错误已在request中处理
|
||||
} finally {
|
||||
sendingCode.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.login-left {
|
||||
flex: 1;
|
||||
background: linear-gradient(135deg, #e8f4fc 0%, #d6e9f8 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.left-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
margin-bottom: 40px;
|
||||
|
||||
.login-bg-img {
|
||||
width: 320px;
|
||||
height: 280px;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.system-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.system-desc {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.login-right {
|
||||
width: 480px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px;
|
||||
box-shadow: -4px 0 20px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.login-box {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-desc {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
:deep(.ant-btn) {
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-form {
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
:deep(.ant-input-affix-wrapper-lg) {
|
||||
padding: 12px 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
:deep(.ant-input-affix-wrapper) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.ant-btn) {
|
||||
width: 120px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 40px;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.login-left {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.login-right {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
287
src/views/order/list.vue
Normal file
287
src/views/order/list.vue
Normal file
@@ -0,0 +1,287 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 总览卡片 -->
|
||||
<a-row :gutter="[16, 16]" class="stat-row">
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">订单总数</span>
|
||||
<span class="stat-num">{{ orderStats.total }}</span>
|
||||
</div>
|
||||
<ShoppingOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">已支付</span>
|
||||
<span class="stat-num green">{{ orderStats.paid }}</span>
|
||||
</div>
|
||||
<CheckCircleOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">待支付</span>
|
||||
<span class="stat-num orange">{{ orderStats.pending }}</span>
|
||||
</div>
|
||||
<ClockCircleOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">总金额</span>
|
||||
<span class="stat-num blue">¥{{ totalAmount.toFixed(2) }}</span>
|
||||
</div>
|
||||
<DollarOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-left">
|
||||
<span class="filter-result">共 <b>{{ pagination.total }}</b> 条记录</span>
|
||||
</div>
|
||||
<div class="filter-right">
|
||||
<a-input
|
||||
v-model:value="searchForm.orderNo"
|
||||
placeholder="搜索订单号"
|
||||
allow-clear
|
||||
style="width: 180px"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-input
|
||||
v-model:value="searchForm.username"
|
||||
placeholder="用户名"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="searchForm.type"
|
||||
placeholder="类型"
|
||||
allow-clear
|
||||
style="width: 110px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option :value="1">VIP充值</a-select-option>
|
||||
<a-select-option :value="2">积分充值</a-select-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model:value="searchForm.status"
|
||||
placeholder="状态"
|
||||
allow-clear
|
||||
style="width: 100px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option :value="0">待支付</a-select-option>
|
||||
<a-select-option :value="1">已支付</a-select-option>
|
||||
<a-select-option :value="2">已取消</a-select-option>
|
||||
<a-select-option :value="3">已退款</a-select-option>
|
||||
</a-select>
|
||||
<a-range-picker v-model:value="searchForm.dateRange" style="width: 220px" @change="handleSearch" />
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
:scroll="{ x: 900 }"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'orderNo'">
|
||||
<span class="order-no">{{ record.orderNo }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="record.type === 1 ? 'blue' : 'green'" size="small">
|
||||
{{ record.type === 1 ? 'VIP' : '积分' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'amount'">
|
||||
<span class="amount">¥{{ record.amount?.toFixed(2) }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="getStatusBadge(record.status)" :text="getStatusText(record.status)" />
|
||||
</template>
|
||||
<template v-if="column.key === 'createdAt'">
|
||||
<span class="time">{{ formatTime(record.createdAt) }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="handleDetail(record)">
|
||||
<EyeOutlined /> 详情
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 订单详情弹窗 -->
|
||||
<a-modal v-model:open="detailVisible" title="订单详情" :footer="null" width="640px">
|
||||
<a-descriptions :column="2" bordered size="small" class="detail-desc">
|
||||
<a-descriptions-item label="订单号" :span="2">
|
||||
<span class="order-no">{{ currentOrder.orderNo }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="用户">{{ currentOrder.username || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="用户ID">{{ currentOrder.userId }}</a-descriptions-item>
|
||||
<a-descriptions-item label="订单类型">
|
||||
<a-tag :color="currentOrder.type === 1 ? 'blue' : 'green'">
|
||||
{{ currentOrder.type === 1 ? 'VIP充值' : '积分充值' }}
|
||||
</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="订单金额">
|
||||
<span class="amount">¥{{ currentOrder.amount?.toFixed(2) }}</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="商品名称" :span="2">{{ currentOrder.productName || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="订单状态">
|
||||
<a-badge :status="getStatusBadge(currentOrder.status)" :text="getStatusText(currentOrder.status)" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="支付方式">{{ currentOrder.payMethod || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ formatTime(currentOrder.createdAt) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="支付时间">{{ formatTime(currentOrder.paidAt) || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="备注" :span="2">{{ currentOrder.remark || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ShoppingOutlined, CheckCircleOutlined, ClockCircleOutlined, DollarOutlined, SearchOutlined, EyeOutlined } from '@ant-design/icons-vue'
|
||||
import { getOrderList } from '@/api/order'
|
||||
import { debounce } from 'lodash-es'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const totalAmount = ref(0)
|
||||
const detailVisible = ref(false)
|
||||
const currentOrder = ref({})
|
||||
const orderStats = ref({ total: 0, paid: 0, pending: 0 })
|
||||
|
||||
const searchForm = reactive({
|
||||
orderNo: '', username: '', type: undefined, status: undefined, dateRange: null
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
current: 1, pageSize: 10, total: 0, showSizeChanger: true, showQuickJumper: true
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', key: 'orderNo', width: 170 },
|
||||
{ title: '用户', dataIndex: 'username', width: 100, ellipsis: true },
|
||||
{ title: '类型', key: 'type', width: 80, align: 'center' },
|
||||
{ title: '商品', dataIndex: 'productName', ellipsis: true },
|
||||
{ title: '金额', key: 'amount', width: 100, align: 'right' },
|
||||
{ title: '状态', key: 'status', width: 90 },
|
||||
{ title: '创建时间', key: 'createdAt', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 80, align: 'center', fixed: 'right' }
|
||||
]
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const map = { 0: 'default', 1: 'success', 2: 'error', 3: 'warning' }
|
||||
return map[status] || 'default'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = { 0: '待支付', 1: '已支付', 2: '已取消', 3: '已退款' }
|
||||
return map[status] || '未知'
|
||||
}
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return ''
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
orderNo: searchForm.orderNo || undefined,
|
||||
username: searchForm.username || undefined,
|
||||
type: searchForm.type,
|
||||
status: searchForm.status,
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
}
|
||||
if (searchForm.dateRange?.length === 2) {
|
||||
params.startDate = searchForm.dateRange[0].format('YYYY-MM-DD')
|
||||
params.endDate = searchForm.dateRange[1].format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
const data = await getOrderList(params)
|
||||
tableData.value = data?.list || []
|
||||
pagination.total = data?.total || 0
|
||||
totalAmount.value = data?.totalAmount || 0
|
||||
if (data?.stats) orderStats.value = data.stats
|
||||
} catch (e) {
|
||||
console.error('获取订单列表失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = debounce(() => {
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}, 300)
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, {
|
||||
orderNo: '', username: '', type: undefined, status: undefined, dateRange: null
|
||||
})
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleTableChange = (pag) => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleDetail = (record) => {
|
||||
currentOrder.value = record
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '@/styles/page-common.less';
|
||||
|
||||
.order-no {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #f5222d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
width: 100px;
|
||||
background: #fafafa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
280
src/views/system/admin.vue
Normal file
280
src/views/system/admin.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 总览卡片 -->
|
||||
<a-row :gutter="[16, 16]" class="stat-row">
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">管理员总数</span>
|
||||
<span class="stat-num">{{ stats.total }}</span>
|
||||
</div>
|
||||
<UserOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">正常状态</span>
|
||||
<span class="stat-num green">{{ stats.active }}</span>
|
||||
</div>
|
||||
<CheckCircleOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">禁用状态</span>
|
||||
<span class="stat-num red">{{ stats.disabled }}</span>
|
||||
</div>
|
||||
<StopOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">角色数量</span>
|
||||
<span class="stat-num blue">{{ allRoles.length }}</span>
|
||||
</div>
|
||||
<SafetyOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-left">
|
||||
<span class="filter-result">共 <b>{{ pagination.total }}</b> 条记录</span>
|
||||
</div>
|
||||
<div class="filter-right">
|
||||
<a-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="搜索用户名/姓名"
|
||||
allow-clear
|
||||
style="width: 180px"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-select
|
||||
v-model:value="searchForm.status"
|
||||
placeholder="状态"
|
||||
allow-clear
|
||||
style="width: 100px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option :value="1">正常</a-select-option>
|
||||
<a-select-option :value="0">禁用</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
<a-button type="primary" v-permission="'system:admin:add'" @click="handleAdd">
|
||||
<PlusOutlined /> 新增管理员
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
:scroll="{ x: 1000 }"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'roles'">
|
||||
<a-tag v-for="role in record.roles" :key="role.id" color="blue" size="small">
|
||||
{{ role.name }}
|
||||
</a-tag>
|
||||
<span v-if="!record.roles?.length" style="color: #999">-</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '正常' : '禁用'" />
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space :size="0" split>
|
||||
<a-button type="link" size="small" v-permission="'system:admin:edit'" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-popconfirm title="确定删除该管理员吗?" @confirm="handleDelete(record)">
|
||||
<a-button type="link" size="small" danger v-permission="'system:admin:delete'">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal v-model:open="modalVisible" :title="isEdit ? '编辑管理员' : '新增管理员'" @ok="handleSubmit" :confirm-loading="submitting" width="560px">
|
||||
<a-form :model="formData" :label-col="{ span: 5 }" style="margin-top: 16px">
|
||||
<a-form-item label="用户名" required>
|
||||
<a-input v-model:value="formData.username" :disabled="isEdit" placeholder="请输入用户名" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="!isEdit" label="密码" required>
|
||||
<a-input-password v-model:value="formData.password" placeholder="请输入密码" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="isEdit" label="修改密码">
|
||||
<a-input-password v-model:value="formData.password" placeholder="留空则不修改密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="真实姓名">
|
||||
<a-input v-model:value="formData.realName" placeholder="请输入真实姓名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号">
|
||||
<a-input v-model:value="formData.phone" placeholder="请输入手机号" />
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱">
|
||||
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色" required>
|
||||
<a-select v-model:value="formData.roleIds" mode="multiple" placeholder="请选择角色">
|
||||
<a-select-option v-for="role in allRoles" :key="role.id" :value="role.id">{{ role.name }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">正常</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { UserOutlined, CheckCircleOutlined, StopOutlined, SafetyOutlined, SearchOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { getAdminList, createAdmin, updateAdmin, deleteAdmin, getAllRoles } from '@/api/system'
|
||||
import { debounce } from 'lodash-es'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const allRoles = ref([])
|
||||
const searchForm = reactive({ keyword: '', status: undefined })
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showQuickJumper: true })
|
||||
const stats = ref({ total: 0, active: 0, disabled: 0 })
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
username: '', password: '', realName: '', phone: '', email: '', roleIds: [], status: 1
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '用户名', dataIndex: 'username', width: 120 },
|
||||
{ title: '真实姓名', dataIndex: 'realName', width: 100 },
|
||||
{ title: '邮箱', dataIndex: 'email', ellipsis: true },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||
{ title: '角色', key: 'roles', width: 150 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '最后登录', dataIndex: 'lastLoginTime', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 180, fixed: 'right' }
|
||||
]
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
const data = await getAllRoles()
|
||||
allRoles.value = data || []
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getAdminList({
|
||||
...searchForm,
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
tableData.value = data?.list || []
|
||||
pagination.total = data?.total || 0
|
||||
if (data?.stats) stats.value = data.stats
|
||||
} catch (error) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = debounce(() => {
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}, 300)
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { keyword: '', status: undefined })
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleTableChange = pag => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, { username: '', password: '', realName: '', phone: '', email: '', roleIds: [], status: 1 })
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
currentId.value = null
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = record => {
|
||||
isEdit.value = true
|
||||
currentId.value = record.id
|
||||
Object.assign(formData, {
|
||||
username: record.username,
|
||||
password: '', // 编辑时密码留空
|
||||
realName: record.realName,
|
||||
phone: record.phone,
|
||||
email: record.email,
|
||||
roleIds: record.roles?.map(r => r.id) || [],
|
||||
status: record.status
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.username) { message.warning('请输入用户名'); return }
|
||||
if (!isEdit.value && !formData.password) { message.warning('请输入密码'); return }
|
||||
if (!formData.roleIds?.length) { message.warning('请选择角色'); return }
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateAdmin(currentId.value, formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createAdmin(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {}
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
const handleDelete = async record => {
|
||||
try {
|
||||
await deleteAdmin(record.id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoles()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '@/styles/page-common.less';
|
||||
</style>
|
||||
234
src/views/system/permission.vue
Normal file
234
src/views/system/permission.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-card>
|
||||
<div class="table-toolbar">
|
||||
<span>权限管理</span>
|
||||
<a-button type="primary" v-permission="'system:permission:add'" @click="handleAdd(null)">
|
||||
新增权限
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
:default-expand-all-rows="true"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'type'">
|
||||
<a-tag :color="typeMap[record.type]?.color">
|
||||
{{ typeMap[record.type]?.text }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a v-permission="'system:permission:add'" @click="handleAdd(record)">新增子级</a>
|
||||
<a-divider type="vertical" />
|
||||
<a v-permission="'system:permission:edit'" @click="handleEdit(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确定删除该权限吗?" @confirm="handleDelete(record)">
|
||||
<a v-permission="'system:permission:delete'" style="color: #ff4d4f">删除</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑权限' : '新增权限'"
|
||||
@ok="handleSubmit"
|
||||
:confirm-loading="submitting"
|
||||
width="600px"
|
||||
>
|
||||
<a-form :model="formData" :label-col="{ span: 5 }">
|
||||
<a-form-item label="权限类型" required>
|
||||
<a-radio-group v-model:value="formData.type">
|
||||
<a-radio :value="1">目录</a-radio>
|
||||
<a-radio :value="2">菜单</a-radio>
|
||||
<a-radio :value="3">按钮</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="权限名称" required>
|
||||
<a-input v-model:value="formData.name" placeholder="请输入权限名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="权限编码" required>
|
||||
<a-input v-model:value="formData.code" placeholder="请输入权限编码,如 user:add" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formData.type !== 3" label="路由路径">
|
||||
<a-input v-model:value="formData.path" placeholder="请输入路由路径" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formData.type === 2" label="组件路径">
|
||||
<a-input v-model:value="formData.component" placeholder="请输入组件路径" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="formData.type !== 3" label="图标">
|
||||
<a-input v-model:value="formData.icon" placeholder="请输入图标名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model:value="formData.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">正常</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getPermissionTree, createPermission, updatePermission, deletePermission } from '@/api/system'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
|
||||
const typeMap = {
|
||||
1: { text: '目录', color: 'blue' },
|
||||
2: { text: '菜单', color: 'green' },
|
||||
3: { text: '按钮', color: 'orange' }
|
||||
}
|
||||
|
||||
const formData = reactive({
|
||||
parentId: 0,
|
||||
type: 2,
|
||||
name: '',
|
||||
code: '',
|
||||
path: '',
|
||||
component: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: '权限名称', dataIndex: 'name', width: 200 },
|
||||
{ title: '权限编码', dataIndex: 'code' },
|
||||
{ title: '类型', key: 'type', width: 80 },
|
||||
{ title: '路由路径', dataIndex: 'path' },
|
||||
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 200 }
|
||||
]
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getPermissionTree()
|
||||
tableData.value = data || []
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
parentId: 0,
|
||||
type: 2,
|
||||
name: '',
|
||||
code: '',
|
||||
path: '',
|
||||
component: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
}
|
||||
|
||||
const handleAdd = parent => {
|
||||
isEdit.value = false
|
||||
currentId.value = null
|
||||
resetForm()
|
||||
if (parent) {
|
||||
formData.parentId = parent.id
|
||||
}
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = record => {
|
||||
isEdit.value = true
|
||||
currentId.value = record.id
|
||||
Object.assign(formData, {
|
||||
parentId: record.parentId,
|
||||
type: record.type,
|
||||
name: record.name,
|
||||
code: record.code,
|
||||
path: record.path,
|
||||
component: record.component,
|
||||
icon: record.icon,
|
||||
sort: record.sort,
|
||||
status: record.status
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || !formData.code) {
|
||||
message.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updatePermission(currentId.value, formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createPermission(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async record => {
|
||||
try {
|
||||
await deletePermission(record.id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
span {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
266
src/views/system/role.vue
Normal file
266
src/views/system/role.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-card>
|
||||
<div class="table-toolbar">
|
||||
<span>角色管理</span>
|
||||
<a-button type="primary" v-permission="'system:role:add'" @click="handleAdd">
|
||||
新增角色
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a v-permission="'system:role:edit'" @click="handleEdit(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a v-permission="'system:role:permission'" @click="handlePermission(record)">权限</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm title="确定删除该角色吗?" @confirm="handleDelete(record)">
|
||||
<a v-permission="'system:role:delete'" style="color: #ff4d4f">删除</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑角色' : '新增角色'"
|
||||
@ok="handleSubmit"
|
||||
:confirm-loading="submitting"
|
||||
>
|
||||
<a-form :model="formData" :label-col="{ span: 5 }">
|
||||
<a-form-item label="角色名称" required>
|
||||
<a-input v-model:value="formData.name" placeholder="请输入角色名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="角色编码" required>
|
||||
<a-input v-model:value="formData.code" :disabled="isEdit" placeholder="请输入角色编码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="formData.description" :rows="2" placeholder="请输入描述" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model:value="formData.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">正常</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<!-- 权限配置弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="permissionModalVisible"
|
||||
title="配置权限"
|
||||
@ok="handlePermissionSubmit"
|
||||
:confirm-loading="permissionSubmitting"
|
||||
width="500px"
|
||||
>
|
||||
<a-tree
|
||||
v-model:checkedKeys="checkedPermissions"
|
||||
:tree-data="permissionTree"
|
||||
checkable
|
||||
:field-names="{ title: 'name', key: 'id', children: 'children' }"
|
||||
default-expand-all
|
||||
/>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import {
|
||||
getRoleList,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
getRolePermissions,
|
||||
updateRolePermissions,
|
||||
getPermissionTree
|
||||
} from '@/api/system'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const permissionTree = ref([])
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
|
||||
const permissionModalVisible = ref(false)
|
||||
const permissionSubmitting = ref(false)
|
||||
const checkedPermissions = ref([])
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '角色名称', dataIndex: 'name' },
|
||||
{ title: '角色编码', dataIndex: 'code' },
|
||||
{ title: '描述', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 180 }
|
||||
]
|
||||
|
||||
const fetchPermissionTree = async () => {
|
||||
try {
|
||||
const data = await getPermissionTree()
|
||||
permissionTree.value = data || []
|
||||
} catch (error) {
|
||||
// 忽略
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getRoleList({
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
tableData.value = data.list || []
|
||||
pagination.total = data.total || 0
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = pag => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, { name: '', code: '', description: '', sort: 0, status: 1 })
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
currentId.value = null
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = record => {
|
||||
isEdit.value = true
|
||||
currentId.value = record.id
|
||||
Object.assign(formData, {
|
||||
name: record.name,
|
||||
code: record.code,
|
||||
description: record.description,
|
||||
sort: record.sort,
|
||||
status: record.status
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || !formData.code) {
|
||||
message.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateRole(currentId.value, formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createRole(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermission = async record => {
|
||||
currentId.value = record.id
|
||||
try {
|
||||
const data = await getRolePermissions(record.id)
|
||||
checkedPermissions.value = data || []
|
||||
permissionModalVisible.value = true
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
}
|
||||
}
|
||||
|
||||
const handlePermissionSubmit = async () => {
|
||||
permissionSubmitting.value = true
|
||||
try {
|
||||
await updateRolePermissions(currentId.value, checkedPermissions.value)
|
||||
message.success('权限配置成功')
|
||||
permissionModalVisible.value = false
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
permissionSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async record => {
|
||||
try {
|
||||
await deleteRole(record.id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPermissionTree()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
span {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
251
src/views/user/list.vue
Normal file
251
src/views/user/list.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 总览卡片 -->
|
||||
<a-row :gutter="[16, 16]" class="stat-row">
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">用户总数</span>
|
||||
<span class="stat-num">{{ stats.total }}</span>
|
||||
</div>
|
||||
<TeamOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">今日新增</span>
|
||||
<span class="stat-num green">{{ stats.todayNew }}</span>
|
||||
</div>
|
||||
<UserAddOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">VIP用户</span>
|
||||
<span class="stat-num orange">{{ stats.vipCount }}</span>
|
||||
</div>
|
||||
<CrownOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card">
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">禁用用户</span>
|
||||
<span class="stat-num red">{{ stats.disabledCount }}</span>
|
||||
</div>
|
||||
<StopOutlined class="stat-bg-icon" />
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-left">
|
||||
<span class="filter-result">共 <b>{{ pagination.total }}</b> 条记录</span>
|
||||
</div>
|
||||
<div class="filter-right">
|
||||
<a-input
|
||||
v-model:value="searchForm.keyword"
|
||||
placeholder="搜索昵称/手机号"
|
||||
allow-clear
|
||||
style="width: 180px"
|
||||
@input="handleSearch"
|
||||
>
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-select
|
||||
v-model:value="searchForm.vipLevel"
|
||||
placeholder="VIP等级"
|
||||
allow-clear
|
||||
style="width: 120px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option :value="0">普通用户</a-select-option>
|
||||
<a-select-option :value="1">VIP1</a-select-option>
|
||||
<a-select-option :value="2">VIP2</a-select-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-model:value="searchForm.status"
|
||||
placeholder="状态"
|
||||
allow-clear
|
||||
style="width: 100px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<a-select-option :value="1">正常</a-select-option>
|
||||
<a-select-option :value="0">禁用</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
:scroll="{ x: 1000 }"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'avatar'">
|
||||
<a-avatar :src="record.avatar" :size="36" />
|
||||
</template>
|
||||
<template v-if="column.key === 'vipLevel'">
|
||||
<a-tag :color="record.vipLevel > 0 ? 'gold' : 'default'">
|
||||
{{ record.vipLevel > 0 ? `VIP${record.vipLevel}` : '普通' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-badge :status="record.status === 1 ? 'success' : 'error'" :text="record.status === 1 ? '正常' : '禁用'" />
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space :size="0" split>
|
||||
<a-button type="link" size="small" @click="handleDetail(record)">详情</a-button>
|
||||
<a-button type="link" size="small" @click="handleAdjustPoints(record)">调整积分</a-button>
|
||||
<a-popconfirm
|
||||
:title="`确定${record.status === 1 ? '禁用' : '启用'}该用户吗?`"
|
||||
@confirm="handleToggleStatus(record)"
|
||||
>
|
||||
<a-button type="link" size="small" :danger="record.status === 1">
|
||||
{{ record.status === 1 ? '禁用' : '启用' }}
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 调整积分弹窗 -->
|
||||
<a-modal v-model:open="pointsModalVisible" title="调整积分" @ok="handlePointsSubmit" :confirm-loading="pointsSubmitting">
|
||||
<a-form :model="pointsForm" :label-col="{ span: 6 }">
|
||||
<a-form-item label="当前积分">{{ currentUser?.points || 0 }}</a-form-item>
|
||||
<a-form-item label="调整类型">
|
||||
<a-radio-group v-model:value="pointsForm.type">
|
||||
<a-radio value="add">增加</a-radio>
|
||||
<a-radio value="reduce">减少</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="积分数量">
|
||||
<a-input-number v-model:value="pointsForm.points" :min="1" style="width: 200px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="pointsForm.remark" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { TeamOutlined, UserAddOutlined, CrownOutlined, StopOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import { getUserList, updateUserStatus, adjustUserPoints } from '@/api/user'
|
||||
import { debounce } from 'lodash-es'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const searchForm = reactive({ keyword: '', vipLevel: undefined, status: undefined })
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showQuickJumper: true })
|
||||
const stats = ref({ total: 0, todayNew: 0, vipCount: 0, disabledCount: 0 })
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '头像', key: 'avatar', width: 60 },
|
||||
{ title: '昵称', dataIndex: 'nickname', ellipsis: true },
|
||||
{ title: '手机号', dataIndex: 'phone', width: 120 },
|
||||
{ title: 'VIP', key: 'vipLevel', width: 80, align: 'center' },
|
||||
{ title: '积分', dataIndex: 'points', width: 80, align: 'right' },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '注册时间', dataIndex: 'createdAt', width: 170 },
|
||||
{ title: '操作', key: 'action', width: 180, fixed: 'right' }
|
||||
]
|
||||
|
||||
const pointsModalVisible = ref(false)
|
||||
const pointsSubmitting = ref(false)
|
||||
const currentUser = ref(null)
|
||||
const pointsForm = reactive({ type: 'add', points: 0, remark: '' })
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getUserList({
|
||||
...searchForm,
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
tableData.value = data?.list || []
|
||||
pagination.total = data?.total || 0
|
||||
// 更新统计
|
||||
if (data?.stats) {
|
||||
stats.value = data.stats
|
||||
}
|
||||
} catch (error) {}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = debounce(() => {
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}, 300)
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(searchForm, { keyword: '', vipLevel: undefined, status: undefined })
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleTableChange = pag => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleDetail = record => console.log('查看详情', record)
|
||||
|
||||
const handleAdjustPoints = record => {
|
||||
currentUser.value = record
|
||||
pointsForm.type = 'add'
|
||||
pointsForm.points = 0
|
||||
pointsForm.remark = ''
|
||||
pointsModalVisible.value = true
|
||||
}
|
||||
|
||||
const handlePointsSubmit = async () => {
|
||||
if (!pointsForm.points || pointsForm.points <= 0) {
|
||||
message.warning('请输入有效的积分数量')
|
||||
return
|
||||
}
|
||||
pointsSubmitting.value = true
|
||||
try {
|
||||
await adjustUserPoints(currentUser.value.id, {
|
||||
points: pointsForm.type === 'add' ? pointsForm.points : -pointsForm.points,
|
||||
remark: pointsForm.remark
|
||||
})
|
||||
message.success('调整成功')
|
||||
pointsModalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {}
|
||||
finally { pointsSubmitting.value = false }
|
||||
}
|
||||
|
||||
const handleToggleStatus = async record => {
|
||||
try {
|
||||
await updateUserStatus(record.id, record.status === 1 ? 0 : 1)
|
||||
message.success('操作成功')
|
||||
fetchData()
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '@/styles/page-common.less';
|
||||
</style>
|
||||
131
src/views/work/audit.vue
Normal file
131
src/views/work/audit.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-card title="待审核作品">
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'contentUrl'">
|
||||
<a-image :src="record.contentUrl" :width="80" :height="80" style="object-fit: cover" />
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="primary" size="small" @click="handleAudit(record, 1)">
|
||||
通过
|
||||
</a-button>
|
||||
<a-button danger size="small" @click="handleReject(record)">
|
||||
拒绝
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 拒绝原因弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="rejectModalVisible"
|
||||
title="拒绝原因"
|
||||
@ok="handleRejectSubmit"
|
||||
:confirm-loading="submitting"
|
||||
>
|
||||
<a-form :model="rejectForm">
|
||||
<a-form-item label="拒绝原因" required>
|
||||
<a-textarea v-model:value="rejectForm.remark" :rows="4" placeholder="请输入拒绝原因" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getWorkList, auditWork } from '@/api/work'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const rejectModalVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const currentWork = ref(null)
|
||||
const rejectForm = reactive({ remark: '' })
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '封面', key: 'contentUrl', width: 100 },
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '描述', dataIndex: 'description', ellipsis: true },
|
||||
{ title: '作者', dataIndex: 'userName', width: 100 },
|
||||
{ title: '提交时间', dataIndex: 'createdAt', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getWorkList({
|
||||
auditStatus: 0,
|
||||
page: pagination.current,
|
||||
pageSize: pagination.pageSize
|
||||
})
|
||||
tableData.value = data.list || []
|
||||
pagination.total = data.total || 0
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTableChange = pag => {
|
||||
pagination.current = pag.current
|
||||
pagination.pageSize = pag.pageSize
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleAudit = async (record, status) => {
|
||||
try {
|
||||
await auditWork(record.id, { auditStatus: status })
|
||||
message.success('审核通过')
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = record => {
|
||||
currentWork.value = record
|
||||
rejectForm.remark = ''
|
||||
rejectModalVisible.value = true
|
||||
}
|
||||
|
||||
const handleRejectSubmit = async () => {
|
||||
if (!rejectForm.remark) {
|
||||
message.warning('请输入拒绝原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await auditWork(currentWork.value.id, {
|
||||
auditStatus: 2,
|
||||
auditRemark: rejectForm.remark
|
||||
})
|
||||
message.success('已拒绝')
|
||||
rejectModalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
186
src/views/work/category.vue
Normal file
186
src/views/work/category.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<a-card>
|
||||
<div class="table-toolbar">
|
||||
<span>分类管理</span>
|
||||
<a-button type="primary" v-permission="'category:add'" @click="handleAdd">
|
||||
新增分类
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="false"
|
||||
row-key="id"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'icon'">
|
||||
<a-avatar v-if="record.icon" :src="record.icon" shape="square" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '启用' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a v-permission="'category:edit'" @click="handleEdit(record)">编辑</a>
|
||||
<a-divider type="vertical" />
|
||||
<a-popconfirm
|
||||
title="确定删除该分类吗?"
|
||||
@confirm="handleDelete(record)"
|
||||
>
|
||||
<a v-permission="'category:delete'" style="color: #ff4d4f">删除</a>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="modalVisible"
|
||||
:title="isEdit ? '编辑分类' : '新增分类'"
|
||||
@ok="handleSubmit"
|
||||
:confirm-loading="submitting"
|
||||
>
|
||||
<a-form :model="formData" :label-col="{ span: 5 }">
|
||||
<a-form-item label="分类名称" required>
|
||||
<a-input v-model:value="formData.name" placeholder="请输入分类名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="图标URL">
|
||||
<a-input v-model:value="formData.icon" placeholder="请输入图标URL" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序">
|
||||
<a-input-number v-model:value="formData.sort" :min="0" style="width: 100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-radio-group v-model:value="formData.status">
|
||||
<a-radio :value="1">启用</a-radio>
|
||||
<a-radio :value="0">禁用</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '@/api/work'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const modalVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
name: '',
|
||||
icon: '',
|
||||
sort: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '图标', key: 'icon', width: 80 },
|
||||
{ title: '分类名称', dataIndex: 'name' },
|
||||
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 150 }
|
||||
]
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getCategoryList()
|
||||
tableData.value = data || []
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, { name: '', icon: '', sort: 0, status: 1 })
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
currentId.value = null
|
||||
resetForm()
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = record => {
|
||||
isEdit.value = true
|
||||
currentId.value = record.id
|
||||
Object.assign(formData, {
|
||||
name: record.name,
|
||||
icon: record.icon,
|
||||
sort: record.sort,
|
||||
status: record.status
|
||||
})
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name) {
|
||||
message.warning('请输入分类名称')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await updateCategory(currentId.value, formData)
|
||||
message.success('更新成功')
|
||||
} else {
|
||||
await createCategory(formData)
|
||||
message.success('创建成功')
|
||||
}
|
||||
modalVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async record => {
|
||||
try {
|
||||
await deleteCategory(record.id)
|
||||
message.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
// 错误已处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.page-container {
|
||||
padding: 16px;
|
||||
}
|
||||
.table-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
span {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
689
src/views/work/list.vue
Normal file
689
src/views/work/list.vue
Normal file
@@ -0,0 +1,689 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- 总览卡片 -->
|
||||
<a-row :gutter="[16, 16]" class="stat-row">
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card card-total">
|
||||
<div class="stat-icon-wrap">
|
||||
<PictureOutlined class="stat-icon" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-num">{{ pagination.total }}</span>
|
||||
<span class="stat-label">作品总数</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card card-pending">
|
||||
<div class="stat-icon-wrap">
|
||||
<ClockCircleOutlined class="stat-icon" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-num">{{ stats.pending }}</span>
|
||||
<span class="stat-label">待审核</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card card-featured">
|
||||
<div class="stat-icon-wrap">
|
||||
<StarOutlined class="stat-icon" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-num">{{ stats.featured }}</span>
|
||||
<span class="stat-label">精选作品</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
<a-col :xs="12" :sm="6">
|
||||
<div class="mini-stat-card card-passed">
|
||||
<div class="stat-icon-wrap">
|
||||
<CheckCircleOutlined class="stat-icon" />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-num">{{ stats.passed }}</span>
|
||||
<span class="stat-label">已通过</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-card :bordered="false" class="table-card">
|
||||
<div class="filter-bar">
|
||||
<div class="filter-left">
|
||||
<span class="filter-result">共 <b>{{ pagination.total }}</b> 条记录</span>
|
||||
</div>
|
||||
<div class="filter-right">
|
||||
<a-input v-model:value="searchForm.keyword" placeholder="搜索标题/描述" allow-clear style="width: 180px" @input="handleSearch">
|
||||
<template #prefix><SearchOutlined /></template>
|
||||
</a-input>
|
||||
<a-select v-model:value="searchForm.categoryId" placeholder="分类" allow-clear style="width: 120px" @change="handleSearch">
|
||||
<a-select-option v-for="cat in categories" :key="cat.id" :value="cat.id">{{ cat.name }}</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="searchForm.taskType" placeholder="生成类型" allow-clear style="width: 120px" @change="handleSearch">
|
||||
<a-select-option value="text2img">文生图</a-select-option>
|
||||
<a-select-option value="img2img">图生图</a-select-option>
|
||||
<a-select-option value="text2video">文生视频</a-select-option>
|
||||
<a-select-option value="img2video">图生视频</a-select-option>
|
||||
</a-select>
|
||||
<a-select v-model:value="searchForm.auditStatus" placeholder="审核状态" allow-clear style="width: 110px" @change="handleSearch">
|
||||
<a-select-option :value="0">待审核</a-select-option>
|
||||
<a-select-option :value="1">已通过</a-select-option>
|
||||
<a-select-option :value="2">已拒绝</a-select-option>
|
||||
</a-select>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
:scroll="{ x: 1200 }"
|
||||
@change="handleTableChange"
|
||||
class="work-table"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'contentUrl'">
|
||||
<div class="cover-wrap">
|
||||
<template v-if="record.contentType === 2">
|
||||
<!-- 视频作品显示视频播放器 -->
|
||||
<video
|
||||
:src="record.contentUrl"
|
||||
:width="56"
|
||||
:height="56"
|
||||
class="cover-video"
|
||||
muted
|
||||
preload="metadata"
|
||||
@click="handleVideoPreview(record)"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
<span class="video-badge">视频</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 图片作品 -->
|
||||
<a-image :src="record.contentUrl" :width="56" :height="56" class="cover-img" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="column.key === 'title'">
|
||||
<a-tooltip :title="record.title" placement="topLeft">
|
||||
<span class="cell-ellipsis title-text">{{ record.title || '-' }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-if="column.key === 'userName'">
|
||||
<a-tooltip :title="record.userName" placement="topLeft">
|
||||
<div class="user-info">
|
||||
<a-avatar :src="record.userAvatar" :size="28" />
|
||||
<span class="cell-ellipsis">{{ record.userName || '-' }}</span>
|
||||
</div>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-if="column.key === 'categoryName'">
|
||||
<a-tooltip :title="record.categoryName" placement="topLeft">
|
||||
<a-tag color="blue" class="category-tag">{{ record.categoryName || '-' }}</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-if="column.key === 'taskTypeName'">
|
||||
<a-tag :color="getTaskTypeColor(record.taskType)" class="type-tag">
|
||||
{{ record.taskTypeName || record.taskType || '-' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'model'">
|
||||
<a-tooltip :title="record.model" placement="topLeft">
|
||||
<span class="cell-ellipsis model-text">{{ record.model || '-' }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-if="column.key === 'auditStatus'">
|
||||
<a-badge :status="auditStatusMap[record.auditStatus]?.badge" :text="auditStatusMap[record.auditStatus]?.text" />
|
||||
</template>
|
||||
<template v-if="column.key === 'isFeatured'">
|
||||
<a-tag :color="record.isFeatured ? 'gold' : 'default'" class="featured-tag">
|
||||
<StarOutlined v-if="record.isFeatured" />
|
||||
{{ record.isFeatured ? '精选' : '普通' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'stats'">
|
||||
<div class="work-stats">
|
||||
<span class="stat-item"><EyeOutlined /> {{ record.viewCount || 0 }}</span>
|
||||
<span class="stat-item"><LikeOutlined /> {{ record.likeCount || 0 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="column.key === 'createdAt'">
|
||||
<a-tooltip :title="record.createdAt" placement="topLeft">
|
||||
<span class="cell-ellipsis time-text">{{ record.createdAt || '-' }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-if="column.key === 'action'">
|
||||
<div class="action-btns">
|
||||
<a-button type="link" size="small" @click="handleDetail(record)">详情</a-button>
|
||||
<!-- 待审核:通过、拒绝 -->
|
||||
<template v-if="record.auditStatus === 0">
|
||||
<a-popconfirm title="确定通过该作品吗?" @confirm="handleAudit(record, 1)">
|
||||
<a-button type="link" size="small" style="color: #52c41a">通过</a-button>
|
||||
</a-popconfirm>
|
||||
<a-button type="link" size="small" danger @click="handleAudit(record, 2)">拒绝</a-button>
|
||||
</template>
|
||||
<!-- 已通过:设精选、下架/上架 -->
|
||||
<template v-else-if="record.auditStatus === 1">
|
||||
<a-button type="link" size="small" @click="handleSetFeatured(record)">
|
||||
{{ record.isFeatured ? '取消精选' : '设精选' }}
|
||||
</a-button>
|
||||
<a-popconfirm :title="record.status === 1 ? '确定下架该作品吗?' : '确定上架该作品吗?'" @confirm="handleSetStatus(record)">
|
||||
<a-button type="link" size="small" :style="{ color: record.status === 1 ? '#fa8c16' : '#52c41a' }">
|
||||
{{ record.status === 1 ? '下架' : '上架' }}
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
<!-- 已拒绝:重新通过 -->
|
||||
<template v-else-if="record.auditStatus === 2">
|
||||
<a-popconfirm title="确定重新通过该作品吗?" @confirm="handleAudit(record, 1)">
|
||||
<a-button type="link" size="small" style="color: #52c41a">通过</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
|
||||
<a-modal v-model:open="detailVisible" title="作品详情" :footer="null" width="720px" class="detail-modal">
|
||||
<a-descriptions :column="2" bordered size="small" class="detail-desc">
|
||||
<a-descriptions-item label="作品ID">{{ currentWork.id }}</a-descriptions-item>
|
||||
<a-descriptions-item label="作者">
|
||||
<div class="user-info">
|
||||
<a-avatar :src="currentWork.userAvatar" :size="22" />
|
||||
<span>{{ currentWork.userName }}</span>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="标题" :span="2">{{ currentWork.title }}</a-descriptions-item>
|
||||
<a-descriptions-item label="描述" :span="2">
|
||||
<div class="desc-content">{{ currentWork.description || '-' }}</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="分类"><a-tag color="blue">{{ currentWork.categoryName }}</a-tag></a-descriptions-item>
|
||||
<a-descriptions-item label="生成类型"><a-tag :color="getTaskTypeColor(currentWork.taskType)">{{ currentWork.taskTypeName }}</a-tag></a-descriptions-item>
|
||||
<a-descriptions-item label="AI模型">{{ currentWork.model || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="内容类型">{{ currentWork.contentType === 1 ? '图片' : '视频' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="提示词" :span="2">
|
||||
<div class="prompt-content">{{ currentWork.prompt || '-' }}</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="数据统计">
|
||||
<div class="detail-stats">
|
||||
<span><EyeOutlined /> {{ currentWork.viewCount || 0 }}</span>
|
||||
<span><LikeOutlined /> {{ currentWork.likeCount || 0 }}</span>
|
||||
<span><StarOutlined /> {{ currentWork.collectCount || 0 }}</span>
|
||||
</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="审核状态">
|
||||
<a-badge :status="auditStatusMap[currentWork.auditStatus]?.badge" :text="auditStatusMap[currentWork.auditStatus]?.text" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间" :span="2">{{ currentWork.createdAt }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<div class="work-preview" v-if="currentWork.contentUrl">
|
||||
<div class="preview-title">内容预览</div>
|
||||
<div class="preview-content">
|
||||
<a-image v-if="currentWork.contentType === 1" :src="currentWork.contentUrl" :width="320" />
|
||||
<video v-else :src="currentWork.contentUrl" controls class="preview-video" />
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 拒绝理由弹窗 -->
|
||||
<a-modal v-model:open="rejectVisible" title="拒绝作品" @ok="confirmReject" width="480px">
|
||||
<div style="margin: 20px 0">
|
||||
<p style="margin-bottom: 12px; color: #666">请填写拒绝理由,将会通知作者:</p>
|
||||
<a-textarea
|
||||
v-model:value="rejectForm.reason"
|
||||
placeholder="请输入拒绝理由,如:内容不符合社区规范、图片质量不佳等"
|
||||
:rows="4"
|
||||
:maxlength="200"
|
||||
show-count
|
||||
/>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PictureOutlined, ClockCircleOutlined, StarOutlined, CheckCircleOutlined, SearchOutlined, EyeOutlined, LikeOutlined } from '@ant-design/icons-vue'
|
||||
import { getWorkList, getWorkStats, setWorkFeatured, setWorkStatus, auditWork, getCategoryTree } from '@/api/work'
|
||||
import { debounce } from 'lodash-es'
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const categories = ref([])
|
||||
const searchForm = reactive({ keyword: '', categoryId: undefined, taskType: undefined, auditStatus: undefined })
|
||||
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showQuickJumper: true })
|
||||
const stats = ref({ pending: 0, featured: 0, passed: 0 })
|
||||
const detailVisible = ref(false)
|
||||
const rejectVisible = ref(false)
|
||||
const currentWork = ref({})
|
||||
const rejectForm = reactive({ reason: '' })
|
||||
|
||||
const getTaskTypeColor = (taskType) => ({ 'text2img': 'blue', 'img2img': 'green', 'text2video': 'purple', 'img2video': 'orange' }[taskType] || 'default')
|
||||
const auditStatusMap = { 0: { text: '待审核', badge: 'warning' }, 1: { text: '已通过', badge: 'success' }, 2: { text: '已拒绝', badge: 'error' } }
|
||||
|
||||
const columns = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' },
|
||||
{ title: '封面', key: 'contentUrl', width: 80, align: 'center' },
|
||||
{ title: '标题', key: 'title', width: 150 },
|
||||
{ title: '作者', key: 'userName', width: 120 },
|
||||
{ title: '分类', key: 'categoryName', width: 100 },
|
||||
{ title: '生成类型', key: 'taskTypeName', width: 100 },
|
||||
{ title: 'AI模型', key: 'model', width: 120 },
|
||||
{ title: '数据', key: 'stats', width: 110 },
|
||||
{ title: '审核', key: 'auditStatus', width: 90 },
|
||||
{ title: '精选', key: 'isFeatured', width: 80, align: 'center' },
|
||||
{ title: '创建时间', key: 'createdAt', width: 160 },
|
||||
{ title: '操作', key: 'action', width: 200, fixed: 'right' }
|
||||
]
|
||||
|
||||
const fetchCategories = async () => { try { categories.value = await getCategoryTree() || [] } catch {} }
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const data = await getWorkStats()
|
||||
stats.value = {
|
||||
pending: data?.pending || 0,
|
||||
featured: data?.featured || 0,
|
||||
passed: data?.passed || 0
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await getWorkList({ ...searchForm, page: pagination.current, pageSize: pagination.pageSize })
|
||||
tableData.value = data?.list || []
|
||||
pagination.total = data?.total || 0
|
||||
} catch (e) { console.error('获取作品列表失败:', e) } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = debounce(() => { pagination.current = 1; fetchData() }, 300)
|
||||
const handleReset = () => { Object.assign(searchForm, { keyword: '', categoryId: undefined, taskType: undefined, auditStatus: undefined }); pagination.current = 1; fetchData() }
|
||||
const handleTableChange = pag => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchData() }
|
||||
const handleDetail = record => { currentWork.value = record; detailVisible.value = true }
|
||||
const handleVideoPreview = record => { currentWork.value = record; detailVisible.value = true }
|
||||
const handleAudit = async (record, auditStatus) => {
|
||||
if (auditStatus === 2) {
|
||||
// 拒绝需要填写理由
|
||||
currentWork.value = record
|
||||
rejectForm.reason = ''
|
||||
rejectVisible.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await auditWork(record.id, { auditStatus, auditRemark: '审核通过' })
|
||||
message.success('审核通过')
|
||||
fetchData()
|
||||
fetchStats()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleReject = record => {
|
||||
currentWork.value = record
|
||||
rejectForm.reason = ''
|
||||
rejectVisible.value = true
|
||||
}
|
||||
|
||||
const confirmReject = async () => {
|
||||
if (!rejectForm.reason.trim()) {
|
||||
message.warning('请填写拒绝理由')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await auditWork(currentWork.value.id, { auditStatus: 2, auditRemark: rejectForm.reason })
|
||||
message.success('作品已拒绝')
|
||||
rejectVisible.value = false
|
||||
fetchData()
|
||||
fetchStats()
|
||||
} catch {}
|
||||
}
|
||||
const handleSetFeatured = async record => { try { await setWorkFeatured(record.id, { isFeatured: record.isFeatured ? 0 : 1 }); message.success('操作成功'); fetchData(); fetchStats() } catch {} }
|
||||
const handleSetStatus = async record => { try { await setWorkStatus(record.id, { status: record.status === 1 ? 0 : 1 }); message.success(record.status === 1 ? '已下架' : '已上架'); fetchData() } catch {} }
|
||||
|
||||
onMounted(() => { fetchCategories(); fetchStats(); fetchData() })
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
@import '@/styles/page-common.less';
|
||||
|
||||
// 统计卡片样式
|
||||
.stat-row {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mini-stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 16px;
|
||||
|
||||
.stat-icon {
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.stat-num {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #8c8c8c;
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.card-total .stat-icon-wrap {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
&.card-pending .stat-icon-wrap {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
&.card-featured .stat-icon-wrap {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
&.card-passed .stat-icon-wrap {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// 表格卡片
|
||||
.table-card {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
|
||||
:deep(.ant-card-body) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// 筛选栏
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
.filter-left {
|
||||
.filter-result {
|
||||
color: #595959;
|
||||
font-size: 14px;
|
||||
|
||||
b {
|
||||
color: #1890ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filter-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
// 表格样式
|
||||
.work-table {
|
||||
:deep(.ant-table) {
|
||||
.ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-weight: 600;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ant-table-tbody > tr {
|
||||
&:hover > td {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通用省略样式
|
||||
.cell-ellipsis {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 封面样式
|
||||
.cover-wrap {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
||||
.cover-img {
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cover-video {
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.video-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// 标题样式
|
||||
.title-text {
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
max-width: 130px;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
// 用户信息
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
color: #595959;
|
||||
max-width: 70px;
|
||||
}
|
||||
|
||||
:deep(.ant-avatar) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 标签样式
|
||||
.category-tag, .type-tag, .featured-tag {
|
||||
max-width: 80px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.featured-tag {
|
||||
:deep(.anticon) {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// 模型文本
|
||||
.model-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
// 时间文本
|
||||
.time-text {
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
// 数据统计
|
||||
.work-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #8c8c8c;
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 操作按钮
|
||||
.action-btns {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
:deep(.ant-btn-link) {
|
||||
padding: 0 6px;
|
||||
height: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// 详情弹窗
|
||||
.detail-modal {
|
||||
:deep(.ant-modal-header) {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
:deep(.ant-descriptions-item-label) {
|
||||
width: 90px;
|
||||
background: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
.desc-content, .prompt-content {
|
||||
max-height: 80px;
|
||||
overflow-y: auto;
|
||||
word-break: break-all;
|
||||
line-height: 1.6;
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.detail-stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #595959;
|
||||
|
||||
:deep(.anticon) {
|
||||
color: #8c8c8c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 预览区域
|
||||
.work-preview {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
|
||||
.preview-title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin-bottom: 16px;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
|
||||
:deep(.ant-image) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.preview-video {
|
||||
max-width: 100%;
|
||||
max-height: 320px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
vite.config.js
Normal file
32
vite.config.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// target: 'http://localhost:8080',
|
||||
target:'https://api.1818ai.com',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
less: {
|
||||
javascriptEnabled: true,
|
||||
modifyVars: {
|
||||
'primary-color': '#1890ff'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user