Compare commits
9
Commits
b839d15826
..
deploy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
295da4da57 | ||
|
|
edc1118a52 | ||
|
|
7b37d04b87 | ||
|
|
a71351ce3c | ||
|
|
74e3ca414a | ||
|
|
e823a43b11 | ||
|
|
139145964a | ||
|
|
12f1fd23b1 | ||
|
|
66f974a71b |
@@ -0,0 +1,19 @@
|
||||
import { http2 } from '@/utils/request.js'
|
||||
|
||||
const base = '/api/v1/admin/server/aws_service'
|
||||
|
||||
export const getAwsServiceList = (params) => http2.get(`${base}/list`, { params })
|
||||
export const getAwsServiceDetail = (id) => http2.get(`${base}/detail`, { params: { id } })
|
||||
export const createAwsService = (data) => http2.post(`${base}/create`, data)
|
||||
export const updateAwsService = (data) => http2.post(`${base}/update`, data)
|
||||
export const testAwsService = (id) => http2.post(`${base}/test`, null, { params: { id } })
|
||||
export const deleteAwsService = (id) => http2.delete(`${base}/delete`, { params: { id } })
|
||||
|
||||
export const getAwsGoodsList = (params) => http2.get(`${base}/goods/list`, { params })
|
||||
export const getAwsGoodsDetail = (id) => http2.get(`${base}/goods/detail`, { params: { id } })
|
||||
export const createAwsGoods = (data) => http2.post(`${base}/goods/create`, data)
|
||||
export const updateAwsGoods = (data) => http2.post(`${base}/goods/update`, data)
|
||||
export const deleteAwsGoods = (id) => http2.delete(`${base}/goods/delete`, { params: { id } })
|
||||
|
||||
export const getAwsInstanceList = (params) => http2.get(`${base}/instances/list`, { params })
|
||||
export const retryAwsInstance = (instanceId) => http2.post(`${base}/instances/retry`, null, { params: { instance_id: instanceId } })
|
||||
@@ -169,6 +169,13 @@ export const updateRemoteHost = (data) => {
|
||||
})
|
||||
}
|
||||
|
||||
/** 设置宿主机禁用状态 */
|
||||
export const setRemoteHostDisabled = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/host/set_disabled', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除宿主机 */
|
||||
export const deleteRemoteHost = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/host/delete', { params })
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { http2 } from '@/utils/request.js'
|
||||
|
||||
const base = '/api/v1/admin/activity/mf_consumption'
|
||||
|
||||
/** 获取魔方特殊消费活动列表 */
|
||||
export const getMFConsumptionPromotionList = (params) =>
|
||||
http2.get(`${base}/list`, { params })
|
||||
|
||||
/** 创建魔方特殊消费活动 */
|
||||
export const createMFConsumptionPromotion = (data) =>
|
||||
http2.post(`${base}/create`, data)
|
||||
|
||||
/** 软删除魔方特殊消费活动 */
|
||||
export const deleteMFConsumptionPromotion = (id) =>
|
||||
http2.delete(`${base}/delete`, { params: { id } })
|
||||
|
||||
/** 永久清理指定时间范围内的 MF 扣减型消费记录 */
|
||||
export const clearMFConsumptionHistories = (data) =>
|
||||
http2.delete(`${base}/histories/clear`, { data })
|
||||
@@ -125,6 +125,13 @@ export const createTask = (data) => {
|
||||
export const getUserGroupList = (data) => {
|
||||
return http2.get('/api/v1/admin/user_group/list?page=' + data.page + '&count=' + data.count)
|
||||
}
|
||||
export const setDefaultUserGroup = (data) => {
|
||||
return http2.post('/api/v1/admin/user_group/set_default', data, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
/**获取用户组成员列表 */
|
||||
export const getUserGroupMemberList = (data) => {
|
||||
return http2.get('/api/v1/admin/user_group/member_list?group_id=' + data.group_id + '&page=' + data.page + '&count=' + data.count)
|
||||
@@ -174,4 +181,4 @@ export const refundBalance = (data) => {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +91,10 @@ export const menus = [
|
||||
{
|
||||
path: '/activity/groupbuy',
|
||||
title: '拼团管理'
|
||||
},
|
||||
{
|
||||
path: '/activity/mf-consumption',
|
||||
title: '魔方特殊消费'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -176,6 +180,16 @@ export const menus = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/aws',
|
||||
title: 'AWS 服务管理',
|
||||
icon: 'Cloudy',
|
||||
children: [
|
||||
{ path: '/aws/services', title: '服务配置' },
|
||||
{ path: '/aws/goods', title: '商品绑定' },
|
||||
{ path: '/aws/instances', title: '履约实例' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '虚拟化平台管理',
|
||||
icon: 'Platform',
|
||||
|
||||
+44
-1
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { TOKEN_KEY, TOKEN_EXPIRE_KEY, USER_INFO_KEY } from '@/config/env.js'
|
||||
import AdminLayout from '../components/layout/AdminLayout.vue'
|
||||
import OperationLog from '@/views/system/OperationLog.vue'
|
||||
|
||||
@@ -373,6 +374,14 @@ const routes = [
|
||||
meta: {
|
||||
title: '拼团管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'mf-consumption',
|
||||
name: 'MFConsumptionPromotion',
|
||||
component: () => import('../views/activity/MFConsumptionPromotion.vue'),
|
||||
meta: {
|
||||
title: '魔方特殊消费'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -662,6 +671,33 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
// AWS 远端商品管理
|
||||
{
|
||||
path: 'aws',
|
||||
name: 'Aws',
|
||||
meta: { title: 'AWS 服务管理', icon: 'Cloudy' },
|
||||
redirect: '/aws/services',
|
||||
children: [
|
||||
{
|
||||
path: 'services',
|
||||
name: 'AwsServices',
|
||||
component: () => import('../views/aws/AwsServices.vue'),
|
||||
meta: { title: '服务配置' }
|
||||
},
|
||||
{
|
||||
path: 'goods',
|
||||
name: 'AwsGoods',
|
||||
component: () => import('../views/aws/AwsGoods.vue'),
|
||||
meta: { title: '商品绑定' }
|
||||
},
|
||||
{
|
||||
path: 'instances',
|
||||
name: 'AwsInstances',
|
||||
component: () => import('../views/aws/AwsInstances.vue'),
|
||||
meta: { title: '履约实例' }
|
||||
}
|
||||
]
|
||||
},
|
||||
// 站点审计路由
|
||||
{
|
||||
path: 'audit',
|
||||
@@ -809,7 +845,14 @@ router.beforeEach((to, from, next) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} - 007UI管理系统` : '007UI管理系统'
|
||||
|
||||
// 这里可以添加登录验证逻辑
|
||||
const isAuthenticated = localStorage.getItem('token')
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const expire = Number(localStorage.getItem(TOKEN_EXPIRE_KEY))
|
||||
const isAuthenticated = !!token && Number.isFinite(expire) && expire > 0 && Date.now() < expire * 1000
|
||||
if (token && !isAuthenticated) {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||
localStorage.removeItem(USER_INFO_KEY)
|
||||
}
|
||||
if (to.path !== '/login' && !isAuthenticated) {
|
||||
next({ path: '/login' })
|
||||
} else {
|
||||
|
||||
+28
-23
@@ -46,6 +46,22 @@ const isTokenExpiringSoon = () => {
|
||||
let isRefreshing = false
|
||||
// 等待刷新token的请求队列
|
||||
let refreshSubscribers = []
|
||||
let authRedirecting = false
|
||||
|
||||
const clearAuthAndRedirect = (message = '登录过期,请重新登录') => {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||
localStorage.removeItem(USER_INFO_KEY)
|
||||
onTokenRefreshed(null)
|
||||
if (authRedirecting) return
|
||||
authRedirecting = true
|
||||
ElMessage.closeAll()
|
||||
ElMessage.warning(message)
|
||||
if (router.currentRoute.value.path !== '/login') router.replace('/login')
|
||||
}
|
||||
|
||||
// 鉴权失效后终止原页面请求链,避免并发请求进入各页面 catch 后重复弹错。
|
||||
const stopExpiredRequest = () => new Promise(() => {})
|
||||
|
||||
// 添加请求到队列
|
||||
const subscribeTokenRefresh = (callback) => {
|
||||
@@ -83,20 +99,12 @@ const doRefreshToken = async () => {
|
||||
}
|
||||
}
|
||||
// 刷新失败,触发登出逻辑
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||
localStorage.removeItem(USER_INFO_KEY)
|
||||
ElMessage.warning('登录过期,请重新登录')
|
||||
router.push('/login')
|
||||
clearAuthAndRedirect()
|
||||
return null
|
||||
} catch (error) {
|
||||
console.error('Token刷新失败:', error)
|
||||
// 刷新失败,触发登出逻辑
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||
localStorage.removeItem(USER_INFO_KEY)
|
||||
ElMessage.warning('登录过期,请重新登录')
|
||||
router.push('/login')
|
||||
clearAuthAndRedirect()
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -122,6 +130,10 @@ class Request {
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
clearAuthAndRedirect()
|
||||
return stopExpiredRequest()
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
@@ -206,14 +218,8 @@ http2.interceptors.request.use(async config => {
|
||||
if (urlNeedAuth(config.url)) {
|
||||
// 检查token是否已过期
|
||||
if (isTokenExpired()) {
|
||||
if (token) {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(TOKEN_EXPIRE_KEY)
|
||||
localStorage.removeItem(USER_INFO_KEY)
|
||||
ElMessage.warning('登录过期,请重新登录')
|
||||
}
|
||||
router.push('/login')
|
||||
return Promise.reject(new Error('Token已过期'))
|
||||
clearAuthAndRedirect(token ? '登录过期,请重新登录' : '请先登录')
|
||||
return stopExpiredRequest()
|
||||
}
|
||||
|
||||
// 检查token是否即将过期,进行无感刷新
|
||||
@@ -251,6 +257,7 @@ http2.interceptors.request.use(async config => {
|
||||
})
|
||||
} else {
|
||||
// 正常情况,直接使用token
|
||||
authRedirecting = false
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
}
|
||||
@@ -269,14 +276,12 @@ http2.interceptors.response.use(
|
||||
}
|
||||
const { status } = error.response;
|
||||
if (status === 401) {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
ElMessage.warning('登陆过期,请重新登陆')
|
||||
router.push('/login')
|
||||
return Promise.reject();
|
||||
clearAuthAndRedirect()
|
||||
return stopExpiredRequest()
|
||||
}
|
||||
// 其他错误处理(可选)
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default request
|
||||
export default request
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="mf-consumption-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>魔方特殊消费</h2>
|
||||
<p>设置订单消费计入用户组累计消费的特殊倍率,活动时间按左闭右开区间生效。</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="danger" plain @click="openClearDialog">
|
||||
<el-icon><Delete /></el-icon>清理消费记录
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreateDialog">
|
||||
<el-icon><Plus /></el-icon>新增活动
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
class="description-alert"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="倍率只影响消费记录计入用户组升级的累计值,不直接修改订单应付金额。"
|
||||
/>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-table v-loading="loading" :data="rows" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="活动名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="tag" label="活动标签" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="消费倍率" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag type="warning" effect="plain">{{ row.multiplier }} 倍</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="活动状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="promotionStatus(row).type">{{ promotionStatus(row).label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开始时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.startTime ?? row.start_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.endTime ?? row.end_time) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" min-width="170">
|
||||
<template #default="{ row }">{{ formatTime(row.CreatedAt ?? row.createdAt ?? row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="removePromotion(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无特殊消费活动" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.count"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="loadPromotions"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="createDialogVisible" title="新增魔方特殊消费活动" width="620px" destroy-on-close>
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="105px">
|
||||
<el-form-item label="活动名称" prop="name">
|
||||
<el-input v-model="createForm.name" maxlength="120" show-word-limit placeholder="例如:MF 周年消费三倍活动" />
|
||||
</el-form-item>
|
||||
<el-form-item label="活动标签" prop="tag">
|
||||
<el-input v-model="createForm.tag" maxlength="120" show-word-limit placeholder="必须唯一,例如:20260824-20260831/Price*3" />
|
||||
<div class="form-tip">标签会写入消费记录,创建后不可修改。</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="活动时间" prop="timeRange">
|
||||
<el-date-picker
|
||||
v-model="createForm.timeRange"
|
||||
type="datetimerange"
|
||||
start-placeholder="开始时间(包含)"
|
||||
end-placeholder="结束时间(不包含)"
|
||||
range-separator="至"
|
||||
:default-time="defaultTimes"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="消费倍率" prop="multiplier">
|
||||
<el-input-number v-model="createForm.multiplier" :min="1" :step="1" step-strictly style="width: 180px" />
|
||||
<span class="multiplier-suffix">倍</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="creating" @click="submitCreate">创建活动</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="clearDialogVisible" title="清理 MF 消费记录" width="620px" destroy-on-close>
|
||||
<el-alert
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="危险操作:记录将被永久删除且无法恢复"
|
||||
description="只清理 source=MF、非余额变更且位于所选时间范围内的扣减型消费记录;退款与补偿记录不会删除。清理后后端会重新计算受影响用户的消费用户组。"
|
||||
/>
|
||||
<el-form ref="clearFormRef" :model="clearForm" :rules="clearRules" label-width="105px" class="clear-form">
|
||||
<el-form-item label="清理范围" prop="timeRange">
|
||||
<el-date-picker
|
||||
v-model="clearForm.timeRange"
|
||||
type="datetimerange"
|
||||
start-placeholder="开始时间(包含)"
|
||||
end-placeholder="结束时间(不包含)"
|
||||
range-separator="至"
|
||||
:default-time="defaultTimes"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认文字" prop="confirmation">
|
||||
<el-input v-model="clearForm.confirmation" autocomplete="off" placeholder="请输入:确认清理" />
|
||||
<div class="form-tip">输入“确认清理”后才能提交。</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="clearDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="clearing" :disabled="clearForm.confirmation !== '确认清理'" @click="submitClear">
|
||||
永久清理
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
clearMFConsumptionHistories,
|
||||
createMFConsumptionPromotion,
|
||||
deleteMFConsumptionPromotion,
|
||||
getMFConsumptionPromotionList
|
||||
} from '@/api/admin/mfConsumption.js'
|
||||
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const clearing = ref(false)
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, count: 20 })
|
||||
const createDialogVisible = ref(false)
|
||||
const clearDialogVisible = ref(false)
|
||||
const createFormRef = ref()
|
||||
const clearFormRef = ref()
|
||||
const defaultTimes = [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
|
||||
|
||||
const newCreateForm = () => ({ name: '', tag: '', timeRange: [], multiplier: 1 })
|
||||
const createForm = reactive(newCreateForm())
|
||||
const clearForm = reactive({ timeRange: [], confirmation: '' })
|
||||
|
||||
const validateTimeRange = (_rule, value, callback) => {
|
||||
if (!Array.isArray(value) || value.length !== 2) return callback(new Error('请选择完整的开始和结束时间'))
|
||||
if (new Date(value[1]).getTime() <= new Date(value[0]).getTime()) return callback(new Error('结束时间必须晚于开始时间'))
|
||||
callback()
|
||||
}
|
||||
|
||||
const createRules = {
|
||||
name: [{ required: true, whitespace: true, message: '请输入活动名称', trigger: 'blur' }],
|
||||
tag: [{ required: true, whitespace: true, message: '请输入唯一活动标签', trigger: 'blur' }],
|
||||
timeRange: [{ required: true, validator: validateTimeRange, trigger: 'change' }],
|
||||
multiplier: [{ required: true, type: 'integer', min: 1, message: '倍率必须是大于等于 1 的整数', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const clearRules = {
|
||||
timeRange: [{ required: true, validator: validateTimeRange, trigger: 'change' }],
|
||||
confirmation: [{ validator: (_rule, value, callback) => value === '确认清理' ? callback() : callback(new Error('请输入“确认清理”')), trigger: 'change' }]
|
||||
}
|
||||
|
||||
const responseBody = (response) => response?.data || {}
|
||||
const toUnixSeconds = (value) => Math.floor(new Date(value).getTime() / 1000)
|
||||
|
||||
function formatTime(value) {
|
||||
if (!value) return '-'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value)
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
function promotionStatus(row) {
|
||||
const now = Date.now()
|
||||
const start = new Date(row.startTime ?? row.start_time).getTime()
|
||||
const end = new Date(row.endTime ?? row.end_time).getTime()
|
||||
if (Number.isFinite(start) && now < start) return { label: '未开始', type: 'info' }
|
||||
if (Number.isFinite(end) && now >= end) return { label: '已结束', type: 'info' }
|
||||
return { label: '进行中', type: 'success' }
|
||||
}
|
||||
|
||||
async function loadPromotions() {
|
||||
loading.value = true
|
||||
try {
|
||||
const body = responseBody(await getMFConsumptionPromotionList(query))
|
||||
if (body.code !== 200) return ElMessage.error(body.message || '获取活动列表失败')
|
||||
rows.value = body.data?.data || body.data?.list || []
|
||||
total.value = body.data?.all_count ?? rows.value.length
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.response?.data?.message || '获取活动列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSizeChange() {
|
||||
query.page = 1
|
||||
loadPromotions()
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
Object.assign(createForm, newCreateForm())
|
||||
createDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitCreate() {
|
||||
await createFormRef.value?.validate()
|
||||
creating.value = true
|
||||
try {
|
||||
const body = responseBody(await createMFConsumptionPromotion({
|
||||
name: createForm.name.trim(),
|
||||
tag: createForm.tag.trim(),
|
||||
start_time: toUnixSeconds(createForm.timeRange[0]),
|
||||
end_time: toUnixSeconds(createForm.timeRange[1]),
|
||||
multiplier: createForm.multiplier
|
||||
}))
|
||||
if (body.code !== 200) return ElMessage.error(body.message || '创建活动失败')
|
||||
ElMessage.success('活动创建成功')
|
||||
createDialogVisible.value = false
|
||||
query.page = 1
|
||||
loadPromotions()
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.response?.data?.message || '创建活动失败,请检查活动时间是否重叠、标签是否唯一')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removePromotion(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除活动“${row.name}”吗?已导入的消费记录不会回溯修改。`,
|
||||
'删除活动',
|
||||
{ type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' }
|
||||
)
|
||||
const body = responseBody(await deleteMFConsumptionPromotion(row.id))
|
||||
if (body.code !== 200) return ElMessage.error(body.message || '删除活动失败')
|
||||
ElMessage.success('活动已删除')
|
||||
if (rows.value.length === 1 && query.page > 1) query.page--
|
||||
loadPromotions()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(error?.response?.data?.message || '删除活动失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openClearDialog() {
|
||||
Object.assign(clearForm, { timeRange: [], confirmation: '' })
|
||||
clearDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitClear() {
|
||||
await clearFormRef.value?.validate()
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`即将永久清理 ${formatTime(clearForm.timeRange[0])} 至 ${formatTime(clearForm.timeRange[1])} 的 MF 消费记录,是否继续?`,
|
||||
'最后确认',
|
||||
{ type: 'error', confirmButtonText: '永久清理', cancelButtonText: '取消', distinguishCancelAndClose: true }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
clearing.value = true
|
||||
try {
|
||||
const body = responseBody(await clearMFConsumptionHistories({
|
||||
start_time: toUnixSeconds(clearForm.timeRange[0]),
|
||||
end_time: toUnixSeconds(clearForm.timeRange[1]),
|
||||
confirm: true
|
||||
}))
|
||||
if (body.code !== 200) return ElMessage.error(body.message || '清理消费记录失败')
|
||||
const result = body.data || {}
|
||||
ElMessage.success(`清理完成:删除 ${result.deleted_count ?? 0} 条记录,影响 ${result.affected_user_count ?? 0} 个用户`)
|
||||
clearDialogVisible.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.response?.data?.message || '清理消费记录失败')
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadPromotions)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mf-consumption-page { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 20px 24px; margin-bottom: 16px; border: 1px solid #e4e7ed; border-radius: 12px; background: linear-gradient(135deg, #fff8ed, #fff); }
|
||||
.page-header h2 { margin: 0; color: #303133; }
|
||||
.page-header p { margin: 7px 0 0; color: #606266; }
|
||||
.header-actions { display: flex; flex-shrink: 0; }
|
||||
.description-alert { margin-bottom: 16px; }
|
||||
.pagination { display: flex; justify-content: flex-end; padding-top: 18px; }
|
||||
.form-tip { margin-top: 5px; color: #909399; font-size: 12px; line-height: 1.4; }
|
||||
.multiplier-suffix { margin-left: 8px; color: #606266; }
|
||||
.clear-form { margin-top: 20px; }
|
||||
@media (max-width: 768px) {
|
||||
.page-header { align-items: flex-start; flex-direction: column; }
|
||||
.header-actions { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="header"><div><h2>AWS 商品绑定</h2><p>配置商品可售目录、默认参数和强制策略</p></div><el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon>新增绑定</el-button></div>
|
||||
<div class="filters"><el-select v-model="query.service_id" clearable placeholder="全部 AWS 服务" @change="search"><el-option v-for="s in services" :key="s.id" :label="s.name" :value="s.id"/></el-select><el-button @click="reset">重置</el-button></div>
|
||||
<el-table v-loading="loading" :data="rows" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70"/>
|
||||
<el-table-column label="商品 ID" width="105"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/product/manage',query:{good_id:row.goodId}})">{{row.goodId}}</el-link></template></el-table-column>
|
||||
<el-table-column label="服务" min-width="140"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/aws/services',query:{id:row.serviceId}})">{{serviceName(row.serviceId)}} (#{{row.serviceId}})</el-link></template></el-table-column>
|
||||
<el-table-column label="区域" min-width="190"><template #default="{row}"><el-tag v-for="v in row.allowedRegions||[]" :key="v" size="small" class="tag">{{v}}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="exchangeRate" label="汇率" width="90"/>
|
||||
<el-table-column prop="gracePeriodHours" label="宽限期(h)" width="100"/>
|
||||
<el-table-column label="强制策略" min-width="210"><template #default="{row}"><el-tag v-if="row.forceAssociateEip" size="small">EIP</el-tag><el-tag v-if="row.forceEncryptedRootVolume" size="small" type="success">加密系统盘</el-tag><el-tag v-if="row.forceInstallAgent" size="small" type="warning">Agent</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="80"><template #default="{row}"><el-tag :type="row.enabled?'success':'info'">{{row.enabled?'启用':'停用'}}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="135" fixed="right"><template #default="{row}"><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-popconfirm title="确认删除无历史记录的绑定?" @confirm="remove(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pager"><el-pagination v-model:current-page="query.page" v-model:page-size="query.count" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next" @change="load"/></div>
|
||||
<el-dialog v-model="dialog" :title="editing?'编辑 AWS 商品绑定':'新增 AWS 商品绑定'" width="820px" top="4vh" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="145px">
|
||||
<el-row :gutter="18"><el-col :span="12"><el-form-item label="商品 ID" prop="good_id"><el-input-number v-model="form.good_id" :min="1" :disabled="editing" style="width:100%"/></el-form-item></el-col><el-col :span="12"><el-form-item label="AWS 服务" prop="service_id"><el-select v-model="form.service_id" style="width:100%"><el-option v-for="s in services" :key="s.id" :label="`${s.name} (#${s.id})`" :value="s.id"/></el-select></el-form-item></el-col></el-row>
|
||||
<el-form-item label="允许区域" prop="allowed_regions"><el-select v-model="form.allowed_regions" multiple filterable allow-create default-first-option placeholder="输入 Region 后回车" style="width:100%"/></el-form-item>
|
||||
<el-form-item label="镜像分类白名单"><el-select v-model="form.allowed_image_categories" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
|
||||
<el-form-item label="镜像白名单"><el-select v-model="form.allowed_images" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
|
||||
<el-form-item label="规格分类白名单"><el-select v-model="form.allowed_type_categories" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
|
||||
<el-form-item label="规格白名单"><el-select v-model="form.allowed_types" multiple filterable allow-create default-first-option style="width:100%"/></el-form-item>
|
||||
<el-row :gutter="18"><el-col :span="12"><el-form-item label="人民币汇率" prop="exchange_rate"><el-input-number v-model="form.exchange_rate" :min="0.000001" :precision="6" style="width:100%"/></el-form-item></el-col><el-col :span="12"><el-form-item label="宽限期(小时)"><el-input-number v-model="form.grace_period_hours" :min="0" style="width:100%"/></el-form-item></el-col></el-row>
|
||||
<el-form-item label="强制策略"><el-checkbox v-model="form.force_associate_eip">绑定 EIP</el-checkbox><el-checkbox v-model="form.force_encrypted_root_volume">加密系统盘</el-checkbox><el-checkbox v-model="form.force_install_agent">安装 Agent</el-checkbox><el-checkbox v-model="form.enabled">启用绑定</el-checkbox></el-form-item>
|
||||
<el-form-item label="默认创建参数" prop="defaults_text"><el-input v-model="form.defaults_text" type="textarea" :rows="7" placeholder="{}" class="json"/></el-form-item>
|
||||
<el-form-item label="商品约束" prop="constraints_text"><el-input v-model="form.constraints_text" type="textarea" :rows="6" placeholder="{}" class="json"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { createAwsGoods, deleteAwsGoods, getAwsGoodsDetail, getAwsGoodsList, getAwsServiceList, updateAwsGoods } from '@/api/admin/awsService.js'
|
||||
const route=useRoute(), loading=ref(false), submitting=ref(false), rows=ref([]), services=ref([]), total=ref(0), dialog=ref(false), editing=ref(false), formRef=ref()
|
||||
const query=reactive({page:1,count:20,service_id:route.query.service_id?Number(route.query.service_id):undefined})
|
||||
const empty=()=>({id:null,good_id:null,service_id:null,allowed_regions:[],allowed_image_categories:[],allowed_images:[],allowed_type_categories:[],allowed_types:[],defaults_text:'{}',constraints_text:'{}',force_associate_eip:false,force_encrypted_root_volume:true,force_install_agent:true,grace_period_hours:168,exchange_rate:1,enabled:true})
|
||||
const form=reactive(empty())
|
||||
const jsonRule=(_,value,cb)=>{try{const v=JSON.parse(value||'{}');if(!v||Array.isArray(v)||typeof v!=='object')throw new Error();cb()}catch{cb(new Error('请输入有效的 JSON 对象'))}}
|
||||
const rules={good_id:[{required:true,message:'请输入商品 ID'}],service_id:[{required:true,message:'请选择服务'}],allowed_regions:[{type:'array',required:true,min:1,message:'至少填写一个区域'}],exchange_rate:[{required:true,message:'请输入大于 0 的汇率'}],defaults_text:[{validator:jsonRule}],constraints_text:[{validator:jsonRule}]}
|
||||
const body=r=>r?.data||{}
|
||||
async function loadServices(){const b=body(await getAwsServiceList({page:1,count:100}));if(b.code===200)services.value=b.data?.data||b.data?.list||[]}
|
||||
async function load(){loading.value=true;try{const params={...query};if(!params.service_id)delete params.service_id;const b=body(await getAwsGoodsList(params));if(b.code===200){rows.value=b.data?.data||b.data?.list||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}}
|
||||
const serviceName=id=>services.value.find(v=>v.id===id)?.name||'服务'
|
||||
function search(){query.page=1;load()} function reset(){query.service_id=undefined;search()}
|
||||
function openCreate(){editing.value=false;Object.assign(form,empty(),{service_id:query.service_id||null});dialog.value=true}
|
||||
async function openEdit(row){editing.value=true;let data=row;try{const b=body(await getAwsGoodsDetail(row.id));if(b.code===200)data=b.data}catch{}Object.assign(form,{id:data.id,good_id:data.goodId,service_id:data.serviceId,allowed_regions:[...(data.allowedRegions||[])],allowed_image_categories:[...(data.allowedImageCategories||[])],allowed_images:[...(data.allowedImages||[])],allowed_type_categories:[...(data.allowedTypeCategories||[])],allowed_types:[...(data.allowedTypes||[])],defaults_text:JSON.stringify(data.defaults||{},null,2),constraints_text:JSON.stringify(data.constraints||{},null,2),force_associate_eip:!!data.forceAssociateEip,force_encrypted_root_volume:!!data.forceEncryptedRootVolume,force_install_agent:!!data.forceInstallAgent,grace_period_hours:data.gracePeriodHours??168,exchange_rate:data.exchangeRate??1,enabled:!!data.enabled});dialog.value=true}
|
||||
function payload(){return{service_id:form.service_id,allowed_regions:form.allowed_regions,allowed_image_categories:form.allowed_image_categories,allowed_images:form.allowed_images,allowed_type_categories:form.allowed_type_categories,allowed_types:form.allowed_types,defaults:JSON.parse(form.defaults_text||'{}'),constraints:JSON.parse(form.constraints_text||'{}'),force_associate_eip:form.force_associate_eip,force_encrypted_root_volume:form.force_encrypted_root_volume,force_install_agent:form.force_install_agent,grace_period_hours:form.grace_period_hours,exchange_rate:form.exchange_rate,enabled:form.enabled}}
|
||||
async function submit(){await formRef.value?.validate();submitting.value=true;try{const data=payload();if(editing.value)data.id=form.id;else data.good_id=form.good_id;const b=body(await(editing.value?updateAwsGoods(data):createAwsGoods(data)));if(b.code===200){ElMessage.success('保存成功');dialog.value=false;load()}else ElMessage.error(b.message||'保存失败')}finally{submitting.value=false}}
|
||||
async function remove(row){const b=body(await deleteAwsGoods(row.id));if(b.code===200){ElMessage.success('删除成功');load()}else ElMessage.error(b.message||'删除失败')}
|
||||
onMounted(()=>{loadServices();load()})
|
||||
</script>
|
||||
<style scoped>
|
||||
.page{padding:20px}.header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;margin-bottom:18px;border:1px solid #d9ecff;border-radius:12px;background:linear-gradient(135deg,#f0f7ff,#f7fbff)}h2{margin:0}.header p{margin:6px 0 0;color:#909399}.filters{display:flex;gap:10px;margin-bottom:16px}.filters .el-select{width:260px}.pager{display:flex;justify-content:flex-end;padding-top:16px}.tag{margin:2px}.json :deep(textarea){font-family:Consolas,Monaco,monospace}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="header"><div><h2>AWS 履约实例</h2><p>查看远端实例快照并审核失败任务</p></div><el-button @click="load">刷新</el-button></div>
|
||||
<div class="filters"><el-select v-model="query.status" clearable placeholder="全部履约状态" @change="search"><el-option v-for="s in statuses" :key="s" :label="statusText(s)" :value="s"/></el-select><el-button @click="reset">重置</el-button></div>
|
||||
<el-table v-loading="loading" :data="rows" border stripe>
|
||||
<el-table-column prop="id" label="实例 ID" width="90"/>
|
||||
<el-table-column label="用户商品" width="105"><template #default="{row}"><el-link type="primary" @click="$router.push(`/user-goods/detail/${row.userGoodsId}`)">{{row.userGoodsId}}</el-link></template></el-table-column>
|
||||
<el-table-column label="订单" width="100"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/order/list',query:{order_id:row.orderId}})">{{row.orderId}}</el-link></template></el-table-column>
|
||||
<el-table-column label="服务" width="95"><template #default="{row}"><el-link type="primary" @click="$router.push({path:'/aws/services',query:{id:row.serviceId}})">{{row.serviceId}}</el-link></template></el-table-column>
|
||||
<el-table-column prop="region" label="Region" min-width="125"/>
|
||||
<el-table-column prop="remoteAwsInstanceId" label="AWS 实例" min-width="160" show-overflow-tooltip/>
|
||||
<el-table-column label="履约状态" width="110"><template #default="{row}"><el-tag :type="statusType(row.provisionStatus)">{{statusText(row.provisionStatus)}}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="pendingAction" label="待执行" width="90"/>
|
||||
<el-table-column prop="retryCount" label="重试" width="65"/>
|
||||
<el-table-column prop="lastError" label="最近错误" min-width="180" show-overflow-tooltip/>
|
||||
<el-table-column label="最近同步" width="170"><template #default="{row}">{{formatTime(row.lastSyncedAt)}}</template></el-table-column>
|
||||
<el-table-column label="操作" width="135" fixed="right"><template #default="{row}"><el-button link type="primary" @click="showDetail(row)">详情</el-button><el-popconfirm v-if="row.provisionStatus==='failed'" title="确认审核并重试该履约?" @confirm="retry(row)"><template #reference><el-button link type="warning" :loading="retryingId===row.id">重试</el-button></template></el-popconfirm></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pager"><el-pagination v-model:current-page="query.page" v-model:page-size="query.count" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next" @change="load"/></div>
|
||||
<el-drawer v-model="drawer" title="AWS 履约详情" size="600px">
|
||||
<template v-if="current"><el-descriptions :column="2" border><el-descriptions-item label="实例 ID">{{current.id}}</el-descriptions-item><el-descriptions-item label="状态">{{statusText(current.provisionStatus)}}</el-descriptions-item><el-descriptions-item label="远端实例">{{current.remoteAwsInstanceId||'-'}}</el-descriptions-item><el-descriptions-item label="任务 ID">{{current.remoteTaskId||'-'}}</el-descriptions-item><el-descriptions-item label="错误代码">{{current.lastErrorCode||'-'}}</el-descriptions-item><el-descriptions-item label="下次重试">{{formatTime(current.nextRetryAt)}}</el-descriptions-item><el-descriptions-item label="错误信息" :span="2">{{current.lastError||'-'}}</el-descriptions-item></el-descriptions><h4>资源快照</h4><pre class="snapshot">{{snapshotText}}</pre></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getAwsInstanceList, retryAwsInstance } from '@/api/admin/awsService.js'
|
||||
const statuses=['pending','submitting','provisioning','active','failed','stopping','stopped','terminating','terminated']
|
||||
const labels={pending:'等待中',submitting:'提交中',provisioning:'创建中',active:'运行中',failed:'失败',stopping:'停止中',stopped:'已停止',terminating:'终止中',terminated:'已终止'}
|
||||
const loading=ref(false), retryingId=ref(null), rows=ref([]), total=ref(0), drawer=ref(false), current=ref(null)
|
||||
const query=reactive({page:1,count:20,status:undefined})
|
||||
const body=r=>r?.data||{}
|
||||
const statusText=s=>labels[s]||s||'-'
|
||||
const statusType=s=>s==='active'?'success':s==='failed'?'danger':['pending','submitting','provisioning','stopping','terminating'].includes(s)?'warning':'info'
|
||||
const formatTime=t=>t?new Date(t).toLocaleString('zh-CN',{hour12:false}):'-'
|
||||
const snapshotText=computed(()=>{const raw=current.value?.resourceSnapshot;if(!raw)return '暂无资源快照';try{return JSON.stringify(typeof raw==='string'?JSON.parse(raw):raw,null,2)}catch{return String(raw)}})
|
||||
async function load(){loading.value=true;try{const params={...query};if(!params.status)delete params.status;const b=body(await getAwsInstanceList(params));if(b.code===200){rows.value=b.data?.list||b.data?.data||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}}
|
||||
function search(){query.page=1;load()} function reset(){query.status=undefined;search()} function showDetail(row){current.value=row;drawer.value=true}
|
||||
async function retry(row){retryingId.value=row.id;try{const b=body(await retryAwsInstance(row.id));if(b.code===200){ElMessage.success('已接受重试');load()}else ElMessage.error(b.message||'重试失败')}finally{retryingId.value=null}}
|
||||
onMounted(load)
|
||||
</script>
|
||||
<style scoped>
|
||||
.page{padding:20px}.header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;margin-bottom:18px;border:1px solid #d9ecff;border-radius:12px;background:linear-gradient(135deg,#f0f7ff,#f7fbff)}h2{margin:0}.header p{margin:6px 0 0;color:#909399}.filters{display:flex;gap:10px;margin-bottom:16px}.filters .el-select{width:230px}.pager{display:flex;justify-content:flex-end;padding-top:16px}.snapshot{padding:16px;border-radius:8px;background:#f5f7fa;white-space:pre-wrap;word-break:break-all;max-height:55vh;overflow:auto}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="header"><div><h2>AWS 服务配置</h2><p>管理 aws-server-sdk 连接和目录缓存策略</p></div><el-button type="primary" @click="openCreate"><el-icon><Plus /></el-icon>新增服务</el-button></div>
|
||||
<div class="filters"><el-input v-model="query.key" clearable placeholder="搜索名称、地址或备注" @keyup.enter="search" @clear="search"/><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></div>
|
||||
<el-table v-loading="loading" :data="rows" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="70"/>
|
||||
<el-table-column prop="name" label="名称" min-width="140"/>
|
||||
<el-table-column prop="baseUrl" label="服务地址" min-width="220" show-overflow-tooltip/>
|
||||
<el-table-column label="Token" width="150"><template #default="{ row }"><el-tag :type="row.tokenConfigured ? 'success' : 'danger'">{{ row.tokenConfigured ? (row.tokenPrefix || '已配置') : '未配置' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="timeoutSec" label="超时(秒)" width="95"/>
|
||||
<el-table-column prop="catalogTtl" label="目录TTL" width="95"/>
|
||||
<el-table-column label="状态" width="85"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="note" label="备注" min-width="140" show-overflow-tooltip/>
|
||||
<el-table-column label="操作" width="235" fixed="right"><template #default="{ row }"><el-button link type="success" :loading="testingId===row.id" @click="test(row)">测试</el-button><el-button link type="primary" @click="openEdit(row)">编辑</el-button><el-button link @click="$router.push({path:'/aws/goods',query:{service_id:row.id}})">商品</el-button><el-popconfirm title="确认删除未被绑定的服务?" @confirm="remove(row)"><template #reference><el-button link type="danger">删除</el-button></template></el-popconfirm></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="pager"><el-pagination v-model:current-page="query.page" v-model:page-size="query.count" :total="total" :page-sizes="[10,20,50]" layout="total, sizes, prev, pager, next" @change="load"/></div>
|
||||
<el-dialog v-model="dialog" :title="editing ? '编辑 AWS 服务' : '新增 AWS 服务'" width="600px" 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"/></el-form-item>
|
||||
<el-form-item label="服务地址" prop="base_url"><el-input v-model="form.base_url" placeholder="https://aws.example.com"/></el-form-item>
|
||||
<el-form-item label="App Token" prop="app_token"><el-input v-model="form.app_token" show-password :placeholder="editing ? '留空保留原 Token' : 'awsapp_...'"/></el-form-item>
|
||||
<el-form-item label="请求超时" prop="timeout_sec"><el-input-number v-model="form.timeout_sec" :min="1" :max="300"/></el-form-item>
|
||||
<el-form-item label="目录缓存" prop="catalog_ttl"><el-input-number v-model="form.catalog_ttl" :min="0"/></el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="form.enabled"/></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.note" type="textarea" :rows="3"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { createAwsService, deleteAwsService, getAwsServiceList, testAwsService, updateAwsService } from '@/api/admin/awsService.js'
|
||||
|
||||
const loading=ref(false), submitting=ref(false), testingId=ref(null), rows=ref([]), total=ref(0), dialog=ref(false), editing=ref(false), formRef=ref()
|
||||
const query=reactive({page:1,count:20,key:''})
|
||||
const empty=()=>({id:null,name:'',base_url:'',app_token:'',enabled:true,timeout_sec:30,catalog_ttl:60,note:''})
|
||||
const form=reactive(empty())
|
||||
const rules={name:[{required:true,message:'请输入名称'}],base_url:[{required:true,message:'请输入服务地址'},{type:'url',message:'请输入有效 URL'}],app_token:[{validator:(_,v,cb)=>{if(!editing.value&&!v)return cb(new Error('请输入 App Token'));if(v&&!v.startsWith('awsapp_'))return cb(new Error('Token 必须以 awsapp_ 开头'));cb()}}]}
|
||||
const body=r=>r?.data||{}
|
||||
async function load(){loading.value=true;try{const b=body(await getAwsServiceList(query));if(b.code===200){rows.value=b.data?.data||b.data?.list||[];total.value=b.data?.all_count||0}else ElMessage.error(b.message||'加载失败')}finally{loading.value=false}}
|
||||
function search(){query.page=1;load()} function reset(){query.key='';search()}
|
||||
function openCreate(){editing.value=false;Object.assign(form,empty());dialog.value=true}
|
||||
function openEdit(row){editing.value=true;Object.assign(form,{id:row.id,name:row.name||'',base_url:row.baseUrl||row.base_url||'',app_token:'',enabled:!!row.enabled,timeout_sec:row.timeoutSec??30,catalog_ttl:row.catalogTtl??60,note:row.note||''});dialog.value=true}
|
||||
async function submit(){await formRef.value?.validate();submitting.value=true;try{const data={name:form.name.trim(),base_url:form.base_url.trim(),enabled:form.enabled,timeout_sec:form.timeout_sec,catalog_ttl:form.catalog_ttl,note:form.note};if(form.app_token)data.app_token=form.app_token; if(editing.value)data.id=form.id;const b=body(await (editing.value?updateAwsService(data):createAwsService(data)));if(b.code===200){ElMessage.success('保存成功');dialog.value=false;load()}else ElMessage.error(b.message||'保存失败')}finally{submitting.value=false}}
|
||||
async function test(row){testingId.value=row.id;try{const b=body(await testAwsService(row.id));b.code===200?ElMessage.success(`连接成功${b.data?.latency_ms?`,${b.data.latency_ms}ms`:''}`):ElMessage.error(b.message||'连接失败')}finally{testingId.value=null}}
|
||||
async function remove(row){const b=body(await deleteAwsService(row.id));if(b.code===200){ElMessage.success('删除成功');load()}else ElMessage.error(b.message||'删除失败')}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:20px}.header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;margin-bottom:18px;border:1px solid #d9ecff;border-radius:12px;background:linear-gradient(135deg,#f0f7ff,#f7fbff)}h2{margin:0;color:#303133}.header p{margin:6px 0 0;color:#909399}.filters{display:flex;gap:10px;margin-bottom:16px}.filters .el-input{width:320px}.pager{display:flex;justify-content:flex-end;padding-top:16px}
|
||||
</style>
|
||||
@@ -162,6 +162,7 @@
|
||||
<span v-if="row.isGroup">{{ row.note || '-' }}</span>
|
||||
<div v-else-if="row.isProduct" class="product-info-inline">
|
||||
<span class="product-price">¥{{ (row.data.price / 100).toFixed(2) }}</span>
|
||||
<el-tag v-if="row.data.remoteGoods || row.data.remote_goods" type="primary" size="small">远端商品</el-tag>
|
||||
<el-tag v-if="row.data.inventoryControl" type="info" size="small">库存: {{ row.data.inventory }}</el-tag>
|
||||
<el-tag v-if="row.data.recommend" type="warning" size="small">推荐</el-tag>
|
||||
<el-tag v-if="row.data.expireTime && row.data.expireTime > 0" type="info" size="small">有效期: {{ row.data.expireTime }}天</el-tag>
|
||||
@@ -682,6 +683,14 @@
|
||||
/>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">启用后,用户购买/续费/升级此商品前须完成实名认证</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="远端商品" prop="remote_goods">
|
||||
<el-switch
|
||||
v-model="productForm.remote_goods"
|
||||
active-text="远端计价/履约"
|
||||
inactive-text="普通商品"
|
||||
/>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">AWS 商品请同时将标签设为 aws,保存后到 AWS 商品绑定页配置目录规则</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -960,6 +969,7 @@ const productForm = reactive({
|
||||
arg_type: 'all',
|
||||
can_renew: true,
|
||||
require_real_name: false,
|
||||
remote_goods: false,
|
||||
sold_out: false,
|
||||
max_per_user: 0,
|
||||
max_first_purchase_duration: 0,
|
||||
@@ -1621,6 +1631,7 @@ const handleAddProduct = () => {
|
||||
recommend_rebate: 0,
|
||||
arg_type: 'all',
|
||||
require_real_name: false,
|
||||
remote_goods: false,
|
||||
max_per_user: 0,
|
||||
max_first_purchase_duration: 0,
|
||||
recommend_words: ''
|
||||
@@ -1658,6 +1669,7 @@ const handleEditProduct = (product, parentGroupId) => {
|
||||
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,
|
||||
remote_goods: product.remoteGoods ?? product.remote_goods ?? 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,
|
||||
@@ -1691,6 +1703,7 @@ const submitProductForm = () => {
|
||||
arg_type: productForm.arg_type,
|
||||
can_renew: productForm.can_renew,
|
||||
require_real_name: productForm.require_real_name,
|
||||
remote_goods: productForm.remote_goods === true,
|
||||
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,
|
||||
|
||||
@@ -859,7 +859,7 @@
|
||||
|
||||
<!-- 迁移虚拟机弹窗 -->
|
||||
<el-dialog v-model="migrateVisible" title="迁移虚拟机(更换宿主机)" width="560px" destroy-on-close>
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:16px">将虚拟机迁移到其他宿主机或宿主机组,请谨慎操作!</el-alert>
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px">宿主机迁移为转移配置后重新开通,不保留原虚拟机数据,请谨慎操作!</el-alert>
|
||||
<el-form label-width="120px" v-loading="migrateOptionsLoading">
|
||||
<el-form-item label="迁移方式">
|
||||
<el-radio-group v-model="migrateMode">
|
||||
@@ -889,6 +889,10 @@
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="到期顺延(天)">
|
||||
<el-input-number v-model="migrateForm.expire_offset_days" :min="0" :step="1" :precision="0" controls-position="right" style="width:100%" />
|
||||
<div style="font-size:12px;color:#909399">迁移完成后,用户商品到期时间向后顺延指定天数;0 表示不调整</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="migrateVisible = false">取消</el-button>
|
||||
@@ -2049,10 +2053,10 @@ const migrateOptionsLoading = ref(false)
|
||||
const migrateMode = ref('host')
|
||||
const migrateHostOptions = ref([])
|
||||
const migrateGroupOptions = ref([])
|
||||
const migrateForm = reactive({ target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 })
|
||||
const migrateForm = reactive({ target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0, expire_offset_days: 0 })
|
||||
|
||||
const openMigrateVm = async () => {
|
||||
Object.assign(migrateForm, { target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0 })
|
||||
Object.assign(migrateForm, { target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0, expire_offset_days: 0 })
|
||||
migrateMode.value = 'host'
|
||||
migrateVisible.value = true
|
||||
migrateOptionsLoading.value = true
|
||||
@@ -2097,6 +2101,7 @@ const submitMigrateVm = async () => {
|
||||
else payload.target_host_group_id = migrateForm.target_host_group_id
|
||||
if (migrateForm.ipv4_num) payload.ipv4_num = migrateForm.ipv4_num
|
||||
if (migrateForm.ipv6_num) payload.ipv6_num = migrateForm.ipv6_num
|
||||
if (migrateForm.expire_offset_days > 0) payload.expire_offset_days = migrateForm.expire_offset_days
|
||||
const res = await migrateUserVm(payload)
|
||||
if (res?.data?.code === 200) {
|
||||
const migrationData = res.data.data || {}
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="升级金额" width="120">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.floor_price || row.FloorPrice" class="price-text">¥{{ row.floor_price || row.FloorPrice }}</span>
|
||||
<span v-if="getFloorPriceYuan(row) !== null" class="price-text">¥{{ getFloorPriceYuan(row).toFixed(2) }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -66,6 +66,12 @@
|
||||
{{ row.higher_level_id || row.HigherLevelId || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="默认组" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="isDefaultGroup(row)" type="success" size="small">默认</el-tag>
|
||||
<span v-else class="muted-text">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(row.fixed || row.Fixed) ? 'warning' : 'success'" size="small">
|
||||
@@ -85,9 +91,12 @@
|
||||
{{ row.create_time || row.CreateTime || row.CreatedAt || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="340" fixed="right">
|
||||
<el-table-column label="操作" width="420" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button v-if="!isDefaultGroup(row)" type="success" link @click="handleSetDefault(row)">
|
||||
<el-icon><CircleCheck /></el-icon>设为默认
|
||||
</el-button>
|
||||
<el-button type="primary" link @click="handleEdit(row)">
|
||||
<el-icon><Edit /></el-icon>编辑
|
||||
</el-button>
|
||||
@@ -465,9 +474,10 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Edit, User, Delete, Connection, Close, Present, CircleCheck } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getUserGroupList,
|
||||
getUserGroupList,
|
||||
setDefaultUserGroup,
|
||||
getUserGroupMemberList,
|
||||
createUserGroup,
|
||||
updateUserGroupInfo,
|
||||
@@ -546,6 +556,14 @@ const memberFormRef = ref(null)
|
||||
const groupSelectorVisible = ref(false)
|
||||
const selectedHigherGroupInfo = ref(null)
|
||||
|
||||
const getGroupId = (row) => row.group_id ?? row.GroupId ?? row.id ?? row.Id
|
||||
const isDefaultGroup = (row) => row.IsDefault === true || row.is_default === true
|
||||
const getFloorPriceCent = (row) => row.floor_price ?? row.FloorPrice
|
||||
const getFloorPriceYuan = (row) => {
|
||||
const value = getFloorPriceCent(row)
|
||||
return value === undefined || value === null || value === '' ? null : Number(value) / 100
|
||||
}
|
||||
|
||||
// 获取用户组列表
|
||||
const fetchGroupList = async () => {
|
||||
loading.value = true
|
||||
@@ -554,10 +572,6 @@ const fetchGroupList = async () => {
|
||||
const code = res.data?.code || res.code
|
||||
if (code === 200) {
|
||||
let responseData = res.data?.data || res.data
|
||||
responseData.data.forEach(item => {
|
||||
item.CreatedAt = formatTime(item.CreatedAt)
|
||||
})
|
||||
|
||||
if (Array.isArray(responseData)) {
|
||||
groupList.value = responseData
|
||||
total.value = responseData.length
|
||||
@@ -571,6 +585,9 @@ const fetchGroupList = async () => {
|
||||
groupList.value = []
|
||||
total.value = 0
|
||||
}
|
||||
groupList.value.forEach(item => {
|
||||
if (item.CreatedAt) item.CreatedAt = formatTime(item.CreatedAt)
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(res.data?.message || '获取用户组列表失败')
|
||||
}
|
||||
@@ -581,6 +598,30 @@ const fetchGroupList = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetDefault = (row) => {
|
||||
const groupId = getGroupId(row)
|
||||
const groupName = row.group_name || row.name || row.Name || `用户组 ${groupId}`
|
||||
|
||||
ElMessageBox.confirm(`确认将“${groupName}”设为默认用户组吗?设置后原默认组将自动取消默认。`, '设置默认用户组', {
|
||||
confirmButtonText: '确认设置',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}).then(async () => {
|
||||
try {
|
||||
const res = await setDefaultUserGroup({ user_group_id: groupId })
|
||||
const code = res.data?.code || res.code
|
||||
if (code === 200) {
|
||||
ElMessage.success('默认用户组设置成功')
|
||||
await fetchGroupList()
|
||||
} else {
|
||||
ElMessage.error(res.data?.message || '设置默认用户组失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error.response?.data?.message || '设置默认用户组失败')
|
||||
}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 获取成员列表
|
||||
const fetchMemberList = async () => {
|
||||
memberLoading.value = true
|
||||
@@ -659,7 +700,7 @@ const handleEdit = (row) => {
|
||||
const groupName = row.group_name || row.name || row.Name
|
||||
const groupAuth = row.auth || row.Auth || ''
|
||||
const higherLevelId = row.higher_level_id || row.HigherLevelId
|
||||
const floorPrice = row.floor_price || row.FloorPrice
|
||||
const floorPrice = getFloorPriceYuan(row)
|
||||
const fixed = row.fixed || row.Fixed || false
|
||||
|
||||
Object.assign(groupForm, {
|
||||
@@ -743,7 +784,7 @@ const submitForm = () => {
|
||||
submitData.higher_level_id = groupForm.higher_level_id
|
||||
}
|
||||
if (groupForm.floor_price !== undefined && groupForm.floor_price !== null) {
|
||||
submitData.floor_price = groupForm.floor_price
|
||||
submitData.floor_price = Math.round(Number(groupForm.floor_price) * 100)
|
||||
}
|
||||
submitData.fixed = groupForm.fixed
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<!-- <el-button type="warning" plain @click="openTokenDialog"><el-icon><Key /></el-icon>创建注册令牌</el-button> -->
|
||||
<el-button :type="detail.disabled ? 'success' : 'warning'" plain :loading="disabledActionLoading" @click="handleSetDisabled">{{ detail.disabled ? '启用宿主机' : '禁用宿主机' }}</el-button>
|
||||
<el-button type="primary" plain @click="handleEdit">编辑宿主机</el-button>
|
||||
<el-button type="danger" plain @click="handleDelete">删除</el-button>
|
||||
</div>
|
||||
@@ -28,8 +29,8 @@
|
||||
<div class="status-item">
|
||||
<span class="status-label">状态</span>
|
||||
<span class="status-value">
|
||||
<span class="status-dot" :class="detail.is_active ? 'dot-running' : 'dot-other'"></span>
|
||||
{{ detail.is_active ? '启用' : '禁用' }}
|
||||
<span class="status-dot" :class="detail.disabled ? 'dot-disabled' : (detail.is_active ? 'dot-running' : 'dot-other')"></span>
|
||||
{{ detail.disabled ? '已禁用' : (detail.is_active ? '在线' : '离线') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
@@ -856,6 +857,12 @@
|
||||
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="调度状态">
|
||||
<div>
|
||||
<el-switch v-model="formData.disabled" active-text="禁用" inactive-text="启用" />
|
||||
<div class="tk-form-tip">禁用后不参与自动调度,也不能作为新建或迁移目标;已有虚拟机不受影响。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
@@ -1022,7 +1029,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, ArrowRight, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument, DataAnalysis } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost,
|
||||
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost, setRemoteHostDisabled,
|
||||
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
|
||||
assignUserNetworking, removeUserNetworkingNetwork,
|
||||
createHostToken, getMetricsHistory, getHostMetricsTop, getHostQuotaStats,
|
||||
@@ -1170,7 +1177,7 @@ const showHardwareInfo = ref(false)
|
||||
|
||||
const formData = reactive({
|
||||
name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '',
|
||||
max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
|
||||
max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', disabled: false,
|
||||
...diskIoDefaults
|
||||
})
|
||||
const formRules = {
|
||||
@@ -1807,7 +1814,7 @@ const handleEdit = () => {
|
||||
port: d.port || 22, user: d.user || '', password: d.password || '', private_key: d.private_key || '',
|
||||
max_cpu: d.max_cpu || 0, max_memory: d.max_memory || 0, max_disk: d.max_disk || 0,
|
||||
rx_bandwidth: d.rx_bandwidth || 0, tx_bandwidth: d.tx_bandwidth || 0,
|
||||
host_group_id: d.host_group_id || 0, description: d.description || '',
|
||||
host_group_id: d.host_group_id || 0, description: d.description || '', disabled: !!d.disabled,
|
||||
...Object.fromEntries(diskIoFields.map(f => [f.key, d[f.key] ?? diskIoDefaults[f.key]]))
|
||||
})
|
||||
showDiskIoSection.value = diskIoFields.some(f => d[f.key] && d[f.key] !== diskIoDefaults[f.key])
|
||||
@@ -1820,18 +1827,42 @@ const handleSubmit = () => {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { ...formData, service_id: serviceId.value, id: hostId.value }
|
||||
const disabled = payload.disabled
|
||||
delete payload.disabled
|
||||
if (!payload.token) delete payload.token
|
||||
if (!payload.password) delete payload.password
|
||||
if (!payload.private_key) delete payload.private_key
|
||||
if (!payload.description) delete payload.description
|
||||
if (!payload.host_group_id) delete payload.host_group_id
|
||||
const res = await updateRemoteHost(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||||
if (res?.data?.code === 200) {
|
||||
const disabledRes = await setRemoteHostDisabled({ service_id: serviceId.value, id: hostId.value, disabled })
|
||||
if (disabledRes?.data?.code === 200) { ElMessage.success('修改成功'); editDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(extractApiError(disabledRes?.data, '设置禁用状态失败'))
|
||||
} else ElMessage.error(extractApiError(res?.data, '修改失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '修改失败')) } finally { submitLoading.value = false }
|
||||
})
|
||||
}
|
||||
|
||||
const disabledActionLoading = ref(false)
|
||||
const handleSetDisabled = () => {
|
||||
if (!detail.value) return
|
||||
const disabled = !detail.value.disabled
|
||||
const action = disabled ? '禁用' : '启用'
|
||||
const message = disabled
|
||||
? `禁用宿主机「${detail.value.name}」后,它将不参与自动调度且不能作为新建或迁移目标,已有虚拟机不受影响。是否继续?`
|
||||
: `确定要启用宿主机「${detail.value.name}」参与自动调度吗?`
|
||||
ElMessageBox.confirm(message, `${action}宿主机`, { type: disabled ? 'warning' : 'info' }).then(async () => {
|
||||
disabledActionLoading.value = true
|
||||
try {
|
||||
const res = await setRemoteHostDisabled({ service_id: serviceId.value, id: hostId.value, disabled })
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, `${action}失败`))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${action}失败`)) }
|
||||
finally { disabledActionLoading.value = false }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
ElMessageBox.confirm(`确定要删除宿主机「${detail.value?.name}」吗?`, '删除确认', {
|
||||
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
|
||||
@@ -2181,6 +2212,7 @@ onBeforeUnmount(() => { isPageActive = false; disposeCharts() })
|
||||
.status-value { font-size: 14px; color: #1d2129; font-weight: 500; display: flex; align-items: center; gap: 6px; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
|
||||
.dot-running { background: #00b42a; }
|
||||
.dot-disabled { background: #f53f3f; }
|
||||
.dot-other { background: #c9cdd4; }
|
||||
|
||||
.detail-tabs { background: #fff; border-radius: 4px; border: 1px solid #e8e8e8; padding: 0 24px; }
|
||||
|
||||
@@ -77,15 +77,17 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'danger'" size="small">{{ row.is_active ? '启用' : '禁用' }}</el-tag>
|
||||
<el-tag v-if="row.disabled" type="danger" size="small">已禁用</el-tag>
|
||||
<el-tag v-else :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '在线' : '离线' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatTimestamp(row.created_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="handleGoDetail(row)">编辑</el-button>
|
||||
<el-button link :type="row.disabled ? 'success' : 'warning'" :loading="disabledActionHostId === row.id" @click="handleSetDisabled(row)">{{ row.disabled ? '启用' : '禁用' }}</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -194,6 +196,12 @@
|
||||
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0; formData._groupName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="dialogType === 'edit'" label="调度状态">
|
||||
<div>
|
||||
<el-switch v-model="formData.disabled" active-text="禁用" inactive-text="启用" />
|
||||
<div class="tk-form-tip">禁用后不参与自动调度,也不能作为新建或迁移目标;已有虚拟机不受影响。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="宿主机介绍(可选)" />
|
||||
</el-form-item>
|
||||
@@ -433,7 +441,7 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowRight, Monitor, Coin, Connection, Key, CopyDocument } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getRemoteHostDetail,
|
||||
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
||||
addRemoteHost, updateRemoteHost, deleteRemoteHost, setRemoteHostDisabled,
|
||||
getHostGroupList, createHostToken, getMetricsHistory
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
@@ -450,6 +458,7 @@ const serviceName = computed(() => injectedServiceName?.value || route.query.ser
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const disabledActionHostId = ref(null)
|
||||
const detailLoading = ref(false)
|
||||
const metricsLoading = ref(false)
|
||||
const hostList = ref([])
|
||||
@@ -510,7 +519,7 @@ const formData = reactive({
|
||||
id: undefined, name: '', base_url: '', ip: '', token: '',
|
||||
port: 22, user: '', password: '', private_key: '',
|
||||
max_cpu: 0, max_memory: 0, max_disk: 0,
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', disabled: false,
|
||||
_groupName: '',
|
||||
...diskIoDefaults
|
||||
})
|
||||
@@ -610,7 +619,7 @@ const resetForm = () => {
|
||||
id: undefined, name: '', base_url: '', ip: '', token: '',
|
||||
port: 22, user: '', password: '', private_key: '',
|
||||
max_cpu: 0, max_memory: 0, max_disk: 0,
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', disabled: false,
|
||||
_groupName: '', ...diskIoDefaults
|
||||
})
|
||||
showDiskIoSection.value = false
|
||||
@@ -675,7 +684,7 @@ const handleEdit = (row) => {
|
||||
port: row.port || 22, user: row.user || '', password: row.password || '', private_key: row.private_key || '',
|
||||
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
|
||||
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
|
||||
host_group_id: row.host_group_id || 0, description: row.description || '',
|
||||
host_group_id: row.host_group_id || 0, description: row.description || '', disabled: !!row.disabled,
|
||||
_groupName: getGroupName(row.host_group_id),
|
||||
...Object.fromEntries(diskIoFields.map(f => [f.key, row[f.key] ?? diskIoDefaults[f.key]]))
|
||||
})
|
||||
@@ -687,6 +696,7 @@ const handleEdit = (row) => {
|
||||
if (detail.password) formData.password = detail.password
|
||||
if (detail.token) formData.token = detail.token
|
||||
if (detail.private_key) formData.private_key = detail.private_key
|
||||
formData.disabled = !!detail.disabled
|
||||
diskIoFields.forEach(f => { if (detail[f.key] !== undefined) formData[f.key] = detail[f.key] })
|
||||
}
|
||||
}).catch(() => {})
|
||||
@@ -699,6 +709,8 @@ const handleSubmit = () => {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { ...formData, service_id: serviceId.value }
|
||||
const disabled = payload.disabled
|
||||
delete payload.disabled
|
||||
delete payload.id
|
||||
delete payload._groupName
|
||||
// 可选参数为空时不提交
|
||||
@@ -713,6 +725,9 @@ const handleSubmit = () => {
|
||||
} else {
|
||||
payload.id = formData.id
|
||||
res = await updateRemoteHost(payload)
|
||||
if (res?.data?.code === 200) {
|
||||
res = await setRemoteHostDisabled({ service_id: serviceId.value, id: formData.id, disabled })
|
||||
}
|
||||
}
|
||||
const body = res?.data
|
||||
if (body?.code === 200) {
|
||||
@@ -730,6 +745,23 @@ const handleSubmit = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleSetDisabled = (row) => {
|
||||
const disabled = !row.disabled
|
||||
const action = disabled ? '禁用' : '启用'
|
||||
const message = disabled
|
||||
? `禁用宿主机「${row.name}」后,它将不参与自动调度且不能作为新建或迁移目标,已有虚拟机不受影响。是否继续?`
|
||||
: `确定要启用宿主机「${row.name}」参与自动调度吗?`
|
||||
ElMessageBox.confirm(message, `${action}宿主机`, { type: disabled ? 'warning' : 'info' }).then(async () => {
|
||||
disabledActionHostId.value = row.id
|
||||
try {
|
||||
const res = await setRemoteHostDisabled({ service_id: serviceId.value, id: row.id, disabled })
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, `${action}失败`))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${action}失败`)) }
|
||||
finally { disabledActionHostId.value = null }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleViewDetail = async (row) => {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
|
||||
@@ -68,10 +68,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row._isHost" :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '在线' : '离线' }}</el-tag>
|
||||
<el-tag v-if="row._isHost && row.disabled" type="danger" size="small">已禁用</el-tag>
|
||||
<el-tag v-else-if="row._isHost" :type="row.is_active ? 'success' : 'info'" size="small">{{ row.is_active ? '在线' : '离线' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<el-table-column label="操作" width="330" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row._isGroup && !row._isUngrouped">
|
||||
<el-button link type="primary" size="small" @click="handleViewGroupDetail(row)">详情</el-button>
|
||||
@@ -83,6 +84,7 @@
|
||||
<template v-else-if="row._isHost">
|
||||
<el-button link type="primary" size="small" @click="handleGoHostDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleEditHost(row)">编辑</el-button>
|
||||
<el-button link :type="row.disabled ? 'success' : 'warning'" size="small" :loading="disabledActionHostId === row.id" @click="handleSetHostDisabled(row)">{{ row.disabled ? '启用' : '禁用' }}</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteHost(row)">删除</el-button>
|
||||
</template>
|
||||
</template>
|
||||
@@ -388,6 +390,12 @@
|
||||
<el-option v-for="g in allGroups" :key="g.id" :value="g.id" :label="`${g.name} (ID: ${g.id})`" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="hostDialogType === 'edit'" label="调度状态">
|
||||
<div>
|
||||
<el-switch v-model="hostForm.disabled" active-text="禁用" inactive-text="启用" />
|
||||
<div class="tk-form-tip">禁用后不参与自动调度,也不能作为新建或迁移目标;已有虚拟机不受影响。</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍">
|
||||
<el-input v-model="hostForm.description" type="textarea" :rows="2" placeholder="可选" />
|
||||
</el-form-item>
|
||||
@@ -414,6 +422,7 @@ import {
|
||||
getOptimalHostInfo,
|
||||
getRemoteHostList, getRemoteHostDetail,
|
||||
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
||||
setRemoteHostDisabled,
|
||||
createHostToken
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
@@ -429,6 +438,7 @@ const serviceName = computed(() => injectedServiceName?.value || route.query.ser
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const disabledActionHostId = ref(null)
|
||||
const allGroups = ref([])
|
||||
const allHosts = ref([])
|
||||
const expandedGroupIds = ref(new Set())
|
||||
@@ -722,7 +732,7 @@ const hostForm = reactive({
|
||||
id: undefined, name: '', base_url: '', ip: '', token: '',
|
||||
port: 22, user: '', password: '', private_key: '',
|
||||
max_cpu: 0, max_memory: 0, max_disk: 0,
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '',
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', disabled: false,
|
||||
...diskIoDefaults
|
||||
})
|
||||
const hostFormRules = {
|
||||
@@ -733,7 +743,7 @@ const hostFormRules = {
|
||||
|
||||
const handleAddHost = () => {
|
||||
hostDialogType.value = 'add'
|
||||
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', ...diskIoDefaults })
|
||||
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: 0, description: '', disabled: false, ...diskIoDefaults })
|
||||
showHostDiskIo.value = false
|
||||
hostIoBwUnit.value = 'MB/s'
|
||||
hostDialogVisible.value = true
|
||||
@@ -741,7 +751,7 @@ const handleAddHost = () => {
|
||||
|
||||
const handleAddHostToGroup = (group) => {
|
||||
hostDialogType.value = 'add'
|
||||
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '', ...diskIoDefaults })
|
||||
Object.assign(hostForm, { id: undefined, name: '', base_url: '', ip: '', token: '', port: 22, user: '', password: '', private_key: '', max_cpu: 0, max_memory: 0, max_disk: 0, rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: group.id, description: '', disabled: false, ...diskIoDefaults })
|
||||
showHostDiskIo.value = false
|
||||
hostIoBwUnit.value = 'MB/s'
|
||||
hostDialogVisible.value = true
|
||||
@@ -754,7 +764,7 @@ const handleEditHost = (row) => {
|
||||
port: row.port || 22, user: row.user || '', password: row.password || '', private_key: row.private_key || '',
|
||||
max_cpu: row.max_cpu || 0, max_memory: row.max_memory || 0, max_disk: row.max_disk || 0,
|
||||
rx_bandwidth: row.rx_bandwidth || 0, tx_bandwidth: row.tx_bandwidth || 0,
|
||||
host_group_id: row.host_group_id || 0, description: row.description || '',
|
||||
host_group_id: row.host_group_id || 0, description: row.description || '', disabled: !!row.disabled,
|
||||
...Object.fromEntries(diskIoFields.map(f => [f.key, row[f.key] ?? diskIoDefaults[f.key]]))
|
||||
})
|
||||
showHostDiskIo.value = diskIoFields.some(f => row[f.key] && row[f.key] !== diskIoDefaults[f.key])
|
||||
@@ -764,6 +774,7 @@ const handleEditHost = (row) => {
|
||||
if (d.password) hostForm.password = d.password
|
||||
if (d.token) hostForm.token = d.token
|
||||
if (d.private_key) hostForm.private_key = d.private_key
|
||||
hostForm.disabled = !!d.disabled
|
||||
diskIoFields.forEach(f => { if (d[f.key] !== undefined) hostForm[f.key] = d[f.key] })
|
||||
}
|
||||
}).catch(() => {})
|
||||
@@ -776,6 +787,8 @@ const submitHostForm = () => {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const payload = { ...hostForm, service_id: serviceId.value }
|
||||
const disabled = payload.disabled
|
||||
delete payload.disabled
|
||||
delete payload.id
|
||||
if (!payload.token) delete payload.token
|
||||
if (!payload.password) delete payload.password
|
||||
@@ -788,6 +801,9 @@ const submitHostForm = () => {
|
||||
} else {
|
||||
payload.id = hostForm.id
|
||||
res = await updateRemoteHost(payload)
|
||||
if (res?.data?.code === 200) {
|
||||
res = await setRemoteHostDisabled({ service_id: serviceId.value, id: hostForm.id, disabled })
|
||||
}
|
||||
}
|
||||
if (res?.data?.code === 200) { ElMessage.success(hostDialogType.value === 'add' ? '新增成功' : '修改成功'); hostDialogVisible.value = false; loadTreeData() }
|
||||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||
@@ -795,6 +811,23 @@ const submitHostForm = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleSetHostDisabled = (row) => {
|
||||
const disabled = !row.disabled
|
||||
const action = disabled ? '禁用' : '启用'
|
||||
const message = disabled
|
||||
? `禁用宿主机「${row.name}」后,它将不参与自动调度且不能作为新建或迁移目标,已有虚拟机不受影响。是否继续?`
|
||||
: `确定要启用宿主机「${row.name}」参与自动调度吗?`
|
||||
ElMessageBox.confirm(message, `${action}宿主机`, { type: disabled ? 'warning' : 'info' }).then(async () => {
|
||||
disabledActionHostId.value = row.id
|
||||
try {
|
||||
const res = await setRemoteHostDisabled({ service_id: serviceId.value, id: row.id, disabled })
|
||||
if (res?.data?.code === 200) { ElMessage.success(`${action}成功`); loadTreeData() }
|
||||
else ElMessage.error(extractApiError(res?.data, `${action}失败`))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, `${action}失败`)) }
|
||||
finally { disabledActionHostId.value = null }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
const handleGoHostDetail = (row) => {
|
||||
router.push({ path: '/virtualization/host-detail', query: { service_id: serviceId.value, id: row.id, service_name: serviceName.value } })
|
||||
}
|
||||
|
||||
@@ -1189,7 +1189,7 @@
|
||||
<!-- 迁移虚拟机弹窗 -->
|
||||
<el-dialog v-model="migrateDialogVisible" title="迁移虚拟机(更换宿主机)" width="580px" destroy-on-close class="tk-dialog">
|
||||
<el-form label-width="100px" v-loading="migrateOptionsLoading">
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">将虚拟机迁移到其他宿主机或宿主机组,请谨慎操作!</el-alert>
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">宿主机迁移为转移配置后重新开通,不保留原虚拟机数据,请谨慎操作!</el-alert>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">迁移配置</div>
|
||||
<!-- <el-form-item label="当前宿主机">
|
||||
@@ -1219,6 +1219,10 @@
|
||||
<el-input-number v-model="migrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="到期顺延(天)">
|
||||
<el-input-number v-model="migrateForm.expire_offset_days" :min="0" :step="1" :precision="0" controls-position="right" />
|
||||
<span class="tk-res-unit">天(0 不顺延)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标网络" v-if="migrateMode === 'host'">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%">
|
||||
<el-tag v-for="n in migrateSelectedNetworks" :key="n.id" closable @close="removeMigrateNetwork(n.id)">{{ n.name || n.id }} (ID:{{ n.id }})</el-tag>
|
||||
@@ -1275,6 +1279,10 @@
|
||||
<el-input-number v-model="dataMigrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="到期顺延(天)">
|
||||
<el-input-number v-model="dataMigrateForm.expire_offset_days" :min="0" :step="1" :precision="0" controls-position="right" />
|
||||
<span class="tk-res-unit">天(0 不顺延)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="网络ID">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;width:100%">
|
||||
<el-tag v-for="n in dataMigrateSelectedNetworks" :key="n.id" closable @close="removeDataMigrateNetwork(n.id)">
|
||||
@@ -2691,7 +2699,7 @@ const migrateOptionsLoading = ref(false)
|
||||
const migrateMode = ref('host')
|
||||
const migrateHostOptions = ref([])
|
||||
const migrateGroupOptions = ref([])
|
||||
const migrateForm = reactive({ target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
|
||||
const migrateForm = reactive({ target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [], expire_offset_days: 0 })
|
||||
const showMigrateNetworkSelector = ref(false)
|
||||
const migrateSelectedNetworks = ref([])
|
||||
const clearMigrateNetworks = () => { migrateSelectedNetworks.value = []; migrateForm.network_ids = [] }
|
||||
@@ -2704,7 +2712,7 @@ const handleMigrateNetworkConfirm = items => {
|
||||
const removeMigrateNetwork = id => { migrateSelectedNetworks.value = migrateSelectedNetworks.value.filter(item => item.id !== id); migrateForm.network_ids = migrateSelectedNetworks.value.map(item => item.id) }
|
||||
|
||||
const handleMigrateVm = async () => {
|
||||
Object.assign(migrateForm, { target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
|
||||
Object.assign(migrateForm, { target_host_id: null, target_host_group_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [], expire_offset_days: 0 })
|
||||
migrateSelectedNetworks.value = []
|
||||
migrateMode.value = 'host'
|
||||
migrateDialogVisible.value = true
|
||||
@@ -2737,6 +2745,7 @@ const submitMigrateVm = async () => {
|
||||
else fd.append('target_host_group_id', migrateForm.target_host_group_id)
|
||||
if (migrateForm.ipv4_num) fd.append('ipv4_num', migrateForm.ipv4_num)
|
||||
if (migrateForm.ipv6_num) fd.append('ipv6_num', migrateForm.ipv6_num)
|
||||
if (migrateForm.expire_offset_days > 0) fd.append('expire_offset_days', migrateForm.expire_offset_days)
|
||||
migrateForm.network_ids.forEach(id => fd.append('network_ids', id))
|
||||
const res = await migrateVm(fd)
|
||||
if (res?.data?.code === 200) {
|
||||
@@ -2762,7 +2771,7 @@ const dataMigrationId = ref('')
|
||||
const dataMigrateTaskId = ref('')
|
||||
const dataMigrationRecordId = ref(null)
|
||||
const dataMigrationChainId = ref('')
|
||||
const dataMigrateForm = reactive({ target_service_id: null, target_host_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
|
||||
const dataMigrateForm = reactive({ target_service_id: null, target_host_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [], expire_offset_days: 0 })
|
||||
const dataMigrateNetworkOptions = ref([])
|
||||
const showDataMigrateNetworkSelector = ref(false)
|
||||
const dataMigrateNetSelectorRef = ref(null)
|
||||
@@ -2786,7 +2795,7 @@ const removeDataMigrateNetwork = (id) => {
|
||||
}
|
||||
|
||||
const handleDataMigrateVm = async () => {
|
||||
Object.assign(dataMigrateForm, { target_service_id: null, target_host_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [] })
|
||||
Object.assign(dataMigrateForm, { target_service_id: null, target_host_id: null, ipv4_num: 0, ipv6_num: 0, network_ids: [], expire_offset_days: 0 })
|
||||
dataMigrateSelectedNetworks.value = []
|
||||
dataMigrateNetworkOptions.value = []
|
||||
dataMigrateVisible.value = true
|
||||
@@ -2839,6 +2848,7 @@ const submitDataMigrate = async () => {
|
||||
fd.append('target_host_id', dataMigrateForm.target_host_id)
|
||||
if (dataMigrateForm.ipv4_num) fd.append('ipv4_num', dataMigrateForm.ipv4_num)
|
||||
if (dataMigrateForm.ipv6_num) fd.append('ipv6_num', dataMigrateForm.ipv6_num)
|
||||
if (dataMigrateForm.expire_offset_days > 0) fd.append('expire_offset_days', dataMigrateForm.expire_offset_days)
|
||||
if (dataMigrateForm.network_ids?.length) {
|
||||
dataMigrateForm.network_ids.forEach(id => fd.append('network_ids', id))
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
<el-button type="primary" size="small" @click="openBatchDataMigrate" :loading="batchLoading">
|
||||
<el-icon><Promotion /></el-icon>批量数据迁移
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="openBatchHostMigrate" :loading="batchLoading">
|
||||
<el-icon><Promotion /></el-icon>批量宿主机迁移
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" @click="handleBatchDelete" :loading="batchLoading">
|
||||
<el-icon><Delete /></el-icon>批量删除
|
||||
</el-button>
|
||||
@@ -469,6 +472,52 @@
|
||||
<!-- 用户选择器 -->
|
||||
<UserListSelector v-model="showUserSelector" :current-user-id="createForm.user_id" @confirm="handleUserSelected" />
|
||||
|
||||
<!-- 批量宿主机迁移 -->
|
||||
<el-dialog v-model="batchHostMigrateVisible" title="批量宿主机迁移(仅更换宿主机,不保留数据)" width="620px" destroy-on-close class="tk-dialog">
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px">
|
||||
宿主机迁移为转移配置后重新开通,不保留原虚拟机数据。将对 {{ batchHostMigrateTargets.length }} 台虚拟机逐台提交迁移,迁移中的实例会被跳过。
|
||||
</el-alert>
|
||||
<el-form :model="batchHostMigrateForm" label-width="110px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">迁移配置</div>
|
||||
<el-form-item label="迁移方式">
|
||||
<el-radio-group v-model="batchHostMigrateMode">
|
||||
<el-radio value="host">指定宿主机</el-radio>
|
||||
<el-radio value="group">指定宿主机组</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="batchHostMigrateMode === 'host'" label="目标宿主机" required>
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="batchHostMigrateForm.target_host_id ? `${batchHostMigrateForm._hostName} (ID: ${batchHostMigrateForm.target_host_id})` : ''" readonly placeholder="请选择目标宿主机" />
|
||||
<el-button type="primary" @click="showBatchHostMigrateHostSelector=true">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="目标宿主机组" required>
|
||||
<el-select v-model="batchHostMigrateForm.target_host_group_id" placeholder="请选择目标宿主机组" filterable style="width:100%" :loading="batchHostMigrateOptionsLoading">
|
||||
<el-option v-for="group in batchHostMigrateGroupOptions" :key="group.id" :label="`${group.name} (ID: ${group.id})`" :value="group.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="tk-resource-grid">
|
||||
<el-form-item label="IPv4 数量"><el-input-number v-model="batchHostMigrateForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个/实例</span></el-form-item>
|
||||
<el-form-item label="IPv6 数量"><el-input-number v-model="batchHostMigrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个/实例</span></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="到期顺延(天)">
|
||||
<el-input-number v-model="batchHostMigrateForm.expire_offset_days" :min="0" :step="1" :precision="0" controls-position="right" />
|
||||
<span class="tk-res-unit">天/实例(0 不顺延)</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="batchHostMigrateMode === 'host'" label="目标网络">
|
||||
<div class="batch-network-field">
|
||||
<div v-if="batchHostMigrateNetworks.length" class="batch-network-tags"><el-tag v-for="network in batchHostMigrateNetworks" :key="network.id" closable @close="removeBatchHostMigrateNetwork(network.id)">{{ network.name || network.id }} (#{{ network.id }})</el-tag></div>
|
||||
<el-button :disabled="!batchHostMigrateForm.target_host_id" @click="showBatchHostMigrateNetworkSelector=true">选择网络</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="batchHostMigrateVisible=false">取消</el-button><el-button type="warning" :loading="batchHostMigrateSubmitting" @click="submitBatchHostMigrate">提交迁移</el-button></template>
|
||||
</el-dialog>
|
||||
<HostSelectorPopup v-model="showBatchHostMigrateHostSelector" :service-id="serviceId" :current-id="batchHostMigrateForm.target_host_id || 0" @confirm="handleBatchHostMigrateHostSelected" />
|
||||
<NetworkSelectorPopup v-model="showBatchHostMigrateNetworkSelector" :service-id="serviceId" :host-id="batchHostMigrateForm.target_host_id || 0" filter-type="bridge" filter-used="false" :multiple="true" @confirm="handleBatchHostMigrateNetworkSelected" />
|
||||
|
||||
<!-- 批量数据迁移 -->
|
||||
<el-dialog v-model="batchMigrateVisible" title="批量数据迁移" width="620px" destroy-on-close class="tk-dialog">
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px">
|
||||
@@ -496,6 +545,10 @@
|
||||
<el-form-item label="IPv4 数量"><el-input-number v-model="batchMigrateForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个/实例</span></el-form-item>
|
||||
<el-form-item label="IPv6 数量"><el-input-number v-model="batchMigrateForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个/实例</span></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="到期顺延(天)">
|
||||
<el-input-number v-model="batchMigrateForm.expire_offset_days" :min="0" :step="1" :precision="0" controls-position="right" />
|
||||
<span class="tk-res-unit">天/实例(0 不顺延)</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标网络">
|
||||
<div class="batch-network-field">
|
||||
<div class="batch-network-tags" v-if="batchMigrateNetworks.length"><el-tag v-for="network in batchMigrateNetworks" :key="network.id" closable @close="removeBatchMigrateNetwork(network.id)">{{ network.name || network.id }} (#{{ network.id }})</el-tag></div>
|
||||
@@ -541,7 +594,7 @@ import {
|
||||
getRemoteHostList, getVmList, getVmDetail, getVmStatus,
|
||||
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
|
||||
resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList, createNetwork, getMetricsHistory,
|
||||
getDataMigrateProgress, dataMigrateVm
|
||||
getDataMigrateProgress, dataMigrateVm, migrateVm, getRemoteHostGroupList
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
||||
@@ -585,6 +638,145 @@ const selectedVms = ref([])
|
||||
const handleSelectionChange = (selection) => { selectedVms.value = selection }
|
||||
const clearSelection = () => { vmTableRef.value?.clearSelection() }
|
||||
|
||||
// 批量宿主机迁移
|
||||
const batchHostMigrateVisible = ref(false)
|
||||
const batchHostMigrateSubmitting = ref(false)
|
||||
const batchHostMigrateMode = ref('host')
|
||||
const batchHostMigrateTargets = ref([])
|
||||
const batchHostMigrateNetworks = ref([])
|
||||
const batchHostMigrateGroupOptions = ref([])
|
||||
const batchHostMigrateOptionsLoading = ref(false)
|
||||
const showBatchHostMigrateHostSelector = ref(false)
|
||||
const showBatchHostMigrateNetworkSelector = ref(false)
|
||||
const batchHostMigrateForm = reactive({
|
||||
target_host_id: 0,
|
||||
target_host_group_id: 0,
|
||||
ipv4_num: 0,
|
||||
ipv6_num: 0,
|
||||
expire_offset_days: 0,
|
||||
network_ids: [],
|
||||
_hostName: ''
|
||||
})
|
||||
|
||||
const resetBatchHostMigrateForm = () => {
|
||||
Object.assign(batchHostMigrateForm, {
|
||||
target_host_id: 0,
|
||||
target_host_group_id: 0,
|
||||
ipv4_num: 0,
|
||||
ipv6_num: 0,
|
||||
expire_offset_days: 0,
|
||||
network_ids: [],
|
||||
_hostName: ''
|
||||
})
|
||||
batchHostMigrateMode.value = 'host'
|
||||
batchHostMigrateNetworks.value = []
|
||||
}
|
||||
|
||||
const openBatchHostMigrate = async () => {
|
||||
const targets = selectedVms.value.filter(vm => !vm.migrating)
|
||||
if (!targets.length) {
|
||||
ElMessage.warning('所选虚拟机均正在迁移中,请重新选择')
|
||||
return
|
||||
}
|
||||
const skipped = selectedVms.value.length - targets.length
|
||||
if (skipped) ElMessage.warning(`已跳过 ${skipped} 台正在迁移的虚拟机`)
|
||||
batchHostMigrateTargets.value = [...targets]
|
||||
resetBatchHostMigrateForm()
|
||||
batchHostMigrateVisible.value = true
|
||||
batchHostMigrateOptionsLoading.value = true
|
||||
try {
|
||||
const res = await getRemoteHostGroupList({ service_id: serviceId.value, page: 1, page_size: 100 })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
batchHostMigrateGroupOptions.value = Array.isArray(inner) ? inner : (inner.host_groups || inner.data || [])
|
||||
} else {
|
||||
batchHostMigrateGroupOptions.value = []
|
||||
}
|
||||
} catch {
|
||||
batchHostMigrateGroupOptions.value = []
|
||||
} finally {
|
||||
batchHostMigrateOptionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBatchHostMigrateHostSelected = (host) => {
|
||||
if (!host?.id) return
|
||||
batchHostMigrateForm.target_host_id = Number(host.id)
|
||||
batchHostMigrateForm._hostName = host.name || host.ip || ''
|
||||
batchHostMigrateForm.network_ids = []
|
||||
batchHostMigrateNetworks.value = []
|
||||
}
|
||||
|
||||
const handleBatchHostMigrateNetworkSelected = (networks) => {
|
||||
const list = Array.isArray(networks) ? networks : (networks ? [networks] : [])
|
||||
const merged = [...batchHostMigrateNetworks.value, ...list]
|
||||
batchHostMigrateNetworks.value = [...new Map(merged.filter(item => item?.id).map(item => [Number(item.id), item])).values()]
|
||||
batchHostMigrateForm.network_ids = batchHostMigrateNetworks.value.map(item => Number(item.id))
|
||||
}
|
||||
|
||||
const removeBatchHostMigrateNetwork = (networkId) => {
|
||||
batchHostMigrateNetworks.value = batchHostMigrateNetworks.value.filter(item => Number(item.id) !== Number(networkId))
|
||||
batchHostMigrateForm.network_ids = batchHostMigrateNetworks.value.map(item => Number(item.id))
|
||||
}
|
||||
|
||||
const submitBatchHostMigrate = async () => {
|
||||
if (batchHostMigrateMode.value === 'host' && !batchHostMigrateForm.target_host_id) {
|
||||
ElMessage.warning('请选择目标宿主机')
|
||||
return
|
||||
}
|
||||
if (batchHostMigrateMode.value === 'group' && !batchHostMigrateForm.target_host_group_id) {
|
||||
ElMessage.warning('请选择目标宿主机组')
|
||||
return
|
||||
}
|
||||
if (!batchHostMigrateTargets.value.length) {
|
||||
ElMessage.warning('没有可迁移的虚拟机')
|
||||
return
|
||||
}
|
||||
const targetName = batchHostMigrateMode.value === 'host'
|
||||
? (batchHostMigrateForm._hostName || batchHostMigrateForm.target_host_id)
|
||||
: (batchHostMigrateGroupOptions.value.find(group => Number(group.id) === Number(batchHostMigrateForm.target_host_group_id))?.name || batchHostMigrateForm.target_host_group_id)
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定将 ${batchHostMigrateTargets.value.length} 台虚拟机迁移到「${targetName}」吗?`,
|
||||
'批量宿主机迁移',
|
||||
{ confirmButtonText: '确定迁移', cancelButtonText: '取消', type: 'warning' }
|
||||
)
|
||||
} catch { return }
|
||||
|
||||
batchHostMigrateSubmitting.value = true
|
||||
batchLoading.value = true
|
||||
let success = 0
|
||||
const failed = []
|
||||
for (const vm of batchHostMigrateTargets.value) {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('service_id', serviceId.value)
|
||||
formData.append('vm_id', vm.id)
|
||||
if (batchHostMigrateMode.value === 'host') formData.append('target_host_id', batchHostMigrateForm.target_host_id)
|
||||
else formData.append('target_host_group_id', batchHostMigrateForm.target_host_group_id)
|
||||
if (batchHostMigrateForm.ipv4_num > 0) formData.append('ipv4_num', batchHostMigrateForm.ipv4_num)
|
||||
if (batchHostMigrateForm.ipv6_num > 0) formData.append('ipv6_num', batchHostMigrateForm.ipv6_num)
|
||||
if (batchHostMigrateForm.expire_offset_days > 0) formData.append('expire_offset_days', batchHostMigrateForm.expire_offset_days)
|
||||
if (batchHostMigrateMode.value === 'host') batchHostMigrateForm.network_ids.forEach(id => formData.append('network_ids', id))
|
||||
const res = await migrateVm(formData)
|
||||
if (res?.data?.code === 200) success++
|
||||
else failed.push(vm.name || `ID ${vm.id}`)
|
||||
} catch {
|
||||
failed.push(vm.name || `ID ${vm.id}`)
|
||||
}
|
||||
}
|
||||
batchHostMigrateSubmitting.value = false
|
||||
batchLoading.value = false
|
||||
batchHostMigrateVisible.value = false
|
||||
clearSelection()
|
||||
if (failed.length) {
|
||||
ElMessage.warning(`批量宿主机迁移提交完成:成功 ${success},失败 ${failed.length}(${failed.slice(0, 3).join('、')}${failed.length > 3 ? ' 等' : ''})`)
|
||||
} else {
|
||||
ElMessage.success(`已提交 ${success} 台虚拟机的宿主机迁移任务`)
|
||||
}
|
||||
loadList()
|
||||
}
|
||||
|
||||
// Batch data migration
|
||||
const batchMigrateVisible = ref(false)
|
||||
const batchMigrateSubmitting = ref(false)
|
||||
@@ -598,6 +790,7 @@ const batchMigrateForm = reactive({
|
||||
target_host_id: 0,
|
||||
ipv4_num: 0,
|
||||
ipv6_num: 0,
|
||||
expire_offset_days: 0,
|
||||
network_ids: [],
|
||||
_serviceName: '',
|
||||
_hostName: ''
|
||||
@@ -609,6 +802,7 @@ const resetBatchMigrateForm = () => {
|
||||
target_host_id: 0,
|
||||
ipv4_num: 0,
|
||||
ipv6_num: 0,
|
||||
expire_offset_days: 0,
|
||||
network_ids: [],
|
||||
_serviceName: '',
|
||||
_hostName: ''
|
||||
@@ -694,6 +888,7 @@ const submitBatchDataMigrate = async () => {
|
||||
formData.append('target_host_id', batchMigrateForm.target_host_id)
|
||||
if (batchMigrateForm.ipv4_num > 0) formData.append('ipv4_num', batchMigrateForm.ipv4_num)
|
||||
if (batchMigrateForm.ipv6_num > 0) formData.append('ipv6_num', batchMigrateForm.ipv6_num)
|
||||
if (batchMigrateForm.expire_offset_days > 0) formData.append('expire_offset_days', batchMigrateForm.expire_offset_days)
|
||||
batchMigrateForm.network_ids.forEach(id => formData.append('network_ids', id))
|
||||
const res = await dataMigrateVm(formData)
|
||||
if (res?.data?.code === 200) success++
|
||||
@@ -1398,6 +1593,7 @@ defineExpose({ loadList })
|
||||
.migrate-inline-pct { color: #e6a23c; font-size: 12px; white-space: nowrap; }
|
||||
.batch-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
|
||||
Reference in New Issue
Block a user