first commit: 初始化1818-admin项目

This commit is contained in:
2026-02-13 17:47:58 +08:00
commit 67091b730d
59 changed files with 14102 additions and 0 deletions

2
.env Normal file
View File

@@ -0,0 +1,2 @@
# 开发环境配置
VITE_API_BASE_URL=/api

2
.env.production Normal file
View File

@@ -0,0 +1,2 @@
# 生产环境配置
VITE_API_BASE_URL=/api

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
dist
.DS_Store
*.local
*.log

29
README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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}`)
}

View File

View 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
View 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

View 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>

View 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>

View 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
View 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>

View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
export { useUserStore } from './modules/user'
export { useAppStore } from './modules/app'

18
src/store/modules/app.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

201
src/views/ai/provider.vue Normal file
View 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
View 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
View 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支持JPGPNG格式文件大小不超过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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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'
}
}
}
}
})