Compare commits

...

6 Commits

Author SHA1 Message Date
94a061691c 🎨 优化cdk管理,新增域名管理功能 2025-09-12 21:52:45 +08:00
36921300df 🎨 新增提现管理功能 2025-09-10 02:32:11 +08:00
856338950e 🎨 优化用户管理页面 2025-09-10 00:18:20 +08:00
a631f6ea46 🎨 新增兑换码功能 2025-09-09 23:54:01 +08:00
3378e709cf 🎨 新增vip用户管理以及讲师包月服务管理功能 2025-09-09 07:21:39 +08:00
773f13104b 🎨 新增用户登录日志功能 2025-09-08 01:48:17 +08:00
17 changed files with 3621 additions and 25 deletions

80
src/api/cdk/index.js Normal file
View File

@@ -0,0 +1,80 @@
import service from '@/utils/request'
// 获取兑换码库列表
export const mkList = (params) => {
return service({
url: '/cdk/mk/list',
method: 'get',
params
})
}
// 新增兑换码库
export const addMk = (data) => {
return service({
url: '/cdk/mk',
method: 'post',
data
})
}
// 编辑兑换码库
export const editMk = (data) => {
return service({
url: '/cdk/mk',
method: 'put',
data
})
}
// 删除兑换码库
export const delMk = (data) => {
return service({
url: '/cdk/mk',
method: 'delete',
data
})
}
// 获取兑换码库详情
export const mkDetail = (params) => {
return service({
url: '/cdk/mk',
method: 'get',
params
})
}
// =======================CDK相关===========================
// 获取兑换码列表
export const cdkList = (params) => {
return service({
url: '/cdk/list',
method: 'get',
params
})
}
// 新增兑换码
export const addCdk = (data) => {
return service({
url: '/cdk/generate',
method: 'post',
data
})
}
// 作废兑换码
export const delCdk = (data) => {
return service({
url: '/cdk',
method: 'delete',
data
})
}

44
src/api/domain.js Normal file
View File

@@ -0,0 +1,44 @@
import service from '@/utils/request'
export const list = (params) => {
return service({
url: '/domain/list',
method: 'get',
params
})
}
// addDomain
export const addDomain = (data) => {
return service({
url: '/domain',
method: 'post',
data
})
}
// deleteDomain
export const deleteDomain = (data) => {
return service({
url: '/domain',
method: 'delete',
data
})
}
// updateDomain
export const updateDomain = (data) => {
return service({
url: '/domain',
method: 'put',
data
})
}
// getDomain
export const getDomain = (id) => {
return service({
url: '/domain/' + id,
method: 'get',
})
}

View File

@@ -0,0 +1,39 @@
import service from '@/utils/request'
export const list = (params) => {
return service({
url: '/app_user/teacher_vips',
method: 'get',
params
})
}
export const add = (data) => {
return service({
url: '/app_user/teacher_vip',
method: 'post',
data
})
}
export const edit = (data) => {
return service({
url: '/app_user/teacher_vip',
method: 'put',
data
})
}
export const del = (data) => {
return service({
url: '/app_user/teacher_vip',
method: 'delete',
data
})
}
export const detail = (params) => {
return service({
url: '/app_user/teacher_vip',
method: 'get',
params
})
}

View File

@@ -75,3 +75,32 @@ export const setVip = (data) => {
data
})
}
// GetLoginInfo
export const getLoginInfo = (params) => {
return service({
url: '/app_user/login/log',
method: 'get',
params
})
}
// RemoveUserVip
export const removeUserVip = (data) => {
return service({
url: '/app_user/vip',
method: 'delete',
data
})
}
// getVipUserList
export const getVipUserList = (params) => {
return service({
url: '/app_user/vip/list',
method: 'get',
params
})
}

20
src/api/user/with.js Normal file
View File

@@ -0,0 +1,20 @@
import service from '@/utils/request'
// list 获取提现列表
export const list = (params) => {
return service({
url: '/sys/with/list',
method: 'get',
params
})
}
// update 更新提现状态
export const update = (data) => {
return service({
url: '/sys/with',
method: 'put',
data
})
}

View File

