Compare commits
12 Commits
860ca1002e
...
deploy
| Author | SHA1 | Date | |
|---|---|---|---|
| f62b4db0e1 | |||
| cae5551107 | |||
| 4bf7c4857b | |||
| 6f82e5e79d | |||
| a8954bd85d | |||
| bdf6dd9382 | |||
| 5e81d33285 | |||
| 38c63cc451 | |||
| 4180f73c53 | |||
| 3227a50f9a | |||
| 86794145f1 | |||
| 84769954c4 |
@@ -816,3 +816,36 @@ export const removeUserNetworkingNetwork = (data) => {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 回收站管理 API
|
||||
* ================================
|
||||
*/
|
||||
|
||||
/** 获取回收站列表 */
|
||||
export const getRecycleBinList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/recycle_bin/list', { params })
|
||||
}
|
||||
|
||||
/** 获取回收站记录详情 */
|
||||
export const getRecycleBinDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/recycle_bin/detail', { params })
|
||||
}
|
||||
|
||||
/** 从回收站恢复虚拟机 */
|
||||
export const restoreRecycleBin = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/recycle_bin/restore', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 永久删除回收站记录 */
|
||||
export const deleteRecycleBin = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/recycle_bin/delete', { params })
|
||||
}
|
||||
|
||||
/** 清空回收站(all=true 全部清空,all=false 仅清理到期) */
|
||||
export const cleanRecycleBin = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/recycle_bin/clean', { params })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { http2 } from '@/utils/request.js'
|
||||
|
||||
const formHeaders = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
||||
|
||||
// ========== 邮件主控服务 ==========
|
||||
|
||||
export const getMailServiceList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/mail_service/list', { params })
|
||||
}
|
||||
|
||||
export const getMailServiceDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/server/mail_service/detail', { params })
|
||||
}
|
||||
|
||||
export const createMailService = (data) => {
|
||||
return http2.post('/api/v1/admin/server/mail_service/create', data, formHeaders)
|
||||
}
|
||||
|
||||
export const updateMailService = (data) => {
|
||||
return http2.post('/api/v1/admin/server/mail_service/update', data, formHeaders)
|
||||
}
|
||||
|
||||
export const deleteMailService = (data) => {
|
||||
return http2.delete('/api/v1/admin/server/mail_service/delete', { data, ...formHeaders })
|
||||
}
|
||||
|
||||
// ========== 邮件额度商品 ==========
|
||||
|
||||
export const getMailGoodsList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/mail_service/goods/list', { params })
|
||||
}
|
||||
|
||||
export const createMailGoods = (data) => {
|
||||
return http2.post('/api/v1/admin/server/mail_service/goods/create', data, formHeaders)
|
||||
}
|
||||
|
||||
export const updateMailGoods = (data) => {
|
||||
return http2.post('/api/v1/admin/server/mail_service/goods/update', data, formHeaders)
|
||||
}
|
||||
|
||||
export const deleteMailGoods = (data) => {
|
||||
return http2.delete('/api/v1/admin/server/mail_service/goods/delete', { data, ...formHeaders })
|
||||
}
|
||||
@@ -39,3 +39,10 @@ export const updateNoticeTemplate = (data) => {
|
||||
export const deleteNoticeTemplate = (params) => {
|
||||
return http2.delete('/api/v1/admin/notice_message/template/delete', { params })
|
||||
}
|
||||
|
||||
/** 使用默认参数预览渲染模板 */
|
||||
export const previewNoticeTemplate = (data) => {
|
||||
return http2.post('/api/v1/admin/notice_message/template/default_msg', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,6 +57,10 @@ export const deleteProductGroup = (data) => {
|
||||
export const getProductList = (params) => {
|
||||
return http2.get('/api/v1/admin/good/goods/list', {params: params})
|
||||
}
|
||||
/**获取商品详情(含商品参数列表) */
|
||||
export const getProductDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/good/goods/detail', {params: params})
|
||||
}
|
||||
/**获取商品标签列表 */
|
||||
export const getProductTagList = () => {
|
||||
return http2.get('/api/v1/admin/good/goods/tag_list')
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { http2 } from '@/utils/request.js'
|
||||
|
||||
const formHeaders = { headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
|
||||
// ========== 短信主控服务 ==========
|
||||
|
||||
export const getSmsServiceList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/sms_service/list', { params })
|
||||
}
|
||||
|
||||
export const createSmsService = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/create', data, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
})
|
||||
}
|
||||
|
||||
export const updateSmsService = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/update', data, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteSmsService = (data) => {
|
||||
return http2.delete('/api/v1/admin/server/sms_service/delete', {
|
||||
data,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
})
|
||||
}
|
||||
|
||||
export const setDefaultSmsService = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/set_default', data, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 短信额度商品 ==========
|
||||
|
||||
export const getSmsGoodsList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/sms_service/goods/list', { params })
|
||||
}
|
||||
|
||||
export const createSmsGoods = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/goods/create', data, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
})
|
||||
}
|
||||
|
||||
export const updateSmsGoods = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/goods/update', data, {
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteSmsGoods = (data) => {
|
||||
return http2.delete('/api/v1/admin/server/sms_service/goods/delete', {
|
||||
data,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
})
|
||||
}
|
||||
|
||||
// ========== 短信签名管理 ==========
|
||||
|
||||
export const getSmsSignatureList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/sms_service/signature/list', { params })
|
||||
}
|
||||
|
||||
export const getSmsSignatureDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/server/sms_service/signature/detail', { params })
|
||||
}
|
||||
|
||||
export const createSmsSignature = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/signature/create', data, formHeaders)
|
||||
}
|
||||
|
||||
export const updateSmsSignature = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/signature/update', data, formHeaders)
|
||||
}
|
||||
|
||||
export const deleteSmsSignature = (data) => {
|
||||
return http2.delete('/api/v1/admin/server/sms_service/signature/delete', { data, ...formHeaders })
|
||||
}
|
||||
|
||||
export const submitSmsSignature = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/signature/submit', data, formHeaders)
|
||||
}
|
||||
|
||||
export const approveSmsSignature = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/signature/approve', data, formHeaders)
|
||||
}
|
||||
|
||||
export const rejectSmsSignature = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/signature/reject', data, formHeaders)
|
||||
}
|
||||
|
||||
// ========== 短信模板管理 ==========
|
||||
|
||||
export const getSmsTemplateList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/sms_service/template/list', { params })
|
||||
}
|
||||
|
||||
export const getSmsTemplateDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/server/sms_service/template/detail', { params })
|
||||
}
|
||||
|
||||
export const createSmsTemplate = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/template/create', data, formHeaders)
|
||||
}
|
||||
|
||||
export const updateSmsTemplate = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/template/update', data, formHeaders)
|
||||
}
|
||||
|
||||
export const deleteSmsTemplate = (data) => {
|
||||
return http2.delete('/api/v1/admin/server/sms_service/template/delete', { data, ...formHeaders })
|
||||
}
|
||||
|
||||
export const submitSmsTemplate = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/template/submit', data, formHeaders)
|
||||
}
|
||||
|
||||
export const approveSmsTemplate = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/template/approve', data, formHeaders)
|
||||
}
|
||||
|
||||
export const rejectSmsTemplate = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/template/reject', data, formHeaders)
|
||||
}
|
||||
|
||||
// ========== 推荐模板管理 ==========
|
||||
|
||||
export const getSmsRecommendedTemplateList = (params) => {
|
||||
return http2.get('/api/v1/admin/server/sms_service/template/recommended/list', { params })
|
||||
}
|
||||
|
||||
export const createSmsRecommendedTemplate = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/template/recommended/create', data, formHeaders)
|
||||
}
|
||||
|
||||
export const updateSmsRecommendedTemplate = (data) => {
|
||||
return http2.post('/api/v1/admin/server/sms_service/template/recommended/update', data, formHeaders)
|
||||
}
|
||||
|
||||
export const deleteSmsRecommendedTemplate = (data) => {
|
||||
return http2.delete('/api/v1/admin/server/sms_service/template/recommended/delete', { data, ...formHeaders })
|
||||
}
|
||||
@@ -34,8 +34,8 @@ export const getUserInfo = (data) => {
|
||||
}
|
||||
|
||||
/**获取用户列表 */
|
||||
export const getUserList = (data) => {
|
||||
return http2.get('/api/v1/admin/user/user/list?page=' + data.page + '&count=' + data.count + '&key=' + data.key)
|
||||
export const getUserList = (params) => {
|
||||
return http2.get('/api/v1/admin/user/user/list', { params })
|
||||
}
|
||||
|
||||
/**更新用户信息 */
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { http2 } from '@/utils/request.js'
|
||||
|
||||
const formHeaders = { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }
|
||||
|
||||
// ========== 用户组优惠(用户组 与 商品/商品组 绑定优惠) ==========
|
||||
|
||||
/** 获取用户组优惠列表(可按 user_group_id / good_id / good_group_id 过滤) */
|
||||
export const getUserGroupDiscountList = (params) => {
|
||||
return http2.get('/api/v1/admin/user_group/discount/list', { params })
|
||||
}
|
||||
|
||||
/** 获取用户组优惠详情 */
|
||||
export const getUserGroupDiscountDetail = (params) => {
|
||||
return http2.get('/api/v1/admin/user_group/discount/detail', { params })
|
||||
}
|
||||
|
||||
/** 添加用户组优惠(将用户组与商品/商品组绑定优惠) */
|
||||
export const addUserGroupDiscount = (data) => {
|
||||
return http2.post('/api/v1/admin/user_group/discount/add', data, formHeaders)
|
||||
}
|
||||
|
||||
/** 修改用户组优惠(可改绑商品/商品组) */
|
||||
export const updateUserGroupDiscount = (data) => {
|
||||
return http2.post('/api/v1/admin/user_group/discount/update', data, formHeaders)
|
||||
}
|
||||
|
||||
/** 删除用户组优惠(解绑用户组与商品/商品组的优惠) */
|
||||
export const deleteUserGroupDiscount = (params) => {
|
||||
return http2.delete('/api/v1/admin/user_group/discount/delete', { params })
|
||||
}
|
||||
@@ -103,6 +103,8 @@ export const deleteUserVmNetworking = (params) => http2.delete(`${BASE}/networki
|
||||
|
||||
// ========== 用户商品 ==========
|
||||
export const getUserGoodsList = (params) => http2.get(`${GOODS_BASE}/list`, { params })
|
||||
// 用户商品数量统计(正常 / 已删除 / 已到期),参数与列表接口一致但不分页
|
||||
export const getUserGoodsCount = (params) => http2.get(`${GOODS_BASE}/count`, { params })
|
||||
export const getUserGoodsDetail = (params) => http2.get(`${GOODS_BASE}/detail`, { params })
|
||||
export const createUserGoods = (data) => http2.post(`${GOODS_BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-select v-model="searchParams.is_admin" placeholder="全部身份" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option label="管理员" :value="true" />
|
||||
<el-option label="普通用户" :value="false" />
|
||||
</el-select>
|
||||
<el-button @click="handleReset" class="reset-btn">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置
|
||||
@@ -118,6 +122,7 @@ const selectedUser = ref(null)
|
||||
|
||||
const searchParams = reactive({
|
||||
key: '',
|
||||
is_admin: undefined,
|
||||
page: 1,
|
||||
count: 10
|
||||
})
|
||||
@@ -161,6 +166,7 @@ const handleSearch = () => {
|
||||
|
||||
const handleReset = () => {
|
||||
searchParams.key = ''
|
||||
searchParams.is_admin = undefined
|
||||
searchParams.page = 1
|
||||
fetchUserList()
|
||||
}
|
||||
@@ -191,22 +197,25 @@ const confirmSelection = () => {
|
||||
|
||||
<style scoped>
|
||||
.user-selector-content {
|
||||
max-height: 500px;
|
||||
max-height: 520px;
|
||||
overflow: hidden;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.selector-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, #f7f8fa 0%, #f0f2f5 100%);
|
||||
border-radius: 10px;
|
||||
border: 1px solid #ebeef5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
max-width: 350px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
@@ -231,7 +240,7 @@ const confirmSelection = () => {
|
||||
}
|
||||
|
||||
.selector-pagination {
|
||||
margin-top: 16px;
|
||||
margin-top: 14px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -244,19 +253,26 @@ const confirmSelection = () => {
|
||||
|
||||
.selected-info {
|
||||
margin-right: auto;
|
||||
color: #606266;
|
||||
color: #409eff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-table__row) {
|
||||
cursor: pointer;
|
||||
transition: background-color .15s;
|
||||
}
|
||||
|
||||
:deep(.el-table__row):hover {
|
||||
background-color: #f5f7fa;
|
||||
:deep(.el-table__row:hover > td) {
|
||||
background-color: #f0f7ff !important;
|
||||
}
|
||||
|
||||
:deep(.current-row) {
|
||||
:deep(.current-row > td) {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
|
||||
@@ -265,8 +281,9 @@ const confirmSelection = () => {
|
||||
}
|
||||
|
||||
:deep(.el-avatar) {
|
||||
background-color: var(--el-color-primary-light-5);
|
||||
background: linear-gradient(135deg, var(--el-color-primary-light-3), var(--el-color-primary));
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -36,13 +36,18 @@
|
||||
<el-table-column prop="gateway" label="网关" width="130" />
|
||||
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="bridge_name" label="网桥名称" width="100" />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<el-table-column label="占用" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row._used === true" type="danger" size="small">已占用</el-tag>
|
||||
<el-tag v-else-if="row._used === false" type="success" size="small">空闲</el-tag>
|
||||
<el-tag v-else type="info" size="small">-</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="禁用" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.disable" type="danger" size="small">禁用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrapper" v-if="total > 0">
|
||||
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
|
||||
@@ -118,6 +123,7 @@ const loadList = async () => {
|
||||
const effectiveUsed = props.filterUsed || usedFilter.value
|
||||
if (effectiveUsed) params.used = effectiveUsed
|
||||
if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value
|
||||
params.disable = false
|
||||
const res = await getNetworkList(params)
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
|
||||
@@ -1,166 +1,111 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="选择用户"
|
||||
width="900px"
|
||||
append-to-body
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="user-selector">
|
||||
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||
<!-- 选择用户 -->
|
||||
<el-tab-pane label="选择用户" name="selectUser">
|
||||
<div class="user-list-container">
|
||||
<!-- 搜索筛选区域 -->
|
||||
<div class="filter-section">
|
||||
<el-form :inline="true" :model="searchParams" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="searchParams.key"
|
||||
placeholder="搜索用户名或ID"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户状态">
|
||||
<el-select
|
||||
v-model="searchParams.status"
|
||||
placeholder="全部状态"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option label="正常" value="1" />
|
||||
<el-option label="禁用" value="0" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch" :icon="Search">
|
||||
搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset" :icon="Refresh">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="success" @click="switchToAdd" :icon="Plus">
|
||||
添加新用户
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<el-dialog v-model="visible" title="选择用户" width="860px" append-to-body @close="handleClose" class="user-selector-dialog">
|
||||
<div class="uls-root">
|
||||
<el-tabs v-model="activeTab" @tab-click="handleTabClick" class="uls-tabs">
|
||||
<!-- ====== 选择用户 ====== -->
|
||||
<el-tab-pane name="selectUser">
|
||||
<template #label>
|
||||
<span class="tab-lbl">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/></svg>
|
||||
选择用户
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 用户列表表格 -->
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="userList"
|
||||
highlight-current-row
|
||||
@current-change="handleCurrentChange"
|
||||
style="width: 100%"
|
||||
:height="350"
|
||||
:row-class-name="tableRowClassName"
|
||||
<!-- 搜索栏 -->
|
||||
<div class="uls-search">
|
||||
<el-input v-model="searchParams.key" placeholder="搜索用户名 / 邮箱 / ID" clearable @keyup.enter="handleSearch" class="uls-search-input" :prefix-icon="Search" />
|
||||
<el-select v-model="searchParams.is_admin" placeholder="全部身份" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option label="管理员" :value="true" />
|
||||
<el-option label="普通用户" :value="false" />
|
||||
</el-select>
|
||||
<el-button type="primary" @click="handleSearch" :icon="Search" circle />
|
||||
<el-button @click="handleReset" :icon="Refresh" circle />
|
||||
</div>
|
||||
|
||||
<!-- 已选提示 -->
|
||||
<transition name="fade">
|
||||
<div class="uls-selected-bar" v-if="selectedUser">
|
||||
<div class="uls-sel-left">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#67c23a"/></svg>
|
||||
<span>已选择:</span>
|
||||
<el-avatar :size="24" :src="selectedUser.cover" class="sel-avatar">{{ selectedUser.user_name?.charAt(0)?.toUpperCase() || 'U' }}</el-avatar>
|
||||
<b>{{ selectedUser.user_name }}</b>
|
||||
<span class="sel-id">#{{ selectedUser.user_id }}</span>
|
||||
</div>
|
||||
<el-button size="small" link type="danger" @click="selectedUser = null">取消选择</el-button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 用户卡片列表 -->
|
||||
<div class="uls-grid" v-loading="loading">
|
||||
<div
|
||||
v-for="user in userList" :key="user.user_id"
|
||||
class="uls-card" :class="{ active: selectedUser?.user_id === user.user_id }"
|
||||
@click="handleCurrentChange(user)"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="user_id" label="用户ID" width="100" align="center" />
|
||||
<el-table-column prop="user_name" label="用户名" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<div class="user-info-cell">
|
||||
<el-avatar :size="32" :src="row.cover">
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
<span class="user-name">{{ row.user_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="phone" label="手机号" width="130" show-overflow-tooltip />
|
||||
<!-- <el-table-column prop="status" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'danger'" size="small">
|
||||
{{ row.status === 1 ? '正常' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column prop="created_at" label="注册时间" width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-container" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="searchParams.page"
|
||||
v-model:page-size="searchParams.count"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
background
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
<div class="uls-card-check" v-if="selectedUser?.user_id === user.user_id">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" fill="#fff"/></svg>
|
||||
</div>
|
||||
<el-avatar :size="42" :src="user.cover" class="uls-avatar">{{ user.user_name?.charAt(0)?.toUpperCase() || 'U' }}</el-avatar>
|
||||
<div class="uls-card-body">
|
||||
<div class="uls-card-name">
|
||||
{{ user.user_name }}
|
||||
<el-tag v-if="user.is_admin" type="warning" size="small" effect="dark" round class="admin-tag">管理员</el-tag>
|
||||
</div>
|
||||
<div class="uls-card-meta">
|
||||
<span class="meta-id">#{{ user.user_id }}</span>
|
||||
<span v-if="user.email" class="meta-email">{{ user.email }}</span>
|
||||
<span v-if="user.phone" class="meta-phone">{{ user.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="userList.length === 0 && !loading" description="暂无用户数据" />
|
||||
</div>
|
||||
|
||||
<el-empty v-if="userList.length === 0 && !loading" description="暂无用户数据" :image-size="80" />
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="uls-pagination" v-if="total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="searchParams.page"
|
||||
v-model:page-size="searchParams.count"
|
||||
:page-sizes="[12, 24, 48]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background small
|
||||
@size-change="s => { searchParams.count = s; searchParams.page = 1; fetchUserList() }"
|
||||
@current-change="p => { searchParams.page = p; fetchUserList() }"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 添加用户 -->
|
||||
<el-tab-pane label="添加用户" name="addUser">
|
||||
<div class="add-user-section">
|
||||
<el-form
|
||||
ref="addFormRef"
|
||||
:model="addForm"
|
||||
:rules="addFormRules"
|
||||
label-width="100px"
|
||||
class="add-user-form"
|
||||
>
|
||||
<!-- ====== 添加用户 ====== -->
|
||||
<el-tab-pane name="addUser">
|
||||
<template #label>
|
||||
<span class="tab-lbl">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/></svg>
|
||||
添加用户
|
||||
</span>
|
||||
</template>
|
||||
<div class="uls-add-section">
|
||||
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="90px" class="uls-add-form">
|
||||
<el-form-item label="用户名" prop="user_name">
|
||||
<el-input
|
||||
v-model="addForm.user_name"
|
||||
placeholder="请输入用户名"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
<el-input v-model="addForm.user_name" placeholder="请输入用户名" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input
|
||||
v-model="addForm.email"
|
||||
placeholder="请输入邮箱地址"
|
||||
type="email"
|
||||
/>
|
||||
<el-input v-model="addForm.email" placeholder="请输入邮箱地址" type="email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone">
|
||||
<el-input
|
||||
v-model="addForm.phone"
|
||||
placeholder="请输入手机号"
|
||||
maxlength="11"
|
||||
/>
|
||||
<el-input v-model="addForm.phone" placeholder="请输入手机号(选填)" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="addForm.password"
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
<el-input v-model="addForm.password" placeholder="请输入密码" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="addForm.confirmPassword"
|
||||
placeholder="请再次输入密码"
|
||||
type="password"
|
||||
show-password
|
||||
/>
|
||||
<el-input v-model="addForm.confirmPassword" placeholder="请再次输入密码" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleAddUser" :loading="addLoading">
|
||||
<el-icon><Plus /></el-icon>
|
||||
立即创建
|
||||
</el-button>
|
||||
<el-button @click="resetAddForm">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
重置表单
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAddUser" :loading="addLoading" :icon="Plus">立即创建</el-button>
|
||||
<el-button @click="resetAddForm" :icon="Refresh">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
@@ -169,16 +114,9 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="uls-footer">
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="handleConfirm"
|
||||
:disabled="!selectedUser"
|
||||
v-if="activeTab === 'selectUser'"
|
||||
>
|
||||
确定选择
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedUser" v-if="activeTab === 'selectUser'">确定选择</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -190,23 +128,12 @@ import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh, Plus, User } from '@element-plus/icons-vue'
|
||||
import { getUserList, createTask } from '@/api/admin/user'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// 当前已选中的用户ID(用于回显)
|
||||
currentUserId: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
}
|
||||
modelValue: { type: Boolean, default: false },
|
||||
currentUserId: { type: [String, Number], default: '' }
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
|
||||
// 响应式数据
|
||||
const visible = ref(false)
|
||||
const activeTab = ref('selectUser')
|
||||
const loading = ref(false)
|
||||
@@ -216,352 +143,131 @@ const total = ref(0)
|
||||
const selectedUser = ref(null)
|
||||
const addFormRef = ref(null)
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = reactive({
|
||||
key: '',
|
||||
status: '',
|
||||
page: 1,
|
||||
count: 10
|
||||
})
|
||||
const searchParams = reactive({ key: '', is_admin: undefined, page: 1, count: 12 })
|
||||
|
||||
// 添加用户表单
|
||||
const addForm = reactive({
|
||||
user_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
// 密码确认验证
|
||||
const addForm = reactive({ user_name: '', email: '', phone: '', password: '', confirmPassword: '' })
|
||||
const validateConfirmPassword = (rule, value, callback) => {
|
||||
if (value === '') {
|
||||
callback(new Error('请再次输入密码'))
|
||||
} else if (value !== addForm.password) {
|
||||
callback(new Error('两次输入密码不一致'))
|
||||
} else {
|
||||
callback()
|
||||
}
|
||||
if (!value) callback(new Error('请再次输入密码'))
|
||||
else if (value !== addForm.password) callback(new Error('两次输入密码不一致'))
|
||||
else callback()
|
||||
}
|
||||
|
||||
// 添加用户表单验证规则
|
||||
const addFormRules = {
|
||||
user_name: [
|
||||
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ min: 2, max: 50, message: '用户名长度在 2 到 50 个字符', trigger: 'blur' }
|
||||
],
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
|
||||
],
|
||||
phone: [
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||
]
|
||||
user_name: [{ required: true, message: '请输入用户名', trigger: 'blur' }, { min: 2, max: 50, message: '2-50 个字符', trigger: 'blur' }],
|
||||
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }, { type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
|
||||
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }, { min: 6, max: 20, message: '6-20 个字符', trigger: 'blur' }],
|
||||
confirmPassword: [{ required: true, message: '请确认密码', trigger: 'blur' }, { validator: validateConfirmPassword, trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
visible.value = newVal
|
||||
if (newVal) {
|
||||
// 重置状态
|
||||
activeTab.value = 'selectUser'
|
||||
selectedUser.value = null
|
||||
searchParams.page = 1
|
||||
fetchUserList()
|
||||
}
|
||||
watch(() => props.modelValue, (v) => {
|
||||
visible.value = v
|
||||
if (v) { activeTab.value = 'selectUser'; selectedUser.value = null; searchParams.page = 1; fetchUserList() }
|
||||
})
|
||||
watch(visible, (v) => emit('update:modelValue', v))
|
||||
|
||||
// 监听 visible 变化
|
||||
watch(visible, (newVal) => {
|
||||
emit('update:modelValue', newVal)
|
||||
})
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUserList = async () => {
|
||||
loading.value = true
|
||||
userList.value = []
|
||||
|
||||
loading.value = true; userList.value = []
|
||||
try {
|
||||
const params = {
|
||||
page: searchParams.page,
|
||||
count: searchParams.count,
|
||||
key: searchParams.key || ''
|
||||
}
|
||||
|
||||
const params = { page: searchParams.page, count: searchParams.count, key: searchParams.key || '' }
|
||||
if (searchParams.is_admin !== undefined && searchParams.is_admin !== null && searchParams.is_admin !== '') params.is_admin = searchParams.is_admin
|
||||
const res = await getUserList(params)
|
||||
|
||||
if (res.data.code === 200) {
|
||||
userList.value = res.data.data?.data || []
|
||||
total.value = res.data.data?.all_count || 0
|
||||
|
||||
// 如果有当前选中的用户ID,自动选中
|
||||
if (props.currentUserId) {
|
||||
const currentUser = userList.value.find(
|
||||
user => user.user_id === props.currentUserId
|
||||
)
|
||||
if (currentUser) {
|
||||
selectedUser.value = currentUser
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res.data.msg || '获取用户列表失败')
|
||||
if (props.currentUserId) { const u = userList.value.find(u => u.user_id === props.currentUserId); if (u) selectedUser.value = u }
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error)
|
||||
ElMessage.error('获取用户列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} catch { ElMessage.error('获取用户列表失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// 处理标签页切换
|
||||
const handleTabClick = (tab) => {
|
||||
if (tab.paneName === 'selectUser') {
|
||||
fetchUserList()
|
||||
}
|
||||
}
|
||||
const handleTabClick = (tab) => { if (tab.paneName === 'selectUser') fetchUserList() }
|
||||
const handleSearch = () => { searchParams.page = 1; fetchUserList() }
|
||||
const handleReset = () => { searchParams.key = ''; searchParams.is_admin = undefined; searchParams.page = 1; fetchUserList() }
|
||||
const handleCurrentChange = (row) => { selectedUser.value = (selectedUser.value?.user_id === row.user_id) ? null : row }
|
||||
const switchToAdd = () => { activeTab.value = 'addUser' }
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
searchParams.page = 1
|
||||
fetchUserList()
|
||||
}
|
||||
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
searchParams.key = ''
|
||||
searchParams.status = ''
|
||||
searchParams.page = 1
|
||||
fetchUserList()
|
||||
}
|
||||
|
||||
// 分页处理
|
||||
const handleSizeChange = (size) => {
|
||||
searchParams.count = size
|
||||
searchParams.page = 1
|
||||
fetchUserList()
|
||||
}
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
searchParams.page = page
|
||||
fetchUserList()
|
||||
}
|
||||
|
||||
// 切换到添加用户标签页
|
||||
const switchToAdd = () => {
|
||||
activeTab.value = 'addUser'
|
||||
}
|
||||
|
||||
// 选择用户
|
||||
const handleCurrentChange = (row) => {
|
||||
selectedUser.value = row
|
||||
}
|
||||
|
||||
// 表格行样式
|
||||
const tableRowClassName = ({ row }) => {
|
||||
if (selectedUser.value && row.user_id === selectedUser.value.user_id) {
|
||||
return 'selected-row'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 添加用户
|
||||
const handleAddUser = async () => {
|
||||
if (!addFormRef.value) return
|
||||
|
||||
await addFormRef.value.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
|
||||
addLoading.value = true
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('user_name', addForm.user_name)
|
||||
formData.append('email', addForm.email)
|
||||
if (addForm.phone) {
|
||||
formData.append('phone', addForm.phone)
|
||||
}
|
||||
formData.append('password', addForm.password)
|
||||
|
||||
const res = await createTask(formData)
|
||||
|
||||
const fd = new FormData()
|
||||
fd.append('user_name', addForm.user_name); fd.append('email', addForm.email)
|
||||
if (addForm.phone) fd.append('phone', addForm.phone)
|
||||
fd.append('password', addForm.password)
|
||||
const res = await createTask(fd)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('用户创建成功')
|
||||
|
||||
// 获取新创建的用户信息
|
||||
const newUser = res.data.data
|
||||
|
||||
// 自动选择新创建的用户
|
||||
if (newUser) {
|
||||
selectedUser.value = {
|
||||
user_id: newUser.user_id || newUser.id,
|
||||
user_name: newUser.user_name || addForm.user_name,
|
||||
email: newUser.email || addForm.email,
|
||||
phone: newUser.phone || addForm.phone,
|
||||
...newUser
|
||||
}
|
||||
|
||||
// 触发确认事件并关闭弹窗
|
||||
emit('confirm', selectedUser.value)
|
||||
handleClose()
|
||||
} else {
|
||||
// 如果没有返回用户信息,切换到选择标签页并刷新列表
|
||||
activeTab.value = 'selectUser'
|
||||
searchParams.page = 1
|
||||
await fetchUserList()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
if (newUser) { selectedUser.value = { user_id: newUser.user_id || newUser.id, user_name: newUser.user_name || addForm.user_name, email: newUser.email || addForm.email, phone: newUser.phone || addForm.phone, ...newUser }; emit('confirm', selectedUser.value); handleClose() }
|
||||
else { activeTab.value = 'selectUser'; searchParams.page = 1; await fetchUserList() }
|
||||
resetAddForm()
|
||||
} else {
|
||||
ElMessage.error(res.data.msg || '用户创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('用户创建失败:', error)
|
||||
ElMessage.error('用户创建失败')
|
||||
} finally {
|
||||
addLoading.value = false
|
||||
}
|
||||
} else { ElMessage.error(res.data.msg || '创建失败') }
|
||||
} catch { ElMessage.error('创建失败') }
|
||||
finally { addLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
// 重置添加表单
|
||||
const resetAddForm = () => {
|
||||
addForm.user_name = ''
|
||||
addForm.email = ''
|
||||
addForm.phone = ''
|
||||
addForm.password = ''
|
||||
addForm.confirmPassword = ''
|
||||
addFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
selectedUser.value = null
|
||||
userList.value = []
|
||||
searchParams.key = ''
|
||||
searchParams.status = ''
|
||||
searchParams.page = 1
|
||||
total.value = 0
|
||||
resetAddForm()
|
||||
}
|
||||
|
||||
// 确认选择
|
||||
const handleConfirm = () => {
|
||||
if (selectedUser.value) {
|
||||
emit('confirm', selectedUser.value)
|
||||
handleClose()
|
||||
} else {
|
||||
ElMessage.warning('请选择一个用户')
|
||||
}
|
||||
}
|
||||
const resetAddForm = () => { Object.assign(addForm, { user_name: '', email: '', phone: '', password: '', confirmPassword: '' }); addFormRef.value?.resetFields() }
|
||||
const handleClose = () => { visible.value = false; selectedUser.value = null; userList.value = []; searchParams.key = ''; searchParams.is_admin = undefined; searchParams.page = 1; total.value = 0; resetAddForm() }
|
||||
const handleConfirm = () => { if (selectedUser.value) { emit('confirm', selectedUser.value); handleClose() } else ElMessage.warning('请选择一个用户') }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-selector {
|
||||
min-height: 450px;
|
||||
}
|
||||
.uls-root { min-height: 460px; }
|
||||
|
||||
.user-list-container {
|
||||
padding: 10px 0;
|
||||
}
|
||||
/* 标签页 */
|
||||
.tab-lbl { display: inline-flex; align-items: center; gap: 5px; }
|
||||
:deep(.el-tabs__item) { font-size: 15px; padding: 0 20px; }
|
||||
:deep(.el-tabs__item.is-active) { font-weight: 600; }
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
/* 搜索栏 */
|
||||
.uls-search { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; }
|
||||
.uls-search-input { flex: 1; max-width: 320px; }
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
/* 已选提示条 */
|
||||
.uls-selected-bar { display: flex; align-items: center; justify-content: space-between; padding: 8px 14px; margin-bottom: 12px; background: linear-gradient(135deg, #f0f9eb 0%, #e1f3d8 100%); border: 1px solid #c2e7b0; border-radius: 8px; }
|
||||
.uls-sel-left { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #1d2129; }
|
||||
.uls-sel-left b { font-weight: 600; }
|
||||
.sel-id { color: #86909c; font-size: 12px; }
|
||||
.sel-avatar { flex-shrink: 0; font-size: 11px; background: linear-gradient(135deg, #67c23a, #85ce61); color: #fff; }
|
||||
|
||||
.search-form :deep(.el-form-item) {
|
||||
margin-bottom: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.fade-enter-active, .fade-leave-active { transition: all .25s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; transform: translateY(-6px); }
|
||||
|
||||
.user-info-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
/* 卡片网格 */
|
||||
.uls-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; max-height: 340px; overflow-y: auto; padding: 2px; }
|
||||
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
.uls-card { position: relative; display: flex; align-items: center; gap: 12px; padding: 12px 14px; border: 2px solid #ebeef5; border-radius: 10px; cursor: pointer; transition: all .2s ease; background: #fff; }
|
||||
.uls-card:hover { border-color: #b3d8ff; background: #f5faff; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(64,158,255,.1); }
|
||||
.uls-card.active { border-color: #409eff; background: #ecf5ff; }
|
||||
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.uls-card-check { position: absolute; top: -1px; right: -1px; width: 24px; height: 24px; background: #409eff; border-radius: 0 8px 0 8px; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.add-user-section {
|
||||
padding: 30px 60px;
|
||||
}
|
||||
.uls-avatar { flex-shrink: 0; font-size: 16px; font-weight: 700; background: linear-gradient(135deg, #c6e2ff, #409eff); color: #fff; }
|
||||
.uls-card.active .uls-avatar { background: linear-gradient(135deg, #409eff, #337ecc); }
|
||||
|
||||
.add-user-form {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.uls-card-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||||
.uls-card-name { font-size: 14px; font-weight: 600; color: #1d2129; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.admin-tag { transform: scale(.85); transform-origin: left center; }
|
||||
|
||||
.add-user-form :deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
.uls-card-meta { display: flex; flex-wrap: wrap; gap: 4px 8px; font-size: 11px; color: #a8abb2; line-height: 1.3; }
|
||||
.meta-id { color: #86909c; font-weight: 500; }
|
||||
.meta-email, .meta-phone { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 150px; }
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
/* 分页 */
|
||||
.uls-pagination { margin-top: 14px; display: flex; justify-content: flex-end; }
|
||||
|
||||
/* 表格样式 */
|
||||
:deep(.el-table__row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
/* 添加用户 */
|
||||
.uls-add-section { padding: 24px 40px; }
|
||||
.uls-add-form { max-width: 460px; margin: 0 auto; }
|
||||
|
||||
:deep(.el-table__row:hover) {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
/* 底部 */
|
||||
.uls-footer { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
|
||||
:deep(.selected-row) {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
|
||||
:deep(.selected-row td) {
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
}
|
||||
|
||||
:deep(.el-table__body tr.current-row > td) {
|
||||
background-color: var(--el-color-primary-light-8) !important;
|
||||
}
|
||||
|
||||
/* 标签页样式 */
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
font-size: 15px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item.is-active) {
|
||||
font-weight: 600;
|
||||
}
|
||||
/* 滚动条美化 */
|
||||
.uls-grid::-webkit-scrollbar { width: 5px; }
|
||||
.uls-grid::-webkit-scrollbar-thumb { background: #dcdfe6; border-radius: 4px; }
|
||||
.uls-grid::-webkit-scrollbar-thumb:hover { background: #c0c4cc; }
|
||||
</style>
|
||||
|
||||
@@ -130,7 +130,10 @@
|
||||
<el-icon :size="18"><Box /></el-icon>
|
||||
</div>
|
||||
<div class="result-info">
|
||||
<span class="result-title" v-html="highlight(item.good?.name || item.tag || ('商品#' + item.id))"></span>
|
||||
<span class="result-title">
|
||||
<span v-html="highlight(item.good?.name || item.tag || ('商品#' + item.id))"></span>
|
||||
<el-tag v-if="isGoodsDeleted(item)" size="small" type="danger" class="deleted-flag">已删除</el-tag>
|
||||
</span>
|
||||
<span class="result-desc">用户: {{ item.user?.UserName || item.userId }} · 到期: {{ formatTime(item.expireTime) }}</span>
|
||||
</div>
|
||||
<el-icon class="result-arrow"><ArrowRight /></el-icon>
|
||||
@@ -225,7 +228,7 @@ const searchOrders = async (key) => {
|
||||
results.order.loading = true
|
||||
results.order.list = []
|
||||
try {
|
||||
const res = await getOrderList({ page: results.order.page, count: pageSize, keyword: key })
|
||||
const res = await getOrderList({ page: results.order.page, count: pageSize, key })
|
||||
if (res.data?.code === 200) {
|
||||
results.order.list = res.data.data?.list || []
|
||||
results.order.total = res.data.data?.all_count || results.order.list.length
|
||||
@@ -251,7 +254,7 @@ const searchGoods = async (key) => {
|
||||
results.goods.loading = true
|
||||
results.goods.list = []
|
||||
try {
|
||||
const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, keyword: key })
|
||||
const res = await getUserGoodsList({ page: results.goods.page, count: pageSize, key })
|
||||
if (res.data?.code === 200) {
|
||||
results.goods.list = res.data.data?.data || []
|
||||
results.goods.total = res.data.data?.all_count || results.goods.list.length
|
||||
@@ -260,6 +263,9 @@ const searchGoods = async (key) => {
|
||||
results.goods.loading = false
|
||||
}
|
||||
|
||||
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
|
||||
const isGoodsDeleted = (item) => !!(item?.deleteAt || item?.DeleteAt || item?.deleted_at)
|
||||
|
||||
const highlight = (text) => {
|
||||
if (!text || !keyword.value) return text
|
||||
const key = keyword.value.trim()
|
||||
@@ -292,12 +298,7 @@ const goToTicket = (item) => {
|
||||
|
||||
const goToGoods = (item) => {
|
||||
visible.value = false
|
||||
const tag = (item.tag || item.good?.tag || '').toLowerCase()
|
||||
if (tag === '云服务器') {
|
||||
router.push({ path: '/user-goods/vm-detail', query: { id: item.id } })
|
||||
} else {
|
||||
router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
|
||||
}
|
||||
router.push({ name: 'UserGoodsDetail', params: { id: item.id } })
|
||||
}
|
||||
|
||||
const orderStatusText = (status) => {
|
||||
@@ -528,6 +529,11 @@ onUnmounted(() => {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.result-title .deleted-flag {
|
||||
margin-left: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.result-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
|
||||
+36
-17
@@ -47,7 +47,8 @@ export const menus = [
|
||||
title: '商品管理',
|
||||
icon: 'Goods',
|
||||
children: [
|
||||
{ path: '/product/manage', title: '商品管理' }
|
||||
{ path: '/product/manage', title: '商品管理' },
|
||||
{ path: '/product/discount', title: '优惠管理' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -70,22 +71,6 @@ export const menus = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/marketing',
|
||||
title: '优惠营销',
|
||||
icon: 'Present',
|
||||
children: [
|
||||
{
|
||||
path: '/marketing/discount',
|
||||
title: '优惠码管理'
|
||||
},
|
||||
{
|
||||
path: '/marketing/voucher',
|
||||
title: '代金券管理'
|
||||
},
|
||||
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/activity',
|
||||
title: '活动管理',
|
||||
@@ -149,6 +134,40 @@ export const menus = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/sms',
|
||||
title: '短信平台管理',
|
||||
icon: 'ChatDotRound',
|
||||
children: [
|
||||
{
|
||||
path: '/sms/service',
|
||||
title: '主控服务管理'
|
||||
},
|
||||
{
|
||||
path: '/sms/goods',
|
||||
title: '额度商品管理'
|
||||
},
|
||||
{
|
||||
path: '/sms/signature',
|
||||
title: '签名管理'
|
||||
},
|
||||
{
|
||||
path: '/sms/template',
|
||||
title: '模板管理'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/mail',
|
||||
title: '邮箱平台管理',
|
||||
icon: 'Message',
|
||||
children: [
|
||||
{
|
||||
path: '/mail/service',
|
||||
title: '主控服务管理'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '虚拟化平台管理',
|
||||
icon: 'Platform',
|
||||
|
||||
+73
-36
@@ -253,6 +253,18 @@ const routes = [
|
||||
component: () => import('../views/product/ProductGroup.vue'),
|
||||
meta: { title: '商品管理' }
|
||||
},
|
||||
{
|
||||
path: 'discount',
|
||||
name: 'DiscountManage',
|
||||
component: () => import('../views/product/DiscountManage.vue'),
|
||||
meta: { title: '优惠管理' }
|
||||
},
|
||||
{
|
||||
path: 'discount/voucher/:id/manage',
|
||||
name: 'VoucherManagement',
|
||||
component: () => import('../views/marketing/VoucherManagement.vue'),
|
||||
meta: { title: '代金券分发管理', hidden: true, activeMenu: '/product/discount' }
|
||||
},
|
||||
{ path: 'list', redirect: '/product/manage' },
|
||||
{ path: 'group', redirect: '/product/manage' }
|
||||
]
|
||||
@@ -282,12 +294,6 @@ const routes = [
|
||||
component: () => import('../views/user-vm/UserVmList.vue'),
|
||||
meta: { title: '云服务器' }
|
||||
},
|
||||
{
|
||||
path: 'vm-detail',
|
||||
name: 'UserVmDetail',
|
||||
component: () => import('../views/user-vm/UserVmDetail.vue'),
|
||||
meta: { title: '用户虚拟机详情', hidden: true, activeMenu: '/user-goods/vm-list' }
|
||||
}
|
||||
]
|
||||
},
|
||||
// 订单管理路由
|
||||
@@ -310,43 +316,20 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
// 优惠营销路由
|
||||
// 优惠营销路由(已合并至 /product/discount,保留重定向兼容旧链接)
|
||||
{
|
||||
path: 'marketing',
|
||||
name: 'Marketing',
|
||||
meta: {
|
||||
title: '优惠营销',
|
||||
icon: 'Present'
|
||||
icon: 'Present',
|
||||
hidden: true
|
||||
},
|
||||
redirect: '/marketing/discount',
|
||||
redirect: '/product/discount',
|
||||
children: [
|
||||
{
|
||||
path: 'discount',
|
||||
name: 'DiscountCode',
|
||||
component: () => import('../views/marketing/DiscountCode.vue'),
|
||||
meta: {
|
||||
title: '优惠码管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'voucher',
|
||||
name: 'Voucher',
|
||||
component: () => import('../views/marketing/Voucher.vue'),
|
||||
meta: {
|
||||
title: '代金券管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'voucher/:id/manage',
|
||||
name: 'VoucherManagement',
|
||||
component: () => import('../views/marketing/VoucherManagement.vue'),
|
||||
meta: {
|
||||
title: '代金券详情管理',
|
||||
hidden: true,
|
||||
activeMenu: '/marketing/voucher'
|
||||
}
|
||||
},
|
||||
|
||||
{ path: 'discount', redirect: '/product/discount' },
|
||||
{ path: 'voucher', redirect: '/product/discount' },
|
||||
{ path: 'voucher/:id/manage', redirect: to => `/product/discount/voucher/${to.params.id}/manage` }
|
||||
]
|
||||
},
|
||||
// 活动管理路由
|
||||
@@ -609,6 +592,60 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
// 短信平台管理路由
|
||||
{
|
||||
path: 'sms',
|
||||
name: 'Sms',
|
||||
meta: {
|
||||
title: '短信平台管理',
|
||||
icon: 'ChatDotRound'
|
||||
},
|
||||
redirect: '/sms/service',
|
||||
children: [
|
||||
{
|
||||
path: 'service',
|
||||
name: 'SmsService',
|
||||
component: () => import('../views/sms/SmsService.vue'),
|
||||
meta: { title: '主控服务管理' }
|
||||
},
|
||||
{
|
||||
path: 'goods',
|
||||
name: 'SmsGoods',
|
||||
component: () => import('../views/sms/SmsGoods.vue'),
|
||||
meta: { title: '额度商品管理' }
|
||||
},
|
||||
{
|
||||
path: 'signature',
|
||||
name: 'SmsSignature',
|
||||
component: () => import('../views/sms/SmsSignature.vue'),
|
||||
meta: { title: '签名管理' }
|
||||
},
|
||||
{
|
||||
path: 'template',
|
||||
name: 'SmsTemplateMgr',
|
||||
component: () => import('../views/sms/SmsTemplate.vue'),
|
||||
meta: { title: '模板管理' }
|
||||
}
|
||||
]
|
||||
},
|
||||
// 邮箱平台管理路由
|
||||
{
|
||||
path: 'mail',
|
||||
name: 'Mail',
|
||||
meta: {
|
||||
title: '邮箱平台管理',
|
||||
icon: 'Message'
|
||||
},
|
||||
redirect: '/mail/service',
|
||||
children: [
|
||||
{
|
||||
path: 'service',
|
||||
name: 'MailService',
|
||||
component: () => import('../views/mail/MailService.vue'),
|
||||
meta: { title: '主控服务管理' }
|
||||
}
|
||||
]
|
||||
},
|
||||
// 站点审计路由
|
||||
{
|
||||
path: 'audit',
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<div class="mail-service-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div class="header-info">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#409eff" stroke-width="1.8">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="M22 6L12 13 2 6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="header-title">邮件主控服务管理</h2>
|
||||
<p class="header-desc">管理邮件平台的主控服务实例,每个服务对应一个 mail-server 节点</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon> 新增服务
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="queryParams.key"
|
||||
placeholder="搜索名称 / 说明 / 地址"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column prop="name" label="服务名称" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="service-name-cell">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#67c23a" stroke-width="2">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="M22 6L12 13 2 6"/>
|
||||
</svg>
|
||||
<span class="name-text">{{ row.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" label="说明" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="note-text">{{ row.note || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="host" label="服务地址" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info" effect="plain" class="host-tag">{{ row.host }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="SMTP 配置" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.smtpHost" class="smtp-info">
|
||||
<span class="smtp-host">{{ row.smtpHost }}:{{ row.smtpPort || '-' }}</span>
|
||||
<el-tag v-if="row.smtpTlsEnable" type="success" size="small" effect="plain">TLS</el-tag>
|
||||
<el-tag v-else type="info" size="small" effect="plain">无TLS</el-tag>
|
||||
</div>
|
||||
<span v-else class="note-text">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="serviceToken" label="Service Token" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="token-cell">
|
||||
<span v-if="!row._showToken" class="token-mask">{{ maskToken(row.serviceToken) }}</span>
|
||||
<span v-else class="token-full">{{ row.serviceToken }}</span>
|
||||
<el-button link size="small" @click="row._showToken = !row._showToken">
|
||||
{{ row._showToken ? '隐藏' : '显示' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" width="170" align="center">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt || row.CreatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="success" size="small" @click="openConsole(row)">控制台</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除该邮件服务?" @confirm="handleDelete(row)">
|
||||
<template #reference>
|
||||
<el-button link type="danger" size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.count"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="fetchList"
|
||||
@current-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑邮件主控服务' : '新增邮件主控服务'"
|
||||
width="600px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="服务名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入服务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明" prop="note">
|
||||
<el-input v-model="form.note" type="textarea" :rows="2" placeholder="服务说明(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务地址" prop="host">
|
||||
<el-input v-model="form.host" placeholder="https://mail.example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Service Token" prop="service_token">
|
||||
<el-input v-model="form.service_token" placeholder="mail-server 的 SERVICE_TOKEN" show-password />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">SMTP 配置(可选)</el-divider>
|
||||
<el-form-item label="SMTP 地址" prop="smtp_host">
|
||||
<el-input v-model="form.smtp_host" placeholder="smtp.example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SMTP 端口" prop="smtp_port">
|
||||
<el-input-number v-model="form.smtp_port" :min="1" :max="65535" placeholder="465" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用 TLS" prop="smtp_tls_enable">
|
||||
<el-switch v-model="form.smtp_tls_enable" active-text="启用" inactive-text="关闭" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getMailServiceList,
|
||||
createMailService,
|
||||
updateMailService,
|
||||
deleteMailService
|
||||
} from '@/api/admin/mailService.js'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
count: 10,
|
||||
key: ''
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
note: '',
|
||||
host: '',
|
||||
service_token: '',
|
||||
smtp_host: '',
|
||||
smtp_port: null,
|
||||
smtp_tls_enable: false
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
|
||||
host: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
|
||||
service_token: [{ required: true, message: '请输入 Service Token', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
return new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
const maskToken = (token) => {
|
||||
if (!token) return '-'
|
||||
if (token.length <= 8) return '****'
|
||||
return token.slice(0, 4) + '****' + token.slice(-4)
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getMailServiceList(queryParams)
|
||||
const body = res.data
|
||||
if (body.code === 200) {
|
||||
const d = body.data?.data || body.data || []
|
||||
tableData.value = (Array.isArray(d) ? d : []).map(item => ({ ...item, _showToken: false }))
|
||||
total.value = body.data?.all_count || body.total || 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.key = ''
|
||||
queryParams.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
form.value = { id: null, name: '', note: '', host: '', service_token: '', smtp_host: '', smtp_port: null, smtp_tls_enable: false }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
form.value = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
note: row.note || '',
|
||||
host: row.host,
|
||||
service_token: row.serviceToken || row.service_token || '',
|
||||
smtp_host: row.smtpHost || row.smtp_host || '',
|
||||
smtp_port: row.smtpPort || row.smtp_port || null,
|
||||
smtp_tls_enable: !!(row.smtpTlsEnable ?? row.smtp_tls_enable)
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (isEdit.value) params.append('id', form.value.id)
|
||||
params.append('name', form.value.name)
|
||||
params.append('note', form.value.note)
|
||||
params.append('host', form.value.host)
|
||||
params.append('service_token', form.value.service_token)
|
||||
if (form.value.smtp_host) params.append('smtp_host', form.value.smtp_host)
|
||||
if (form.value.smtp_port) params.append('smtp_port', form.value.smtp_port)
|
||||
params.append('smtp_tls_enable', form.value.smtp_tls_enable ? 'true' : 'false')
|
||||
|
||||
const fn = isEdit.value ? updateMailService : createMailService
|
||||
const res = await fn(params)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('id', row.id)
|
||||
const res = await deleteMailService(params)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const openConsole = (row) => {
|
||||
const base = (row.host || '').replace(/\/+$/, '')
|
||||
if (!base) return ElMessage.warning('该服务未配置地址')
|
||||
window.open(`${base}/ui/index.html?token=${encodeURIComponent(row.serviceToken || '')}`, '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mail-service-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4f8 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e0ecf5;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.service-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.host-tag {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.smtp-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.smtp-host {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.token-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.token-mask {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.token-full {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: #303133;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,677 +0,0 @@
|
||||
<template>
|
||||
<div class="discount-code-container">
|
||||
<!-- 主容器 -->
|
||||
<el-card class="main-container" shadow="never">
|
||||
<!-- 搜索和操作栏 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-content">
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增优惠码
|
||||
</el-button>
|
||||
<el-button type="success" @click="fetchDiscountList">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
|
||||
<el-icon><Delete /></el-icon>批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 优惠码列表 -->
|
||||
<div class="table-section">
|
||||
<!-- 骨架屏 -->
|
||||
<div v-if="loading" class="skeleton-container">
|
||||
<div v-for="i in 5" :key="i" class="skeleton-row">
|
||||
<div class="skeleton-cell skeleton-checkbox"></div>
|
||||
<div class="skeleton-cell skeleton-id"></div>
|
||||
<div class="skeleton-cell skeleton-code"></div>
|
||||
<div class="skeleton-cell skeleton-name"></div>
|
||||
<div class="skeleton-cell skeleton-type"></div>
|
||||
<div class="skeleton-cell skeleton-value"></div>
|
||||
<div class="skeleton-cell skeleton-min"></div>
|
||||
<div class="skeleton-cell skeleton-max"></div>
|
||||
<div class="skeleton-cell skeleton-times"></div>
|
||||
<div class="skeleton-cell skeleton-action"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-else
|
||||
v-loading="loading"
|
||||
:data="discountList"
|
||||
@selection-change="handleSelectionChange"
|
||||
style="width: 100%"
|
||||
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="code" label="优惠码" min-width="150" />
|
||||
<el-table-column prop="name" label="名称" min-width="180" />
|
||||
<el-table-column label="优惠类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.percentage ? 'success' : 'primary'">
|
||||
{{ row.percentage ? '百分比折扣' : '固定金额' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="优惠值" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.percentage" class="discount-value">{{ (row.percentage / 100).toFixed(0) }}%</span>
|
||||
<span v-else class="amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最低消费" width="120">
|
||||
<template #default="{ row }">
|
||||
¥{{ (row.minAmount / 100).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最大抵扣" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.maxAmount">¥{{ (row.maxAmount / 100).toFixed(2) }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="maxTimes" label="最大使用次数" width="120" />
|
||||
<el-table-column prop="userTimes" label="单用户次数" width="120" />
|
||||
<el-table-column label="可叠加" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-icon v-if="row.canStacking" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
|
||||
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="续费可用" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-icon v-if="row.renew" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
|
||||
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="success" link @click="handleView(row)">查看</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.count"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
background
|
||||
class="pagination"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 优惠码表单对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogType === 'add' ? '新增优惠码' : '编辑优惠码'"
|
||||
width="700px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form
|
||||
ref="discountFormRef"
|
||||
:model="discountForm"
|
||||
:rules="discountRules"
|
||||
label-width="140px"
|
||||
>
|
||||
<el-form-item label="优惠码" prop="code">
|
||||
<el-input v-model="discountForm.code" placeholder="请输入优惠码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优惠码名称" prop="name">
|
||||
<el-input v-model="discountForm.name" placeholder="请输入优惠码名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="note">
|
||||
<el-input v-model="discountForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优惠类型" prop="discount_mode">
|
||||
<el-radio-group v-model="discountForm.discount_mode">
|
||||
<el-radio label="amount">固定金额</el-radio>
|
||||
<el-radio label="percentage">百分比折扣</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额" prop="amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage">
|
||||
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最低消费" prop="min_amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大抵扣" prop="max_amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大使用次数" prop="max_times">
|
||||
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单用户最大次数" prop="user_times">
|
||||
<el-input-number v-model="discountForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期" prop="timeRange">
|
||||
<el-date-picker
|
||||
v-model="discountForm.timeRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:teleported="true"
|
||||
popper-class="discount-date-picker"
|
||||
placement="top-start"
|
||||
:editable="true"
|
||||
:clearable="true"
|
||||
style="width: 100%"
|
||||
@keyup.enter="handleDatePickerEnter"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="续费可用" prop="renew">
|
||||
<el-switch v-model="discountForm.renew" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
<el-form-item label="同类型可叠加" prop="can_stacking">
|
||||
<el-switch v-model="discountForm.can_stacking" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
<el-form-item label="其他类型可叠加" prop="can_combine">
|
||||
<el-switch v-model="discountForm.can_combine" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情查看对话框 -->
|
||||
<DiscountDetailDialog
|
||||
v-model="detailDialogVisible"
|
||||
type="code"
|
||||
:detail-data="currentDetail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Delete, Search, Refresh, SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDiscountCodeList,
|
||||
getDiscountCodeDetail,
|
||||
createDiscountCode,
|
||||
updateDiscountCode,
|
||||
deleteDiscountCode
|
||||
} from '@/api/admin/discount'
|
||||
import { timeToTimestamp } from '@/utils/tool'
|
||||
import DiscountDetailDialog from '@/components/marketing/DiscountDetailDialog.vue'
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
discount_type: 'code', // 固定为code表示优惠码
|
||||
page: 1,
|
||||
count: 10
|
||||
})
|
||||
|
||||
// 优惠码表单
|
||||
const discountForm = reactive({
|
||||
code_id: undefined,
|
||||
discount_type: 'code', // 固定为code
|
||||
code: '',
|
||||
name: '',
|
||||
note: '',
|
||||
discount_mode: 'amount', // amount 或 percentage
|
||||
amount: 0,
|
||||
percentage: 0,
|
||||
min_amount: 0,
|
||||
max_amount: 0,
|
||||
max_times: 0,
|
||||
user_times: 0,
|
||||
timeRange: [],
|
||||
renew: false,
|
||||
can_stacking: false,
|
||||
can_combine: false
|
||||
})
|
||||
|
||||
const discountRules = {
|
||||
code: [
|
||||
{ required: true, message: '请输入优惠码', trigger: 'blur' }
|
||||
],
|
||||
name: [
|
||||
{ required: true, message: '请输入优惠码名称', trigger: 'blur' }
|
||||
],
|
||||
discount_mode: [
|
||||
{ required: true, message: '请选择优惠类型', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 状态数据
|
||||
const loading = ref(false)
|
||||
const discountList = ref([])
|
||||
const total = ref(0)
|
||||
const selectedRows = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
const discountFormRef = ref(null)
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
// 获取优惠码列表
|
||||
const fetchDiscountList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDiscountCodeList(queryParams)
|
||||
console.log('优惠码列表数据:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
discountList.value = res.data.data?.data || []
|
||||
total.value = res.data.data?.all_count || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取优惠码列表失败:', error)
|
||||
ElMessage.error('获取优惠码列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 选择项变化
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedRows.value = selection
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleSizeChange = (size) => {
|
||||
queryParams.count = size
|
||||
fetchDiscountList()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page) => {
|
||||
queryParams.page = page
|
||||
fetchDiscountList()
|
||||
}
|
||||
|
||||
// 新增优惠码
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
dialogVisible.value = true
|
||||
Object.assign(discountForm, {
|
||||
code_id: undefined,
|
||||
discount_type: 'code',
|
||||
code: '',
|
||||
name: '',
|
||||
note: '',
|
||||
discount_mode: 'amount',
|
||||
amount: 0,
|
||||
percentage: 0,
|
||||
min_amount: 0,
|
||||
max_amount: 0,
|
||||
max_times: 0,
|
||||
user_times: 0,
|
||||
timeRange: [],
|
||||
renew: false,
|
||||
can_stacking: false,
|
||||
can_combine: false
|
||||
})
|
||||
discountFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 编辑优惠码
|
||||
const handleEdit = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
dialogVisible.value = true
|
||||
|
||||
// 转换日期字符串为日期选择器格式
|
||||
const startTime = row.startTime ? new Date(row.startTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
|
||||
const endTime = row.endTime ? new Date(row.endTime).toLocaleString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'}).replace(/\//g, '-') : ''
|
||||
|
||||
Object.assign(discountForm, {
|
||||
code_id: row.id,
|
||||
discount_type: 'code',
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
note: row.note || '',
|
||||
discount_mode: row.percentage ? 'percentage' : 'amount',
|
||||
amount: row.amount ? row.amount / 100 : 0,
|
||||
percentage: row.percentage ? row.percentage / 100 : 0,
|
||||
min_amount: row.minAmount ? row.minAmount / 100 : 0,
|
||||
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
|
||||
max_times: row.maxTimes || 0,
|
||||
user_times: row.userTimes || 0,
|
||||
timeRange: startTime && endTime ? [startTime, endTime] : [],
|
||||
renew: row.renew || false,
|
||||
can_stacking: row.canStacking || false,
|
||||
can_combine: row.canCombine || false
|
||||
})
|
||||
}
|
||||
|
||||
// 查看优惠码详情
|
||||
const handleView = async (row) => {
|
||||
try {
|
||||
const res = await getDiscountCodeDetail({ code_id: row.id })
|
||||
console.log('优惠码详情:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
currentDetail.value = res.data.data
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取优惠码详情失败:', error)
|
||||
ElMessage.error('获取优惠码详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除优惠码
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除优惠码 ${row.code} 吗?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteDiscountCode({ code_id: row.id })
|
||||
console.log('删除响应:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchDiscountList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRows.value.length === 0) {
|
||||
ElMessage.warning('请至少选择一条记录')
|
||||
return
|
||||
}
|
||||
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const deletePromises = selectedRows.value.map(row =>
|
||||
deleteDiscountCode({ code_id: row.id })
|
||||
)
|
||||
|
||||
const results = await Promise.allSettled(deletePromises)
|
||||
|
||||
const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.data?.code === 200).length
|
||||
const failCount = results.length - successCount
|
||||
|
||||
if (failCount === 0) {
|
||||
ElMessage.success(`批量删除成功,共删除 ${successCount} 条记录`)
|
||||
} else if (successCount === 0) {
|
||||
ElMessage.error(`批量删除失败,所有 ${failCount} 条记录删除失败`)
|
||||
} else {
|
||||
ElMessage.warning(`批量删除完成,成功 ${successCount} 条,失败 ${failCount} 条`)
|
||||
}
|
||||
|
||||
fetchDiscountList()
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
ElMessage.error('批量删除操作异常')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 处理日期选择器回车事件
|
||||
const handleDatePickerEnter = (event) => {
|
||||
// 回车键确认日期选择
|
||||
const datePicker = event.target.closest('.el-date-editor')
|
||||
if (datePicker) {
|
||||
// 触发失焦事件,确认日期选择
|
||||
event.target.blur()
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const submitForm = () => {
|
||||
discountFormRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const submitData = {
|
||||
discount_type: 'code',
|
||||
code: discountForm.code,
|
||||
name: discountForm.name,
|
||||
note: discountForm.note,
|
||||
min_amount: Math.round(discountForm.min_amount * 100),
|
||||
max_amount: Math.round(discountForm.max_amount * 100),
|
||||
max_times: discountForm.max_times || 0,
|
||||
user_times: discountForm.user_times || 0,
|
||||
renew: discountForm.renew,
|
||||
can_stacking: discountForm.can_stacking,
|
||||
can_combine: discountForm.can_combine
|
||||
}
|
||||
|
||||
// 根据优惠类型设置amount或percentage
|
||||
if (discountForm.discount_mode === 'percentage') {
|
||||
submitData.percentage = Math.round(discountForm.percentage * 100)
|
||||
submitData.amount = 0
|
||||
} else {
|
||||
submitData.amount = Math.round(discountForm.amount * 100)
|
||||
submitData.percentage = 0
|
||||
}
|
||||
|
||||
// 处理时间(转换为秒级时间戳)
|
||||
if (discountForm.timeRange && discountForm.timeRange.length === 2) {
|
||||
submitData.start_time = timeToTimestamp(discountForm.timeRange[0])
|
||||
submitData.end_time = timeToTimestamp(discountForm.timeRange[1])
|
||||
} else {
|
||||
submitData.start_time = ''
|
||||
submitData.end_time = ''
|
||||
}
|
||||
|
||||
// 如果是编辑,添加code_id
|
||||
if (dialogType.value === 'edit') {
|
||||
submitData.code_id = discountForm.code_id
|
||||
}
|
||||
|
||||
console.log('提交优惠码数据:', submitData)
|
||||
|
||||
let res
|
||||
if (dialogType.value === 'add') {
|
||||
res = await createDiscountCode(submitData)
|
||||
} else {
|
||||
res = await updateDiscountCode(submitData)
|
||||
}
|
||||
|
||||
console.log('提交响应:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
|
||||
dialogVisible.value = false
|
||||
fetchDiscountList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchDiscountList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.discount-code-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
border: 1px solid #e1e8ed;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.filter-content {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.table-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #f56c6c;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.discount-value {
|
||||
color: #67c23a;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e1e8ed;
|
||||
background: #fafbfc;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
:deep(.el-table) {
|
||||
border: none;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
:deep(.el-table__header) {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: #f8f9fa !important;
|
||||
border-bottom: 2px solid #e1e8ed;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
:deep(.el-table tr:hover > td) {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 骨架屏样式 */
|
||||
.skeleton-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.skeleton-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.skeleton-cell {
|
||||
height: 20px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-loading 1.5s ease-in-out infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.skeleton-checkbox { width: 55px; }
|
||||
.skeleton-id { width: 80px; }
|
||||
.skeleton-code { width: 150px; }
|
||||
.skeleton-name { width: 180px; }
|
||||
.skeleton-type { width: 120px; }
|
||||
.skeleton-value { width: 120px; }
|
||||
.skeleton-min { width: 120px; }
|
||||
.skeleton-max { width: 120px; }
|
||||
.skeleton-times { width: 120px; }
|
||||
.skeleton-action { width: 200px; height: 32px; }
|
||||
|
||||
@keyframes skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 时间选择器弹出层样式 - 非 scoped */
|
||||
.discount-date-picker {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
.discount-date-picker .el-picker-panel {
|
||||
max-width: 90vw;
|
||||
}
|
||||
</style>
|
||||
@@ -161,47 +161,39 @@
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择类型" prop="select_type" v-if="dialogType === 'add'">
|
||||
<el-radio-group v-model="form.select_type" @change="handleSelectTypeChange">
|
||||
<el-radio value="product">商品</el-radio>
|
||||
<el-radio value="product_group">商品组</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择商品" prop="selected_product" v-if="dialogType === 'add' && form.select_type === 'product'">
|
||||
<el-select
|
||||
v-model="form.selected_product"
|
||||
placeholder="请选择商品"
|
||||
filterable
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handleProductChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in productOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (ID: ${item.id})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="选择商品组" prop="selected_group" v-if="dialogType === 'add' && form.select_type === 'product_group'">
|
||||
<el-select
|
||||
v-model="form.selected_group"
|
||||
placeholder="请选择商品组"
|
||||
filterable
|
||||
clearable
|
||||
style="width: 100%"
|
||||
@change="handleProductGroupChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in productGroupOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (ID: ${item.id})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-form-item label="选择关联对象" v-if="dialogType === 'add'">
|
||||
<div class="goods-tree-wrapper">
|
||||
<div class="goods-tree-toolbar">
|
||||
<span class="tree-tip">可自由勾选商品组与商品,展开层级查看下属内容</span>
|
||||
<div class="tree-summary">
|
||||
已选 <b>{{ checkedSummary.groupCount }}</b> 个商品组 / <b>{{ checkedSummary.productCount }}</b> 个商品
|
||||
</div>
|
||||
</div>
|
||||
<el-tree
|
||||
ref="goodsTreeRef"
|
||||
:props="treeProps"
|
||||
:load="loadTreeNode"
|
||||
lazy
|
||||
show-checkbox
|
||||
check-strictly
|
||||
node-key="key"
|
||||
class="goods-tree"
|
||||
@check="handleTreeCheck"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<span class="tree-node">
|
||||
<el-tag size="small" :type="data.nodeType === 'group' ? 'warning' : 'primary'" effect="plain">
|
||||
{{ data.nodeType === 'group' ? '组' : '品' }}
|
||||
</el-tag>
|
||||
<span class="tree-node-label">{{ data.label }}</span>
|
||||
<span class="tree-node-id">ID: {{ data.rawId }}</span>
|
||||
<span v-if="data.nodeType === 'product' && data.price != null" class="tree-node-price">
|
||||
¥{{ (data.price / 100).toFixed(2) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 编辑模式显示字段 -->
|
||||
@@ -234,7 +226,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, onMounted, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, Search, Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import {
|
||||
@@ -276,36 +268,27 @@ const form = reactive({
|
||||
code_id: undefined,
|
||||
goods_id: undefined,
|
||||
goods_name: '',
|
||||
goods_type: '',
|
||||
select_type: 'product', // 选择类型:product 或 product_group
|
||||
selected_product: undefined, // 选中的商品ID
|
||||
selected_group: undefined // 选中的商品组ID
|
||||
goods_type: ''
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
code_id: [
|
||||
{ required: true, message: '请选择代金券', trigger: 'change' }
|
||||
],
|
||||
select_type: [
|
||||
{ required: true, message: '请选择类型', trigger: 'change' }
|
||||
],
|
||||
selected_product: [
|
||||
{ required: true, message: '请选择商品', trigger: 'change' }
|
||||
],
|
||||
selected_group: [
|
||||
{ required: true, message: '请选择商品组', trigger: 'change' }
|
||||
],
|
||||
goods_id: [
|
||||
{ required: true, message: '请输入商品ID', trigger: 'blur' }
|
||||
],
|
||||
goods_name: [
|
||||
{ required: true, message: '请输入商品名称', trigger: 'blur' }
|
||||
],
|
||||
goods_type: [
|
||||
{ required: true, message: '请选择商品类型', trigger: 'change' }
|
||||
]
|
||||
}
|
||||
|
||||
// 折叠层级选择器相关
|
||||
const goodsTreeRef = ref(null)
|
||||
const treeProps = {
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
isLeaf: 'isLeaf'
|
||||
}
|
||||
const checkedSummary = reactive({ groupCount: 0, productCount: 0 })
|
||||
|
||||
// 状态数据
|
||||
const loading = ref(false)
|
||||
const goodsList = ref([])
|
||||
@@ -436,33 +419,79 @@ const fetchProductGroupList = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 选择类型变化
|
||||
const handleSelectTypeChange = (type) => {
|
||||
form.selected_product = undefined
|
||||
form.selected_group = undefined
|
||||
form.goods_id = undefined
|
||||
form.goods_name = ''
|
||||
form.goods_type = ''
|
||||
}
|
||||
// 折叠层级选择器:懒加载节点
|
||||
// node.level === 0 时加载顶级商品组;展开商品组时加载其子分组与下属商品
|
||||
const loadTreeNode = async (node, resolve) => {
|
||||
try {
|
||||
// 根节点:仅加载 level=1 的顶级商品组
|
||||
if (node.level === 0) {
|
||||
const res = await getProductGroupList({ level: 1 })
|
||||
if (res.data.code === 200) {
|
||||
const groups = res.data.data?.data || []
|
||||
return resolve(groups.map(buildGroupNode))
|
||||
}
|
||||
return resolve([])
|
||||
}
|
||||
|
||||
// 选择商品变化
|
||||
const handleProductChange = (productId) => {
|
||||
const product = productOptions.value.find(item => item.id === productId)
|
||||
if (product) {
|
||||
form.goods_id = product.id
|
||||
form.goods_name = product.goodsName || product.name || ''
|
||||
form.goods_type = 'product'
|
||||
// 商品组节点:逐级加载子分组 + 下属商品
|
||||
if (node.data?.nodeType === 'group') {
|
||||
const groupId = node.data.rawId
|
||||
const childLevel = (node.data.level || 1) + 1
|
||||
const tasks = [
|
||||
getProductList({ good_group_id: groupId, delete: false })
|
||||
]
|
||||
// 仅当存在子分组时才请求下一级分组
|
||||
if (node.data.existSub) {
|
||||
tasks.push(getProductGroupList({ parent_id: groupId, level: childLevel }))
|
||||
}
|
||||
const results = await Promise.all(tasks)
|
||||
|
||||
const productRes = results[0]
|
||||
const productNodes = (productRes.data.code === 200 ? (productRes.data.data?.data || []) : [])
|
||||
.map(buildProductNode)
|
||||
|
||||
let groupNodes = []
|
||||
if (node.data.existSub && results[1]?.data.code === 200) {
|
||||
groupNodes = (results[1].data.data?.data || []).map(buildGroupNode)
|
||||
}
|
||||
|
||||
return resolve([...groupNodes, ...productNodes])
|
||||
}
|
||||
|
||||
return resolve([])
|
||||
} catch (error) {
|
||||
console.error('加载层级数据失败:', error)
|
||||
ElMessage.error('加载层级数据失败')
|
||||
return resolve([])
|
||||
}
|
||||
}
|
||||
|
||||
// 选择商品组变化
|
||||
const handleProductGroupChange = (groupId) => {
|
||||
const group = productGroupOptions.value.find(item => item.id === groupId)
|
||||
if (group) {
|
||||
form.goods_id = group.id
|
||||
form.goods_name = group.name || ''
|
||||
form.goods_type = 'product_group'
|
||||
}
|
||||
// 构建商品组节点
|
||||
const buildGroupNode = (group) => ({
|
||||
key: `group_${group.id}`,
|
||||
rawId: group.id,
|
||||
nodeType: 'group',
|
||||
label: group.name,
|
||||
level: group.level || 1,
|
||||
existSub: group.existSub || false,
|
||||
isLeaf: false
|
||||
})
|
||||
|
||||
// 构建商品节点
|
||||
const buildProductNode = (product) => ({
|
||||
key: `product_${product.id}`,
|
||||
rawId: product.id,
|
||||
nodeType: 'product',
|
||||
label: product.name,
|
||||
price: product.price,
|
||||
isLeaf: true
|
||||
})
|
||||
|
||||
// 勾选变化时更新汇总
|
||||
const handleTreeCheck = () => {
|
||||
const nodes = goodsTreeRef.value?.getCheckedNodes() || []
|
||||
checkedSummary.groupCount = nodes.filter(n => n.nodeType === 'group').length
|
||||
checkedSummary.productCount = nodes.filter(n => n.nodeType === 'product').length
|
||||
}
|
||||
|
||||
// 获取商品关联列表
|
||||
@@ -527,16 +556,15 @@ const handleAdd = () => {
|
||||
code_id: queryParams.code_id ? Number(queryParams.code_id) : undefined,
|
||||
goods_id: undefined,
|
||||
goods_name: '',
|
||||
goods_type: '',
|
||||
select_type: 'product',
|
||||
selected_product: undefined,
|
||||
selected_group: undefined
|
||||
goods_type: ''
|
||||
})
|
||||
checkedSummary.groupCount = 0
|
||||
checkedSummary.productCount = 0
|
||||
formRef.value?.resetFields()
|
||||
|
||||
// 加载商品和商品组列表
|
||||
fetchProductList()
|
||||
fetchProductGroupList()
|
||||
// 等待对话框渲染后清空树的勾选状态
|
||||
nextTick(() => {
|
||||
goodsTreeRef.value?.setCheckedKeys([])
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑商品关联
|
||||
@@ -641,80 +669,77 @@ const handleBatchDelete = () => {
|
||||
|
||||
// 提交表单
|
||||
const submitForm = () => {
|
||||
// 新增模式下的额外验证
|
||||
if (dialogType.value === 'add') {
|
||||
if (!form.code_id) {
|
||||
ElMessage.warning('请选择代金券')
|
||||
return
|
||||
}
|
||||
if (form.select_type === 'product' && !form.selected_product) {
|
||||
ElMessage.warning('请选择商品')
|
||||
return
|
||||
}
|
||||
if (form.select_type === 'product_group' && !form.selected_group) {
|
||||
ElMessage.warning('请选择商品组')
|
||||
return
|
||||
}
|
||||
if (!form.goods_id || !form.goods_name || !form.goods_type) {
|
||||
ElMessage.warning('请先选择商品或商品组')
|
||||
return
|
||||
}
|
||||
if (!form.code_id) {
|
||||
ElMessage.warning('请选择代金券')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (dialogType.value === 'add') {
|
||||
// 收集树中勾选的商品组与商品
|
||||
const checkedNodes = goodsTreeRef.value?.getCheckedNodes() || []
|
||||
const goodIds = checkedNodes.filter(n => n.nodeType === 'product').map(n => n.rawId)
|
||||
const goodGroupIds = checkedNodes.filter(n => n.nodeType === 'group').map(n => n.rawId)
|
||||
|
||||
if (goodIds.length === 0 && goodGroupIds.length === 0) {
|
||||
ElMessage.warning('请至少勾选一个商品或商品组')
|
||||
return
|
||||
}
|
||||
|
||||
submitAdd(goodIds, goodGroupIds)
|
||||
return
|
||||
}
|
||||
|
||||
// 编辑模式
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const submitData = {
|
||||
code_id: String(form.code_id)
|
||||
}
|
||||
|
||||
// 根据选择类型决定传 good_id 还是 good_group_id
|
||||
if (dialogType.value === 'add') {
|
||||
if (form.select_type === 'product') {
|
||||
// 选择的是商品,传 good_id
|
||||
submitData.good_ids = String(form.goods_id)
|
||||
} else if (form.select_type === 'product_group') {
|
||||
// 选择的是商品组,传 good_group_id
|
||||
submitData.good_group_ids = String(form.goods_id)
|
||||
}
|
||||
} else {
|
||||
// 编辑模式:传 discount_good_id
|
||||
submitData.discount_good_id = String(form.id)
|
||||
|
||||
// 根据 goods_type 判断传 good_id 还是 good_group_id
|
||||
if (form.goods_type === 'product') {
|
||||
submitData.good_id = String(form.goods_id)
|
||||
} else if (form.goods_type === 'product_group') {
|
||||
submitData.good_group_id = String(form.goods_id)
|
||||
} else {
|
||||
// 其他类型默认使用 good_id
|
||||
submitData.good_id = String(form.goods_id)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('提交商品关联数据:', submitData)
|
||||
|
||||
let res
|
||||
if (dialogType.value === 'add') {
|
||||
res = await addDiscountGoods(submitData)
|
||||
} else {
|
||||
res = await updateDiscountGoods(submitData)
|
||||
}
|
||||
|
||||
console.log('提交响应:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
|
||||
dialogVisible.value = false
|
||||
fetchGoodsList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
if (!valid) return
|
||||
try {
|
||||
const submitData = {
|
||||
code_id: String(form.code_id),
|
||||
discount_good_id: String(form.id)
|
||||
}
|
||||
if (form.goods_type === 'product_group') {
|
||||
submitData.good_group_id = String(form.goods_id)
|
||||
} else {
|
||||
submitData.good_id = String(form.goods_id)
|
||||
}
|
||||
|
||||
const res = await updateDiscountGoods(submitData)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('修改成功')
|
||||
dialogVisible.value = false
|
||||
fetchGoodsList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 新增提交:根据勾选构建 good_ids / good_group_ids(逗号分隔)
|
||||
const submitAdd = async (goodIds, goodGroupIds) => {
|
||||
try {
|
||||
const submitData = { code_id: String(form.code_id) }
|
||||
if (goodIds.length > 0) {
|
||||
submitData.good_ids = goodIds.join(',')
|
||||
}
|
||||
if (goodGroupIds.length > 0) {
|
||||
submitData.good_group_ids = goodGroupIds.join(',')
|
||||
}
|
||||
|
||||
console.log('提交商品关联数据:', submitData)
|
||||
const res = await addDiscountGoods(submitData)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('新增成功')
|
||||
dialogVisible.value = false
|
||||
fetchGoodsList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchVoucherListOptions()
|
||||
@@ -798,6 +823,57 @@ onMounted(() => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 折叠层级选择器样式 */
|
||||
.goods-tree-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid #e1e8ed;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goods-tree-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #fafbfc;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.tree-summary b {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.goods-tree {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tree-node-label {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.tree-node-id {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tree-node-price {
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ const editForm = reactive({
|
||||
|
||||
const editRules = {
|
||||
discount_id: [
|
||||
{ required: true, message: '请输入代金券ID', trigger: 'blur' }
|
||||
{ required: false, message: '请输入代金券ID', trigger: 'blur' }
|
||||
],
|
||||
use_times: [
|
||||
{ required: true, message: '请输入已使用次数', trigger: 'blur' }
|
||||
|
||||
@@ -1,569 +0,0 @@
|
||||
<template>
|
||||
<div class="voucher-container">
|
||||
<!-- 搜索和操作栏 -->
|
||||
<el-card class="filter-container" shadow="never">
|
||||
<div class="action-bar">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增代金券
|
||||
</el-button>
|
||||
<el-button type="success" @click="fetchVoucherList">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
<el-button type="danger" :disabled="!selectedRows.length" @click="handleBatchDelete">
|
||||
<el-icon><Delete /></el-icon>批量删除
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 代金券列表 -->
|
||||
<el-card class="table-container" shadow="never">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="voucherList"
|
||||
@selection-change="handleSelectionChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column type="selection" width="55" />
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="代金券名称" min-width="200" />
|
||||
<el-table-column label="面额" width="120">
|
||||
<template #default="{ row }">
|
||||
<span class="amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最低消费" width="120">
|
||||
<template #default="{ row }">
|
||||
¥{{ (row.minAmount / 100).toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最大抵扣" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.maxAmount">¥{{ (row.maxAmount / 100).toFixed(2) }}</span>
|
||||
<span v-else>无限制</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="maxTimes" label="最大使用次数" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ row.maxTimes || '无限制' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userTimes" label="单用户次数" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.userTimes || '无限制' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期(天)" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.duration ? (row.duration / 86400).toFixed(0) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="续费可用" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-icon v-if="row.renew" color="#67c23a" :size="20"><SuccessFilled /></el-icon>
|
||||
<el-icon v-else color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="primary" link @click="handleManage(row)">管理</el-button>
|
||||
<el-button type="success" link @click="handleView(row)">查看</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.count"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
background
|
||||
class="pagination"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 代金券表单对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogType === 'add' ? '新增代金券' : '编辑代金券'"
|
||||
width="700px"
|
||||
>
|
||||
<el-form
|
||||
ref="voucherFormRef"
|
||||
:model="voucherForm"
|
||||
:rules="voucherRules"
|
||||
label-width="140px"
|
||||
>
|
||||
<el-form-item label="代金券名称" prop="name">
|
||||
<el-input v-model="voucherForm.name" placeholder="请输入代金券名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="note">
|
||||
<el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
<el-form-item label="面额" prop="amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最低消费" prop="min_amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大抵扣" prop="max_amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大使用次数" prop="max_times">
|
||||
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单用户最大次数" prop="user_times">
|
||||
<el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期" prop="duration_days">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="flex:1" />
|
||||
<span class="unit-text">天</span>
|
||||
</div>
|
||||
<div class="form-tip">代金券领取后的有效持续时间</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="发放时间范围" prop="timeRange">
|
||||
<el-date-picker
|
||||
v-model="voucherForm.timeRange"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
:teleported="true"
|
||||
popper-class="voucher-date-picker"
|
||||
placement="top-start"
|
||||
:editable="true"
|
||||
:clearable="true"
|
||||
style="width: 100%"
|
||||
@keyup.enter="handleDatePickerEnter"
|
||||
/>
|
||||
<div class="form-tip">代金券可以发放给用户的时间范围</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="续费可用" prop="renew">
|
||||
<el-switch v-model="voucherForm.renew" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
<el-form-item label="同类型可叠加" prop="can_stacking">
|
||||
<el-switch v-model="voucherForm.can_stacking" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
<el-form-item label="其他类型可叠加" prop="can_combine">
|
||||
<el-switch v-model="voucherForm.can_combine" active-text="是" inactive-text="否" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitForm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 详情查看对话框 -->
|
||||
<DiscountDetailDialog
|
||||
v-model="detailDialogVisible"
|
||||
type="coupon"
|
||||
:detail-data="currentDetail"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Delete, Refresh, SuccessFilled, CircleCloseFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getDiscountCodeList,
|
||||
getDiscountCodeDetail,
|
||||
createDiscountCode,
|
||||
updateDiscountCode,
|
||||
deleteDiscountCode
|
||||
} from '@/api/admin/discount'
|
||||
import { timeToTimestamp } from '@/utils/tool'
|
||||
import DiscountDetailDialog from '@/components/marketing/DiscountDetailDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
discount_type: 'coupon', // 固定为coupon表示代金券
|
||||
page: 1,
|
||||
count: 10
|
||||
})
|
||||
|
||||
// 代金券表单
|
||||
const voucherForm = reactive({
|
||||
code_id: undefined,
|
||||
discount_type: 'coupon', // 固定为coupon
|
||||
name: '',
|
||||
note: '',
|
||||
amount: 0,
|
||||
min_amount: 0,
|
||||
max_amount: 0,
|
||||
max_times: 0,
|
||||
user_times: 0,
|
||||
duration_days: 30, // 默认30天
|
||||
timeRange: [],
|
||||
renew: false,
|
||||
can_stacking: false,
|
||||
can_combine: false
|
||||
})
|
||||
|
||||
const voucherRules = {
|
||||
name: [
|
||||
{ required: true, message: '请输入代金券名称', trigger: 'blur' }
|
||||
],
|
||||
amount: [
|
||||
{ required: true, message: '请输入面额', trigger: 'blur' }
|
||||
],
|
||||
duration_days: [
|
||||
{ required: true, message: '请输入有效期天数', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 状态数据
|
||||
const loading = ref(false)
|
||||
const voucherList = ref([])
|
||||
const total = ref(0)
|
||||
const selectedRows = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
const voucherFormRef = ref(null)
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
// 获取代金券列表
|
||||
const fetchVoucherList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getDiscountCodeList(queryParams)
|
||||
console.log('代金券列表数据:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
voucherList.value = res.data.data?.data || []
|
||||
total.value = res.data.data?.all_count || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取代金券列表失败:', error)
|
||||
ElMessage.error('获取代金券列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 选择项变化
|
||||
const handleSelectionChange = (selection) => {
|
||||
selectedRows.value = selection
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleSizeChange = (size) => {
|
||||
queryParams.count = size
|
||||
fetchVoucherList()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page) => {
|
||||
queryParams.page = page
|
||||
fetchVoucherList()
|
||||
}
|
||||
|
||||
// 新增代金券
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
dialogVisible.value = true
|
||||
Object.assign(voucherForm, {
|
||||
code_id: undefined,
|
||||
discount_type: 'coupon',
|
||||
name: '',
|
||||
note: '',
|
||||
amount: 0,
|
||||
min_amount: 0,
|
||||
max_amount: 0,
|
||||
max_times: 0,
|
||||
user_times: 0,
|
||||
duration_days: 30,
|
||||
timeRange: [],
|
||||
renew: false,
|
||||
can_stacking: false,
|
||||
can_combine: false
|
||||
})
|
||||
voucherFormRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 编辑代金券
|
||||
const handleEdit = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
dialogVisible.value = true
|
||||
|
||||
console.log('编辑代金券原始数据:', row)
|
||||
|
||||
// 转换时间为日期字符串(YYYY-MM-DD HH:mm:ss 格式)
|
||||
let startTime = ''
|
||||
let endTime = ''
|
||||
|
||||
if (row.startTime) {
|
||||
// 处理字符串格式的时间(如 "2026-01-08T00:00:00+08:00")
|
||||
const start = new Date(row.startTime)
|
||||
if (!isNaN(start.getTime())) {
|
||||
startTime = start.getFullYear() + '-' +
|
||||
String(start.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(start.getDate()).padStart(2, '0') + ' ' +
|
||||
String(start.getHours()).padStart(2, '0') + ':' +
|
||||
String(start.getMinutes()).padStart(2, '0') + ':' +
|
||||
String(start.getSeconds()).padStart(2, '0')
|
||||
}
|
||||
}
|
||||
|
||||
if (row.endTime) {
|
||||
// 处理字符串格式的时间(如 "2026-02-25T00:00:00+08:00")
|
||||
const end = new Date(row.endTime)
|
||||
if (!isNaN(end.getTime())) {
|
||||
endTime = end.getFullYear() + '-' +
|
||||
String(end.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(end.getDate()).padStart(2, '0') + ' ' +
|
||||
String(end.getHours()).padStart(2, '0') + ':' +
|
||||
String(end.getMinutes()).padStart(2, '0') + ':' +
|
||||
String(end.getSeconds()).padStart(2, '0')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('转换后的时间:', { startTime, endTime })
|
||||
|
||||
Object.assign(voucherForm, {
|
||||
code_id: row.id,
|
||||
discount_type: 'coupon',
|
||||
name: row.name,
|
||||
note: row.note || '',
|
||||
amount: row.amount ? row.amount / 100 : 0,
|
||||
min_amount: row.minAmount ? row.minAmount / 100 : 0,
|
||||
max_amount: row.maxAmount ? row.maxAmount / 100 : 0,
|
||||
max_times: row.maxTimes || 0,
|
||||
user_times: row.userTimes || 0,
|
||||
duration_days: row.duration ? Math.round(row.duration / 86400) : 30, // 秒转天
|
||||
timeRange: startTime && endTime ? [startTime, endTime] : [],
|
||||
renew: row.renew || false,
|
||||
can_stacking: row.canStacking || false,
|
||||
can_combine: row.canCombine || false
|
||||
})
|
||||
|
||||
console.log('表单数据:', voucherForm)
|
||||
}
|
||||
|
||||
// 管理代金券
|
||||
const handleManage = (row) => {
|
||||
router.push(`/marketing/voucher/${row.id}/manage`)
|
||||
}
|
||||
|
||||
// 查看代金券详情
|
||||
const handleView = async (row) => {
|
||||
try {
|
||||
const res = await getDiscountCodeDetail({ code_id: row.id })
|
||||
console.log('代金券详情:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
currentDetail.value = res.data.data
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取代金券详情失败:', error)
|
||||
ElMessage.error('获取代金券详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除代金券
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除代金券 ${row.name} 吗?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteDiscountCode({ code_id: row.id })
|
||||
console.log('删除响应:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchVoucherList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = () => {
|
||||
if (selectedRows.value.length === 0) {
|
||||
ElMessage.warning('请至少选择一条记录')
|
||||
return
|
||||
}
|
||||
ElMessageBox.confirm(`确认删除选中的 ${selectedRows.value.length} 条记录吗?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const deletePromises = selectedRows.value.map(row =>
|
||||
deleteDiscountCode({ code_id: row.id })
|
||||
)
|
||||
|
||||
const results = await Promise.allSettled(deletePromises)
|
||||
|
||||
const successCount = results.filter(r => r.status === 'fulfilled' && r.value?.data?.code === 200).length
|
||||
const failCount = results.length - successCount
|
||||
|
||||
if (failCount === 0) {
|
||||
ElMessage.success(`批量删除成功,共删除 ${successCount} 条记录`)
|
||||
} else if (successCount === 0) {
|
||||
ElMessage.error(`批量删除失败,所有 ${failCount} 条记录删除失败`)
|
||||
} else {
|
||||
ElMessage.warning(`批量删除完成,成功 ${successCount} 条,失败 ${failCount} 条`)
|
||||
}
|
||||
|
||||
fetchVoucherList()
|
||||
} catch (error) {
|
||||
console.error('批量删除失败:', error)
|
||||
ElMessage.error('批量删除操作异常')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 处理日期选择器回车事件
|
||||
const handleDatePickerEnter = (event) => {
|
||||
// 回车键确认日期选择
|
||||
const datePicker = event.target.closest('.el-date-editor')
|
||||
if (datePicker) {
|
||||
// 触发失焦事件,确认日期选择
|
||||
event.target.blur()
|
||||
}
|
||||
}
|
||||
|
||||
// 提交代金券表单
|
||||
const submitForm = () => {
|
||||
voucherFormRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const submitData = {
|
||||
discount_type: 'coupon',
|
||||
name: voucherForm.name,
|
||||
note: voucherForm.note,
|
||||
amount: Math.round(voucherForm.amount * 100),
|
||||
percentage: 0, // 代金券固定为0
|
||||
min_amount: Math.round(voucherForm.min_amount * 100),
|
||||
max_amount: Math.round(voucherForm.max_amount * 100),
|
||||
max_times: voucherForm.max_times || 0,
|
||||
user_times: voucherForm.user_times || 0,
|
||||
duration: voucherForm.duration_days * 86400, // 天转秒
|
||||
renew: voucherForm.renew,
|
||||
can_stacking: voucherForm.can_stacking,
|
||||
can_combine: voucherForm.can_combine
|
||||
}
|
||||
|
||||
// 处理时间(转换为秒级时间戳)
|
||||
if (voucherForm.timeRange && voucherForm.timeRange.length === 2) {
|
||||
submitData.start_time = timeToTimestamp(voucherForm.timeRange[0])
|
||||
submitData.end_time = timeToTimestamp(voucherForm.timeRange[1])
|
||||
} else {
|
||||
submitData.start_time = ''
|
||||
submitData.end_time = ''
|
||||
}
|
||||
|
||||
// 如果是编辑,添加code_id
|
||||
if (dialogType.value === 'edit') {
|
||||
submitData.code_id = voucherForm.code_id
|
||||
}
|
||||
|
||||
console.log('提交代金券数据:', submitData)
|
||||
|
||||
let res
|
||||
if (dialogType.value === 'add') {
|
||||
res = await createDiscountCode(submitData)
|
||||
} else {
|
||||
res = await updateDiscountCode(submitData)
|
||||
}
|
||||
|
||||
console.log('提交响应:', res.data)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success(dialogType.value === 'add' ? '新增成功' : '修改成功')
|
||||
dialogVisible.value = false
|
||||
fetchVoucherList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchVoucherList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.voucher-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.filter-container {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #f56c6c;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 24px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 时间选择器弹出层样式 - 非 scoped */
|
||||
.voucher-date-picker {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
.voucher-date-picker .el-picker-panel {
|
||||
max-width: 90vw;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联信息ID">
|
||||
<el-input v-model="queryParams.id" placeholder="请输入关联信息ID" clearable style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleQuery">
|
||||
<el-icon><Search /></el-icon>查询
|
||||
@@ -203,15 +200,14 @@ const router = useRouter()
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
user_id: undefined,
|
||||
code_id: props.codeId || undefined,
|
||||
id: '',
|
||||
id: props.codeId || undefined,
|
||||
page: 1,
|
||||
count: 10
|
||||
})
|
||||
|
||||
watch(() => props.codeId, (newVal) => {
|
||||
if (newVal) {
|
||||
queryParams.code_id = newVal
|
||||
queryParams.id = newVal
|
||||
fetchHistoryList()
|
||||
}
|
||||
})
|
||||
@@ -285,8 +281,8 @@ const statistics = computed(() => {
|
||||
})
|
||||
// 获取查询用户名称
|
||||
const getQueryUserName = () => {
|
||||
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id)
|
||||
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}`
|
||||
const user = UserOptions.value.find(u => u.user_id === queryParams.user_id)
|
||||
return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${queryParams.user_id}`
|
||||
}
|
||||
|
||||
// 获取使用记录列表
|
||||
@@ -347,15 +343,13 @@ const confirmUserSelection = (user) => {
|
||||
}
|
||||
|
||||
if (selectorType.value === 'query') {
|
||||
// 查询表单选择
|
||||
queryParams.user_id = user.UserId
|
||||
queryParams.user_id = user.user_id
|
||||
} else {
|
||||
// 编辑表单选择
|
||||
editForm.user_id = user.UserId
|
||||
editForm.user_id = user.user_id
|
||||
}
|
||||
|
||||
// 将选中的用户添加到 UserOptions 中(如果不存在)
|
||||
if (!UserOptions.value.find(u => u.UserId === user.UserId)) {
|
||||
if (!UserOptions.value.find(u => u.user_id === user.user_id)) {
|
||||
UserOptions.value.push(user)
|
||||
}
|
||||
|
||||
@@ -372,8 +366,7 @@ const handleQuery = () => {
|
||||
// 重置查询
|
||||
const resetQuery = () => {
|
||||
queryParams.user_id = undefined
|
||||
queryParams.code_id = undefined
|
||||
queryParams.id = ''
|
||||
queryParams.id = undefined
|
||||
queryParams.page = 1
|
||||
fetchHistoryList()
|
||||
}
|
||||
|
||||
@@ -423,15 +423,13 @@ const confirmUserSelection = (user) => {
|
||||
}
|
||||
|
||||
if (selectorType.value === 'query') {
|
||||
// 查询表单选择
|
||||
queryParams.user_id = user.UserId
|
||||
queryParams.user_id = user.user_id
|
||||
} else {
|
||||
// 编辑表单选择
|
||||
editForm.user_id = user.UserId
|
||||
editForm.user_id = user.user_id
|
||||
}
|
||||
|
||||
// 将选中的用户添加到 UserOptions 中(如果不存在)
|
||||
if (!UserOptions.value.find(u => u.UserId === user.UserId)) {
|
||||
if (!UserOptions.value.find(u => u.user_id === user.user_id)) {
|
||||
UserOptions.value.push(user)
|
||||
}
|
||||
|
||||
@@ -451,14 +449,14 @@ const clearEditUser = () => {
|
||||
|
||||
// 获取查询用户名称
|
||||
const getQueryUserName = () => {
|
||||
const user = UserOptions.value.find(u => u.UserId === queryParams.user_id)
|
||||
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${queryParams.user_id}`
|
||||
const user = UserOptions.value.find(u => u.user_id === queryParams.user_id)
|
||||
return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${queryParams.user_id}`
|
||||
}
|
||||
|
||||
// 获取编辑用户名称
|
||||
const getEditUserName = () => {
|
||||
const user = UserOptions.value.find(u => u.UserId === editForm.user_id)
|
||||
return user ? `${user.UserName} (ID: ${user.UserId})` : `用户ID: ${editForm.user_id}`
|
||||
const user = UserOptions.value.find(u => u.user_id === editForm.user_id)
|
||||
return user ? `${user.user_name} (ID: ${user.user_id})` : `用户ID: ${editForm.user_id}`
|
||||
}
|
||||
|
||||
// 获取代金券列表
|
||||
|
||||
@@ -50,7 +50,7 @@ const activeTab = ref('user-distribution')
|
||||
const voucherId = computed(() => route.params.id)
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/marketing/voucher')
|
||||
router.push('/product/discount')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+975
-321
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+292
-129
@@ -122,6 +122,12 @@
|
||||
<el-tag v-else-if="row.isProduct" type="success" size="small" style="margin-left: 8px;">商品</el-tag>
|
||||
|
||||
<span class="group-name">{{ row.name }}</span>
|
||||
<el-tag
|
||||
v-if="(row.isGroup && row.recommendWords) || (row.isProduct && row.data?.recommendWords)"
|
||||
type="danger"
|
||||
size="small"
|
||||
style="margin-left: 6px;"
|
||||
>{{ row.isGroup ? row.recommendWords : row.data?.recommendWords }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -207,6 +213,7 @@
|
||||
<div class="group-name-cell">
|
||||
<el-avatar v-if="row.cover" :size="32" :src="row.cover" />
|
||||
<span class="group-name">{{ row.name }}</span>
|
||||
<el-tag v-if="row.recommendWords" type="danger" size="small" style="margin-left: 6px;">{{ row.recommendWords }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -393,6 +400,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 推荐词 -->
|
||||
<el-form-item prop="recommend_words" label="推荐词">
|
||||
<el-input v-model="groupForm.recommend_words" placeholder="推荐词(可选,如:热销、新品)" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 备注 -->
|
||||
<el-form-item prop="note" label="备注" class="note-form-item">
|
||||
<el-input
|
||||
@@ -567,6 +579,9 @@
|
||||
<el-icon class="group-picker-arrow"><ArrowRight /></el-icon>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="所属表" prop="table">
|
||||
<el-input v-model="productForm.table" placeholder="请输入商品所属表(如 kvm_service)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品介绍" prop="content">
|
||||
<el-input
|
||||
v-model="productForm.content"
|
||||
@@ -588,125 +603,190 @@
|
||||
@confirm="handleProductCoverSelect"
|
||||
/>
|
||||
|
||||
<!-- 价格与销售 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">价格与销售</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item prop="price">
|
||||
<template #label>
|
||||
<span>商品价格<span class="unit-suffix">(元)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
placeholder="请输入价格"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="单个商品数量" prop="pay_num">
|
||||
<el-input-number
|
||||
v-model="productForm.pay_num"
|
||||
:min="1"
|
||||
placeholder="请输入数量"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="expire_time">
|
||||
<template #label>
|
||||
<span>有效期<span class="unit-suffix">(天,0 为永久)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.expire_time"
|
||||
:min="0"
|
||||
placeholder="请输入有效期"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许续费" prop="can_renew">
|
||||
<el-switch
|
||||
v-model="productForm.can_renew"
|
||||
active-text="允许"
|
||||
inactive-text="禁止"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="购买类型" prop="arg_type">
|
||||
<el-select
|
||||
v-model="productForm.arg_type"
|
||||
placeholder="请选择购买类型"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="所有类型" value="all" />
|
||||
<el-option label="仅套餐" value="plan" />
|
||||
<el-option label="仅自定义参数" value="customize" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="需要实名" prop="require_real_name">
|
||||
<el-switch
|
||||
v-model="productForm.require_real_name"
|
||||
active-text="需要"
|
||||
inactive-text="不需要"
|
||||
/>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">启用后,用户购买/续费/升级此商品前须完成实名认证</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tab 栏管理 -->
|
||||
<el-tabs v-model="productFormTab" class="product-form-tabs">
|
||||
<el-tab-pane label="价格与销售" name="sales">
|
||||
<div class="form-grid">
|
||||
<el-form-item prop="price">
|
||||
<template #label>
|
||||
<span>商品价格<span class="unit-suffix">(元)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
placeholder="请输入价格"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="单个商品数量" prop="pay_num">
|
||||
<el-input-number
|
||||
v-model="productForm.pay_num"
|
||||
:min="1"
|
||||
placeholder="请输入数量"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="expire_time">
|
||||
<template #label>
|
||||
<span>有效期<span class="unit-suffix">(天,0 为永久)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.expire_time"
|
||||
:min="0"
|
||||
placeholder="请输入有效期"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许续费" prop="can_renew">
|
||||
<el-switch
|
||||
v-model="productForm.can_renew"
|
||||
active-text="允许"
|
||||
inactive-text="禁止"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="renew_price" v-if="productForm.can_renew">
|
||||
<template #label>
|
||||
<span>续费价格<span class="unit-suffix">(元,0 沿用商品价格)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.renew_price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="0.01"
|
||||
placeholder="续费基础价格"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="购买类型" prop="arg_type">
|
||||
<el-select
|
||||
v-model="productForm.arg_type"
|
||||
placeholder="请选择购买类型"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option label="所有类型" value="all" />
|
||||
<el-option label="仅套餐" value="plan" />
|
||||
<el-option label="仅自定义参数" value="customize" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="需要实名" prop="require_real_name">
|
||||
<el-switch
|
||||
v-model="productForm.require_real_name"
|
||||
active-text="需要"
|
||||
inactive-text="不需要"
|
||||
/>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">启用后,用户购买/续费/升级此商品前须完成实名认证</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 库存管理 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">库存管理</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="库存控制" prop="inventory_control">
|
||||
<el-switch
|
||||
v-model="productForm.inventory_control"
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="库存数量" prop="inventory">
|
||||
<el-input-number
|
||||
v-model="productForm.inventory"
|
||||
:min="0"
|
||||
:disabled="!productForm.inventory_control"
|
||||
placeholder="请输入库存"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<el-tab-pane label="库存管理" name="inventory">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="库存控制" prop="inventory_control">
|
||||
<el-switch
|
||||
v-model="productForm.inventory_control"
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="库存数量" prop="inventory">
|
||||
<el-input-number
|
||||
v-model="productForm.inventory"
|
||||
:min="0"
|
||||
:disabled="!productForm.inventory_control"
|
||||
placeholder="请输入库存"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="售罄状态" prop="sold_out">
|
||||
<el-switch
|
||||
v-model="productForm.sold_out"
|
||||
active-text="售罄"
|
||||
inactive-text="正常"
|
||||
active-color="#f56c6c"
|
||||
/>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">开启后用户端将无法选购该商品</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="购买限制" prop="max_per_user">
|
||||
<el-input-number
|
||||
v-model="productForm.max_per_user"
|
||||
:min="0"
|
||||
placeholder="0 表示不限"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">限制单用户最大购买数量,0 表示不限制</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="首购最大时长" prop="max_first_purchase_duration">
|
||||
<el-input-number
|
||||
v-model="productForm.max_first_purchase_duration"
|
||||
:min="0"
|
||||
placeholder="0 表示不限"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">限制用户首次购买的最大时长(单位:月),0 表示不限制</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 推荐与参数 -->
|
||||
<div class="form-section">
|
||||
<div class="form-section-title">推荐与参数</div>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="推荐" prop="recommend">
|
||||
<el-switch
|
||||
v-model="productForm.recommend"
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="recommend_rebate">
|
||||
<template #label>
|
||||
<span>推荐返还<span class="unit-suffix">(%)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.recommend_rebate"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:disabled="!productForm.recommend"
|
||||
placeholder="返还百分比"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<el-tab-pane label="推荐与通知" name="promotion">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="推荐" prop="recommend">
|
||||
<el-switch
|
||||
v-model="productForm.recommend"
|
||||
active-text="启用"
|
||||
inactive-text="禁用"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="recommend_rebate">
|
||||
<template #label>
|
||||
<span>推荐返还<span class="unit-suffix">(%)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.recommend_rebate"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:disabled="!productForm.recommend"
|
||||
placeholder="返还百分比"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item prop="renew_recommend_rebate">
|
||||
<template #label>
|
||||
<span>续费推介返还<span class="unit-suffix">(%,0 沿用推荐返还)</span></span>
|
||||
</template>
|
||||
<el-input-number
|
||||
v-model="productForm.renew_recommend_rebate"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:disabled="!productForm.recommend"
|
||||
placeholder="续费推介返还百分比"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="推荐词" prop="recommend_words">
|
||||
<el-input v-model="productForm.recommend_words" placeholder="推荐词(可选,如:热销、新品)" clearable style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="购买通知" prop="send_notice">
|
||||
<el-switch
|
||||
v-model="productForm.send_notice"
|
||||
active-text="发送"
|
||||
inactive-text="不发送"
|
||||
/>
|
||||
<div style="font-size:12px;color:#909399;margin:4px">用户购买后是否发送通知给管理员</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
@@ -764,7 +844,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onActivated, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, Folder, ArrowRight, Loading, Grid, List, Document, Picture, Delete, CollectionTag } from '@element-plus/icons-vue'
|
||||
import {
|
||||
@@ -777,10 +858,14 @@ import {
|
||||
getProductGroupTagList,
|
||||
deleteProductGroupTag,
|
||||
getProductList,
|
||||
getProductDetail,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
} from '@/api/admin/product'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
|
||||
import GroupTagManager from './components/GroupTagManager.vue'
|
||||
import ProductParameterManager from './components/ProductParameterManager.vue'
|
||||
@@ -823,7 +908,8 @@ const groupForm = reactive({
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
tag_id: undefined,
|
||||
index: 0
|
||||
index: 0,
|
||||
recommend_words: ''
|
||||
})
|
||||
|
||||
const groupRules = {
|
||||
@@ -873,7 +959,14 @@ const productForm = reactive({
|
||||
recommend_rebate: 0,
|
||||
arg_type: 'all',
|
||||
can_renew: true,
|
||||
require_real_name: false
|
||||
require_real_name: false,
|
||||
sold_out: false,
|
||||
max_per_user: 0,
|
||||
max_first_purchase_duration: 0,
|
||||
send_notice: false,
|
||||
renew_price: 0,
|
||||
renew_recommend_rebate: 0,
|
||||
recommend_words: ''
|
||||
})
|
||||
|
||||
const productRules = {
|
||||
@@ -895,6 +988,7 @@ const productRules = {
|
||||
// 商品对话框状态
|
||||
const productDialogVisible = ref(false)
|
||||
const productDialogType = ref('add')
|
||||
const productFormTab = ref('sales')
|
||||
const productFormRef = ref(null)
|
||||
const coverSelectorVisible = ref(false)
|
||||
|
||||
@@ -1419,7 +1513,8 @@ const handleAdd = (parentRow) => {
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
tag_id: parentTagId || undefined,
|
||||
index: 0
|
||||
index: 0,
|
||||
recommend_words: ''
|
||||
})
|
||||
console.log('添加子级,父级信息:', parentRow.name, 'ID:', parentRow.id, 'Level:', parentRow.level, '标签:', parentRow.tag)
|
||||
} else {
|
||||
@@ -1434,7 +1529,8 @@ const handleAdd = (parentRow) => {
|
||||
cover_id: undefined,
|
||||
cover_url: '',
|
||||
tag_id: undefined,
|
||||
index: 0
|
||||
index: 0,
|
||||
recommend_words: ''
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1455,7 +1551,8 @@ const handleEdit = (row) => {
|
||||
cover_id: row.coverId || undefined,
|
||||
cover_url: row.cover || '',
|
||||
tag_id: row.tag?.id || row.tagId || undefined,
|
||||
index: row.index || 0
|
||||
index: row.index || 0,
|
||||
recommend_words: row.recommendWords || ''
|
||||
})
|
||||
|
||||
dialogVisible.value = true
|
||||
@@ -1523,7 +1620,10 @@ const handleAddProduct = () => {
|
||||
recommend: false,
|
||||
recommend_rebate: 0,
|
||||
arg_type: 'all',
|
||||
require_real_name: false
|
||||
require_real_name: false,
|
||||
max_per_user: 0,
|
||||
max_first_purchase_duration: 0,
|
||||
recommend_words: ''
|
||||
})
|
||||
|
||||
selectedProductGroup.value = null
|
||||
@@ -1557,7 +1657,14 @@ const handleEditProduct = (product, parentGroupId) => {
|
||||
recommend_rebate: product.recommendRebate,
|
||||
arg_type: product.argType || 'all',
|
||||
can_renew: product.canRenew !== undefined ? product.canRenew : (product.can_renew !== undefined ? product.can_renew : true),
|
||||
require_real_name: product.requireRealName ?? product.require_real_name ?? false
|
||||
require_real_name: product.requireRealName ?? product.require_real_name ?? false,
|
||||
sold_out: !!product.soldOut,
|
||||
max_per_user: product.maxPerUser ?? product.max_per_user ?? 0,
|
||||
max_first_purchase_duration: product.maxFirstPurchaseDuration ?? product.max_first_purchase_duration ?? 0,
|
||||
send_notice: !!product.sendNotice,
|
||||
renew_price: (product.renewPrice ?? product.renew_price ?? 0) / 100,
|
||||
renew_recommend_rebate: product.renewRecommendRebate ?? product.renew_recommend_rebate ?? 0,
|
||||
recommend_words: product.recommendWords ?? product.recommend_words ?? ''
|
||||
})
|
||||
|
||||
productDialogVisible.value = true
|
||||
@@ -1583,7 +1690,14 @@ const submitProductForm = () => {
|
||||
recommend_rebate: Number(productForm.recommend_rebate) || 0,
|
||||
arg_type: productForm.arg_type,
|
||||
can_renew: productForm.can_renew,
|
||||
require_real_name: productForm.require_real_name
|
||||
require_real_name: productForm.require_real_name,
|
||||
sold_out: productForm.sold_out === true,
|
||||
max_per_user: Number(productForm.max_per_user) || 0,
|
||||
max_first_purchase_duration: Number(productForm.max_first_purchase_duration) || 0,
|
||||
send_notice: productForm.send_notice === true,
|
||||
renew_price: Number(productForm.renew_price) || 0,
|
||||
renew_recommend_rebate: Number(productForm.renew_recommend_rebate) || 0,
|
||||
recommend_words: productForm.recommend_words || ''
|
||||
}
|
||||
|
||||
let res
|
||||
@@ -1722,7 +1836,8 @@ const submitForm = () => {
|
||||
name: groupForm.name.trim(),
|
||||
note: groupForm.note || '',
|
||||
disable: groupForm.disable,
|
||||
index: Number(groupForm.index) || 0
|
||||
index: Number(groupForm.index) || 0,
|
||||
recommend_words: groupForm.recommend_words || ''
|
||||
}
|
||||
|
||||
if (groupForm.parent_id) {
|
||||
@@ -1795,9 +1910,45 @@ watch(activeTab, (newVal) => {
|
||||
const groupTagManagerRef = ref(null)
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchGroupList()
|
||||
// 根据 good_id 请求商品详情并弹出对应商品弹窗(用于从订单详情等页面跳转)
|
||||
const openProductByGoodId = async (goodId) => {
|
||||
const id = Number(goodId)
|
||||
if (!id) return
|
||||
try {
|
||||
const res = await getProductDetail({ good_id: id })
|
||||
if (res.data.code === 200 && res.data.data) {
|
||||
handleEditProduct(res.data.data)
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '获取商品详情失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取商品详情失败:', error)
|
||||
ElMessage.error('获取商品详情失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 检查并处理 good_id 参数
|
||||
const checkAndOpenGoodId = async () => {
|
||||
if (route.query.good_id) {
|
||||
await openProductByGoodId(route.query.good_id)
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchGroupList()
|
||||
fetchAllTagOptions()
|
||||
await checkAndOpenGoodId()
|
||||
})
|
||||
|
||||
// 组件被 keep-alive 缓存后重新激活时,再次检查 good_id
|
||||
onActivated(() => {
|
||||
checkAndOpenGoodId()
|
||||
})
|
||||
|
||||
// 同一页面内 query 变化时也触发(如从其他标签页点击跳转)
|
||||
watch(() => route.query.good_id, (newId) => {
|
||||
if (newId) checkAndOpenGoodId()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2185,6 +2336,18 @@ onMounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.product-form-tabs {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.product-form-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.product-form-tabs :deep(.el-tab-pane) {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
margin-bottom: var(--section-gap);
|
||||
padding: 16px;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,94 +3,237 @@
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button @click="goBack" link class="back-btn">
|
||||
<el-icon><ArrowLeft /></el-icon> 返回所有商品列表
|
||||
<el-icon><ArrowLeft /></el-icon> 返回列表
|
||||
</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="page-title">所有商品详情</span>
|
||||
<span class="page-title">用户商品详情</span>
|
||||
<el-tag v-if="detail" :type="currentTag === '云服务器' ? 'primary' : 'info'" size="small" style="margin-left:8px">{{ detail.tag || detail.good?.tag || '通用' }}</el-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" plain @click="loadDetail" :loading="loading">
|
||||
<el-button plain @click="loadDetail" :loading="loading">
|
||||
<el-icon><Refresh /></el-icon> 刷新
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openEdit" :disabled="!detail">
|
||||
<el-icon><Edit /></el-icon> 编辑
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="handleDelete" :disabled="!detail">
|
||||
<el-icon><Delete /></el-icon> 删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="main-content" v-loading="loading">
|
||||
<!-- 空状态 -->
|
||||
<el-empty v-if="!loading && !detail" description="未找到商品数据" :image-size="160">
|
||||
<el-button type="primary" @click="loadDetail">重新加载</el-button>
|
||||
</el-empty>
|
||||
|
||||
<el-card class="profile-card" shadow="hover" v-if="detail">
|
||||
<div class="profile-header">
|
||||
<div class="profile-basic">
|
||||
<div class="icon-wrapper">
|
||||
<el-icon :size="48" color="#409eff"><Monitor /></el-icon>
|
||||
<template v-if="detail">
|
||||
<!-- 已删除提示 -->
|
||||
<el-alert
|
||||
v-if="isDeleted"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="deleted-alert"
|
||||
title="该用户商品已删除"
|
||||
:description="`删除时间:${deletedTime}。已删除商品仅展示上游快照信息(itemArg),不再请求虚拟机等实时详情。`"
|
||||
/>
|
||||
|
||||
<!-- Hero 概览区 -->
|
||||
<div class="hero-section">
|
||||
<div class="hero-left">
|
||||
<div class="hero-icon">
|
||||
<svg viewBox="0 0 48 48" width="44" height="44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="4" y="8" width="40" height="28" rx="4" stroke="currentColor" stroke-width="2.5" />
|
||||
<path d="M16 40h16" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
||||
<path d="M24 36v4" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
||||
<circle cx="24" cy="22" r="6" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M24 19v3l2 1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="identity">
|
||||
<div class="name-row">
|
||||
<h1 class="name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
|
||||
<el-button size="small" type="primary" plain @click="openEdit">编辑</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleDelete">删除</el-button>
|
||||
</div>
|
||||
<div class="id-row">
|
||||
<span class="label">ID:</span>
|
||||
<span class="value">{{ detail.id || goodsId }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="label">用户ID:</span>
|
||||
<span class="value">{{ detail.userId || detail.user_id || '-' }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="label">到期:</span>
|
||||
<span class="value">{{ formatExpireTime(detail.expireTime || detail.expire_time) }}</span>
|
||||
<el-divider direction="vertical" />
|
||||
<span class="label">续费价:</span>
|
||||
<span class="value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</span>
|
||||
<div class="hero-info">
|
||||
<h1 class="hero-name">{{ detail.good?.name || '用户商品 #' + goodsId }}</h1>
|
||||
<div class="hero-meta">
|
||||
<span class="meta-chip">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM1 8a7 7 0 1114 0A7 7 0 011 8z" fill="currentColor"/><path d="M8 4v4.5l3 1.5" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>
|
||||
{{ expireLabel }}
|
||||
</span>
|
||||
<span class="meta-chip" :class="expireStatusClass">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><circle cx="8" cy="8" r="4" fill="currentColor"/></svg>
|
||||
{{ expireStatusText }}
|
||||
</span>
|
||||
<span class="meta-chip id-chip">ID: {{ detail.id || goodsId }}</span>
|
||||
<span v-if="isDeleted" class="meta-chip status-deleted">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><circle cx="8" cy="8" r="4" fill="currentColor"/></svg>
|
||||
已删除
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="profile-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">套餐ID</div>
|
||||
<div class="stat-value">{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
|
||||
<div class="hero-right">
|
||||
<div class="price-block">
|
||||
<div class="price-label">续费价格</div>
|
||||
<div class="price-value">{{ detail.renewPrice ? '¥' + (detail.renewPrice / 100).toFixed(2) : '-' }}</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">备注</div>
|
||||
<div class="stat-value note-value">{{ detail.note || '-' }}</div>
|
||||
<div class="price-divider"></div>
|
||||
<div class="price-block">
|
||||
<div class="price-label">基础价格</div>
|
||||
<div class="price-value secondary">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-divider style="margin: 16px 0 12px" />
|
||||
<el-descriptions :column="3" border size="small" style="width:100%">
|
||||
<el-descriptions-item label="商品ID">{{ detail.goodId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单ID">{{ detail.orderId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="归属项ID">{{ detail.itemId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="基础价格">{{ (detail.basePrice || detail.base_price) ? '¥' + ((detail.basePrice || detail.base_price) / 100).toFixed(2) : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTime(detail.CreatedAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTime(detail.UpdatedAt) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="hover" v-if="detail" class="related-card">
|
||||
<template #header>
|
||||
<span class="card-title">关联信息</span>
|
||||
</template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单状态">
|
||||
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
|
||||
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<!-- 数据指标卡片行 -->
|
||||
<div class="metrics-row">
|
||||
<div class="metric-card is-link" @click="goToUser">
|
||||
<div class="metric-icon" style="background:#eef3ff;color:#4f6ef7">
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><circle cx="10" cy="7" r="3.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 17c0-2.76 3.13-5 7-5s7 2.24 7 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<div class="metric-label">所属用户</div>
|
||||
<div class="metric-value">#{{ detail.userId || detail.user_id || '-' }}</div>
|
||||
<div class="metric-name">{{ detail.user?.userName || '-' }}</div>
|
||||
</div>
|
||||
<div class="metric-arrow">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card is-link" @click="goToProduct">
|
||||
<div class="metric-icon" style="background:#f0faf0;color:#52c41a">
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M17 7l-7-4-7 4v6l7 4 7-4V7z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M10 11v7M3 7l7 4 7-4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<div class="metric-label">关联商品</div>
|
||||
<div class="metric-value">#{{ detail.goodId || '-' }}</div>
|
||||
<div class="metric-name">{{ detail.good?.name || '-' }}</div>
|
||||
</div>
|
||||
<div class="metric-arrow">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card is-link" @click="goToOrder">
|
||||
<div class="metric-icon" style="background:#fff7ed;color:#f5a623">
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><rect x="3" y="3" width="14" height="14" rx="2.5" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5.5M6 13h3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<div class="metric-label">关联订单</div>
|
||||
<div class="metric-value">#{{ detail.orderId || '-' }}</div>
|
||||
<div class="metric-name">{{ detail.order?.name || '-' }}</div>
|
||||
</div>
|
||||
<div class="metric-arrow">
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none"><path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon" style="background:#fef0f0;color:#f56c6c">
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M10 2l2.47 5.01L18 7.75l-4 3.9.94 5.51L10 14.51l-4.94 2.65.94-5.51-4-3.9 5.53-.74z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<div class="metric-label">套餐ID</div>
|
||||
<div class="metric-value">#{{ detail.goodPlanId || detail.good_plan_id || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<div class="metric-icon" style="background:#f5f0ff;color:#722ed1">
|
||||
<svg viewBox="0 0 20 20" width="20" height="20" fill="none"><path d="M7 3h6l4 4v9a1 1 0 01-1 1H4a1 1 0 01-1-1V7l4-4z" stroke="currentColor" stroke-width="1.5"/><path d="M7 3v4H3M10 9v5M8 12h4" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<div class="metric-label">归属项ID</div>
|
||||
<div class="metric-value">#{{ detail.itemId || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详细信息折叠区 -->
|
||||
<div class="detail-section">
|
||||
<div class="section-header" @click="detailExpanded = !detailExpanded">
|
||||
<div class="section-header-left">
|
||||
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span class="section-title">详细信息</span>
|
||||
</div>
|
||||
<el-icon :class="{ 'is-expanded': detailExpanded }"><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div v-show="detailExpanded" class="section-body">
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">商品标签</span>
|
||||
<span class="info-value"><el-tag size="small" type="info">{{ detail.good?.tag || detail.tag || '-' }}</el-tag></span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">商品Table</span>
|
||||
<span class="info-value">{{ detail.good?.table || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">备注</span>
|
||||
<span class="info-value">{{ detail.note || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">订单状态</span>
|
||||
<span class="info-value">
|
||||
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
|
||||
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">创建时间</span>
|
||||
<span class="info-value">{{ formatTime(detail.CreatedAt) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">更新时间</span>
|
||||
<span class="info-value">{{ formatTime(detail.UpdatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
|
||||
<!-- 已删除:展示 itemArg 上游快照信息,不再请求虚拟机详情 -->
|
||||
<el-card v-if="isDeleted" shadow="hover" class="related-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><rect x="2" y="3" width="16" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M6 7h8M6 10h5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
|
||||
<span class="card-title">上游快照信息 (itemArg)</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions v-if="itemArgEntries.length" :column="2" border size="small">
|
||||
<el-descriptions-item v-for="item in itemArgEntries" :key="item.key" :label="item.label">
|
||||
{{ item.value }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-empty v-else description="该商品无 itemArg 快照信息" :image-size="100" />
|
||||
</el-card>
|
||||
|
||||
<!-- 类型适配栏目区域 -->
|
||||
<UserVmDetail v-else-if="currentTag === '云服务器'" embedded :goods-id="goodsId" />
|
||||
|
||||
<el-card v-else-if="detail.good" shadow="hover" class="related-card">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<svg viewBox="0 0 20 20" width="18" height="18" fill="none"><path d="M10 2l2.47 5.01L18 7.75l-4 3.9.94 5.51L10 14.51l-4.94 2.65.94-5.51-4-3.9 5.53-.74z" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg>
|
||||
<span class="card-title">关联信息</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="商品名称">{{ detail.good?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品Table">{{ detail.good?.table || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品标签">{{ detail.good?.tag || detail.tag || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单名称">{{ detail.order?.name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单状态">
|
||||
<el-tag v-if="detail.order" :type="detail.order.state === 1 ? 'success' : detail.order.state === 0 ? 'warning' : 'info'" size="small">
|
||||
{{ detail.order.state === 1 ? '已支付' : detail.order.state === 0 ? '待支付' : '已失效' }}
|
||||
</el-tag>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{{ detail.userId || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="110px">
|
||||
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
|
||||
@@ -142,14 +285,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onActivated, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Monitor } from '@element-plus/icons-vue'
|
||||
import { ArrowLeft, ArrowDown, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
||||
import { getUserGoodsDetail, updateUserGoods, deleteUserGoods } from '@/api/admin/userVm'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import VmSelectorPopup from '@/components/admin/VmSelectorPopup.vue'
|
||||
import KvmServiceSelector from '@/components/admin/KvmServiceSelector.vue'
|
||||
import UserVmDetail from '@/views/user-vm/UserVmDetail.vue'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -159,6 +303,7 @@ const goodsId = computed(() => parseInt(route.params.id) || 0)
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const detailExpanded = ref(false)
|
||||
|
||||
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
const formatExpireTime = (t) => {
|
||||
@@ -168,8 +313,82 @@ const formatExpireTime = (t) => {
|
||||
return d.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const expireLabel = computed(() => formatExpireTime(detail.value?.expireTime || detail.value?.expire_time))
|
||||
|
||||
const expireStatusText = computed(() => {
|
||||
const t = detail.value?.expireTime || detail.value?.expire_time
|
||||
if (!t) return '未知'
|
||||
const d = dayjs(t)
|
||||
if (d.year() < 2000) return '永久有效'
|
||||
const diff = d.diff(dayjs(), 'day')
|
||||
if (diff < 0) return '已到期'
|
||||
if (diff <= 7) return `即将到期 (${diff}天)`
|
||||
return '正常'
|
||||
})
|
||||
|
||||
const expireStatusClass = computed(() => {
|
||||
const t = detail.value?.expireTime || detail.value?.expire_time
|
||||
if (!t) return ''
|
||||
const d = dayjs(t)
|
||||
if (d.year() < 2000) return 'status-forever'
|
||||
const diff = d.diff(dayjs(), 'day')
|
||||
if (diff < 0) return 'status-expired'
|
||||
if (diff <= 7) return 'status-warning'
|
||||
return 'status-ok'
|
||||
})
|
||||
|
||||
const goBack = () => router.push('/user-goods/list')
|
||||
|
||||
const currentTag = computed(() => (detail.value?.tag || detail.value?.good?.tag || '').toLowerCase())
|
||||
|
||||
// 是否已删除(后端返回 deleteAt 字段,有值即已删除)
|
||||
const isDeleted = computed(() => !!(detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at))
|
||||
const deletedTime = computed(() => {
|
||||
const t = detail.value?.deleteAt || detail.value?.DeleteAt || detail.value?.deleted_at
|
||||
return t ? formatTime(t) : '-'
|
||||
})
|
||||
|
||||
// 解析 itemArg(可能是 JSON 字符串或对象),用于已删除商品展示上游快照信息
|
||||
const parsedItemArg = computed(() => {
|
||||
const raw = detail.value?.itemArg ?? detail.value?.ItemArg ?? detail.value?.item_arg
|
||||
if (!raw) return null
|
||||
if (typeof raw === 'string') {
|
||||
try { return JSON.parse(raw) } catch { return null }
|
||||
}
|
||||
if (typeof raw === 'object') return raw
|
||||
return null
|
||||
})
|
||||
|
||||
// itemArg 字段中文标签映射
|
||||
const itemArgLabelMap = {
|
||||
id: '实例ID',
|
||||
name: '实例名称',
|
||||
vcpu: 'CPU核数',
|
||||
memory: '内存',
|
||||
status: '运行状态',
|
||||
state: '运行状态',
|
||||
ips: 'IP地址',
|
||||
ip: 'IP地址',
|
||||
disk: '磁盘',
|
||||
bandwidth: '带宽',
|
||||
image: '镜像',
|
||||
image_name: '镜像名称',
|
||||
os_type: '系统类型'
|
||||
}
|
||||
|
||||
// 将 itemArg 转换为可展示的键值对列表
|
||||
const itemArgEntries = computed(() => {
|
||||
const obj = parsedItemArg.value
|
||||
if (!obj || typeof obj !== 'object') return []
|
||||
return Object.entries(obj)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== '' && typeof v !== 'object')
|
||||
.map(([k, v]) => ({
|
||||
key: k,
|
||||
label: itemArgLabelMap[k] || k,
|
||||
value: String(v)
|
||||
}))
|
||||
})
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!goodsId.value) return
|
||||
loading.value = true
|
||||
@@ -182,6 +401,22 @@ const loadDetail = async () => {
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const goToUser = () => {
|
||||
const uid = detail.value?.userId || detail.value?.user_id
|
||||
if (!uid) return
|
||||
router.push({ path: '/user/detail', query: { user_id: uid } })
|
||||
}
|
||||
const goToProduct = () => {
|
||||
const gid = detail.value?.goodId
|
||||
if (!gid) return
|
||||
router.push({ path: '/product/manage', query: { good_id: gid } })
|
||||
}
|
||||
const goToOrder = () => {
|
||||
const oid = detail.value?.orderId
|
||||
if (!oid) return
|
||||
router.push({ path: '/order/list', query: { order_id: oid } })
|
||||
}
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editForm = reactive({ note: '', renew_price: 0, base_price: 0, expire_time: '', item_id: 0, _serviceId: 0, _serviceName: '', _itemName: '' })
|
||||
const showVmSelector = ref(false)
|
||||
@@ -202,7 +437,6 @@ const openEdit = () => {
|
||||
_serviceName: '',
|
||||
_itemName: detail.value?.itemId ? `虚拟机 #${detail.value.itemId}` : ''
|
||||
})
|
||||
if (detail.value?.good?.table === 'kvm_service') { /* 通过选择器弹窗选择,无需预加载 */ }
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
@@ -233,186 +467,126 @@ const handleDelete = () => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
let isInitialMount = true
|
||||
onMounted(loadDetail)
|
||||
onActivated(() => {
|
||||
if (isInitialMount) {
|
||||
isInitialMount = false
|
||||
return
|
||||
}
|
||||
detail.value = null
|
||||
loadDetail()
|
||||
})
|
||||
watch(goodsId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) { detail.value = null; loadDetail() }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.goods-detail-page {
|
||||
padding: 0;
|
||||
}
|
||||
.goods-detail-page { padding: 0; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
}
|
||||
/* ---- Page Header ---- */
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 0; }
|
||||
.back-btn { font-size: 14px; color: #606266; }
|
||||
.back-btn:hover { color: #409eff; }
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
/* ---- Main Content ---- */
|
||||
.main-content { padding: 20px; min-height: 300px; }
|
||||
|
||||
.back-btn {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
/* ---- Hero Section ---- */
|
||||
.hero-section {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: linear-gradient(135deg, #f0f5ff 0%, #f8faff 60%, #fff 100%);
|
||||
border: 1px solid #e1e8f0; border-radius: 10px;
|
||||
padding: 24px 28px; gap: 24px;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
color: #409eff;
|
||||
.hero-left { display: flex; align-items: center; gap: 20px; flex: 1; min-width: 0; }
|
||||
.hero-icon {
|
||||
width: 72px; height: 72px; border-radius: 16px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #6366f1 100%);
|
||||
color: #fff; display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0; box-shadow: 0 6px 16px rgba(64, 158, 255, 0.25);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
.hero-info { min-width: 0; flex: 1; }
|
||||
.hero-name { font-size: 20px; font-weight: 700; color: #1a1a2e; margin: 0 0 10px; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hero-meta { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.meta-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 12px; color: #606266; background: #fff; border: 1px solid #e8e8e8;
|
||||
padding: 3px 10px; border-radius: 20px; white-space: nowrap;
|
||||
}
|
||||
.meta-chip.id-chip { font-family: 'SF Mono', 'Consolas', monospace; letter-spacing: 0.3px; }
|
||||
.meta-chip.status-ok { color: #52c41a; border-color: #b7eb8f; background: #f6ffed; }
|
||||
.meta-chip.status-warning { color: #fa8c16; border-color: #ffd591; background: #fff7e6; }
|
||||
.meta-chip.status-expired { color: #f5222d; border-color: #ffa39e; background: #fff1f0; }
|
||||
.meta-chip.status-forever { color: #722ed1; border-color: #d3adf7; background: #f9f0ff; }
|
||||
.meta-chip.status-deleted { color: #f5222d; border-color: #ffa39e; background: #fff1f0; }
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.deleted-alert { margin-bottom: 16px; }
|
||||
|
||||
.hero-right { display: flex; align-items: center; gap: 0; flex-shrink: 0; }
|
||||
.price-block { text-align: center; padding: 0 20px; }
|
||||
.price-label { font-size: 12px; color: #909399; margin-bottom: 6px; }
|
||||
.price-value { font-size: 22px; font-weight: 700; color: #1a1a2e; }
|
||||
.price-value.secondary { color: #606266; font-weight: 600; }
|
||||
.price-divider { width: 1px; height: 36px; background: #e0e0e0; }
|
||||
|
||||
/* ---- Metrics Row ---- */
|
||||
.metrics-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-top: 16px; }
|
||||
.metric-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
|
||||
padding: 14px 16px; transition: all 0.2s; position: relative;
|
||||
}
|
||||
.metric-card:hover { border-color: #d0d5dd; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
|
||||
.metric-card.is-link { cursor: pointer; }
|
||||
.metric-card.is-link:hover { border-color: #409eff; background: #f8fbff; box-shadow: 0 2px 12px rgba(64, 158, 255, 0.08); }
|
||||
.metric-card.is-link:hover .metric-arrow { color: #409eff; transform: translateX(2px); }
|
||||
.metric-card.is-link:hover .metric-value { color: #409eff; }
|
||||
.metric-icon { width: 40px; height: 40px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.metric-body { min-width: 0; flex: 1; }
|
||||
.metric-label { font-size: 12px; color: #909399; margin-bottom: 2px; }
|
||||
.metric-value { font-size: 15px; font-weight: 600; color: #303133; font-family: 'SF Mono', 'Consolas', monospace; transition: color 0.2s; }
|
||||
.metric-name { font-size: 12px; color: #909399; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.metric-arrow { color: #c0c4cc; transition: all 0.2s; flex-shrink: 0; }
|
||||
|
||||
.main-content {
|
||||
padding: 20px;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
margin-bottom: 0;
|
||||
border: 1px solid #e1e8ed;
|
||||
border-radius: 8px;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.profile-basic {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #e8f4fd 0%, #d6eaff 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.id-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.id-row .label {
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.id-row .value {
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-stats {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
min-width: 80px;
|
||||
padding: 10px 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.note-value {
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
max-width: 200px;
|
||||
/* ---- Detail Section (collapsible) ---- */
|
||||
.detail-section {
|
||||
margin-top: 16px; background: #fff; border: 1px solid #ebeef5; border-radius: 8px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.related-card {
|
||||
margin-top: 20px;
|
||||
border: 1px solid #e1e8ed;
|
||||
border-radius: 8px;
|
||||
.section-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 14px 18px; cursor: pointer; user-select: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.section-header:hover { background: #fafbfc; }
|
||||
.section-header-left { display: flex; align-items: center; gap: 8px; color: #303133; }
|
||||
.section-title { font-size: 14px; font-weight: 600; }
|
||||
.section-header .el-icon { transition: transform 0.25s; color: #909399; font-size: 14px; }
|
||||
.section-header .el-icon.is-expanded { transform: rotate(180deg); }
|
||||
.section-body { padding: 0 18px 18px; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px 24px; }
|
||||
.info-item { display: flex; flex-direction: column; gap: 4px; }
|
||||
.info-label { font-size: 12px; color: #909399; }
|
||||
.info-value { font-size: 14px; color: #303133; font-weight: 500; }
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.slide-enter-active, .slide-leave-active { transition: all 0.25s ease; overflow: hidden; }
|
||||
.slide-enter-from, .slide-leave-to { opacity: 0; max-height: 0; padding-top: 0; padding-bottom: 0; }
|
||||
.slide-enter-to, .slide-leave-from { opacity: 1; max-height: 300px; }
|
||||
|
||||
.selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-descriptions__label) {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
/* ---- Related Card (fallback for non-VM) ---- */
|
||||
.related-card { margin-top: 16px; border: 1px solid #ebeef5; border-radius: 8px; }
|
||||
.card-header-row { display: flex; align-items: center; gap: 8px; color: #303133; }
|
||||
.card-title { font-size: 15px; font-weight: 600; color: #303133; }
|
||||
|
||||
/* ---- Shared ---- */
|
||||
.selector-row { display: flex; align-items: center; width: 100%; }
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
:deep(.el-descriptions__label) { font-weight: 500; color: #606266; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,45 @@
|
||||
<template>
|
||||
<div class="user-goods-list">
|
||||
<el-card class="main-container" shadow="never">
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card" :class="{ active: query.status === 'all' }" @click="handleStatusCard('all')">
|
||||
<div class="stat-icon stat-icon-total"><el-icon><Box /></el-icon></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-label">商品总数</div>
|
||||
<div class="stat-value">{{ countTotal }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" :class="{ active: query.status === 'normal' }" @click="handleStatusCard('normal')">
|
||||
<div class="stat-icon stat-icon-normal"><el-icon><CircleCheck /></el-icon></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-label">正常</div>
|
||||
<div class="stat-value">{{ counts.normal }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" :class="{ active: query.status === 'expired' }" @click="handleStatusCard('expired')">
|
||||
<div class="stat-icon stat-icon-expired"><el-icon><Clock /></el-icon></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-label">已到期</div>
|
||||
<div class="stat-value">{{ counts.expired }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" :class="{ active: query.status === 'pending' }" @click="handleStatusCard('pending')">
|
||||
<div class="stat-icon stat-icon-pending"><el-icon><Timer /></el-icon></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-label">待开通</div>
|
||||
<div class="stat-value">{{ counts.pending }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card" :class="{ active: query.status === 'deleted' }" @click="handleStatusCard('deleted')">
|
||||
<div class="stat-icon stat-icon-deleted"><el-icon><Delete /></el-icon></div>
|
||||
<div class="stat-body">
|
||||
<div class="stat-label">已删除</div>
|
||||
<div class="stat-value">{{ counts.deleted }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选与操作栏 -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-content">
|
||||
@@ -23,11 +62,20 @@
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" style="width:120px" @change="handleSearch">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="正常" value="normal" />
|
||||
<el-option label="已到期" value="expired" />
|
||||
<el-option label="待开通" value="pending" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>查询
|
||||
</el-button>
|
||||
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
|
||||
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.status = 'all'; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="action-bar">
|
||||
@@ -87,12 +135,22 @@
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="isDeleted(row)" :content="`删除于 ${formatTime(row.deleteAt || row.DeleteAt || row.deleted_at)}`" placement="top">
|
||||
<el-tag size="small" type="danger">已删除</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tag v-else-if="isExpired(row)" size="small" type="warning">已到期</el-tag>
|
||||
<el-tag v-else-if="row.status === 'pending'" size="small" type="info">待开通</el-tag>
|
||||
<el-tag v-else size="small" type="success">正常</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="套餐ID" width="90">
|
||||
<template #default="{ row }">{{ row.goodPlanId || row.good_plan_id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="订单" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link v-if="row.orderId" type="primary" :underline="false" @click.stop="router.push({ path: '/order/list', query: { key: row.orderId } })">{{ row.order?.name || `订单 #${row.orderId}` }}</el-link>
|
||||
<el-link v-if="row.orderId" type="primary" :underline="false" @click.stop="router.push({ path: '/order/list', query: { order_id: row.orderId } })">{{ row.order?.name || `订单 #${row.orderId}` }}</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -464,8 +522,8 @@
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowDown } from '@element-plus/icons-vue'
|
||||
import { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
|
||||
import { Plus, Refresh, Search, ArrowDown, Box, CircleCheck, Clock, Delete, Timer } from '@element-plus/icons-vue'
|
||||
import { getUserGoodsList, getUserGoodsCount, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { formatToApiTime } from '@/utils/tool'
|
||||
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
|
||||
@@ -480,12 +538,16 @@ const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '' })
|
||||
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '', status: 'all' })
|
||||
const filterUserName = ref('')
|
||||
const filterGoodName = ref('')
|
||||
const showFilterUserSelector = ref(false)
|
||||
const showFilterProductSelector = ref(false)
|
||||
|
||||
// 用户商品数量统计
|
||||
const counts = reactive({ normal: 0, deleted: 0, expired: 0, pending: 0 })
|
||||
const countTotal = computed(() => (counts.normal || 0) + (counts.deleted || 0) + (counts.expired || 0) + (counts.pending || 0))
|
||||
|
||||
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
|
||||
const formatExpireTime = (t) => {
|
||||
@@ -495,6 +557,17 @@ const formatExpireTime = (t) => {
|
||||
return d.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
|
||||
const isDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
|
||||
|
||||
// 判断用户商品是否已到期(永久商品除外)
|
||||
const isExpired = (row) => {
|
||||
const t = row?.expireTime || row?.expire_time
|
||||
if (!t) return false
|
||||
const d = dayjs(t)
|
||||
return d.year() >= 2000 && d.isBefore(dayjs())
|
||||
}
|
||||
|
||||
const formatMemory = (kb) => {
|
||||
if (!kb) return '-'
|
||||
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
|
||||
@@ -520,13 +593,20 @@ const getStatusText = (status) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 组装筛选参数(count 与 list 共用)
|
||||
const buildFilterParams = () => {
|
||||
const params = {}
|
||||
if (query.key) params.key = query.key
|
||||
if (query.user_id) params.user_id = parseInt(query.user_id) || undefined
|
||||
if (query.good_id) params.good_id = parseInt(query.good_id) || undefined
|
||||
return params
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: query.page, count: query.count }
|
||||
if (query.key) params.key = query.key
|
||||
if (query.user_id) params.user_id = parseInt(query.user_id) || undefined
|
||||
if (query.good_id) params.good_id = parseInt(query.good_id) || undefined
|
||||
const params = { page: query.page, count: query.count, ...buildFilterParams() }
|
||||
if (query.status && query.status !== 'all') params.status = query.status
|
||||
const res = await getUserGoodsList(params)
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
@@ -536,7 +616,27 @@ const loadList = async () => {
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleSearch = () => { query.page = 1; loadList() }
|
||||
// 加载数量统计(透传筛选条件,与列表联动)
|
||||
const loadCount = async () => {
|
||||
try {
|
||||
const res = await getUserGoodsCount(buildFilterParams())
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
counts.normal = d.normal ?? 0
|
||||
counts.deleted = d.deleted ?? 0
|
||||
counts.expired = d.expired ?? 0
|
||||
counts.pending = d.pending ?? 0
|
||||
}
|
||||
} catch { /* 统计失败不阻断列表 */ }
|
||||
}
|
||||
|
||||
const handleSearch = () => { query.page = 1; loadList(); loadCount() }
|
||||
|
||||
// 点击统计卡片快速切换状态筛选
|
||||
const handleStatusCard = (status) => {
|
||||
query.status = status
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
// ---- 订单参数配置 ----
|
||||
const argsSpecList = ref([])
|
||||
@@ -841,12 +941,7 @@ const submitEdit = async () => {
|
||||
|
||||
// ---- 详情 / 删除 ----
|
||||
const handleDetail = (row) => {
|
||||
const tag = (row.tag || row.good?.tag || '').toLowerCase()
|
||||
if (tag === '云服务器') {
|
||||
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
|
||||
} else {
|
||||
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
|
||||
}
|
||||
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
|
||||
}
|
||||
|
||||
const handleMoreCmd = (cmd, row) => {
|
||||
@@ -911,7 +1006,7 @@ const handleSendRemind = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
onMounted(() => { loadList(); loadCount() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -924,6 +1019,61 @@ onMounted(loadList)
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e1e8ed;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.stat-card.active {
|
||||
border-color: #409eff;
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon-total { background: #eef3ff; color: #4f6ef7; }
|
||||
.stat-icon-normal { background: #f0faf0; color: #52c41a; }
|
||||
.stat-icon-expired { background: #fff7e6; color: #fa8c16; }
|
||||
.stat-icon-deleted { background: #fff1f0; color: #f5222d; }
|
||||
.stat-icon-pending { background: #e6f7ff; color: #1890ff; }
|
||||
|
||||
.stat-body { display: flex; flex-direction: column; }
|
||||
.stat-label { font-size: 13px; color: #909399; margin-bottom: 2px; }
|
||||
.stat-value { font-size: 22px; font-weight: 600; color: #2c3e50; line-height: 1.1; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -275,13 +275,13 @@
|
||||
<el-form-item v-if="currentParam?.type === 'number'" label="数值范围" prop="attr_range">
|
||||
<div class="range-config-row">
|
||||
<el-select v-model="paramValueForm.range_type" class="range-type-select">
|
||||
<el-option label="<" value="before">
|
||||
<span class="range-opt-symbol"><</span>
|
||||
<span class="range-opt-desc">小于</span>
|
||||
<el-option label="≤" value="before">
|
||||
<span class="range-opt-symbol">≤</span>
|
||||
<span class="range-opt-desc">小于等于</span>
|
||||
</el-option>
|
||||
<el-option label=">" value="after">
|
||||
<span class="range-opt-symbol">></span>
|
||||
<span class="range-opt-desc">大于</span>
|
||||
<el-option label="≥" value="after">
|
||||
<span class="range-opt-symbol">≥</span>
|
||||
<span class="range-opt-desc">大于等于</span>
|
||||
</el-option>
|
||||
<el-option label="=" value="equal">
|
||||
<span class="range-opt-symbol">=</span>
|
||||
@@ -635,7 +635,7 @@ const getArgTypeTag = (type) => {
|
||||
return tagMap[type] || 'info'
|
||||
}
|
||||
const getRangeTypeText = (type) => {
|
||||
const typeMap = { 'after': '>', 'before': '<', 'equal': '=' }
|
||||
const typeMap = { 'after': '≥', 'before': '≤', 'equal': '=' }
|
||||
return typeMap[type] || type || '-'
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@
|
||||
<span class="price-symbol">¥</span>
|
||||
<span class="price-amount">{{ formatPlanPrice(row) }}</span>
|
||||
</div>
|
||||
<div v-if="isFixedPrice(row) && getRenewPrice(row) > 0" class="plan-renew-price">
|
||||
续费 ¥{{ formatRenewPrice(row) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="plan-stat plan-stat-inventory" :class="getInventoryClass(row)">
|
||||
<div class="plan-stat-label">
|
||||
@@ -113,6 +116,9 @@
|
||||
<el-tag :type="row.canUpdate || row.can_update ? 'success' : 'info'" size="small" effect="plain">
|
||||
{{ row.canUpdate || row.can_update ? '允许升级' : '禁止升级' }}
|
||||
</el-tag>
|
||||
<el-tag v-if="(row.maxPerUser || row.max_per_user) > 0" type="warning" size="small" effect="plain">
|
||||
限购{{ row.maxPerUser || row.max_per_user }}次
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 说明 -->
|
||||
@@ -271,6 +277,13 @@
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="续费固定价格" prop="renew_fixed_price" v-if="planForm.enable_fixed_price === true">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="planForm.renew_fixed_price" :min="0" :precision="2" :step="0.01" style="flex:1" placeholder="0 表示沿用固定价格" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
<div class="form-tip">续费时使用的固定价格,为 0 时沿用上方固定价格</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
@@ -288,6 +301,10 @@
|
||||
<el-switch v-model="planForm.can_update" active-text="允许" inactive-text="不允许" />
|
||||
<div class="form-tip">控制用户是否可以升级到此套餐</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="购买限制" prop="max_per_user">
|
||||
<el-input-number v-model="planForm.max_per_user" :min="0" placeholder="单用户最大购买数量" style="width: 100%" />
|
||||
<div class="form-tip">限制单个用户对该套餐的最大购买数量,0 表示不限制</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -378,7 +395,9 @@ const planForm = reactive({
|
||||
index: 0,
|
||||
disable: false,
|
||||
show_home: false,
|
||||
can_update: false
|
||||
can_update: false,
|
||||
max_per_user: 0,
|
||||
renew_fixed_price: 0
|
||||
})
|
||||
const planFormRules = {
|
||||
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }]
|
||||
@@ -455,6 +474,14 @@ const formatPlanPrice = (row) => {
|
||||
return (Number(raw) / 100).toFixed(2)
|
||||
}
|
||||
|
||||
const getRenewPrice = (row) => {
|
||||
return Number(row.renewFixedPrice ?? row.renew_fixed_price ?? 0)
|
||||
}
|
||||
|
||||
const formatRenewPrice = (row) => {
|
||||
return (getRenewPrice(row) / 100).toFixed(2)
|
||||
}
|
||||
|
||||
const getInventoryNum = (row) => {
|
||||
return Number(row.inventory ?? 0) || 0
|
||||
}
|
||||
@@ -546,11 +573,11 @@ const findMatchingNumberAttr = (spec, numValue) => {
|
||||
if (!spec.attrs || spec.attrs.length === 0) return null
|
||||
const sortedAttrs = [...spec.attrs].sort((a, b) => (a.index || 0) - (b.index || 0))
|
||||
for (const attr of sortedAttrs) {
|
||||
const phase = attr.phase || 0
|
||||
const rangeVal = attr.range ?? attr.phase ?? 0
|
||||
const rangeType = attr.rangeType || 'before'
|
||||
if (rangeType === 'before' && numValue < phase) return attr
|
||||
else if (rangeType === 'after' && numValue > phase) return attr
|
||||
else if (rangeType === 'equal' && numValue === phase) return attr
|
||||
if (rangeType === 'before' && numValue <= rangeVal) return attr
|
||||
else if (rangeType === 'after' && numValue >= rangeVal) return attr
|
||||
else if (rangeType === 'equal' && numValue === rangeVal) return attr
|
||||
}
|
||||
return sortedAttrs[sortedAttrs.length - 1]
|
||||
}
|
||||
@@ -758,7 +785,7 @@ const handleAddPlan = async () => {
|
||||
displayUnits[spec.id] = getParamDefaultUnit(spec)
|
||||
}
|
||||
}
|
||||
Object.assign(planForm, { plan_id: undefined, name: '', note: '', args: '', extra_arg_ids: '', extra_arg_ids_array: [], inventory: 0, fixed_price: 0, enable_fixed_price: false, index: 0, disable: false, show_home: false, can_update: false })
|
||||
Object.assign(planForm, { plan_id: undefined, name: '', note: '', args: '', extra_arg_ids: '', extra_arg_ids_array: [], inventory: 0, fixed_price: 0, enable_fixed_price: false, index: 0, disable: false, show_home: false, can_update: false, max_per_user: 0 })
|
||||
planFormDialogVisible.value = true
|
||||
nextTick(() => { planFormRef.value?.resetFields() })
|
||||
}
|
||||
@@ -785,7 +812,9 @@ const handleEditPlan = async (row) => {
|
||||
inventory: data.inventory || 0, fixed_price: ((data.fixedPrice || data.fixed_price || 0) / 100).toFixed(2) * 1,
|
||||
enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price),
|
||||
index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home),
|
||||
can_update: !!(data.canUpdate || data.can_update)
|
||||
can_update: !!(data.canUpdate || data.can_update),
|
||||
max_per_user: data.maxPerUser ?? data.max_per_user ?? 0,
|
||||
renew_fixed_price: ((data.renewFixedPrice ?? data.renew_fixed_price ?? 0) / 100).toFixed(2) * 1
|
||||
})
|
||||
initSelectedArgsFromJson(data.args, extraArgIdsArray)
|
||||
planFormDialogVisible.value = true
|
||||
@@ -842,7 +871,9 @@ const submitPlanForm = () => {
|
||||
args: planForm.args || '', extra_arg_ids: extraArgIdsStr || planForm.extra_arg_ids || '',
|
||||
inventory: Number(planForm.inventory) || 0, fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0,
|
||||
index: Number(planForm.index) || 0, show_home: planForm.show_home === true,
|
||||
can_update: planForm.can_update === true
|
||||
can_update: planForm.can_update === true,
|
||||
max_per_user: Number(planForm.max_per_user) || 0,
|
||||
renew_fixed_price: Math.round(Number(planForm.renew_fixed_price) * 100) || 0
|
||||
}
|
||||
if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true
|
||||
let res
|
||||
@@ -998,6 +1029,14 @@ watch(() => props.visible, (val) => {
|
||||
color: #f56c6c;
|
||||
font-weight: 700;
|
||||
}
|
||||
.plan-renew-price {
|
||||
font-size: 11px;
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.plan-stat-price.is-dynamic {
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e6f2ff 100%);
|
||||
border-color: #c6e2ff;
|
||||
|
||||
@@ -0,0 +1,892 @@
|
||||
<template>
|
||||
<div class="sms-goods-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div class="header-info">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#e6a23c" stroke-width="1.8">
|
||||
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="header-title">短信额度商品管理</h2>
|
||||
<p class="header-desc">
|
||||
管理短信平台的额度商品配置
|
||||
<template v-if="filterServiceName">
|
||||
— 当前筛选:<el-tag size="small" type="warning">{{ filterServiceName }}</el-tag>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="router.push('/sms/service')">
|
||||
<el-icon><Back /></el-icon> 返回服务列表
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon> 新增商品
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-select
|
||||
v-model="queryParams.service_id"
|
||||
placeholder="筛选主控服务"
|
||||
clearable
|
||||
style="width: 220px"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-model="queryParams.key"
|
||||
placeholder="搜索商品名称"
|
||||
clearable
|
||||
style="width: 240px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column label="关联服务" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="goToService(row.serviceId)">
|
||||
{{ resolveServiceName(row.serviceId) }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联商品" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span class="goods-name">{{ row.good?.name || `商品#${row.goodId}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="额度类型" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="quotaTypeTag(row.quotaType).type" effect="dark" size="small">
|
||||
{{ quotaTypeTag(row.quotaType).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期/周期配置" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.quotaType === 2">
|
||||
<span class="config-label">有效期模式:</span>
|
||||
<el-tag size="small" type="info">{{ row.expireMode === 'fixed' ? '固定' : '用户选择' }}</el-tag>
|
||||
</template>
|
||||
<template v-else-if="row.quotaType === 3">
|
||||
<span class="config-label">周期模式:</span>
|
||||
<el-tag size="small" type="info">{{ row.cycleMode === 'fixed' ? '固定' : '用户选择' }}</el-tag>
|
||||
<template v-if="row.cycleMode === 'fixed'">
|
||||
<span class="config-detail">每{{ row.cycleValue || 1 }}{{ cycleUnitLabel(row.cycleUnit) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="config-detail">永久有效</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="note-text">{{ row.note || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="CreatedAt" label="创建时间" width="170" align="center">
|
||||
<template #default="{ row }">{{ formatTime(row.CreatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除该商品绑定?" @confirm="handleDelete(row)">
|
||||
<template #reference>
|
||||
<el-button link type="danger" size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.count"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="fetchList"
|
||||
@current-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增弹窗 -->
|
||||
<el-dialog
|
||||
v-model="addDialogVisible"
|
||||
title="新增短信额度商品"
|
||||
width="680px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="addFormRef" :model="addForm" :rules="addRules" label-width="130px">
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
<el-form-item label="关联主控服务" prop="service_id">
|
||||
<el-select v-model="addForm.service_id" placeholder="选择短信主控服务" style="width: 100%">
|
||||
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="商品分组" prop="good_group_id">
|
||||
<el-tree-select
|
||||
v-model="addForm.good_group_id"
|
||||
:data="groupTreeData"
|
||||
:props="groupTreeProps"
|
||||
lazy
|
||||
:load="loadGroupChildren"
|
||||
node-key="id"
|
||||
placeholder="选择商品分组"
|
||||
clearable
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="商品名称" prop="name">
|
||||
<el-input v-model="addForm.name" placeholder="如:短信1000条包" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品介绍">
|
||||
<el-input v-model="addForm.content" type="textarea" :rows="2" placeholder="商品介绍(可选,系统自动追加配额说明)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="管理备注">
|
||||
<el-input v-model="addForm.note" placeholder="管理备注(可选)" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">额度配置</el-divider>
|
||||
<el-form-item label="额度类型" prop="quota_type">
|
||||
<div class="quota-type-cards">
|
||||
<div
|
||||
v-for="qt in quotaTypes"
|
||||
:key="qt.value"
|
||||
class="qt-card"
|
||||
:class="{ active: addForm.quota_type === qt.value }"
|
||||
@click="addForm.quota_type = qt.value"
|
||||
>
|
||||
<div class="qt-icon" v-html="qt.icon"></div>
|
||||
<div class="qt-label">{{ qt.label }}</div>
|
||||
<div class="qt-desc">{{ qt.desc }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="额度数量模式" prop="quota_value_type">
|
||||
<el-radio-group v-model="addForm.quota_value_type">
|
||||
<el-radio value="number">数值范围(用户输入数值)</el-radio>
|
||||
<el-radio value="select">固定选项(用户选择档位)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="addForm.quota_value_type === 'number'">
|
||||
<el-form-item label="最小值">
|
||||
<el-input-number v-model="addForm.quota_min" :min="1" :step="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大值">
|
||||
<el-input-number v-model="addForm.quota_max" :min="1" :step="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="步长">
|
||||
<el-input-number v-model="addForm.quota_step" :min="1" :step="10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单条价格" prop="quota_unit_price">
|
||||
<el-input-number v-model="addForm.quota_unit_price" :min="0" :step="0.01" :precision="2" controls-position="right" />
|
||||
<span class="opt-unit">元/条</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="额度选项">
|
||||
<div class="dynamic-options">
|
||||
<div v-for="(item, idx) in addForm.quota_options_list" :key="idx" class="option-row">
|
||||
<el-input-number v-model="item.value" :min="1" placeholder="数量" controls-position="right" class="opt-value" />
|
||||
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
|
||||
<el-input-number v-model="item.price" :min="0" :step="0.01" :precision="2" placeholder="价格" controls-position="right" class="opt-price" />
|
||||
<span class="opt-unit">元</span>
|
||||
<el-button link type="danger" @click="addForm.quota_options_list.splice(idx, 1)" :disabled="addForm.quota_options_list.length <= 1">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="addForm.quota_options_list.push({ value: null, label: '', price: null })">
|
||||
<el-icon><Plus /></el-icon> 添加选项
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 短期额度配置 -->
|
||||
<template v-if="addForm.quota_type === 2">
|
||||
<el-divider content-position="left">有效期配置</el-divider>
|
||||
<el-form-item label="有效期模式" prop="expire_mode">
|
||||
<el-radio-group v-model="addForm.expire_mode">
|
||||
<el-radio value="fixed">固定天数(管理员指定)</el-radio>
|
||||
<el-radio value="select">用户选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="addForm.expire_mode === 'fixed'" label="有效天数">
|
||||
<el-input-number v-model="addForm.expire_fixed" :min="1" />
|
||||
<span style="margin-left: 8px; color: #909399">天</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="addForm.expire_mode === 'select'" label="有效期选项">
|
||||
<div class="dynamic-options">
|
||||
<div v-for="(item, idx) in addForm.expire_options_list" :key="idx" class="option-row">
|
||||
<el-input-number v-model="item.value" :min="1" placeholder="天数" controls-position="right" class="opt-value" />
|
||||
<span class="opt-unit">天</span>
|
||||
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
|
||||
<el-button link type="danger" @click="addForm.expire_options_list.splice(idx, 1)" :disabled="addForm.expire_options_list.length <= 1">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="addForm.expire_options_list.push({ value: null, label: '' })">
|
||||
<el-icon><Plus /></el-icon> 添加选项
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<!-- 周期额度配置 -->
|
||||
<template v-if="addForm.quota_type === 3">
|
||||
<el-divider content-position="left">周期配置</el-divider>
|
||||
<el-form-item label="周期单位模式" prop="cycle_mode">
|
||||
<el-radio-group v-model="addForm.cycle_mode">
|
||||
<el-radio value="fixed">固定单位(管理员指定)</el-radio>
|
||||
<el-radio value="select">用户选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="addForm.cycle_mode === 'fixed'" label="周期单位">
|
||||
<el-select v-model="addForm.cycle_unit" style="width: 160px">
|
||||
<el-option label="天" value="day" />
|
||||
<el-option label="周" value="week" />
|
||||
<el-option label="月" value="month" />
|
||||
<el-option label="年" value="year" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="addForm.cycle_mode === 'select'" label="周期选项">
|
||||
<div class="dynamic-options">
|
||||
<div v-for="(item, idx) in addForm.cycle_options_list" :key="idx" class="option-row">
|
||||
<el-select v-model="item.value" placeholder="周期单位" class="opt-cycle-unit">
|
||||
<el-option label="天" value="day" />
|
||||
<el-option label="周" value="week" />
|
||||
<el-option label="月" value="month" />
|
||||
<el-option label="年" value="year" />
|
||||
</el-select>
|
||||
<el-input v-model="item.label" placeholder="显示名称" class="opt-label" />
|
||||
<el-button link type="danger" @click="addForm.cycle_options_list.splice(idx, 1)" :disabled="addForm.cycle_options_list.length <= 1">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button size="small" @click="addForm.cycle_options_list.push({ value: '', label: '' })">
|
||||
<el-icon><Plus /></el-icon> 添加选项
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="周期数值" prop="cycle_value">
|
||||
<el-input-number v-model="addForm.cycle_value" :min="1" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmitAdd">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑弹窗(仅可修改 SmsGoods 自身字段) -->
|
||||
<el-dialog
|
||||
v-model="editDialogVisible"
|
||||
title="编辑短信额度商品"
|
||||
width="580px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="editFormRef" :model="editForm" label-width="130px">
|
||||
<el-form-item label="管理备注">
|
||||
<el-input v-model="editForm.note" placeholder="管理备注" />
|
||||
</el-form-item>
|
||||
<el-form-item label="额度类型">
|
||||
<div class="quota-type-cards small">
|
||||
<div
|
||||
v-for="qt in quotaTypes"
|
||||
:key="qt.value"
|
||||
class="qt-card"
|
||||
:class="{ active: editForm.quota_type === qt.value }"
|
||||
@click="editForm.quota_type = qt.value"
|
||||
>
|
||||
<div class="qt-icon" v-html="qt.icon"></div>
|
||||
<div class="qt-label">{{ qt.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="editForm.quota_type === 2">
|
||||
<el-form-item label="有效期模式">
|
||||
<el-radio-group v-model="editForm.expire_mode">
|
||||
<el-radio value="fixed">固定天数</el-radio>
|
||||
<el-radio value="select">用户选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-if="editForm.quota_type === 3">
|
||||
<el-form-item label="周期单位模式">
|
||||
<el-radio-group v-model="editForm.cycle_mode">
|
||||
<el-radio value="fixed">固定单位</el-radio>
|
||||
<el-radio value="select">用户选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.cycle_mode === 'fixed'" label="周期单位">
|
||||
<el-select v-model="editForm.cycle_unit" style="width: 160px">
|
||||
<el-option label="天" value="day" />
|
||||
<el-option label="周" value="week" />
|
||||
<el-option label="月" value="month" />
|
||||
<el-option label="年" value="year" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="周期数值">
|
||||
<el-input-number v-model="editForm.cycle_value" :min="1" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmitEdit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Search, Back, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getSmsServiceList,
|
||||
getSmsGoodsList,
|
||||
createSmsGoods,
|
||||
updateSmsGoods,
|
||||
deleteSmsGoods
|
||||
} from '@/api/admin/smsService.js'
|
||||
import { getProductGroupList } from '@/api/admin/product'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const serviceOptions = ref([])
|
||||
const serviceMap = ref({})
|
||||
const addDialogVisible = ref(false)
|
||||
const editDialogVisible = ref(false)
|
||||
const addFormRef = ref(null)
|
||||
const editFormRef = ref(null)
|
||||
|
||||
const groupTreeData = ref([])
|
||||
const groupTreeProps = { label: 'name', children: 'children', isLeaf: (data) => !data.existSub }
|
||||
const filterServiceName = computed(() => route.query.service_name || '')
|
||||
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
count: 10,
|
||||
service_id: route.query.service_id ? parseInt(route.query.service_id) : undefined,
|
||||
key: ''
|
||||
})
|
||||
|
||||
const quotaTypes = [
|
||||
{
|
||||
value: 1, label: '长期',
|
||||
desc: '永久有效,一次购买持续可用',
|
||||
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>'
|
||||
},
|
||||
{
|
||||
value: 2, label: '短期',
|
||||
desc: '限时有效,到期后额度失效',
|
||||
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>'
|
||||
},
|
||||
{
|
||||
value: 3, label: '周期',
|
||||
desc: '按周期自动重置额度',
|
||||
icon: '<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>'
|
||||
}
|
||||
]
|
||||
|
||||
const defaultAddForm = () => ({
|
||||
service_id: queryParams.service_id || null,
|
||||
good_group_id: null,
|
||||
name: '',
|
||||
content: '',
|
||||
note: '',
|
||||
quota_type: 1,
|
||||
quota_value_type: 'number',
|
||||
quota_options_list: [{ value: null, label: '', price: null }],
|
||||
quota_min: 100,
|
||||
quota_max: 100000,
|
||||
quota_step: 100,
|
||||
quota_unit_price: 0.05,
|
||||
expire_mode: 'fixed',
|
||||
expire_fixed: 30,
|
||||
expire_options_list: [{ value: null, label: '' }],
|
||||
cycle_mode: 'fixed',
|
||||
cycle_unit: 'month',
|
||||
cycle_value: 1,
|
||||
cycle_options_list: [{ value: '', label: '' }]
|
||||
})
|
||||
|
||||
const serializeOptions = (list, withPrice = false) => {
|
||||
return list
|
||||
.filter(item => item.value !== null && item.value !== '' && item.label)
|
||||
.map(item => withPrice ? `${item.value}:${item.label}:${item.price ?? 0}` : `${item.value}:${item.label}`)
|
||||
.join(',')
|
||||
}
|
||||
|
||||
const addForm = ref(defaultAddForm())
|
||||
|
||||
const editForm = ref({
|
||||
id: null,
|
||||
note: '',
|
||||
quota_type: 1,
|
||||
expire_mode: '',
|
||||
cycle_mode: '',
|
||||
cycle_unit: '',
|
||||
cycle_value: 1
|
||||
})
|
||||
|
||||
const addRules = {
|
||||
service_id: [{ required: true, message: '请选择主控服务', trigger: 'change' }],
|
||||
name: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
||||
quota_type: [{ required: true, message: '请选择额度类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
return new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
const quotaTypeTag = (type) => {
|
||||
const map = { 1: { label: '长期', type: 'success' }, 2: { label: '短期', type: 'warning' }, 3: { label: '周期', type: '' } }
|
||||
return map[type] || { label: '未知', type: 'info' }
|
||||
}
|
||||
|
||||
const cycleUnitLabel = (unit) => {
|
||||
const map = { day: '天', week: '周', month: '月', year: '年' }
|
||||
return map[unit] || unit
|
||||
}
|
||||
|
||||
const resolveServiceName = (id) => {
|
||||
return serviceMap.value[id] || `服务#${id}`
|
||||
}
|
||||
|
||||
const goToService = (serviceId) => {
|
||||
router.push({ path: '/sms/service' })
|
||||
}
|
||||
|
||||
const loadGroupOptions = async () => {
|
||||
try {
|
||||
const res = await getProductGroupList({ level: 1, count: 199 })
|
||||
if (res.data.code === 200) {
|
||||
groupTreeData.value = (res.data.data?.data || res.data.data || []).map(item => ({
|
||||
...item,
|
||||
existSub: !!item.existSub
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const loadGroupChildren = async (node, resolve) => {
|
||||
if (node.level === 0) return resolve(groupTreeData.value)
|
||||
try {
|
||||
const res = await getProductGroupList({ parent_id: node.data.id, level: node.data.level + 1, count: 199 })
|
||||
if (res.data.code === 200) {
|
||||
const children = (res.data.data?.data || res.data.data || []).map(item => ({
|
||||
...item,
|
||||
existSub: !!item.existSub
|
||||
}))
|
||||
resolve(children)
|
||||
} else {
|
||||
resolve([])
|
||||
}
|
||||
} catch (e) {
|
||||
resolve([])
|
||||
}
|
||||
}
|
||||
|
||||
const loadServiceOptions = async () => {
|
||||
try {
|
||||
const res = await getSmsServiceList({ page: 1, count: 199 })
|
||||
const body = res.data
|
||||
if (body.code === 200) {
|
||||
const list = body.data?.data || body.data || []
|
||||
serviceOptions.value = Array.isArray(list) ? list : []
|
||||
const map = {}
|
||||
serviceOptions.value.forEach(s => { map[s.id] = s.name })
|
||||
serviceMap.value = map
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { ...queryParams }
|
||||
if (!params.service_id) delete params.service_id
|
||||
const res = await getSmsGoodsList(params)
|
||||
const body = res.data
|
||||
if (body.code === 200) {
|
||||
const d = body.data?.data || body.data || []
|
||||
tableData.value = Array.isArray(d) ? d : []
|
||||
total.value = body.data?.all_count || 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.key = ''
|
||||
queryParams.service_id = undefined
|
||||
queryParams.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
addForm.value = defaultAddForm()
|
||||
addDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmitAdd = async () => {
|
||||
await addFormRef.value?.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('service_id', addForm.value.service_id)
|
||||
if (addForm.value.good_group_id) params.append('good_group_id', addForm.value.good_group_id)
|
||||
params.append('name', addForm.value.name)
|
||||
if (addForm.value.content) params.append('content', addForm.value.content)
|
||||
if (addForm.value.note) params.append('note', addForm.value.note)
|
||||
params.append('quota_type', addForm.value.quota_type)
|
||||
params.append('quota_value_type', addForm.value.quota_value_type)
|
||||
|
||||
if (addForm.value.quota_value_type === 'number') {
|
||||
params.append('quota_min', addForm.value.quota_min)
|
||||
params.append('quota_max', addForm.value.quota_max)
|
||||
params.append('quota_step', addForm.value.quota_step)
|
||||
params.append('quota_unit_price', addForm.value.quota_unit_price)
|
||||
} else {
|
||||
params.append('quota_options', serializeOptions(addForm.value.quota_options_list, true))
|
||||
}
|
||||
|
||||
if (addForm.value.quota_type === 2) {
|
||||
params.append('expire_mode', addForm.value.expire_mode)
|
||||
if (addForm.value.expire_mode === 'fixed') {
|
||||
params.append('expire_fixed', addForm.value.expire_fixed)
|
||||
} else {
|
||||
params.append('expire_options', serializeOptions(addForm.value.expire_options_list))
|
||||
}
|
||||
}
|
||||
|
||||
if (addForm.value.quota_type === 3) {
|
||||
params.append('cycle_mode', addForm.value.cycle_mode)
|
||||
params.append('cycle_value', addForm.value.cycle_value)
|
||||
if (addForm.value.cycle_mode === 'fixed') {
|
||||
params.append('cycle_unit', addForm.value.cycle_unit)
|
||||
} else {
|
||||
params.append('cycle_options', serializeOptions(addForm.value.cycle_options_list))
|
||||
}
|
||||
}
|
||||
|
||||
const res = await createSmsGoods(params)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('创建成功')
|
||||
addDialogVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '创建失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('创建失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
editForm.value = {
|
||||
id: row.id,
|
||||
note: row.note || '',
|
||||
quota_type: row.quotaType,
|
||||
expire_mode: row.expireMode || 'fixed',
|
||||
cycle_mode: row.cycleMode || 'fixed',
|
||||
cycle_unit: row.cycleUnit || 'month',
|
||||
cycle_value: row.cycleValue || 1
|
||||
}
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmitEdit = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('id', editForm.value.id)
|
||||
if (editForm.value.note) params.append('note', editForm.value.note)
|
||||
params.append('quota_type', editForm.value.quota_type)
|
||||
|
||||
if (editForm.value.quota_type === 2) {
|
||||
params.append('expire_mode', editForm.value.expire_mode)
|
||||
}
|
||||
if (editForm.value.quota_type === 3) {
|
||||
params.append('cycle_mode', editForm.value.cycle_mode)
|
||||
params.append('cycle_value', editForm.value.cycle_value)
|
||||
if (editForm.value.cycle_mode === 'fixed') {
|
||||
params.append('cycle_unit', editForm.value.cycle_unit)
|
||||
}
|
||||
}
|
||||
|
||||
const res = await updateSmsGoods(params)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('更新成功')
|
||||
editDialogVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '更新失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('更新失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('id', row.id)
|
||||
const res = await deleteSmsGoods(params)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadServiceOptions(), loadGroupOptions()])
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sms-goods-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(135deg, #fef9f0 0%, #fdf2e4 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0dfc8;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(230, 162, 60, 0.15);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
background: #fafbfc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.goods-name {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.config-detail {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* 额度类型卡片 */
|
||||
.quota-type-cards {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quota-type-cards.small .qt-card {
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.qt-card {
|
||||
flex: 1;
|
||||
padding: 16px;
|
||||
border: 2px solid #ebeef5;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.qt-card:hover {
|
||||
border-color: #c0c4cc;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.qt-card.active {
|
||||
border-color: #409eff;
|
||||
background: #f0f7ff;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.qt-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.qt-card.active .qt-icon {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.qt-label {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.qt-desc {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 动态选项列表 */
|
||||
.dynamic-options {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.option-row:last-of-type {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.opt-value {
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.opt-unit {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.opt-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.opt-price {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.opt-cycle-unit {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div class="sms-service-page">
|
||||
<!-- 页面头部 -->
|
||||
<div class="page-header">
|
||||
<div class="header-info">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#409eff" stroke-width="1.8">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
<path d="M8 9h8M8 13h4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="header-title">短信主控服务管理</h2>
|
||||
<p class="header-desc">管理短信平台的主控服务实例,每个服务对应一个 sms-server 节点</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon> 新增服务
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-input
|
||||
v-model="queryParams.key"
|
||||
placeholder="搜索名称 / 说明 / 地址"
|
||||
clearable
|
||||
style="width: 300px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
>
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-table :data="tableData" v-loading="loading" stripe border style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column prop="name" label="服务名称" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="service-name-cell">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="#67c23a" stroke-width="2">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/>
|
||||
<path d="M8 21h8M12 17v4"/>
|
||||
</svg>
|
||||
<span class="name-text">{{ row.name }}</span>
|
||||
<el-tag v-if="row.default" type="success" size="small" effect="dark" style="margin-left: 6px">默认</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="note" label="说明" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="note-text">{{ row.note || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="host" label="服务地址" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="info" effect="plain" class="host-tag">{{ row.host }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="serviceToken" label="Service Token" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="token-cell">
|
||||
<span v-if="!row._showToken" class="token-mask">{{ maskToken(row.serviceToken) }}</span>
|
||||
<span v-else class="token-full">{{ row.serviceToken }}</span>
|
||||
<el-button link size="small" @click="row._showToken = !row._showToken">
|
||||
{{ row._showToken ? '隐藏' : '显示' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="CreatedAt" label="创建时间" width="170" align="center">
|
||||
<template #default="{ row }">{{ formatTime(row.CreatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="320" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="success" size="small" @click="openConsole(row)">控制台</el-button>
|
||||
<el-button v-if="!row.default" link type="warning" size="small" @click="handleSetDefault(row)">设为默认</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="primary" size="small" @click="goToGoods(row)">额度商品</el-button>
|
||||
<el-popconfirm :title="row.default ? '该服务为默认服务,删除前请先设置其他服务为默认' : '确认删除该服务?'" :disabled="row.default" @confirm="handleDelete(row)">
|
||||
<template #reference>
|
||||
<el-button link type="danger" size="small" :disabled="row.default">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.count"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="fetchList"
|
||||
@current-change="fetchList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="isEdit ? '编辑短信主控服务' : '新增短信主控服务'"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
|
||||
<el-form-item label="服务名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入服务名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明" prop="note">
|
||||
<el-input v-model="form.note" type="textarea" :rows="2" placeholder="服务说明(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务地址" prop="host">
|
||||
<el-input v-model="form.host" placeholder="https://sms.example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Service Token" prop="service_token">
|
||||
<el-input v-model="form.service_token" placeholder="sms-server 的 SERVICE_TOKEN" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getSmsServiceList,
|
||||
createSmsService,
|
||||
updateSmsService,
|
||||
deleteSmsService,
|
||||
setDefaultSmsService
|
||||
} from '@/api/admin/smsService.js'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const formRef = ref(null)
|
||||
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
count: 10,
|
||||
key: ''
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
note: '',
|
||||
host: '',
|
||||
service_token: ''
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
|
||||
host: [{ required: true, message: '请输入服务地址', trigger: 'blur' }],
|
||||
service_token: [{ required: true, message: '请输入 Service Token', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
return new Date(t).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
const maskToken = (token) => {
|
||||
if (!token) return '-'
|
||||
if (token.length <= 8) return '****'
|
||||
return token.slice(0, 4) + '****' + token.slice(-4)
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getSmsServiceList(queryParams)
|
||||
const body = res.data
|
||||
if (body.code === 200) {
|
||||
const d = body.data?.data || body.data || []
|
||||
tableData.value = (Array.isArray(d) ? d : []).map(item => ({ ...item, _showToken: false }))
|
||||
total.value = body.data?.all_count || 0
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
queryParams.key = ''
|
||||
queryParams.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
form.value = { id: null, name: '', note: '', host: '', service_token: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
isEdit.value = true
|
||||
form.value = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
note: row.note || '',
|
||||
host: row.host,
|
||||
service_token: row.serviceToken || ''
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
submitting.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (isEdit.value) params.append('id', form.value.id)
|
||||
params.append('name', form.value.name)
|
||||
params.append('note', form.value.note)
|
||||
params.append('host', form.value.host)
|
||||
params.append('service_token', form.value.service_token)
|
||||
|
||||
const fn = isEdit.value ? updateSmsService : createSmsService
|
||||
const res = await fn(params)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success(isEdit.value ? '更新成功' : '创建成功')
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '操作失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('操作失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('id', row.id)
|
||||
const res = await deleteSmsService(params)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetDefault = async (row) => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('id', row.id)
|
||||
const res = await setDefaultSmsService(params)
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success(`已将「${row.name}」设为默认短信服务`)
|
||||
fetchList()
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '设置失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error('设置默认服务失败')
|
||||
}
|
||||
}
|
||||
|
||||
const openConsole = (row) => {
|
||||
const base = (row.host || '').replace(/\/+$/, '')
|
||||
if (!base) return ElMessage.warning('该服务未配置地址')
|
||||
window.open(`${base}/login?serverToken=${encodeURIComponent(row.serviceToken || '')}`, '_blank')
|
||||
}
|
||||
|
||||
const goToGoods = (row) => {
|
||||
router.push({ path: '/sms/goods', query: { service_id: row.id, service_name: row.name } })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sms-service-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding: 20px 24px;
|
||||
background: linear-gradient(135deg, #f0f7ff 0%, #e8f4f8 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e0ecf5;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
background: #fafbfc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.service-name-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.name-text {
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.host-tag {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.token-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.token-mask {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: #c0c4cc;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.token-full {
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,549 @@
|
||||
<template>
|
||||
<div class="sms-signature-page">
|
||||
<div class="page-header">
|
||||
<div class="header-info">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#67c23a" stroke-width="1.8">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<path d="M9 15l2 2 4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="header-title">短信签名管理</h2>
|
||||
<p class="header-desc">管理用户提交的短信签名,审核通过后方可使用</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon> 新增签名
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="queryParams.service_id" placeholder="选择主控服务" style="width: 200px" @change="handleSearch">
|
||||
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
<el-select v-model="queryParams.status" placeholder="全部状态" clearable style="width: 140px" @change="handleSearch">
|
||||
<el-option label="草稿" :value="0" />
|
||||
<el-option label="审核中" :value="1" />
|
||||
<el-option label="已通过" :value="2" />
|
||||
<el-option label="已驳回" :value="3" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-if="filterUserInfo"
|
||||
:model-value="`${filterUserInfo.user_name} (ID: ${queryParams.user_id})`"
|
||||
readonly
|
||||
style="width: 200px"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon class="clear-icon" @click="clearFilterUser"><Close /></el-icon>
|
||||
</template>
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
<el-input v-else placeholder="筛选用户" readonly style="width: 140px" @click="filterUserSelectorVisible = true">
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 卡片列表 -->
|
||||
<div v-loading="loading" class="signature-cards">
|
||||
<div v-if="!tableData.length && !loading" class="empty-state">
|
||||
<el-empty description="暂无签名数据" :image-size="80" />
|
||||
</div>
|
||||
<div v-for="item in tableData" :key="item.ID" class="sig-card" :class="`status-${item.status}`">
|
||||
<div class="sig-card-header">
|
||||
<div class="sig-card-title">
|
||||
<span class="sig-title-text">{{ item.title }}</span>
|
||||
<el-tag :type="statusTagType(item.status)" size="small" effect="dark">{{ statusText(item.status) }}</el-tag>
|
||||
</div>
|
||||
<span class="sig-card-id">#{{ item.ID }}</span>
|
||||
</div>
|
||||
|
||||
<div class="sig-card-body">
|
||||
<div class="sig-card-info">
|
||||
<div class="sig-info-row">
|
||||
<span class="sig-label">申请人</span>
|
||||
<span class="sig-value">{{ item.applicant_name || '-' }}</span>
|
||||
</div>
|
||||
<div class="sig-info-row">
|
||||
<span class="sig-label">用户</span>
|
||||
<el-link type="primary" :underline="false" @click="$router.push({ path: '/user/detail', query: { user_id: item.user_id } })">ID: {{ item.user_id }}</el-link>
|
||||
</div>
|
||||
<div class="sig-info-row">
|
||||
<span class="sig-label">公司</span>
|
||||
<span class="sig-value">{{ item.applicant_company || '-' }}</span>
|
||||
</div>
|
||||
<div class="sig-info-row">
|
||||
<span class="sig-label">身份证</span>
|
||||
<span class="sig-value sig-id-card">{{ item.applicant_id_card || '-' }}</span>
|
||||
</div>
|
||||
<div class="sig-info-row">
|
||||
<span class="sig-label">创建时间</span>
|
||||
<span class="sig-value">{{ formatDate(item.CreatedAt) }}</span>
|
||||
</div>
|
||||
<div v-if="item.reject_reason" class="sig-info-row reject-row">
|
||||
<span class="sig-label">驳回原因</span>
|
||||
<span class="sig-value reject-text">{{ item.reject_reason }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sig-card-license">
|
||||
<el-image
|
||||
v-if="item.license_url"
|
||||
:src="item.license_url"
|
||||
:preview-src-list="[item.license_url]"
|
||||
fit="cover"
|
||||
class="license-img"
|
||||
preview-teleported
|
||||
/>
|
||||
<div v-else class="license-placeholder">
|
||||
<el-icon :size="24"><Picture /></el-icon>
|
||||
<span>无资质证明</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sig-card-footer">
|
||||
<el-button type="primary" size="small" @click="handleEdit(item)">编辑</el-button>
|
||||
<el-button v-if="item.status === 0" type="warning" size="small" @click="handleSubmit(item)">提交审核</el-button>
|
||||
<el-button v-if="item.status === 1" type="success" size="small" @click="handleApprove(item)">通过</el-button>
|
||||
<el-button v-if="item.status === 1" type="danger" size="small" plain @click="handleReject(item)">驳回</el-button>
|
||||
<el-button type="danger" size="small" plain @click="handleDelete(item)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.count"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
@size-change="fetchList"
|
||||
@current-change="fetchList"
|
||||
background
|
||||
class="pagination"
|
||||
/>
|
||||
|
||||
<!-- 新增/编辑对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增签名' : '编辑签名'" width="560px" append-to-body destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-width="110px">
|
||||
<el-form-item label="主控服务" prop="service_id">
|
||||
<el-select v-model="form.service_id" placeholder="选择服务" style="width: 100%" :disabled="dialogType === 'edit'">
|
||||
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户" prop="user_id">
|
||||
<el-input
|
||||
v-if="selectedUserInfo"
|
||||
:model-value="`${selectedUserInfo.user_name} (ID: ${form.user_id})`"
|
||||
readonly
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon v-if="dialogType === 'add'" class="clear-icon" @click="clearUser"><Close /></el-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button @click="userSelectorVisible = true" :disabled="dialogType === 'edit'"><el-icon><User /></el-icon></el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input v-else placeholder="请选择用户" readonly @click="openUserSelector">
|
||||
<template #append>
|
||||
<el-button @click="openUserSelector" :disabled="dialogType === 'edit'"><el-icon><User /></el-icon></el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="签名标题" prop="title">
|
||||
<el-input v-model="form.title" placeholder="如:某某公司" maxlength="30" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="申请人姓名">
|
||||
<el-input v-model="form.applicant_name" placeholder="申请人真实姓名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份证号">
|
||||
<el-input v-model="form.applicant_id_card" placeholder="申请人身份证号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司名称">
|
||||
<el-input v-model="form.applicant_company" placeholder="申请人公司(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="营业执照URL">
|
||||
<el-input v-model="form.license_url" placeholder="营业执照图片地址(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleFormSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 驳回原因对话框 -->
|
||||
<el-dialog v-model="rejectDialogVisible" title="驳回签名" width="420px" append-to-body>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="驳回原因">
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="confirmReject">确认驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 表单用户选择器 -->
|
||||
<UserListSelector
|
||||
v-model="userSelectorVisible"
|
||||
:current-user-id="form.user_id"
|
||||
@confirm="handleUserSelect"
|
||||
/>
|
||||
<!-- 筛选用户选择器 -->
|
||||
<UserListSelector
|
||||
v-model="filterUserSelectorVisible"
|
||||
:current-user-id="queryParams.user_id ? Number(queryParams.user_id) : undefined"
|
||||
@confirm="handleFilterUserSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, User, Close, Picture } from '@element-plus/icons-vue'
|
||||
import { formatDate } from '@/utils/tool'
|
||||
import {
|
||||
getSmsServiceList,
|
||||
getSmsSignatureList, createSmsSignature, updateSmsSignature, deleteSmsSignature,
|
||||
submitSmsSignature, approveSmsSignature, rejectSmsSignature
|
||||
} from '@/api/admin/smsService'
|
||||
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const serviceOptions = ref([])
|
||||
|
||||
const queryParams = reactive({ service_id: null, page: 1, count: 10, user_id: '', status: null })
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogType = ref('add')
|
||||
const formRef = ref(null)
|
||||
const form = reactive({ service_id: null, user_id: null, title: '', applicant_name: '', applicant_id_card: '', applicant_company: '', license_url: '' })
|
||||
const formRules = {
|
||||
service_id: [{ required: true, message: '请选择服务', trigger: 'change' }],
|
||||
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
|
||||
title: [{ required: true, message: '请输入签名标题', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const rejectDialogVisible = ref(false)
|
||||
const rejectReason = ref('')
|
||||
const currentRow = ref(null)
|
||||
|
||||
const userSelectorVisible = ref(false)
|
||||
const selectedUserInfo = ref(null)
|
||||
const filterUserSelectorVisible = ref(false)
|
||||
const filterUserInfo = ref(null)
|
||||
|
||||
const statusText = (s) => ({ 0: '草稿', 1: '审核中', 2: '已通过', 3: '已驳回' }[s] || '未知')
|
||||
const statusTagType = (s) => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' }[s] || 'info')
|
||||
|
||||
const loadServices = async () => {
|
||||
try {
|
||||
const res = await getSmsServiceList({ page: 1, count: 199 })
|
||||
const body = res.data
|
||||
if (body.code === 200) {
|
||||
const list = body.data?.data || body.data || []
|
||||
serviceOptions.value = Array.isArray(list) ? list : []
|
||||
if (serviceOptions.value.length && !queryParams.service_id) {
|
||||
queryParams.service_id = serviceOptions.value[0].id
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
if (!queryParams.service_id) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: queryParams.service_id, page: queryParams.page, count: queryParams.count }
|
||||
if (queryParams.user_id) params.user_id = Number(queryParams.user_id)
|
||||
if (queryParams.status !== null && queryParams.status !== '') params.status = queryParams.status
|
||||
const res = await getSmsSignatureList(params)
|
||||
if (res.data.code === 200) {
|
||||
const d = res.data.data
|
||||
tableData.value = d?.data || d || []
|
||||
total.value = d?.all_count || 0
|
||||
}
|
||||
} catch (e) { ElMessage.error('获取签名列表失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
const handleFilterUserSelect = (user) => {
|
||||
queryParams.user_id = String(user.user_id)
|
||||
filterUserInfo.value = user
|
||||
handleSearch()
|
||||
}
|
||||
const clearFilterUser = () => {
|
||||
queryParams.user_id = ''
|
||||
filterUserInfo.value = null
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handleSearch = () => { queryParams.page = 1; fetchList() }
|
||||
const handleReset = () => { queryParams.user_id = ''; queryParams.status = null; filterUserInfo.value = null; queryParams.page = 1; fetchList() }
|
||||
|
||||
const openUserSelector = () => {
|
||||
if (dialogType.value === 'edit') return
|
||||
userSelectorVisible.value = true
|
||||
}
|
||||
|
||||
const handleUserSelect = (user) => {
|
||||
form.user_id = user.user_id
|
||||
selectedUserInfo.value = user
|
||||
}
|
||||
|
||||
const clearUser = () => {
|
||||
form.user_id = null
|
||||
selectedUserInfo.value = null
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
selectedUserInfo.value = null
|
||||
Object.assign(form, { service_id: queryParams.service_id, user_id: null, title: '', applicant_name: '', applicant_id_card: '', applicant_company: '', license_url: '' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEdit = (row) => {
|
||||
dialogType.value = 'edit'
|
||||
selectedUserInfo.value = { user_id: row.user_id, user_name: `用户${row.user_id}` }
|
||||
Object.assign(form, {
|
||||
service_id: queryParams.service_id,
|
||||
signature_id: row.ID,
|
||||
user_id: row.user_id,
|
||||
title: row.title,
|
||||
applicant_name: row.applicant_name || '',
|
||||
applicant_id_card: row.applicant_id_card || '',
|
||||
applicant_company: row.applicant_company || '',
|
||||
license_url: row.license_url || ''
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
formRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const data = { ...form }
|
||||
let res
|
||||
if (dialogType.value === 'add') {
|
||||
res = await createSmsSignature(data)
|
||||
} else {
|
||||
res = await updateSmsSignature(data)
|
||||
}
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success(dialogType.value === 'add' ? '创建成功' : '修改成功')
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} else { ElMessage.error(res.data.message || '操作失败') }
|
||||
} catch (e) { ElMessage.error('操作失败') }
|
||||
finally { submitting.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = (row) => {
|
||||
ElMessageBox.confirm(`确认提交签名「${row.title}」进行审核?`, '提交审核', { type: 'info' }).then(async () => {
|
||||
try {
|
||||
const res = await submitSmsSignature({ service_id: queryParams.service_id, signature_id: row.ID })
|
||||
if (res.data.code === 200) { ElMessage.success('已提交审核'); fetchList() }
|
||||
else { ElMessage.error(res.data.message || '提交失败') }
|
||||
} catch { ElMessage.error('提交失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleApprove = (row) => {
|
||||
ElMessageBox.confirm(`确认通过签名「${row.title}」?`, '审核通过', { type: 'success' }).then(async () => {
|
||||
try {
|
||||
const res = await approveSmsSignature({ service_id: queryParams.service_id, signature_id: row.ID })
|
||||
if (res.data.code === 200) { ElMessage.success('已通过'); fetchList() }
|
||||
else { ElMessage.error(res.data.message || '操作失败') }
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleReject = (row) => {
|
||||
currentRow.value = row
|
||||
rejectReason.value = ''
|
||||
rejectDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmReject = async () => {
|
||||
if (!rejectReason.value.trim()) { ElMessage.warning('请输入驳回原因'); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await rejectSmsSignature({ service_id: queryParams.service_id, signature_id: currentRow.value.ID, reject_reason: rejectReason.value })
|
||||
if (res.data.code === 200) { ElMessage.success('已驳回'); rejectDialogVisible.value = false; fetchList() }
|
||||
else { ElMessage.error(res.data.message || '驳回失败') }
|
||||
} catch { ElMessage.error('驳回失败') }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确认删除签名「${row.title}」?`, '删除', { type: 'warning' }).then(async () => {
|
||||
try {
|
||||
const res = await deleteSmsSignature({ service_id: queryParams.service_id, signature_id: row.ID })
|
||||
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchList() }
|
||||
else { ElMessage.error(res.data.message || '删除失败') }
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadServices()
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sms-signature-page { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.header-info { display: flex; align-items: center; gap: 14px; }
|
||||
.header-icon { width: 48px; height: 48px; border-radius: 12px; background: #f0f9eb; display: flex; align-items: center; justify-content: center; }
|
||||
.header-title { margin: 0; font-size: 20px; color: #303133; }
|
||||
.header-desc { margin: 4px 0 0; font-size: 13px; color: #909399; }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
|
||||
.pagination { margin-top: 16px; justify-content: flex-end; }
|
||||
.clear-icon { cursor: pointer; color: #909399; transition: color 0.2s; }
|
||||
.clear-icon:hover { color: #f56c6c; }
|
||||
|
||||
/* 卡片列表 */
|
||||
.signature-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.sig-card {
|
||||
background: #fff;
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 10px;
|
||||
padding: 18px;
|
||||
transition: all 0.25s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.sig-card:hover {
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
border-color: #d9d9d9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.sig-card.status-1 { border-left: 3px solid #e6a23c; }
|
||||
.sig-card.status-2 { border-left: 3px solid #67c23a; }
|
||||
.sig-card.status-3 { border-left: 3px solid #f56c6c; }
|
||||
|
||||
.sig-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.sig-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.sig-title-text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
.sig-card-id {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.sig-card-body {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.sig-card-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.sig-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.sig-label {
|
||||
color: #909399;
|
||||
min-width: 56px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sig-value {
|
||||
color: #303133;
|
||||
word-break: break-all;
|
||||
}
|
||||
.sig-id-card {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
.reject-row .sig-value {
|
||||
color: #f56c6c;
|
||||
}
|
||||
.reject-text { color: #f56c6c; }
|
||||
|
||||
.sig-card-license {
|
||||
flex-shrink: 0;
|
||||
width: 90px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.license-img {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ebeef5;
|
||||
cursor: pointer;
|
||||
}
|
||||
.license-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 6px;
|
||||
background: #f5f7fa;
|
||||
border: 1px dashed #dcdfe6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: #c0c4cc;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sig-card-footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f2f5;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,632 @@
|
||||
<template>
|
||||
<div class="sms-template-page">
|
||||
<div class="page-header">
|
||||
<div class="header-info">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="#409eff" stroke-width="1.8">
|
||||
<path d="M4 4h16v16H4z" rx="2"/>
|
||||
<path d="M8 8h8M8 12h6M8 16h4" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="header-title">短信模板管理</h2>
|
||||
<p class="header-desc">管理用户短信模板的审核、推荐模板的维护</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务选择 -->
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="serviceId" placeholder="选择主控服务" style="width: 200px" @change="onServiceChange">
|
||||
<el-option v-for="s in serviceOptions" :key="s.id" :label="s.name" :value="s.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 主内容 Tabs -->
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<!-- 用户模板 -->
|
||||
<el-tab-pane label="用户模板" name="user">
|
||||
<div class="tab-toolbar">
|
||||
<div class="tab-filters">
|
||||
<el-select v-model="tplQuery.status" placeholder="全部状态" clearable style="width: 130px" @change="fetchTemplates">
|
||||
<el-option label="草稿" :value="0" />
|
||||
<el-option label="审核中" :value="1" />
|
||||
<el-option label="已通过" :value="2" />
|
||||
<el-option label="已驳回" :value="3" />
|
||||
</el-select>
|
||||
<el-input
|
||||
v-if="tplFilterUserInfo"
|
||||
:model-value="`${tplFilterUserInfo.user_name} (ID: ${tplQuery.user_id})`"
|
||||
readonly
|
||||
style="width: 200px"
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon class="clear-icon" @click="clearTplFilterUser"><Close /></el-icon>
|
||||
</template>
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
<el-input v-else placeholder="筛选用户" readonly style="width: 140px" @click="tplFilterUserSelectorVisible = true">
|
||||
<template #prefix><el-icon><User /></el-icon></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="fetchTemplates">搜索</el-button>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleAddTemplate">
|
||||
<el-icon><Plus /></el-icon> 新增模板
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="tplList" v-loading="tplLoading" stripe border style="width: 100%">
|
||||
<el-table-column prop="ID" label="ID" width="70" align="center" />
|
||||
<el-table-column label="用户ID" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-link type="primary" :underline="false" @click="$router.push({ path: '/user/detail', query: { user_id: row.user_id } })">{{ row.user_id }}</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="模板名称" min-width="140" />
|
||||
<el-table-column label="内容" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="row.content" placement="top" :show-after="300">
|
||||
<span class="content-cell">{{ row.content }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参数" width="150">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.params && row.params.length">
|
||||
<el-tag v-for="p in row.params" :key="p.key" size="small" style="margin: 2px">{{ p.key }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusText(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="驳回原因" min-width="130">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.reject_reason" class="reject-text">{{ row.reject_reason }}</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEditTemplate(row)">编辑</el-button>
|
||||
<el-button v-if="row.status === 0" type="warning" link @click="handleSubmitTpl(row)">提交审核</el-button>
|
||||
<el-button v-if="row.status === 1" type="success" link @click="handleApproveTpl(row)">通过</el-button>
|
||||
<el-button v-if="row.status === 1" type="danger" link @click="handleRejectTpl(row)">驳回</el-button>
|
||||
<el-button type="danger" link @click="handleDeleteTpl(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="tplQuery.page"
|
||||
v-model:page-size="tplQuery.count"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="tplTotal"
|
||||
@size-change="fetchTemplates"
|
||||
@current-change="fetchTemplates"
|
||||
background
|
||||
class="pagination"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 推荐模板 -->
|
||||
<el-tab-pane label="推荐模板" name="recommended">
|
||||
<div class="tab-toolbar">
|
||||
<div></div>
|
||||
<el-button type="primary" @click="handleAddRecommended">
|
||||
<el-icon><Plus /></el-icon> 新增推荐模板
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="recList" v-loading="recLoading" stripe border style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column prop="name" label="名称" min-width="140" />
|
||||
<el-table-column label="分类" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.category" size="small">{{ row.category }}</el-tag>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="内容" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip :content="row.content" placement="top" :show-after="300">
|
||||
<span class="content-cell">{{ row.content }}</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参数" width="150">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.params && row.params.length">
|
||||
<el-tag v-for="p in row.params" :key="p.key" size="small" style="margin: 2px">{{ p.key }}</el-tag>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEditRecommended(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="handleDeleteRecommended(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
v-model:current-page="recQuery.page"
|
||||
v-model:page-size="recQuery.count"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="recTotal"
|
||||
@size-change="fetchRecommended"
|
||||
@current-change="fetchRecommended"
|
||||
background
|
||||
class="pagination"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 用户模板表单对话框 -->
|
||||
<el-dialog v-model="tplDialogVisible" :title="tplDialogType === 'add' ? '新增模板' : '编辑模板'" width="640px" append-to-body destroy-on-close>
|
||||
<el-form ref="tplFormRef" :model="tplForm" :rules="tplFormRules" label-width="100px">
|
||||
<el-form-item label="用户" prop="user_id">
|
||||
<el-input
|
||||
v-if="tplSelectedUser"
|
||||
:model-value="`${tplSelectedUser.user_name} (ID: ${tplForm.user_id})`"
|
||||
readonly
|
||||
>
|
||||
<template #suffix>
|
||||
<el-icon v-if="tplDialogType === 'add'" class="clear-icon" @click="clearTplUser"><Close /></el-icon>
|
||||
</template>
|
||||
<template #append>
|
||||
<el-button @click="tplUserSelectorVisible = true" :disabled="tplDialogType === 'edit'"><el-icon><User /></el-icon></el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input v-else placeholder="请选择用户" readonly @click="openTplUserSelector">
|
||||
<template #append>
|
||||
<el-button @click="openTplUserSelector" :disabled="tplDialogType === 'edit'"><el-icon><User /></el-icon></el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板名称" prop="name">
|
||||
<el-input v-model="tplForm.name" placeholder="请输入模板名称" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="推荐模板">
|
||||
<el-select v-model="tplForm.recommended_id" placeholder="选择推荐模板(可选)" clearable style="width: 100%" @change="onRecommendedSelect">
|
||||
<el-option v-for="r in recOptionsCache" :key="r.id" :label="r.name" :value="r.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板参数">
|
||||
<div class="params-editor">
|
||||
<div v-for="(p, idx) in tplForm.paramsList" :key="idx" class="param-row">
|
||||
<el-input v-model="p.key" placeholder="参数名" style="width: 100px" />
|
||||
<el-select v-model="p.type" placeholder="类型" style="width: 100px">
|
||||
<el-option label="字符串" value="string" />
|
||||
<el-option label="数字" value="number" />
|
||||
<el-option label="时间" value="date" />
|
||||
</el-select>
|
||||
<el-input-number v-model="p.max_len" :min="1" :max="999" placeholder="长度" style="width: 100px" />
|
||||
<el-button type="danger" link @click="tplForm.paramsList.splice(idx, 1)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
<el-button type="primary" link @click="tplForm.paramsList.push({ key: '', type: 'string', max_len: 20 })">
|
||||
<el-icon><Plus /></el-icon> 添加参数
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板内容" prop="content">
|
||||
<el-input v-model="tplForm.content" type="textarea" :rows="4" placeholder="如:您的验证码为{code},有效期{time}分钟。" />
|
||||
<div v-if="tplForm.paramsList.length" class="insert-btns">
|
||||
<span class="insert-label">插入参数:</span>
|
||||
<el-button v-for="p in tplForm.paramsList.filter(x => x.key)" :key="p.key" size="small" @click="insertParam(p.key)">{{ '{' + p.key + '}' }}</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="tplDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitTplForm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 推荐模板表单对话框 -->
|
||||
<el-dialog v-model="recDialogVisible" :title="recDialogType === 'add' ? '新增推荐模板' : '编辑推荐模板'" width="600px" append-to-body destroy-on-close>
|
||||
<el-form ref="recFormRef" :model="recForm" :rules="recFormRules" label-width="100px">
|
||||
<el-form-item label="模板名称" prop="name">
|
||||
<el-input v-model="recForm.name" placeholder="请输入名称" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-input v-model="recForm.category" placeholder="如:验证码、通知、营销" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板参数">
|
||||
<div class="params-editor">
|
||||
<div v-for="(p, idx) in recForm.paramsList" :key="idx" class="param-row">
|
||||
<el-input v-model="p.key" placeholder="参数名" style="width: 100px" />
|
||||
<el-select v-model="p.type" placeholder="类型" style="width: 100px">
|
||||
<el-option label="字符串" value="string" />
|
||||
<el-option label="数字" value="number" />
|
||||
<el-option label="时间" value="date" />
|
||||
</el-select>
|
||||
<el-input-number v-model="p.max_len" :min="1" :max="999" placeholder="长度" style="width: 100px" />
|
||||
<el-button type="danger" link @click="recForm.paramsList.splice(idx, 1)"><el-icon><Delete /></el-icon></el-button>
|
||||
</div>
|
||||
<el-button type="primary" link @click="recForm.paramsList.push({ key: '', type: 'string', max_len: 20 })">
|
||||
<el-icon><Plus /></el-icon> 添加参数
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板内容" prop="content">
|
||||
<el-input v-model="recForm.content" type="textarea" :rows="4" placeholder="模板内容,参数使用 {key} 格式" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="recDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitRecForm">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 驳回对话框 -->
|
||||
<el-dialog v-model="rejectDialogVisible" title="驳回模板" width="420px" append-to-body>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="驳回原因">
|
||||
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rejectDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="confirmRejectTpl">确认驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 表单用户选择器 -->
|
||||
<UserListSelector
|
||||
v-model="tplUserSelectorVisible"
|
||||
:current-user-id="tplForm.user_id"
|
||||
@confirm="handleTplUserSelect"
|
||||
/>
|
||||
<!-- 筛选用户选择器 -->
|
||||
<UserListSelector
|
||||
v-model="tplFilterUserSelectorVisible"
|
||||
:current-user-id="tplQuery.user_id ? Number(tplQuery.user_id) : undefined"
|
||||
@confirm="handleTplFilterUserSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Delete, User, Close } from '@element-plus/icons-vue'
|
||||
import { formatDate } from '@/utils/tool'
|
||||
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||
import {
|
||||
getSmsServiceList,
|
||||
getSmsTemplateList, createSmsTemplate, updateSmsTemplate, deleteSmsTemplate,
|
||||
submitSmsTemplate, approveSmsTemplate, rejectSmsTemplate,
|
||||
getSmsRecommendedTemplateList, createSmsRecommendedTemplate, updateSmsRecommendedTemplate, deleteSmsRecommendedTemplate
|
||||
} from '@/api/admin/smsService'
|
||||
|
||||
const serviceOptions = ref([])
|
||||
const serviceId = ref(null)
|
||||
const activeTab = ref('user')
|
||||
const submitting = ref(false)
|
||||
|
||||
const statusText = (s) => ({ 0: '草稿', 1: '审核中', 2: '已通过', 3: '已驳回' }[s] || '未知')
|
||||
const statusTagType = (s) => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger' }[s] || 'info')
|
||||
|
||||
// ========== 服务选择 ==========
|
||||
const loadServices = async () => {
|
||||
try {
|
||||
const res = await getSmsServiceList({ page: 1, count: 199 })
|
||||
if (res.data.code === 200) {
|
||||
const list = res.data.data?.data || res.data.data || []
|
||||
serviceOptions.value = Array.isArray(list) ? list : []
|
||||
if (serviceOptions.value.length && !serviceId.value) {
|
||||
serviceId.value = serviceOptions.value[0].id
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
const onServiceChange = () => {
|
||||
fetchTemplates()
|
||||
fetchRecommended()
|
||||
}
|
||||
|
||||
// ========== 用户模板 ==========
|
||||
const tplLoading = ref(false)
|
||||
const tplList = ref([])
|
||||
const tplTotal = ref(0)
|
||||
const tplQuery = reactive({ page: 1, count: 10, user_id: '', status: null })
|
||||
const tplFilterUserSelectorVisible = ref(false)
|
||||
const tplFilterUserInfo = ref(null)
|
||||
|
||||
const handleTplFilterUserSelect = (user) => {
|
||||
tplQuery.user_id = String(user.user_id)
|
||||
tplFilterUserInfo.value = user
|
||||
fetchTemplates()
|
||||
}
|
||||
const clearTplFilterUser = () => {
|
||||
tplQuery.user_id = ''
|
||||
tplFilterUserInfo.value = null
|
||||
fetchTemplates()
|
||||
}
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
if (!serviceId.value) return
|
||||
tplLoading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, page: tplQuery.page, count: tplQuery.count }
|
||||
if (tplQuery.user_id) params.user_id = Number(tplQuery.user_id)
|
||||
if (tplQuery.status !== null && tplQuery.status !== '') params.status = tplQuery.status
|
||||
const res = await getSmsTemplateList(params)
|
||||
if (res.data.code === 200) {
|
||||
const d = res.data.data
|
||||
tplList.value = d?.data || d || []
|
||||
tplTotal.value = d?.all_count || 0
|
||||
}
|
||||
} catch { ElMessage.error('获取模板列表失败') }
|
||||
finally { tplLoading.value = false }
|
||||
}
|
||||
|
||||
// 模板表单
|
||||
const tplDialogVisible = ref(false)
|
||||
const tplDialogType = ref('add')
|
||||
const tplFormRef = ref(null)
|
||||
const tplForm = reactive({ user_id: null, name: '', content: '', paramsList: [], recommended_id: null, template_id: null })
|
||||
const tplUserSelectorVisible = ref(false)
|
||||
const tplSelectedUser = ref(null)
|
||||
|
||||
const openTplUserSelector = () => {
|
||||
if (tplDialogType.value === 'edit') return
|
||||
tplUserSelectorVisible.value = true
|
||||
}
|
||||
const handleTplUserSelect = (user) => {
|
||||
tplForm.user_id = user.user_id
|
||||
tplSelectedUser.value = user
|
||||
}
|
||||
const clearTplUser = () => {
|
||||
tplForm.user_id = null
|
||||
tplSelectedUser.value = null
|
||||
}
|
||||
const tplFormRules = {
|
||||
user_id: [{ required: true, message: '请输入用户ID', trigger: 'blur' }],
|
||||
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
|
||||
content: [{ required: true, message: '请输入模板内容', trigger: 'blur' }]
|
||||
}
|
||||
const recOptionsCache = ref([])
|
||||
|
||||
const handleAddTemplate = () => {
|
||||
tplDialogType.value = 'add'
|
||||
tplSelectedUser.value = null
|
||||
Object.assign(tplForm, { user_id: null, name: '', content: '', paramsList: [], recommended_id: null, template_id: null })
|
||||
loadRecOptionsCache()
|
||||
tplDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEditTemplate = (row) => {
|
||||
tplDialogType.value = 'edit'
|
||||
tplSelectedUser.value = { user_id: row.user_id, user_name: `用户${row.user_id}` }
|
||||
const params = Array.isArray(row.params) ? row.params.map(p => ({ ...p })) : []
|
||||
Object.assign(tplForm, { user_id: row.user_id, name: row.name, content: row.content, paramsList: params, recommended_id: row.recommended_id || null, template_id: row.ID })
|
||||
loadRecOptionsCache()
|
||||
tplDialogVisible.value = true
|
||||
}
|
||||
|
||||
const loadRecOptionsCache = async () => {
|
||||
if (!serviceId.value) return
|
||||
try {
|
||||
const res = await getSmsRecommendedTemplateList({ service_id: serviceId.value, page: 1, count: 100 })
|
||||
if (res.data.code === 200) {
|
||||
recOptionsCache.value = res.data.data?.data || res.data.data || []
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const onRecommendedSelect = (id) => {
|
||||
if (!id) return
|
||||
const rec = recOptionsCache.value.find(r => r.id === id)
|
||||
if (rec) {
|
||||
tplForm.content = rec.content
|
||||
tplForm.paramsList = Array.isArray(rec.params) ? rec.params.map(p => ({ ...p })) : []
|
||||
}
|
||||
}
|
||||
|
||||
const insertParam = (key) => {
|
||||
tplForm.content += `{${key}}`
|
||||
}
|
||||
|
||||
const submitTplForm = () => {
|
||||
tplFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const paramsJson = tplForm.paramsList.filter(p => p.key).length > 0
|
||||
? JSON.stringify(tplForm.paramsList.filter(p => p.key))
|
||||
: ''
|
||||
const data = {
|
||||
service_id: serviceId.value,
|
||||
user_id: tplForm.user_id,
|
||||
name: tplForm.name,
|
||||
content: tplForm.content
|
||||
}
|
||||
if (paramsJson) data.params = paramsJson
|
||||
if (tplForm.recommended_id) data.recommended_id = tplForm.recommended_id
|
||||
|
||||
let res
|
||||
if (tplDialogType.value === 'add') {
|
||||
res = await createSmsTemplate(data)
|
||||
} else {
|
||||
data.template_id = tplForm.template_id
|
||||
res = await updateSmsTemplate(data)
|
||||
}
|
||||
if (res.data.code === 200) { ElMessage.success('操作成功'); tplDialogVisible.value = false; fetchTemplates() }
|
||||
else { ElMessage.error(res.data.message || '操作失败') }
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
finally { submitting.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmitTpl = (row) => {
|
||||
ElMessageBox.confirm(`提交模板「${row.name}」审核?`, '提交审核', { type: 'info' }).then(async () => {
|
||||
try {
|
||||
const res = await submitSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
|
||||
if (res.data.code === 200) { ElMessage.success('已提交'); fetchTemplates() }
|
||||
else { ElMessage.error(res.data.message || '操作失败') }
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleApproveTpl = (row) => {
|
||||
ElMessageBox.confirm(`通过模板「${row.name}」?`, '审核通过', { type: 'success' }).then(async () => {
|
||||
try {
|
||||
const res = await approveSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
|
||||
if (res.data.code === 200) { ElMessage.success('已通过'); fetchTemplates() }
|
||||
else { ElMessage.error(res.data.message || '操作失败') }
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const rejectDialogVisible = ref(false)
|
||||
const rejectReason = ref('')
|
||||
const currentRejectRow = ref(null)
|
||||
|
||||
const handleRejectTpl = (row) => {
|
||||
currentRejectRow.value = row
|
||||
rejectReason.value = ''
|
||||
rejectDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmRejectTpl = async () => {
|
||||
if (!rejectReason.value.trim()) { ElMessage.warning('请输入驳回原因'); return }
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await rejectSmsTemplate({ service_id: serviceId.value, template_id: currentRejectRow.value.ID, reject_reason: rejectReason.value })
|
||||
if (res.data.code === 200) { ElMessage.success('已驳回'); rejectDialogVisible.value = false; fetchTemplates() }
|
||||
else { ElMessage.error(res.data.message || '操作失败') }
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
const handleDeleteTpl = (row) => {
|
||||
ElMessageBox.confirm(`确认删除模板「${row.name}」?`, '删除', { type: 'warning' }).then(async () => {
|
||||
try {
|
||||
const res = await deleteSmsTemplate({ service_id: serviceId.value, template_id: row.ID })
|
||||
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchTemplates() }
|
||||
else { ElMessage.error(res.data.message || '删除失败') }
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ========== 推荐模板 ==========
|
||||
const recLoading = ref(false)
|
||||
const recList = ref([])
|
||||
const recTotal = ref(0)
|
||||
const recQuery = reactive({ page: 1, count: 10 })
|
||||
|
||||
const fetchRecommended = async () => {
|
||||
if (!serviceId.value) return
|
||||
recLoading.value = true
|
||||
try {
|
||||
const res = await getSmsRecommendedTemplateList({ service_id: serviceId.value, page: recQuery.page, count: recQuery.count })
|
||||
if (res.data.code === 200) {
|
||||
const d = res.data.data
|
||||
recList.value = d?.data || d || []
|
||||
recTotal.value = d?.all_count || 0
|
||||
}
|
||||
} catch { ElMessage.error('获取推荐模板失败') }
|
||||
finally { recLoading.value = false }
|
||||
}
|
||||
|
||||
const recDialogVisible = ref(false)
|
||||
const recDialogType = ref('add')
|
||||
const recFormRef = ref(null)
|
||||
const recForm = reactive({ name: '', content: '', paramsList: [], category: '', template_id: null })
|
||||
const recFormRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
content: [{ required: true, message: '请输入内容', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const handleAddRecommended = () => {
|
||||
recDialogType.value = 'add'
|
||||
Object.assign(recForm, { name: '', content: '', paramsList: [], category: '', template_id: null })
|
||||
recDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleEditRecommended = (row) => {
|
||||
recDialogType.value = 'edit'
|
||||
const params = Array.isArray(row.params) ? row.params.map(p => ({ ...p })) : []
|
||||
Object.assign(recForm, { name: row.name, content: row.content, paramsList: params, category: row.category || '', template_id: row.id })
|
||||
recDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitRecForm = () => {
|
||||
recFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const paramsJson = recForm.paramsList.filter(p => p.key).length > 0
|
||||
? JSON.stringify(recForm.paramsList.filter(p => p.key))
|
||||
: ''
|
||||
const data = {
|
||||
service_id: serviceId.value,
|
||||
name: recForm.name,
|
||||
content: recForm.content
|
||||
}
|
||||
if (paramsJson) data.params = paramsJson
|
||||
if (recForm.category) data.category = recForm.category
|
||||
|
||||
let res
|
||||
if (recDialogType.value === 'add') {
|
||||
res = await createSmsRecommendedTemplate(data)
|
||||
} else {
|
||||
data.template_id = recForm.template_id
|
||||
res = await updateSmsRecommendedTemplate(data)
|
||||
}
|
||||
if (res.data.code === 200) { ElMessage.success('操作成功'); recDialogVisible.value = false; fetchRecommended() }
|
||||
else { ElMessage.error(res.data.message || '操作失败') }
|
||||
} catch { ElMessage.error('操作失败') }
|
||||
finally { submitting.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteRecommended = (row) => {
|
||||
ElMessageBox.confirm(`确认删除推荐模板「${row.name}」?`, '删除', { type: 'warning' }).then(async () => {
|
||||
try {
|
||||
const res = await deleteSmsRecommendedTemplate({ service_id: serviceId.value, template_id: row.id })
|
||||
if (res.data.code === 200) { ElMessage.success('删除成功'); fetchRecommended() }
|
||||
else { ElMessage.error(res.data.message || '删除失败') }
|
||||
} catch { ElMessage.error('删除失败') }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
onMounted(async () => {
|
||||
await loadServices()
|
||||
fetchTemplates()
|
||||
fetchRecommended()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sms-template-page { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.header-info { display: flex; align-items: center; gap: 14px; }
|
||||
.header-icon { width: 48px; height: 48px; border-radius: 12px; background: #ecf5ff; display: flex; align-items: center; justify-content: center; }
|
||||
.header-title { margin: 0; font-size: 20px; color: #303133; }
|
||||
.header-desc { margin: 4px 0 0; font-size: 13px; color: #909399; }
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 16px; align-items: center; }
|
||||
.tab-toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.tab-filters { display: flex; gap: 10px; align-items: center; }
|
||||
.pagination { margin-top: 16px; justify-content: flex-end; }
|
||||
.content-cell { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; font-size: 13px; cursor: pointer; }
|
||||
.reject-text { color: #f56c6c; font-size: 12px; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
|
||||
.params-editor { width: 100%; }
|
||||
.param-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||
.insert-btns { margin-top: 8px; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.insert-label { font-size: 12px; color: #909399; }
|
||||
.clear-icon { cursor: pointer; color: #909399; transition: color 0.2s; }
|
||||
.clear-icon:hover { color: #f56c6c; }
|
||||
</style>
|
||||
@@ -193,43 +193,75 @@
|
||||
</el-tabs>
|
||||
|
||||
<!-- ==================== 模板编辑弹窗 ==================== -->
|
||||
<el-dialog v-model="tplDialogVisible" :title="tplForm.id ? '编辑模板' : '新增模板'" width="640px" append-to-body destroy-on-close>
|
||||
<el-form :model="tplForm" :rules="tplRules" ref="tplFormRef" label-width="90px" label-position="right">
|
||||
<el-form-item label="模板名称" prop="name">
|
||||
<el-input v-model="tplForm.name" placeholder="例:用户注册通知" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板标识" prop="tag">
|
||||
<el-input v-model="tplForm.tag" placeholder="例:user_register_notify" maxlength="100" :disabled="!!tplForm.id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板类型" prop="type">
|
||||
<el-radio-group v-model="tplForm.type">
|
||||
<el-radio value="email">
|
||||
<span class="radio-with-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="#e6a23c"/></svg>
|
||||
邮件
|
||||
</span>
|
||||
</el-radio>
|
||||
<el-radio value="phone">
|
||||
<span class="radio-with-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="#67c23a"/></svg>
|
||||
短信
|
||||
</span>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="可用参数" v-if="tplFormArgs.length > 0">
|
||||
<div class="args-btn-group">
|
||||
<el-button v-for="arg in tplFormArgs" :key="arg" size="small" @click="insertArg(arg)" class="arg-insert-btn">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:3px"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor"/></svg>
|
||||
{{ arg }}
|
||||
<el-dialog v-model="tplDialogVisible" :title="tplForm.id ? '编辑模板' : '新增模板'" width="1060px" append-to-body destroy-on-close @opened="onTplDialogOpened">
|
||||
<div class="tpl-dialog-body">
|
||||
<!-- 左侧:表单 -->
|
||||
<div class="tpl-dialog-left">
|
||||
<el-form :model="tplForm" :rules="tplRules" ref="tplFormRef" label-width="90px" label-position="right">
|
||||
<el-form-item label="模板名称" prop="name">
|
||||
<el-input v-model="tplForm.name" placeholder="例:用户注册通知" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板标识" prop="tag">
|
||||
<el-input v-model="tplForm.tag" placeholder="例:user_register_notify" maxlength="100" :disabled="!!tplForm.id" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板类型" prop="type">
|
||||
<el-radio-group v-model="tplForm.type">
|
||||
<el-radio value="email">
|
||||
<span class="radio-with-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" fill="#e6a23c"/></svg>
|
||||
邮件
|
||||
</span>
|
||||
</el-radio>
|
||||
<el-radio value="phone">
|
||||
<span class="radio-with-icon">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H5.17L4 17.17V4h16v12z" fill="#67c23a"/></svg>
|
||||
短信
|
||||
</span>
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="可用参数" v-if="tplFormArgs.length > 0">
|
||||
<div class="args-btn-group">
|
||||
<el-button v-for="arg in tplFormArgs" :key="arg" size="small" @click="insertArg(arg)" class="arg-insert-btn">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" style="margin-right:3px"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" fill="currentColor"/></svg>
|
||||
{{ arg }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="form-hint">点击参数按钮可将其插入到模板内容光标处</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板内容" prop="content">
|
||||
<el-input ref="contentInputRef" v-model="tplForm.content" type="textarea" :rows="8" placeholder="模板内容,点击上方参数按钮插入变量" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:渲染预览 -->
|
||||
<div class="tpl-dialog-right">
|
||||
<div class="preview-header">
|
||||
<span class="preview-title">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z" fill="currentColor"/></svg>
|
||||
渲染预览
|
||||
</span>
|
||||
<el-button size="small" link type="primary" @click="fetchPreview" :loading="previewLoading">
|
||||
<el-icon><Refresh /></el-icon> 刷新
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="form-hint">点击参数按钮可将其插入到模板内容光标处</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板内容" prop="content">
|
||||
<el-input ref="contentInputRef" v-model="tplForm.content" type="textarea" :rows="6" placeholder="模板内容,点击上方参数按钮插入变量" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="preview-body" v-loading="previewLoading">
|
||||
<template v-if="previewData.rendered">
|
||||
<div class="preview-rendered" :class="{ 'html-mode': tplForm.type === 'email' }" v-html="previewRenderedHtml"></div>
|
||||
</template>
|
||||
<div v-else-if="previewError" class="preview-empty">
|
||||
<el-icon :size="32" color="#f56c6c"><WarningFilled /></el-icon>
|
||||
<p>{{ previewError }}</p>
|
||||
</div>
|
||||
<div v-else class="preview-empty">
|
||||
<el-icon :size="32" color="#c0c4cc"><View /></el-icon>
|
||||
<p>填写模板标识和类型后自动加载预览</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="tplDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="tplSubmitting" @click="handleSubmitTpl">确定</el-button>
|
||||
@@ -239,12 +271,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, View, WarningFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getNoticeChannelList, updateNoticeChannel,
|
||||
getNoticeTemplateList, addNoticeTemplate, updateNoticeTemplate, deleteNoticeTemplate
|
||||
getNoticeTemplateList, addNoticeTemplate, updateNoticeTemplate, deleteNoticeTemplate,
|
||||
previewNoticeTemplate
|
||||
} from '@/api/admin/noticeChannel'
|
||||
|
||||
// ==================== 渠道配置 ====================
|
||||
@@ -410,6 +443,70 @@ const handleDeleteTpl = async (row) => {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ==================== 渲染预览 ====================
|
||||
const previewLoading = ref(false)
|
||||
const previewError = ref('')
|
||||
const previewData = ref({ rendered: '', default_args: null })
|
||||
|
||||
const previewRenderedHtml = computed(() => {
|
||||
const text = previewData.value.rendered || ''
|
||||
if (tplForm.value.type === 'email') return text
|
||||
return text.replace(/\n/g, '<br>')
|
||||
})
|
||||
|
||||
let previewTimer = null
|
||||
const debouncedFetchPreview = () => {
|
||||
clearTimeout(previewTimer)
|
||||
previewTimer = setTimeout(() => fetchPreview(), 600)
|
||||
}
|
||||
|
||||
const fetchPreview = async () => {
|
||||
const tag = tplForm.value.tag?.trim()
|
||||
const type = tplForm.value.type
|
||||
const content = tplForm.value.content?.trim()
|
||||
|
||||
if (!content && (!tag || !type)) {
|
||||
previewData.value = { rendered: '', default_args: null }
|
||||
previewError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
previewLoading.value = true
|
||||
previewError.value = ''
|
||||
try {
|
||||
const params = {}
|
||||
if (content) {
|
||||
params.content = content
|
||||
} else {
|
||||
params.tag = tag
|
||||
params.type = type
|
||||
}
|
||||
const res = await previewNoticeTemplate(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body.data) {
|
||||
previewData.value = body.data
|
||||
} else {
|
||||
previewData.value = { rendered: '', default_args: null }
|
||||
previewError.value = body?.message || '渲染失败'
|
||||
}
|
||||
} catch {
|
||||
previewData.value = { rendered: '', default_args: null }
|
||||
previewError.value = '请求失败'
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onTplDialogOpened = () => {
|
||||
if (tplForm.value.tag && tplForm.value.type) {
|
||||
fetchPreview()
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => tplForm.value.tag, debouncedFetchPreview)
|
||||
watch(() => tplForm.value.type, debouncedFetchPreview)
|
||||
watch(() => tplForm.value.content, debouncedFetchPreview)
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
const formatTime = (t) => {
|
||||
if (!t) return '-'
|
||||
@@ -495,6 +592,129 @@ onMounted(() => {
|
||||
.form-hint { margin-top: 6px; font-size: 12px; color: #a8abb2; line-height: 1.4; }
|
||||
.form-hint code { background: #f5f7fa; padding: 1px 5px; border-radius: 3px; font-size: 11px; color: #606266; }
|
||||
|
||||
/* ===== 弹窗 ===== */
|
||||
/* ===== 弹窗双栏布局 ===== */
|
||||
.radio-with-icon { display: inline-flex; align-items: center; gap: 4px; }
|
||||
|
||||
.tpl-dialog-body {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.tpl-dialog-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tpl-dialog-right {
|
||||
width: 380px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: #f5f7fa;
|
||||
border-bottom: 1px solid #e4e7ed;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.preview-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.preview-rendered {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #303133;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.preview-rendered.html-mode {
|
||||
white-space: normal;
|
||||
transform: scale(0.75);
|
||||
transform-origin: top left;
|
||||
width: 133.33%;
|
||||
max-height: 426px;
|
||||
}
|
||||
|
||||
.preview-args {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.preview-args-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.preview-args-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-arg-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-arg-key {
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Consolas','Monaco',monospace;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-arg-val {
|
||||
color: #606266;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.preview-empty p {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1649
-15
File diff suppressed because it is too large
Load Diff
@@ -1130,13 +1130,7 @@ const goToUserDetail = () => {
|
||||
const goToUserGoods = () => {
|
||||
const goods = ticketInfo.value?.userGoods
|
||||
if (!goods) return
|
||||
const tag = (goods.tag || goods.good?.tag || '').toLowerCase()
|
||||
let href
|
||||
if (tag === '云服务器') {
|
||||
href = router.resolve({ path: '/user-goods/vm-detail', query: { id: goods.id } }).href
|
||||
} else {
|
||||
href = router.resolve({ name: 'UserGoodsDetail', params: { id: goods.id } }).href
|
||||
}
|
||||
const href = router.resolve({ name: 'UserGoodsDetail', params: { id: goods.id } }).href
|
||||
window.open(href, '_blank')
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="uvm-detail">
|
||||
<div class="page-header">
|
||||
<div class="uvm-detail" :class="{ 'is-embedded': embedded }">
|
||||
<div class="page-header" v-if="!embedded">
|
||||
<div class="header-left">
|
||||
<el-button link @click="goBack"><el-icon><ArrowLeft /></el-icon>返回列表</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
@@ -36,6 +36,7 @@
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<el-button v-if="isVmGoods" size="small" type="primary" @click="handleVnc">VNC</el-button>
|
||||
<el-button v-if="isVmGoods" size="small" type="success" @click="handleSshConnect" :disabled="!vmPublicIpList.length || isWindows">SSH连接</el-button>
|
||||
<el-button v-if="isVmGoods" size="small" type="success" @click="handlePower('start')" :disabled="vm?.status === 'running'">启动</el-button>
|
||||
<el-button v-if="isVmGoods" size="small" type="warning" @click="handlePower('reboot')">重启</el-button>
|
||||
<el-button v-if="isVmGoods" size="small" type="danger" @click="handlePower('stop')" :disabled="vm?.status === 'stopped' || vm?.status === 'stop'">关机</el-button>
|
||||
@@ -1181,6 +1182,11 @@ import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, onActivated
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument, Edit, Delete } from '@element-plus/icons-vue'
|
||||
|
||||
const props = defineProps({
|
||||
embedded: { type: Boolean, default: false },
|
||||
goodsId: { type: Number, default: 0 }
|
||||
})
|
||||
import {
|
||||
getUserVmDetail, getUserVmVnc, getUserVmHostImages,
|
||||
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
|
||||
@@ -1210,7 +1216,7 @@ import * as echarts from 'echarts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userGoodsId = ref(parseInt(route.query.id) || 0)
|
||||
const userGoodsId = ref(props.embedded ? props.goodsId : (parseInt(route.query.id) || 0))
|
||||
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
@@ -1318,7 +1324,7 @@ const vmStatusType = (s) => vmStatusTypeUtil(s)
|
||||
const vmStatusLabel = (s) => vmStatusLabelUtil(s)
|
||||
const taskStatusType = (s) => ({ running: 'primary', completed: 'success', ready: 'success', failed: 'danger', error: 'danger', pending: 'info' }[s] || 'info')
|
||||
|
||||
const goBack = () => router.back()
|
||||
const goBack = () => { if (!props.embedded) router.back() }
|
||||
|
||||
const loadDetail = async () => {
|
||||
if (!userGoodsId.value) return
|
||||
@@ -1407,6 +1413,15 @@ const loadSgLockInfo = async () => {
|
||||
const vncVisible = ref(false)
|
||||
const vncLoading = ref(false)
|
||||
const vncResult = ref(null)
|
||||
const handleSshConnect = () => {
|
||||
const host = vmPublicIpList.value[0]
|
||||
if (!host) return ElMessage.warning('无可用公网 IP')
|
||||
const username = isWindows.value ? 'Administrator' : 'root'
|
||||
const password = vm.value?.root_password || ''
|
||||
const encodedPwd = btoa(password)
|
||||
window.open(`https://webssh1.007yjs.com/?hostname=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodedPwd}`, '_blank')
|
||||
}
|
||||
|
||||
const handleVnc = async () => {
|
||||
vncVisible.value = true; vncLoading.value = true; vncResult.value = null
|
||||
try {
|
||||
@@ -2663,16 +2678,22 @@ const reloadWithNewId = (newId) => {
|
||||
loadDetail()
|
||||
}
|
||||
|
||||
watch(() => route.query.id, (newVal) => {
|
||||
if (route.path === '/user-goods/vm-detail') {
|
||||
if (props.embedded) {
|
||||
watch(() => props.goodsId, (newVal) => {
|
||||
reloadWithNewId(newVal || 0)
|
||||
})
|
||||
} else {
|
||||
watch(() => route.query.id, (newVal) => {
|
||||
reloadWithNewId(parseInt(newVal) || 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => { loadDetail() })
|
||||
|
||||
onActivated(() => {
|
||||
reloadWithNewId(parseInt(route.query.id) || 0)
|
||||
if (!props.embedded) {
|
||||
reloadWithNewId(parseInt(route.query.id) || 0)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { disposeCharts() })
|
||||
@@ -2680,6 +2701,7 @@ onBeforeUnmount(() => { disposeCharts() })
|
||||
|
||||
<style scoped>
|
||||
.uvm-detail { padding: 0; }
|
||||
.uvm-detail.is-embedded .main-content { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: #fff; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 0; }
|
||||
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
|
||||
|
||||
@@ -28,11 +28,20 @@
|
||||
<el-option label="未绑定" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" style="width:120px" @change="handleSearch">
|
||||
<el-option label="全部" value="all" />
|
||||
<el-option label="正常" value="normal" />
|
||||
<el-option label="已到期" value="expired" />
|
||||
<el-option label="待开通" value="pending" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>查询
|
||||
</el-button>
|
||||
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.bound = null; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
|
||||
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; query.bound = null; query.status = 'all'; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="action-bar">
|
||||
@@ -73,6 +82,15 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tooltip v-if="isDeleted(row)" :content="`删除于 ${deletedTimeText(row)}`" placement="top">
|
||||
<el-tag size="small" type="danger">已删除</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tag v-else-if="row.status === 'pending'" size="small" type="info">待开通</el-tag>
|
||||
<el-tag v-else size="small" type="success">正常</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="续费价格" width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.renewPrice">¥{{ (row.renewPrice / 100).toFixed(2) }}</span>
|
||||
@@ -939,7 +957,7 @@ const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, count: 10, bound: null, user_id: '', good_id: '', key: '' })
|
||||
const query = reactive({ page: 1, count: 10, bound: null, user_id: '', good_id: '', key: '', status: 'all' })
|
||||
const filterUserName = ref('')
|
||||
const filterGoodName = ref('')
|
||||
const showFilterUserSelector = ref(false)
|
||||
@@ -961,6 +979,13 @@ const formatExpireTime = (t) => {
|
||||
return d.format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
|
||||
const isDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
|
||||
const deletedTimeText = (row) => {
|
||||
const t = row?.deleteAt || row?.DeleteAt || row?.deleted_at
|
||||
return t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
}
|
||||
|
||||
const loadGoods = async () => {
|
||||
// 不再需要加载商品列表,直接加载用户商品
|
||||
loadList()
|
||||
@@ -976,6 +1001,7 @@ const loadList = async () => {
|
||||
if (query.user_id) params.user_id = query.user_id
|
||||
if (query.good_id) params.good_id = query.good_id
|
||||
if (query.key) params.key = query.key
|
||||
if (query.status && query.status !== 'all') params.status = query.status
|
||||
const res = await getUserGoodsList(params)
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
@@ -1001,7 +1027,7 @@ const handleSearch = () => { query.page = 1; loadList() }
|
||||
|
||||
const goDetail = (row) => {
|
||||
if (!row.id) return
|
||||
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
|
||||
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
|
||||
}
|
||||
|
||||
// ---- 新建 ----
|
||||
|
||||
+363
-18
@@ -181,18 +181,29 @@
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="订单列表" name="3">
|
||||
<el-table :data="userOrderList" v-loading="orderListLoading" stripe style="width: 100%">
|
||||
<el-table-column prop="id" label="订单ID" width="100" />
|
||||
<el-table-column prop="name" label="商品名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="price" label="金额" width="100">
|
||||
<template #default="{row}">¥{{ (row.price / 100).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100">
|
||||
<el-table-column prop="id" label="订单ID" width="80" />
|
||||
<el-table-column prop="name" label="商品名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="getOrderStatusType(row.status)" size="small">{{ getOrderStatusText(row.status) }}</el-tag>
|
||||
<el-tag :type="getOrderTypeTag(row.type)" size="small">{{ getOrderTypeText(row.type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="160">
|
||||
<template #default="{row}">{{ formatDate(row.created_at) }}</template>
|
||||
<el-table-column label="金额" width="120">
|
||||
<template #default="{row}">
|
||||
<span>¥{{ (row.price / 100).toFixed(2) }}</span>
|
||||
<div v-if="row.renewPrice" style="font-size:12px;color:#909399">续费价: ¥{{ (row.renewPrice / 100).toFixed(2) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="getOrderStatusType(row.state)" size="small">{{ getOrderStatusText(row.state) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" width="160">
|
||||
<template #default="{row}">{{ row.expireTime ? formatDate(row.expireTime) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="160">
|
||||
<template #default="{row}">{{ formatDate(row.CreatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" fixed="right">
|
||||
<template #default="scope">
|
||||
@@ -256,6 +267,13 @@
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="90">
|
||||
<template #default="{row}">
|
||||
<el-tag v-if="isGoodsDeleted(row)" size="small" type="danger">已删除</el-tag>
|
||||
<el-tag v-else-if="isGoodsExpired(row)" size="small" type="warning">已到期</el-tag>
|
||||
<el-tag v-else size="small" type="success">正常</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" min-width="140">
|
||||
<template #default="{row}">{{ formatDate(row.expireTime) }}</template>
|
||||
</el-table-column>
|
||||
@@ -276,6 +294,64 @@
|
||||
</div>
|
||||
<el-empty v-else description="暂无已购商品" :image-size="100" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="代金券" name="6">
|
||||
<div class="voucher-action-bar">
|
||||
<el-button type="primary" size="small" @click="handleAddVoucher">
|
||||
<el-icon><Plus /></el-icon>添加代金券
|
||||
</el-button>
|
||||
<el-button type="success" size="small" @click="fetchUserVoucherListData">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="userVoucherList" v-loading="voucherListLoading" stripe style="width: 100%">
|
||||
<el-table-column prop="Id" label="ID" width="70" />
|
||||
<el-table-column label="代金券名称" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{row}">{{ row.discount?.name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面额" min-width="100">
|
||||
<template #default="{row}">
|
||||
<span class="voucher-amount">¥{{ row.discount?.amount ? (row.discount.amount / 100).toFixed(2) : '0.00' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="已使用/最大" min-width="100">
|
||||
<template #default="{row}">
|
||||
{{ row.useTimes || 0 }} / {{ row.maxUseTimes || '∞' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="80">
|
||||
<template #default="{row}">
|
||||
<el-tag :type="getVoucherStatusType(row)" size="small">
|
||||
{{ getVoucherStatusText(row) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过期时间" min-width="140">
|
||||
<template #default="{row}">{{ formatVoucherDate(row.expireAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="140">
|
||||
<template #default="{row}">{{ formatDate(row.CreatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="{row}">
|
||||
<el-button type="warning" link size="small" @click="handleEditVoucher(row)">编辑</el-button>
|
||||
<el-divider direction="vertical" />
|
||||
<el-button type="danger" link size="small" @click="handleDeleteVoucher(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-wrapper" v-if="voucherListTotal > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="voucherListPage"
|
||||
v-model:page-size="voucherListPageSize"
|
||||
:total="voucherListTotal"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, prev, pager, next"
|
||||
@size-change="handleVoucherListSizeChange"
|
||||
@current-change="handleVoucherListPageChange"
|
||||
/>
|
||||
</div>
|
||||
<el-empty v-if="!voucherListLoading && !userVoucherList.length" description="暂无代金券" :image-size="100" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -451,7 +527,7 @@
|
||||
<el-option label="正式环境" value="production">
|
||||
<div class="env-option">
|
||||
<span>正式环境</span>
|
||||
<span class="env-url">www.007yjs.com</span>
|
||||
<span class="env-url">console.007yjs.com</span>
|
||||
</div>
|
||||
</el-option>
|
||||
<el-option label="测试环境" value="test">
|
||||
@@ -478,6 +554,49 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 代金券添加/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="voucherDialogVisible"
|
||||
:title="voucherDialogType === 'add' ? '添加代金券' : '编辑代金券'"
|
||||
width="500px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form ref="voucherFormRef" :model="voucherForm" :rules="voucherFormRules" label-width="120px">
|
||||
<el-form-item label="代金券" prop="discount_id" v-if="voucherDialogType === 'add'">
|
||||
<el-select v-model="voucherForm.discount_id" placeholder="请选择代金券" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in discountCouponOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (¥${(item.amount/100).toFixed(2)})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="已使用次数" prop="use_times" v-if="voucherDialogType === 'edit'">
|
||||
<el-input-number v-model="voucherForm.use_times" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大使用次数" prop="max_use_times" v-if="voucherDialogType === 'edit'">
|
||||
<el-input-number v-model="voucherForm.max_use_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="过期时间" prop="expire_at" v-if="voucherDialogType === 'edit'">
|
||||
<el-date-picker
|
||||
v-model="voucherForm.expire_at"
|
||||
type="datetime"
|
||||
placeholder="选择过期时间"
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
value-format="X"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="voucherDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitVoucherForm">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 管理员权限管理对话框 -->
|
||||
<el-dialog v-model="adminDialogVisible" title="修改管理员权限" width="500px" append-to-body>
|
||||
<el-form :model="adminForm" label-width="100px">
|
||||
@@ -518,7 +637,7 @@ import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||
import {
|
||||
ArrowLeft, Refresh, Edit as EditIcon, Delete, Wallet, Avatar, Lock,
|
||||
UserFilled, Document, Clock, List, Switch, User, Camera, Upload,
|
||||
UploadFilled, Key, Monitor, Setting, Close
|
||||
UploadFilled, Key, Monitor, Setting, Close, Plus
|
||||
} from '@element-plus/icons-vue'
|
||||
import { getUserGroupList, getUserBalanceCount } from '@/api/admin/user'
|
||||
import { getFileDetail } from '@/api/admin/file'
|
||||
@@ -531,6 +650,13 @@ import { getAdminGroupList } from '@/api/admin/group'
|
||||
import { getOrderList } from '@/api/admin/order'
|
||||
import { getTickerList } from '@/api/ticket'
|
||||
import { getUserGoodsList } from '@/api/admin/product'
|
||||
import {
|
||||
getUserVoucherList,
|
||||
allocateVoucher,
|
||||
updateUserVoucher,
|
||||
deleteUserVoucher,
|
||||
getDiscountCodeList
|
||||
} from '@/api/admin/discount'
|
||||
|
||||
const Edit = EditIcon
|
||||
const route = useRoute()
|
||||
@@ -578,11 +704,41 @@ const ticketListPageSize = ref(10)
|
||||
|
||||
// 已购商品列表相关
|
||||
const userGoodsList = ref([])
|
||||
// 判断用户商品是否已删除(后端返回 deleteAt 字段,有值即已删除)
|
||||
const isGoodsDeleted = (row) => !!(row?.deleteAt || row?.DeleteAt || row?.deleted_at)
|
||||
// 判断用户商品是否已到期(永久商品除外)
|
||||
const isGoodsExpired = (row) => {
|
||||
const t = row?.expireTime || row?.expire_time
|
||||
if (!t) return false
|
||||
const d = new Date(t)
|
||||
return d.getFullYear() >= 2000 && d.getTime() < Date.now()
|
||||
}
|
||||
const goodsListLoading = ref(false)
|
||||
const goodsListTotal = ref(0)
|
||||
const goodsListPage = ref(1)
|
||||
const goodsListPageSize = ref(10)
|
||||
|
||||
// 代金券列表相关
|
||||
const userVoucherList = ref([])
|
||||
const voucherListLoading = ref(false)
|
||||
const voucherListTotal = ref(0)
|
||||
const voucherListPage = ref(1)
|
||||
const voucherListPageSize = ref(10)
|
||||
const voucherDialogVisible = ref(false)
|
||||
const voucherDialogType = ref('add')
|
||||
const voucherFormRef = ref(null)
|
||||
const discountCouponOptions = ref([])
|
||||
const voucherForm = reactive({
|
||||
discount_id: undefined,
|
||||
id: undefined,
|
||||
use_times: 0,
|
||||
max_use_times: 0,
|
||||
expire_at: undefined
|
||||
})
|
||||
const voucherFormRules = {
|
||||
discount_id: [{ required: true, message: '请选择代金券', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 用户余额相关
|
||||
const userBalance = ref({
|
||||
balance: 0,
|
||||
@@ -680,7 +836,7 @@ const selectedEnvironment = ref('')
|
||||
|
||||
// 环境配置
|
||||
const environments = {
|
||||
production: 'https://www.007yjs.com',
|
||||
production: 'https://console.007yjs.com',
|
||||
test: 'https://apiserver.s1f.ren',
|
||||
local: 'http://localhost:5173'
|
||||
}
|
||||
@@ -707,6 +863,8 @@ const handleTabClick = (tab) => {
|
||||
fetchUserTicketList()
|
||||
} else if (tab.props.name === '5') {
|
||||
fetchUserGoodsList()
|
||||
} else if (tab.props.name === '6') {
|
||||
fetchUserVoucherListData()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1017,7 +1175,6 @@ const fetchUserOrderList = async () => {
|
||||
page: orderListPage.value,
|
||||
count: orderListPageSize.value
|
||||
})
|
||||
console.log('111',res)
|
||||
if (res.data.code === 200) {
|
||||
userOrderList.value = res.data.data.list || []
|
||||
orderListTotal.value = res.data.data.all_count || 0
|
||||
@@ -1100,6 +1257,169 @@ const handleGoodsListPageChange = (page) => {
|
||||
fetchUserGoodsList()
|
||||
}
|
||||
|
||||
// 获取用户代金券列表
|
||||
const fetchUserVoucherListData = async () => {
|
||||
if (!route.query.user_id) return
|
||||
voucherListLoading.value = true
|
||||
try {
|
||||
const res = await getUserVoucherList({
|
||||
user_id: route.query.user_id,
|
||||
page: voucherListPage.value,
|
||||
count: voucherListPageSize.value
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
userVoucherList.value = res.data.data?.data || []
|
||||
voucherListTotal.value = res.data.data?.all_count || 0
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('获取代金券列表失败')
|
||||
} finally {
|
||||
voucherListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVoucherListSizeChange = (size) => {
|
||||
voucherListPageSize.value = size
|
||||
voucherListPage.value = 1
|
||||
fetchUserVoucherListData()
|
||||
}
|
||||
|
||||
const handleVoucherListPageChange = (page) => {
|
||||
voucherListPage.value = page
|
||||
fetchUserVoucherListData()
|
||||
}
|
||||
|
||||
// 代金券状态判断
|
||||
const getVoucherStatusText = (row) => {
|
||||
if (row.useTimes >= row.maxUseTimes && row.maxUseTimes > 0) return '已用完'
|
||||
const expireAt = row.expireAt ? new Date(row.expireAt).getTime() : 0
|
||||
if (expireAt > 0 && new Date(row.expireAt).getFullYear() !== 1970 && expireAt < Date.now()) return '已过期'
|
||||
return '可使用'
|
||||
}
|
||||
|
||||
const getVoucherStatusType = (row) => {
|
||||
if (row.useTimes >= row.maxUseTimes && row.maxUseTimes > 0) return 'info'
|
||||
const expireAt = row.expireAt ? new Date(row.expireAt).getTime() : 0
|
||||
if (expireAt > 0 && new Date(row.expireAt).getFullYear() !== 1970 && expireAt < Date.now()) return 'warning'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
// 格式化代金券日期(过滤零值日期)
|
||||
const formatVoucherDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
if (isNaN(date.getTime()) || date.getFullYear() === 1970 || date.getFullYear() === 1) return '-'
|
||||
return formatDate(dateStr)
|
||||
}
|
||||
|
||||
// 获取代金券选项(用于添加对话框)
|
||||
const fetchDiscountCouponOptions = async () => {
|
||||
try {
|
||||
const res = await getDiscountCodeList({ discount_type: 'coupon', page: 1, count: 100 })
|
||||
if (res.data.code === 200) {
|
||||
discountCouponOptions.value = res.data.data?.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取代金券选项失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加代金券
|
||||
const handleAddVoucher = () => {
|
||||
voucherDialogType.value = 'add'
|
||||
voucherDialogVisible.value = true
|
||||
Object.assign(voucherForm, {
|
||||
discount_id: undefined,
|
||||
id: undefined,
|
||||
use_times: 0,
|
||||
max_use_times: 0,
|
||||
expire_at: undefined
|
||||
})
|
||||
voucherFormRef.value?.resetFields()
|
||||
fetchDiscountCouponOptions()
|
||||
}
|
||||
|
||||
// 编辑代金券
|
||||
const handleEditVoucher = (row) => {
|
||||
voucherDialogType.value = 'edit'
|
||||
voucherDialogVisible.value = true
|
||||
|
||||
let expireTime = undefined
|
||||
if (row.expireAt && row.expireAt !== '0001-01-01T00:00:00Z') {
|
||||
const date = new Date(row.expireAt)
|
||||
if (!isNaN(date.getTime()) && date.getFullYear() !== 1970) {
|
||||
expireTime = Math.floor(date.getTime() / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(voucherForm, {
|
||||
discount_id: row.discountId,
|
||||
id: row.Id,
|
||||
use_times: row.useTimes || 0,
|
||||
max_use_times: row.maxUseTimes || 0,
|
||||
expire_at: expireTime
|
||||
})
|
||||
}
|
||||
|
||||
// 删除代金券
|
||||
const handleDeleteVoucher = (row) => {
|
||||
const name = row.discount?.name || '该代金券'
|
||||
ElMessageBox.confirm(`确认删除代金券「${name}」吗?`, '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteUserVoucher({
|
||||
user_id: route.query.user_id,
|
||||
id: row.Id
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchUserVoucherListData()
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.message || '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 提交代金券表单
|
||||
const submitVoucherForm = () => {
|
||||
voucherFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
let res
|
||||
if (voucherDialogType.value === 'add') {
|
||||
res = await allocateVoucher({
|
||||
user_id: route.query.user_id,
|
||||
code_id: voucherForm.discount_id
|
||||
})
|
||||
} else {
|
||||
res = await updateUserVoucher({
|
||||
user_id: route.query.user_id,
|
||||
id: voucherForm.id,
|
||||
discount_id: voucherForm.discount_id,
|
||||
use_times: voucherForm.use_times,
|
||||
max_use_times: voucherForm.max_use_times,
|
||||
expire_at: voucherForm.expire_at
|
||||
})
|
||||
}
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success(voucherDialogType.value === 'add' ? '添加成功' : '修改成功')
|
||||
voucherDialogVisible.value = false
|
||||
fetchUserVoucherListData()
|
||||
} else {
|
||||
ElMessage.error(res.data.message || '操作失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取用户余额信息
|
||||
const fetchUserBalance = async () => {
|
||||
if (!route.query.user_id) return
|
||||
@@ -1119,14 +1439,25 @@ const fetchUserBalance = async () => {
|
||||
}
|
||||
|
||||
// 订单状态
|
||||
const getOrderStatusText = (status) => {
|
||||
const getOrderStatusText = (state) => {
|
||||
const map = { 0: '待支付', 1: '已支付', 2: '已取消', 3: '已退款', 4: '已完成' }
|
||||
return map[status] || '未知'
|
||||
return map[state] || '未知'
|
||||
}
|
||||
|
||||
const getOrderStatusType = (status) => {
|
||||
const getOrderStatusType = (state) => {
|
||||
const map = { 0: 'warning', 1: 'success', 2: 'info', 3: 'danger', 4: 'success' }
|
||||
return map[status] || 'info'
|
||||
return map[state] || 'info'
|
||||
}
|
||||
|
||||
// 订单类型
|
||||
const getOrderTypeText = (type) => {
|
||||
const map = { create: '新购', renew: '续费', upgrade: '升级' }
|
||||
return map[type] || type || '未知'
|
||||
}
|
||||
|
||||
const getOrderTypeTag = (type) => {
|
||||
const map = { create: 'primary', renew: 'success', upgrade: 'warning' }
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
// 工单状态
|
||||
@@ -1144,7 +1475,7 @@ const getTicketStatusType = (status) => {
|
||||
const handleViewOrder = (row) => {
|
||||
router.push({
|
||||
path: '/order/list',
|
||||
query: { order_id: row.order_id }
|
||||
query: { order_id: row.id }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1413,6 +1744,7 @@ const fetchActiveTabData = () => {
|
||||
else if (tab === '3') fetchUserOrderList()
|
||||
else if (tab === '4') fetchUserTicketList()
|
||||
else if (tab === '5') fetchUserGoodsList()
|
||||
else if (tab === '6') fetchUserVoucherListData()
|
||||
}
|
||||
|
||||
const loadUserData = async () => {
|
||||
@@ -1831,4 +2163,17 @@ onActivated(() => {
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 代金券操作栏 */
|
||||
.voucher-action-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.voucher-amount {
|
||||
color: #f56c6c;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
{{ row.create_time || row.CreateTime || row.CreatedAt || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<el-table-column label="操作" width="340" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" link @click="handleEdit(row)">
|
||||
@@ -94,6 +94,9 @@
|
||||
<el-button type="success" link @click="handleViewMembers(row)">
|
||||
<el-icon><User /></el-icon>成员
|
||||
</el-button>
|
||||
<el-button type="warning" link @click="handleViewDiscount(row)">
|
||||
<el-icon><Present /></el-icon>优惠
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="handleDelete(row)">
|
||||
<el-icon><Delete /></el-icon>删除
|
||||
</el-button>
|
||||
@@ -288,13 +291,181 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 用户组优惠管理对话框 -->
|
||||
<el-dialog
|
||||
v-model="discountDialogVisible"
|
||||
:title="`优惠管理 - ${currentGroupName}`"
|
||||
width="900px"
|
||||
append-to-body
|
||||
class="custom-dialog"
|
||||
>
|
||||
<div class="discount-action-bar">
|
||||
<el-button type="primary" @click="handleAddDiscount">
|
||||
<el-icon><Plus /></el-icon>新增优惠绑定
|
||||
</el-button>
|
||||
<el-button type="success" @click="fetchDiscountList">
|
||||
<el-icon><Refresh /></el-icon>刷新
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="discountLoading"
|
||||
:data="discountList"
|
||||
style="width: 100%"
|
||||
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="绑定对象" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.good && row.good.id" type="primary" effect="plain">
|
||||
商品:{{ row.good.name }} (ID:{{ row.good.id }})
|
||||
</el-tag>
|
||||
<el-tag v-else-if="row.goodGroup && row.goodGroup.id" type="warning" effect="plain">
|
||||
商品组:{{ row.goodGroup.name }} (ID:{{ row.goodGroup.id }})
|
||||
</el-tag>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="固定抵扣额" width="130">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.amount">¥{{ (row.amount / 100).toFixed(2) }}</span>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="百分比抵扣" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.percentage">{{ (row.percentage / 100).toFixed(0) }}%</span>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEditDiscount(row)">编辑</el-button>
|
||||
<el-button type="danger" link @click="handleDeleteDiscount(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无优惠绑定" :image-size="80" />
|
||||
</template>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="discountTotal > 0"
|
||||
v-model:current-page="discountPage"
|
||||
v-model:page-size="discountPageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="discountTotal"
|
||||
@size-change="handleDiscountSizeChange"
|
||||
@current-change="handleDiscountPageChange"
|
||||
background
|
||||
class="discount-pagination"
|
||||
/>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 用户组优惠 新增/编辑 对话框 -->
|
||||
<el-dialog
|
||||
v-model="discountFormVisible"
|
||||
:title="discountFormType === 'add' ? '新增优惠绑定' : '编辑优惠绑定'"
|
||||
width="620px"
|
||||
append-to-body
|
||||
>
|
||||
<el-form
|
||||
ref="discountFormRef"
|
||||
:model="discountForm"
|
||||
:rules="discountFormRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<!-- 新增模式:折叠层级选择器 -->
|
||||
<el-form-item v-if="discountFormType === 'add'" label="选择商品">
|
||||
<div class="goods-tree-wrapper">
|
||||
<div class="goods-tree-toolbar">
|
||||
<span class="tree-tip">勾选商品组或商品(仅可选一个)</span>
|
||||
<div class="tree-summary">
|
||||
已选: <b>{{ discountTreeChecked.name || '无' }}</b>
|
||||
</div>
|
||||
</div>
|
||||
<el-tree
|
||||
ref="discountTreeRef"
|
||||
:props="discountTreeProps"
|
||||
:load="loadDiscountTreeNode"
|
||||
lazy
|
||||
show-checkbox
|
||||
check-strictly
|
||||
node-key="key"
|
||||
class="goods-tree"
|
||||
@check="handleDiscountTreeCheck"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<span class="tree-node">
|
||||
<el-tag size="small" :type="data.nodeType === 'group' ? 'warning' : 'primary'" effect="plain">
|
||||
{{ data.nodeType === 'group' ? '组' : '品' }}
|
||||
</el-tag>
|
||||
<span class="tree-node-label">{{ data.label }}</span>
|
||||
<span class="tree-node-id">ID: {{ data.rawId }}</span>
|
||||
<span v-if="data.nodeType === 'product' && data.price != null" class="tree-node-price">
|
||||
¥{{ (data.price / 100).toFixed(2) }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 编辑模式:保留选择器 -->
|
||||
<template v-if="discountFormType === 'edit'">
|
||||
<el-form-item label="绑定类型" prop="bind_type">
|
||||
<el-radio-group v-model="discountForm.bind_type" @change="handleBindTypeChange">
|
||||
<el-radio label="good">商品</el-radio>
|
||||
<el-radio label="good_group">商品组</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="discountForm.bind_type === 'good'" label="选择商品" prop="good_id">
|
||||
<el-select v-model="discountForm.good_id" placeholder="请选择商品" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in productOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (ID:${item.id})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="选择商品组" prop="good_group_id">
|
||||
<el-select v-model="discountForm.good_group_id" placeholder="请选择商品组" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in productGroupOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (ID:${item.id})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item label="固定抵扣额" prop="amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="0表示不使用" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="百分比抵扣" prop="percentage">
|
||||
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="0表示不使用" style="width: 100%" />
|
||||
<div style="color:#909399;font-size:12px;margin-top:4px">固定抵扣额与百分比抵扣二选一填写</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="discountFormVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitDiscountForm">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Edit, User, Delete, Connection, Close } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getUserGroupList,
|
||||
getUserGroupMemberList,
|
||||
@@ -303,6 +474,13 @@ import {
|
||||
deleteUserGroup,
|
||||
addUserGroupMember
|
||||
} from '@/api/admin/user'
|
||||
import {
|
||||
getUserGroupDiscountList,
|
||||
addUserGroupDiscount,
|
||||
updateUserGroupDiscount,
|
||||
deleteUserGroupDiscount
|
||||
} from '@/api/admin/userGroupDiscount'
|
||||
import { getProductList, getProductGroupList } from '@/api/admin/product'
|
||||
import { formatTime } from '@/utils/tool'
|
||||
import UserGroupSelector from '@/components/admin/UserGroupSelector.vue'
|
||||
|
||||
@@ -624,6 +802,301 @@ const clearHigherGroup = () => {
|
||||
selectedHigherGroupInfo.value = null
|
||||
}
|
||||
|
||||
/** -------------------- 用户组优惠管理 -------------------- */
|
||||
const discountDialogVisible = ref(false)
|
||||
const discountFormVisible = ref(false)
|
||||
const discountLoading = ref(false)
|
||||
const discountList = ref([])
|
||||
const discountTotal = ref(0)
|
||||
const discountPage = ref(1)
|
||||
const discountPageSize = ref(10)
|
||||
const discountFormType = ref('add')
|
||||
const discountFormRef = ref(null)
|
||||
const currentGroupId = ref(undefined)
|
||||
const currentGroupName = ref('')
|
||||
const productOptions = ref([])
|
||||
const productGroupOptions = ref([])
|
||||
|
||||
const discountForm = reactive({
|
||||
id: undefined,
|
||||
bind_type: 'good',
|
||||
good_id: undefined,
|
||||
good_group_id: undefined,
|
||||
amount: 0,
|
||||
percentage: 0
|
||||
})
|
||||
|
||||
const discountFormRules = {
|
||||
bind_type: [{ required: true, message: '请选择绑定类型', trigger: 'change' }],
|
||||
good_id: [{ required: true, message: '请选择商品', trigger: 'change' }],
|
||||
good_group_id: [{ required: true, message: '请选择商品组', trigger: 'change' }]
|
||||
}
|
||||
|
||||
// 折叠层级选择器相关
|
||||
const discountTreeRef = ref(null)
|
||||
const discountTreeProps = {
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
isLeaf: 'isLeaf'
|
||||
}
|
||||
const discountTreeChecked = reactive({ name: '', nodeType: '', rawId: undefined })
|
||||
|
||||
// 折叠层级懒加载
|
||||
const loadDiscountTreeNode = async (node, resolve) => {
|
||||
try {
|
||||
if (node.level === 0) {
|
||||
const res = await getProductGroupList({ level: 1 })
|
||||
if (res.data.code === 200) {
|
||||
const groups = res.data.data?.data || []
|
||||
return resolve(groups.map(buildDiscountGroupNode))
|
||||
}
|
||||
return resolve([])
|
||||
}
|
||||
|
||||
if (node.data?.nodeType === 'group') {
|
||||
const groupId = node.data.rawId
|
||||
const childLevel = (node.data.level || 1) + 1
|
||||
const tasks = [
|
||||
getProductList({ good_group_id: groupId, delete: false })
|
||||
]
|
||||
if (node.data.existSub) {
|
||||
tasks.push(getProductGroupList({ parent_id: groupId, level: childLevel }))
|
||||
}
|
||||
const results = await Promise.all(tasks)
|
||||
|
||||
const productNodes = (results[0].data.code === 200 ? (results[0].data.data?.data || []) : [])
|
||||
.map(buildDiscountProductNode)
|
||||
|
||||
let groupNodes = []
|
||||
if (node.data.existSub && results[1]?.data.code === 200) {
|
||||
groupNodes = (results[1].data.data?.data || []).map(buildDiscountGroupNode)
|
||||
}
|
||||
|
||||
return resolve([...groupNodes, ...productNodes])
|
||||
}
|
||||
|
||||
return resolve([])
|
||||
} catch (error) {
|
||||
console.error('加载层级数据失败:', error)
|
||||
return resolve([])
|
||||
}
|
||||
}
|
||||
|
||||
const buildDiscountGroupNode = (group) => ({
|
||||
key: `group_${group.id}`,
|
||||
rawId: group.id,
|
||||
nodeType: 'group',
|
||||
label: group.name,
|
||||
level: group.level || 1,
|
||||
existSub: group.existSub || false,
|
||||
isLeaf: false
|
||||
})
|
||||
|
||||
const buildDiscountProductNode = (product) => ({
|
||||
key: `product_${product.id}`,
|
||||
rawId: product.id,
|
||||
nodeType: 'product',
|
||||
label: product.name,
|
||||
price: product.price,
|
||||
isLeaf: true
|
||||
})
|
||||
|
||||
// 树勾选:只允许单选
|
||||
const handleDiscountTreeCheck = (data, { checkedKeys }) => {
|
||||
if (checkedKeys.length > 1) {
|
||||
discountTreeRef.value?.setCheckedKeys([data.key])
|
||||
}
|
||||
const nodes = discountTreeRef.value?.getCheckedNodes() || []
|
||||
if (nodes.length > 0) {
|
||||
const node = nodes[0]
|
||||
discountTreeChecked.name = node.label
|
||||
discountTreeChecked.nodeType = node.nodeType
|
||||
discountTreeChecked.rawId = node.rawId
|
||||
} else {
|
||||
discountTreeChecked.name = ''
|
||||
discountTreeChecked.nodeType = ''
|
||||
discountTreeChecked.rawId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
// 加载商品/商品组选项
|
||||
const loadProductOptions = async () => {
|
||||
try {
|
||||
const [pRes, gRes] = await Promise.all([
|
||||
getProductList({ page: 1, count: 100 }),
|
||||
getProductGroupList({ page: 1, count: 100 })
|
||||
])
|
||||
if (pRes.data.code === 200) {
|
||||
productOptions.value = pRes.data.data?.data || pRes.data.data || []
|
||||
}
|
||||
if (gRes.data.code === 200) {
|
||||
productGroupOptions.value = gRes.data.data?.data || gRes.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载商品选项失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户组优惠列表
|
||||
const fetchDiscountList = async () => {
|
||||
if (!currentGroupId.value) return
|
||||
discountLoading.value = true
|
||||
try {
|
||||
const res = await getUserGroupDiscountList({
|
||||
user_group_id: currentGroupId.value,
|
||||
page: discountPage.value,
|
||||
count: discountPageSize.value
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
const resData = res.data.data
|
||||
if (Array.isArray(resData)) {
|
||||
discountList.value = resData
|
||||
discountTotal.value = res.data.all_count || resData.length
|
||||
} else {
|
||||
discountList.value = resData?.data || []
|
||||
discountTotal.value = resData?.all_count || discountList.value.length
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户组优惠列表失败:', error)
|
||||
ElMessage.error('获取用户组优惠列表失败')
|
||||
} finally {
|
||||
discountLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDiscountSizeChange = (size) => {
|
||||
discountPageSize.value = size
|
||||
discountPage.value = 1
|
||||
fetchDiscountList()
|
||||
}
|
||||
|
||||
const handleDiscountPageChange = (page) => {
|
||||
discountPage.value = page
|
||||
fetchDiscountList()
|
||||
}
|
||||
|
||||
// 打开优惠管理对话框
|
||||
const handleViewDiscount = (row) => {
|
||||
currentGroupId.value = row.group_id || row.GroupId || row.id || row.Id
|
||||
currentGroupName.value = row.group_name || row.name || row.Name || ''
|
||||
discountDialogVisible.value = true
|
||||
discountPage.value = 1
|
||||
loadProductOptions()
|
||||
fetchDiscountList()
|
||||
}
|
||||
|
||||
// 绑定类型切换时清空对端选择
|
||||
const handleBindTypeChange = () => {
|
||||
discountForm.good_id = undefined
|
||||
discountForm.good_group_id = undefined
|
||||
}
|
||||
|
||||
// 新增优惠绑定
|
||||
const handleAddDiscount = () => {
|
||||
discountFormType.value = 'add'
|
||||
discountFormVisible.value = true
|
||||
Object.assign(discountForm, {
|
||||
id: undefined,
|
||||
bind_type: 'good',
|
||||
good_id: undefined,
|
||||
good_group_id: undefined,
|
||||
amount: 0,
|
||||
percentage: 0
|
||||
})
|
||||
Object.assign(discountTreeChecked, { name: '', nodeType: '', rawId: undefined })
|
||||
discountFormRef.value?.resetFields()
|
||||
nextTick(() => {
|
||||
discountTreeRef.value?.setCheckedKeys([])
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑优惠绑定
|
||||
const handleEditDiscount = (row) => {
|
||||
discountFormType.value = 'edit'
|
||||
discountFormVisible.value = true
|
||||
const isGroup = !!(row.goodGroup && row.goodGroup.id)
|
||||
Object.assign(discountForm, {
|
||||
id: row.id,
|
||||
bind_type: isGroup ? 'good_group' : 'good',
|
||||
good_id: (row.good && row.good.id) ? row.good.id : undefined,
|
||||
good_group_id: (row.goodGroup && row.goodGroup.id) ? row.goodGroup.id : undefined,
|
||||
amount: row.amount ? row.amount / 100 : 0,
|
||||
percentage: row.percentage ? row.percentage / 100 : 0
|
||||
})
|
||||
loadProductOptions()
|
||||
}
|
||||
|
||||
// 删除优惠绑定
|
||||
const handleDeleteDiscount = (row) => {
|
||||
ElMessageBox.confirm('确认删除该优惠绑定吗?', '警告', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await deleteUserGroupDiscount({ discount_id: row.id })
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success('删除成功')
|
||||
fetchDiscountList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || '删除失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 提交优惠绑定表单
|
||||
const submitDiscountForm = () => {
|
||||
discountFormRef.value?.validate(async (valid) => {
|
||||
if (!valid && discountFormType.value === 'edit') return
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.append('user_group_id', currentGroupId.value)
|
||||
|
||||
if (discountFormType.value === 'add') {
|
||||
// 新增模式:从树中获取勾选项
|
||||
if (!discountTreeChecked.rawId) {
|
||||
ElMessage.warning('请选择一个商品或商品组')
|
||||
return
|
||||
}
|
||||
if (discountTreeChecked.nodeType === 'group') {
|
||||
params.append('good_group_id', discountTreeChecked.rawId)
|
||||
} else {
|
||||
params.append('good_id', discountTreeChecked.rawId)
|
||||
}
|
||||
} else {
|
||||
// 编辑模式:从表单获取
|
||||
if (discountForm.bind_type === 'good') {
|
||||
params.append('good_id', discountForm.good_id)
|
||||
} else {
|
||||
params.append('good_group_id', discountForm.good_group_id)
|
||||
}
|
||||
params.append('id', discountForm.id)
|
||||
}
|
||||
|
||||
params.append('amount', Math.round((discountForm.amount || 0) * 100))
|
||||
params.append('percentage', Math.round((discountForm.percentage || 0) * 100))
|
||||
|
||||
let res
|
||||
if (discountFormType.value === 'add') {
|
||||
res = await addUserGroupDiscount(params)
|
||||
} else {
|
||||
res = await updateUserGroupDiscount(params)
|
||||
}
|
||||
if (res.data.code === 200) {
|
||||
ElMessage.success(discountFormType.value === 'add' ? '新增成功' : '修改成功')
|
||||
discountFormVisible.value = false
|
||||
fetchDiscountList()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
ElMessage.error(error.response?.data?.message || '操作失败')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchGroupList()
|
||||
@@ -696,6 +1169,71 @@ onMounted(() => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.discount-action-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.discount-pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* 折叠层级选择器样式 */
|
||||
.goods-tree-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid #e1e8ed;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.goods-tree-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #fafbfc;
|
||||
border-bottom: 1px solid #e1e8ed;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.tree-summary b {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.goods-tree {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tree-node-label {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.tree-node-id {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.tree-node-price {
|
||||
color: #f56c6c;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
/* 表格样式优化 */
|
||||
:deep(.el-table) {
|
||||
border: none;
|
||||
|
||||
+11
-12
@@ -15,17 +15,21 @@
|
||||
class="search-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<span class="search-label">身份</span>
|
||||
<el-select v-model="queryParams.is_admin" placeholder="全部" clearable class="search-input-small" style="width: 110px">
|
||||
<el-option label="管理员" :value="true" />
|
||||
<el-option label="普通用户" :value="false" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<span class="search-label">用户ID</span>
|
||||
<el-input
|
||||
:model-value="jumpUserName || (jumpUserId ? jumpUserId : '')"
|
||||
v-model="jumpUserId"
|
||||
placeholder="输入ID跳转"
|
||||
readonly
|
||||
clearable
|
||||
class="search-input-small"
|
||||
style="cursor:pointer"
|
||||
@click="showJumpUserSelector = true"
|
||||
@clear="jumpUserId = ''; jumpUserName = ''"
|
||||
@keyup.enter="handleJumpToUser"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-buttons">
|
||||
@@ -334,11 +338,6 @@
|
||||
:current-user-id="userForm.recommend_id"
|
||||
@confirm="handleRecommendUserConfirm"
|
||||
/>
|
||||
<!-- 筛选用户ID选择器 -->
|
||||
<UserListSelector
|
||||
v-model="showJumpUserSelector"
|
||||
@confirm="u => { jumpUserId = String(u.user_id); jumpUserName = u.user_name || `用户 #${u.user_id}` }"
|
||||
/>
|
||||
|
||||
<!-- 修改头像对话框 -->
|
||||
<el-dialog
|
||||
@@ -663,12 +662,11 @@ const router = useRouter()
|
||||
|
||||
// 跳转用户ID
|
||||
const jumpUserId = ref('')
|
||||
const jumpUserName = ref('')
|
||||
const showJumpUserSelector = ref(false)
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
key: '',
|
||||
is_admin: undefined,
|
||||
page: 1,
|
||||
count: 10
|
||||
})
|
||||
@@ -872,6 +870,7 @@ const handleQuery = () => {
|
||||
// 重置查询
|
||||
const resetQuery = () => {
|
||||
queryParams.key = ''
|
||||
queryParams.is_admin = undefined
|
||||
queryParams.page = 1
|
||||
fetchUserList()
|
||||
}
|
||||
|
||||
@@ -556,6 +556,9 @@
|
||||
<el-tab-pane label="备份管理" name="backup">
|
||||
<BackupManage v-if="hostTabLoaded['backup']" ref="backupManageRef" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="回收站" name="recycleBin">
|
||||
<RecycleBinManage v-if="hostTabLoaded['recycleBin']" ref="recycleBinManageRef" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="虚拟机监控" name="vmMonitor">
|
||||
<VmMonitor v-if="hostTabLoaded['vmMonitor']" ref="vmMonitorRef" />
|
||||
</el-tab-pane>
|
||||
@@ -954,6 +957,7 @@ import VolumeManage from '@/views/virtualization/VolumeManage.vue'
|
||||
import VmManage from '@/views/virtualization/VmManage.vue'
|
||||
import SnapshotManage from '@/views/virtualization/SnapshotManage.vue'
|
||||
import BackupManage from '@/views/virtualization/BackupManage.vue'
|
||||
import RecycleBinManage from '@/views/virtualization/RecycleBinManage.vue'
|
||||
import VmMonitor from '@/views/virtualization/VmMonitor.vue'
|
||||
import { useTagsViewStore } from '@/store/tagsViewStore'
|
||||
import UserListSelector from '@/components/admin/UserListSelector.vue'
|
||||
@@ -969,7 +973,7 @@ const serviceName = computed(() => route.query.service_name || '')
|
||||
const hostId = computed(() => parseInt(route.query.id) || 0)
|
||||
|
||||
const activeTab = ref('info')
|
||||
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, vmMonitor: false, networking: false })
|
||||
const hostTabLoaded = reactive({ image: false, network: false, volume: false, vm: false, snapshot: false, backup: false, recycleBin: false, vmMonitor: false, networking: false })
|
||||
|
||||
const imageManageRef = ref(null)
|
||||
const networkManageRef = ref(null)
|
||||
@@ -977,8 +981,9 @@ const volumeManageRef = ref(null)
|
||||
const vmManageRef = ref(null)
|
||||
const snapshotManageRef = ref(null)
|
||||
const backupManageRef = ref(null)
|
||||
const recycleBinManageRef = ref(null)
|
||||
const vmMonitorRef = ref(null)
|
||||
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef, vmMonitor: vmMonitorRef }
|
||||
const tabRefMap = { image: imageManageRef, network: networkManageRef, volume: volumeManageRef, vm: vmManageRef, snapshot: snapshotManageRef, backup: backupManageRef, recycleBin: recycleBinManageRef, vmMonitor: vmMonitorRef }
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (!['info', 'monitor', 'networking'].includes(tab)) {
|
||||
@@ -1940,10 +1945,13 @@ const initPage = () => {
|
||||
if (activeTab.value === 'monitor') loadHistoricalMetrics()
|
||||
}
|
||||
|
||||
watch(hostId, () => { if (isPageActive) initPage() })
|
||||
onActivated(() => {
|
||||
const isCurrentRoute = () => route.name === 'VirtHostDetail'
|
||||
|
||||
watch(hostId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
onActivated(async () => {
|
||||
isPageActive = true
|
||||
if (loadedHostId !== hostId.value) initPage()
|
||||
await nextTick()
|
||||
if (isCurrentRoute() && loadedHostId !== hostId.value) initPage()
|
||||
})
|
||||
onMounted(() => { isPageActive = true; initPage() })
|
||||
onDeactivated(() => { isPageActive = false })
|
||||
|
||||
@@ -32,8 +32,13 @@
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusType(detail.status)" size="small">{{ statusLabel(detail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="模式">
|
||||
<el-tag :type="detail.mode === 'remote' ? 'warning' : 'success'" size="small">{{ detail.mode === 'remote' ? '远端模式' : '本地模式' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="大小">{{ detail.size ? formatSize(detail.size) : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="路径" :span="2"><span class="mono-text">{{ detail.path || '-' }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="下载URL" :span="2"><span class="mono-text">{{ detail.url || '-' }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="来源地址" :span="2"><span class="mono-text">{{ detail.source || '-' }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="介绍" :span="2">{{ detail.description || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(detail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(detail.updated_at) }}</el-descriptions-item>
|
||||
@@ -79,6 +84,11 @@
|
||||
<el-option label="系统镜像" value="system" /><el-option label="数据镜像" value="data" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="镜像模式">
|
||||
<el-select v-model="formData.mode" style="width: 100%">
|
||||
<el-option label="本地模式" value="local" /><el-option label="远端模式" value="remote" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="formData.status" style="width: 100%">
|
||||
<el-option label="等待中" value="pending" /><el-option label="下载中" value="downloading" />
|
||||
@@ -127,7 +137,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Edit, Delete } from '@element-plus/icons-vue'
|
||||
@@ -160,7 +170,7 @@ const syncHostId = ref('')
|
||||
const reloadHostId = ref('')
|
||||
const formRef = ref(null)
|
||||
|
||||
const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '' })
|
||||
const formData = reactive({ name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', mode: 'local' })
|
||||
const formRules = {
|
||||
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
|
||||
path: [{ required: true, message: '请输入路径', trigger: 'blur' }]
|
||||
@@ -232,7 +242,7 @@ const loadHostStatus = async () => {
|
||||
const handleEdit = () => {
|
||||
if (!detail.value) return
|
||||
const d = detail.value
|
||||
Object.assign(formData, { name: d.name || '', path: d.path || '', os_type: d.os_type || 'linux', type: d.type || 'system', description: d.description || '', status: d.status || '' })
|
||||
Object.assign(formData, { name: d.name || '', path: d.path || '', os_type: d.os_type || 'linux', type: d.type || 'system', description: d.description || '', status: d.status || '', mode: d.mode || 'local' })
|
||||
editDialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -241,7 +251,7 @@ const handleSubmitEdit = () => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { image_id: imageId.value, service_id: serviceId.value, image_name: formData.name, path: formData.path, os_type: formData.os_type, type: formData.type, description: formData.description || undefined, status: formData.status || undefined }
|
||||
const payload = { image_id: imageId.value, service_id: serviceId.value, image_name: formData.name, path: formData.path, os_type: formData.os_type, type: formData.type, description: formData.description || undefined, status: formData.status || undefined, mode: formData.mode }
|
||||
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
|
||||
const res = await updateImage(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||
@@ -319,8 +329,10 @@ const initPage = async () => {
|
||||
loadHostStatus()
|
||||
}
|
||||
|
||||
watch(imageId, () => { if (isPageActive) initPage() })
|
||||
onActivated(() => { isPageActive = true; if (loadedImageId !== imageId.value) initPage() })
|
||||
const isCurrentRoute = () => route.name === 'VirtImageDetail'
|
||||
|
||||
watch(imageId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedImageId !== imageId.value) initPage() })
|
||||
onDeactivated(() => { isPageActive = false })
|
||||
onMounted(() => { isPageActive = true; initPage() })
|
||||
</script>
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模式" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.mode === 'remote' ? 'warning' : 'success'" size="small">{{ row.mode === 'remote' ? '远端' : '本地' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主控状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
@@ -109,6 +114,13 @@
|
||||
<el-option label="数据镜像" value="data" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="镜像模式" prop="mode">
|
||||
<el-select v-model="formData.mode" style="width: 100%">
|
||||
<el-option label="本地模式(下载到本地提供服务)" value="local" />
|
||||
<el-option label="远端模式(直接使用远端URL)" value="remote" />
|
||||
</el-select>
|
||||
<div style="color:#909399;font-size:12px;margin-top:4px">本地模式会下载镜像到主控;远端模式宿主机直接从远端URL下载</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍(可选)" />
|
||||
</el-form-item>
|
||||
@@ -148,10 +160,19 @@
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusType(currentDetail.status)" size="small">{{ statusLabel(currentDetail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="模式">
|
||||
<el-tag :type="currentDetail.mode === 'remote' ? 'warning' : 'success'" size="small">{{ currentDetail.mode === 'remote' ? '远端模式' : '本地模式' }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="大小">{{ currentDetail.size ? formatSize(currentDetail.size) : '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="路径" :span="2">
|
||||
<span class="mono-text">{{ currentDetail.path || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="下载URL" :span="2">
|
||||
<span class="mono-text">{{ currentDetail.url || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="来源地址" :span="2">
|
||||
<span class="mono-text">{{ currentDetail.source || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="介绍" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
|
||||
@@ -291,7 +312,7 @@ const currentHostLabel = computed(() => {
|
||||
|
||||
const formData = reactive({
|
||||
image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system',
|
||||
description: '', status: '', size: 0, image_name: ''
|
||||
description: '', status: '', size: 0, image_name: '', mode: 'local'
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
@@ -400,7 +421,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0 })
|
||||
Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0, mode: 'local' })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -409,7 +430,8 @@ const handleEdit = (row) => {
|
||||
Object.assign(formData, {
|
||||
image_id: row.id, name: row.name, image_name: row.name, path: row.path || '',
|
||||
os_type: row.os_type || 'linux', type: row.type || 'system',
|
||||
description: row.description || '', status: row.status || '', size: row.size || 0
|
||||
description: row.description || '', status: row.status || '', size: row.size || 0,
|
||||
mode: row.mode || 'local'
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -423,7 +445,8 @@ const handleSubmit = () => {
|
||||
if (dialogType.value === 'add') {
|
||||
res = await createImage({
|
||||
service_id: serviceId.value, name: formData.name, path: formData.path,
|
||||
os_type: formData.os_type, type: formData.type, description: formData.description || undefined
|
||||
os_type: formData.os_type, type: formData.type, description: formData.description || undefined,
|
||||
mode: formData.mode
|
||||
})
|
||||
} else {
|
||||
const payload = {
|
||||
@@ -431,7 +454,8 @@ const handleSubmit = () => {
|
||||
image_name: formData.name, path: formData.path,
|
||||
os_type: formData.os_type, type: formData.type,
|
||||
description: formData.description || undefined,
|
||||
status: formData.status || undefined, size: formData.size || undefined
|
||||
status: formData.status || undefined, size: formData.size || undefined,
|
||||
mode: formData.mode
|
||||
}
|
||||
// 清除 undefined
|
||||
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
|
||||
|
||||
@@ -33,6 +33,14 @@
|
||||
<el-option label="IPv4" value="ipv4" />
|
||||
<el-option label="IPv6" value="ipv6" />
|
||||
</el-select>
|
||||
<el-select v-model="filterUsed" placeholder="使用状态" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option label="已用" value="true" />
|
||||
<el-option label="未用" value="false" />
|
||||
</el-select>
|
||||
<el-select v-model="filterDisable" placeholder="禁用状态" clearable style="width: 120px" @change="handleSearch">
|
||||
<el-option label="已禁用" value="true" />
|
||||
<el-option label="已启用" value="false" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 网络列表 -->
|
||||
@@ -54,10 +62,34 @@
|
||||
<el-table-column label="宿主机" width="140">
|
||||
<template #default="{ row }">{{ getHostLabel(row.host_id) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<el-table-column label="使用状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="isNetworkUsed(row) ? 'success' : 'info'" size="small">
|
||||
{{ isNetworkUsed(row) ? '已用' : '未用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="虚拟机" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="isNetworkUsed(row)" link type="primary" @click="goToVmDetail(row.vm_id)">
|
||||
{{ row.vm_id }}
|
||||
</el-button>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="禁用" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.disable" type="danger" size="small">已禁用</el-tag>
|
||||
<el-tag v-else type="success" size="small">启用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleViewDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link :type="row.disable ? 'success' : 'warning'" @click="handleToggleDisable(row)">
|
||||
{{ row.disable ? '启用' : '禁用' }}
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -113,6 +145,10 @@
|
||||
<el-form-item label="逻辑端口名">
|
||||
<el-input v-model="formData.ls_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
<el-form-item label="禁用网络" v-if="dialogType === 'edit'">
|
||||
<el-switch v-model="formData.disable" active-text="禁用" inactive-text="启用" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">禁用后该网络不参与自动分配</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -142,6 +178,27 @@
|
||||
<el-descriptions-item label="逻辑网桥">{{ currentDetail.ls_bridge_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="逻辑端口">{{ currentDetail.ls_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标设备">{{ currentDetail.target_device || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用状态">
|
||||
<el-tag :type="isNetworkUsed(currentDetail) ? 'success' : 'info'" size="small">
|
||||
{{ isNetworkUsed(currentDetail) ? '已用' : '未用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="虚拟机">
|
||||
<el-button v-if="isNetworkUsed(currentDetail)" link type="primary" @click="goToVmDetail(currentDetail.vm_id)">
|
||||
{{ currentDetail.vm_id }}
|
||||
</el-button>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="主网卡">
|
||||
<el-tag :type="currentDetail.is_primary ? 'warning' : 'info'" size="small">
|
||||
{{ currentDetail.is_primary ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="禁用状态">
|
||||
<el-tag :type="currentDetail.disable ? 'danger' : 'success'" size="small">
|
||||
{{ currentDetail.disable ? '已禁用' : '启用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<template #footer><el-button @click="detailVisible = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
@@ -221,6 +278,15 @@ const total = ref(0)
|
||||
const keyword = ref('')
|
||||
const filterType = ref('')
|
||||
const filterIpVersion = ref('')
|
||||
const filterUsed = ref('')
|
||||
const filterDisable = ref('')
|
||||
|
||||
const isNetworkUsed = (row) => !!row?.vm_id
|
||||
|
||||
const goToVmDetail = (vmId) => {
|
||||
if (!vmId) return
|
||||
router.push({ path: '/virtualization/vm-detail', query: { service_id: serviceId.value, service_name: serviceName.value, vm_id: vmId } })
|
||||
}
|
||||
const hostIdInput = ref(0)
|
||||
const hostOptions = ref([])
|
||||
const queryParams = reactive({ page: 1, page_size: 10 })
|
||||
@@ -254,7 +320,8 @@ const currentDetail = ref(null)
|
||||
|
||||
const formData = reactive({
|
||||
id: undefined, name: '', address: '', gateway: '', nameservers: '',
|
||||
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0
|
||||
type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: 0,
|
||||
disable: false
|
||||
})
|
||||
|
||||
const formRules = {
|
||||
@@ -275,6 +342,8 @@ const loadList = async () => {
|
||||
if (keyword.value) params.key = keyword.value
|
||||
if (filterType.value) params.type = filterType.value
|
||||
if (filterIpVersion.value) params.ip_version = filterIpVersion.value
|
||||
if (filterUsed.value !== '') params.used = filterUsed.value
|
||||
if (filterDisable.value !== '') params.disable = filterDisable.value
|
||||
const res = await getNetworkList(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
@@ -295,7 +364,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
dialogType.value = 'add'
|
||||
Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0 })
|
||||
Object.assign(formData, { id: undefined, name: '', address: '', gateway: '', nameservers: '', type: 'bridge', mac_address: '', bridge_name: '', ls_bridge_name: '', ls_name: '', host_id: hostIdInput.value || hostId.value || 0, disable: false })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -305,7 +374,7 @@ const handleEdit = (row) => {
|
||||
id: row.id, name: row.name, address: row.address, gateway: row.gateway,
|
||||
nameservers: row.nameservers || '', type: row.type, mac_address: row.mac_address || '',
|
||||
bridge_name: row.bridge_name || '', ls_bridge_name: row.ls_bridge_name || '',
|
||||
ls_name: row.ls_name || '', host_id: row.host_id
|
||||
ls_name: row.ls_name || '', host_id: row.host_id, disable: !!row.disable
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -332,6 +401,7 @@ const handleSubmit = () => {
|
||||
res = await createNetwork(fd)
|
||||
} else {
|
||||
fd.append('id', formData.id)
|
||||
fd.append('disable', formData.disable)
|
||||
res = await updateNetwork(fd)
|
||||
}
|
||||
if (res?.data?.code === 200) {
|
||||
@@ -359,6 +429,25 @@ const handleViewDetail = async (row) => {
|
||||
} catch { /* fallback */ }
|
||||
}
|
||||
|
||||
const handleToggleDisable = (row) => {
|
||||
const nextState = !row.disable
|
||||
const actionText = nextState ? '禁用' : '启用'
|
||||
ElMessageBox.confirm(`确定要${actionText}网络「${row.name}」吗?${nextState ? '禁用后该网络不参与自动分配。' : ''}`, `${actionText}确认`, {
|
||||
confirmButtonText: `确定${actionText}`, cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('id', row.id)
|
||||
fd.append('host_id', row.host_id)
|
||||
fd.append('disable', nextState)
|
||||
const res = await updateNetwork(fd)
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${actionText}成功`); loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, `${actionText}失败`))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${actionText}失败`)) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定要删除网络「${row.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<div class="recycle-bin-manage">
|
||||
<div class="toolbar">
|
||||
<el-input v-model="keyword" placeholder="按虚拟机名称搜索" style="width: 200px" size="small" clearable
|
||||
@clear="loadList" @keyup.enter="loadList" />
|
||||
<el-select v-model="filterStatus" placeholder="按状态过滤" style="width: 140px" size="small" clearable
|
||||
@change="() => { currentPage = 1; loadList() }">
|
||||
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
|
||||
</el-select>
|
||||
<el-button size="small" :icon="Search" @click="loadList">搜索</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
|
||||
<el-dropdown trigger="click" @command="handleClean" style="margin-left: auto">
|
||||
<el-button size="small" type="danger">
|
||||
清空回收站<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="expired">清理已到期</el-dropdown-item>
|
||||
<el-dropdown-item command="all">清空全部</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe size="small" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="vm_name" label="虚拟机名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="vm_id" label="原虚拟机ID" width="100" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="任务ID" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="mono-text">{{ row.task_id || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" width="170">
|
||||
<template #default="{ row }">
|
||||
<span :style="isExpired(row.expire_at) ? 'color: #f56c6c' : ''">{{ formatTs(row.expire_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatTs(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" width="170">
|
||||
<template #default="{ row }">{{ formatTs(row.updated_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
|
||||
<el-button link type="success" size="small" @click="handleRestore(row)"
|
||||
:disabled="!canRestore(row.status)">恢复</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)"
|
||||
:disabled="row.status === 'restoring' || row.status === 'purging'">永久删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!list.length && !loading" description="回收站为空" :image-size="60" />
|
||||
<div class="pagination-wrapper" v-if="total > 0">
|
||||
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
|
||||
@size-change="s => { pageSize = s; currentPage = 1; loadList() }"
|
||||
@current-change="p => { currentPage = p; loadList() }" />
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog v-model="detailVisible" title="回收站记录详情" width="720px" destroy-on-close>
|
||||
<div v-loading="detailLoading">
|
||||
<el-descriptions :column="2" border size="small" v-if="detailData?.recycle" style="margin-bottom: 16px">
|
||||
<el-descriptions-item label="记录ID">{{ detailData.recycle.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="原虚拟机ID">{{ detailData.recycle.vm_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="虚拟机名称">{{ detailData.recycle.vm_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="statusTagType(detailData.recycle.status)" size="small">{{ statusLabel(detailData.recycle.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="宿主机ID">{{ detailData.recycle.host_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="归档目录">
|
||||
<span class="mono-text">{{ detailData.recycle.recycle_dir || '-' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="到期时间">{{ formatTs(detailData.recycle.expire_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ formatTs(detailData.recycle.created_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="detailData">
|
||||
<div v-for="snap in snapshotSections" :key="snap.key">
|
||||
<div class="snapshot-title">{{ snap.label }}</div>
|
||||
<el-input type="textarea" :model-value="formatSnapshot(detailData[snap.key])" :rows="6" readonly
|
||||
style="margin-bottom: 12px; font-family: Consolas, Monaco, monospace; font-size: 12px" />
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-if="!detailData" description="暂无详情数据" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 恢复弹窗(可选指定网络) -->
|
||||
<el-dialog v-model="restoreVisible" title="恢复虚拟机" width="520px" destroy-on-close>
|
||||
<el-form label-width="120px">
|
||||
<el-form-item label="虚拟机名称">
|
||||
<span>{{ restoreRow?.vm_name }} (ID: {{ restoreRow?.vm_id }})</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="外网网络">
|
||||
<div class="net-select-area">
|
||||
<div v-if="selectedBridgeNetwork" class="net-selected-item">
|
||||
<el-tag closable @close="selectedBridgeNetwork = null">
|
||||
{{ selectedBridgeNetwork.name }} ({{ selectedBridgeNetwork.address }})
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button size="small" @click="showBridgeNetSelector = true">
|
||||
{{ selectedBridgeNetwork ? '更换' : '选择外网' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="内网网络">
|
||||
<div class="net-select-area">
|
||||
<div v-if="selectedNatNetworks.length" class="net-selected-list">
|
||||
<el-tag v-for="net in selectedNatNetworks" :key="net.id" closable
|
||||
@close="removeNatNetwork(net.id)" style="margin-right: 4px; margin-bottom: 4px">
|
||||
{{ net.name }} ({{ net.address }})
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button size="small" @click="showNatNetSelector = true">添加内网</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="restoreVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="restoreLoading" @click="submitRestore">确认恢复</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 恢复 - 外网选择器 -->
|
||||
<NetworkSelectorPopup v-model="showBridgeNetSelector"
|
||||
:service-id="serviceId" :host-id="restoreRow?.host_id || 0"
|
||||
filter-type="bridge" filter-used="false"
|
||||
@confirm="handleBridgeNetConfirm" />
|
||||
<!-- 恢复 - 内网选择器 -->
|
||||
<NetworkSelectorPopup v-model="showNatNetSelector"
|
||||
:service-id="serviceId" :host-id="restoreRow?.host_id || 0"
|
||||
filter-type="nat" filter-used="false"
|
||||
@confirm="handleNatNetConfirm" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, inject, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRecycleBinList, getRecycleBinDetail,
|
||||
restoreRecycleBin, deleteRecycleBin, cleanRecycleBin
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import NetworkSelectorPopup from '@/components/admin/NetworkSelectorPopup.vue'
|
||||
|
||||
const serviceId = inject('serviceId')
|
||||
const hostId = inject('hostId')
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const keyword = ref('')
|
||||
const filterStatus = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'pending', label: '等待归档' },
|
||||
{ value: 'archiving', label: '归档中' },
|
||||
{ value: 'ready', label: '就绪' },
|
||||
{ value: 'archived', label: '已归档' },
|
||||
{ value: 'restoring', label: '恢复中' },
|
||||
{ value: 'purging', label: '清理中' }
|
||||
]
|
||||
|
||||
const statusLabelMap = {
|
||||
pending: '等待归档', archiving: '归档中', ready: '就绪', archived: '已归档',
|
||||
restoring: '恢复中', purging: '清理中', failed: '失败', error: '错误'
|
||||
}
|
||||
const statusLabel = (s) => statusLabelMap[s] || s || '-'
|
||||
const statusTagType = (s) => ({
|
||||
ready: 'success', archived: 'success', pending: 'info', archiving: 'warning',
|
||||
restoring: 'primary', purging: 'danger', failed: 'danger', error: 'danger'
|
||||
}[s] || 'info')
|
||||
|
||||
// ready 和 archived 都表示归档完成,可执行恢复
|
||||
const canRestore = (status) => status === 'ready' || status === 'archived'
|
||||
|
||||
const formatTs = (ts) => {
|
||||
if (!ts) return '-'
|
||||
if (typeof ts === 'object' && ts.seconds) return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
|
||||
if (typeof ts === 'string' || typeof ts === 'number') {
|
||||
const d = new Date(ts)
|
||||
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
const isExpired = (ts) => {
|
||||
if (!ts) return false
|
||||
let t
|
||||
if (typeof ts === 'object' && ts.seconds) t = Number(ts.seconds) * 1000
|
||||
else t = new Date(ts).getTime()
|
||||
return !isNaN(t) && t < Date.now()
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
service_id: serviceId.value,
|
||||
host_id: hostId.value,
|
||||
page: currentPage.value,
|
||||
count: pageSize.value
|
||||
}
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
|
||||
const res = await getRecycleBinList(params)
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
list.value = d.data || d.list || (Array.isArray(d) ? d : [])
|
||||
total.value = d.meta?.count ?? d.total ?? list.value.length
|
||||
} else { list.value = []; total.value = 0 }
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
}
|
||||
|
||||
/* ---- 详情 ---- */
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref(null)
|
||||
|
||||
const snapshotSections = [
|
||||
{ key: 'vm_snapshot', label: '虚拟机快照' },
|
||||
{ key: 'volumes_snapshot', label: '磁盘快照' },
|
||||
{ key: 'networks_snapshot', label: '网络快照' },
|
||||
{ key: 'traffic_policy_snapshot', label: '流量策略快照' }
|
||||
]
|
||||
|
||||
const formatSnapshot = (raw) => {
|
||||
if (!raw) return '(无)'
|
||||
try { return JSON.stringify(JSON.parse(raw), null, 2) } catch { return raw }
|
||||
}
|
||||
|
||||
const handleDetail = async (row) => {
|
||||
detailData.value = null
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res = await getRecycleBinDetail({
|
||||
service_id: serviceId.value,
|
||||
host_id: hostId.value,
|
||||
recycle_id: row.id
|
||||
})
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
detailData.value = res.data.data.data ?? res.data.data
|
||||
} else { ElMessage.warning('获取详情失败') }
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e?.response?.data, '获取详情失败'))
|
||||
} finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
/* ---- 恢复 ---- */
|
||||
const restoreVisible = ref(false)
|
||||
const restoreLoading = ref(false)
|
||||
const restoreRow = ref(null)
|
||||
|
||||
// 网络选择器
|
||||
const showBridgeNetSelector = ref(false)
|
||||
const showNatNetSelector = ref(false)
|
||||
const selectedBridgeNetwork = ref(null)
|
||||
const selectedNatNetworks = ref([])
|
||||
|
||||
const handleRestore = (row) => {
|
||||
restoreRow.value = row
|
||||
selectedBridgeNetwork.value = null
|
||||
selectedNatNetworks.value = []
|
||||
restoreVisible.value = true
|
||||
}
|
||||
|
||||
const handleBridgeNetConfirm = (net) => { selectedBridgeNetwork.value = net }
|
||||
const handleNatNetConfirm = (net) => {
|
||||
if (!selectedNatNetworks.value.find(n => n.id === net.id)) {
|
||||
selectedNatNetworks.value.push(net)
|
||||
}
|
||||
}
|
||||
const removeNatNetwork = (id) => {
|
||||
selectedNatNetworks.value = selectedNatNetworks.value.filter(n => n.id !== id)
|
||||
}
|
||||
|
||||
const submitRestore = async () => {
|
||||
restoreLoading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('host_id', hostId.value)
|
||||
fd.append('recycle_id', restoreRow.value.id)
|
||||
if (selectedNatNetworks.value.length) {
|
||||
selectedNatNetworks.value.forEach(n => fd.append('network_ids', n.id))
|
||||
}
|
||||
if (selectedBridgeNetwork.value) {
|
||||
fd.append('internet_network_id', selectedBridgeNetwork.value.id)
|
||||
}
|
||||
const res = await restoreRecycleBin(fd)
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('恢复任务已提交')
|
||||
restoreVisible.value = false
|
||||
loadList()
|
||||
} else { ElMessage.error(extractApiError(res?.data, '恢复失败')) }
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e?.response?.data, '恢复失败'))
|
||||
} finally { restoreLoading.value = false }
|
||||
}
|
||||
|
||||
/* ---- 永久删除 ---- */
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(
|
||||
`确定要永久删除「${row.vm_name}」的回收站记录吗?此操作不可恢复!`,
|
||||
'永久删除确认',
|
||||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(async () => {
|
||||
try {
|
||||
const res = await deleteRecycleBin({
|
||||
service_id: serviceId.value,
|
||||
host_id: hostId.value,
|
||||
recycle_id: row.id
|
||||
})
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除任务已提交'); loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/* ---- 清空回收站 ---- */
|
||||
const handleClean = (command) => {
|
||||
const isAll = command === 'all'
|
||||
const msg = isAll ? '确定要清空全部回收站记录吗?此操作不可恢复!' : '确定要清理所有已到期的回收站记录吗?'
|
||||
ElMessageBox.confirm(msg, '清空回收站', {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await cleanRecycleBin({
|
||||
service_id: serviceId.value,
|
||||
host_id: hostId.value,
|
||||
all: isAll
|
||||
})
|
||||
if (res?.data?.code === 200) {
|
||||
const purged = res.data.data?.purged ?? 0
|
||||
ElMessage.success(`已提交清理任务,清理 ${purged} 条记录`)
|
||||
loadList()
|
||||
} else { ElMessage.error(extractApiError(res?.data, '清空失败')) }
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '清空失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(() => { loadList() })
|
||||
|
||||
defineExpose({ loadList })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recycle-bin-manage { padding: 0; }
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; margin-top: 12px; margin-bottom: 12px; }
|
||||
.mono-text { font-family: Consolas, Monaco, monospace; font-size: 12px; }
|
||||
.snapshot-title { font-size: 13px; font-weight: 600; color: #606266; margin: 12px 0 6px; }
|
||||
.pagination-wrapper { margin-top: 12px; display: flex; justify-content: flex-end; }
|
||||
.net-select-area { display: flex; flex-direction: column; gap: 6px; }
|
||||
.net-selected-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<el-button type="primary" @click="handleGetVnc">远程连接</el-button>
|
||||
<el-button type="success" @click="handleSshConnect" :disabled="!publicIpList.length || isWindows">SSH连接</el-button>
|
||||
<el-button type="danger" @click="handlePower('stop')" :disabled="detail.status === 'stopped' || detail.status === 'stop' || isMigrating">关机</el-button>
|
||||
<el-dropdown trigger="click" @command="handleMoreCommand">
|
||||
<el-button>更多 <el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
|
||||
@@ -2783,6 +2784,15 @@ const loadVncNodes = async () => {
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const handleSshConnect = () => {
|
||||
const host = publicIpList.value[0]
|
||||
if (!host) return ElMessage.warning('无可用公网 IP')
|
||||
const username = isWindows.value ? 'Administrator' : 'root'
|
||||
const password = detail.value?.root_password || ''
|
||||
const encodedPwd = btoa(password)
|
||||
window.open(`https://webssh1.007yjs.com/?hostname=${encodeURIComponent(host)}&username=${encodeURIComponent(username)}&password=${encodedPwd}`, '_blank')
|
||||
}
|
||||
|
||||
const handleGetVnc = async () => {
|
||||
vncNodeId.value = null
|
||||
vncResult.value = null
|
||||
@@ -3946,11 +3956,14 @@ const loadSgLockInfo = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(vmId, () => { if (isPageActive) initPage() })
|
||||
const isCurrentRoute = () => route.name === 'VirtVmDetail'
|
||||
|
||||
watch(vmId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
watch(activeTab, (tab) => { if (detail.value) triggerTabLoad(tab) })
|
||||
onActivated(() => {
|
||||
onActivated(async () => {
|
||||
isPageActive = true
|
||||
if (loadedVmId !== vmId.value) initPage()
|
||||
await nextTick()
|
||||
if (isCurrentRoute() && loadedVmId !== vmId.value) initPage()
|
||||
})
|
||||
onDeactivated(() => { isPageActive = false; stopMigratePolling() })
|
||||
onBeforeUnmount(() => { isPageActive = false; disposeCharts(); stopMigratePolling(); stopDetailAutoRefresh() })
|
||||
|
||||
@@ -28,8 +28,24 @@
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作栏 -->
|
||||
<div class="batch-bar" v-if="selectedVms.length">
|
||||
<span class="batch-info">已选择 <strong>{{ selectedVms.length }}</strong> 台虚拟机</span>
|
||||
<el-button type="success" size="small" @click="handleBatchPower('start')" :loading="batchLoading">
|
||||
<el-icon><VideoPlay /></el-icon>批量开机
|
||||
</el-button>
|
||||
<el-button type="warning" size="small" @click="handleBatchPower('stop')" :loading="batchLoading">
|
||||
<el-icon><SwitchButton /></el-icon>批量关机
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click="handleBatchDelete" :loading="batchLoading">
|
||||
<el-icon><Delete /></el-icon>批量删除
|
||||
</el-button>
|
||||
<el-button size="small" @click="clearSelection">取消选择</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 虚拟机列表 -->
|
||||
<el-table :data="vmList" v-loading="loading" stripe>
|
||||
<el-table ref="vmTableRef" :data="vmList" v-loading="loading" stripe @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="45" />
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="配置" min-width="200">
|
||||
@@ -433,7 +449,7 @@
|
||||
import { ref, reactive, computed, inject, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled, VideoPlay, SwitchButton, Delete } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getVmList, getVmDetail, getVmStatus,
|
||||
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
|
||||
@@ -466,6 +482,7 @@ const serviceName = computed(() => injectedServiceName?.value || route.query.ser
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const batchLoading = ref(false)
|
||||
const vmList = ref([])
|
||||
const total = ref(0)
|
||||
const keyword = ref('')
|
||||
@@ -473,6 +490,12 @@ const filterStatus = ref('')
|
||||
const hostOptions = ref([])
|
||||
const queryParams = reactive({ page: 1, page_size: 10 })
|
||||
|
||||
// 批量操作
|
||||
const vmTableRef = ref(null)
|
||||
const selectedVms = ref([])
|
||||
const handleSelectionChange = (selection) => { selectedVms.value = selection }
|
||||
const clearSelection = () => { vmTableRef.value?.clearSelection() }
|
||||
|
||||
// 选择器
|
||||
const showCreateImageSelector = ref(false)
|
||||
const showRebuildImageSelector = ref(false)
|
||||
@@ -989,6 +1012,72 @@ const handleDelete = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 批量电源操作(开机/关机)
|
||||
const handleBatchPower = (action) => {
|
||||
const label = action === 'start' ? '开机' : '关机'
|
||||
const targets = selectedVms.value
|
||||
const skipped = targets.filter(v =>
|
||||
action === 'start' ? v.status === 'running' : (v.status === 'stopped' || v.status === 'stop')
|
||||
)
|
||||
const toOperate = targets.filter(v => !skipped.includes(v))
|
||||
|
||||
if (!toOperate.length) {
|
||||
ElMessage.warning(`所选虚拟机均已处于目标状态,无需${label}`)
|
||||
return
|
||||
}
|
||||
|
||||
const msg = skipped.length
|
||||
? `将对 ${toOperate.length} 台虚拟机执行${label}(${skipped.length} 台已跳过),是否继续?`
|
||||
: `确定要对 ${toOperate.length} 台虚拟机执行批量${label}吗?`
|
||||
|
||||
ElMessageBox.confirm(msg, `批量${label}`, {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消',
|
||||
type: action === 'stop' ? 'warning' : 'info'
|
||||
}).then(async () => {
|
||||
batchLoading.value = true
|
||||
const api = action === 'start' ? startVm : stopVm
|
||||
let success = 0, fail = 0
|
||||
await Promise.allSettled(toOperate.map(async (vm) => {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('vm_id', vm.id)
|
||||
const res = await api(fd)
|
||||
if (res?.data?.code === 200) success++
|
||||
else fail++
|
||||
} catch { fail++ }
|
||||
}))
|
||||
batchLoading.value = false
|
||||
clearSelection()
|
||||
ElMessage[fail ? 'warning' : 'success'](`批量${label}完成:成功 ${success},失败 ${fail}`)
|
||||
loadList()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const handleBatchDelete = () => {
|
||||
const count = selectedVms.value.length
|
||||
ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${count} 台虚拟机吗?此操作不可恢复。`,
|
||||
'批量删除',
|
||||
{ confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning' }
|
||||
).then(async () => {
|
||||
batchLoading.value = true
|
||||
let success = 0, fail = 0
|
||||
await Promise.allSettled(selectedVms.value.map(async (vm) => {
|
||||
try {
|
||||
const res = await deleteVm({ service_id: serviceId.value, vm_id: vm.id })
|
||||
if (res?.data?.code === 200) success++
|
||||
else fail++
|
||||
} catch { fail++ }
|
||||
}))
|
||||
batchLoading.value = false
|
||||
clearSelection()
|
||||
ElMessage[fail ? 'warning' : 'success'](`批量删除完成:成功 ${success},失败 ${fail}`)
|
||||
loadList()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -1007,4 +1096,19 @@ defineExpose({ loadList })
|
||||
.migrate-inline-status { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
|
||||
.migrate-inline-label { color: #e6a23c; font-size: 13px; font-weight: 600; white-space: nowrap; }
|
||||
.migrate-inline-pct { color: #e6a23c; font-size: 12px; white-space: nowrap; }
|
||||
.batch-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: #ecf5ff;
|
||||
border: 1px solid #d9ecff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.batch-info {
|
||||
font-size: 13px;
|
||||
color: #409eff;
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
||||
@@ -273,8 +273,10 @@ const initPage = () => {
|
||||
loadDetail()
|
||||
}
|
||||
|
||||
watch(volumeId, () => { if (isPageActive) initPage() })
|
||||
onActivated(() => { isPageActive = true; if (loadedVolumeId !== volumeId.value) initPage() })
|
||||
const isCurrentRoute = () => route.name === 'VirtVolumeDetail'
|
||||
|
||||
watch(volumeId, () => { if (isPageActive && isCurrentRoute()) initPage() })
|
||||
onActivated(async () => { isPageActive = true; await nextTick(); if (isCurrentRoute() && loadedVolumeId !== volumeId.value) initPage() })
|
||||
onDeactivated(() => { isPageActive = false })
|
||||
onMounted(() => { isPageActive = true; initPage() })
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user