@@ -174,6 +174,10 @@ export const USER_SEARCH_CONFIG = [
{
label: 'SVIP',
value: 3
},
{
label: '过期VIP用户',
value: 4
}
]
},
@@ -291,17 +295,175 @@ export const ORDER_TABLE_CONFIG = {
}
]
}
// 用户列表
export const USER_TABLE_CONFIG = {
// VIP用户搜索配置
export const VIP_USER_SEARCH_CONFIG = [
{
type: 'input',
prop: 'user_id',
label: '用户ID',
placeholder: '请输入用户ID',
},
{
type: 'input',
prop: 'name',
label: '用户名称',
placeholder: '请输入用户名称',
}
]
// VIP用户表格配置
export const VIP_USER_TABLE_CONFIG = {
index: true,
schemes: [
{
attrs: {
label: 'ID',
prop: 'ID',
label: '头像',
prop: 'avatar',
align: 'center'
},
slot: 'avatar'
},
{
attrs: {
label: '用户ID',
prop: 'id',
align: 'center'
}
},
{
attrs: {
label: '用户名称',
prop: 'nick_name',
align: 'center'
}
},
{
attrs: {
label: '手机号',
prop: 'phone',
align: 'center'
}
},
{
attrs: {
label: 'VIP等级',
prop: 'user_label',
align: 'center'
},
slot: 'user_label'
},
{
attrs: {
label: 'VIP到期时间',
prop: 'vip_expire_time',
align: 'center'
},
slot: 'vip_expire_time'
},
{
attrs: {
label: '账户余额',
prop: 'balance',
align: 'center'
},
slot: 'balance'
},
{
attrs: {
label: '操作',
prop: 'operate',
align: 'center'
},
slot: 'operate'
}
]
}
// 用户登录日志搜索配置
export const LOGIN_LOG_SEARCH_CONFIG = [
{
type: 'input',
prop: 'user_id',
label: '用户ID',
placeholder: '请输入用户ID',
},
{
type: 'input',
prop: 'user_name',
label: '用户名称',
placeholder: '请输入用户名称',
}
]
// 用户登录日志表格配置
export const LOGIN_LOG_TABLE_CONFIG = {
index: true,
schemes: [
{
attrs: {
label: '用户ID',
prop: 'user_id',
align: 'center'
}
},
{
attrs: {
label: '用户名称',
prop: 'user_name',
align: 'center'
}
},
{
attrs: {
label: '手机号',
prop: 'phone',
align: 'center'
}
},
{
attrs: {
label: '登录IP',
prop: 'ip',
align: 'center'
}
},
{
attrs: {
label: '登录地址',
prop: 'address',
align: 'center'
}
},
{
attrs: {
label: '设备信息',
prop: 'device',
align: 'center'
}
},
{
attrs: {
label: '登录方式',
prop: 'mode',
align: 'center'
}
},
{
attrs: {
label: '登录时间',
prop: 'login_time',
align: 'center'
},
slot: 'login_time'
}
]
}
// 用户列表
export const USER_TABLE_CONFIG = {
index: true,
schemes: [
{
attrs: {
label: '头像',
@@ -365,6 +527,7 @@ export const USER_TABLE_CONFIG = {
}
]
}
// 文章列表
export const ARTICLE_TABLE_CONFIG = {
index: true,
@@ -424,4 +587,4 @@ export const ARTICLE_TABLE_CONFIG = {
slot: 'operate'
}
]
}
}

View File

@@ -5,6 +5,8 @@
"/src/view/bot/bot/botForm.vue": "BotForm",
"/src/view/category/category/category.vue": "Category",
"/src/view/category/category/categoryForm.vue": "CategoryForm",
"/src/view/cdk/cdkManage.vue": "CdkManage",
"/src/view/cdk/index.vue": "CdkLibraryManagement",
"/src/view/dashboard/components/banner.vue": "Banner",
"/src/view/dashboard/components/card.vue": "Card",
"/src/view/dashboard/components/charts-content-numbers.vue": "ChartsContentNumbers",
@@ -15,6 +17,7 @@
"/src/view/dashboard/components/table.vue": "Table",
"/src/view/dashboard/components/wiki.vue": "Wiki",
"/src/view/dashboard/index.vue": "Dashboard",
"/src/view/domain/index.vue": "Domain",
"/src/view/error/index.vue": "Error",
"/src/view/error/reload.vue": "Reload",
"/src/view/example/breakpoint/breakpoint.vue": "BreakPoint",
@@ -25,6 +28,7 @@
"/src/view/goods/article/edit.vue": "Edit",
"/src/view/goods/article/index.vue": "Index",
"/src/view/goods/index.vue": "goods",
"/src/view/goods/teacher_vip/index.vue": "Index",
"/src/view/goods/vip/index.vue": "VipList",
"/src/view/init/index.vue": "Init",
"/src/view/layout/aside/asideComponent/asyncSubmenu.vue": "AsyncSubmenu",
@@ -90,7 +94,10 @@
"/src/view/systemTools/version/version.vue": "SysVersion",
"/src/view/user/index.vue": "UserManage",
"/src/view/user/user/index.vue": "Index",
"/src/view/user/user/loginLog.vue": "LoginLog",
"/src/view/user/user/teacherApply.vue": "TeacherApply",
"/src/view/user/user/vipUser.vue": "VipUser",
"/src/view/user/user/with.vue": "WithdrawManagement",
"/src/plugin/announcement/form/info.vue": "InfoForm",
"/src/plugin/announcement/view/info.vue": "Info",
"/src/plugin/customerservice/view/chat/index.vue": "ServiceMain",

776
src/view/cdk/cdkManage.vue Normal file
View File

@@ -0,0 +1,776 @@
<template>
<div>
<!-- 页面头部 -->
<div class="page-header">
<div class="header-left">
<el-button @click="goBack" type="text" class="back-btn">
<el-icon><ArrowLeft /></el-icon>
返回兑换码库管理
</el-button>
<h2>{{ libraryInfo.codeName }} - 兑换码管理</h2>
</div>
<div class="header-right">
<el-button type="primary" @click="handleGenerateCdk">生成兑换码</el-button>
</div>
</div>
<!-- 搜索区域 -->
<div class="gva-search-box">
<el-form :inline="true" :model="searchForm" class="demo-form-inline">
<el-form-item label="兑换码">
<el-input v-model="searchForm.code" placeholder="请输入兑换码" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="未使用" :value="1" />
<el-option label="已使用" :value="2" />
<el-option label="已作废" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="使用者">
<el-input v-model="searchForm.useName" placeholder="请输入使用者名称" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchData">查询</el-button>
<el-button @click="resetData">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 统计信息 -->
<div class="stats-box">
<el-row :gutter="20">
<el-col :span="6">
<div class="stat-item">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">总数量</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item unused">
<div class="stat-value">{{ stats.unused }}</div>
<div class="stat-label">未使用</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item used">
<div class="stat-value">{{ stats.used }}</div>
<div class="stat-label">已使用</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item invalid">
<div class="stat-value">{{ stats.invalid }}</div>
<div class="stat-label">已作废</div>
</div>
</el-col>
</el-row>
</div>
<!-- 表格区域 -->
<div class="gva-table-box">
<el-table
:data="tableData"
v-loading="tableLoading"
style="width: 100%"
class="gva-table"
stripe
>
<el-table-column prop="ID" label="ID" width="80" />
<el-table-column prop="code" label="兑换码" min-width="180">
<template #default="{ row }">
<div class="code-cell">
<span>{{ row.code }}</span>
<el-button
type="text"
@click="copyCode(row.code)"
class="copy-btn"
title="复制兑换码"
>
<el-icon><CopyDocument /></el-icon>
</el-button>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="validDay" label="有效期(天)" width="120" />
<el-table-column prop="expireAt" label="到期时间" width="180">
<template #default="{ row }">
<span :class="{ 'text-danger': isExpiringSoon(row.expireAt), 'text-expired': isExpired(row.expireAt) }">
{{ formatDate(row.expireAt) }}
</span>
</template>
</el-table-column>
<el-table-column prop="useName" label="使用者" width="120">
<template #default="{ row }">
{{ row.useName || '-' }}
</template>
</el-table-column>
<el-table-column prop="useAt" label="使用时间" width="180">
<template #default="{ row }">
{{ row.useAt ? formatDate(row.useAt) : '-' }}
</template>
</el-table-column>
<el-table-column prop="CreatedAt" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
type="text"
@click="openShare(row)"
>
分享
</el-button>
<el-button
type="text"
@click="handleDelete(row)"
style="color: #f56c6c"
:disabled="row.status !== 1"
>
作废
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
class="gva-pagination"
/>
</div>
<!-- 分享弹窗 -->
<el-dialog
v-model="shareDialogVisible"
title="分享兑换码"
width="520px"
:close-on-click-modal="false"
:close-on-press-escape="false"
>
<el-form label-width="100px">
<el-form-item label="选择域名">
<el-select
v-model="shareForm.domain"
placeholder="请选择域名"
filterable
style="width: 100%"
@change="updateShareLink"
>
<el-option
v-for="item in domainOptions"
:key="item.ID"
:label="item.name ? `${item.name}${item.domain_url}` : item.domain_url"
:value="item.domain_url"
/>
</el-select>
</el-form-item>
<el-form-item label="分享链接">
<div class="flex" style="width: 100%">
<el-input v-model="shareForm.link" readonly />
<el-button style="margin-left: 10px" type="primary" @click="copyShareLink" :disabled="!shareForm.link">复制</el-button>
</div>
</el-form-item>
<el-form-item label="二维码" v-if="shareForm.link">
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<vue-qr :text="shareForm.link" :size="180" :margin="0" :autoColor="true" :dotScale="1"/>
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="closeShare"> </el-button>
</span>
</template>
</el-dialog>
<!-- 生成兑换码弹窗 -->
<el-dialog
v-model="generateDialogVisible"
title="生成兑换码"
width="500px"
:before-close="handleGenerateClose"
>
<el-form
ref="generateFormRef"
:model="generateForm"
:rules="getDynamicRules()"
label-width="120px"
>
<el-form-item label="兑换码库">
<el-input v-model="libraryInfo.codeName" disabled />
</el-form-item>
<el-form-item label="生成数量" prop="number">
<el-input-number
v-model="generateForm.number"
:min="1"
:max="1000"
placeholder="请输入生成数量"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="有效期" prop="validityType">
<el-radio-group v-model="generateForm.validityType" @change="handleValidityTypeChange">
<el-radio :label="'permanent'">永久有效</el-radio>
<el-radio :label="'custom'">自定义天数</el-radio>
</el-radio-group>
<div v-if="generateForm.validityType === 'custom'" style="margin-top: 10px;">
<el-input-number
v-model="generateForm.customDays"
:min="1"
:max="3650"
placeholder="请输入有效期天数"
style="width: 200px"
/>
<span style="margin-left: 8px;"></span>
</div>
<div style="font-size: 12px; color: #999; margin-top: 5px;">
{{ generateForm.validityType === 'permanent' ? '兑换码永久有效,不会过期' : '兑换码的有效期,超过此期限将无法使用' }}
</div>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleGenerateClose">取消</el-button>
<el-button type="primary" @click="submitGenerate">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { cdkList, addCdk, delCdk } from '@/api/cdk/index.js'
import { list as domainList } from '@/api/domain'
import vueQr from 'vue-qr/src/packages/vue-qr.vue'
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, CopyDocument } from '@element-plus/icons-vue'
defineOptions({
name: 'CdkManage'
})
// 注册二维码组件(局部)
const components = { vueQr }
const route = useRoute()
const router = useRouter()
// 兑换码库信息
const libraryInfo = ref({
ID: '',
codeName: ''
})
// 表格相关数据
const tableLoading = ref(false)
const tableData = ref([])
const queryParams = ref({
page: 1,
pageSize: 10,
eid: 0
})
const total = ref(0)
// 搜索表单
const searchForm = ref({
code: '',
status: '',
useName: ''
})
// 分享相关
const shareDialogVisible = ref(false)
const shareForm = ref({
domain: '',
link: ''
})
const currentShareCode = ref('')
const domainOptions = ref([])
const openShare = async (row) => {
currentShareCode.value = row.code
shareForm.value = { domain: '', link: '' }
shareDialogVisible.value = true
if (!domainOptions.value.length) {
try {
const res = await domainList({ page: 1, pageSize: 1000 })
if (res.code === 0) {
domainOptions.value = res.data.list || []
}
} catch (e) {
// ignore
}
}
}
const stripProtocol = (url) => {
if (!url) return ''
return url.replace(/^https?:\/\//i, '').replace(/\/$/, '')
}
const updateShareLink = () => {
const host = stripProtocol(shareForm.value.domain)
if (!host || !currentShareCode.value) {
shareForm.value.link = ''
return
}
shareForm.value.link = `${host}/pages/user/cdk/index?dhm=${encodeURIComponent(currentShareCode.value)}`
}
const copyShareLink = async () => {
if (!shareForm.value.link) return
try {
await navigator.clipboard.writeText(shareForm.value.link)
ElMessage.success('链接已复制到剪贴板')
} catch (error) {
// 兼容不支持的情况
const ta = document.createElement('textarea')
ta.value = shareForm.value.link
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
ElMessage.success('链接已复制到剪贴板')
}
}
const closeShare = () => {
shareDialogVisible.value = false
}
// 统计数据
const stats = computed(() => {
const unused = tableData.value.filter(item => item.status === 1).length
const used = tableData.value.filter(item => item.status === 2).length
const invalid = tableData.value.filter(item => item.status === 3).length
return {
total: total.value,
unused,
used,
invalid
}
})
// 生成兑换码弹窗
const generateDialogVisible = ref(false)
const generateFormRef = ref(null)
const generateForm = ref({
eid: 0,
number: 1,
expirer: 30,
validityType: 'custom', // 'permanent' 或 'custom'
customDays: 30
})
// 表单验证规则
const generateRules = {
number: [
{ required: true, message: '请输入生成数量', trigger: 'blur' },
{ type: 'number', min: 1, max: 1000, message: '生成数量必须在1-1000之间', trigger: 'blur' }
],
validityType: [
{ required: true, message: '请选择有效期类型', trigger: 'change' }
]
}
// 动态验证规则
const getDynamicRules = () => {
const rules = { ...generateRules }
if (generateForm.value.validityType === 'custom') {
rules.customDays = [
{ required: true, message: '请输入有效期天数', trigger: 'blur' },
{ type: 'number', min: 1, max: 3650, message: '有效期必须在1-3650天之间', trigger: 'blur' }
]
}
return rules
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
1: '未使用',
2: '已使用',
3: '已作废'
}
return statusMap[status] || '未知'
}
// 获取状态类型
const getStatusType = (status) => {
const typeMap = {
1: 'success',
2: 'warning',
3: 'danger'
}
return typeMap[status] || 'info'
}
// 判断是否即将过期7天内
const isExpiringSoon = (expireTime) => {
if (!expireTime) return false
const expireDate = new Date(expireTime)
const now = new Date()
const diffTime = expireDate.getTime() - now.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays <= 7 && diffDays > 0
}
// 判断是否已过期
const isExpired = (expireTime) => {
if (!expireTime) return false
const expireDate = new Date(expireTime)
const now = new Date()
return expireDate.getTime() < now.getTime()
}
// 复制兑换码
const copyCode = async (code) => {
try {
await navigator.clipboard.writeText(code)
ElMessage.success('兑换码已复制到剪贴板')
} catch (error) {
// 降级方案
const textArea = document.createElement('textarea')
textArea.value = code
document.body.appendChild(textArea)
textArea.select()
document.execCommand('copy')
document.body.removeChild(textArea)
ElMessage.success('兑换码已复制到剪贴板')
}
}
// 搜索数据
const searchData = () => {
queryParams.value.page = 1
getList()
}
// 重置搜索
const resetData = () => {
searchForm.value = {
code: '',
status: '',
useName: ''
}
queryParams.value.page = 1
getList()
}
// 分页处理
const handleSizeChange = (size) => {
queryParams.value.pageSize = size
getList()
}
const handleCurrentChange = (page) => {
queryParams.value.page = page
getList()
}
// 获取兑换码列表
async function getList() {
tableLoading.value = true
try {
const params = {
...queryParams.value,
...searchForm.value
}
const res = await cdkList(params)
if (res.code === 0) {
tableData.value = res.data.list || []
total.value = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取数据失败')
}
} catch (error) {
console.error('获取兑换码列表失败:', error)
ElMessage.error('获取数据失败')
} finally {
tableLoading.value = false
}
}
// 处理有效期类型变化
const handleValidityTypeChange = (type) => {
if (type === 'permanent') {
generateForm.value.expirer = 0
} else {
generateForm.value.expirer = generateForm.value.customDays || 30
}
}
// 生成兑换码
const handleGenerateCdk = () => {
generateForm.value = {
eid: Number(libraryInfo.value.ID),
number: 1,
expirer: 30,
validityType: 'custom',
customDays: 30
}
generateDialogVisible.value = true
}
// 关闭生成弹窗
const handleGenerateClose = () => {
generateDialogVisible.value = false
generateFormRef.value?.resetFields()
}
// 提交生成
const submitGenerate = async () => {
if (!generateFormRef.value) return
// 动态设置验证规则
generateFormRef.value.clearValidate()
// 根据选择的有效期类型设置expirer值
if (generateForm.value.validityType === 'permanent') {
generateForm.value.expirer = 0
} else {
generateForm.value.expirer = generateForm.value.customDays
}
// 验证表单
let isValid = true
// 验证生成数量
if (!generateForm.value.number || generateForm.value.number < 1 || generateForm.value.number > 1000) {
ElMessage.error('生成数量必须在1-1000之间')
isValid = false
}
// 验证自定义天数
if (generateForm.value.validityType === 'custom') {
if (!generateForm.value.customDays || generateForm.value.customDays < 1 || generateForm.value.customDays > 3650) {
ElMessage.error('有效期必须在1-3650天之间')
isValid = false
}
}
if (isValid) {
try {
// 准备提交的数据
const submitData = {
eid: generateForm.value.eid,
number: generateForm.value.number,
expirer: generateForm.value.expirer
}
const res = await addCdk(submitData)
if (res.code === 0) {
ElMessage.success('生成成功')
handleGenerateClose()
getList()
} else {
ElMessage.error(res.msg || '生成失败')
}
} catch (error) {
ElMessage.error('生成失败')
}
}
}
// 作废兑换码
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要作废这个兑换码吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await delCdk({ ID: row.ID })
if (res.code === 0) {
ElMessage.success('作废成功')
getList()
} else {
ElMessage.error(res.msg || '作废失败')
}
} catch (error) {
// 用户取消作废
}
}
// 返回上一页
const goBack = () => {
router.go(-1)
}
// 页面初始化
onMounted(() => {
// 从路由参数获取兑换码库信息
const { id, name } = route.query
if (id && name) {
libraryInfo.value = {
ID: id,
codeName: decodeURIComponent(name)
}
queryParams.value.eid = id
getList()
} else {
ElMessage.error('缺少兑换码库信息')
goBack()
}
})
</script>
<style lang="scss" scoped>
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: #fff;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.header-left {
display: flex;
align-items: center;
.back-btn {
margin-right: 15px;
color: #409eff;
&:hover {
color: #66b1ff;
}
}
h2 {
margin: 0;
font-size: 18px;
color: #303133;
}
}
}
.gva-search-box {
padding: 20px;
background: #fff;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stats-box {
padding: 20px;
background: #fff;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.stat-item {
text-align: center;
padding: 20px;
border-radius: 8px;
background: #f8f9fa;
&.unused {
background: #f0f9ff;
color: #0369a1;
}
&.used {
background: #fefce8;
color: #a16207;
}
&.invalid {
background: #fef2f2;
color: #dc2626;
}
.stat-value {
font-size: 28px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px;
opacity: 0.8;
}
}
}
.gva-table-box {
background: #fff;
border-radius: 4px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.gva-table {
:deep(.el-table__header) {
background-color: #f5f7fa;
}
:deep(.el-table__row:hover > td) {
background-color: #f5f7fa !important;
}
}
.gva-pagination {
margin-top: 20px;
text-align: right;
}
.demo-form-inline {
.el-form-item {
margin-right: 20px;
}
}
.code-cell {
display: flex;
align-items: center;
justify-content: space-between;
.copy-btn {
margin-left: 8px;
padding: 4px;
&:hover {
color: #409eff;
}
}
}
.text-danger {
color: #f56c6c;
font-weight: bold;
}
.text-expired {
color: #909399;
text-decoration: line-through;
}
</style>

746
src/view/cdk/index.vue Normal file
View File

@@ -0,0 +1,746 @@
<template>
<div>
<div class="cdk-library-management">
<!-- 兑换码库管理 -->
<div class="gva-search-box">
<el-form :inline="true" :model="mkSearchForm" class="demo-form-inline">
<el-form-item label="兑换码库名称">
<el-input v-model="mkSearchForm.codeName" placeholder="请输入兑换码库名称" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchMkData">查询</el-button>
<el-button @click="resetMkData">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" @click="handleAddMk">新增兑换码库</el-button>
</div>
<el-table
:data="mkTableData"
v-loading="mkTableLoading"
style="width: 100%"
class="gva-table"
stripe
>
<el-table-column prop="ID" label="ID" width="80" />
<el-table-column prop="codeName" label="兑换码库名称" min-width="150" />
<el-table-column prop="type" label="类型" width="100">
<template #default="{ row }">
{{ getTypeText(row.type) }}
</template>
</el-table-column>
<el-table-column prop="item" label="关联项目" width="150">
<template #default="{ row }">
{{ getItemDisplayName(row) }}
</template>
</el-table-column>
<el-table-column prop="num" label="兑换码数量" width="120" />
<el-table-column prop="no" label="已使用数量" width="120" />
<el-table-column label="使用率" width="100">
<template #default="{ row }">
{{ getUsageRate(row.no, row.num) }}
</template>
</el-table-column>
<el-table-column prop="CreatedAt" label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="text" @click="handleEditMk(row)">编辑</el-button>
<el-button type="text" @click="handleManageCdk(row)">兑换码管理</el-button>
<el-button type="text" @click="handleDeleteMk(row)" style="color: #f56c6c">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="mkQueryParams.page"
v-model:page-size="mkQueryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="mkTotal"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleMkSizeChange"
@current-change="handleMkCurrentChange"
class="gva-pagination"
/>
</div>
</div>
<!-- 新增/编辑兑换码库弹窗 -->
<el-dialog
v-model="mkDialogVisible"
:title="mkDialogTitle"
width="500px"
:before-close="handleMkClose"
>
<el-form
ref="mkFormRef"
:model="mkForm"
:rules="mkRules"
label-width="120px"
>
<el-form-item label="兑换码库名称" prop="codeName">
<el-input v-model="mkForm.codeName" placeholder="请输入兑换码库名称" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="mkForm.type" placeholder="请选择类型" style="width: 100%" @change="handleTypeChange">
<el-option label="VIP" :value="1" />
<el-option label="讲师VIP" :value="2" />
<el-option label="课程" :value="3" />
</el-select>
</el-form-item>
<el-form-item :label="getItemLabel(mkForm.type)" prop="item">
<el-button type="primary" @click="handleSelectItem" style="width: 100%">
{{ selectedItemName || '请选择' + getItemLabel(mkForm.type) }}
</el-button>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleMkClose">取消</el-button>
<el-button type="primary" @click="submitMk">确定</el-button>
</span>
</template>
</el-dialog>
<!-- 选择项目弹窗 -->
<el-dialog
v-model="selectItemDialogVisible"
:title="'选择' + getItemLabel(mkForm.type)"
width="900px"
:before-close="handleSelectItemClose"
>
<!-- 搜索框 -->
<div style="margin-bottom: 15px;">
<el-input
v-model="itemSearchKeyword"
placeholder="请输入关键词搜索"
clearable
@input="handleItemSearch"
style="width: 300px;"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<el-table
:data="filteredItemOptions"
v-loading="itemOptionsLoading"
style="width: 100%"
@row-click="handleItemRowClick"
highlight-current-row
>
<el-table-column prop="ID" label="ID" width="80" />
<el-table-column :prop="getItemNameProp(mkForm.type)" :label="getItemLabel(mkForm.type) + '名称'" min-width="200" />
<!-- VIP套餐相关列 -->
<template v-if="mkForm.type === 1">
<el-table-column prop="price" label="价格" width="100">
<template #default="{ row }">
¥{{ row.price }}
</template>
</el-table-column>
<el-table-column prop="duration" label="时长(天)" width="100" />
</template>
<!-- 讲师VIP相关列 -->
<template v-if="mkForm.type === 2">
<el-table-column prop="price" label="价格" width="100">
<template #default="{ row }">
¥{{ row.price }}
</template>
</el-table-column>
<el-table-column prop="teacher_name" label="讲师" width="120" />
<el-table-column prop="desc" label="描述" width="150" show-overflow-tooltip />
</template>
<!-- 课程相关列 -->
<template v-if="mkForm.type === 3">
<el-table-column prop="price" label="价格" width="100">
<template #default="{ row }">
¥{{ row.price }}
</template>
</el-table-column>
<el-table-column prop="teacherName" label="讲师" width="120" />
<el-table-column prop="isFree" label="是否免费" width="100">
<template #default="{ row }">
<el-tag :type="row.isFree === 1 ? 'success' : 'info'">
{{ row.isFree === 1 ? '免费' : '付费' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '上架' : '下架' }}
</el-tag>
</template>
</el-table-column>
</template>
</el-table>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleSelectItemClose">取消</el-button>
<el-button type="primary" @click="confirmSelectItem" :disabled="!selectedItem">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {
mkList,
addMk,
editMk,
delMk
} from '@/api/cdk/index.js'
import { list as vipList } from '@/api/goods/vip.js'
import { list as teacherVipList } from '@/api/goods/teacherVip.js'
import { list as goodsList } from '@/api/goods/index.js'
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search } from '@element-plus/icons-vue'
defineOptions({
name: 'CdkLibraryManagement'
})
const router = useRouter()
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
// 兑换码库相关数据
const mkTableLoading = ref(false)
const mkTableData = ref([])
const mkQueryParams = ref({
page: 1,
pageSize: 10
})
const mkTotal = ref(0)
const mkSearchForm = ref({
codeName: ''
})
// 兑换码库弹窗相关
const mkDialogVisible = ref(false)
const mkFormRef = ref(null)
const mkForm = ref({
ID: '',
codeName: '',
type: 1,
item: ''
})
const mkDialogTitle = computed(() => mkForm.value.ID ? '编辑兑换码库' : '新增兑换码库')
// 选择项目相关
const selectItemDialogVisible = ref(false)
const itemOptions = ref([])
const itemOptionsLoading = ref(false)
const selectedItem = ref(null)
const selectedItemName = ref('')
const itemSearchKeyword = ref('')
// 过滤后的项目选项
const filteredItemOptions = computed(() => {
if (!itemSearchKeyword.value) {
return itemOptions.value
}
const keyword = itemSearchKeyword.value.toLowerCase()
return itemOptions.value.filter(item => {
const nameProp = getItemNameProp(mkForm.value.type)
const name = item[nameProp]?.toLowerCase() || ''
const teacherName = (item.teacher_name || item.teacherName || '').toLowerCase()
const desc = (item.desc || '').toLowerCase()
return name.includes(keyword) ||
teacherName.includes(keyword) ||
desc.includes(keyword) ||
item.ID.toString().includes(keyword)
})
})
// 项目名称缓存
const itemNameCache = ref({})
// 表单验证规则
const mkRules = {
codeName: [
{ required: true, message: '请输入兑换码库名称', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择类型', trigger: 'change' }
],
item: [
{ required: true, message: '请选择对应的项目', trigger: 'change' }
]
}
// 获取类型文本
const getTypeText = (type) => {
const typeMap = {
1: 'VIP',
2: '讲师VIP',
3: '课程'
}
return typeMap[type] || '未知'
}
// 获取使用率
const getUsageRate = (used, total) => {
if (!total || total === 0) return '0%'
const rate = ((used || 0) / total * 100).toFixed(1)
return `${rate}%`
}
// 获取项目标签
const getItemLabel = (type) => {
const labelMap = {
1: 'VIP套餐',
2: '讲师VIP',
3: '课程'
}
return labelMap[type] || '项目'
}
// 获取项目名称属性
const getItemNameProp = (type) => {
const propMap = {
1: 'name',
2: 'title',
3: 'title'
}
return propMap[type] || 'name'
}
// 处理类型变化
const handleTypeChange = (type) => {
// 清空已选择的项目
mkForm.value.item = ''
selectedItem.value = null
selectedItemName.value = ''
console.log('类型变更为:', getTypeText(type))
}
// 处理选择项目
const handleSelectItem = async () => {
if (!mkForm.value.type) {
ElMessage.warning('请先选择类型')
return
}
await loadItemOptions()
selectItemDialogVisible.value = true
}
// 加载项目选项
const loadItemOptions = async () => {
itemOptionsLoading.value = true
try {
let res
const params = { page: 1, pageSize: 1000 }
switch (mkForm.value.type) {
case 1: // VIP
res = await vipList(params)
break
case 2: // 讲师VIP
res = await teacherVipList(params)
break
case 3: // 课程
res = await goodsList(params)
break
default:
itemOptions.value = []
return
}
if (res.code === 0) {
itemOptions.value = res.data.list || []
} else {
ElMessage.error(res.msg || '获取数据失败')
itemOptions.value = []
}
} catch (error) {
console.error('加载项目选项失败:', error)
ElMessage.error('加载数据失败')
itemOptions.value = []
} finally {
itemOptionsLoading.value = false
}
}
// 处理项目行点击
const handleItemRowClick = (row) => {
selectedItem.value = row
}
// 确认选择项目
const confirmSelectItem = () => {
if (!selectedItem.value) {
ElMessage.warning('请选择一个项目')
return
}
mkForm.value.item = selectedItem.value.ID
const nameProp = getItemNameProp(mkForm.value.type)
selectedItemName.value = selectedItem.value[nameProp]
handleSelectItemClose()
}
// 关闭选择项目弹窗
const handleSelectItemClose = () => {
selectItemDialogVisible.value = false
selectedItem.value = null
itemSearchKeyword.value = ''
}
// 处理项目搜索
const handleItemSearch = () => {
// 搜索逻辑已在 computed 中处理
}
// 获取项目显示名称
const getItemDisplayName = (row) => {
const cacheKey = `${row.type}_${row.item}`
if (itemNameCache.value[cacheKey]) {
return itemNameCache.value[cacheKey]
}
return `ID: ${row.item}`
}
// 加载项目名称缓存
const loadItemNameCache = async () => {
try {
// 获取所有类型的数据并缓存名称
const [vipRes, teacherVipRes, goodsRes] = await Promise.all([
vipList({ page: 1, pageSize: 1000 }),
teacherVipList({ page: 1, pageSize: 1000 }),
goodsList({ page: 1, pageSize: 1000 })
])
// 缓存VIP名称
if (vipRes.code === 0 && vipRes.data.list) {
vipRes.data.list.forEach(item => {
itemNameCache.value[`1_${item.ID}`] = item.name
})
}
// 缓存讲师VIP名称
if (teacherVipRes.code === 0 && teacherVipRes.data.list) {
teacherVipRes.data.list.forEach(item => {
itemNameCache.value[`2_${item.ID}`] = item.title
})
}
// 缓存课程名称
if (goodsRes.code === 0 && goodsRes.data.list) {
goodsRes.data.list.forEach(item => {
itemNameCache.value[`3_${item.ID}`] = item.title
})
}
} catch (error) {
console.error('加载项目名称缓存失败:', error)
}
}
// 兑换码库相关方法
const searchMkData = () => {
mkQueryParams.value.page = 1
getMkList()
}
const resetMkData = () => {
mkSearchForm.value = {
codeName: ''
}
mkQueryParams.value = {
page: 1,
pageSize: 10
}
getMkList()
}
const handleMkSizeChange = (size) => {
mkQueryParams.value.pageSize = size
getMkList()
}
const handleMkCurrentChange = (page) => {
mkQueryParams.value.page = page
getMkList()
}
async function getMkList() {
mkTableLoading.value = true
try {
const params = {
...mkQueryParams.value,
...mkSearchForm.value
}
const res = await mkList(params)
if (res.code === 0) {
mkTableData.value = res.data.list || []
mkTotal.value = res.data.total || 0
}
} finally {
mkTableLoading.value = false
}
}
// 获取兑换码库选项
async function getMkOptions() {
try {
const res = await mkList({ page: 1, pageSize: 1000 })
if (res.code === 0) {
mkOptions.value = res.data.list || []
}
} catch (error) {
console.error('获取兑换码库选项失败:', error)
}
}
const handleAddMk = () => {
mkForm.value = {
codeName: '',
type: 1,
item: ''
}
selectedItemName.value = ''
selectedItem.value = null
mkDialogVisible.value = true
}
const handleEditMk = async (row) => {
mkForm.value = {
ID: row.ID,
codeName: row.codeName,
type: row.type,
item: row.item
}
// 获取已选择项目的名称
await loadItemOptions()
const nameProp = getItemNameProp(row.type)
const selectedItemData = itemOptions.value.find(item => item.ID === row.item)
selectedItemName.value = selectedItemData ? selectedItemData[nameProp] : `ID: ${row.item}`
mkDialogVisible.value = true
}
const handleManageCdk = (row) => {
// 跳转到兑换码管理页面传递兑换码库ID和名称
router.push({
path: 'cdkManage',
query: {
id: row.ID,
name: encodeURIComponent(row.codeName)
}
})
}
const handleDeleteMk = async (row) => {
try {
await ElMessageBox.confirm('确定要删除这个兑换码库吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await delMk({ ID: row.ID })
if (res.code === 0) {
ElMessage.success('删除成功')
getMkList()
} else {
ElMessage.error(res.msg || '删除失败')
}
} catch (error) {
// 用户取消删除
}
}
const handleMkClose = () => {
mkDialogVisible.value = false
mkFormRef.value?.resetFields()
selectedItemName.value = ''
selectedItem.value = null
}
const submitMk = async () => {
if (!mkFormRef.value) return
await mkFormRef.value.validate(async (valid) => {
if (valid) {
try {
const isEdit = !!mkForm.value.ID
const res = isEdit ? await editMk(mkForm.value) : await addMk(mkForm.value)
if (res.code === 0) {
ElMessage.success(isEdit ? '编辑成功' : '新增成功')
handleMkClose()
getMkList()
} else {
ElMessage.error(res.msg || (isEdit ? '编辑失败' : '新增失败'))
}
} catch (error) {
ElMessage.error('操作失败')
}
}
})
}
// 兑换码相关方法
const searchCdkData = () => {
cdkQueryParams.value.page = 1
getCdkList()
}
const resetCdkData = () => {
cdkSearchForm.value = {
code: '',
mkId: '',
status: ''
}
cdkQueryParams.value = {
page: 1,
pageSize: 10
}
getCdkList()
}
const handleCdkSizeChange = (size) => {
cdkQueryParams.value.pageSize = size
getCdkList()
}
const handleCdkCurrentChange = (page) => {
cdkQueryParams.value.page = page
getCdkList()
}
async function getCdkList() {
cdkTableLoading.value = true
try {
const params = {
...cdkQueryParams.value,
...cdkSearchForm.value
}
const res = await cdkList(params)
if (res.code === 0) {
cdkTableData.value = res.data.list || []
cdkTotal.value = res.data.total || 0
}
} finally {
cdkTableLoading.value = false
}
}
const handleDeleteCdk = async (row) => {
try {
await ElMessageBox.confirm('确定要作废这个兑换码吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const res = await delCdk({ ID: row.ID })
if (res.code === 0) {
ElMessage.success('作废成功')
getCdkList()
} else {
ElMessage.error(res.msg || '作废失败')
}
} catch (error) {
// 用户取消作废
}
}
// 页面初始化
onMounted(() => {
loadItemNameCache()
getMkList()
})
</script>
<style lang="scss" scoped>
.cdk-library-management {
// 兑换码库管理样式
}
.gva-search-box {
padding: 20px;
background: #fff;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.gva-table-box {
background: #fff;
border-radius: 4px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.gva-btn-list {
margin-bottom: 20px;
}
}
.gva-table {
:deep(.el-table__header) {
background-color: #f5f7fa;
}
:deep(.el-table__row:hover > td) {
background-color: #f5f7fa !important;
}
}
.gva-pagination {
margin-top: 20px;
text-align: right;
}
.demo-form-inline {
.el-form-item {
margin-right: 20px;
}
}
.text-danger {
color: #f56c6c;
font-weight: bold;
}
</style>

394
src/view/domain/index.vue Normal file
View File

@@ -0,0 +1,394 @@
<template>
<div>
<div class="gva-search-box">
<el-form ref="elSearchFormRef" :inline="true" :model="searchInfo" class="demo-form-inline" :rules="searchRule" @keyup.enter="onSubmit">
<el-form-item label="创建日期" prop="createdAt">
<template #label>
<span>
创建日期
<el-tooltip content="搜索范围是开始日期(包含)至结束日期(不包含)">
<el-icon><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<el-date-picker v-model="searchInfo.startCreatedAt" type="datetime" placeholder="开始日期" :disabled-date="time=> searchInfo.endCreatedAt ? time.getTime() > searchInfo.endCreatedAt.getTime() : false"></el-date-picker>
<el-date-picker v-model="searchInfo.endCreatedAt" type="datetime" placeholder="结束日期" :disabled-date="time=> searchInfo.startCreatedAt ? time.getTime() < searchInfo.startCreatedAt.getTime() : false"></el-date-picker>
</el-form-item>
<template v-if="showAllQuery">
<el-form-item label="域名名称">
<el-input v-model="searchInfo.name" placeholder="请输入名称" clearable style="width: 200px" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchInfo.status" placeholder="全部" clearable style="width: 180px">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="类型">
<el-select v-model="searchInfo.type" placeholder="全部" clearable style="width: 180px">
<el-option label="炮灰域名" :value="1" />
<el-option label="入口域名" :value="2" />
</el-select>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
<el-button icon="refresh" @click="onReset">重置</el-button>
<el-button link type="primary" icon="arrow-down" @click="showAllQuery=true" v-if="!showAllQuery">展开</el-button>
<el-button link type="primary" icon="arrow-up" @click="showAllQuery=false" v-else>收起</el-button>
</el-form-item>
</el-form>
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" icon="plus" @click="openDialog()">新增</el-button>
<el-button icon="delete" style="margin-left: 10px;" :disabled="!multipleSelection.length" @click="onDelete">删除</el-button>
</div>
<el-table
ref="multipleTable"
style="width: 100%"
tooltip-effect="dark"
:data="tableData"
row-key="ID"
@selection-change="handleSelectionChange"
@sort-change="sortChange"
>
<el-table-column type="selection" width="55" />
<el-table-column align="left" label="日期" prop="CreatedAt" width="180">
<template #default="scope">{{ formatDate(scope.row.CreatedAt) }}</template>
</el-table-column>
<el-table-column sortable align="left" label="名称" prop="name" min-width="140" />
<el-table-column align="left" label="域名" prop="domain_url" min-width="220">
<template #default="scope">
<el-link :href="scope.row.domain_url" target="_blank" type="primary">{{ scope.row.domain_url }}</el-link>
</template>
</el-table-column>
<el-table-column align="left" label="描述" prop="description" min-width="200" show-overflow-tooltip />
<el-table-column align="left" label="状态" prop="status" width="120">
<template #default="scope">{{ formatStatus(scope.row.status) }}</template>
</el-table-column>
<el-table-column align="left" label="类型" prop="type" width="120">
<template #default="scope">{{ formatType(scope.row.type) }}</template>
</el-table-column>
<el-table-column align="left" label="操作" fixed="right" :min-width="appStore.operateMinWith">
<template #default="scope">
<el-button type="primary" link class="table-button" @click="getDetails(scope.row)"><el-icon style="margin-right: 5px"><InfoFilled /></el-icon>查看</el-button>
<el-button type="primary" link icon="edit" class="table-button" @click="updateDomainFunc(scope.row)">编辑</el-button>
<el-button type="primary" link icon="delete" @click="deleteRow(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination
layout="total, sizes, prev, pager, next, jumper"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<el-drawer destroy-on-close :size="appStore.drawerSize" v-model="dialogFormVisible" :show-close="false" :before-close="closeDialog">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">{{ type==='create' ? '新增' : '编辑' }}</span>
<div>
<el-button :loading="btnLoading" type="primary" @click="enterDialog"> </el-button>
<el-button @click="closeDialog"> </el-button>
</div>
</div>
</template>
<el-form :model="formData" label-position="top" ref="elFormRef" :rules="rule" label-width="80px">
<el-form-item label="名称:" prop="name">
<el-input v-model="formData.name" placeholder="请输入名称" />
</el-form-item>
<el-form-item label="域名地址:" prop="domain_url">
<el-input v-model="formData.domain_url" placeholder="例如https://example.com" />
</el-form-item>
<el-form-item label="描述:" prop="description">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="请输入描述" />
</el-form-item>
<el-form-item label="状态:" prop="status">
<el-select v-model="formData.status" placeholder="请选择" style="width: 100%">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="类型:" prop="type">
<el-select v-model="formData.type" placeholder="请选择" style="width: 100%">
<el-option label="炮灰域名" :value="1" />
<el-option label="入口域名" :value="2" />
</el-select>
</el-form-item>
</el-form>
</el-drawer>
<el-drawer destroy-on-close :size="appStore.drawerSize" v-model="detailShow" :show-close="true" :before-close="closeDetailShow" title="查看">
<el-descriptions :column="1" border>
<el-descriptions-item label="名称">{{ detailFrom.name }}</el-descriptions-item>
<el-descriptions-item label="域名">
<el-link :href="detailFrom.domain_url" target="_blank" type="primary">{{ detailFrom.domain_url }}</el-link>
</el-descriptions-item>
<el-descriptions-item label="描述">{{ detailFrom.description }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ formatStatus(detailFrom.status) }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ formatType(detailFrom.type) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(detailFrom.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(detailFrom.UpdatedAt) }}</el-descriptions-item>
</el-descriptions>
</el-drawer>
</div>
</template>
<script setup>
import { list, addDomain, deleteDomain, updateDomain, getDomain } from '@/api/domain'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref, reactive } from 'vue'
import { useAppStore } from "@/pinia"
import { formatDate } from '@/utils/format'
defineOptions({ name: 'Domain' })
const appStore = useAppStore()
const btnLoading = ref(false)
const showAllQuery = ref(false)
const formData = ref({
name: '',
domain_url: '',
description: '',
status: undefined,
type: undefined,
})
const rule = reactive({
name: [
{ required: true, message: '请输入名称', trigger: ['input','blur'] },
{ whitespace: true, message: '不能只输入空格', trigger: ['input','blur'] },
],
domain_url: [
{ required: true, message: '请输入域名地址', trigger: ['input','blur'] },
],
status: [
{ required: true, message: '请选择状态', trigger: ['change','blur'] },
],
type: [
{ required: true, message: '请选择类型', trigger: ['change','blur'] },
],
})
const searchRule = reactive({
createdAt: [
{ validator: (rule, value, callback) => {
if (searchInfo.value.startCreatedAt && !searchInfo.value.endCreatedAt) {
callback(new Error('请填写结束日期'))
} else if (!searchInfo.value.startCreatedAt && searchInfo.value.endCreatedAt) {
callback(new Error('请填写开始日期'))
} else if (searchInfo.value.startCreatedAt && searchInfo.value.endCreatedAt && (searchInfo.value.startCreatedAt.getTime() === searchInfo.value.endCreatedAt.getTime() || searchInfo.value.startCreatedAt.getTime() > searchInfo.value.endCreatedAt.getTime())) {
callback(new Error('开始日期应当早于结束日期'))
} else {
callback()
}
}, trigger: 'change' }
],
})
const elFormRef = ref()
const elSearchFormRef = ref()
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const searchInfo = ref({})
const sortChange = ({ prop, order }) => {
const sortMap = { name: 'name', CreatedAt: 'created_at' }
let sort = sortMap[prop]
if(!sort){
sort = prop && prop.replace(/[A-Z]/g, match => `_${match.toLowerCase()}`)
}
searchInfo.value.sort = sort
searchInfo.value.order = order
getTableData()
}
const onReset = () => {
searchInfo.value = {}
getTableData()
}
const onSubmit = () => {
elSearchFormRef.value?.validate(async(valid) => {
if (!valid) return
page.value = 1
getTableData()
})
}
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
const getTableData = async() => {
const table = await list({ page: page.value, pageSize: pageSize.value, ...searchInfo.value })
if (table.code === 0) {
tableData.value = table.data.list
total.value = table.data.total
page.value = table.data.page
pageSize.value = table.data.pageSize
}
}
getTableData()
const multipleSelection = ref([])
const handleSelectionChange = (val) => {
multipleSelection.value = val
}
const deleteRow = (row) => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
deleteDomainFunc(row)
})
}
const onDelete = async() => {
ElMessageBox.confirm('确定要删除吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async() => {
if (multipleSelection.value.length === 0) {
ElMessage({ type: 'warning', message: '请选择要删除的数据' })
return
}
const results = await Promise.all(
multipleSelection.value.map(item => deleteDomain({ ID: item.ID }))
)
const ok = results.every(r => r && r.code === 0)
if (ok) {
ElMessage({ type: 'success', message: '删除成功' })
if (tableData.value.length === multipleSelection.value.length && page.value > 1) {
page.value--
}
getTableData()
}
})
}
const type = ref('')
const updateDomainFunc = async(row) => {
const res = await getDomain(row.ID)
type.value = 'update'
if (res.code === 0) {
formData.value = { ...res.data }
dialogFormVisible.value = true
}
}
const deleteDomainFunc = async (row) => {
const res = await deleteDomain({ ID: row.ID })
if (res.code === 0) {
ElMessage({ type: 'success', message: '删除成功' })
if (tableData.value.length === 1 && page.value > 1) {
page.value--
}
getTableData()
}
}
const dialogFormVisible = ref(false)
const openDialog = () => {
type.value = 'create'
dialogFormVisible.value = true
}
const closeDialog = () => {
dialogFormVisible.value = false
formData.value = {
name: '',
domain_url: '',
description: '',
status: undefined,
type: undefined,
}
}
const enterDialog = async () => {
btnLoading.value = true
elFormRef.value?.validate( async (valid) => {
if (!valid) return btnLoading.value = false
let res
switch (type.value) {
case 'create':
res = await addDomain(formData.value)
break
case 'update':
res = await updateDomain(formData.value)
break
default:
res = await addDomain(formData.value)
break
}
btnLoading.value = false
if (res.code === 0) {
ElMessage({ type: 'success', message: '创建/更改成功' })
closeDialog()
getTableData()
}
})
}
const detailFrom = ref({})
const detailShow = ref(false)
const openDetailShow = () => { detailShow.value = true }
const getDetails = async (row) => {
const res = await getDomain(row.ID)
if (res.code === 0) {
detailFrom.value = res.data
openDetailShow()
}
}
const closeDetailShow = () => {
detailShow.value = false
detailFrom.value = {}
}
const formatStatus = (s) => s === 1 ? '启用' : s === 2 ? '禁用' : '-'
const formatType = (t) => t === 1 ? '炮灰域名' : t === 2 ? '入口域名' : '-'
</script>
<style>
</style>

View File

View File

@@ -0,0 +1,279 @@
<template>
<div>
<div class="gva-search-box">
<el-form :inline="true" :model="searchInfo" class="demo-form-inline" @keyup.enter="onSubmit">
<el-form-item label="讲师">
<div style="display:flex;gap:8px;align-items:center">
<el-input v-model="searchInfo.teacher_name" placeholder="请选择讲师" clearable style="width: 220px" readonly />
<el-button type="primary" plain @click="openTeacherChoose('search')">选择讲师</el-button>
<el-button @click="clearTeacher('search')">清空</el-button>
</div>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="search" @click="onSubmit">查询</el-button>
<el-button icon="refresh" @click="onReset">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="gva-table-box">
<div class="gva-btn-list">
<el-button type="primary" icon="plus" @click="openEdit('create')">新增</el-button>
</div>
<el-table
ref="multipleTable"
style="width: 100%"
tooltip-effect="dark"
:data="tableData"
row-key="ID"
>
<el-table-column type="index" label="#" width="60" />
<el-table-column align="left" label="ID" prop="ID" width="100" />
<el-table-column align="left" label="标题" prop="title" min-width="160" />
<el-table-column align="left" label="讲师ID" prop="teacher_id" width="110" />
<el-table-column align="left" label="讲师" prop="teacher_name" min-width="140" />
<el-table-column align="left" label="头像" width="120">
<template #default="scope">
<el-image
v-if="scope.row.avatar"
style="width: 60px; height: 60px"
:src="scope.row.avatar"
:preview-src-list="[scope.row.avatar]"
fit="cover"
/>
<span v-else></span>
</template>
</el-table-column>
<el-table-column align="left" label="价格" prop="price" width="120">
<template #default="{ row }">
{{ formatPrice(row.price) }}
</template>
</el-table-column>
<el-table-column align="left" label="更新时间" prop="UpdatedAt" width="180">
<template #default="{ row }">{{ formatDate(row.UpdatedAt) }}</template>
</el-table-column>
<el-table-column align="left" label="描述" prop="desc" min-width="200" show-overflow-tooltip />
<el-table-column align="left" label="操作" fixed="right" width="160">
<template #default="{ row }">
<el-button type="primary" link @click="openEdit('update', row)">编辑</el-button>
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="gva-pagination">
<el-pagination
layout="total, sizes, prev, pager, next, jumper"
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
:total="total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</div>
<!-- 选择讲师弹窗 -->
<userChoose ref="userChooseRef" title="选择讲师人员" @getRecipientInfo="onChooseTeacher" />
<!-- 新增/编辑 抽屉 -->
<el-drawer destroy-on-close :size="'520px'" v-model="dialogVisible" :show-close="false" :before-close="closeDialog">
<template #header>
<div class="flex justify-between items-center">
<span class="text-lg">{{ editType==='create'?'新增讲师包月':'编辑讲师包月' }}</span>
<div>
<el-button :loading="btnLoading" type="primary" @click="submitForm"> </el-button>
<el-button @click="closeDialog"> </el-button>
</div>
</div>
</template>
<el-form :model="formData" ref="formRef" label-position="top" :rules="rules">
<el-form-item label="标题" prop="title">
<el-input v-model="formData.title" placeholder="请输入标题" />
</el-form-item>
<el-form-item label="讲师" prop="teacher_id">
<div style="display:flex;gap:8px;align-items:center;width:100%">
<el-input v-model="formData.teacher_name" placeholder="请选择讲师" readonly />
<el-button type="primary" plain @click="openTeacherChoose('form')">选择讲师</el-button>
</div>
</el-form-item>
<el-form-item label="价格(元)" prop="price">
<el-input v-model.number="formData.price" type="number" min="0" placeholder="请输入价格" />
</el-form-item>
<el-form-item label="描述" prop="desc">
<el-input v-model="formData.desc" type="textarea" :rows="4" placeholder="请输入描述" />
</el-form-item>
</el-form>
</el-drawer>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { list as getTeacherVipList, add as createTeacherVip, edit as updateTeacherVip, del as deleteTeacherVip, detail as getDetail } from '@/api/goods/teacherVip'
import { ElMessageBox, ElMessage } from 'element-plus'
import { formatDate } from '@/utils/format'
import userChoose from '@/components/userChoose/index.vue'
const page = ref(1)
const total = ref(0)
const pageSize = ref(10)
const tableData = ref([])
const searchInfo = ref({ teacher_id: undefined, teacher_name: '' })
// 讲师选择
const userChooseRef = ref()
const chooseScene = ref('search')
const openTeacherChoose = (scene) => {
chooseScene.value = scene
userChooseRef.value && userChooseRef.value.open()
}
const clearTeacher = (scene) => {
if (scene === 'search') {
searchInfo.value.teacher_id = undefined
searchInfo.value.teacher_name = ''
} else {
formData.value.teacher_id = undefined
formData.value.teacher_name = ''
}
}
const onChooseTeacher = (data) => {
const id = data.id || data.ID
const name = data.nick_name || data.username || data.name
if (chooseScene.value === 'search') {
searchInfo.value.teacher_id = id
searchInfo.value.teacher_name = name
} else {
formData.value.teacher_id = id
formData.value.teacher_name = name
}
}
const formatPrice = (val) => {
if (val === null || val === undefined) return '-'
const yuan = Number(val) / 100
return `${yuan.toFixed(2)}`
}
const onReset = () => {
searchInfo.value = { teacher_id: undefined }
page.value = 1
getTableData()
}
const onSubmit = () => {
page.value = 1
getTableData()
}
const handleSizeChange = (val) => {
pageSize.value = val
getTableData()
}
const handleCurrentChange = (val) => {
page.value = val
getTableData()
}
const getTableData = async () => {
const params = { page: page.value, pageSize: pageSize.value }
if (searchInfo.value.teacher_id !== undefined && searchInfo.value.teacher_id !== '' && searchInfo.value.teacher_id !== null) {
params.teacher_id = searchInfo.value.teacher_id
}
const res = await getTeacherVipList(params)
if (res.code === 0) {
tableData.value = res.data.list || []
total.value = res.data.total || 0
page.value = res.data.page || page.value
pageSize.value = res.data.pageSize || pageSize.value
}
}
getTableData()
// ============== 新增/编辑/删除 ==============
const dialogVisible = ref(false)
const formRef = ref()
const btnLoading = ref(false)
const editType = ref('create')
const formData = ref({
ID: undefined,
title: '',
teacher_id: undefined,
teacher_name: '',
price: 0,
desc: ''
})
const rules = {
title: [{ required: true, message: '请输入标题', trigger: ['blur','input'] }],
teacher_id: [{ required: true, message: '请选择讲师', trigger: ['change','blur'] }],
price: [{ required: true, message: '请输入价格', trigger: ['blur','input'] }]
}
const resetForm = () => {
formData.value = { ID: undefined, title: '', teacher_id: undefined, teacher_name: '', price: 0, desc: '' }
}
const openEdit = async(type, row) => {
editType.value = type
if (type === 'update' && row?.ID) {
const res = await getDetail({ id: row.ID })
if (res.code === 0) {
const data = { ...res.data }
if (data.price !== null && data.price !== undefined) {
data.price = Number(data.price) / 100
}
formData.value = { ...formData.value, ...data }
}
} else {
resetForm()
}
dialogVisible.value = true
}
const closeDialog = () => {
dialogVisible.value = false
resetForm()
}
const submitForm = () => {
formRef.value?.validate(async(valid)=>{
if (!valid) return
btnLoading.value = true
const payload = { ...formData.value }
// 元 -> 分
if (payload.price !== null && payload.price !== undefined) {
payload.price = Math.round(Number(payload.price) * 100)
}
let res
if (editType.value === 'create') {
res = await createTeacherVip(payload)
} else {
res = await updateTeacherVip(payload)
}
btnLoading.value = false
if (res && res.code === 0) {
ElMessage.success('保存成功')
closeDialog()
getTableData()
}
})
}
const handleDelete = async(row) => {
try {
await ElMessageBox.confirm('确定要删除该记录吗?','提示',{ type:'warning' })
const res = await deleteTeacherVip({ ID: row.ID })
if (res.code === 0) {
ElMessage.success('删除成功')
getTableData()
}
} catch { /* 用户取消 */ }
}
</script>
<style scoped>
</style>

View File

@@ -12,7 +12,7 @@
<div class="gva-btn-list">
<el-button type="info" @click="exportOrders">导出订单</el-button>
</div>
<!-- 订单统计信息 -->
<div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px;">
<el-row :gutter="20">
@@ -66,7 +66,8 @@
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="order_no" label="订单号" align="center" min-width="180" />
<el-table-column prop="title" label="商品名称" align="center" min-width="150" />
<el-table-column prop="name" label="用户姓名" align="center" width="100" />
<el-table-column prop="name" label="用户姓名" align="center" width="100" />
<el-table-column prop="user_id" label="用户ID" align="center" width="100" />
<el-table-column label="价格" align="center" width="100">
<template #default="{ row }">
<span class="text-green-600 font-bold">{{ formatPrice(row.price) }}</span>
@@ -106,7 +107,7 @@
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div style="margin-top: 20px; text-align: right;">
<el-pagination
@@ -308,7 +309,7 @@ function getOrderStats() {
.gva-btn-list {
margin-bottom: 20px;
}
.el-row {
.el-col {
.el-statistic {
@@ -317,12 +318,12 @@ function getOrderStats() {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.el-statistic__number {
font-size: 24px;
font-weight: bold;
}
.el-statistic__label {
color: #666;
margin-top: 8px;

View File

@@ -53,7 +53,7 @@
<!--user_label 1: 普通用户 2: VIP 3:SVIP-->
<template #user_label="{ row }">
{{ row.user_label === 1 ? '注册会员' : row.user_label === 2 ? 'VIP' : 'SVIP' }}
{{ row.user_label === 1 ? '注册会员' : row.user_label === 2 ? 'VIP' : row.user_label === 3 ? 'SVIP':row.user_label === 4 ? '过期VIP':'未知' }}
</template>
<!--balance 账户余额-->
@@ -74,7 +74,7 @@
<el-dialog
v-model="balanceDialogVisible"
title="修改余额"
width="400px"
width="450px"
:before-close="handleBalanceClose"
>
<el-form
@@ -84,17 +84,30 @@
label-width="100px"
>
<el-form-item label="当前余额">
<span>{{ currentUser.balance }}</span>
<span style="font-weight: bold; color: #409eff;">¥{{ currentUser.balance }}</span>
</el-form-item>
<el-form-item label="新余额" prop="balance">
<el-form-item label="操作类型" prop="change_type">
<el-select v-model="balanceForm.change_type" placeholder="请选择操作类型" style="width: 100%">
<el-option label="增加余额" :value="1" />
<el-option label="减少余额" :value="2" />
</el-select>
</el-form-item>
<el-form-item :label="balanceForm.change_type === 1 ? '增加金额' : '减少金额'" prop="balance">
<el-input-number
v-model="balanceForm.balance"
:precision="2"
:step="0.01"
:min="0"
placeholder="请输入新余额"
:min="0.01"
:max="balanceForm.change_type === 2 ? currentUser.balance : 999999"
:placeholder="balanceForm.change_type === 1 ? '请输入要增加的金额' : '请输入要减少的金额'"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="操作后余额">
<span style="font-weight: bold; color: #67c23a;">
¥{{ getNewBalance() }}
</span>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
@@ -118,13 +131,14 @@
label-width="100px"
>
<el-form-item label="当前标签">
<span>{{ currentUser.user_label === 1 ? '注册会员' : currentUser.user_label === 2 ? 'VIP' : 'SVIP' }}</span>
<span>{{ currentUser.user_label === 1 ? '注册会员' : currentUser.user_label === 2 ? 'VIP' : currentUser.user_label === 3 ? 'SVIP' : currentUser.user_label === 4 ? '过期VIP' :"未知" }}</span>
</el-form-item>
<el-form-item label="VIP类型" prop="user_label">
<el-select v-model="vipForm.user_label" placeholder="请选择VIP类型">
<el-option label="注册会员" :value="1" />
<el-option label="VIP" :value="2" />
<el-option label="SVIP" :value="3" />
<el-option label="过期VIP" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="到期时间" prop="vip_expire_time">
@@ -168,7 +182,7 @@
{{ userDetail.user_type === 1 ? '普通用户' : '讲师' }}
</el-descriptions-item>
<el-descriptions-item label="用户标签">
{{ userDetail.user_label === 1 ? '注册会员' : userDetail.user_label === 2 ? 'VIP' : 'SVIP' }}
{{ userDetail.user_label === 1 ? '注册会员' : userDetail.user_label === 2 ? 'VIP' : userDetail.user_label === 3 ? 'SVIP' : userDetail.user_label === 4 ? '过期VIP':"未知"}}
</el-descriptions-item>
<el-descriptions-item label="账户余额">{{ userDetail.balance }}</el-descriptions-item>
<el-descriptions-item label="状态">
@@ -210,6 +224,7 @@
<el-option label="注册会员" :value="1" />
<el-option label="VIP" :value="2" />
<el-option label="SVIP" :value="3" />
<el-option label="过期VIP用户" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="昵称" prop="nick_name">
@@ -362,14 +377,18 @@
const balanceFormRef = ref(null)
const currentUser = ref({})
const balanceForm = ref({
ID: '',
balance: 0
id: '',
balance: 0,
change_type: 1
})
const balanceRules = {
change_type: [
{ required: true, message: '请选择操作类型', trigger: 'change' }
],
balance: [
{ required: true, message: '请输入额', trigger: 'blur' },
{ type: 'number', min: 0, message: '余额不能小于0', trigger: 'blur' }
{ required: true, message: '请输入额', trigger: 'blur' },
{ type: 'number', min: 0.01, message: '金额必须大于0', trigger: 'blur' }
]
}
@@ -393,12 +412,31 @@
function handleBalance(row) {
currentUser.value = row
balanceForm.value = {
ID: row.ID,
balance: row.balance
id: row.ID,
balance: 0,
change_type: 1
}
balanceDialogVisible.value = true
}
// 计算操作后的新余额
function getNewBalance() {
if (!balanceForm.value.balance || !currentUser.value.balance) {
return currentUser.value.balance || 0
}
const currentBalance = parseFloat(currentUser.value.balance)
const changeAmount = parseFloat(balanceForm.value.balance)
if (balanceForm.value.change_type === 1) {
// 增加
return (currentBalance + changeAmount).toFixed(2)
} else {
// 减少
return Math.max(0, currentBalance - changeAmount).toFixed(2)
}
}
function handleBalanceClose() {
balanceDialogVisible.value = false
balanceFormRef.value?.resetFields()
@@ -406,6 +444,18 @@
async function submitBalance() {
if (!balanceFormRef.value) return
// 额外验证:减少余额时不能超过当前余额
if (balanceForm.value.change_type === 2) {
const currentBalance = parseFloat(currentUser.value.balance)
const reduceAmount = parseFloat(balanceForm.value.balance)
if (reduceAmount > currentBalance) {
ElMessage.error('减少金额不能超过当前余额')
return
}
}
await balanceFormRef.value.validate(async (valid) => {
if (valid) {
const res = await setBalance(balanceForm.value)

View File

@@ -0,0 +1,106 @@
<template>
<div>
<div class="searchForm">
<searchForm
:search="LOGIN_LOG_SEARCH_CONFIG"
@searchData="searchData"
@resetData="resetData"
class="search-box searchForm"
/>
</div>
<div class="gva-table-box">
<Content
@changePage="changePage"
:total="total"
v-model:tabloading="tableLoading"
v-model:currentPage="queryParams.page"
v-model:pageSize="queryParams.pageSize"
:data="tableData"
:config="LOGIN_LOG_TABLE_CONFIG"
>
<template #login_time="{ row }">
{{ row.login_time }}
</template>
</Content>
</div>
</div>
</template>
<script setup>
import { ElMessage } from 'element-plus'
import searchForm from '@/components/searchForm/index.vue'
import Content from '@/components/content/index.vue'
import { getLoginInfo } from '@/api/user/index.js'
import { ref, onMounted } from 'vue'
import { LOGIN_LOG_SEARCH_CONFIG, LOGIN_LOG_TABLE_CONFIG } from '@/config'
const tableLoading = ref(true)
const tableData = ref([])
const queryParams = ref({
page: 1,
pageSize: 10,
user_id: '',
user_name: ''
})
const total = ref(0)
// 搜索数据
const searchData = (data) => {
queryParams.value.page = 1
queryParams.value = { ...queryParams.value, ...data }
getList()
}
// 重置数据
const resetData = () => {
queryParams.value = {
page: 1,
pageSize: 10,
user_id: '',
user_name: ''
}
getList()
}
// 获取登录日志列表
async function getList() {
try {
tableLoading.value = true
const res = await getLoginInfo(queryParams.value)
if (res.code === 0) {
tableData.value = res.data.list || []
total.value = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取登录日志失败')
}
} catch (error) {
console.error('获取登录日志失败:', error)
ElMessage.error('获取登录日志失败')
} finally {
tableLoading.value = false
}
}
// 分页处理
function changePage(data) {
queryParams.value.page = data
getList()
}
// 页面加载时获取数据
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
.searchForm {
margin-bottom: 20px;
}
.gva-table-box {
background: white;
border-radius: 4px;
padding: 20px;
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<div>
<div class="searchForm">
<searchForm
:search="VIP_USER_SEARCH_CONFIG"
@searchData="searchData"
@resetData="resetData"
class="search-box searchForm"
/>
</div>
<div class="gva-table-box">
<Content
@changePage="changePage"
:total="total"
v-model:tabloading="tableLoading"
v-model:currentPage="queryParams.page"
v-model:pageSize="queryParams.pageSize"
:data="tableData"
:config="VIP_USER_TABLE_CONFIG"
>
<template #avatar="{ row }">
<el-image
style="width: 60px; height: 60px"
:src="row.avatar"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
:preview-src-list="[row.avatar]"
show-progress
:initial-index="4"
fit="cover"
/>
</template>
<template #user_label="{ row }">
<el-tag :type="getVipLabelType(row.user_label)">
{{ getVipLabelText(row.user_label) }}
</el-tag>
</template>
<template #vip_expire_time="{ row }">
{{ row.vip_expire_time || '-' }}
</template>
<template #balance="{ row }">
{{ formatBalance(row.balance) }}
</template>
<template #operate="{ row }">
<el-button type="danger" link @click="handleRemoveVip(row)">
移除VIP
</el-button>
</template>
</Content>
</div>
</div>
</template>
<script setup>
import { ElMessage, ElMessageBox } from 'element-plus'
import searchForm from '@/components/searchForm/index.vue'
import Content from '@/components/content/index.vue'
import { getVipUserList, removeUserVip } from '@/api/user/index.js'
import { ref, onMounted } from 'vue'
import { VIP_USER_SEARCH_CONFIG, VIP_USER_TABLE_CONFIG } from '@/config'
const tableLoading = ref(true)
const tableData = ref([])
const queryParams = ref({
page: 1,
pageSize: 10,
user_id: '',
name: ''
})
const total = ref(0)
// 搜索数据
const searchData = (data) => {
queryParams.value.page = 1
queryParams.value = { ...queryParams.value, ...data }
getList()
}
// 重置数据
const resetData = () => {
queryParams.value = {
page: 1,
pageSize: 10,
user_id: '',
name: ''
}
getList()
}
// 获取VIP用户列表
async function getList() {
try {
tableLoading.value = true
const res = await getVipUserList(queryParams.value)
if (res.code === 0) {
tableData.value = res.data.list || []
total.value = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取VIP用户列表失败')
}
} catch (error) {
console.error('获取VIP用户列表失败:', error)
ElMessage.error('获取VIP用户列表失败')
} finally {
tableLoading.value = false
}
}
// 分页处理
function changePage(data) {
queryParams.value.page = data
getList()
}
// 格式化余额显示
function formatBalance(balance) {
if (!balance || balance === 0) return '0.00元'
return (balance / 100).toFixed(2) + '元'
}
// 获取VIP等级标签类型
function getVipLabelType(userLabel) {
const typeMap = {
1: 'info', // 注册会员
2: 'warning', // VIP
3: 'success' // SVIP
}
return typeMap[userLabel] || 'info'
}
// 获取VIP等级文本
function getVipLabelText(userLabel) {
const textMap = {
1: '注册会员',
2: 'VIP',
3: 'SVIP'
}
return textMap[userLabel] || '未知'
}
// 移除用户VIP
async function handleRemoveVip(row) {
try {
await ElMessageBox.confirm(
`确定要移除用户 "${row.nick_name}" 的VIP身份吗此操作不可撤销。`,
'确认移除VIP',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
const res = await removeUserVip({ id: row.id })
if (res.code === 0) {
ElMessage.success('移除VIP成功')
getList() // 刷新列表
} else {
ElMessage.error(res.msg || '移除VIP失败')
}
} catch (error) {
// 取消操作时不需要提示
if (error !== 'cancel') {
ElMessage.error('移除VIP失败')
}
}
}
// 页面加载时获取数据
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
.searchForm {
margin-bottom: 20px;
}
.gva-table-box {
background: white;
border-radius: 4px;
padding: 20px;
}
</style>

676
src/view/user/user/with.vue Normal file
View File

@@ -0,0 +1,676 @@
<template>
<div>
<!-- 搜索区域 -->
<div class="gva-search-box">
<el-form :inline="true" :model="searchForm" class="demo-form-inline">
<el-form-item label="用户名">
<el-input v-model="searchForm.userName" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="提现类型">
<el-select v-model="searchForm.withType" placeholder="请选择提现类型" clearable>
<el-option label="微信" :value="1" />
<el-option label="支付宝" :value="2" />
<el-option label="银行卡" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="searchForm.status" placeholder="请选择状态" clearable>
<el-option label="待处理" :value="1" />
<el-option label="已完成" :value="2" />
<el-option label="已拒绝" :value="3" />
<el-option label="已取消" :value="4" />
</el-select>
</el-form-item>
<el-form-item label="提现时间">
<el-date-picker
v-model="searchForm.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
clearable
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="searchData">查询</el-button>
<el-button @click="resetData">重置</el-button>
</el-form-item>
</el-form>
</div>
<!-- 统计信息 -->
<div class="stats-box">
<el-row :gutter="20">
<el-col :span="6">
<div class="stat-item">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">总申请数</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item pending">
<div class="stat-value">{{ stats.pending }}</div>
<div class="stat-label">待处理</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item completed">
<div class="stat-value">{{ stats.completed }}</div>
<div class="stat-label">已完成</div>
</div>
</el-col>
<el-col :span="6">
<div class="stat-item rejected">
<div class="stat-value">{{ stats.rejected }}</div>
<div class="stat-label">已拒绝</div>
</div>
</el-col>
</el-row>
</div>
<!-- 表格区域 -->
<div class="gva-table-box">
<el-table
:data="tableData"
v-loading="tableLoading"
style="width: 100%"
class="gva-table"
stripe
>
<el-table-column prop="ID" label="ID" width="80" />
<el-table-column prop="userName" label="用户名" width="120" />
<el-table-column prop="withType" label="提现类型" width="100">
<template #default="{ row }">
<el-tag :type="getWithTypeTagType(row.withType)">
{{ getWithTypeText(row.withType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="amount" label="申请金额" width="120">
<template #default="{ row }">
<span style="color: #f56c6c; font-weight: bold;">¥{{ row.amount }}</span>
</template>
</el-table-column>
<el-table-column prop="realAmount" label="实际到账" width="120">
<template #default="{ row }">
<span style="color: #67c23a; font-weight: bold;">
{{ row.realAmount > 0 ? `¥${row.realAmount}` : '-' }}
</span>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusTagType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="accountInfo" label="收款信息" width="120">
<template #default="{ row }">
<el-button type="text" @click="viewAccountInfo(row)">查看</el-button>
</template>
</el-table-column>
<el-table-column prop="CreatedAt" label="申请时间" width="180">
<template #default="{ row }">
{{ formatDate(row.CreatedAt) }}
</template>
</el-table-column>
<el-table-column prop="arriveTime" label="到账时间" width="180">
<template #default="{ row }">
{{ row.arriveTime ? formatDate(row.arriveTime) : '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.status === 1"
type="text"
@click="handleApprove(row)"
>
通过
</el-button>
<el-button
v-if="row.status === 1"
type="text"
@click="handleReject(row)"
style="color: #f56c6c"
>
拒绝
</el-button>
<el-button type="text" @click="viewDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="queryParams.page"
v-model:page-size="queryParams.pageSize"
:page-sizes="[10, 20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
class="gva-pagination"
/>
</div>
<!-- 审核弹窗 -->
<el-dialog
v-model="approveDialogVisible"
:title="approveType === 'approve' ? '通过提现申请' : '拒绝提现申请'"
width="500px"
:before-close="handleApproveClose"
>
<el-form
ref="approveFormRef"
:model="approveForm"
:rules="approveRules"
label-width="120px"
>
<el-form-item label="用户名">
<span>{{ currentWithdraw.userName }}</span>
</el-form-item>
<el-form-item label="申请金额">
<span style="color: #f56c6c; font-weight: bold;">¥{{ currentWithdraw.amount }}</span>
</el-form-item>
<el-form-item v-if="approveType === 'approve'" label="实际到账金额" prop="realAmount">
<el-input-number
v-model="approveForm.realAmount"
:precision="2"
:step="0.01"
:min="0"
:max="currentWithdraw.amount"
placeholder="请输入实际到账金额"
style="width: 100%"
/>
<div style="font-size: 12px; color: #999; margin-top: 5px;">
可扣除手续费等费用不能超过申请金额
</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input
v-model="approveForm.remark"
type="textarea"
:rows="3"
:placeholder="approveType === 'approve' ? '请输入通过备注(可选)' : '请输入拒绝原因'"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleApproveClose">取消</el-button>
<el-button
type="primary"
@click="submitApprove"
:class="approveType === 'reject' ? 'reject-btn' : ''"
>
{{ approveType === 'approve' ? '确认通过' : '确认拒绝' }}
</el-button>
</span>
</template>
</el-dialog>
<!-- 收款信息弹窗 -->
<el-dialog
v-model="accountInfoDialogVisible"
title="收款信息"
width="600px"
:before-close="handleAccountInfoClose"
>
<div class="account-info-content">
<el-descriptions :column="2" border>
<el-descriptions-item label="提现类型">
<el-tag :type="getWithTypeTagType(currentWithdraw.withType)">
{{ getWithTypeText(currentWithdraw.withType) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="申请金额">
<span style="color: #f56c6c; font-weight: bold;">¥{{ currentWithdraw.amount }}</span>
</el-descriptions-item>
</el-descriptions>
<div style="margin-top: 20px;">
<h4>收款信息</h4>
<div v-if="isImageUrl(currentWithdraw.accountInfo)" class="image-info">
<el-image
:src="currentWithdraw.accountInfo"
:preview-src-list="[currentWithdraw.accountInfo]"
fit="contain"
style="max-width: 300px; max-height: 300px;"
/>
<p style="margin-top: 10px; color: #666;">{{ getWithTypeText(currentWithdraw.withType) }}收款码</p>
</div>
<div v-else class="text-info">
<el-input
:value="currentWithdraw.accountInfo"
readonly
type="textarea"
:rows="3"
/>
<p style="margin-top: 10px; color: #666;">银行卡号或账户信息</p>
</div>
</div>
</div>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog
v-model="detailDialogVisible"
title="提现详情"
width="600px"
:before-close="handleDetailClose"
>
<el-descriptions :column="2" border>
<el-descriptions-item label="提现ID">{{ currentWithdraw.ID }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ currentWithdraw.userName }}</el-descriptions-item>
<el-descriptions-item label="提现类型">
<el-tag :type="getWithTypeTagType(currentWithdraw.withType)">
{{ getWithTypeText(currentWithdraw.withType) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="申请金额">
<span style="color: #f56c6c; font-weight: bold;">¥{{ currentWithdraw.amount }}</span>
</el-descriptions-item>
<el-descriptions-item label="实际到账">
<span style="color: #67c23a; font-weight: bold;">
{{ currentWithdraw.realAmount > 0 ? `¥${currentWithdraw.realAmount}` : '-' }}
</span>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusTagType(currentWithdraw.status)">
{{ getStatusText(currentWithdraw.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="申请时间">{{ formatDate(currentWithdraw.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="到账时间">
{{ currentWithdraw.arriveTime ? formatDate(currentWithdraw.arriveTime) : '-' }}
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup>
import { list, update } from '@/api/user/with.js'
import { ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
defineOptions({
name: 'WithdrawManagement'
})
// 表格相关数据
const tableLoading = ref(false)
const tableData = ref([])
const queryParams = ref({
page: 1,
pageSize: 10
})
const total = ref(0)
// 搜索表单
const searchForm = ref({
userName: '',
withType: '',
status: '',
dateRange: []
})
// 统计数据
const stats = computed(() => {
const pending = tableData.value.filter(item => item.status === 1).length
const completed = tableData.value.filter(item => item.status === 2).length
const rejected = tableData.value.filter(item => item.status === 3).length
return {
total: total.value,
pending,
completed,
rejected
}
})
// 审核弹窗
const approveDialogVisible = ref(false)
const approveFormRef = ref(null)
const approveType = ref('approve') // 'approve' 或 'reject'
const currentWithdraw = ref({})
const approveForm = ref({
id: '',
status: 2,
realAmount: 0,
remark: ''
})
// 收款信息弹窗
const accountInfoDialogVisible = ref(false)
// 详情弹窗
const detailDialogVisible = ref(false)
// 表单验证规则
const approveRules = {
realAmount: [
{ required: true, message: '请输入实际到账金额', trigger: 'blur' },
{ type: 'number', min: 0, message: '金额不能小于0', trigger: 'blur' }
],
remark: [
{
validator: (rule, value, callback) => {
if (approveType.value === 'reject' && !value) {
callback(new Error('拒绝时必须填写拒绝原因'))
} else {
callback()
}
},
trigger: 'blur'
}
]
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
// 获取提现类型文本
const getWithTypeText = (type) => {
const typeMap = {
1: '微信',
2: '支付宝',
3: '银行卡'
}
return typeMap[type] || '未知'
}
// 获取提现类型标签类型
const getWithTypeTagType = (type) => {
const typeMap = {
1: 'success',
2: 'primary',
3: 'warning'
}
return typeMap[type] || 'info'
}
// 获取状态文本
const getStatusText = (status) => {
const statusMap = {
1: '待处理',
2: '已完成',
3: '已拒绝',
4: '已取消'
}
return statusMap[status] || '未知'
}
// 获取状态标签类型
const getStatusTagType = (status) => {
const typeMap = {
1: 'warning',
2: 'success',
3: 'danger',
4: 'info'
}
return typeMap[status] || 'info'
}
// 判断是否为图片URL
const isImageUrl = (url) => {
if (!url) return false
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp']
return imageExtensions.some(ext => url.toLowerCase().includes(ext))
}
// 搜索数据
const searchData = () => {
queryParams.value.page = 1
getList()
}
// 重置搜索
const resetData = () => {
searchForm.value = {
userName: '',
withType: '',
status: '',
dateRange: []
}
queryParams.value.page = 1
getList()
}
// 分页处理
const handleSizeChange = (size) => {
queryParams.value.pageSize = size
getList()
}
const handleCurrentChange = (page) => {
queryParams.value.page = page
getList()
}
// 获取提现列表
async function getList() {
tableLoading.value = true
try {
const params = {
...queryParams.value,
...searchForm.value
}
// 处理日期范围
if (searchForm.value.dateRange && searchForm.value.dateRange.length === 2) {
params.startTime = searchForm.value.dateRange[0]
params.endTime = searchForm.value.dateRange[1]
}
delete params.dateRange
const res = await list(params)
if (res.code === 0) {
tableData.value = res.data.list || []
total.value = res.data.total || 0
} else {
ElMessage.error(res.msg || '获取数据失败')
}
} catch (error) {
console.error('获取提现列表失败:', error)
ElMessage.error('获取数据失败')
} finally {
tableLoading.value = false
}
}
// 通过申请
const handleApprove = (row) => {
currentWithdraw.value = row
approveType.value = 'approve'
approveForm.value = {
id: row.ID,
status: 2,
realAmount: row.amount,
remark: ''
}
approveDialogVisible.value = true
}
// 拒绝申请
const handleReject = (row) => {
currentWithdraw.value = row
approveType.value = 'reject'
approveForm.value = {
id: row.ID,
status: 3,
realAmount: 0,
remark: ''
}
approveDialogVisible.value = true
}
// 查看收款信息
const viewAccountInfo = (row) => {
currentWithdraw.value = row
accountInfoDialogVisible.value = true
}
// 查看详情
const viewDetail = (row) => {
currentWithdraw.value = row
detailDialogVisible.value = true
}
// 关闭审核弹窗
const handleApproveClose = () => {
approveDialogVisible.value = false
approveFormRef.value?.resetFields()
}
// 关闭收款信息弹窗
const handleAccountInfoClose = () => {
accountInfoDialogVisible.value = false
}
// 关闭详情弹窗
const handleDetailClose = () => {
detailDialogVisible.value = false
}
// 提交审核
const submitApprove = async () => {
if (!approveFormRef.value) return
await approveFormRef.value.validate(async (valid) => {
if (valid) {
try {
const res = await update(approveForm.value)
if (res.code === 0) {
ElMessage.success(approveType.value === 'approve' ? '审核通过成功' : '拒绝成功')
handleApproveClose()
getList()
} else {
ElMessage.error(res.msg || '操作失败')
}
} catch (error) {
ElMessage.error('操作失败')
}
}
})
}
// 页面初始化
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
.gva-search-box {
padding: 20px;
background: #fff;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stats-box {
padding: 20px;
background: #fff;
border-radius: 4px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.stat-item {
text-align: center;
padding: 20px;
border-radius: 8px;
background: #f8f9fa;
&.pending {
background: #fef9e7;
color: #e6a23c;
}
&.completed {
background: #f0f9ff;
color: #67c23a;
}
&.rejected {
background: #fef2f2;
color: #f56c6c;
}
.stat-value {
font-size: 28px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px;
opacity: 0.8;
}
}
}
.gva-table-box {
background: #fff;
border-radius: 4px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.gva-table {
:deep(.el-table__header) {
background-color: #f5f7fa;
}
:deep(.el-table__row:hover > td) {
background-color: #f5f7fa !important;
}
}
.gva-pagination {
margin-top: 20px;
text-align: right;
}
.demo-form-inline {
.el-form-item {
margin-right: 20px;
}
}
.account-info-content {
.image-info {
text-align: center;
.el-image {
border: 1px solid #dcdfe6;
border-radius: 4px;
}
}
.text-info {
.el-textarea {
:deep(.el-textarea__inner) {
background-color: #f5f7fa;
}
}
}
}
.reject-btn {
background-color: #f56c6c !important;
border-color: #f56c6c !important;
&:hover {
background-color: #f78989 !important;
border-color: #f78989 !important;
}
}
</style>