fix: 提交修改
This commit is contained in:
+72
-1
@@ -226,11 +226,16 @@ html, body {
|
||||
color: #3498db !important;
|
||||
}
|
||||
|
||||
/* 卡片扁平化 */
|
||||
/* 卡片扁平化 + 层次感 */
|
||||
.el-card {
|
||||
border-radius: 0 !important;
|
||||
border: 1px solid #e1e8ed !important;
|
||||
box-shadow: none !important;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
.el-card[shadow="hover"]:hover {
|
||||
border-color: #c0c4cc !important;
|
||||
box-shadow: 0 2px 12px rgba(44, 62, 80, 0.08) !important;
|
||||
}
|
||||
|
||||
/* 表格扁平化 */
|
||||
@@ -434,4 +439,70 @@ html, body {
|
||||
.el-dialog .el-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Descriptions 描述列表增强 */
|
||||
.el-descriptions {
|
||||
--el-descriptions-item-bordered-label-background: #fafbfc;
|
||||
}
|
||||
.el-descriptions__label {
|
||||
color: #606266 !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
.el-descriptions__content {
|
||||
color: #1d2129 !important;
|
||||
}
|
||||
|
||||
/* Loading 遮罩增强 */
|
||||
.el-loading-mask {
|
||||
background-color: rgba(255, 255, 255, 0.85) !important;
|
||||
}
|
||||
.el-loading-spinner .circular {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.el-loading-spinner .el-loading-text {
|
||||
color: #606266 !important;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Message Box 增强 */
|
||||
.el-message-box {
|
||||
border-radius: 0 !important;
|
||||
box-shadow: 0 4px 16px rgba(44, 62, 80, 0.15) !important;
|
||||
}
|
||||
.el-message-box__header {
|
||||
padding: 16px 20px 12px !important;
|
||||
}
|
||||
.el-message-box__title {
|
||||
font-weight: 600 !important;
|
||||
color: #1d2129 !important;
|
||||
}
|
||||
.el-message-box__btns .el-button {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Alert 增强 */
|
||||
.el-alert {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
/* Tabs 增强 */
|
||||
.el-tabs__item {
|
||||
transition: color 0.2s ease !important;
|
||||
}
|
||||
.el-tabs__item.is-active {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
/* Switch 开关增强 */
|
||||
.el-switch {
|
||||
--el-switch-on-color: #2c3e50;
|
||||
}
|
||||
|
||||
/* 全局链接按钮悬浮下划线 */
|
||||
.el-button.is-link:hover,
|
||||
.el-button--primary.is-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -145,6 +145,11 @@ export const getRemoteHostMetrics = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/host/metrics', { params })
|
||||
}
|
||||
|
||||
/** 查询历史指标(宿主机或虚拟机) */
|
||||
export const getMetricsHistory = (params) => {
|
||||
return http2.get('/api/v1/admin/server/host_service/point/host/metrics_history', { params })
|
||||
}
|
||||
|
||||
/** 新增宿主机 */
|
||||
export const addRemoteHost = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/host/add', data, {
|
||||
@@ -164,6 +169,13 @@ export const deleteRemoteHost = (params) => {
|
||||
return http2.delete('/api/v1/admin/server/host_service/point/host/delete', { params })
|
||||
}
|
||||
|
||||
/** 创建宿主机注册令牌 */
|
||||
export const createHostToken = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/host/create_token', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* ================================
|
||||
* 主控服务接口 - 镜像管理
|
||||
@@ -445,21 +457,28 @@ export const deleteVm = (params) => {
|
||||
|
||||
/** 迁移虚拟机(更换宿主机) */
|
||||
export const migrateVm = (data) => {
|
||||
return http2.post('/api/v1/admin/service/host_service/point/vm/migrate', data, {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/migrate', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 发起虚拟机数据迁移 */
|
||||
export const dataMigrateVm = (data) => {
|
||||
return http2.post('/api/v1/admin/service/host_service/point/vm/data_migrate', data, {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/data_migrate', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取虚拟机数据迁移进度 */
|
||||
export const getDataMigrateProgress = (params) => {
|
||||
return http2.get('/api/v1/admin/service/host_service/point/vm/data_migrate/progress', { params })
|
||||
return http2.get('/api/v1/admin/server/host_service/point/vm/data_migrate/progress', { params })
|
||||
}
|
||||
|
||||
/** 中断虚拟机数据迁移 */
|
||||
export const abortDataMigrate = (data) => {
|
||||
return http2.post('/api/v1/admin/server/host_service/point/vm/data_migrate/abort', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ export const getUserVmList = (params) => http2.get(`${BASE}/list`, { params })
|
||||
export const getUserVmDetail = (params) => http2.get(`${BASE}/detail`, { params })
|
||||
export const getUserVmVnc = (params) => http2.get(`${BASE}/vnc`, { params })
|
||||
export const getUserVmHostImages = (params) => http2.get(`${BASE}/host_images`, { params })
|
||||
export const getGoodHostGroupImages = (params) => http2.get(`${BASE}/good_host_group_images`, { params })
|
||||
export const createUserVm = (data) => http2.post(`${BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const bindUserVm = (data) => http2.post(`${BASE}/bind`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const transferUserVm = (data) => http2.post(`${BASE}/transfer`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
@@ -102,3 +103,9 @@ export const getUserGoodsDetail = (params) => http2.get(`${GOODS_BASE}/detail`,
|
||||
export const createUserGoods = (data) => http2.post(`${GOODS_BASE}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const updateUserGoods = (data) => http2.post(`${GOODS_BASE}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const deleteUserGoods = (params) => http2.delete(`${GOODS_BASE}/delete`, { params })
|
||||
|
||||
export const getUserVmMetricsHistory = (params) => http2.get(`${BASE}/metrics_history`, { params })
|
||||
|
||||
// ========== 到期提醒 ==========
|
||||
export const getExpireRemindList = (params) => http2.get(`${GOODS_BASE}/expire_remind/list`, { params })
|
||||
export const sendExpireRemind = (data) => http2.post(`${GOODS_BASE}/expire_remind/send`, data, { headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { http2 } from '@/utils/request.js'
|
||||
|
||||
const fd = (data) => {
|
||||
const f = new FormData()
|
||||
Object.entries(data).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null || v === '') return
|
||||
f.append(k, v)
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
const BASE_GROUP = '/api/v1/admin/server/vnc_command/group'
|
||||
const BASE_ITEM = '/api/v1/admin/server/vnc_command/item'
|
||||
|
||||
// 分组
|
||||
export const getVncCommandGroupList = () => http2.get(`${BASE_GROUP}/list`)
|
||||
export const createVncCommandGroup = (data) => http2.post(`${BASE_GROUP}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const updateVncCommandGroup = (data) => http2.post(`${BASE_GROUP}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const deleteVncCommandGroup = (params) => http2.delete(`${BASE_GROUP}/delete`, { params })
|
||||
|
||||
// 指令项
|
||||
export const createVncCommandItem = (data) => http2.post(`${BASE_ITEM}/create`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const updateVncCommandItem = (data) => http2.post(`${BASE_ITEM}/update`, fd(data), { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||
export const deleteVncCommandItem = (params) => http2.delete(`${BASE_ITEM}/delete`, { params })
|
||||
@@ -1,20 +1,20 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="选择头像"
|
||||
:title="title"
|
||||
width="800px"
|
||||
append-to-body
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="avatar-selector">
|
||||
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||
<!-- 用户文件列表 -->
|
||||
<el-tab-pane label="用户文件" name="userFiles">
|
||||
<!-- 文件列表 -->
|
||||
<el-tab-pane label="文件" name="userFiles">
|
||||
<div class="file-list-container">
|
||||
<div class="file-list-header">
|
||||
<h4>用户文件列表</h4>
|
||||
<h4>文件列表</h4>
|
||||
<el-button type="primary" @click="switchToUpload" :icon="Upload">
|
||||
上传新头像
|
||||
上传新文件
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="file-grid" v-loading="loading">
|
||||
@@ -58,8 +58,8 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 上传头像 -->
|
||||
<el-tab-pane label="上传头像" name="upload">
|
||||
<!-- 上传文件 -->
|
||||
<el-tab-pane label="上传文件" name="upload">
|
||||
<div class="upload-section">
|
||||
<el-upload
|
||||
:http-request="handleUpload"
|
||||
@@ -118,6 +118,10 @@ import { closeAllMessage } from '../../utils/message'
|
||||
currentCoverId: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '选择文件'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -40,11 +40,14 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { getImageList } from '@/api/admin/kvmService'
|
||||
import { getUserVmHostImages, getGoodHostGroupImages } from '@/api/admin/userVm'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
serviceId: { type: Number, default: 0 },
|
||||
currentId: { type: Number, default: 0 }
|
||||
goodId: { type: Number, default: 0 },
|
||||
currentId: { type: Number, default: 0 },
|
||||
useUserVmApi: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
@@ -70,14 +73,31 @@ const handleSearch = () => { page.value = 1; loadList() }
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterOsType.value) params.os_type = filterOsType.value
|
||||
const res = await getImageList(params)
|
||||
let res
|
||||
if (props.goodId > 0) {
|
||||
const params = { good_id: props.goodId, page: page.value, count: pageSize }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterOsType.value) params.os_type = filterOsType.value
|
||||
res = await getGoodHostGroupImages(params)
|
||||
} else if (props.useUserVmApi) {
|
||||
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterOsType.value) params.os_type = filterOsType.value
|
||||
res = await getUserVmHostImages(params)
|
||||
} else {
|
||||
const params = { service_id: props.serviceId, page: page.value, count: pageSize }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterOsType.value) params.os_type = filterOsType.value
|
||||
res = await getImageList(params)
|
||||
}
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const inner = body.data
|
||||
list.value = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||
let items = inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||
if (props.useUserVmApi || props.goodId > 0) {
|
||||
items = items.map(item => item.image || item).filter(Boolean)
|
||||
}
|
||||
list.value = items
|
||||
total.value = inner.total ?? inner.all_count ?? list.value.length
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@@ -38,7 +38,9 @@
|
||||
<el-table-column prop="bridge_name" label="网桥名称" width="100" />
|
||||
<el-table-column label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.used ? 'danger' : 'success'" size="small">{{ row.used ? '已占用' : '空闲' }}</el-tag>
|
||||
<el-tag v-if="row._used === true" type="danger" size="small">已占用</el-tag>
|
||||
<el-tag v-else-if="row._used === false" type="success" size="small">空闲</el-tag>
|
||||
<el-tag v-else type="info" size="small">-</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -119,7 +121,14 @@ const loadList = async () => {
|
||||
const res = await getNetworkList(params)
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
list.value = inner.data || inner.networks || (Array.isArray(inner) ? inner : [])
|
||||
const items = inner.data || inner.networks || (Array.isArray(inner) ? inner : [])
|
||||
list.value = items.map(item => ({
|
||||
...item,
|
||||
_used: item.used !== undefined ? item.used
|
||||
: effectiveUsed === 'true' ? true
|
||||
: effectiveUsed === 'false' ? false
|
||||
: null
|
||||
}))
|
||||
total.value = inner.meta?.count ?? inner.total ?? list.value.length
|
||||
} else { list.value = []; total.value = 0 }
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
|
||||
@@ -52,9 +52,22 @@
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-else-if="spec.type === 'number'">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<el-input-number v-model="createSpecValues[spec.id]" :min="spec.min || 0" :max="spec.max || 9999" :step="spec.step || 1" :step-strictly="true" size="small" @change="buildCreateArgsJson" style="width:180px" />
|
||||
<span style="font-size:12px;color:#909399">范围: {{ spec.min || 0 }} ~ {{ spec.max || 9999 }},步长: {{ spec.step || 1 }}</span>
|
||||
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<el-input-number
|
||||
v-model="createDisplayValues[spec.id]"
|
||||
:min="hasUnit(spec) ? fromBaseUnit(spec.min ?? 0, createDisplayUnits[spec.id], getArgKey(spec)) : (spec.min ?? 0)"
|
||||
:max="hasUnit(spec) ? fromBaseUnit(spec.max ?? 0, createDisplayUnits[spec.id], getArgKey(spec)) : (spec.max ?? 0)"
|
||||
:step="hasUnit(spec) ? (fromBaseUnit(spec.step ?? 1, createDisplayUnits[spec.id], getArgKey(spec)) || 1) : (spec.step ?? 1)"
|
||||
:step-strictly="true"
|
||||
size="small"
|
||||
@change="onCreateNumberChange(spec)"
|
||||
style="width:180px"
|
||||
/>
|
||||
<el-select v-if="hasUnit(spec)" :model-value="createDisplayUnits[spec.id]" size="small" style="width:90px" @change="(newUnit) => onCreateUnitChange(spec, newUnit)">
|
||||
<el-option v-for="u in getParamUnits(spec)" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
<span style="font-size:12px;color:#909399">范围: {{ spec.min ?? 0 }} ~ {{ spec.max ?? 0 }}
|
||||
<template v-if="hasUnit(spec)"> {{ getBaseUnit(getArgKey(spec)) }}</template>,步长: {{ spec.step ?? 1 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -82,6 +95,7 @@ import { ref, reactive, watch } from 'vue'
|
||||
import { Refresh, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getProductPlanList, createProductPlan, getProductParameterList } from '@/api/admin/product'
|
||||
import { hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit, toBaseUnit, fromBaseUnit } from '@/utils/dynamicUnit'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
@@ -102,6 +116,8 @@ const createForm = reactive({ name: '', note: '', index: 0, args: '' })
|
||||
const createSpecList = ref([])
|
||||
const createSpecLoading = ref(false)
|
||||
const createSpecValues = reactive({})
|
||||
const createDisplayValues = reactive({})
|
||||
const createDisplayUnits = reactive({})
|
||||
|
||||
watch(showCreate, (v) => {
|
||||
if (v && props.goodId) loadCreateSpec()
|
||||
@@ -114,12 +130,42 @@ const loadCreateSpec = async () => {
|
||||
if (res?.data?.code === 200) {
|
||||
createSpecList.value = res.data.data || []
|
||||
for (const spec of createSpecList.value) {
|
||||
if (spec.type === 'number' && createSpecValues[spec.id] === undefined) createSpecValues[spec.id] = spec.min || 0
|
||||
if (spec.type === 'number') {
|
||||
if (createSpecValues[spec.id] === undefined) createSpecValues[spec.id] = spec.min ?? 0
|
||||
if (hasUnit(spec)) {
|
||||
createDisplayUnits[spec.id] = getParamDefaultUnit(spec)
|
||||
createDisplayValues[spec.id] = fromBaseUnit(spec.min ?? 0, createDisplayUnits[spec.id], getArgKey(spec))
|
||||
} else {
|
||||
createDisplayValues[spec.id] = spec.min ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { createSpecList.value = [] } finally { createSpecLoading.value = false }
|
||||
}
|
||||
|
||||
const onCreateNumberChange = (spec) => {
|
||||
if (hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = createDisplayUnits[spec.id]
|
||||
createSpecValues[spec.id] = Math.round(toBaseUnit(createDisplayValues[spec.id] || 0, unit, argKey))
|
||||
} else {
|
||||
createSpecValues[spec.id] = createDisplayValues[spec.id]
|
||||
}
|
||||
buildCreateArgsJson()
|
||||
}
|
||||
|
||||
const onCreateUnitChange = (spec, newUnit) => {
|
||||
const argKey = getArgKey(spec)
|
||||
const oldUnit = createDisplayUnits[spec.id]
|
||||
const oldDisplay = createDisplayValues[spec.id] || 0
|
||||
const baseValue = oldUnit ? toBaseUnit(oldDisplay, oldUnit, argKey) : oldDisplay
|
||||
createDisplayUnits[spec.id] = newUnit
|
||||
createDisplayValues[spec.id] = fromBaseUnit(baseValue, newUnit, argKey)
|
||||
createSpecValues[spec.id] = Math.round(baseValue)
|
||||
buildCreateArgsJson()
|
||||
}
|
||||
|
||||
const buildCreateArgsJson = () => {
|
||||
const result = []
|
||||
for (const spec of createSpecList.value) {
|
||||
@@ -127,11 +173,11 @@ const buildCreateArgsJson = () => {
|
||||
if (val === undefined || val === null || val === '') continue
|
||||
if (spec.type === 'select') {
|
||||
const attr = spec.attrs?.find(a => a.id === val)
|
||||
if (attr) result.push({ arg_id: spec.id, name: spec.name, attr_id: attr.id, value: attr.value, number: 0 })
|
||||
if (attr) result.push({ arg_id: spec.id, name: spec.name, attr_id: attr.id, value: attr.value, number: 0, key: getArgKey(spec) || undefined })
|
||||
} else if (spec.type === 'number') {
|
||||
result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: '', number: val })
|
||||
result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: '', number: val, key: getArgKey(spec) || undefined })
|
||||
} else {
|
||||
result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(val), number: 0 })
|
||||
result.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(val), number: 0, key: getArgKey(spec) || undefined })
|
||||
}
|
||||
}
|
||||
createForm.args = result.length > 0 ? JSON.stringify(result) : ''
|
||||
@@ -171,6 +217,8 @@ const submitCreate = async () => {
|
||||
showCreate.value = false
|
||||
Object.assign(createForm, { name: '', note: '', index: 0, args: '' })
|
||||
for (const k in createSpecValues) delete createSpecValues[k]
|
||||
for (const k in createDisplayValues) delete createDisplayValues[k]
|
||||
for (const k in createDisplayUnits) delete createDisplayUnits[k]
|
||||
loadList()
|
||||
} else ElMessage.error(res?.data?.message || '创建失败')
|
||||
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
|
||||
|
||||
@@ -29,6 +29,22 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="商品标签">
|
||||
<el-select
|
||||
v-model="searchParams.tag"
|
||||
placeholder="全部标签"
|
||||
:clearable="!defaultTag"
|
||||
:disabled="!!defaultTag"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in tagOptions"
|
||||
:key="item"
|
||||
:label="item"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch" :icon="Search">
|
||||
搜索
|
||||
@@ -116,7 +132,7 @@
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { getProductList, getProductGroupList } from '@/api/admin/product'
|
||||
import { getProductList, getProductGroupList, getProductTagList } from '@/api/admin/product'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
@@ -128,6 +144,11 @@ const props = defineProps({
|
||||
currentProductId: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
// 默认标签过滤(设置后自动锁定该标签)
|
||||
defaultTag: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -140,12 +161,14 @@ const activeTab = ref('selectProduct')
|
||||
const loading = ref(false)
|
||||
const productList = ref([])
|
||||
const groupOptions = ref([])
|
||||
const tagOptions = ref([])
|
||||
const total = ref(0)
|
||||
const selectedProduct = ref(null)
|
||||
|
||||
// 搜索参数
|
||||
const searchParams = reactive({
|
||||
good_group_id: '',
|
||||
tag: '',
|
||||
page: 1,
|
||||
count: 10
|
||||
})
|
||||
@@ -154,11 +177,14 @@ const searchParams = reactive({
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
visible.value = newVal
|
||||
if (newVal) {
|
||||
// 重置状态
|
||||
activeTab.value = 'selectProduct'
|
||||
selectedProduct.value = null
|
||||
searchParams.page = 1
|
||||
if (props.defaultTag) {
|
||||
searchParams.tag = props.defaultTag
|
||||
}
|
||||
fetchGroupList()
|
||||
fetchTagList()
|
||||
fetchProductList()
|
||||
}
|
||||
})
|
||||
@@ -180,6 +206,18 @@ const fetchGroupList = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取商品标签列表
|
||||
const fetchTagList = async () => {
|
||||
try {
|
||||
const res = await getProductTagList()
|
||||
if (res.data.code === 200) {
|
||||
tagOptions.value = res.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取标签列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取商品列表
|
||||
const fetchProductList = async () => {
|
||||
loading.value = true
|
||||
@@ -193,6 +231,9 @@ const fetchProductList = async () => {
|
||||
if (searchParams.good_group_id) {
|
||||
params.good_group_id = searchParams.good_group_id
|
||||
}
|
||||
if (searchParams.tag) {
|
||||
params.tag = searchParams.tag
|
||||
}
|
||||
|
||||
const res = await getProductList(params)
|
||||
|
||||
@@ -243,6 +284,7 @@ const handleSearch = () => {
|
||||
// 重置搜索
|
||||
const handleReset = () => {
|
||||
searchParams.good_group_id = ''
|
||||
searchParams.tag = props.defaultTag || ''
|
||||
searchParams.page = 1
|
||||
fetchProductList()
|
||||
}
|
||||
@@ -278,6 +320,7 @@ const handleClose = () => {
|
||||
selectedProduct.value = null
|
||||
productList.value = []
|
||||
searchParams.good_group_id = ''
|
||||
searchParams.tag = props.defaultTag || ''
|
||||
searchParams.page = 1
|
||||
total.value = 0
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="选择用户组"
|
||||
:title="adminGroup ? '选择管理员组' : '选择用户组'"
|
||||
width="900px"
|
||||
append-to-body
|
||||
@close="handleClose"
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="user-group-selector">
|
||||
<el-tabs v-model="activeTab" @tab-click="handleTabClick">
|
||||
<!-- 选择用户组 -->
|
||||
<el-tab-pane label="选择用户组" name="selectGroup">
|
||||
<el-tab-pane :label="adminGroup ? '选择管理员组' : '选择用户组'" name="selectGroup">
|
||||
<div class="group-list-container">
|
||||
<!-- 搜索筛选区域 -->
|
||||
<div class="filter-section">
|
||||
@@ -17,7 +17,7 @@
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="searchParams.key"
|
||||
placeholder="搜索用户组名称"
|
||||
:placeholder="adminGroup ? '搜索管理员组名称' : '搜索用户组名称'"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
style="width: 200px"
|
||||
@@ -38,8 +38,35 @@
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 管理员组列表表格 -->
|
||||
<el-table
|
||||
v-if="adminGroup"
|
||||
v-loading="loading"
|
||||
:data="groupList"
|
||||
highlight-current-row
|
||||
@current-change="handleCurrentChange"
|
||||
style="width: 100%"
|
||||
:height="350"
|
||||
:row-class-name="tableRowClassName"
|
||||
>
|
||||
<el-table-column type="index" label="序号" width="60" align="center" />
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="name" label="组名称" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="group-name">{{ row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="auth" label="权限标识" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="note" label="备注" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.note || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 用户组列表表格 -->
|
||||
<el-table
|
||||
v-else
|
||||
v-loading="loading"
|
||||
:data="groupList"
|
||||
highlight-current-row
|
||||
@@ -102,7 +129,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="groupList.length === 0 && !loading" description="暂无用户组数据" />
|
||||
<el-empty v-if="groupList.length === 0 && !loading" :description="adminGroup ? '暂无管理员组数据' : '暂无用户组数据'" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
@@ -128,6 +155,7 @@ import { ref, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { getUserGroupList } from '@/api/admin/user'
|
||||
import { getAdminGroupList } from '@/api/admin/group'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
@@ -144,6 +172,11 @@ const props = defineProps({
|
||||
excludeGroupId: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
// 是否请求管理员组接口
|
||||
adminGroup: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -193,12 +226,15 @@ const fetchGroupList = async () => {
|
||||
count: searchParams.count
|
||||
}
|
||||
|
||||
const res = await getUserGroupList(params)
|
||||
const res = props.adminGroup ? await getAdminGroupList(params) : await getUserGroupList(params)
|
||||
|
||||
if (res.data.code === 200) {
|
||||
let responseData = res.data?.data || res.data
|
||||
|
||||
if (Array.isArray(responseData)) {
|
||||
if (props.adminGroup) {
|
||||
groupList.value = responseData?.data || []
|
||||
total.value = responseData?.total || groupList.value.length
|
||||
} else if (Array.isArray(responseData)) {
|
||||
groupList.value = responseData
|
||||
total.value = responseData.length
|
||||
} else if (responseData.list) {
|
||||
|
||||
@@ -1,71 +1,67 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择公网网络(网桥)" width="680px" append-to-body @close="handleClose">
|
||||
<div class="selector-toolbar">
|
||||
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button>
|
||||
<el-button type="primary" :icon="Plus" @click="showCreate = true">创建组网</el-button>
|
||||
<span style="color:#909399;font-size:13px">仅显示网桥(bridge)类型网络</span>
|
||||
</div>
|
||||
<el-table :data="list" v-loading="loading" highlight-current-row
|
||||
@current-change="row => selected = row" :height="280" stripe size="small">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="address" label="地址(CIDR)" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
||||
<el-table-column prop="mac_address" label="MAC" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default>
|
||||
<el-tag type="success" size="small">网桥</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无网桥网络" />
|
||||
<div class="selector-footer-bar">
|
||||
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span>
|
||||
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total"
|
||||
layout="total,sizes,prev,pager,next" small background
|
||||
@size-change="s => { pageSize = s; page = 1; loadList() }"
|
||||
@current-change="p => { page = p; loadList() }" />
|
||||
<el-dialog v-model="visible" title="选择网络" width="800px" append-to-body @close="handleClose">
|
||||
<div class="selector-container">
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="keyword" placeholder="搜索网络" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
|
||||
<template #prefix><el-icon><Search /></el-icon></template>
|
||||
</el-input>
|
||||
<el-tag v-if="filterType" :type="filterType === 'bridge' ? 'success' : 'warning'" size="small" effect="dark">仅{{ filterType === 'bridge' ? '网桥' : 'NAT' }}</el-tag>
|
||||
<el-tag v-if="filterUnused" type="success" size="small" effect="dark">仅未占用</el-tag>
|
||||
<el-select v-model="ipVersionFilter" placeholder="IP版本" clearable style="width: 110px" @change="handleSearch">
|
||||
<el-option label="IPv4" value="ipv4" />
|
||||
<el-option label="IPv6" value="ipv6" />
|
||||
</el-select>
|
||||
<el-button :icon="Refresh" @click="loadList" circle />
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
|
||||
:height="340" :row-class-name="rowClassName" size="small" stripe>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">
|
||||
{{ row.type === 'bridge' ? '网桥' : 'NAT' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="address" label="地址(CIDR)" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="gateway" label="网关" min-width="120" />
|
||||
<el-table-column prop="nameservers" label="DNS" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="bridge_name" label="网桥名称" width="100" />
|
||||
</el-table>
|
||||
<div class="pagination-wrapper" v-if="total > 0">
|
||||
<el-pagination v-model:current-page="page" v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next" small
|
||||
@size-change="s => { pageSize = s; page = 1; loadList() }"
|
||||
@current-change="p => { page = p; loadList() }" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建组网弹窗 -->
|
||||
<el-dialog v-model="showCreate" title="创建组网" width="440px" append-to-body destroy-on-close>
|
||||
<el-form :model="createForm" label-width="90px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="createForm.name" placeholder="组网名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网桥名称" required>
|
||||
<el-input v-model="createForm.bridge_name" placeholder="网桥名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关">
|
||||
<el-input v-model="createForm.gateway" placeholder="可选,如 10.0.0.1/24" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="createForm.description" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreate = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createLoading" @click="submitCreate">创建</el-button>
|
||||
<div style="display: flex; justify-content: space-between; width: 100%">
|
||||
<el-button v-if="props.showCreateButton" type="success" @click="handleCreate">创建网络</el-button>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { Refresh, Plus } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getUserVmNetworkList, createUserVmNetworking } from '@/api/admin/userVm'
|
||||
import { ref, watch } from 'vue'
|
||||
import { Search, Refresh } from '@element-plus/icons-vue'
|
||||
import { getUserVmNetworkList } from '@/api/admin/userVm'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
userGoodsId: { type: Number, default: 0 }
|
||||
userGoodsId: { type: Number, default: 0 },
|
||||
filterType: { type: String, default: '' },
|
||||
filterUnused: { type: Boolean, default: false },
|
||||
showCreateButton: { type: Boolean, default: true }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm', 'create'])
|
||||
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -73,54 +69,66 @@ const list = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const selected = ref(null)
|
||||
const keyword = ref('')
|
||||
const ipVersionFilter = ref('')
|
||||
const selectedItem = ref(null)
|
||||
|
||||
const showCreate = ref(false)
|
||||
const createLoading = ref(false)
|
||||
const createForm = reactive({ name: '', bridge_name: '', gateway: '', description: '' })
|
||||
watch(() => props.modelValue, (val) => {
|
||||
visible.value = val
|
||||
if (val) {
|
||||
page.value = 1
|
||||
keyword.value = ''
|
||||
ipVersionFilter.value = ''
|
||||
selectedItem.value = null
|
||||
loadList()
|
||||
}
|
||||
})
|
||||
watch(visible, (val) => emit('update:modelValue', val))
|
||||
|
||||
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
|
||||
watch(visible, (v) => emit('update:modelValue', v))
|
||||
const handleSearch = () => { page.value = 1; loadList() }
|
||||
|
||||
const loadList = async () => {
|
||||
if (!props.userGoodsId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getUserVmNetworkList({ user_goods_id: props.userGoodsId, page: page.value, count: pageSize.value })
|
||||
const params = { user_goods_id: props.userGoodsId, page: page.value, count: pageSize.value }
|
||||
if (keyword.value) params.key = keyword.value
|
||||
if (ipVersionFilter.value) params.ip_version = ipVersionFilter.value
|
||||
const res = await getUserVmNetworkList(params)
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
const all = d.data || (Array.isArray(d) ? d : [])
|
||||
list.value = all.filter(n => n.type === 'bridge')
|
||||
total.value = list.value.length
|
||||
}
|
||||
} catch { /* */ } finally { loading.value = false }
|
||||
const inner = res.data.data
|
||||
let all = inner.data || (Array.isArray(inner) ? inner : [])
|
||||
if (props.filterType) {
|
||||
all = all.filter(n => n.type === props.filterType)
|
||||
}
|
||||
if (props.filterUnused) {
|
||||
all = all.filter(n => !n.vm_id)
|
||||
}
|
||||
list.value = all
|
||||
total.value = inner.meta?.count ?? inner.total ?? all.length
|
||||
} else { list.value = []; total.value = 0 }
|
||||
} catch { list.value = []; total.value = 0 } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!createForm.name || !createForm.bridge_name) { ElMessage.warning('请填写名称和网桥名称'); return }
|
||||
createLoading.value = true
|
||||
try {
|
||||
const res = await createUserVmNetworking({
|
||||
user_goods_id: props.userGoodsId,
|
||||
name: createForm.name,
|
||||
bridge_name: createForm.bridge_name,
|
||||
gateway: createForm.gateway,
|
||||
description: createForm.description
|
||||
})
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('创建成功')
|
||||
showCreate.value = false
|
||||
Object.assign(createForm, { name: '', bridge_name: '', gateway: '', description: '' })
|
||||
loadList()
|
||||
} else ElMessage.error(res?.data?.message || '创建失败')
|
||||
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
|
||||
const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
|
||||
const handleCurrentChange = (row) => { selectedItem.value = row }
|
||||
const handleConfirm = () => {
|
||||
if (selectedItem.value) {
|
||||
emit('confirm', selectedItem.value)
|
||||
visible.value = false
|
||||
}
|
||||
}
|
||||
const handleClose = () => { selectedItem.value = null }
|
||||
const handleCreate = () => {
|
||||
visible.value = false
|
||||
emit('create')
|
||||
}
|
||||
|
||||
const handleClose = () => { visible.value = false }
|
||||
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
|
||||
.selector-container { min-height: 200px; }
|
||||
.filter-bar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
:deep(.selected-row) { background-color: #ecf5ff !important; }
|
||||
:deep(.el-table__body tr) { cursor: pointer; }
|
||||
</style>
|
||||
|
||||
@@ -51,7 +51,12 @@
|
||||
<el-dialog v-model="showCreate" title="新建数据卷" width="440px" append-to-body destroy-on-close>
|
||||
<el-form :model="createForm" label-width="100px">
|
||||
<el-form-item label="名称" required><el-input v-model="createForm.name" placeholder="数据卷名称" /></el-form-item>
|
||||
<el-form-item label="大小(GB)"><el-input-number v-model="createForm.size" :min="1" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="大小">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="createForm.size" :min="1" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="createForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标设备名"><el-input v-model="createForm.target_device" placeholder="不填自动生成" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -84,7 +89,7 @@ const selected = ref(null)
|
||||
|
||||
const showCreate = ref(false)
|
||||
const createLoading = ref(false)
|
||||
const createForm = reactive({ name: '', size: 10, target_device: '' })
|
||||
const createForm = reactive({ name: '', size: 10, _sizeUnit: 'GB', target_device: '' })
|
||||
|
||||
watch(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
|
||||
watch(visible, (v) => emit('update:modelValue', v))
|
||||
@@ -106,11 +111,17 @@ const submitCreate = async () => {
|
||||
if (!createForm.name) { ElMessage.warning('请输入名称'); return }
|
||||
createLoading.value = true
|
||||
try {
|
||||
const res = await createUserVmVolume({ user_goods_id: props.userGoodsId, ...createForm })
|
||||
const sizeGb = createForm._sizeUnit === 'TB' ? createForm.size * 1024 : createForm.size
|
||||
const res = await createUserVmVolume({
|
||||
user_goods_id: props.userGoodsId,
|
||||
name: createForm.name,
|
||||
size: sizeGb,
|
||||
target_device: createForm.target_device
|
||||
})
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('创建成功')
|
||||
showCreate.value = false
|
||||
Object.assign(createForm, { name: '', size: 10, target_device: '' })
|
||||
Object.assign(createForm, { name: '', size: 10, _sizeUnit: 'GB', target_device: '' })
|
||||
loadList()
|
||||
} else ElMessage.error(res?.data?.message || '创建失败')
|
||||
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
|
||||
@@ -128,4 +139,6 @@ const handleConfirm = () => {
|
||||
<style scoped>
|
||||
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; }
|
||||
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; }
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-select { width: 90px; flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
+5
-1
@@ -48,7 +48,7 @@ export const menus = [
|
||||
icon: 'ShoppingCart',
|
||||
children: [
|
||||
{ path: '/user-goods/list', title: '所有商品' },
|
||||
{ path: '/user-goods/vm-list', title: '云计算平台' }
|
||||
{ path: '/user-goods/vm-list', title: '云服务器' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -152,6 +152,10 @@ export const menus = [
|
||||
{
|
||||
path: '/virtualization/host-group-mapping',
|
||||
title: '宿主机组映射管理'
|
||||
},
|
||||
{
|
||||
path: '/virtualization/vnc-command',
|
||||
title: 'VNC指令管理'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+10
-2
@@ -255,7 +255,7 @@ const routes = [
|
||||
meta: { title: '所有商品' }
|
||||
},
|
||||
{
|
||||
path: 'detail',
|
||||
path: 'detail/:id',
|
||||
name: 'UserGoodsDetail',
|
||||
component: () => import('../views/product/UserGoodsDetail.vue'),
|
||||
meta: { title: '用户商品详情', hidden: true, activeMenu: '/user-goods/list' }
|
||||
@@ -264,7 +264,7 @@ const routes = [
|
||||
path: 'vm-list',
|
||||
name: 'UserVmList',
|
||||
component: () => import('../views/user-vm/UserVmList.vue'),
|
||||
meta: { title: '云计算平台' }
|
||||
meta: { title: '云服务器' }
|
||||
},
|
||||
{
|
||||
path: 'vm-detail',
|
||||
@@ -515,6 +515,14 @@ const routes = [
|
||||
activeMenu: '/virtualization/kvm-service'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'vnc-command',
|
||||
name: 'VncCommandManage',
|
||||
component: () => import('../views/virtualization/VncCommandManage.vue'),
|
||||
meta: {
|
||||
title: 'VNC指令管理'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'host-detail',
|
||||
name: 'VirtHostDetail',
|
||||
|
||||
+357
-1
@@ -131,11 +131,367 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 响应式工具类 */
|
||||
/* ==================== 全局弹窗卡片样式 ==================== */
|
||||
/* 自动为所有未手动分区的弹窗表单添加卡片背景 */
|
||||
.el-dialog:not(.tk-dialog):not(.token-dialog):not(.token-result-dialog) .el-dialog__body > .el-form {
|
||||
background: #fafbfc;
|
||||
border-radius: 8px;
|
||||
padding: 20px 20px 4px;
|
||||
border: 1px solid #f0f2f5;
|
||||
}
|
||||
/* 统一弹窗 footer 按钮对齐 */
|
||||
.el-dialog .el-dialog__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.tk-dialog .el-dialog__body {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
.tk-dialog .el-dialog__body::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
.tk-dialog .el-form {
|
||||
padding: 0 4px;
|
||||
}
|
||||
.tk-section {
|
||||
background: #fafbfc;
|
||||
border-radius: 8px;
|
||||
padding: 20px 20px 4px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #f0f2f5;
|
||||
}
|
||||
.tk-section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
margin-bottom: 18px;
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid #409eff;
|
||||
line-height: 1;
|
||||
}
|
||||
.tk-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.tk-resource-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0 24px;
|
||||
}
|
||||
.tk-resource-grid .el-form-item {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.tk-resource-grid .el-form-item .el-form-item__label {
|
||||
width: 80px !important;
|
||||
}
|
||||
.tk-resource-grid .el-form-item .el-form-item__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.tk-resource-grid .el-input-number {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tk-unit-select {
|
||||
width: 68px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tk-res-unit {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tk-inline-unit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
.tk-inline-unit .el-input-number,
|
||||
.tk-inline-unit .el-input,
|
||||
.tk-inline-unit .el-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ==================== 全局页面布局组件 ==================== */
|
||||
|
||||
/* 页面头部 */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.page-header .header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.page-header .header-info h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1d2129;
|
||||
}
|
||||
.page-header .sub-info {
|
||||
font-size: 13px;
|
||||
color: #909399;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.page-header .header-right {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 嵌入式工具栏 */
|
||||
.embedded-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 通用工具栏 */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 筛选栏 */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 筛选区域(卡片式) */
|
||||
.filter-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* 绑定选择器行 */
|
||||
.bind-selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 详情操作按钮组 */
|
||||
.detail-actions {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ==================== 全局表格增强 ==================== */
|
||||
.el-table {
|
||||
--el-table-header-bg-color: #fafafa;
|
||||
--el-table-row-hover-bg-color: #f5f7fa;
|
||||
--el-table-border-color: #ebeef5;
|
||||
}
|
||||
.el-table th.el-table__cell {
|
||||
font-weight: 600 !important;
|
||||
color: #1d2129 !important;
|
||||
font-size: 13px !important;
|
||||
border-bottom: 2px solid #e1e8ed !important;
|
||||
}
|
||||
.el-table td.el-table__cell {
|
||||
border-bottom: 1px solid #f0f2f5 !important;
|
||||
color: #34495e !important;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.el-table .el-table__empty-block {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.el-table .el-table__empty-text {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* 表格固定列阴影 */
|
||||
.el-table__fixed {
|
||||
box-shadow: 4px 0 8px -4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.el-table__fixed-right {
|
||||
box-shadow: -4px 0 8px -4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* ==================== 全局骨架屏样式 ==================== */
|
||||
@keyframes tk-skeleton-loading {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.skeleton-container {
|
||||
padding: 20px;
|
||||
}
|
||||
.skeleton-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
gap: 16px;
|
||||
}
|
||||
.skeleton-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.skeleton-cell {
|
||||
height: 20px;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: tk-skeleton-loading 1.5s ease-in-out infinite;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ==================== 全局过渡动画 ==================== */
|
||||
.el-table,
|
||||
.el-card,
|
||||
.el-tag,
|
||||
.el-button {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* ==================== 通用文本类 ==================== */
|
||||
.text-muted {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
.mono-text {
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
color: #409eff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ==================== 视觉增强 ==================== */
|
||||
|
||||
/* 卡片式筛选区域 */
|
||||
.filter-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #ebeef5;
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* 操作栏 */
|
||||
.action-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 通用结果/令牌展示 */
|
||||
.tk-result-wrapper { text-align: center; }
|
||||
.tk-result-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; text-align: left; }
|
||||
.tk-result-icon { font-size: 36px; color: #e6a23c; background: #fdf6ec; border-radius: 50%; padding: 10px; }
|
||||
.tk-result-name { font-size: 16px; font-weight: 600; color: #1d2129; }
|
||||
.tk-result-meta { font-size: 13px; color: #909399; margin-top: 2px; }
|
||||
.tk-token-block { background: #1d2129; border-radius: 8px; padding: 16px; margin-bottom: 16px; text-align: left; }
|
||||
.tk-token-label { font-size: 11px; color: #909399; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.tk-token-value { font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 13px; color: #67c23a; word-break: break-all; line-height: 1.6; user-select: all; }
|
||||
.tk-copy-btn { width: 100%; }
|
||||
|
||||
/* 表单提示 */
|
||||
.form-hint {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 资源信息标签组 */
|
||||
.resource-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* ==================== 响应式工具类 ==================== */
|
||||
|
||||
/* 表格横向滚动提示 */
|
||||
.el-table {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hidden-xs {
|
||||
display: none !important;
|
||||
}
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-header .header-right {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filter-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.filter-bar .el-input,
|
||||
.filter-bar .el-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
.pagination-wrapper {
|
||||
justify-content: center;
|
||||
}
|
||||
.pagination-wrapper .el-pagination {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
/* 弹窗在移动端更宽 */
|
||||
.el-dialog {
|
||||
width: 92% !important;
|
||||
margin: 5vh auto !important;
|
||||
}
|
||||
/* 表格小屏字号调整 */
|
||||
.el-table td.el-table__cell {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
/* 表单小屏行距压缩 */
|
||||
.el-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
/* tk-resource-grid 在移动端变为单列 */
|
||||
.tk-resource-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 中等屏幕适配 */
|
||||
@media (max-width: 1200px) {
|
||||
.el-table .el-table__body-wrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 992px) {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Dynamic Unit System
|
||||
*
|
||||
* Handles dynamic unit conversion and display for product parameters.
|
||||
* Base units: memory=KB, storage=GB, bandwidth=Mbps, cpu=Core
|
||||
*/
|
||||
|
||||
const UNIT_CONVERSIONS = {
|
||||
memory: { KB: 1, MB: 1024, GB: 1024 * 1024, TB: 1024 * 1024 * 1024 },
|
||||
cpu: { Core: 1 },
|
||||
bandwidth_up: { Mbps: 1, Gbps: 1000 },
|
||||
bandwidth_down: { Mbps: 1, Gbps: 1000 },
|
||||
storage: { GB: 1, TB: 1024 },
|
||||
ipv4: { '个': 1 },
|
||||
ipv6: { '个': 1 },
|
||||
custom: {}
|
||||
}
|
||||
|
||||
const BASE_UNITS = {
|
||||
memory: 'KB',
|
||||
cpu: 'Core',
|
||||
bandwidth_up: 'Mbps',
|
||||
bandwidth_down: 'Mbps',
|
||||
storage: 'GB',
|
||||
ipv4: '个',
|
||||
ipv6: '个',
|
||||
custom: ''
|
||||
}
|
||||
|
||||
const DEFAULT_DISPLAY_UNITS = {
|
||||
memory: 'MB',
|
||||
cpu: 'Core',
|
||||
bandwidth_up: 'Mbps',
|
||||
bandwidth_down: 'Mbps',
|
||||
storage: 'GB',
|
||||
ipv4: '个',
|
||||
ipv6: '个',
|
||||
custom: ''
|
||||
}
|
||||
|
||||
const ARG_KEY_OPTIONS = [
|
||||
{ label: '内存 (memory)', value: 'memory' },
|
||||
{ label: 'CPU (cpu)', value: 'cpu' },
|
||||
{ label: 'IPv4', value: 'ipv4' },
|
||||
{ label: 'IPv6', value: 'ipv6' },
|
||||
{ label: '上行带宽 (bandwidth_up)', value: 'bandwidth_up' },
|
||||
{ label: '下行带宽 (bandwidth_down)', value: 'bandwidth_down' },
|
||||
{ label: '存储空间 (storage)', value: 'storage' },
|
||||
{ label: '自定义 (custom)', value: 'custom' }
|
||||
]
|
||||
|
||||
/**
|
||||
* Convert value between units
|
||||
* @param {number} value
|
||||
* @param {string} fromUnit
|
||||
* @param {string} toUnit
|
||||
* @param {string} argKey - e.g. 'memory', 'storage'
|
||||
*/
|
||||
export function convertUnit(value, fromUnit, toUnit, argKey) {
|
||||
if (value === null || value === undefined || fromUnit === toUnit) return value
|
||||
const conversions = UNIT_CONVERSIONS[argKey]
|
||||
if (!conversions || !conversions[fromUnit] || !conversions[toUnit]) return value
|
||||
const baseValue = value * conversions[fromUnit]
|
||||
return baseValue / conversions[toUnit]
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert from display unit to base unit for storage/submission
|
||||
*/
|
||||
export function toBaseUnit(value, displayUnit, argKey) {
|
||||
const baseUnit = BASE_UNITS[argKey]
|
||||
if (!baseUnit || !displayUnit) return value
|
||||
return convertUnit(value, displayUnit, baseUnit, argKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert from base unit to display unit for showing in UI
|
||||
*/
|
||||
export function fromBaseUnit(value, displayUnit, argKey) {
|
||||
const baseUnit = BASE_UNITS[argKey]
|
||||
if (!baseUnit || !displayUnit) return value
|
||||
return convertUnit(value, baseUnit, displayUnit, argKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base unit string for a given argKey
|
||||
*/
|
||||
export function getBaseUnit(argKey) {
|
||||
return BASE_UNITS[argKey] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default display unit for a given argKey
|
||||
*/
|
||||
export function getDefaultDisplayUnit(argKey) {
|
||||
return DEFAULT_DISPLAY_UNITS[argKey] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available units for a parameter type
|
||||
*/
|
||||
export function getAvailableUnits(argKey) {
|
||||
const conversions = UNIT_CONVERSIONS[argKey]
|
||||
return conversions ? Object.keys(conversions) : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Get argKey select options
|
||||
*/
|
||||
export function getArgKeyOptions() {
|
||||
return ARG_KEY_OPTIONS
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a parameter has dynamic unit enabled.
|
||||
* Returns true when arg_key maps to a known unit type with multiple selectable units.
|
||||
*/
|
||||
export function hasUnit(param) {
|
||||
if (!param) return false
|
||||
const argKey = param.argKey || param.arg_key || param.key || ''
|
||||
if (!argKey || !(argKey in UNIT_CONVERSIONS)) return false
|
||||
return Object.keys(UNIT_CONVERSIONS[argKey]).length > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the argKey from a parameter object (handles camelCase, snake_case, and plain key)
|
||||
*/
|
||||
export function getArgKey(param) {
|
||||
if (!param) return ''
|
||||
return param.argKey || param.arg_key || param.key || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the available units from a parameter object
|
||||
*/
|
||||
export function getParamUnits(param) {
|
||||
if (!hasUnit(param)) return []
|
||||
const argKey = getArgKey(param)
|
||||
const paramUnits = param.availableUnits || param.available_units
|
||||
if (paramUnits && paramUnits.length > 0) return paramUnits
|
||||
return getAvailableUnits(argKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default unit from a parameter object
|
||||
*/
|
||||
export function getParamDefaultUnit(param) {
|
||||
if (!hasUnit(param)) return ''
|
||||
const argKey = getArgKey(param)
|
||||
return param.defaultUnit || param.default_unit || getDefaultDisplayUnit(argKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a unit is valid for a parameter type
|
||||
*/
|
||||
export function isValidUnit(unit, argKey) {
|
||||
const conversions = UNIT_CONVERSIONS[argKey]
|
||||
return conversions && Object.prototype.hasOwnProperty.call(conversions, unit)
|
||||
}
|
||||
|
||||
export function formatValueWithUnit(value, unit) {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return unit ? `${value} ${unit}` : String(value)
|
||||
}
|
||||
@@ -467,7 +467,7 @@
|
||||
<h3 class="tab-title">数据卷列表</h3>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="showAddVolumeDialog = true"
|
||||
@click="handleAddVolume"
|
||||
:icon="Plus"
|
||||
:disabled="vmInfo.state != 2"
|
||||
>
|
||||
@@ -671,8 +671,11 @@
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
|
||||
<el-form-item label="大小(GB)" prop="size">
|
||||
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" />
|
||||
<el-form-item label="大小" prop="size">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" style="flex:1" />
|
||||
<el-select v-model="volumeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -693,8 +696,11 @@
|
||||
>
|
||||
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
|
||||
|
||||
<el-form-item label="大小(GB)" prop="size">
|
||||
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" />
|
||||
<el-form-item label="大小" prop="size">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" style="flex:1" />
|
||||
<el-select v-model="volumeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -1067,6 +1073,7 @@ const showMigrateVolumeDialog = ref(false);
|
||||
const currentVolumeToEdit = ref(null);
|
||||
const volumeForm = reactive({
|
||||
size: 10,
|
||||
_sizeUnit: 'GB'
|
||||
});
|
||||
const volumeFormRef = ref(null);
|
||||
const volumeRules = {
|
||||
@@ -2371,6 +2378,7 @@ const handleAddVolume = () => {
|
||||
showAddVolumeDialog.value = true;
|
||||
// 重置表单
|
||||
volumeForm.size = 10;
|
||||
volumeForm._sizeUnit = 'GB';
|
||||
};
|
||||
|
||||
// 编辑数据卷
|
||||
@@ -2378,6 +2386,7 @@ const handleEditVolume = (volume) => {
|
||||
currentVolumeToEdit.value = volume;
|
||||
// 填充表单
|
||||
volumeForm.size = volume.size;
|
||||
volumeForm._sizeUnit = 'GB';
|
||||
showEditVolumeDialog.value = true;
|
||||
};
|
||||
|
||||
@@ -2404,9 +2413,10 @@ const submitAddVolume = async () => {
|
||||
if (valid) {
|
||||
addingVolume.value = true;
|
||||
try {
|
||||
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
|
||||
const res = await addVolume({
|
||||
instance_id: route.query.instance_id,
|
||||
size: String(volumeForm.size),
|
||||
size: String(sizeGb),
|
||||
user_id: user_id.value
|
||||
});
|
||||
console.log("添加数据卷112",res)
|
||||
@@ -2438,9 +2448,10 @@ const submitEditVolume = async () => {
|
||||
editingVolume.value = true;
|
||||
try {
|
||||
// 这里应该调用修改数据卷的API
|
||||
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
|
||||
const res = await updateVolume({
|
||||
volume_id: currentVolumeToEdit.value.id,
|
||||
size: volumeForm.size
|
||||
size: sizeGb
|
||||
});
|
||||
console.log("编辑数据卷数据:",res)
|
||||
|
||||
@@ -2770,4 +2781,7 @@ const fetchServersList = async () => {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-select { width: 90px; flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
@@ -315,7 +315,11 @@
|
||||
class="data-table"
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="size" label="空间大小(MB)" width="140" />
|
||||
<el-table-column prop="size" label="空间大小(MB)" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.size != null && row.size !== '' ? `${row.size} MB` : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mount_path" label="挂载路径" min-width="200" />
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="160" />
|
||||
</el-table>
|
||||
|
||||
@@ -211,11 +211,17 @@
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="typeForm.name" placeholder="请输入名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格(分)" prop="price">
|
||||
<el-input-number v-model="typeForm.price" :min="0" style="width: 100%" />
|
||||
<el-form-item label="价格" prop="price">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="typeForm.price" :min="0" style="flex:1" />
|
||||
<span class="unit-text">分</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="续费价格(分)" prop="renewPrice">
|
||||
<el-input-number v-model="typeForm.renewPrice" :min="0" style="width: 100%" />
|
||||
<el-form-item label="续费价格" prop="renewPrice">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="typeForm.renewPrice" :min="0" style="flex:1" />
|
||||
<span class="unit-text">分</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="拼团人数" prop="maxPerson">
|
||||
<el-input-number v-model="typeForm.maxPerson" :min="2" :max="100" style="width: 100%" />
|
||||
@@ -903,4 +909,7 @@ onMounted(() => {
|
||||
:deep(.el-tabs__active-bar) {
|
||||
background-color: #2c3e50;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -52,11 +52,17 @@
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="请输入名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格(分)" prop="price">
|
||||
<el-input-number v-model="form.price" :min="0" style="width: 100%" />
|
||||
<el-form-item label="价格" prop="price">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="form.price" :min="0" style="flex:1" />
|
||||
<span class="unit-text">分</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="续费价格(分)" prop="renewPrice">
|
||||
<el-input-number v-model="form.renewPrice" :min="0" style="width: 100%" />
|
||||
<el-form-item label="续费价格" prop="renewPrice">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="form.renewPrice" :min="0" style="flex:1" />
|
||||
<span class="unit-text">分</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="拼团人数" prop="maxPerson">
|
||||
<el-input-number v-model="form.maxPerson" :min="2" :max="100" style="width: 100%" />
|
||||
@@ -257,4 +263,6 @@ onMounted(() => { fetchTags() })
|
||||
.table-card { box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); }
|
||||
.pagination-wrapper { margin-top: 20px; display: flex; justify-content: flex-end; }
|
||||
.note-fields-container { width: 100%; }
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -823,15 +823,19 @@ watch(salesRange, (newVal) => {
|
||||
/* 统计卡片样式 */
|
||||
.stat-card {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
transition: all 0.3s;
|
||||
overflow: hidden;
|
||||
border-left: 3px solid transparent !important;
|
||||
}
|
||||
|
||||
.stat-card.visitors { border-left-color: #1890ff !important; }
|
||||
.stat-card.orders { border-left-color: #52c41a !important; }
|
||||
.stat-card.sales { border-left-color: #faad14 !important; }
|
||||
.stat-card.conversion { border-left-color: #722ed1 !important; }
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
@@ -862,10 +866,10 @@ watch(salesRange, (newVal) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
font-size: 28px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
font-size: 24px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -927,8 +931,6 @@ watch(salesRange, (newVal) => {
|
||||
|
||||
.chart-card {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -1036,8 +1038,6 @@ watch(salesRange, (newVal) => {
|
||||
|
||||
.activity-card, .todo-card {
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.card-header-custom {
|
||||
@@ -1169,8 +1169,6 @@ watch(salesRange, (newVal) => {
|
||||
|
||||
.list-card {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
height: 410px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1209,8 +1207,9 @@ watch(salesRange, (newVal) => {
|
||||
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
color: #8c8c8c;
|
||||
padding: 40px 0;
|
||||
color: #909399;
|
||||
padding: 60px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
|
||||
@@ -141,17 +141,26 @@
|
||||
<el-radio label="percentage">百分比折扣</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额(元)" prop="amount">
|
||||
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="width: 100%" />
|
||||
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额" prop="amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="discountForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入优惠金额" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage">
|
||||
<el-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最低消费(元)" prop="min_amount">
|
||||
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="width: 100%" />
|
||||
<el-form-item label="最低消费" prop="min_amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="discountForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大抵扣(元)" prop="max_amount">
|
||||
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="width: 100%" />
|
||||
<el-form-item label="最大抵扣" prop="max_amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="discountForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大使用次数" prop="max_times">
|
||||
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||
@@ -651,6 +660,9 @@ onMounted(() => {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -798,33 +798,6 @@ onMounted(() => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
:deep(.el-table) {
|
||||
border: none;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
:deep(.el-table__header) {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: #f8f9fa !important;
|
||||
border-bottom: 2px solid #e1e8ed;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
:deep(.el-table tr:hover > td) {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -803,33 +803,6 @@ onMounted(() => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
:deep(.el-table) {
|
||||
border: none;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
:deep(.el-table__header) {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: #f8f9fa !important;
|
||||
border-bottom: 2px solid #e1e8ed;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
:deep(.el-table tr:hover > td) {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -105,14 +105,23 @@
|
||||
<el-form-item label="备注" prop="note">
|
||||
<el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
|
||||
</el-form-item>
|
||||
<el-form-item label="面额(元)" prop="amount">
|
||||
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="width: 100%" />
|
||||
<el-form-item label="面额" prop="amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最低消费(元)" prop="min_amount">
|
||||
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="width: 100%" />
|
||||
<el-form-item label="最低消费" prop="min_amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="voucherForm.min_amount" :min="0" :precision="2" :step="0.01" placeholder="满多少可使用" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大抵扣(元)" prop="max_amount">
|
||||
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="width: 100%" />
|
||||
<el-form-item label="最大抵扣" prop="max_amount">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="voucherForm.max_amount" :min="0" :precision="2" :step="0.01" placeholder="0表示无限制" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大使用次数" prop="max_times">
|
||||
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||
@@ -120,8 +129,11 @@
|
||||
<el-form-item label="单用户最大次数" prop="user_times">
|
||||
<el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(天)" prop="duration_days">
|
||||
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="width: 100%" />
|
||||
<el-form-item label="有效期" prop="duration_days">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="flex:1" />
|
||||
<span class="unit-text">天</span>
|
||||
</div>
|
||||
<div class="form-tip">代金券领取后的有效持续时间</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="发放时间范围" prop="timeRange">
|
||||
@@ -539,6 +551,9 @@ onMounted(() => {
|
||||
margin-top: 24px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -248,11 +248,17 @@
|
||||
<el-form-item label="购买数量" prop="pay_num">
|
||||
<el-input-number v-model="orderForm.pay_num" :min="1" placeholder="请输入数量" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格(分)" prop="price">
|
||||
<el-input-number v-model="orderForm.price" :min="0" placeholder="请输入价格(分)" style="width: 100%" />
|
||||
<el-form-item label="价格" prop="price">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="orderForm.price" :min="0" placeholder="请输入价格(分)" style="flex:1" />
|
||||
<span class="unit-text">分</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="续费价格(分)" prop="renew_price">
|
||||
<el-input-number v-model="orderForm.renew_price" :min="0" placeholder="请输入续费价格(分)" style="width: 100%" />
|
||||
<el-form-item label="续费价格" prop="renew_price">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="orderForm.renew_price" :min="0" placeholder="请输入续费价格(分)" style="flex:1" />
|
||||
<span class="unit-text">分</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="过期时间" prop="expire_time">
|
||||
<el-date-picker
|
||||
@@ -957,4 +963,7 @@ onMounted(() => {
|
||||
.clear-icon:hover {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<el-table-column label="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<template v-if="row.isGroup">
|
||||
@@ -437,7 +437,7 @@
|
||||
<el-dialog
|
||||
v-model="showTagSelector"
|
||||
title="选择分组标签"
|
||||
width="600px"
|
||||
width="650px"
|
||||
append-to-body
|
||||
>
|
||||
<div class="tag-selector-header">
|
||||
@@ -467,6 +467,11 @@
|
||||
<el-tag type="primary">{{ row.name }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link size="small" @click.stop="handleDeleteTagFromSelector(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无标签数据" :image-size="60" />
|
||||
</template>
|
||||
@@ -551,6 +556,7 @@
|
||||
v-model="coverSelectorVisible"
|
||||
:user-id="1"
|
||||
:current-cover-id="productForm.cover_id"
|
||||
title="选择封面"
|
||||
@confirm="handleProductCoverSelect"
|
||||
/>
|
||||
|
||||
@@ -560,14 +566,20 @@
|
||||
<el-form-item label="库存数量" prop="inventory">
|
||||
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品价格(元)" prop="price">
|
||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="width: 100%" />
|
||||
<el-form-item label="商品价格" prop="price">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="单个商品数量" prop="pay_num">
|
||||
<el-input-number v-model="productForm.pay_num" :min="1" placeholder="请输入单个商品数量" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(天)" prop="expire_time">
|
||||
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="width: 100%" />
|
||||
<el-form-item label="有效期" prop="expire_time">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="flex:1" />
|
||||
<span class="unit-text">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="推荐" prop="recommend">
|
||||
<el-switch v-model="productForm.recommend" active-text="启用" inactive-text="禁用" />
|
||||
@@ -651,6 +663,7 @@ import {
|
||||
hideProductGroup,
|
||||
startProductGroup,
|
||||
getProductGroupTagList,
|
||||
deleteProductGroupTag,
|
||||
getProductList,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
@@ -1117,6 +1130,25 @@ const clearTag = () => {
|
||||
groupForm.tag_id = undefined
|
||||
}
|
||||
|
||||
const handleDeleteTagFromSelector = (row) => {
|
||||
ElMessageBox.confirm(`确定删除标签「${row.name}」吗?删除后使用该标签的分组将失去关联。`, '删除确认', { type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await deleteProductGroupTag({ id: row.id })
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('标签已删除')
|
||||
fetchTagOptionsForSelector()
|
||||
fetchAllTagOptions()
|
||||
} else {
|
||||
ElMessage.error(res?.data?.message || '删除失败')
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(e?.response?.data?.message || '删除失败')
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
watch(showTagSelector, (val) => {
|
||||
if (val) {
|
||||
tagSelectorSearch.value = ''
|
||||
@@ -1468,7 +1500,7 @@ const clearProductGroup = () => {
|
||||
}
|
||||
|
||||
const handleProductCoverSelect = (file) => {
|
||||
productForm.cover_id = file.id
|
||||
productForm.cover_id = file.cover_id
|
||||
coverSelectorVisible.value = false
|
||||
}
|
||||
|
||||
@@ -1791,6 +1823,9 @@ onMounted(() => {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
|
||||
.recommend-user-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
+378
-153
@@ -73,7 +73,11 @@
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="商品名称" min-width="200" />
|
||||
<el-table-column label="商品名称" min-width="200">
|
||||
<template #default="{ row }">
|
||||
{{ row.name }}(ID:{{ row.id }})
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标签" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.tag" size="small" type="success">{{ row.tag }}</el-tag>
|
||||
@@ -221,6 +225,7 @@
|
||||
v-model="coverSelectorVisible"
|
||||
:user-id="1"
|
||||
:current-cover-id="productForm.cover_id"
|
||||
title="选择封面"
|
||||
@confirm="handleCoverSelect"
|
||||
/>
|
||||
<el-form-item label="库存控制" prop="inventory_control">
|
||||
@@ -229,14 +234,20 @@
|
||||
<el-form-item label="库存数量" prop="inventory">
|
||||
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品价格(元)" prop="price">
|
||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="width: 100%" />
|
||||
<el-form-item label="商品价格" prop="price">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="单个商品数量" prop="pay_num">
|
||||
<el-input-number v-model="productForm.pay_num" :min="1" placeholder="请输入单个商品数量" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期(天)" prop="expire_time">
|
||||
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="width: 100%" />
|
||||
<el-form-item label="有效期" prop="expire_time">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="flex:1" />
|
||||
<span class="unit-text">天</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="推荐" prop="recommend">
|
||||
<el-switch v-model="productForm.recommend" active-text="启用" inactive-text="禁用" />
|
||||
@@ -252,6 +263,13 @@
|
||||
</el-select>
|
||||
<div class="form-tip">all: 所有参数 / plan: 套餐 / customize: 自定义</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="归属项 ID" prop="attribution_id">
|
||||
<el-select v-model="productForm.attribution_id" placeholder="请选择归属项" style="width: 100%" @change="handleAttributionChange">
|
||||
<el-option label="虚拟机" value="vm" />
|
||||
<el-option label="其他" value="other" />
|
||||
</el-select>
|
||||
<div class="form-tip">选择归属项类型,虚拟机会关联相关数据</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
@@ -320,61 +338,57 @@
|
||||
:title="paramFormType === 'add' ? '新增商品参数' : '编辑商品参数'"
|
||||
width="600px"
|
||||
append-to-body
|
||||
class="tk-dialog"
|
||||
>
|
||||
<el-form
|
||||
ref="paramFormRef"
|
||||
:model="paramForm"
|
||||
:rules="paramRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="参数名称" prop="arg_name">
|
||||
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参数类型" prop="arg_type">
|
||||
<el-radio-group v-model="paramForm.arg_type">
|
||||
<el-radio label="string">字符串</el-radio>
|
||||
<el-radio label="number">数字</el-radio>
|
||||
<el-radio label="select">选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否必选" prop="must">
|
||||
<el-switch
|
||||
v-model="paramForm.must"
|
||||
:active-value="true"
|
||||
:inactive-value="false"
|
||||
active-text="必选"
|
||||
inactive-text="可选"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">权限控制</el-divider>
|
||||
<el-form-item label="允许单独购买">
|
||||
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户组优惠">
|
||||
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户优惠">
|
||||
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠(代金券与优惠码)</div>
|
||||
</el-form-item>
|
||||
<!-- number 类型参数的额外配置 -->
|
||||
<el-form ref="paramFormRef" :model="paramForm" :rules="paramRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="参数名称" prop="arg_name">
|
||||
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参数类型" prop="arg_type">
|
||||
<el-radio-group v-model="paramForm.arg_type">
|
||||
<el-radio label="string">字符串</el-radio>
|
||||
<el-radio label="number">数字</el-radio>
|
||||
<el-radio label="select">选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否必选" prop="must">
|
||||
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">权限控制</div>
|
||||
<el-form-item label="允许单独购买">
|
||||
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户组优惠">
|
||||
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户优惠">
|
||||
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠(代金券与优惠码)</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template v-if="paramForm.arg_type === 'number'">
|
||||
<el-divider content-position="left">数值参数配置</el-divider>
|
||||
<el-form-item label="步进值" prop="arg_step">
|
||||
<el-input-number v-model="paramForm.arg_step" :min="1" placeholder="步进值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最小值" prop="arg_min">
|
||||
<el-input-number v-model="paramForm.arg_min" placeholder="最小值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大值" prop="arg_max">
|
||||
<el-input-number v-model="paramForm.arg_max" placeholder="最大值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">数值参数配置</div>
|
||||
<el-form-item label="步进值" prop="arg_step">
|
||||
<el-input-number v-model="paramForm.arg_step" :min="1" placeholder="步进值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最小值" prop="arg_min">
|
||||
<el-input-number v-model="paramForm.arg_min" placeholder="最小值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大值" prop="arg_max">
|
||||
<el-input-number v-model="paramForm.arg_max" placeholder="最大值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="paramFormDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitParamForm">确定</el-button>
|
||||
</div>
|
||||
@@ -440,45 +454,44 @@
|
||||
:title="paramValueFormType === 'add' ? '添加参数值' : '编辑参数值'"
|
||||
width="550px"
|
||||
append-to-body
|
||||
class="tk-dialog"
|
||||
>
|
||||
<el-form
|
||||
ref="paramValueFormRef"
|
||||
:model="paramValueForm"
|
||||
:rules="paramValueRules"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="值名称" prop="attr_name">
|
||||
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
|
||||
</el-form-item>
|
||||
<!-- select 类型显示参数值 -->
|
||||
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value">
|
||||
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" />
|
||||
</el-form-item>
|
||||
<!-- number 类型显示范围配置 -->
|
||||
<el-form ref="paramValueFormRef" :model="paramValueForm" :rules="paramValueRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">参数值信息</div>
|
||||
<el-form-item label="值名称" prop="attr_name">
|
||||
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value">
|
||||
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="paramValueForm.index" :min="0" placeholder="排序索引" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="attr_price">
|
||||
<el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template v-if="currentParam?.type === 'number'">
|
||||
<el-divider content-position="left">数值范围配置(phase)</el-divider>
|
||||
<el-form-item label="范围类型" prop="range_type">
|
||||
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
|
||||
<el-option label="小于等于 (before)" value="before" />
|
||||
<el-option label="大于等于 (after)" value="after" />
|
||||
<el-option label="等于 (equal)" value="equal" />
|
||||
</el-select>
|
||||
<div class="form-tip">before: 数值 ≤ phase 时匹配 | after: 数值 ≥ phase 时匹配</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值" prop="attr_range">
|
||||
<el-input-number v-model="paramValueForm.attr_range" :min="0" placeholder="范围阈值" style="width: 100%" />
|
||||
<div class="form-tip">例如:phase=100, rangeType=before 表示 0-100 范围</div>
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">数值范围配置</div>
|
||||
<el-form-item label="范围类型" prop="range_type">
|
||||
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
|
||||
<el-option label="小于 (before)" value="before" />
|
||||
<el-option label="大于 (after)" value="after" />
|
||||
<el-option label="等于 (equal)" value="equal" />
|
||||
</el-select>
|
||||
<div class="form-tip">before: 数值 < phase 时匹配 | after: 数值 > phase 时匹配</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值" prop="attr_range">
|
||||
<el-input-number v-model="paramValueForm.attr_range" :min="0" placeholder="范围阈值" style="width: 100%" />
|
||||
<div class="form-tip">例如:phase=100, rangeType=before 表示 0-100 范围</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="paramValueForm.index" :min="0" placeholder="排序索引" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="attr_price">
|
||||
<el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="paramValueFormDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitParamValueForm">确定</el-button>
|
||||
</div>
|
||||
@@ -541,6 +554,13 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="允许升级" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.canUpdate || row.can_update ? 'success' : 'info'" size="small">
|
||||
{{ row.canUpdate || row.can_update ? '允许' : '不允许' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEditPlan(row)">编辑</el-button>
|
||||
@@ -631,16 +651,26 @@
|
||||
<template v-else-if="spec.type === 'number'">
|
||||
<div class="number-input-wrapper">
|
||||
<el-input-number
|
||||
v-model="selectedArgs[spec.id]"
|
||||
:min="spec.min || 0"
|
||||
:max="spec.max || 9999"
|
||||
:step="spec.step || 1"
|
||||
v-model="displayValues[spec.id]"
|
||||
:min="getSpecDisplayMin(spec)"
|
||||
:max="getSpecDisplayMax(spec)"
|
||||
:step="getSpecDisplayStep(spec)"
|
||||
:step-strictly="true"
|
||||
size="small"
|
||||
@change="updateArgsJson"
|
||||
@change="onNumberDisplayChange(spec)"
|
||||
/>
|
||||
<el-select
|
||||
v-if="hasUnit(spec)"
|
||||
:model-value="displayUnits[spec.id]"
|
||||
size="small"
|
||||
style="width: 90px"
|
||||
@change="(newUnit) => onPlanUnitChange(spec, newUnit)"
|
||||
>
|
||||
<el-option v-for="u in getParamUnits(spec)" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
<span class="number-range">
|
||||
(范围: {{ spec.min || 0 }} - {{ spec.max || 9999 }},步长: {{ spec.step || 1 }})
|
||||
({{ spec.min ?? 0 }} - {{ spec.max ?? 0 }}
|
||||
<template v-if="hasUnit(spec)"> {{ getBaseUnit(getArgKey(spec)) }}</template>,步长: {{ spec.step ?? 1 }})
|
||||
</span>
|
||||
</div>
|
||||
<!-- 显示匹配的价格区间 -->
|
||||
@@ -666,15 +696,18 @@
|
||||
<el-empty v-else-if="planSpecList.length > 0" description="请先选择需要配置的参数" :image-size="60" />
|
||||
<el-empty v-else description="暂无参数配置,请先为商品添加参数" :image-size="60" />
|
||||
|
||||
<!-- 查看JSON按钮 -->
|
||||
<div class="args-actions" v-if="selectedArgSpecs.length > 0">
|
||||
<el-button type="info" plain size="small" @click="showArgsPreview = true">
|
||||
<el-icon><View /></el-icon>
|
||||
查看配置JSON
|
||||
<el-icon><View /></el-icon>查看配置JSON
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="handleCopyArgsJson">
|
||||
<el-icon><CopyDocument /></el-icon>复制JSON
|
||||
</el-button>
|
||||
<el-button type="success" plain size="small" @click="handlePasteArgsJson">
|
||||
<el-icon><DocumentAdd /></el-icon>粘贴JSON
|
||||
</el-button>
|
||||
<el-button type="warning" plain size="small" @click="clearArgsSelection">
|
||||
<el-icon><Delete /></el-icon>
|
||||
清空选择
|
||||
<el-icon><Delete /></el-icon>清空选择
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -728,8 +761,11 @@
|
||||
/>
|
||||
<div class="form-tip">启用后套餐价格将使用固定价格,不再根据参数计算</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="固定价格(元)" prop="fixed_price" v-if="planForm.enable_fixed_price === true">
|
||||
<el-input-number v-model="planForm.fixed_price" :min="0" :precision="2" :step="0.01" style="width: 100%" placeholder="请输入固定价格(元)" />
|
||||
<el-form-item label="固定价格" prop="fixed_price" v-if="planForm.enable_fixed_price === true">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="planForm.fixed_price" :min="0" :precision="2" :step="0.01" style="flex:1" placeholder="请输入固定价格(元)" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" />
|
||||
@@ -748,6 +784,10 @@
|
||||
/>
|
||||
<div class="form-tip">控制商品套餐是否在首页显示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许升级" prop="can_update">
|
||||
<el-switch v-model="planForm.can_update" active-text="允许" inactive-text="不允许" />
|
||||
<div class="form-tip">控制用户是否可以升级到此套餐</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -789,6 +829,15 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 手动粘贴JSON对话框 -->
|
||||
<el-dialog v-model="showPasteDialog" title="粘贴JSON" width="500px" append-to-body>
|
||||
<el-input v-model="pasteJsonText" type="textarea" :rows="8" placeholder="请将JSON粘贴到此处" />
|
||||
<template #footer>
|
||||
<el-button @click="showPasteDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="doPasteFromText">确定导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 商品分组选择器对话框 -->
|
||||
<el-dialog
|
||||
v-model="showGroupSelector"
|
||||
@@ -851,9 +900,14 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Delete, Search, Refresh, Picture, ArrowRight, Loading, View } from '@element-plus/icons-vue'
|
||||
import { Plus, Delete, Search, Refresh, Picture, ArrowRight, Loading, View, CopyDocument, DocumentAdd } from '@element-plus/icons-vue'
|
||||
import AvatarSelector from '@/components/admin/AvatarSelector.vue'
|
||||
import { getProductList, createProduct, updateProduct, deleteProduct, getProductGroupList,
|
||||
import {
|
||||
getProductList,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
getProductGroupList,
|
||||
getProductTagList,
|
||||
getProductParameterList,
|
||||
getProductParameterDetail,
|
||||
@@ -873,6 +927,11 @@ import { getProductList, createProduct, updateProduct, deleteProduct, getProduct
|
||||
disablePlanFixedPrice,
|
||||
enablePlanFixedPrice
|
||||
} from '@/api/admin/product'
|
||||
import { getUserVmList } from '@/api/admin/userVm'
|
||||
import {
|
||||
hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit,
|
||||
toBaseUnit, fromBaseUnit, formatValueWithUnit
|
||||
} from '@/utils/dynamicUnit'
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
@@ -897,7 +956,8 @@ const productForm = reactive({
|
||||
expire_time: 0,
|
||||
recommend: false,
|
||||
recommend_rebate: 0,
|
||||
arg_type: 'all' // 商品参数类型 all/plan/customize
|
||||
arg_type: 'all', // 商品参数类型 all/plan/customize
|
||||
attribution_id: '' // 归属项ID
|
||||
})
|
||||
|
||||
const productRules = {
|
||||
@@ -1170,6 +1230,7 @@ const handleAdd = () => {
|
||||
good_group_id: undefined,
|
||||
inventory_control: false,
|
||||
inventory: 0,
|
||||
attribution_id: '',
|
||||
price: 0,
|
||||
pay_num: 1,
|
||||
expire_time: 0,
|
||||
@@ -1295,7 +1356,8 @@ const submitForm = () => {
|
||||
pay_num: productForm.pay_num || 1,
|
||||
expire_time: productForm.expire_time || 0,
|
||||
recommend_rebate: productForm.recommend_rebate || 0,
|
||||
arg_type: productForm.arg_type || 'all' // 商品参数类型
|
||||
arg_type: productForm.arg_type || 'all', // 商品参数类型
|
||||
attribution_id: productForm.attribution_id || '' // 归属项ID
|
||||
}
|
||||
|
||||
console.log('提交的数据:', submitData) // 调试日志
|
||||
@@ -1318,6 +1380,27 @@ const submitForm = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// 处理归属项变化
|
||||
const handleAttributionChange = async (value) => {
|
||||
if (value === 'vm' && productForm.id) {
|
||||
try {
|
||||
// 当选择虚拟机时,调用用户虚拟机列表API
|
||||
const res = await getUserVmList({
|
||||
page: 1,
|
||||
count: 10,
|
||||
good_id: productForm.id // 传递当前商品ID
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
console.log('虚拟机列表:', res.data.data)
|
||||
ElMessage.success('已获取虚拟机关联数据')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取虚拟机列表失败:', error)
|
||||
ElMessage.error('获取虚拟机关联数据失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 打开封面选择器
|
||||
const openCoverSelector = () => {
|
||||
coverSelectorVisible.value = true
|
||||
@@ -1436,7 +1519,7 @@ const getArgTypeTag = (type) => {
|
||||
|
||||
// 范围类型显示
|
||||
const getRangeTypeText = (type) => {
|
||||
const typeMap = { 'after': '大于 >', 'before': '小于 <', 'equal': '等于 =' }
|
||||
const typeMap = { 'after': '大于 >', 'before': '小于 <', 'equal': '等于 =' }
|
||||
return typeMap[type] || type || '-'
|
||||
}
|
||||
|
||||
@@ -1566,19 +1649,17 @@ const fetchParamValuesList = async () => {
|
||||
const handleAddParamValue = () => {
|
||||
paramValueFormType.value = 'add'
|
||||
paramValueFormDialogVisible.value = true
|
||||
// 先重置表单数据
|
||||
Object.assign(paramValueForm, {
|
||||
attr_id: undefined,
|
||||
attr_name: '',
|
||||
attr_value: '',
|
||||
attr_price: 0,
|
||||
index: 0,
|
||||
attr_range: 0,
|
||||
range_type: 'equal'
|
||||
})
|
||||
// 等待 DOM 更新后再重置表单验证状态
|
||||
nextTick(() => {
|
||||
paramValueFormRef.value?.resetFields()
|
||||
Object.assign(paramValueForm, {
|
||||
attr_id: undefined,
|
||||
attr_name: '',
|
||||
attr_value: '',
|
||||
attr_price: 0,
|
||||
index: 0,
|
||||
attr_range: 0,
|
||||
range_type: 'equal'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1682,7 +1763,8 @@ const planForm = reactive({
|
||||
enable_fixed_price: false,
|
||||
index: 0,
|
||||
disable: false,
|
||||
show_home: false
|
||||
show_home: false,
|
||||
can_update: false
|
||||
})
|
||||
|
||||
const planFormRules = {
|
||||
@@ -1692,8 +1774,12 @@ const planFormRules = {
|
||||
// 套餐参数选择相关
|
||||
const planSpecList = ref([]) // 当前商品的参数列表
|
||||
const selectedArgIds = ref([]) // 选中的参数ID列表
|
||||
const selectedArgs = reactive({}) // 选中的参数值 { arg_id: value_id 或 value }
|
||||
const showArgsPreview = ref(false) // 显示参数预览对话框
|
||||
const selectedArgs = reactive({}) // 选中的参数值(基础单位)
|
||||
const displayValues = reactive({}) // 显示值(当前选中单位)
|
||||
const displayUnits = reactive({}) // 当前选中的显示单位
|
||||
const showArgsPreview = ref(false)
|
||||
const showPasteDialog = ref(false)
|
||||
const pasteJsonText = ref('')
|
||||
|
||||
// 额外参数相关
|
||||
const selectedExtraArgIds = ref([]) // 选中的额外参数ID列表
|
||||
@@ -1724,13 +1810,19 @@ const fetchPlanSpecList = async () => {
|
||||
|
||||
// 参数选择变化
|
||||
const onSelectedArgsChange = () => {
|
||||
// 清除未选中参数的值
|
||||
for (const key in selectedArgs) {
|
||||
if (!selectedArgIds.value.includes(Number(key))) {
|
||||
delete selectedArgs[key]
|
||||
delete displayValues[key]
|
||||
delete displayUnits[key]
|
||||
}
|
||||
}
|
||||
for (const specId of selectedArgIds.value) {
|
||||
const spec = planSpecList.value.find(s => s.id === specId)
|
||||
if (spec && spec.type === 'number' && hasUnit(spec) && !displayUnits[specId]) {
|
||||
displayUnits[specId] = getParamDefaultUnit(spec)
|
||||
}
|
||||
}
|
||||
// 同时从额外参数中移除已选的参数
|
||||
selectedExtraArgIds.value = selectedExtraArgIds.value.filter(
|
||||
id => !selectedArgIds.value.includes(id)
|
||||
)
|
||||
@@ -1738,6 +1830,49 @@ const onSelectedArgsChange = () => {
|
||||
updateExtraArgIds()
|
||||
}
|
||||
|
||||
const getSpecDisplayMin = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.min ?? 0
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
return unit ? fromBaseUnit(spec.min ?? 0, unit, argKey) : (spec.min ?? 0)
|
||||
}
|
||||
const getSpecDisplayMax = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.max ?? 0
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
return unit ? fromBaseUnit(spec.max ?? 0, unit, argKey) : (spec.max ?? 0)
|
||||
}
|
||||
const getSpecDisplayStep = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.step ?? 1
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
if (!unit) return spec.step ?? 1
|
||||
const converted = fromBaseUnit(spec.step ?? 1, unit, argKey)
|
||||
return converted > 0 ? converted : 1
|
||||
}
|
||||
|
||||
const onNumberDisplayChange = (spec) => {
|
||||
if (hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
selectedArgs[spec.id] = Math.round(toBaseUnit(displayValues[spec.id] || 0, unit, argKey))
|
||||
} else {
|
||||
selectedArgs[spec.id] = displayValues[spec.id]
|
||||
}
|
||||
updateArgsJson()
|
||||
}
|
||||
|
||||
const onPlanUnitChange = (spec, newUnit) => {
|
||||
const argKey = getArgKey(spec)
|
||||
const oldUnit = displayUnits[spec.id]
|
||||
const oldDisplay = displayValues[spec.id] || 0
|
||||
const baseValue = oldUnit ? toBaseUnit(oldDisplay, oldUnit, argKey) : oldDisplay
|
||||
displayUnits[spec.id] = newUnit
|
||||
displayValues[spec.id] = fromBaseUnit(baseValue, newUnit, argKey)
|
||||
selectedArgs[spec.id] = Math.round(baseValue)
|
||||
updateArgsJson()
|
||||
}
|
||||
|
||||
// 额外参数选择变化
|
||||
const onSelectedExtraArgsChange = () => {
|
||||
updateExtraArgIds()
|
||||
@@ -1775,34 +1910,25 @@ const updateArgsJson = () => {
|
||||
if (selectedValue === undefined || selectedValue === '') continue
|
||||
|
||||
if (spec.type === 'select') {
|
||||
// select 类型:找到选中的值对象
|
||||
const attrObj = spec.attrs?.find(a => a.id === selectedValue)
|
||||
if (attrObj) {
|
||||
argsArray.push({
|
||||
arg_id: spec.id,
|
||||
name: spec.name,
|
||||
attr_id: attrObj.id,
|
||||
value: attrObj.value || ''
|
||||
arg_id: spec.id, name: spec.name, attr_id: attrObj.id,
|
||||
value: attrObj.value || '', key: getArgKey(spec) || undefined
|
||||
})
|
||||
}
|
||||
} else if (spec.type === 'number') {
|
||||
// number 类型:根据数值找到对应的价格区间ID
|
||||
const numValue = Number(selectedValue)
|
||||
const matchedAttr = findMatchingNumberAttr(spec, numValue)
|
||||
|
||||
argsArray.push({
|
||||
arg_id: spec.id,
|
||||
name: spec.name,
|
||||
arg_id: spec.id, name: spec.name,
|
||||
attr_id: matchedAttr ? matchedAttr.id : 0,
|
||||
number: numValue
|
||||
number: numValue, key: getArgKey(spec) || undefined
|
||||
})
|
||||
} else {
|
||||
// string 类型
|
||||
argsArray.push({
|
||||
arg_id: spec.id,
|
||||
name: spec.name,
|
||||
attr_id: 0,
|
||||
value: String(selectedValue)
|
||||
arg_id: spec.id, name: spec.name, attr_id: 0,
|
||||
value: String(selectedValue), key: getArgKey(spec) || undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1821,12 +1947,9 @@ const findMatchingNumberAttr = (spec, numValue) => {
|
||||
const phase = attr.phase || 0
|
||||
const rangeType = attr.rangeType || 'before'
|
||||
|
||||
// rangeType: before 表示小于等于 phase
|
||||
// rangeType: after 表示大于等于 phase
|
||||
// rangeType: equal 表示等于 phase
|
||||
if (rangeType === 'before' && numValue <= phase) {
|
||||
if (rangeType === 'before' && numValue < phase) {
|
||||
return attr
|
||||
} else if (rangeType === 'after' && numValue >= phase) {
|
||||
} else if (rangeType === 'after' && numValue > phase) {
|
||||
return attr
|
||||
} else if (rangeType === 'equal' && numValue === phase) {
|
||||
return attr
|
||||
@@ -1858,24 +1981,108 @@ const generateArgId = (argId, value) => {
|
||||
// 清空参数选择
|
||||
const clearArgsSelection = () => {
|
||||
selectedArgIds.value = []
|
||||
for (const key in selectedArgs) {
|
||||
delete selectedArgs[key]
|
||||
}
|
||||
for (const key in selectedArgs) delete selectedArgs[key]
|
||||
for (const key in displayValues) delete displayValues[key]
|
||||
for (const key in displayUnits) delete displayUnits[key]
|
||||
selectedExtraArgIds.value = []
|
||||
planForm.args = ''
|
||||
planForm.extra_arg_ids = ''
|
||||
planForm.extra_arg_ids_array = []
|
||||
}
|
||||
|
||||
// 获取选中值的显示文本
|
||||
const handleCopyArgsJson = async () => {
|
||||
if (!planForm.args) { ElMessage.warning('暂无参数配置可复制'); return }
|
||||
try {
|
||||
await navigator.clipboard.writeText(planForm.args)
|
||||
ElMessage.success('已复制参数配置JSON到剪贴板')
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = planForm.args
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
ElMessage.success('已复制参数配置JSON到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasteArgsJson = async () => {
|
||||
let clipText = ''
|
||||
try {
|
||||
clipText = await navigator.clipboard.readText()
|
||||
} catch {
|
||||
pasteJsonText.value = ''
|
||||
showPasteDialog.value = true
|
||||
return
|
||||
}
|
||||
if (!clipText || !clipText.trim()) { ElMessage.warning('剪贴板为空'); return }
|
||||
applyPastedJson(clipText)
|
||||
}
|
||||
|
||||
const doPasteFromText = () => {
|
||||
const text = pasteJsonText.value
|
||||
if (!text || !text.trim()) { ElMessage.warning('请输入JSON内容'); return }
|
||||
showPasteDialog.value = false
|
||||
applyPastedJson(text)
|
||||
}
|
||||
|
||||
const applyPastedJson = (jsonText) => {
|
||||
let pastedArgs
|
||||
try {
|
||||
pastedArgs = JSON.parse(jsonText)
|
||||
if (!Array.isArray(pastedArgs)) { ElMessage.error('JSON格式错误,需要数组格式'); return }
|
||||
} catch {
|
||||
ElMessage.error('JSON解析失败,请检查格式')
|
||||
return
|
||||
}
|
||||
clearArgsSelection()
|
||||
const matchedIds = []
|
||||
for (const arg of pastedArgs) {
|
||||
let spec = null
|
||||
if (arg.key) spec = planSpecList.value.find(s => getArgKey(s) === arg.key)
|
||||
if (!spec && arg.name) spec = planSpecList.value.find(s => s.name === arg.name)
|
||||
if (!spec && arg.arg_id) spec = planSpecList.value.find(s => s.id === arg.arg_id)
|
||||
if (!spec) continue
|
||||
matchedIds.push(spec.id)
|
||||
if (spec.type === 'select') {
|
||||
if (arg.attr_id) {
|
||||
const attrObj = spec.attrs?.find(a => a.id === arg.attr_id)
|
||||
if (attrObj) selectedArgs[spec.id] = attrObj.id
|
||||
else if (arg.value) { const byVal = spec.attrs?.find(a => a.value === arg.value || a.name === arg.value); if (byVal) selectedArgs[spec.id] = byVal.id }
|
||||
} else if (arg.value) { const byVal = spec.attrs?.find(a => a.value === arg.value || a.name === arg.value); if (byVal) selectedArgs[spec.id] = byVal.id }
|
||||
} else if (spec.type === 'number') {
|
||||
const numVal = Number(arg.number !== undefined ? arg.number : arg.value)
|
||||
selectedArgs[spec.id] = numVal
|
||||
if (hasUnit(spec)) {
|
||||
const unit = getParamDefaultUnit(spec)
|
||||
displayUnits[spec.id] = unit
|
||||
displayValues[spec.id] = fromBaseUnit(numVal, unit, getArgKey(spec))
|
||||
} else {
|
||||
displayValues[spec.id] = numVal
|
||||
}
|
||||
} else {
|
||||
selectedArgs[spec.id] = arg.value || ''
|
||||
}
|
||||
}
|
||||
selectedArgIds.value = matchedIds
|
||||
updateArgsJson()
|
||||
updateExtraArgIds()
|
||||
ElMessage.success(`已从JSON导入 ${matchedIds.length} 个参数配置`)
|
||||
}
|
||||
|
||||
const getSelectedValueDisplay = (spec) => {
|
||||
const selectedValue = selectedArgs[spec.id]
|
||||
if (selectedValue === undefined || selectedValue === '') return null
|
||||
|
||||
if (spec.type === 'select') {
|
||||
const attrObj = spec.attrs?.find(a => a.id === selectedValue)
|
||||
return attrObj ? attrObj.name : null
|
||||
}
|
||||
if (hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id] || getParamDefaultUnit(spec)
|
||||
const displayVal = fromBaseUnit(Number(selectedValue), unit, argKey)
|
||||
return formatValueWithUnit(displayVal, unit)
|
||||
}
|
||||
return String(selectedValue)
|
||||
}
|
||||
|
||||
@@ -1928,8 +2135,15 @@ const initSelectedArgsFromJson = (argsJson, extraArgIds = []) => {
|
||||
}
|
||||
}
|
||||
} else if (spec.type === 'number') {
|
||||
// number 类型:优先使用 number 字段,兼容 value 字段
|
||||
selectedArgs[spec.id] = Number(arg.number !== undefined ? arg.number : arg.value)
|
||||
const numVal = Number(arg.number !== undefined ? arg.number : arg.value)
|
||||
selectedArgs[spec.id] = numVal
|
||||
if (hasUnit(spec)) {
|
||||
const unit = getParamDefaultUnit(spec)
|
||||
displayUnits[spec.id] = unit
|
||||
displayValues[spec.id] = fromBaseUnit(numVal, unit, getArgKey(spec))
|
||||
} else {
|
||||
displayValues[spec.id] = numVal
|
||||
}
|
||||
} else {
|
||||
selectedArgs[spec.id] = arg.value
|
||||
}
|
||||
@@ -2033,6 +2247,11 @@ const handleAddPlan = async () => {
|
||||
|
||||
// 默认选择所有参数
|
||||
selectedArgIds.value = planSpecList.value.map(spec => spec.id)
|
||||
for (const spec of planSpecList.value) {
|
||||
if (spec.type === 'number' && hasUnit(spec)) {
|
||||
displayUnits[spec.id] = getParamDefaultUnit(spec)
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(planForm, {
|
||||
plan_id: undefined,
|
||||
@@ -2046,7 +2265,8 @@ const handleAddPlan = async () => {
|
||||
enable_fixed_price: false,
|
||||
index: 0,
|
||||
disable: false,
|
||||
show_home: false
|
||||
show_home: false,
|
||||
can_update: false
|
||||
})
|
||||
|
||||
planFormDialogVisible.value = true
|
||||
@@ -2102,7 +2322,8 @@ const handleEditPlan = async (row) => {
|
||||
enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price), // 转为布尔值
|
||||
index: data.index || 0,
|
||||
disable: data.disable || false,
|
||||
show_home: !!(data.showHome || data.show_home) // 转为布尔值
|
||||
show_home: !!(data.showHome || data.show_home),
|
||||
can_update: !!(data.canUpdate || data.can_update)
|
||||
})
|
||||
|
||||
// 从已有的args初始化选择状态(包括额外参数)
|
||||
@@ -2202,7 +2423,8 @@ const submitPlanForm = () => {
|
||||
inventory: Number(planForm.inventory) || 0,
|
||||
fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0, // 元转分
|
||||
index: Number(planForm.index) || 0,
|
||||
show_home: planForm.show_home === true
|
||||
show_home: planForm.show_home === true,
|
||||
can_update: planForm.can_update === true
|
||||
}
|
||||
|
||||
// 只有创建时才传递 enable_fixed_price
|
||||
@@ -2674,5 +2896,8 @@ const submitPlanForm = () => {
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
|
||||
@@ -94,10 +94,18 @@
|
||||
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
|
||||
<el-form :model="editForm" label-width="110px">
|
||||
<el-form-item label="备注"><el-input v-model="editForm.note" /></el-form-item>
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="editForm.renew_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="续费价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editForm.renew_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editForm.base_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)"><el-input-number v-model="editForm.base_price" :min="0" :precision="2" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="到期时间"><el-date-picker v-model="editForm.expire_time" type="datetime" placeholder="选择到期时间" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="归属项">
|
||||
<div style="width:100%">
|
||||
@@ -146,7 +154,7 @@ import dayjs from 'dayjs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const goodsId = computed(() => parseInt(route.query.id) || 0)
|
||||
const goodsId = computed(() => parseInt(route.params.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
@@ -404,4 +412,7 @@ watch(goodsId, (newId, oldId) => {
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -6,12 +6,16 @@
|
||||
<div class="filter-content">
|
||||
<el-form :inline="true" class="search-form">
|
||||
<el-form-item label="用户ID">
|
||||
<el-input v-model="query.user_id" placeholder="筛选用户" clearable style="width:120px"
|
||||
@keyup.enter="handleSearch" @clear="handleSearch" />
|
||||
<el-input :model-value="filterUserName || (query.user_id ? `${query.user_id}` : '')"
|
||||
readonly placeholder="筛选用户" clearable style="width:140px;cursor:pointer"
|
||||
@click="showFilterUserSelector = true"
|
||||
@clear="query.user_id = ''; filterUserName = ''; handleSearch()" />
|
||||
</el-form-item>
|
||||
<el-form-item label="商品ID">
|
||||
<el-input v-model="query.good_id" placeholder="筛选商品" clearable style="width:120px"
|
||||
@keyup.enter="handleSearch" @clear="handleSearch" />
|
||||
<el-input :model-value="filterGoodName || (query.good_id ? `${query.good_id}` : '')"
|
||||
readonly placeholder="筛选商品" clearable style="width:140px;cursor:pointer"
|
||||
@click="showFilterProductSelector = true"
|
||||
@clear="query.good_id = ''; filterGoodName = ''; handleSearch()" />
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="query.key" placeholder="搜索关键词" clearable style="width:180px"
|
||||
@@ -23,7 +27,7 @@
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>查询
|
||||
</el-button>
|
||||
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; handleSearch()">重置</el-button>
|
||||
<el-button @click="query.user_id = ''; query.good_id = ''; query.key = ''; filterUserName = ''; filterGoodName = ''; handleSearch()">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="action-bar">
|
||||
@@ -69,8 +73,8 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.good?.name || '-' }}</template>
|
||||
<el-table-column label="商品" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.good?.name || '-' }} <span style="color:#909399;font-size:12px">(ID:{{ row.good?.id || row.goodId || '-' }})</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="标签" width="100">
|
||||
<template #default="{ row }">
|
||||
@@ -97,11 +101,13 @@
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right">
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="action-buttons">
|
||||
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button link type="warning" size="small" @click="openRemindList(row)">提醒记录</el-button>
|
||||
<el-button link type="success" size="small" @click="handleSendRemind(row)">发送提醒</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -189,11 +195,17 @@
|
||||
<div v-else class="form-hint">普通商品,点击将商品ID赋值为归属项</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="createForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="续费价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="createForm._renewYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)">
|
||||
<el-input-number v-model="createForm._baseYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="基础价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="createForm._baseYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="createForm.expire_time" type="datetime"
|
||||
@@ -226,11 +238,17 @@
|
||||
<div v-if="editForm._goodTag === '云服务器'" class="form-hint">云服务器商品,点击选择用户虚拟机作为归属项</div>
|
||||
<div v-else class="form-hint">普通商品,点击将商品ID赋值为归属项</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="editForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="续费价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editForm._renewYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)">
|
||||
<el-input-number v-model="editForm._baseYuan" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="基础价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editForm._baseYuan" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="editForm.expire_time" type="datetime"
|
||||
@@ -250,9 +268,9 @@
|
||||
|
||||
<ProductSelector v-model="showProductSelector" @confirm="handleProductSelected" />
|
||||
<UserSelector v-model:visible="showUserSelector" @select="u => { createForm.user_id = u.user_id; createForm._userName = u.user_name }" />
|
||||
<UserSelector v-model:visible="showVmUserSelector" @select="handleVmUserSelect" />
|
||||
<OrderSelector v-model="showOrderSelector" @confirm="o => { createForm.order_id = o.id; createForm._orderName = o.name }" />
|
||||
<PlanSelector v-model="showPlanSelector" :good-id="createForm.good_id" @confirm="handlePlanSelectedForCreate" />
|
||||
|
||||
<!-- 用户虚拟机选择弹窗 -->
|
||||
<el-dialog v-model="showVmListDialog" title="选择用户虚拟机" width="800px" append-to-body destroy-on-close>
|
||||
<div style="margin-bottom:12px">
|
||||
@@ -261,8 +279,24 @@
|
||||
<el-input v-model="vmListQuery.key" placeholder="搜索" clearable style="width:180px"
|
||||
@keyup.enter="loadVmListForItem" @clear="loadVmListForItem" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户ID">
|
||||
<div class="selector-row">
|
||||
<el-input :model-value="vmListQuery._userName || (vmListQuery.user_id ? `用户 #${vmListQuery.user_id}` : '')"
|
||||
readonly placeholder="按用户筛选" style="flex:1" @click="showVmUserSelector = true" />
|
||||
<el-button v-if="vmListQuery.user_id" @click="vmListQuery.user_id = ''; vmListQuery._userName = ''; loadVmListForItem()" style="margin-left:4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="vmListQuery.status" placeholder="筛选状态" clearable style="width:120px" @change="loadVmListForItem">
|
||||
<el-option label="运行中" value="running" />
|
||||
<el-option label="已停止" value="stopped" />
|
||||
<el-option label="未知" value="unknown" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="loadVmListForItem">搜索</el-button>
|
||||
<el-button @click="loadVmListForItem" :icon="Refresh">刷新</el-button>
|
||||
<el-button @click="resetVmListFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
@@ -270,17 +304,36 @@
|
||||
@current-change="r => vmListSelected = r" :height="350" style="width:100%"
|
||||
:header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600 }">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="用户" min-width="120">
|
||||
<template #default="{ row }">{{ row.user?.UserName || row.user?.username || '-' }}</template>
|
||||
<el-table-column label="虚拟机名称" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.name || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="商品" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.good?.name || '-' }}</template>
|
||||
<el-table-column label="配置" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.vcpu && row.memory">
|
||||
{{ row.vcpu }}核 / {{ formatMemory(row.memory) }}
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="归属项ID" width="100">
|
||||
<template #default="{ row }">{{ row.itemId || row.item_id || '-' }}</template>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="到期时间" width="170">
|
||||
<template #default="{ row }">{{ formatExpireTime(row.expireTime || row.expire_time) }}</template>
|
||||
<el-table-column label="绑定状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.bound ? 'success' : 'info'" size="small">
|
||||
{{ row.bound ? '已绑定' : '未绑定' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="IP地址" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.ips || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="用户ID" width="80">
|
||||
<template #default="{ row }">{{ row.user_id || '-' }}</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="暂无虚拟机数据" :image-size="80" />
|
||||
@@ -316,9 +369,21 @@
|
||||
</el-radio-group>
|
||||
</template>
|
||||
<template v-else-if="spec.type === 'number'">
|
||||
<div style="display:flex;align-items:center;gap:12px">
|
||||
<el-input-number v-model="argsValues[spec.id]" :min="spec.min || 0" :max="spec.max || 9999" :step="spec.step || 1" :step-strictly="true" @change="buildArgsJson" style="width:200px" />
|
||||
<span class="form-hint" style="margin-top:0">范围: {{ spec.min || 0 }} ~ {{ spec.max || 9999 }},步长: {{ spec.step || 1 }}</span>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<el-input-number
|
||||
v-model="argsDisplayValues[spec.id]"
|
||||
:min="hasUnit(spec) ? fromBaseUnit(spec.min ?? 0, argsDisplayUnits[spec.id], getArgKey(spec)) : (spec.min ?? 0)"
|
||||
:max="hasUnit(spec) ? fromBaseUnit(spec.max ?? 0, argsDisplayUnits[spec.id], getArgKey(spec)) : (spec.max ?? 0)"
|
||||
:step="hasUnit(spec) ? (fromBaseUnit(spec.step ?? 1, argsDisplayUnits[spec.id], getArgKey(spec)) || 1) : (spec.step ?? 1)"
|
||||
:step-strictly="true"
|
||||
@change="onArgsNumberChange(spec)"
|
||||
style="width:200px"
|
||||
/>
|
||||
<el-select v-if="hasUnit(spec)" :model-value="argsDisplayUnits[spec.id]" size="default" style="width:90px" @change="(newUnit) => onArgsUnitChange(spec, newUnit)">
|
||||
<el-option v-for="u in getParamUnits(spec)" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
<span class="form-hint" style="margin-top:0">范围: {{ spec.min ?? 0 }} ~ {{ spec.max ?? 0 }}
|
||||
<template v-if="hasUnit(spec)"> {{ getBaseUnit(getArgKey(spec)) }}</template>,步长: {{ spec.step ?? 1 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -337,6 +402,40 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 到期提醒记录弹窗 -->
|
||||
<el-dialog v-model="remindVisible" title="到期提醒记录" width="700px" destroy-on-close append-to-body>
|
||||
<div style="margin-bottom:12px;display:flex;justify-content:space-between;align-items:center">
|
||||
<span style="font-size:13px;color:#909399">用户商品 ID: {{ remindGoodsId }}</span>
|
||||
<el-button type="primary" size="small" :icon="Refresh" @click="loadRemindList">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="remindList" v-loading="remindLoading" stripe size="small" :max-height="400">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="user_goods_id" label="用户商品ID" width="110" />
|
||||
<el-table-column prop="user_id" label="用户ID" width="80" />
|
||||
<el-table-column label="提醒类型" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.type === 'manual' ? 'warning' : 'info'">{{ row.type === 'manual' ? '手动' : '自动' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发送状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.status === 'success' ? 'success' : row.status === 'failed' ? 'danger' : 'info'">{{ row.status || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发送时间" min-width="160">
|
||||
<template #default="{ row }">{{ row.created_at ? dayjs(row.created_at).format('YYYY-MM-DD HH:mm:ss') : (row.send_time || '-') }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" label="内容" min-width="180" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:12px" v-if="remindTotal > remindQuery.count">
|
||||
<el-pagination v-model:current-page="remindQuery.page" :page-size="remindQuery.count" :total="remindTotal" layout="total, prev, pager, next" small background @current-change="loadRemindList" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 筛选区选择器 -->
|
||||
<UserSelector v-model:visible="showFilterUserSelector" @select="u => { query.user_id = u.user_id; filterUserName = u.user_name; handleSearch() }" />
|
||||
<ProductSelector v-model="showFilterProductSelector" @confirm="p => { query.good_id = p.id; filterGoodName = p.name; handleSearch() }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -345,10 +444,11 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList } from '@/api/admin/userVm'
|
||||
import { getUserGoodsList, createUserGoods, updateUserGoods, deleteUserGoods, getUserVmList, getExpireRemindList, sendExpireRemind } from '@/api/admin/userVm'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { formatToApiTime } from '@/utils/tool'
|
||||
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product'
|
||||
import { hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit, toBaseUnit, fromBaseUnit } from '@/utils/dynamicUnit'
|
||||
import ProductSelector from '@/components/admin/ProductSelector.vue'
|
||||
import UserSelector from '@/components/UserSelector/index.vue'
|
||||
import OrderSelector from '@/components/admin/OrderSelector.vue'
|
||||
@@ -360,6 +460,10 @@ const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '' })
|
||||
const filterUserName = ref('')
|
||||
const filterGoodName = ref('')
|
||||
const showFilterUserSelector = ref(false)
|
||||
const showFilterProductSelector = ref(false)
|
||||
|
||||
const formatTime = (t) => t ? dayjs(t).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||||
|
||||
@@ -370,6 +474,31 @@ const formatExpireTime = (t) => {
|
||||
return d.format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatMemory = (kb) => {
|
||||
if (!kb) return '-'
|
||||
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
|
||||
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
|
||||
return kb + ' KB'
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
switch (status) {
|
||||
case 'running': return 'success'
|
||||
case 'stop': return 'danger'
|
||||
case 'stopped': return 'danger'
|
||||
default: return 'info'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
switch (status) {
|
||||
case 'running': return '运行中'
|
||||
case 'stop': return '已停止'
|
||||
case 'stopped': return '已停止'
|
||||
default: return status || '未知'
|
||||
}
|
||||
}
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -392,6 +521,8 @@ const handleSearch = () => { query.page = 1; loadList() }
|
||||
const argsSpecList = ref([])
|
||||
const argsSpecLoading = ref(false)
|
||||
const argsValues = reactive({})
|
||||
const argsDisplayValues = reactive({})
|
||||
const argsDisplayUnits = reactive({})
|
||||
const showArgsDialog = ref(false)
|
||||
|
||||
const argsCount = computed(() => {
|
||||
@@ -419,13 +550,38 @@ const loadArgsSpec = async (goodId) => {
|
||||
argsSpecList.value = res.data.data || []
|
||||
for (const spec of argsSpecList.value) {
|
||||
if (spec.type === 'number' && argsValues[spec.id] === undefined) {
|
||||
argsValues[spec.id] = spec.min || 0
|
||||
argsValues[spec.id] = spec.min ?? 0
|
||||
if (hasUnit(spec)) {
|
||||
argsDisplayUnits[spec.id] = getParamDefaultUnit(spec)
|
||||
argsDisplayValues[spec.id] = fromBaseUnit(spec.min ?? 0, argsDisplayUnits[spec.id], getArgKey(spec))
|
||||
} else {
|
||||
argsDisplayValues[spec.id] = spec.min ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { argsSpecList.value = [] } finally { argsSpecLoading.value = false }
|
||||
}
|
||||
|
||||
const onArgsNumberChange = (spec) => {
|
||||
if (hasUnit(spec)) {
|
||||
argsValues[spec.id] = Math.round(toBaseUnit(argsDisplayValues[spec.id] || 0, argsDisplayUnits[spec.id], getArgKey(spec)))
|
||||
} else {
|
||||
argsValues[spec.id] = argsDisplayValues[spec.id]
|
||||
}
|
||||
buildArgsJson()
|
||||
}
|
||||
const onArgsUnitChange = (spec, newUnit) => {
|
||||
const argKey = getArgKey(spec)
|
||||
const oldUnit = argsDisplayUnits[spec.id]
|
||||
const oldDisplay = argsDisplayValues[spec.id] || 0
|
||||
const baseValue = oldUnit ? toBaseUnit(oldDisplay, oldUnit, argKey) : oldDisplay
|
||||
argsDisplayUnits[spec.id] = newUnit
|
||||
argsDisplayValues[spec.id] = fromBaseUnit(baseValue, newUnit, argKey)
|
||||
argsValues[spec.id] = Math.round(baseValue)
|
||||
buildArgsJson()
|
||||
}
|
||||
|
||||
const buildArgsJson = () => {
|
||||
const argsArray = []
|
||||
for (const spec of argsSpecList.value) {
|
||||
@@ -515,7 +671,8 @@ const vmListForItem = ref([])
|
||||
const vmListLoading = ref(false)
|
||||
const vmListSelected = ref(null)
|
||||
const vmListTotal = ref(0)
|
||||
const vmListQuery = reactive({ page: 1, count: 10, key: '' })
|
||||
const vmListQuery = reactive({ page: 1, count: 10, key: '', user_id: '', status: '', _userName: '' })
|
||||
const showVmUserSelector = ref(false)
|
||||
|
||||
const handleItemSelect = () => {
|
||||
if (createForm._goodTag === '云服务器') {
|
||||
@@ -534,6 +691,14 @@ const loadVmListForItem = async () => {
|
||||
try {
|
||||
const params = { page: vmListQuery.page, count: vmListQuery.count }
|
||||
if (vmListQuery.key) params.key = vmListQuery.key
|
||||
if (vmListQuery.user_id) params.user_id = parseInt(vmListQuery.user_id) || undefined
|
||||
if (vmListQuery.status) params.status = vmListQuery.status
|
||||
// 传递good_id参数
|
||||
if (vmItemTarget.value === 'edit' && editForm._goodId) {
|
||||
params.good_id = editForm._goodId
|
||||
} else if (vmItemTarget.value === 'create' && createForm.good_id) {
|
||||
params.good_id = createForm.good_id
|
||||
}
|
||||
const res = await getUserVmList(params)
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
@@ -555,7 +720,23 @@ const confirmVmForItem = () => {
|
||||
}
|
||||
showVmListDialog.value = false
|
||||
vmListSelected.value = null
|
||||
ElMessage.success('已选择虚拟机')
|
||||
ElMessage.success('虚拟机已选择')
|
||||
}
|
||||
|
||||
const resetVmListFilters = () => {
|
||||
vmListQuery.key = ''
|
||||
vmListQuery.user_id = ''
|
||||
vmListQuery.status = ''
|
||||
vmListQuery._userName = ''
|
||||
vmListQuery.page = 1
|
||||
loadVmListForItem()
|
||||
}
|
||||
|
||||
const handleVmUserSelect = (user) => {
|
||||
vmListQuery.user_id = user.user_id
|
||||
vmListQuery._userName = user.user_name
|
||||
showVmUserSelector.value = false
|
||||
loadVmListForItem()
|
||||
}
|
||||
|
||||
const submitCreate = async () => {
|
||||
@@ -638,7 +819,14 @@ const submitEdit = async () => {
|
||||
}
|
||||
|
||||
// ---- 详情 / 删除 ----
|
||||
const handleDetail = (row) => { router.push({ name: 'UserGoodsDetail', params: { id: row.id } }) }
|
||||
const handleDetail = (row) => {
|
||||
const tag = (row.tag || row.good?.tag || '').toLowerCase()
|
||||
if (tag === '云服务器') {
|
||||
router.push({ path: '/user-goods/vm-detail', query: { id: row.id } })
|
||||
} else {
|
||||
router.push({ name: 'UserGoodsDetail', params: { id: row.id } })
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (row) => {
|
||||
ElMessageBox.confirm(`确定删除该用户商品吗?`, '删除确认', { type: 'warning' })
|
||||
@@ -651,6 +839,45 @@ const handleDelete = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 到期提醒 ----
|
||||
const remindVisible = ref(false)
|
||||
const remindLoading = ref(false)
|
||||
const remindList = ref([])
|
||||
const remindTotal = ref(0)
|
||||
const remindGoodsId = ref(0)
|
||||
const remindQuery = reactive({ page: 1, count: 10 })
|
||||
|
||||
const openRemindList = (row) => {
|
||||
remindGoodsId.value = row.id
|
||||
remindQuery.page = 1
|
||||
remindVisible.value = true
|
||||
loadRemindList()
|
||||
}
|
||||
|
||||
const loadRemindList = async () => {
|
||||
remindLoading.value = true
|
||||
try {
|
||||
const res = await getExpireRemindList({ user_goods_id: remindGoodsId.value, page: remindQuery.page, count: remindQuery.count })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const d = res.data.data
|
||||
remindList.value = d.data || d.list || (Array.isArray(d) ? d : [])
|
||||
remindTotal.value = d.all_count ?? d.meta?.count ?? d.total ?? remindList.value.length
|
||||
} else { remindList.value = []; remindTotal.value = 0 }
|
||||
} catch { remindList.value = []; remindTotal.value = 0 }
|
||||
finally { remindLoading.value = false }
|
||||
}
|
||||
|
||||
const handleSendRemind = (row) => {
|
||||
ElMessageBox.confirm(`确定手动发送到期提醒给该用户商品(ID: ${row.id})吗?`, '发送确认', { type: 'warning', confirmButtonText: '确认发送' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await sendExpireRemind({ user_goods_id: row.id, user_id: row.userId || row.user_id })
|
||||
if (res?.data?.code === 200) ElMessage.success('发送成功')
|
||||
else ElMessage.error(extractApiError(res?.data, '发送失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '发送失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(loadList)
|
||||
</script>
|
||||
|
||||
@@ -708,28 +935,6 @@ onMounted(loadList)
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
border: none;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: #f8f9fa !important;
|
||||
border-bottom: 2px solid #e1e8ed;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
:deep(.el-table tr:hover > td) {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
.user-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -746,11 +951,6 @@ onMounted(loadList)
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.price-text {
|
||||
color: #e74c3c;
|
||||
font-weight: 600;
|
||||
@@ -882,4 +1082,7 @@ onMounted(loadList)
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -29,10 +29,18 @@
|
||||
<el-tag :type="getArgTypeTag(row.type)">{{ getArgTypeText(row.type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数值配置" min-width="180">
|
||||
<el-table-column label="数值配置" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.type === 'number'">
|
||||
<span class="number-config">步进: {{ row.step || '-' }} | 范围: {{ row.min ?? '-' }} ~ {{ row.max ?? '-' }}</span>
|
||||
<div class="number-config">
|
||||
<div>步进: {{ row.step || '-' }} | 范围: {{ row.min ?? '-' }} ~ {{ row.max ?? '-' }}
|
||||
<template v-if="hasUnit(row)"> ({{ getBaseUnit(getArgKey(row)) }})</template>
|
||||
</div>
|
||||
<div v-if="hasUnit(row)" class="unit-info">
|
||||
<el-tag size="small" type="success">{{ getArgKey(row) }}</el-tag>
|
||||
<el-tag size="small" type="warning">{{ getParamDefaultUnit(row) }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
@@ -58,49 +66,88 @@
|
||||
:title="paramFormType === 'add' ? '新增商品参数' : '编辑商品参数'"
|
||||
width="600px"
|
||||
append-to-body
|
||||
class="tk-dialog"
|
||||
>
|
||||
<el-form ref="paramFormRef" :model="paramForm" :rules="paramRules" label-width="120px">
|
||||
<el-form-item label="参数名称" prop="arg_name">
|
||||
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参数类型" prop="arg_type">
|
||||
<el-radio-group v-model="paramForm.arg_type">
|
||||
<el-radio label="string">字符串</el-radio>
|
||||
<el-radio label="number">数字</el-radio>
|
||||
<el-radio label="select">选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否必选" prop="must">
|
||||
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">权限控制</el-divider>
|
||||
<el-form-item label="允许单独购买">
|
||||
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户组优惠">
|
||||
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户优惠">
|
||||
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠(代金券与优惠码)</div>
|
||||
</el-form-item>
|
||||
<el-form ref="paramFormRef" :model="paramForm" :rules="paramRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="参数名称" prop="arg_name">
|
||||
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="参数类型" prop="arg_type">
|
||||
<el-radio-group v-model="paramForm.arg_type">
|
||||
<el-radio label="string">字符串</el-radio>
|
||||
<el-radio label="number">数字</el-radio>
|
||||
<el-radio label="select">选择</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否必选" prop="must">
|
||||
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">权限控制</div>
|
||||
<el-form-item label="允许单独购买">
|
||||
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户组优惠">
|
||||
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户优惠">
|
||||
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠(代金券与优惠码)</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template v-if="paramForm.arg_type === 'number'">
|
||||
<el-divider content-position="left">数值参数配置</el-divider>
|
||||
<el-form-item label="步进值" prop="arg_step">
|
||||
<el-input-number v-model="paramForm.arg_step" :min="1" placeholder="步进值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最小值" prop="arg_min">
|
||||
<el-input-number v-model="paramForm.arg_min" placeholder="最小值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最大值" prop="arg_max">
|
||||
<el-input-number v-model="paramForm.arg_max" placeholder="最大值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">数值参数配置</div>
|
||||
<el-form-item label="步进值" prop="arg_step">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="paramForm.step_display" :min="1" placeholder="步进值" style="flex: 1" />
|
||||
<el-select v-if="paramForm.enable_unit && paramForm.arg_key" :model-value="paramForm.step_unit" style="width: 100px" @change="(v) => onFieldUnitChange('step', v)">
|
||||
<el-option v-for="u in formUnits" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最小值" prop="arg_min">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="paramForm.min_display" placeholder="最小值" style="flex: 1" />
|
||||
<el-select v-if="paramForm.enable_unit && paramForm.arg_key" :model-value="paramForm.min_unit" style="width: 100px" @change="(v) => onFieldUnitChange('min', v)">
|
||||
<el-option v-for="u in formUnits" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="最大值" prop="arg_max">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="paramForm.max_display" placeholder="最大值" style="flex: 1" />
|
||||
<el-select v-if="paramForm.enable_unit && paramForm.arg_key" :model-value="paramForm.max_unit" style="width: 100px" @change="(v) => onFieldUnitChange('max', v)">
|
||||
<el-option v-for="u in formUnits" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="paramForm.enable_unit && paramForm.arg_key" class="form-tip">
|
||||
实际提交({{ getBaseUnit(paramForm.arg_key) }}): 步进={{ calcBase(paramForm.step_display, paramForm.step_unit) }} | 最小={{ calcBase(paramForm.min_display, paramForm.min_unit) }} | 最大={{ calcBase(paramForm.max_display, paramForm.max_unit) }}
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用动态单位">
|
||||
<el-switch v-model="paramForm.enable_unit" active-text="启用" inactive-text="禁用" @change="onEnableUnitChange" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">启用后可在输入框右侧切换单位,提交时自动转为基础单位</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="paramForm.enable_unit" label="参数键" prop="arg_key">
|
||||
<el-select v-model="paramForm.arg_key" placeholder="选择参数键" style="width: 100%" @change="onArgKeyChange">
|
||||
<el-option v-for="opt in argKeyOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">用于识别参数类型,基础单位:
|
||||
<strong v-if="paramForm.arg_key">{{ getBaseUnit(paramForm.arg_key) }}</strong>
|
||||
<span v-else>请先选择</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="paramFormDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitParamForm">确定</el-button>
|
||||
</div>
|
||||
@@ -110,7 +157,12 @@
|
||||
<!-- 参数值管理对话框 -->
|
||||
<el-dialog v-model="paramValuesDialogVisible" title="参数值管理" width="800px" append-to-body>
|
||||
<div class="values-header">
|
||||
<span>参数:{{ currentParam?.name }}</span>
|
||||
<div>
|
||||
<span>参数:{{ currentParam?.name }}</span>
|
||||
<el-tag v-if="hasUnit(currentParam)" size="small" type="success" style="margin-left: 8px">
|
||||
{{ getArgKey(currentParam) }} · 基础单位: {{ getBaseUnit(getArgKey(currentParam)) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleAddParamValue">
|
||||
<el-icon><Plus /></el-icon>添加参数值
|
||||
</el-button>
|
||||
@@ -123,11 +175,13 @@
|
||||
>
|
||||
<el-table-column prop="id" label="值ID" width="80" />
|
||||
<el-table-column prop="name" label="值名称" min-width="120" />
|
||||
<el-table-column label="值/范围" min-width="150">
|
||||
<el-table-column label="值/范围" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<template v-if="currentParam?.type === 'select'">{{ row.value || '-' }}</template>
|
||||
<template v-else-if="currentParam?.type === 'number'">
|
||||
<el-tag size="small" type="info">{{ getRangeTypeText(row.rangeType) }} {{ row.range }}</el-tag>
|
||||
<el-tag size="small" type="info">
|
||||
{{ getRangeTypeText(row.rangeType) }} {{ formatPhaseDisplay(row.phase || row.range) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template v-else>{{ row.value || '-' }}</template>
|
||||
</template>
|
||||
@@ -156,37 +210,65 @@
|
||||
:title="paramValueFormType === 'add' ? '添加参数值' : '编辑参数值'"
|
||||
width="550px"
|
||||
append-to-body
|
||||
class="tk-dialog"
|
||||
>
|
||||
<el-form ref="paramValueFormRef" :model="paramValueForm" :rules="paramValueRules" label-width="120px">
|
||||
<el-form-item label="值名称" prop="attr_name">
|
||||
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value">
|
||||
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" />
|
||||
</el-form-item>
|
||||
<el-form ref="paramValueFormRef" :model="paramValueForm" :rules="paramValueRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">参数值信息</div>
|
||||
<el-form-item label="值名称" prop="attr_name">
|
||||
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value">
|
||||
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="paramValueForm.index" :min="0" placeholder="排序索引" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="attr_price">
|
||||
<el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<template v-if="currentParam?.type === 'number'">
|
||||
<el-divider content-position="left">数值范围配置(phase)</el-divider>
|
||||
<el-form-item label="范围类型" prop="range_type">
|
||||
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
|
||||
<el-option label="小于等于 (before)" value="before" />
|
||||
<el-option label="大于等于 (after)" value="after" />
|
||||
<el-option label="等于 (equal)" value="equal" />
|
||||
</el-select>
|
||||
<div class="form-tip">before: 数值 ≤ phase 时匹配 | after: 数值 ≥ phase 时匹配</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值" prop="attr_range">
|
||||
<el-input-number v-model="paramValueForm.attr_range" :min="0" placeholder="范围阈值" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">数值范围配置</div>
|
||||
<el-form-item label="范围类型" prop="range_type">
|
||||
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
|
||||
<el-option label="小于 (before)" value="before" />
|
||||
<el-option label="大于 (after)" value="after" />
|
||||
<el-option label="等于 (equal)" value="equal" />
|
||||
</el-select>
|
||||
<div class="form-tip">before: 数值 < phase 时匹配 | after: 数值 > phase 时匹配</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值" prop="attr_range">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number
|
||||
v-model="paramValueForm.attr_range_display"
|
||||
:min="valueDisplayMin"
|
||||
:max="valueDisplayMax"
|
||||
:step="valueDisplayStep"
|
||||
:step-strictly="true"
|
||||
placeholder="范围阈值"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<el-select
|
||||
v-if="hasUnit(currentParam)"
|
||||
v-model="paramValueForm.display_unit"
|
||||
style="width: 100px"
|
||||
@change="onValueUnitChange"
|
||||
>
|
||||
<el-option v-for="u in currentParamUnits" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="hasUnit(currentParam)" class="form-tip">
|
||||
实际存储值: {{ computedBaseValue }} {{ getBaseUnit(getArgKey(currentParam)) }}
|
||||
(范围: {{ currentParam?.min ?? 0 }} ~ {{ currentParam?.max ?? '-' }} {{ getBaseUnit(getArgKey(currentParam)) }},步长: {{ currentParam?.step || 1 }})
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="paramValueForm.index" :min="0" placeholder="排序索引" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格" prop="attr_price">
|
||||
<el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="paramValueFormDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitParamValueForm">确定</el-button>
|
||||
</div>
|
||||
@@ -195,7 +277,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, nextTick } from 'vue'
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import {
|
||||
@@ -208,6 +290,11 @@ import {
|
||||
updateProductParameterValue,
|
||||
deleteProductParameterValue
|
||||
} from '@/api/admin/product'
|
||||
import {
|
||||
getAvailableUnits, getArgKeyOptions, hasUnit, getArgKey,
|
||||
getBaseUnit, getDefaultDisplayUnit, getParamDefaultUnit, getParamUnits,
|
||||
toBaseUnit, fromBaseUnit, formatValueWithUnit
|
||||
} from '@/utils/dynamicUnit'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@@ -215,6 +302,8 @@ const props = defineProps({
|
||||
})
|
||||
const emit = defineEmits(['update:visible'])
|
||||
|
||||
const argKeyOptions = getArgKeyOptions()
|
||||
|
||||
const paramLoading = ref(false)
|
||||
const parameterList = ref([])
|
||||
|
||||
@@ -229,15 +318,70 @@ const paramForm = reactive({
|
||||
arg_step: 1,
|
||||
arg_min: 0,
|
||||
arg_max: 100,
|
||||
step_display: 1,
|
||||
min_display: 0,
|
||||
max_display: 100,
|
||||
step_unit: '',
|
||||
min_unit: '',
|
||||
max_unit: '',
|
||||
user_add: false,
|
||||
use_user_group_discount: false,
|
||||
use_user_discount: false
|
||||
use_user_discount: false,
|
||||
enable_unit: false,
|
||||
arg_key: ''
|
||||
})
|
||||
const paramRules = {
|
||||
arg_name: [{ required: true, message: '请输入参数名称', trigger: 'blur' }],
|
||||
arg_type: [{ required: true, message: '请选择参数类型', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const formUnits = computed(() => {
|
||||
if (!paramForm.arg_key) return []
|
||||
return getAvailableUnits(paramForm.arg_key)
|
||||
})
|
||||
|
||||
const calcBase = (displayVal, unit) => {
|
||||
if (!paramForm.enable_unit || !paramForm.arg_key || !unit) return displayVal
|
||||
return Math.round(toBaseUnit(displayVal || 0, unit, paramForm.arg_key))
|
||||
}
|
||||
|
||||
const onFieldUnitChange = (field, newUnit) => {
|
||||
const key = paramForm.arg_key
|
||||
const oldUnit = paramForm[`${field}_unit`]
|
||||
const oldDisplay = paramForm[`${field}_display`] || 0
|
||||
const baseVal = oldUnit ? toBaseUnit(oldDisplay, oldUnit, key) : oldDisplay
|
||||
paramForm[`${field}_unit`] = newUnit
|
||||
paramForm[`${field}_display`] = fromBaseUnit(baseVal, newUnit, key)
|
||||
}
|
||||
|
||||
const onArgKeyChange = () => {
|
||||
const key = paramForm.arg_key
|
||||
const defaultUnit = key ? getDefaultDisplayUnit(key) : ''
|
||||
paramForm.step_unit = defaultUnit
|
||||
paramForm.min_unit = defaultUnit
|
||||
paramForm.max_unit = defaultUnit
|
||||
if (key && defaultUnit) {
|
||||
paramForm.step_display = fromBaseUnit(paramForm.arg_step, defaultUnit, key)
|
||||
paramForm.min_display = fromBaseUnit(paramForm.arg_min, defaultUnit, key)
|
||||
paramForm.max_display = fromBaseUnit(paramForm.arg_max, defaultUnit, key)
|
||||
} else {
|
||||
paramForm.step_display = paramForm.arg_step
|
||||
paramForm.min_display = paramForm.arg_min
|
||||
paramForm.max_display = paramForm.arg_max
|
||||
}
|
||||
}
|
||||
|
||||
const onEnableUnitChange = (val) => {
|
||||
if (!val) {
|
||||
paramForm.step_display = paramForm.arg_step
|
||||
paramForm.min_display = paramForm.arg_min
|
||||
paramForm.max_display = paramForm.arg_max
|
||||
paramForm.step_unit = ''
|
||||
paramForm.min_unit = ''
|
||||
paramForm.max_unit = ''
|
||||
}
|
||||
}
|
||||
|
||||
const paramValuesDialogVisible = ref(false)
|
||||
const paramValuesLoading = ref(false)
|
||||
const paramValueList = ref([])
|
||||
@@ -253,12 +397,58 @@ const paramValueForm = reactive({
|
||||
attr_price: 0,
|
||||
index: 0,
|
||||
attr_range: 0,
|
||||
attr_range_display: 0,
|
||||
display_unit: '',
|
||||
range_type: 'equal'
|
||||
})
|
||||
const paramValueRules = {
|
||||
attr_name: [{ required: true, message: '请输入值名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const currentParamUnits = computed(() => {
|
||||
if (!hasUnit(currentParam.value)) return []
|
||||
return getParamUnits(currentParam.value)
|
||||
})
|
||||
|
||||
const valueDisplayMin = computed(() => {
|
||||
if (!hasUnit(currentParam.value)) return 0
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
const baseMin = currentParam.value?.min ?? 0
|
||||
return fromBaseUnit(baseMin, paramValueForm.display_unit, argKey)
|
||||
})
|
||||
const valueDisplayMax = computed(() => {
|
||||
if (!hasUnit(currentParam.value)) return 9999999
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
const baseMax = currentParam.value?.max
|
||||
if (baseMax === undefined || baseMax === null) return 9999999
|
||||
return fromBaseUnit(baseMax, paramValueForm.display_unit, argKey)
|
||||
})
|
||||
const valueDisplayStep = computed(() => {
|
||||
if (!hasUnit(currentParam.value)) return 1
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
const baseStep = currentParam.value?.step || 1
|
||||
return fromBaseUnit(baseStep, paramValueForm.display_unit, argKey)
|
||||
})
|
||||
|
||||
const computedBaseValue = computed(() => {
|
||||
if (!hasUnit(currentParam.value)) return paramValueForm.attr_range_display
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
return Math.round(toBaseUnit(paramValueForm.attr_range_display || 0, paramValueForm.display_unit, argKey))
|
||||
})
|
||||
|
||||
const formatPhaseDisplay = (phaseValue) => {
|
||||
if (phaseValue === undefined || phaseValue === null) return '-'
|
||||
if (!hasUnit(currentParam.value)) return String(phaseValue)
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
const displayUnit = getParamDefaultUnit(currentParam.value)
|
||||
const displayVal = fromBaseUnit(phaseValue, displayUnit, argKey)
|
||||
return formatValueWithUnit(displayVal, displayUnit)
|
||||
}
|
||||
|
||||
const onValueUnitChange = () => {
|
||||
// Recalculate: keep the base value, recalculate display value
|
||||
}
|
||||
|
||||
const getArgTypeText = (type) => {
|
||||
const typeMap = { 'string': '字符串', 'number': '数字', 'select': '选择' }
|
||||
return typeMap[type] || '未知'
|
||||
@@ -268,7 +458,7 @@ const getArgTypeTag = (type) => {
|
||||
return tagMap[type] || 'info'
|
||||
}
|
||||
const getRangeTypeText = (type) => {
|
||||
const typeMap = { 'after': '大于 >', 'before': '小于 <', 'equal': '等于 =' }
|
||||
const typeMap = { 'after': '>', 'before': '<', 'equal': '=' }
|
||||
return typeMap[type] || type || '-'
|
||||
}
|
||||
|
||||
@@ -290,14 +480,45 @@ const fetchParameterList = async () => {
|
||||
const handleAddParameter = () => {
|
||||
paramFormType.value = 'add'
|
||||
paramFormDialogVisible.value = true
|
||||
Object.assign(paramForm, { arg_id: undefined, arg_name: '', arg_type: 'string', must: false, arg_step: 1, arg_min: 0, arg_max: 100, user_add: false, use_user_group_discount: false, use_user_discount: false })
|
||||
Object.assign(paramForm, {
|
||||
arg_id: undefined, arg_name: '', arg_type: 'string', must: false,
|
||||
arg_step: 1, arg_min: 0, arg_max: 100,
|
||||
step_display: 1, min_display: 0, max_display: 100,
|
||||
step_unit: '', min_unit: '', max_unit: '',
|
||||
user_add: false, use_user_group_discount: false, use_user_discount: false,
|
||||
enable_unit: false, arg_key: ''
|
||||
})
|
||||
nextTick(() => { paramFormRef.value?.resetFields() })
|
||||
}
|
||||
|
||||
const handleEditParameter = (row) => {
|
||||
paramFormType.value = 'edit'
|
||||
paramFormDialogVisible.value = true
|
||||
Object.assign(paramForm, { arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false, arg_step: row.step || 1, arg_min: row.min || 0, arg_max: row.max || 100, user_add: row.userAdd ?? row.user_add ?? false, use_user_group_discount: row.useUserGroupDiscount ?? row.use_user_group_discount ?? false, use_user_discount: row.useUserDiscount ?? row.use_user_discount ?? false })
|
||||
const enableUnit = row.enableUnit || false
|
||||
const argKey = row.argKey || ''
|
||||
const baseStep = row.step || 1
|
||||
const baseMin = row.min || 0
|
||||
const baseMax = row.max || 100
|
||||
let defaultUnit = ''
|
||||
let stepDisplay = baseStep
|
||||
let minDisplay = baseMin
|
||||
let maxDisplay = baseMax
|
||||
if (enableUnit && argKey) {
|
||||
defaultUnit = getDefaultDisplayUnit(argKey)
|
||||
stepDisplay = fromBaseUnit(baseStep, defaultUnit, argKey)
|
||||
minDisplay = fromBaseUnit(baseMin, defaultUnit, argKey)
|
||||
maxDisplay = fromBaseUnit(baseMax, defaultUnit, argKey)
|
||||
}
|
||||
Object.assign(paramForm, {
|
||||
arg_id: row.id, arg_name: row.name, arg_type: row.type, must: row.must || false,
|
||||
arg_step: baseStep, arg_min: baseMin, arg_max: baseMax,
|
||||
step_display: stepDisplay, min_display: minDisplay, max_display: maxDisplay,
|
||||
step_unit: defaultUnit, min_unit: defaultUnit, max_unit: defaultUnit,
|
||||
user_add: row.userAdd ?? row.user_add ?? false,
|
||||
use_user_group_discount: row.useUserGroupDiscount ?? row.use_user_group_discount ?? false,
|
||||
use_user_discount: row.useUserDiscount ?? row.use_user_discount ?? false,
|
||||
enable_unit: enableUnit, arg_key: argKey
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteParameter = (row) => {
|
||||
@@ -315,11 +536,27 @@ const submitParamForm = () => {
|
||||
paramFormRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
try {
|
||||
const submitData = { good_id: Number(props.goodId), arg_name: paramForm.arg_name, arg_type: paramForm.arg_type, must: paramForm.must === true, user_add: paramForm.user_add === true, use_user_group_discount: paramForm.use_user_group_discount === true, use_user_discount: paramForm.use_user_discount === true }
|
||||
const submitData = {
|
||||
good_id: Number(props.goodId),
|
||||
arg_name: paramForm.arg_name,
|
||||
arg_type: paramForm.arg_type,
|
||||
must: paramForm.must === true,
|
||||
user_add: paramForm.user_add === true,
|
||||
use_user_group_discount: paramForm.use_user_group_discount === true,
|
||||
use_user_discount: paramForm.use_user_discount === true
|
||||
}
|
||||
if (paramForm.arg_type === 'number') {
|
||||
submitData.arg_step = Number(paramForm.arg_step)
|
||||
submitData.arg_min = Number(paramForm.arg_min)
|
||||
submitData.arg_max = Number(paramForm.arg_max)
|
||||
if (paramForm.enable_unit && paramForm.arg_key) {
|
||||
submitData.arg_step = calcBase(paramForm.step_display, paramForm.step_unit)
|
||||
submitData.arg_min = calcBase(paramForm.min_display, paramForm.min_unit)
|
||||
submitData.arg_max = calcBase(paramForm.max_display, paramForm.max_unit)
|
||||
submitData.enable_unit = true
|
||||
submitData.arg_key = paramForm.arg_key
|
||||
} else {
|
||||
submitData.arg_step = Number(paramForm.step_display)
|
||||
submitData.arg_min = Number(paramForm.min_display)
|
||||
submitData.arg_max = Number(paramForm.max_display)
|
||||
}
|
||||
}
|
||||
if (paramFormType.value === 'edit') submitData.arg_id = paramForm.arg_id
|
||||
const res = paramFormType.value === 'add' ? await createProductParameter(submitData) : await updateProductParameter(submitData)
|
||||
@@ -348,14 +585,30 @@ const fetchParamValuesList = async () => {
|
||||
const handleAddParamValue = () => {
|
||||
paramValueFormType.value = 'add'
|
||||
paramValueFormDialogVisible.value = true
|
||||
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: 0, attr_range: 0, range_type: 'equal' })
|
||||
nextTick(() => { paramValueFormRef.value?.resetFields() })
|
||||
const defaultUnit = hasUnit(currentParam.value) ? getParamDefaultUnit(currentParam.value) : ''
|
||||
nextTick(() => {
|
||||
paramValueFormRef.value?.resetFields()
|
||||
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: 0, attr_range: 0, attr_range_display: 0, display_unit: defaultUnit, range_type: 'equal' })
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditParamValue = (row) => {
|
||||
paramValueFormType.value = 'edit'
|
||||
paramValueFormDialogVisible.value = true
|
||||
Object.assign(paramValueForm, { attr_id: row.id, attr_name: row.name, attr_value: row.value || '', attr_price: row.price / 100 || 0, index: row.index || 0, attr_range: row.phase || 0, range_type: row.rangeType || 'equal' })
|
||||
const baseValue = row.phase || 0
|
||||
let displayValue = baseValue
|
||||
let displayUnit = ''
|
||||
if (hasUnit(currentParam.value)) {
|
||||
const argKey = getArgKey(currentParam.value)
|
||||
displayUnit = getParamDefaultUnit(currentParam.value)
|
||||
displayValue = fromBaseUnit(baseValue, displayUnit, argKey)
|
||||
}
|
||||
Object.assign(paramValueForm, {
|
||||
attr_id: row.id, attr_name: row.name, attr_value: row.value || '',
|
||||
attr_price: row.price / 100 || 0, index: row.index || 0,
|
||||
attr_range: baseValue, attr_range_display: displayValue,
|
||||
display_unit: displayUnit, range_type: row.rangeType || 'equal'
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteParamValue = (row) => {
|
||||
@@ -375,7 +628,27 @@ const submitParamValueForm = () => {
|
||||
try {
|
||||
const submitData = { good_id: Number(props.goodId), arg_id: Number(currentParam.value.id), attr_name: paramValueForm.attr_name, index: Number(paramValueForm.index), attr_price: Number(paramValueForm.attr_price) }
|
||||
if (currentParam.value.type === 'select') submitData.attr_value = paramValueForm.attr_value
|
||||
if (currentParam.value.type === 'number') { submitData.attr_range = Number(paramValueForm.attr_range); submitData.range_type = paramValueForm.range_type }
|
||||
if (currentParam.value.type === 'number') {
|
||||
let rangeValue
|
||||
if (hasUnit(currentParam.value)) {
|
||||
rangeValue = computedBaseValue.value
|
||||
} else {
|
||||
rangeValue = Number(paramValueForm.attr_range_display)
|
||||
}
|
||||
const baseMin = currentParam.value.min ?? 0
|
||||
const baseMax = currentParam.value.max
|
||||
const baseStep = currentParam.value.step || 1
|
||||
if (rangeValue < baseMin || (baseMax !== undefined && baseMax !== null && rangeValue > baseMax)) {
|
||||
ElMessage.warning(`阈值超出范围 (${baseMin} ~ ${baseMax ?? '∞'} ${hasUnit(currentParam.value) ? getBaseUnit(getArgKey(currentParam.value)) : ''})`)
|
||||
return
|
||||
}
|
||||
if (baseStep > 0 && (rangeValue - baseMin) % baseStep !== 0) {
|
||||
ElMessage.warning(`阈值必须符合步长 ${baseStep} 的要求`)
|
||||
return
|
||||
}
|
||||
submitData.attr_range = rangeValue
|
||||
submitData.range_type = paramValueForm.range_type
|
||||
}
|
||||
if (paramValueFormType.value === 'edit') submitData.attr_id = paramValueForm.attr_id
|
||||
const res = paramValueFormType.value === 'add' ? await addProductParameterValue(submitData) : await updateProductParameterValue(submitData)
|
||||
if (res.data.code === 200) { ElMessage.success(paramValueFormType.value === 'add' ? '添加成功' : '修改成功'); paramValueFormDialogVisible.value = false; fetchParamValuesList() }
|
||||
@@ -396,7 +669,11 @@ watch(() => props.visible, (val) => {
|
||||
.action-buttons .el-button { padding: 4px 8px; }
|
||||
.text-muted { color: #c0c4cc; font-size: 12px; }
|
||||
.number-config { color: #909399; font-size: 13px; }
|
||||
.number-config div { line-height: 1.4; }
|
||||
.unit-info { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
|
||||
.unit-text { font-size: 12px; color: #666; }
|
||||
.values-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.dialog-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 0; }
|
||||
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
.unit-input-row { display: flex; align-items: center; gap: 8px; width: 100%; }
|
||||
</style>
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="套餐名称" min-width="120" />
|
||||
<el-table-column label="参数配置" min-width="200">
|
||||
<el-table-column label="参数配置" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.argsParsed && row.argsParsed.length > 0" class="args-list">
|
||||
<el-tag v-for="(arg, index) in row.argsParsed" :key="index" size="small" type="info" style="margin-right: 4px; margin-bottom: 4px;">
|
||||
{{ arg.name || arg.value || `参数${arg.arg_id}` }}
|
||||
{{ arg.name }}: {{ formatArgTagDisplay(arg) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else class="text-muted">-</span>
|
||||
@@ -48,6 +48,13 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="允许升级" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.canUpdate || row.can_update ? 'success' : 'info'" size="small">
|
||||
{{ row.canUpdate || row.can_update ? '允许' : '不允许' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="handleEditPlan(row)">编辑</el-button>
|
||||
@@ -84,12 +91,14 @@
|
||||
<div class="args-config-container">
|
||||
<div class="args-select-row">
|
||||
<el-select v-model="selectedArgIds" multiple placeholder="请选择需要配置的参数" style="width: 100%" @change="onSelectedArgsChange">
|
||||
<el-option v-for="spec in planSpecList" :key="spec.id" :label="spec.name" :value="spec.id" />
|
||||
<el-option v-for="spec in planSpecList" :key="spec.id" :label="spec.must ? `* ${spec.name}` : spec.name" :value="spec.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div v-if="selectedArgSpecs.length > 0" class="args-selector">
|
||||
<div v-for="spec in selectedArgSpecs" :key="spec.id" class="spec-item">
|
||||
<div class="spec-label">{{ spec.name }}</div>
|
||||
<div class="spec-label">
|
||||
<span v-if="spec.must" class="must-star">*</span>{{ spec.name }}
|
||||
</div>
|
||||
<div class="spec-values">
|
||||
<template v-if="spec.type === 'select' && spec.attrs && spec.attrs.length > 0">
|
||||
<el-radio-group v-model="selectedArgs[spec.id]" size="small" @change="updateArgsJson">
|
||||
@@ -98,8 +107,26 @@
|
||||
</template>
|
||||
<template v-else-if="spec.type === 'number'">
|
||||
<div class="number-input-wrapper">
|
||||
<el-input-number v-model="selectedArgs[spec.id]" :min="spec.min || 0" :max="spec.max || 9999" :step="spec.step || 1" :step-strictly="true" size="small" @change="updateArgsJson" />
|
||||
<span class="number-range">(范围: {{ spec.min || 0 }} - {{ spec.max || 9999 }},步长: {{ spec.step || 1 }})</span>
|
||||
<el-input-number
|
||||
v-model="displayValues[spec.id]"
|
||||
:min="getSpecDisplayMin(spec)"
|
||||
:max="getSpecDisplayMax(spec)"
|
||||
:step="getSpecDisplayStep(spec)"
|
||||
:step-strictly="true"
|
||||
size="small"
|
||||
@change="onNumberDisplayChange(spec)"
|
||||
/>
|
||||
<el-select
|
||||
v-if="hasUnit(spec)"
|
||||
:model-value="displayUnits[spec.id]"
|
||||
size="small"
|
||||
style="width: 90px"
|
||||
@change="(newUnit) => onPlanUnitChange(spec, newUnit)"
|
||||
>
|
||||
<el-option v-for="u in getParamUnits(spec)" :key="u" :label="u" :value="u" />
|
||||
</el-select>
|
||||
<span class="number-range">({{ spec.min ?? 0 }} - {{ spec.max ?? 0 }}
|
||||
<template v-if="hasUnit(spec)">{{ getBaseUnit(getArgKey(spec)) }}</template>,步长: {{ spec.step ?? 1 }})</span>
|
||||
</div>
|
||||
<div v-if="spec.attrs && spec.attrs.length > 0 && selectedArgs[spec.id]" class="matched-attr-info">
|
||||
<el-tag type="success" size="small">匹配区间: {{ getMatchedAttrName(spec, selectedArgs[spec.id]) }}</el-tag>
|
||||
@@ -117,6 +144,12 @@
|
||||
<el-button type="info" plain size="small" @click="showArgsPreview = true">
|
||||
<el-icon><View /></el-icon>查看配置JSON
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="handleCopyArgsJson">
|
||||
<el-icon><CopyDocument /></el-icon>复制JSON
|
||||
</el-button>
|
||||
<el-button type="success" plain size="small" @click="handlePasteArgsJson">
|
||||
<el-icon><DocumentAdd /></el-icon>粘贴JSON
|
||||
</el-button>
|
||||
<el-button type="warning" plain size="small" @click="clearArgsSelection">
|
||||
<el-icon><Delete /></el-icon>清空选择
|
||||
</el-button>
|
||||
@@ -127,7 +160,7 @@
|
||||
<div class="args-config-container">
|
||||
<div class="form-tip" style="margin-bottom: 8px;">选择参数配置中未选择的参数作为额外参数</div>
|
||||
<el-select v-model="selectedExtraArgIds" multiple placeholder="请选择额外参数" style="width: 100%" @change="onSelectedExtraArgsChange">
|
||||
<el-option v-for="spec in extraSpecList" :key="spec.id" :label="`${spec.name} (ID: ${spec.id})`" :value="spec.id" />
|
||||
<el-option v-for="spec in extraSpecList" :key="spec.id" :label="spec.must ? `* ${spec.name} (ID: ${spec.id})` : `${spec.name} (ID: ${spec.id})`" :value="spec.id" />
|
||||
</el-select>
|
||||
<el-empty v-if="extraSpecList.length === 0" description="所有参数已在参数配置中选择" :image-size="40" />
|
||||
</div>
|
||||
@@ -140,8 +173,11 @@
|
||||
<el-switch v-model="planForm.enable_fixed_price" :active-value="true" :inactive-value="false" active-text="启用" inactive-text="禁用" :loading="fixedPriceLoading" @change="handleFixedPriceChange" />
|
||||
<div class="form-tip">启用后套餐价格将使用固定价格,不再根据参数计算</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="固定价格(元)" prop="fixed_price" v-if="planForm.enable_fixed_price === true">
|
||||
<el-input-number v-model="planForm.fixed_price" :min="0" :precision="2" :step="0.01" style="width: 100%" placeholder="请输入固定价格(元)" />
|
||||
<el-form-item label="固定价格" prop="fixed_price" v-if="planForm.enable_fixed_price === true">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="planForm.fixed_price" :min="0" :precision="2" :step="0.01" style="flex:1" placeholder="请输入固定价格(元)" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序索引" prop="index">
|
||||
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" />
|
||||
@@ -156,6 +192,10 @@
|
||||
<el-switch v-model="planForm.show_home" active-text="展示" inactive-text="不展示" />
|
||||
<div class="form-tip">控制商品套餐是否在首页显示</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="允许升级" prop="can_update">
|
||||
<el-switch v-model="planForm.can_update" active-text="允许" inactive-text="不允许" />
|
||||
<div class="form-tip">控制用户是否可以升级到此套餐</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<template #footer>
|
||||
@@ -166,6 +206,15 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 手动粘贴JSON对话框 -->
|
||||
<el-dialog v-model="showPasteDialog" title="粘贴JSON" width="500px" append-to-body>
|
||||
<el-input v-model="pasteJsonText" type="textarea" :rows="8" placeholder="请将JSON粘贴到此处" />
|
||||
<template #footer>
|
||||
<el-button @click="showPasteDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="doPasteFromText">确定导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 参数配置预览对话框 -->
|
||||
<el-dialog v-model="showArgsPreview" title="参数配置预览" width="500px" append-to-body>
|
||||
<div class="args-preview">
|
||||
@@ -192,7 +241,7 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, watch, nextTick } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Delete, View } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Delete, View, CopyDocument, DocumentAdd } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getProductParameterList,
|
||||
getProductPlanList,
|
||||
@@ -205,6 +254,10 @@ import {
|
||||
disablePlanFixedPrice,
|
||||
enablePlanFixedPrice
|
||||
} from '@/api/admin/product'
|
||||
import {
|
||||
hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit,
|
||||
toBaseUnit, fromBaseUnit, formatValueWithUnit
|
||||
} from '@/utils/dynamicUnit'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@@ -232,7 +285,8 @@ const planForm = reactive({
|
||||
enable_fixed_price: false,
|
||||
index: 0,
|
||||
disable: false,
|
||||
show_home: false
|
||||
show_home: false,
|
||||
can_update: false
|
||||
})
|
||||
const planFormRules = {
|
||||
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }]
|
||||
@@ -241,12 +295,73 @@ const planFormRules = {
|
||||
const planSpecList = ref([])
|
||||
const selectedArgIds = ref([])
|
||||
const selectedArgs = reactive({})
|
||||
const displayValues = reactive({})
|
||||
const displayUnits = reactive({})
|
||||
const showArgsPreview = ref(false)
|
||||
const showPasteDialog = ref(false)
|
||||
const pasteJsonText = ref('')
|
||||
const selectedExtraArgIds = ref([])
|
||||
|
||||
const selectedArgSpecs = computed(() => planSpecList.value.filter(spec => selectedArgIds.value.includes(spec.id)))
|
||||
const extraSpecList = computed(() => planSpecList.value.filter(spec => !selectedArgIds.value.includes(spec.id)))
|
||||
|
||||
const getSpecDisplayMin = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.min ?? 0
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
return unit ? fromBaseUnit(spec.min ?? 0, unit, argKey) : (spec.min ?? 0)
|
||||
}
|
||||
const getSpecDisplayMax = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.max ?? 0
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
return unit ? fromBaseUnit(spec.max ?? 0, unit, argKey) : (spec.max ?? 0)
|
||||
}
|
||||
const getSpecDisplayStep = (spec) => {
|
||||
if (!hasUnit(spec)) return spec.step ?? 1
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
if (!unit) return spec.step ?? 1
|
||||
const converted = fromBaseUnit(spec.step ?? 1, unit, argKey)
|
||||
return converted > 0 ? converted : 1
|
||||
}
|
||||
|
||||
const onNumberDisplayChange = (spec) => {
|
||||
if (hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id]
|
||||
selectedArgs[spec.id] = Math.round(toBaseUnit(displayValues[spec.id] || 0, unit, argKey))
|
||||
} else {
|
||||
selectedArgs[spec.id] = displayValues[spec.id]
|
||||
}
|
||||
updateArgsJson()
|
||||
}
|
||||
|
||||
const onPlanUnitChange = (spec, newUnit) => {
|
||||
const argKey = getArgKey(spec)
|
||||
const oldUnit = displayUnits[spec.id]
|
||||
const oldDisplay = displayValues[spec.id] || 0
|
||||
const baseValue = oldUnit ? toBaseUnit(oldDisplay, oldUnit, argKey) : oldDisplay
|
||||
displayUnits[spec.id] = newUnit
|
||||
displayValues[spec.id] = fromBaseUnit(baseValue, newUnit, argKey)
|
||||
selectedArgs[spec.id] = Math.round(baseValue)
|
||||
updateArgsJson()
|
||||
}
|
||||
|
||||
const formatArgTagDisplay = (arg) => {
|
||||
if (arg.number !== undefined && arg.number !== 0) {
|
||||
const spec = planSpecList.value.find(s => s.id === arg.arg_id)
|
||||
if (spec && hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const displayUnit = getParamDefaultUnit(spec)
|
||||
const displayVal = fromBaseUnit(arg.number, displayUnit, argKey)
|
||||
return formatValueWithUnit(displayVal, displayUnit)
|
||||
}
|
||||
return String(arg.number)
|
||||
}
|
||||
return arg.value || '-'
|
||||
}
|
||||
|
||||
const parseArgs = (argsStr) => {
|
||||
if (!argsStr) return []
|
||||
try { const parsed = JSON.parse(argsStr); return Array.isArray(parsed) ? parsed : [] }
|
||||
@@ -262,8 +377,14 @@ const fetchPlanSpecList = async () => {
|
||||
}
|
||||
|
||||
const onSelectedArgsChange = () => {
|
||||
for (const key in selectedArgs) { if (!selectedArgIds.value.includes(Number(key))) delete selectedArgs[key] }
|
||||
for (const key in selectedArgs) { if (!selectedArgIds.value.includes(Number(key))) { delete selectedArgs[key]; delete displayValues[key]; delete displayUnits[key] } }
|
||||
selectedExtraArgIds.value = selectedExtraArgIds.value.filter(id => !selectedArgIds.value.includes(id))
|
||||
for (const specId of selectedArgIds.value) {
|
||||
const spec = planSpecList.value.find(s => s.id === specId)
|
||||
if (spec && spec.type === 'number' && hasUnit(spec) && !displayUnits[specId]) {
|
||||
displayUnits[specId] = getParamDefaultUnit(spec)
|
||||
}
|
||||
}
|
||||
updateArgsJson()
|
||||
updateExtraArgIds()
|
||||
}
|
||||
@@ -280,13 +401,13 @@ const updateArgsJson = () => {
|
||||
if (selectedValue === undefined || selectedValue === '') continue
|
||||
if (spec.type === 'select') {
|
||||
const attrObj = spec.attrs?.find(a => a.id === selectedValue)
|
||||
if (attrObj) argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: attrObj.id, value: attrObj.value || '' })
|
||||
if (attrObj) argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: attrObj.id, value: attrObj.value || '', key: getArgKey(spec) || undefined })
|
||||
} else if (spec.type === 'number') {
|
||||
const numValue = Number(selectedValue)
|
||||
const matchedAttr = findMatchingNumberAttr(spec, numValue)
|
||||
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: matchedAttr ? matchedAttr.id : 0, number: numValue })
|
||||
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: matchedAttr ? matchedAttr.id : 0, number: numValue, key: getArgKey(spec) || undefined })
|
||||
} else {
|
||||
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(selectedValue) })
|
||||
argsArray.push({ arg_id: spec.id, name: spec.name, attr_id: 0, value: String(selectedValue), key: getArgKey(spec) || undefined })
|
||||
}
|
||||
}
|
||||
planForm.args = argsArray.length > 0 ? JSON.stringify(argsArray) : ''
|
||||
@@ -298,8 +419,8 @@ const findMatchingNumberAttr = (spec, numValue) => {
|
||||
for (const attr of sortedAttrs) {
|
||||
const phase = attr.phase || 0
|
||||
const rangeType = attr.rangeType || 'before'
|
||||
if (rangeType === 'before' && numValue <= phase) return attr
|
||||
else if (rangeType === 'after' && numValue >= phase) return attr
|
||||
if (rangeType === 'before' && numValue < phase) return attr
|
||||
else if (rangeType === 'after' && numValue > phase) return attr
|
||||
else if (rangeType === 'equal' && numValue === phase) return attr
|
||||
}
|
||||
return sortedAttrs[sortedAttrs.length - 1]
|
||||
@@ -314,6 +435,8 @@ const getMatchedAttrName = (spec, numValue) => {
|
||||
const clearArgsSelection = () => {
|
||||
selectedArgIds.value = []
|
||||
for (const key in selectedArgs) delete selectedArgs[key]
|
||||
for (const key in displayValues) delete displayValues[key]
|
||||
for (const key in displayUnits) delete displayUnits[key]
|
||||
selectedExtraArgIds.value = []
|
||||
planForm.args = ''
|
||||
planForm.extra_arg_ids = ''
|
||||
@@ -324,6 +447,12 @@ const getSelectedValueDisplay = (spec) => {
|
||||
const selectedValue = selectedArgs[spec.id]
|
||||
if (selectedValue === undefined || selectedValue === '') return null
|
||||
if (spec.type === 'select') { const attrObj = spec.attrs?.find(a => a.id === selectedValue); return attrObj ? attrObj.name : null }
|
||||
if (hasUnit(spec)) {
|
||||
const argKey = getArgKey(spec)
|
||||
const unit = displayUnits[spec.id] || getParamDefaultUnit(spec)
|
||||
const displayVal = fromBaseUnit(Number(selectedValue), unit, argKey)
|
||||
return formatValueWithUnit(displayVal, unit)
|
||||
}
|
||||
return String(selectedValue)
|
||||
}
|
||||
|
||||
@@ -334,6 +463,100 @@ const formatArgsJsonPreview = () => {
|
||||
try { return JSON.stringify(JSON.parse(planForm.args), null, 2) } catch { return planForm.args }
|
||||
}
|
||||
|
||||
const handleCopyArgsJson = async () => {
|
||||
if (!planForm.args) { ElMessage.warning('暂无参数配置可复制'); return }
|
||||
try {
|
||||
await navigator.clipboard.writeText(planForm.args)
|
||||
ElMessage.success('已复制参数配置JSON到剪贴板')
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = planForm.args
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
ElMessage.success('已复制参数配置JSON到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasteArgsJson = async () => {
|
||||
let clipText = ''
|
||||
try {
|
||||
clipText = await navigator.clipboard.readText()
|
||||
} catch {
|
||||
pasteJsonText.value = ''
|
||||
showPasteDialog.value = true
|
||||
return
|
||||
}
|
||||
if (!clipText || !clipText.trim()) { ElMessage.warning('剪贴板为空'); return }
|
||||
applyPastedJson(clipText)
|
||||
}
|
||||
|
||||
const doPasteFromText = () => {
|
||||
const text = pasteJsonText.value
|
||||
if (!text || !text.trim()) { ElMessage.warning('请输入JSON内容'); return }
|
||||
showPasteDialog.value = false
|
||||
applyPastedJson(text)
|
||||
}
|
||||
|
||||
const applyPastedJson = (jsonText) => {
|
||||
let pastedArgs
|
||||
try {
|
||||
pastedArgs = JSON.parse(jsonText)
|
||||
if (!Array.isArray(pastedArgs)) { ElMessage.error('JSON格式错误,需要数组格式'); return }
|
||||
} catch {
|
||||
ElMessage.error('JSON解析失败,请检查格式')
|
||||
return
|
||||
}
|
||||
|
||||
clearArgsSelection()
|
||||
const matchedIds = []
|
||||
for (const arg of pastedArgs) {
|
||||
let spec = null
|
||||
if (arg.key) {
|
||||
spec = planSpecList.value.find(s => getArgKey(s) === arg.key)
|
||||
}
|
||||
if (!spec && arg.name) {
|
||||
spec = planSpecList.value.find(s => s.name === arg.name)
|
||||
}
|
||||
if (!spec && arg.arg_id) {
|
||||
spec = planSpecList.value.find(s => s.id === arg.arg_id)
|
||||
}
|
||||
if (!spec) continue
|
||||
|
||||
matchedIds.push(spec.id)
|
||||
if (spec.type === 'select') {
|
||||
if (arg.attr_id) {
|
||||
const attrObj = spec.attrs?.find(a => a.id === arg.attr_id)
|
||||
if (attrObj) selectedArgs[spec.id] = attrObj.id
|
||||
else if (arg.value) {
|
||||
const byVal = spec.attrs?.find(a => a.value === arg.value || a.name === arg.value)
|
||||
if (byVal) selectedArgs[spec.id] = byVal.id
|
||||
}
|
||||
} else if (arg.value) {
|
||||
const byVal = spec.attrs?.find(a => a.value === arg.value || a.name === arg.value)
|
||||
if (byVal) selectedArgs[spec.id] = byVal.id
|
||||
}
|
||||
} else if (spec.type === 'number') {
|
||||
const numVal = Number(arg.number !== undefined ? arg.number : arg.value)
|
||||
selectedArgs[spec.id] = numVal
|
||||
if (hasUnit(spec)) {
|
||||
const unit = getParamDefaultUnit(spec)
|
||||
displayUnits[spec.id] = unit
|
||||
displayValues[spec.id] = fromBaseUnit(numVal, unit, getArgKey(spec))
|
||||
} else {
|
||||
displayValues[spec.id] = numVal
|
||||
}
|
||||
} else {
|
||||
selectedArgs[spec.id] = arg.value || ''
|
||||
}
|
||||
}
|
||||
selectedArgIds.value = matchedIds
|
||||
updateArgsJson()
|
||||
updateExtraArgIds()
|
||||
ElMessage.success(`已从JSON导入 ${matchedIds.length} 个参数配置`)
|
||||
}
|
||||
|
||||
const initSelectedArgsFromJson = (argsJson, extraArgIds = []) => {
|
||||
clearArgsSelection()
|
||||
const argsParamIds = []
|
||||
@@ -349,8 +572,19 @@ const initSelectedArgsFromJson = (argsJson, extraArgIds = []) => {
|
||||
if (arg.attr_id) selectedArgs[spec.id] = arg.attr_id
|
||||
else if (arg.id) selectedArgs[spec.id] = arg.id
|
||||
else { const attrObj = spec.attrs?.find(a => a.value === arg.value || a.name === arg.name); if (attrObj) selectedArgs[spec.id] = attrObj.id }
|
||||
} else if (spec.type === 'number') { selectedArgs[spec.id] = Number(arg.number !== undefined ? arg.number : arg.value) }
|
||||
else { selectedArgs[spec.id] = arg.value }
|
||||
} else if (spec.type === 'number') {
|
||||
const numVal = Number(arg.number !== undefined ? arg.number : arg.value)
|
||||
selectedArgs[spec.id] = numVal
|
||||
if (hasUnit(spec)) {
|
||||
const unit = getParamDefaultUnit(spec)
|
||||
displayUnits[spec.id] = unit
|
||||
displayValues[spec.id] = fromBaseUnit(numVal, unit, getArgKey(spec))
|
||||
} else {
|
||||
displayValues[spec.id] = numVal
|
||||
}
|
||||
} else {
|
||||
selectedArgs[spec.id] = arg.value
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('解析args失败:', e) }
|
||||
@@ -382,7 +616,12 @@ const handleAddPlan = async () => {
|
||||
await fetchPlanSpecList()
|
||||
clearArgsSelection()
|
||||
selectedArgIds.value = planSpecList.value.map(spec => spec.id)
|
||||
Object.assign(planForm, { plan_id: undefined, name: '', note: '', args: '', extra_arg_ids: '', extra_arg_ids_array: [], inventory: 0, fixed_price: 0, enable_fixed_price: false, index: 0, disable: false, show_home: false })
|
||||
for (const spec of planSpecList.value) {
|
||||
if (spec.type === 'number' && hasUnit(spec)) {
|
||||
displayUnits[spec.id] = getParamDefaultUnit(spec)
|
||||
}
|
||||
}
|
||||
Object.assign(planForm, { plan_id: undefined, name: '', note: '', args: '', extra_arg_ids: '', extra_arg_ids_array: [], inventory: 0, fixed_price: 0, enable_fixed_price: false, index: 0, disable: false, show_home: false, can_update: false })
|
||||
planFormDialogVisible.value = true
|
||||
nextTick(() => { planFormRef.value?.resetFields() })
|
||||
}
|
||||
@@ -408,7 +647,8 @@ const handleEditPlan = async (row) => {
|
||||
extra_arg_ids: extraArgIdsArray.join(','), extra_arg_ids_array: extraArgIdsArray,
|
||||
inventory: data.inventory || 0, fixed_price: ((data.fixedPrice || data.fixed_price || 0) / 100).toFixed(2) * 1,
|
||||
enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price),
|
||||
index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home)
|
||||
index: data.index || 0, disable: data.disable || false, show_home: !!(data.showHome || data.show_home),
|
||||
can_update: !!(data.canUpdate || data.can_update)
|
||||
})
|
||||
initSelectedArgsFromJson(data.args, extraArgIdsArray)
|
||||
planFormDialogVisible.value = true
|
||||
@@ -451,13 +691,21 @@ const handleFixedPriceChange = async (value) => {
|
||||
const submitPlanForm = () => {
|
||||
planFormRef.value?.validate(async (valid) => {
|
||||
if (valid) {
|
||||
const mustSpecs = planSpecList.value.filter(s => s.must)
|
||||
const allSelectedIds = [...selectedArgIds.value, ...selectedExtraArgIds.value]
|
||||
const missingMust = mustSpecs.filter(s => !allSelectedIds.includes(s.id))
|
||||
if (missingMust.length > 0) {
|
||||
ElMessage.warning(`以下必选参数未配置:${missingMust.map(s => s.name).join('、')},请在参数配置或额外参数中选择`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const extraArgIdsStr = selectedExtraArgIds.value.join(',')
|
||||
const submitData = {
|
||||
good_id: String(props.goodId), name: planForm.name, note: planForm.note || '',
|
||||
args: planForm.args || '', extra_arg_ids: extraArgIdsStr || planForm.extra_arg_ids || '',
|
||||
inventory: Number(planForm.inventory) || 0, fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0,
|
||||
index: Number(planForm.index) || 0, show_home: planForm.show_home === true
|
||||
index: Number(planForm.index) || 0, show_home: planForm.show_home === true,
|
||||
can_update: planForm.can_update === true
|
||||
}
|
||||
if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true
|
||||
let res
|
||||
@@ -490,16 +738,17 @@ watch(() => props.visible, (val) => {
|
||||
.args-config-container { width: 100%; }
|
||||
.args-select-row { margin-bottom: 12px; }
|
||||
.args-selector { border: 1px solid #e4e7ed; border-radius: 4px; padding: 12px; background: #fafafa; max-height: 300px; overflow-y: auto; }
|
||||
.spec-item { display: flex; align-items: flex-start; padding: 10px 0; border-bottom: 1px dashed #e4e7ed; }
|
||||
.spec-item { display: flex; align-items: flex-start; padding: 10px 0; border-bottom: 1px dashed #e4e7ed; gap: 12px; }
|
||||
.spec-item:last-child { border-bottom: none; }
|
||||
.spec-label { width: 100px; flex-shrink: 0; font-weight: 500; color: #606266; padding-top: 4px; }
|
||||
.spec-values { flex: 1; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
||||
.spec-label { min-width: 80px; max-width: 160px; flex-shrink: 0; font-weight: 500; color: #606266; padding-top: 4px; display: flex; align-items: center; white-space: nowrap; }
|
||||
.must-star { color: #f56c6c; font-size: 16px; font-weight: 700; margin-right: 2px; line-height: 1; }
|
||||
.spec-values { flex: 1; display: flex; flex-direction: column; gap: 6px; }
|
||||
.spec-values :deep(.el-radio-group) { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.spec-values :deep(.el-radio-button__inner) { padding: 6px 12px; }
|
||||
.number-input-wrapper { display: flex; align-items: center; gap: 8px; }
|
||||
.number-input-wrapper { display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
|
||||
.number-range { color: #909399; font-size: 12px; }
|
||||
.matched-attr-info { margin-top: 6px; }
|
||||
.args-actions { margin-top: 12px; display: flex; gap: 8px; }
|
||||
.matched-attr-info { margin-top: 2px; }
|
||||
.args-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.args-preview { padding: 0; }
|
||||
.preview-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.preview-list { max-height: 200px; overflow-y: auto; }
|
||||
@@ -509,4 +758,6 @@ watch(() => props.visible, (val) => {
|
||||
.preview-value { color: #409eff; font-weight: 500; }
|
||||
.preview-value.not-selected { color: #c0c4cc; font-style: italic; }
|
||||
.json-preview { background: #f5f7fa; border: 1px solid #e4e7ed; border-radius: 4px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 12px; color: #606266; max-height: 200px; overflow: auto; white-space: pre-wrap; word-break: break-all; margin: 0; }
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-text { font-size: 13px; color: #606266; flex-shrink: 0; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@@ -349,33 +349,6 @@ onMounted(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
:deep(.el-table) {
|
||||
border: none;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
:deep(.el-table__header) {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: #f8f9fa !important;
|
||||
border-bottom: 2px solid #e1e8ed;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
:deep(.el-table tr:hover > td) {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
<UserGroupSelector
|
||||
v-model="groupSelectorVisible"
|
||||
:current-group-id="selectorType === 'query' ? queryParams.admin_group_id : permissionForm.admin_group_id"
|
||||
admin-group
|
||||
@confirm="handleGroupSelectorConfirm"
|
||||
/>
|
||||
|
||||
@@ -465,12 +466,15 @@ const handleGroupSelectorConfirm = (group) => {
|
||||
|
||||
if (selectorType.value === 'query') {
|
||||
queryParams.admin_group_id = groupId
|
||||
if (!GroupOptions.value.find(g => g.id === groupId)) {
|
||||
GroupOptions.value.push({ id: groupId, name: groupName })
|
||||
}
|
||||
fetchAdminPermissionList()
|
||||
} else {
|
||||
permissionForm.admin_group_id = groupId
|
||||
}
|
||||
|
||||
if (!GroupOptions.value.find(g => g.id === groupId)) {
|
||||
GroupOptions.value.push({ id: groupId, name: groupName })
|
||||
if (!GroupOptions.value.find(g => g.id === groupId)) {
|
||||
GroupOptions.value.push({ id: groupId, name: groupName })
|
||||
}
|
||||
}
|
||||
}
|
||||
groupSelectorVisible.value = false
|
||||
@@ -492,10 +496,10 @@ const handleUserSelectorConfirm = (user) => {
|
||||
|
||||
if (selectorType.value === 'query') {
|
||||
queryParams.user_id = userId
|
||||
// 添加到 UserOptions 用于显示名称
|
||||
if (!UserOptions.value.find(u => u.UserId === userId)) {
|
||||
UserOptions.value.push({ UserId: userId, UserName: userName })
|
||||
}
|
||||
fetchAdminPermissionList()
|
||||
} else if (selectorType.value === 'form') {
|
||||
permissionForm.user_id = userId
|
||||
if (!UserOptions.value.find(u => u.UserId === userId)) {
|
||||
|
||||
@@ -528,33 +528,6 @@ onMounted(() => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
:deep(.el-table) {
|
||||
border: none;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
:deep(.el-table__header) {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: #f8f9fa !important;
|
||||
border-bottom: 2px solid #e1e8ed;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
:deep(.el-table tr:hover > td) {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="ticket-list-page">
|
||||
<!-- 顶部工具栏 -->
|
||||
<div class="toolbar">
|
||||
<!-- 顶部状态标签栏 -->
|
||||
<div class="status-bar">
|
||||
<div class="status-tabs">
|
||||
<div class="tab-item pending" :class="{ active: activeStatus === 'pending' }" @click="filterByStatus('pending')">
|
||||
待处理 <span class="count">{{ stats.pending }}</span>
|
||||
@@ -19,48 +19,47 @@
|
||||
全部 <span class="count">{{ stats.total }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<el-select v-model="sortBy" placeholder="排序方式" clearable style="width: 140px" @change="handleSortChange">
|
||||
<el-option label="不排序" value="" />
|
||||
<el-option label="创建时间" value="created_at" />
|
||||
<el-option label="更新时间" value="updated_at" />
|
||||
<el-option label="工单号" value="id" />
|
||||
</el-select>
|
||||
<el-select v-model="sortOrder" placeholder="排序顺序" clearable style="width: 100px" @change="handleSortChange">
|
||||
<el-option label="默认" value="" />
|
||||
<el-option label="降序" value="desc" />
|
||||
<el-option label="升序" value="asc" />
|
||||
</el-select>
|
||||
<!-- 用户筛选输入框 -->
|
||||
<el-input
|
||||
:model-value="selectedUser ? selectedUser.user_name : ''"
|
||||
placeholder="点击选择用户筛选"
|
||||
readonly
|
||||
style="width: 180px; cursor: pointer"
|
||||
@click="showUserDialog = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
<template #suffix v-if="selectedUser">
|
||||
<el-icon @click.stop="clearUserFilter" style="cursor: pointer"><Close /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<!-- 关键词搜索 -->
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索工单标题/内容"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@input="handleKeywordSearch"
|
||||
@clear="handleKeywordSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button icon="Refresh" @click="refreshList">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 筛选工具栏 -->
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="sortBy" placeholder="排序方式" clearable style="width: 140px" @change="handleSortChange">
|
||||
<el-option label="不排序" value="" />
|
||||
<el-option label="创建时间" value="created_at" />
|
||||
<el-option label="更新时间" value="updated_at" />
|
||||
<el-option label="工单号" value="id" />
|
||||
</el-select>
|
||||
<el-select v-model="sortOrder" placeholder="排序顺序" clearable style="width: 100px" @change="handleSortChange">
|
||||
<el-option label="默认" value="" />
|
||||
<el-option label="降序" value="desc" />
|
||||
<el-option label="升序" value="asc" />
|
||||
</el-select>
|
||||
<el-input
|
||||
:model-value="selectedUser ? selectedUser.user_name : ''"
|
||||
placeholder="点击选择用户筛选"
|
||||
readonly
|
||||
style="width: 180px; cursor: pointer"
|
||||
@click="showUserDialog = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
</template>
|
||||
<template #suffix v-if="selectedUser">
|
||||
<el-icon @click.stop="clearUserFilter" style="cursor: pointer"><Close /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-input
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索工单标题/内容"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@input="handleKeywordSearch"
|
||||
@clear="handleKeywordSearch"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Search /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
<el-button icon="Refresh" @click="refreshList">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 工单表格(PC端) -->
|
||||
@@ -559,36 +558,36 @@ onBeforeUnmount(() => {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
.status-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 50px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
justify-content: flex-start;
|
||||
padding: 14px 20px 0;
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: #f5f7fa;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: #409eff;
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tab-item.pending.active { background: #e6a23c; }
|
||||
@@ -601,10 +600,13 @@ onBeforeUnmount(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.user-dialog-content {
|
||||
@@ -682,8 +684,6 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
@@ -771,25 +771,19 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* 大屏平板尺寸响应式样式 (1020px - 1280px) */
|
||||
@media (max-width: 1280px) and (min-width: 1021px) {
|
||||
.toolbar {
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
flex-wrap: wrap;
|
||||
.filter-bar {
|
||||
padding: 10px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-right .el-select {
|
||||
.filter-bar .el-select {
|
||||
width: 120px !important;
|
||||
}
|
||||
|
||||
.toolbar-right .el-input {
|
||||
.filter-bar .el-input {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
/* 表格列宽调整 */
|
||||
:deep(.el-table) {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -801,12 +795,8 @@ onBeforeUnmount(() => {
|
||||
|
||||
/* 平板尺寸响应式样式 (769px - 1020px) */
|
||||
@media (max-width: 1020px) and (min-width: 769px) {
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: 12px 16px;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
.status-bar {
|
||||
padding: 10px 16px 0;
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
@@ -825,22 +815,20 @@ onBeforeUnmount(() => {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
.filter-bar {
|
||||
padding: 10px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-right .el-select {
|
||||
.filter-bar .el-select {
|
||||
width: 120px !important;
|
||||
}
|
||||
|
||||
.toolbar-right .el-input {
|
||||
.filter-bar .el-input {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* 表格列宽调整 */
|
||||
:deep(.el-table) {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -857,11 +845,8 @@ onBeforeUnmount(() => {
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
.status-bar {
|
||||
padding: 10px 12px 0;
|
||||
}
|
||||
|
||||
.status-tabs {
|
||||
@@ -881,19 +866,18 @@ onBeforeUnmount(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
.filter-bar {
|
||||
padding: 10px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-right .el-select,
|
||||
.toolbar-right .el-input {
|
||||
.filter-bar .el-select,
|
||||
.filter-bar .el-input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.toolbar-right .el-button {
|
||||
.filter-bar .el-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<div class="name-row">
|
||||
<h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2>
|
||||
<el-tag v-if="vm?.status" :type="vmStatusType(vm.status)" size="small" style="margin-left:8px">{{ vmStatusLabel(vm.status) }}</el-tag>
|
||||
<el-tag v-if="vm?.rescue" size="small" type="danger" effect="dark" style="margin-left:4px">救援模式</el-tag>
|
||||
<el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
@@ -43,8 +44,8 @@
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="suspend">暂停</el-dropdown-item>
|
||||
<el-dropdown-item command="resume">恢复</el-dropdown-item>
|
||||
<el-dropdown-item command="rescue">救援模式</el-dropdown-item>
|
||||
<el-dropdown-item command="exitRescue">退出救援</el-dropdown-item>
|
||||
<el-dropdown-item command="rescue" :disabled="!!vm?.rescue">救援模式</el-dropdown-item>
|
||||
<el-dropdown-item command="exitRescue" :disabled="!vm?.rescue">退出救援</el-dropdown-item>
|
||||
<el-dropdown-item divided command="rebuild">重装系统</el-dropdown-item>
|
||||
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
|
||||
<el-dropdown-item command="refactorVm">重构虚拟机</el-dropdown-item>
|
||||
@@ -68,13 +69,57 @@
|
||||
<div class="config-cell"><span class="config-label">上行带宽</span><span class="config-value">{{ vm.tx_bandwidth || 0 }} Mbps</span></div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-cell"><span class="config-label">SSH端口</span><span class="config-value">{{ vm.ssh_port || 22 }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">流量上限</span><span class="config-value">{{ formatTraffic(vm.traffic_max) }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">IP</span><span class="config-value" style="color:#165dff;font-weight:500">{{ vm.ips || '-' }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">续费价格</span><span class="config-value">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">用户名</span><span class="config-value" style="font-weight:500">{{ isWindows ? 'Administrator' : 'root' }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">远程端口</span><span class="config-value">{{ isWindows ? (vm.ssh_port || 3389) : (vm.ssh_port || 22) }}</span></div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">外网IP</span>
|
||||
<span class="config-value" style="color:#165dff;font-weight:500" v-if="vmPublicIpList.length">
|
||||
{{ vmPublicIpList[0] }}
|
||||
<el-popover v-if="vmPublicIpList.length > 1" trigger="hover" placement="bottom-start" :width="280">
|
||||
<template #reference>
|
||||
<el-tag size="small" type="primary" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ vmPublicIpList.length - 1 }}</el-tag>
|
||||
</template>
|
||||
<div class="ip-popover-list">
|
||||
<div v-for="(ip, idx) in vmPublicIpList" :key="idx" class="ip-popover-item">{{ ip }}</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</span>
|
||||
<span class="config-value" v-else>-</span>
|
||||
</div>
|
||||
<div class="config-cell">
|
||||
<span class="config-label">内网IP</span>
|
||||
<span class="config-value" style="color:#67c23a;font-weight:500" v-if="vmPrivateIpList.length">
|
||||
{{ vmPrivateIpList[0] }}
|
||||
<el-popover v-if="vmPrivateIpList.length > 1" trigger="hover" placement="bottom-start" :width="280">
|
||||
<template #reference>
|
||||
<el-tag size="small" type="success" style="margin-left:4px;cursor:pointer;vertical-align:middle">+{{ vmPrivateIpList.length - 1 }}</el-tag>
|
||||
</template>
|
||||
<div class="ip-popover-list">
|
||||
<div v-for="(ip, idx) in vmPrivateIpList" :key="idx" class="ip-popover-item">{{ ip }}</div>
|
||||
</div>
|
||||
</el-popover>
|
||||
</span>
|
||||
<span class="config-value" v-else>-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-cell">
|
||||
<span class="config-label">Root密码</span>
|
||||
<span class="config-value pwd-value">
|
||||
<code class="pwd-text">{{ showPassword ? (vm.root_password || '-') : '••••••••' }}</code>
|
||||
<el-button link size="small" @click="showPassword = !showPassword" class="pwd-btn">
|
||||
<el-icon :size="14"><View v-if="!showPassword" /><Hide v-else /></el-icon>
|
||||
</el-button>
|
||||
<el-button link size="small" type="primary" @click="copyPassword" class="pwd-btn">
|
||||
<el-icon :size="14"><CopyDocument /></el-icon>
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-cell"><span class="config-label">流量上限</span><span class="config-value">{{ formatTraffic(vm.traffic_max) }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">续费价格</span><span class="config-value">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">基础价格</span><span class="config-value">¥{{ (userGoods.basePrice / 100).toFixed(2) }}</span></div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-cell"><span class="config-label">商品</span><span class="config-value">{{ userGoods.good?.name || '-' }}</span></div>
|
||||
<div class="config-cell"><span class="config-label">备注</span><span class="config-value">{{ userGoods.note || '-' }}</span></div>
|
||||
<div class="config-cell">
|
||||
@@ -84,8 +129,6 @@
|
||||
<span v-else style="color:#c0c4cc">未绑定</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-row">
|
||||
<div class="config-cell">
|
||||
<span class="config-label">出站安全组</span>
|
||||
<span class="config-value">
|
||||
@@ -93,7 +136,7 @@
|
||||
<span v-else style="color:#c0c4cc">未绑定</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="config-cell" style="flex:3" v-if="vmImage">
|
||||
<div class="config-cell" v-if="vmImage">
|
||||
<span class="config-label">镜像</span>
|
||||
<span class="config-value">{{ vmImage.name }} <el-tag size="small" :type="vmImage.os_type === 'linux' ? 'success' : 'primary'" style="margin-left:4px">{{ vmImage.os_type }}</el-tag></span>
|
||||
</div>
|
||||
@@ -229,6 +272,7 @@
|
||||
<!-- 网络 -->
|
||||
<el-tab-pane v-if="isVmGoods" label="网络" name="network">
|
||||
<div class="tab-toolbar">
|
||||
<el-button size="small" type="primary" @click="showBindNetworkSelector = true">绑定网络</el-button>
|
||||
<el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="vmNetworks" stripe size="small">
|
||||
@@ -286,6 +330,79 @@
|
||||
@size-change="s => { networkingPageSize = s; networkingPage = 1; loadNetworkings() }" @current-change="p => { networkingPage = p; loadNetworkings() }" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 监控 -->
|
||||
<el-tab-pane v-if="isVmGoods" label="监控" name="monitor">
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">监控指标</h3>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<el-select v-model="monitorInterval" size="small" style="width: 120px" @change="loadMetricsHistory">
|
||||
<el-option label="1分钟" value="1m" />
|
||||
<el-option label="5分钟" value="5m" />
|
||||
<el-option label="1小时" value="1h" />
|
||||
</el-select>
|
||||
<el-button size="small" :icon="Refresh" @click="loadMetricsHistory" :loading="metricsLoading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="latestMetrics">
|
||||
<div class="metric-summary-row">
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">CPU 使用率</div>
|
||||
<div class="metric-summary-value">{{ latestMetrics.cpu_usage?.toFixed(1) }}%</div>
|
||||
<div class="metric-summary-sub"> </div>
|
||||
</div>
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">内存</div>
|
||||
<div class="metric-summary-value">{{ vmMemPercent(latestMetrics) }}%</div>
|
||||
<div class="metric-summary-sub">{{ formatMemKB(latestMetrics.mem_used) }} / {{ formatMemKB(latestMetrics.mem_total) }}</div>
|
||||
</div>
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">磁盘 I/O</div>
|
||||
<div class="metric-summary-value">读 {{ formatBytesRaw(latestMetrics.disk_read) }}</div>
|
||||
<div class="metric-summary-sub">写 {{ formatBytesRaw(latestMetrics.disk_write) }}</div>
|
||||
</div>
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">网络流量</div>
|
||||
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.net_rx) }}</div>
|
||||
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.net_tx) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="metricsData">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU 使用率</span></template>
|
||||
<div ref="cpuChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 内存使用率</span></template>
|
||||
<div ref="memChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 磁盘 I/O</span></template>
|
||||
<div ref="diskChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> 网络流量</span></template>
|
||||
<div ref="netChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
<el-empty v-else-if="!metricsLoading" description="暂无监控数据" :image-size="80" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
@@ -302,7 +419,7 @@
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="vncVisible = false">关闭</el-button>
|
||||
<el-button v-if="vncResult?.url" type="primary" @click="window.open(vncResult.url, '_blank')">打开连接</el-button>
|
||||
<el-button v-if="vncResult?.url" type="primary" @click="openVncUrl">打开连接</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -328,7 +445,12 @@
|
||||
<el-dialog v-model="volCreateVisible" title="创建数据卷" width="440px" destroy-on-close>
|
||||
<el-form :model="volCreateForm" label-width="100px">
|
||||
<el-form-item label="名称" required><el-input v-model="volCreateForm.name" /></el-form-item>
|
||||
<el-form-item label="大小(GB)"><el-input-number v-model="volCreateForm.size" :min="1" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="大小">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="volCreateForm.size" :min="1" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">GB</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标设备名"><el-input v-model="volCreateForm.target_device" placeholder="不填自动生成" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -341,7 +463,12 @@
|
||||
<el-dialog v-model="volResizeVisible" title="扩容数据卷" width="400px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="当前大小">{{ volResizeTarget?.size || 0 }} GB</el-form-item>
|
||||
<el-form-item label="新大小(GB)"><el-input-number v-model="volNewSize" :min="volResizeTarget?.size || 1" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="新大小">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="volNewSize" :min="volResizeTarget?.size || 1" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">GB</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="volResizeVisible = false">取消</el-button>
|
||||
@@ -394,8 +521,8 @@
|
||||
<!-- 创建组网 -->
|
||||
<el-dialog v-model="networkingCreateVisible" title="创建组网" width="440px" destroy-on-close>
|
||||
<el-form :model="networkingCreateForm" label-width="90px">
|
||||
<el-form-item label="名称" required><el-input v-model="networkingCreateForm.name" /></el-form-item>
|
||||
<el-form-item label="网桥名称" required><el-input v-model="networkingCreateForm.bridge_name" /></el-form-item>
|
||||
<el-form-item label="名称"><el-input v-model="networkingCreateForm.name" placeholder="不填则随机生成" /></el-form-item>
|
||||
<el-form-item label="网桥名称"><el-input v-model="networkingCreateForm.bridge_name" placeholder="不填则随机生成" /></el-form-item>
|
||||
<el-form-item label="网关"><el-input v-model="networkingCreateForm.gateway" placeholder="可选" /></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="networkingCreateForm.description" /></el-form-item>
|
||||
</el-form>
|
||||
@@ -462,10 +589,31 @@
|
||||
|
||||
<!-- 修改带宽 -->
|
||||
<el-dialog v-model="trafficVisible" title="修改带宽" width="440px" destroy-on-close>
|
||||
<el-form :model="trafficForm" label-width="130px">
|
||||
<el-form-item label="下行带宽(Mbps)"><el-input-number v-model="trafficForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="上行带宽(Mbps)"><el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="流量上限(GB)"><el-input-number v-model="trafficForm._trafficGB" :min="0" :precision="2" controls-position="right" style="width:100%" /></el-form-item>
|
||||
<el-form :model="trafficForm" label-width="100px">
|
||||
<el-form-item label="下行带宽">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="trafficForm.rx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="trafficForm._rxUnit" class="unit-select" style="width:100px">
|
||||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="上行带宽">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="trafficForm._txUnit" class="unit-select" style="width:100px">
|
||||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="流量上限">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="trafficForm._trafficValue" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="trafficForm._trafficUnit" class="unit-select" style="width:100px">
|
||||
<el-option label="MB" value="MB" /><el-option label="GB" value="GB" /><el-option label="TB" value="TB" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="trafficVisible = false">取消</el-button>
|
||||
@@ -602,17 +750,27 @@
|
||||
@confirm="vol => handleMountVolume(vol)" />
|
||||
|
||||
<!-- 编辑虚拟机弹窗(对接 /user_vm/update) -->
|
||||
<el-dialog v-model="editVmVisible" title="编辑虚拟机配置" width="560px" destroy-on-close class="scrollable-dialog">
|
||||
<el-form :model="editVmForm" label-width="130px" v-loading="editVmLoading">
|
||||
<el-dialog v-model="editVmVisible" title="编辑虚拟机配置" width="680px" destroy-on-close class="scrollable-dialog">
|
||||
<el-form :model="editVmForm" label-width="90px" v-loading="editVmLoading">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下行带宽(Mbps)">
|
||||
<el-input-number v-model="editVmForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="下行带宽">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editVmForm.rx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="editVmForm._rxUnit" class="unit-select" style="width:100px">
|
||||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="上行带宽(Mbps)">
|
||||
<el-input-number v-model="editVmForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="上行带宽">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editVmForm.tx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="editVmForm._txUnit" class="unit-select" style="width:100px">
|
||||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -666,30 +824,48 @@
|
||||
@confirm="net => { editVmForm.internet_network_id = net.id; editVmForm._networkName = net.name }" />
|
||||
|
||||
<!-- 重构虚拟机弹窗 -->
|
||||
<el-dialog v-model="refactorVisible" title="重构虚拟机" width="640px" destroy-on-close class="scrollable-dialog">
|
||||
<el-dialog v-model="refactorVisible" title="重构虚拟机" width="720px" destroy-on-close class="scrollable-dialog">
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom:12px">重构会修改虚拟机底层配置,请谨慎操作!</el-alert>
|
||||
<el-form :model="refactorForm" label-width="120px">
|
||||
<el-form :model="refactorForm" label-width="90px">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="内存(MB)">
|
||||
<el-input-number v-model="refactorForm._memoryMB" :min="0" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="内存">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="refactorForm._memoryValue" :min="0" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="refactorForm._memoryUnit" class="unit-select" style="width:85px">
|
||||
<el-option label="MB" value="MB" /><el-option label="GB" value="GB" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="vCPU">
|
||||
<el-input-number v-model="refactorForm.vcpu" :min="0" controls-position="right" style="width:100%" />
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="refactorForm.vcpu" :min="0" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">核</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下行带宽(Mbps)">
|
||||
<el-input-number v-model="refactorForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="下行带宽">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="refactorForm.rx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="refactorForm._rxUnit" class="unit-select" style="width:100px">
|
||||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="上行带宽(Mbps)">
|
||||
<el-input-number v-model="refactorForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="上行带宽">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="refactorForm.tx_bandwidth" :min="0" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="refactorForm._txUnit" class="unit-select" style="width:100px">
|
||||
<el-option label="Mbps" value="Mbps" /><el-option label="Gbps" value="Gbps" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -732,14 +908,24 @@
|
||||
<UserVmNetworkSelector v-model="showRefactorNetSelector" :user-goods-id="userGoodsId"
|
||||
@confirm="net => { refactorForm.internet_network_id = net.id; refactorForm._networkName = net.name }" />
|
||||
|
||||
<!-- 绑定网络选择器 -->
|
||||
<UserVmNetworkSelector v-model="showBindNetworkSelector" :user-goods-id="userGoodsId"
|
||||
:show-create-button="false" @confirm="handleBindNetworkConfirm" />
|
||||
|
||||
<!-- 编辑商品信息弹窗 --> <el-dialog v-model="editGoodsVisible" title="编辑商品信息" width="480px" destroy-on-close>
|
||||
<el-form :model="editGoodsForm" label-width="110px">
|
||||
<el-form-item label="备注"><el-input v-model="editGoodsForm.note" /></el-form-item>
|
||||
<el-form-item label="续费价格(元)">
|
||||
<el-input-number v-model="editGoodsForm.renew_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="续费价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editGoodsForm.renew_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="基础价格(元)">
|
||||
<el-input-number v-model="editGoodsForm.base_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<el-form-item label="基础价格">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="editGoodsForm.base_price" :min="0" :precision="2" controls-position="right" style="flex:1" />
|
||||
<span class="unit-text">元</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="到期时间">
|
||||
<el-date-picker v-model="editGoodsForm.expire_time" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width:100%" />
|
||||
@@ -773,10 +959,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, onActivated, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled } from '@element-plus/icons-vue'
|
||||
import { ArrowLeft, Refresh, ArrowDown, Monitor, WarningFilled, View, Hide, CopyDocument } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getUserVmDetail, getUserVmVnc, getUserVmHostImages,
|
||||
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
|
||||
@@ -788,7 +974,8 @@ import {
|
||||
createUserVmPostGroupRule, updateUserVmPostGroupRule, deleteUserVmPostGroupRule,
|
||||
getUserVmPostGroupDetail,
|
||||
getUserVmNetworkList, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking,
|
||||
getUserGoodsDetail
|
||||
getUserGoodsDetail,
|
||||
getUserVmMetricsHistory
|
||||
} from '@/api/admin/userVm'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool'
|
||||
@@ -797,10 +984,11 @@ import UserVmSecurityGroupSelector from '@/components/admin/UserVmSecurityGroupS
|
||||
import UserVmVolumeSelector from '@/components/admin/UserVmVolumeSelector.vue'
|
||||
import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue'
|
||||
import dayjs from 'dayjs'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userGoodsId = computed(() => parseInt(route.query.id) || 0)
|
||||
const userGoodsId = ref(parseInt(route.query.id) || 0)
|
||||
|
||||
const loading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
@@ -814,7 +1002,39 @@ const vmVolumes = ref([])
|
||||
const vmImage = ref(null)
|
||||
const inPortGroup = ref(null)
|
||||
const outPortGroup = ref(null)
|
||||
const isVmGoods = ref(false) // 是否为虚拟机类型商品
|
||||
const isVmGoods = ref(false)
|
||||
const showPassword = ref(false)
|
||||
|
||||
const copyPassword = async () => {
|
||||
const pwd = vm.value?.root_password
|
||||
if (!pwd) { ElMessage.warning('暂无密码'); return }
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(pwd)
|
||||
} else {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = pwd
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px;opacity:0'
|
||||
document.body.appendChild(ta)
|
||||
ta.focus()
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
ElMessage.success('密码已复制到剪贴板')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
const isWindows = computed(() => vmImage.value?.os_type === 'windows')
|
||||
|
||||
const vmPublicIpList = computed(() => {
|
||||
return vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.address ? n.address.split('/')[0] : n.name).filter(Boolean)
|
||||
})
|
||||
const vmPrivateIpList = computed(() => {
|
||||
return vmNetworks.value.filter(n => n.type === 'nat').map(n => n.address ? n.address.split('/')[0] : n.name).filter(Boolean)
|
||||
})
|
||||
|
||||
// 安全组列表(来自详情接口,入站+出站)
|
||||
const inPortGroupList = computed(() => {
|
||||
@@ -906,6 +1126,7 @@ const handleTabChange = (tab) => {
|
||||
if (tab === 'backup') { loadBackups(); loadBackupQuota() }
|
||||
if (tab === 'security') loadSgLockInfo()
|
||||
if (tab === 'networking') loadNetworkings()
|
||||
if (tab === 'monitor' && !metricsData.value) loadMetricsHistory()
|
||||
}
|
||||
|
||||
// 请求安全组详情补充 lock 字段(使用用户虚拟机安全组详情接口)
|
||||
@@ -935,6 +1156,7 @@ const handleVnc = async () => {
|
||||
else ElMessage.error(extractApiError(res?.data, '获取VNC失败'))
|
||||
} catch { /* */ } finally { vncLoading.value = false }
|
||||
}
|
||||
const openVncUrl = () => { if (vncResult.value?.url) window.open(vncResult.value.url, '_blank') }
|
||||
|
||||
// ---- 电源 ----
|
||||
const powerVisible = ref(false)
|
||||
@@ -955,7 +1177,7 @@ const submitPower = async () => {
|
||||
const handleMoreCmd = (cmd) => {
|
||||
if (powerLabels[cmd]) { handlePower(cmd); return }
|
||||
if (cmd === 'rebuild') { rebuildImageId.value = 0; rebuildImageName.value = ''; rebuildImages.value = []; rebuildVisible.value = true; loadRebuildImages() }
|
||||
if (cmd === 'updateTraffic') { Object.assign(trafficForm, { rx_bandwidth: vm.value?.rx_bandwidth || 0, tx_bandwidth: vm.value?.tx_bandwidth || 0, _trafficGB: ((vm.value?.traffic_max || 0) / 1024).toFixed(2) * 1 }); trafficVisible.value = true }
|
||||
if (cmd === 'updateTraffic') { Object.assign(trafficForm, { rx_bandwidth: vm.value?.rx_bandwidth || 0, tx_bandwidth: vm.value?.tx_bandwidth || 0, _trafficValue: +((vm.value?.traffic_max || 0) / 1024).toFixed(2), _rxUnit: 'Mbps', _txUnit: 'Mbps', _trafficUnit: 'GB' }); trafficVisible.value = true }
|
||||
if (cmd === 'transfer') { Object.assign(transferForm, { target_user_id: 0, _userName: '' }); transferVisible.value = true }
|
||||
if (cmd === 'updateVm') openEditVm()
|
||||
if (cmd === 'refactorVm') openRefactorVm()
|
||||
@@ -1282,6 +1504,45 @@ const loadNetworks = async () => {
|
||||
} catch { /* */ } finally { networkLoading.value = false }
|
||||
}
|
||||
|
||||
// ---- 绑定网络 ----
|
||||
const showBindNetworkSelector = ref(false)
|
||||
|
||||
const handleBindNetworkConfirm = async (selectedNetwork) => {
|
||||
const existingIds = vmNetworks.value.map(n => n.id)
|
||||
if (existingIds.includes(selectedNetwork.id)) {
|
||||
ElMessage.warning('该网络已绑定')
|
||||
return
|
||||
}
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const payload = { user_goods_id: userGoodsId.value }
|
||||
const bridgeIds = vmNetworks.value.filter(n => n.type === 'bridge').map(n => n.id)
|
||||
const natNet = vmNetworks.value.find(n => n.type === 'nat')
|
||||
if (selectedNetwork.type === 'nat') {
|
||||
payload.internet_network_id = selectedNetwork.id
|
||||
if (bridgeIds.length) payload.network_ids = bridgeIds
|
||||
} else {
|
||||
payload.network_ids = [...bridgeIds, selectedNetwork.id]
|
||||
if (natNet) payload.internet_network_id = natNet.id
|
||||
}
|
||||
if (vm.value?.rx_bandwidth) payload.rx_bandwidth = vm.value.rx_bandwidth
|
||||
if (vm.value?.tx_bandwidth) payload.tx_bandwidth = vm.value.tx_bandwidth
|
||||
if (vm.value?.ssh_port) payload.ssh_port = vm.value.ssh_port
|
||||
if (inPortGroup.value?.id) payload.port_group_id = inPortGroup.value.id
|
||||
const res = await updateUserVm(payload)
|
||||
if (res?.data?.code === 200) {
|
||||
ElMessage.success('绑定网络成功')
|
||||
loadDetail()
|
||||
} else {
|
||||
ElMessage.error(extractApiError(res?.data, '绑定网络失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e?.response?.data, '绑定网络失败'))
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 组网 ----
|
||||
const networkings = ref([])
|
||||
const networkingLoading = ref(false)
|
||||
@@ -1318,7 +1579,7 @@ const loadNetworkings = async () => {
|
||||
}
|
||||
const handleCreateNetworking = () => { Object.assign(networkingCreateForm, { name: '', bridge_name: '', gateway: '', description: '' }); networkingCreateVisible.value = true }
|
||||
const submitCreateNetworking = async () => {
|
||||
if (!networkingCreateForm.name || !networkingCreateForm.bridge_name) { ElMessage.warning('请填写名称和网桥名称'); return }
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await createUserVmNetworking({ user_goods_id: userGoodsId.value, ...networkingCreateForm })
|
||||
@@ -1463,7 +1724,8 @@ const refactorVisible = ref(false)
|
||||
const showRefactorSgSelector = ref(false)
|
||||
const showRefactorNetSelector = ref(false)
|
||||
const refactorForm = reactive({
|
||||
_memoryMB: 0, vcpu: 0, rx_bandwidth: 0, tx_bandwidth: 0,
|
||||
_memoryValue: 0, _memoryUnit: 'MB', vcpu: 0,
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, _rxUnit: 'Mbps', _txUnit: 'Mbps',
|
||||
root_password: '', uuid: '', mate_data_id: '', physical_name: '', config_path: '',
|
||||
ssh_port: 0, vnc_port: 0, vnc_password: '',
|
||||
port_group_id: 0, _sgName: '', internet_network_id: 0, _networkName: ''
|
||||
@@ -1471,10 +1733,12 @@ const refactorForm = reactive({
|
||||
|
||||
const openRefactorVm = () => {
|
||||
Object.assign(refactorForm, {
|
||||
_memoryMB: vm.value ? Math.round((vm.value.memory || 0) / 1024) : 0,
|
||||
_memoryValue: vm.value ? Math.round((vm.value.memory || 0) / 1024) : 0,
|
||||
_memoryUnit: 'MB',
|
||||
vcpu: vm.value?.vcpu || 0,
|
||||
rx_bandwidth: vm.value?.rx_bandwidth || 0,
|
||||
tx_bandwidth: vm.value?.tx_bandwidth || 0,
|
||||
_rxUnit: 'Mbps', _txUnit: 'Mbps',
|
||||
root_password: '', uuid: vm.value?.uuid || '',
|
||||
mate_data_id: vm.value?.mate_data_id || '',
|
||||
physical_name: '', config_path: '',
|
||||
@@ -1491,10 +1755,15 @@ const submitRefactorVm = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const payload = { user_goods_id: userGoodsId.value }
|
||||
if (refactorForm._memoryMB) payload.memory = Math.round(refactorForm._memoryMB * 1024) // MB → KB
|
||||
if (refactorForm._memoryValue) {
|
||||
const memKB = refactorForm._memoryUnit === 'GB' ? refactorForm._memoryValue * 1024 * 1024 : refactorForm._memoryValue * 1024
|
||||
payload.memory = Math.round(memKB)
|
||||
}
|
||||
if (refactorForm.vcpu) payload.vcpu = refactorForm.vcpu
|
||||
if (refactorForm.rx_bandwidth) payload.rx_bandwidth = refactorForm.rx_bandwidth
|
||||
if (refactorForm.tx_bandwidth) payload.tx_bandwidth = refactorForm.tx_bandwidth
|
||||
const refRx = refactorForm._rxUnit === 'Gbps' ? refactorForm.rx_bandwidth * 1000 : refactorForm.rx_bandwidth
|
||||
const refTx = refactorForm._txUnit === 'Gbps' ? refactorForm.tx_bandwidth * 1000 : refactorForm.tx_bandwidth
|
||||
if (refRx) payload.rx_bandwidth = Math.round(refRx)
|
||||
if (refTx) payload.tx_bandwidth = Math.round(refTx)
|
||||
if (refactorForm.root_password) payload.root_password = refactorForm.root_password
|
||||
if (refactorForm.uuid) payload.uuid = refactorForm.uuid
|
||||
if (refactorForm.mate_data_id) payload.mate_data_id = refactorForm.mate_data_id
|
||||
@@ -1518,6 +1787,7 @@ const showEditVmSgSelector = ref(false)
|
||||
const showEditVmNetSelector = ref(false)
|
||||
const editVmForm = reactive({
|
||||
rx_bandwidth: 0, tx_bandwidth: 0,
|
||||
_rxUnit: 'Mbps', _txUnit: 'Mbps',
|
||||
root_password: '',
|
||||
ssh_port: 22,
|
||||
port_group_id: 0, _sgName: '',
|
||||
@@ -1529,6 +1799,7 @@ const openEditVm = async () => {
|
||||
Object.assign(editVmForm, {
|
||||
rx_bandwidth: vm.value?.rx_bandwidth || 0,
|
||||
tx_bandwidth: vm.value?.tx_bandwidth || 0,
|
||||
_rxUnit: 'Mbps', _txUnit: 'Mbps',
|
||||
root_password: '',
|
||||
ssh_port: vm.value?.ssh_port || 22,
|
||||
port_group_id: inPortGroup.value?.id || 0,
|
||||
@@ -1547,8 +1818,10 @@ const submitEditVm = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const payload = { user_goods_id: userGoodsId.value }
|
||||
if (editVmForm.rx_bandwidth) payload.rx_bandwidth = editVmForm.rx_bandwidth
|
||||
if (editVmForm.tx_bandwidth) payload.tx_bandwidth = editVmForm.tx_bandwidth
|
||||
const editRx = editVmForm._rxUnit === 'Gbps' ? editVmForm.rx_bandwidth * 1000 : editVmForm.rx_bandwidth
|
||||
const editTx = editVmForm._txUnit === 'Gbps' ? editVmForm.tx_bandwidth * 1000 : editVmForm.tx_bandwidth
|
||||
if (editRx) payload.rx_bandwidth = Math.round(editRx)
|
||||
if (editTx) payload.tx_bandwidth = Math.round(editTx)
|
||||
if (editVmForm.root_password) payload.root_password = editVmForm.root_password
|
||||
if (editVmForm.ssh_port) payload.ssh_port = editVmForm.ssh_port
|
||||
if (editVmForm.port_group_id) payload.port_group_id = editVmForm.port_group_id
|
||||
@@ -1563,15 +1836,20 @@ const submitEditVm = async () => {
|
||||
|
||||
// ---- 修改带宽 ----
|
||||
const trafficVisible = ref(false)
|
||||
const trafficForm = reactive({ rx_bandwidth: 0, tx_bandwidth: 0, _trafficGB: 0 })
|
||||
const trafficForm = reactive({ rx_bandwidth: 0, tx_bandwidth: 0, _trafficValue: 0, _rxUnit: 'Mbps', _txUnit: 'Mbps', _trafficUnit: 'GB' })
|
||||
const submitUpdateTraffic = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const rxBw = trafficForm._rxUnit === 'Gbps' ? trafficForm.rx_bandwidth * 1000 : trafficForm.rx_bandwidth
|
||||
const txBw = trafficForm._txUnit === 'Gbps' ? trafficForm.tx_bandwidth * 1000 : trafficForm.tx_bandwidth
|
||||
const trafficMb = trafficForm._trafficUnit === 'TB' ? (trafficForm._trafficValue || 0) * 1024 * 1024
|
||||
: trafficForm._trafficUnit === 'GB' ? (trafficForm._trafficValue || 0) * 1024
|
||||
: (trafficForm._trafficValue || 0)
|
||||
const payload = {
|
||||
user_goods_id: userGoodsId.value,
|
||||
rx_bandwidth: trafficForm.rx_bandwidth,
|
||||
tx_bandwidth: trafficForm.tx_bandwidth,
|
||||
traffic_max: Math.round((trafficForm._trafficGB || 0) * 1024) // GB → Mb
|
||||
rx_bandwidth: Math.round(rxBw),
|
||||
tx_bandwidth: Math.round(txBw),
|
||||
traffic_max: Math.round(trafficMb)
|
||||
}
|
||||
const res = await updateUserVmTraffic(payload)
|
||||
if (res?.data?.code === 200) { ElMessage.success('修改成功'); trafficVisible.value = false; loadDetail() }
|
||||
@@ -1627,12 +1905,179 @@ const submitEditGoods = async () => {
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '保存失败')) } finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDetail()
|
||||
// ---- 监控指标 ----
|
||||
const cpuChartRef = ref(null)
|
||||
const memChartRef = ref(null)
|
||||
const diskChartRef = ref(null)
|
||||
const netChartRef = ref(null)
|
||||
let cpuChart = null
|
||||
let memChart = null
|
||||
let diskChart = null
|
||||
let netChart = null
|
||||
|
||||
const metricsData = ref(null)
|
||||
const metricsLoading = ref(false)
|
||||
const monitorInterval = ref('1m')
|
||||
|
||||
const latestMetrics = computed(() => {
|
||||
const arr = metricsData.value
|
||||
if (!Array.isArray(arr) || !arr.length) return null
|
||||
return arr[arr.length - 1]
|
||||
})
|
||||
|
||||
watch(userGoodsId, (newId, oldId) => {
|
||||
if (newId && newId !== oldId) {
|
||||
const formatBytesRaw = (val) => {
|
||||
if (!val && val !== 0) return '-'
|
||||
val = Number(val)
|
||||
if (val >= 1099511627776) return (val / 1099511627776).toFixed(2) + ' TB'
|
||||
if (val >= 1073741824) return (val / 1073741824).toFixed(2) + ' GB'
|
||||
if (val >= 1048576) return (val / 1048576).toFixed(2) + ' MB'
|
||||
if (val >= 1024) return (val / 1024).toFixed(1) + ' KB'
|
||||
return val + ' B'
|
||||
}
|
||||
|
||||
const formatNetLabel = (v) => {
|
||||
if (!v) return '0 B/s'
|
||||
v = Number(v)
|
||||
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
|
||||
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
|
||||
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
|
||||
return v + ' B/s'
|
||||
}
|
||||
|
||||
const formatMemKB = (kb) => {
|
||||
if (!kb && kb !== 0) return '-'
|
||||
kb = Number(kb)
|
||||
if (kb >= 1073741824) return (kb / 1073741824).toFixed(1) + ' TB'
|
||||
if (kb >= 1048576) return (kb / 1048576).toFixed(1) + ' GB'
|
||||
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
|
||||
return kb + ' KB'
|
||||
}
|
||||
|
||||
const vmMemPercent = (m) => {
|
||||
if (!m || !m.mem_total) return '0.0'
|
||||
return ((m.mem_used / m.mem_total) * 100).toFixed(1)
|
||||
}
|
||||
|
||||
const loadMetricsHistory = async () => {
|
||||
if (!userGoodsId.value) return
|
||||
metricsLoading.value = true
|
||||
try {
|
||||
const now = new Date()
|
||||
let startTime = new Date(now)
|
||||
const interval = monitorInterval.value
|
||||
switch (interval) {
|
||||
case '1m': startTime.setHours(now.getHours() - 1); break
|
||||
case '5m': startTime.setHours(now.getHours() - 6); break
|
||||
case '1h': startTime.setDate(now.getDate() - 1); break
|
||||
}
|
||||
const params = {
|
||||
user_goods_id: userGoodsId.value,
|
||||
start: startTime.toISOString(),
|
||||
end_time: now.toISOString(),
|
||||
interval
|
||||
}
|
||||
const res = await getUserVmMetricsHistory(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
metricsData.value = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
||||
await nextTick()
|
||||
renderMetricsCharts()
|
||||
} else {
|
||||
ElMessage.error(extractApiError(body, '加载监控数据失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e?.response?.data, '加载监控数据失败'))
|
||||
} finally {
|
||||
metricsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const renderMetricsCharts = () => {
|
||||
const metrics = metricsData.value
|
||||
if (!Array.isArray(metrics) || !metrics.length) return
|
||||
|
||||
const showDate = monitorInterval.value === '1h'
|
||||
const labelRotate = showDate ? 45 : 0
|
||||
|
||||
const times = metrics.map(m => {
|
||||
const date = new Date(m.bucket)
|
||||
if (showDate) return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
|
||||
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
|
||||
const memData = metrics.map(m => m.mem_total ? ((m.mem_used / m.mem_total) * 100) : 0)
|
||||
const diskReadData = metrics.map(m => m.disk_read ?? 0)
|
||||
const diskWriteData = metrics.map(m => m.disk_write ?? 0)
|
||||
const netRxData = metrics.map(m => m.net_rx ?? 0)
|
||||
const netTxData = metrics.map(m => m.net_tx ?? 0)
|
||||
|
||||
const baseGrid = { top: 10, right: 16, bottom: showDate ? 40 : 24, left: 50 }
|
||||
const makeXAxis = () => ({ type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10, rotate: labelRotate } })
|
||||
const makeSeries = (name, data, color) => ({ name, type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color }, itemStyle: { color }, data })
|
||||
|
||||
if (cpuChartRef.value) {
|
||||
if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value)
|
||||
cpuChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} CPU: ${p[0].value.toFixed(1)}%` },
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
|
||||
series: [makeSeries('CPU', cpuData, '#409eff')]
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (memChartRef.value) {
|
||||
if (!memChart) memChart = echarts.init(memChartRef.value)
|
||||
memChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` },
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
|
||||
series: [makeSeries('内存', memData, '#67c23a')]
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (diskChartRef.value) {
|
||||
if (!diskChart) diskChart = echarts.init(diskChartRef.value)
|
||||
diskChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||||
let s = params[0].axisValue
|
||||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatBytesRaw(p.value)}` })
|
||||
return s
|
||||
}},
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatBytesRaw(v) } },
|
||||
series: [makeSeries('读取', diskReadData, '#409eff'), makeSeries('写入', diskWriteData, '#e6a23c')]
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (netChartRef.value) {
|
||||
if (!netChart) netChart = echarts.init(netChartRef.value)
|
||||
netChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||||
let s = params[0].axisValue
|
||||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
|
||||
return s
|
||||
}},
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: v => formatNetLabel(v) } },
|
||||
series: [makeSeries('接收', netRxData, '#409eff'), makeSeries('发送', netTxData, '#e6a23c')]
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
const disposeCharts = () => {
|
||||
cpuChart?.dispose(); cpuChart = null
|
||||
memChart?.dispose(); memChart = null
|
||||
diskChart?.dispose(); diskChart = null
|
||||
netChart?.dispose(); netChart = null
|
||||
}
|
||||
|
||||
onMounted(() => { loadDetail() })
|
||||
|
||||
onActivated(() => {
|
||||
const newId = parseInt(route.query.id) || 0
|
||||
if (newId && newId !== userGoodsId.value) {
|
||||
userGoodsId.value = newId
|
||||
userGoods.value = null
|
||||
vm.value = null
|
||||
vmNetworks.value = []
|
||||
@@ -1640,9 +2085,13 @@ watch(userGoodsId, (newId, oldId) => {
|
||||
inPortGroup.value = null
|
||||
outPortGroup.value = null
|
||||
isVmGoods.value = false
|
||||
metricsData.value = null
|
||||
disposeCharts()
|
||||
loadDetail()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { disposeCharts() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1661,9 +2110,7 @@ watch(userGoodsId, (newId, oldId) => {
|
||||
.overview-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tabs-card { }
|
||||
.tab-toolbar { display: flex; gap: 8px; align-items: center; margin: 12px 0; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.selector-row { display: flex; align-items: center; width: 100%; }
|
||||
.mono-text { font-family: 'Cascadia Code', Consolas, monospace; font-size: 12px; }
|
||||
|
||||
/* VM 配置网格 */
|
||||
.vm-config-grid { border: 1px solid #e8e8e8; border-radius: 4px; overflow: hidden; }
|
||||
@@ -1673,4 +2120,29 @@ watch(userGoodsId, (newId, oldId) => {
|
||||
.config-cell:last-child { border-right: none; }
|
||||
.config-label { font-size: 12px; color: #86909c; line-height: 1; }
|
||||
.config-value { font-size: 13px; color: #1d2129; line-height: 1.4; word-break: break-all; }
|
||||
.pwd-value { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.pwd-text { font-family: 'Consolas', 'Monaco', monospace; font-size: 13px; background: #f5f7fa; padding: 2px 8px; border-radius: 3px; letter-spacing: .5px; user-select: all; }
|
||||
.pwd-btn { padding: 0 !important; height: auto !important; min-height: auto !important; }
|
||||
|
||||
/* 单位输入行 */
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-select :deep(.el-input__wrapper) { padding: 0 8px; }
|
||||
.unit-text { flex-shrink: 0; font-size: 13px; color: #606266; padding: 0 4px; min-width: 36px; text-align: center; line-height: 32px; background: #f5f7fa; border: 1px solid #dcdfe6; border-radius: 4px; }
|
||||
|
||||
/* 监控指标 */
|
||||
.section-block { padding: 0 4px; }
|
||||
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.section-title { margin: 0; font-size: 15px; font-weight: 600; color: #303133; }
|
||||
.metrics-card { margin-bottom: 0; }
|
||||
.metrics-title { font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||||
.chart-container { width: 100%; height: 220px; }
|
||||
.metric-summary-row { display: flex; gap: 16px; margin-bottom: 16px; }
|
||||
.metric-summary-card { flex: 1; min-width: 0; background: #f7f8fa; border-radius: 6px; padding: 14px 16px; border: 1px solid #e8e8e8; display: flex; flex-direction: column; }
|
||||
.metric-summary-label { font-size: 12px; color: #86909c; margin-bottom: 8px; }
|
||||
.metric-summary-value { font-size: 22px; font-weight: 600; color: #1d2129; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.metric-summary-sub { font-size: 12px; color: #86909c; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ip-popover-list { max-height: 200px; overflow-y: auto; }
|
||||
.ip-popover-item { padding: 4px 0; font-size: 13px; color: #303133; border-bottom: 1px dashed #ebeef5; word-break: break-all; }
|
||||
.ip-popover-item:last-child { border-bottom: none; }
|
||||
</style>
|
||||
|
||||
+1452
-221
File diff suppressed because it is too large
Load Diff
+15
-54
@@ -17,12 +17,15 @@
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<span class="search-label">用户ID</span>
|
||||
<el-input
|
||||
v-model="jumpUserId"
|
||||
placeholder="输入ID跳转"
|
||||
clearable
|
||||
<el-input
|
||||
:model-value="jumpUserName || (jumpUserId ? jumpUserId : '')"
|
||||
placeholder="输入ID跳转"
|
||||
readonly
|
||||
clearable
|
||||
class="search-input-small"
|
||||
@keyup.enter="handleJumpToUser"
|
||||
style="cursor:pointer"
|
||||
@click="showJumpUserSelector = true"
|
||||
@clear="jumpUserId = ''; jumpUserName = ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="search-buttons">
|
||||
@@ -331,6 +334,11 @@
|
||||
:current-user-id="userForm.recommend_id"
|
||||
@confirm="handleRecommendUserConfirm"
|
||||
/>
|
||||
<!-- 筛选用户ID选择器 -->
|
||||
<UserListSelector
|
||||
v-model="showJumpUserSelector"
|
||||
@confirm="u => { jumpUserId = String(u.user_id); jumpUserName = u.user_name || `用户 #${u.user_id}` }"
|
||||
/>
|
||||
|
||||
<!-- 修改头像对话框 -->
|
||||
<el-dialog
|
||||
@@ -655,6 +663,8 @@ const router = useRouter()
|
||||
|
||||
// 跳转用户ID
|
||||
const jumpUserId = ref('')
|
||||
const jumpUserName = ref('')
|
||||
const showJumpUserSelector = ref(false)
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
@@ -1946,55 +1956,6 @@ onMounted(() => {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
:deep(.el-table) {
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
:deep(.el-table__header) {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
background: #f8f9fa !important;
|
||||
border-bottom: 2px solid #e1e8ed;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.el-table td) {
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
color: #34495e;
|
||||
}
|
||||
|
||||
:deep(.el-table tr:hover > td) {
|
||||
background-color: #f8f9fa !important;
|
||||
}
|
||||
|
||||
/* 固定列阴影效果 */
|
||||
:deep(.el-table__fixed) {
|
||||
box-shadow: 4px 0 8px -4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
:deep(.el-table__fixed-right) {
|
||||
box-shadow: -4px 0 8px -4px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* 表格滚动条样式 */
|
||||
:deep(.el-scrollbar__bar.is-horizontal) {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
background-color: #c0c4cc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__thumb:hover) {
|
||||
background-color: #909399;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ const vmOptionsLoading = ref(false)
|
||||
const loadVmOptions = async () => {
|
||||
vmOptionsLoading.value = true
|
||||
try {
|
||||
const res = await getVmList({ service_id: serviceId.value, page: 1, page_size: 10 })
|
||||
const res = await getVmList({ service_id: serviceId.value, page: 1, count: 10 })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
vmOptions.value = inner.vms || inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||
@@ -230,6 +230,5 @@ defineExpose({ loadList })
|
||||
|
||||
<style scoped>
|
||||
.backup-manage { padding: 0; }
|
||||
.toolbar { display: flex; gap: 8px; margin-top: 12px; margin-bottom: 16px; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.toolbar { margin-top: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<h2 class="instance-name">{{ detail.name || '-' }} <span class="instance-id">{{ detail.id }}</span></h2>
|
||||
</div>
|
||||
<div class="overview-actions">
|
||||
<!-- <el-button type="warning" plain @click="openTokenDialog"><el-icon><Key /></el-icon>创建注册令牌</el-button> -->
|
||||
<el-button type="primary" plain @click="handleEdit">编辑宿主机</el-button>
|
||||
<el-button type="danger" plain @click="handleDelete">删除</el-button>
|
||||
</div>
|
||||
@@ -135,51 +136,69 @@
|
||||
<el-tab-pane label="监控" name="monitor">
|
||||
<div class="section-block">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">实时指标</h3>
|
||||
<h3 class="section-title">监控指标</h3>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<el-tag v-if="pollingActive" type="success" size="small" effect="plain">自动刷新中</el-tag>
|
||||
<el-button size="small" :icon="Refresh" @click="loadMetrics" :loading="metricsLoading">刷新指标</el-button>
|
||||
<el-select v-model="historyTimeRange" size="small" style="width: 120px" @change="loadHistoricalMetrics">
|
||||
<el-option v-for="option in historyTimeOptions" :key="option.value" :label="option.label" :value="option.value" />
|
||||
</el-select>
|
||||
<el-button size="small" :icon="Refresh" @click="loadHistoricalMetrics" :loading="historicalMetricsLoading">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="metricsData">
|
||||
<template v-if="latestMetrics">
|
||||
<div class="metric-summary-row">
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">CPU 使用率</div>
|
||||
<div class="metric-summary-value">{{ latestMetrics.cpu_usage?.toFixed(1) }}%</div>
|
||||
<div class="metric-summary-sub">{{ latestMetrics.cpu_count }} 核</div>
|
||||
</div>
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">内存使用率</div>
|
||||
<div class="metric-summary-value">{{ latestMetrics.mem_percent?.toFixed(1) }}%</div>
|
||||
<div class="metric-summary-sub">{{ formatBytesRaw(latestMetrics.mem_used) }} / {{ formatBytesRaw(latestMetrics.mem_total) }}</div>
|
||||
</div>
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">公网流量</div>
|
||||
<div class="metric-summary-value">↓{{ formatNetLabel(latestMetrics.inet_rx) }}</div>
|
||||
<div class="metric-summary-sub">↑{{ formatNetLabel(latestMetrics.inet_tx) }}</div>
|
||||
</div>
|
||||
<div class="metric-summary-card">
|
||||
<div class="metric-summary-label">内网流量</div>
|
||||
<div class="metric-summary-value">↓{{ formatBytesRaw(latestMetrics.net_rx) }}</div>
|
||||
<div class="metric-summary-sub">↑{{ formatBytesRaw(latestMetrics.net_tx) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="historicalMetricsData">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12" v-if="metricsData.cpu">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU 使用率 {{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}% ({{ metricsData.cpu.cpu_count ?? '-' }}核)</span></template>
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU 使用率</span></template>
|
||||
<div ref="cpuChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="metricsData.memory">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存 {{ formatBytesRaw(metricsData.memory.used) }} / {{ formatBytesRaw(metricsData.memory.total) }} ({{ metricsData.memory.percent ?? 0 }}%)</span></template>
|
||||
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存使用率</span></template>
|
||||
<div ref="memChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16" style="margin-top: 16px">
|
||||
<el-col :span="12" v-if="metricsData.disk">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Box /></el-icon> 磁盘</span></template>
|
||||
<div v-for="(info, path) in metricsData.disk" :key="path" class="disk-item">
|
||||
<div class="disk-path">{{ path }}</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(info.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(info.used) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(info.free) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ info.percent ?? '-' }}%</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 公网流量</span></template>
|
||||
<div ref="inetChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="12" v-if="metricsData.network || metricsData.internet_speed">
|
||||
<el-col :span="12">
|
||||
<el-card shadow="hover" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
|
||||
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 内网流量</span></template>
|
||||
<div ref="netChartRef" class="chart-container"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
<el-empty v-else description="加载指标数据中..." />
|
||||
<el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -287,45 +306,55 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建组网弹窗 -->
|
||||
<el-dialog v-model="nwCreateVisible" title="创建组网" width="480px" destroy-on-close>
|
||||
<el-dialog v-model="nwCreateVisible" title="创建组网" width="480px" destroy-on-close class="tk-dialog">
|
||||
<el-form ref="nwCreateFormRef" :model="nwCreateForm" :rules="nwCreateRules" label-width="100px">
|
||||
<el-form-item label="用户" prop="user_id">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="nwCreateForm.user_id ? `${nwCreateUserName} (ID: ${nwCreateForm.user_id})` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showNwUserSelector = true">选择</el-button>
|
||||
<el-button v-if="nwCreateForm.user_id" @click="nwCreateForm.user_id = 0; nwCreateUserName = ''">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="网桥名称">
|
||||
<el-input v-model="nwCreateForm.bridge_name" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关">
|
||||
<el-input v-model="nwCreateForm.gateway" placeholder="可选,如 10.0.0.1" />
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">组网信息</div>
|
||||
<el-form-item label="用户" prop="user_id">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="nwCreateForm.user_id ? `${nwCreateUserName} (ID: ${nwCreateForm.user_id})` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showNwUserSelector = true">选择</el-button>
|
||||
<el-button v-if="nwCreateForm.user_id" @click="nwCreateForm.user_id = 0; nwCreateUserName = ''">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="网桥名称">
|
||||
<el-input v-model="nwCreateForm.bridge_name" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关">
|
||||
<el-input v-model="nwCreateForm.gateway" placeholder="可选,如 10.0.0.1" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="nwCreateVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwCreate">创建</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="nwCreateVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwCreate">创建</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 分配IP弹窗 -->
|
||||
<el-dialog v-model="nwAssignVisible" title="为虚拟机分配组网IP" width="480px" destroy-on-close>
|
||||
<el-dialog v-model="nwAssignVisible" title="为虚拟机分配组网IP" width="480px" destroy-on-close class="tk-dialog">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="组网">{{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }})</el-form-item>
|
||||
<el-form-item label="虚拟机" required>
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="nwAssignVmId ? `${nwAssignVmName} (ID: ${nwAssignVmId})` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showNwVmSelector = true">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="指定IP">
|
||||
<el-input v-model="nwAssignIp" placeholder="留空自动分配" />
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">分配信息</div>
|
||||
<el-form-item label="组网">{{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }})</el-form-item>
|
||||
<el-form-item label="虚拟机" required>
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="nwAssignVmId ? `${nwAssignVmName} (ID: ${nwAssignVmId})` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showNwVmSelector = true">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="指定IP">
|
||||
<el-input v-model="nwAssignIp" placeholder="留空自动分配" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="nwAssignVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwAssign" :disabled="!nwAssignVmId">分配</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="nwAssignVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwAssign" :disabled="!nwAssignVmId">分配</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -334,61 +363,163 @@
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog v-model="editDialogVisible" title="编辑宿主机" width="890px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
|
||||
<el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item>
|
||||
<el-form-item label="服务地址" prop="base_url"><el-input v-model="formData.base_url" /></el-form-item>
|
||||
<el-form-item label="IP 地址" prop="ip"><el-input v-model="formData.ip" /></el-form-item>
|
||||
<el-form-item label="认证Token"><el-input v-model="formData.token" show-password /></el-form-item>
|
||||
<el-divider content-position="left">SSH 配置</el-divider>
|
||||
<el-form-item label="SSH 端口"><el-input-number v-model="formData.port" :min="0" :max="65535" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="SSH 用户名"><el-input v-model="formData.user" /></el-form-item>
|
||||
<el-form-item label="SSH 密码"><el-input v-model="formData.password" show-password /></el-form-item>
|
||||
<el-form-item label="私钥"><el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容" /></el-form-item>
|
||||
<el-divider content-position="left">资源限制</el-divider>
|
||||
<el-form-item label="最大CPU(核)"><el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" style="width: 100%" /></el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大内存">
|
||||
<div class="unit-input-row">
|
||||
<el-select v-model="memoryUnit" style="width: 70px; flex-shrink: 0;" size="default">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="wide-number" />
|
||||
</div>
|
||||
<el-dialog v-model="editDialogVisible" title="编辑宿主机" width="890px" destroy-on-close class="tk-dialog">
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item>
|
||||
<el-form-item label="服务地址" prop="base_url"><el-input v-model="formData.base_url" /></el-form-item>
|
||||
<el-form-item label="IP 地址" prop="ip"><el-input v-model="formData.ip" /></el-form-item>
|
||||
<el-form-item label="认证Token"><el-input v-model="formData.token" show-password /></el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">SSH 配置</div>
|
||||
<el-form-item label="端口"><el-input-number v-model="formData.port" :min="0" :max="65535" controls-position="right" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="用户名"><el-input v-model="formData.user" /></el-form-item>
|
||||
<el-form-item label="密码"><el-input v-model="formData.password" show-password /></el-form-item>
|
||||
<el-form-item label="私钥"><el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容" /></el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">资源限制</div>
|
||||
<div class="tk-resource-grid">
|
||||
<el-form-item label="CPU"><el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span></el-form-item>
|
||||
<el-form-item label="内存">
|
||||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="memoryUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大磁盘">
|
||||
<div class="unit-input-row">
|
||||
<el-select v-model="diskUnit" style="width: 70px; flex-shrink: 0;" size="default">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" class="wide-number" />
|
||||
</div>
|
||||
<el-form-item label="磁盘">
|
||||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="diskUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12"><el-form-item label="下行带宽(Mbps)"><el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="上行带宽(Mbps)"><el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" /></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-form-item label="宿主机组">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showGroupSelector = true">选择</el-button>
|
||||
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0">清除</el-button>
|
||||
<el-form-item label="下行带宽"><el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span></el-form-item>
|
||||
<el-form-item label="上行带宽"><el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span></el-form-item>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">其他配置</div>
|
||||
<el-form-item label="宿主机组">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showGroupSelector = true">选择</el-button>
|
||||
<el-button v-if="formData.host_group_id" @click="formData.host_group_id = 0">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<HostGroupSelectorPopup v-model="showGroupSelector" :service-id="serviceId" :current-id="formData.host_group_id" @confirm="g => formData.host_group_id = g.id" />
|
||||
|
||||
<!-- 创建注册令牌弹窗 -->
|
||||
<el-dialog v-model="tokenDialogVisible" title="创建宿主机注册令牌" width="700px" destroy-on-close class="token-dialog">
|
||||
<el-form ref="tokenFormRef" :model="tokenForm" :rules="tokenRules" label-width="120px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="宿主机名称" prop="name">
|
||||
<el-input v-model="tokenForm.name" placeholder="为该宿主机命名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属宿主机组" prop="host_group_id">
|
||||
<div style="display: flex; gap: 8px; width: 100%">
|
||||
<el-input :model-value="tokenForm.host_group_id ? `宿主机组 #${tokenForm.host_group_id}` : ''" placeholder="请选择宿主机组" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showTokenGroupSelector = true">选择</el-button>
|
||||
<el-button v-if="tokenForm.host_group_id" @click="tokenForm.host_group_id = 0">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机描述">
|
||||
<el-input v-model="tokenForm.description" type="textarea" :rows="2" placeholder="宿主机描述(可选)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">资源配额</div>
|
||||
<div class="tk-resource-grid">
|
||||
<el-form-item label="CPU" prop="max_cpu" class="tk-res-item">
|
||||
<el-input-number v-model="tokenForm.max_cpu" :min="1" controls-position="right" /><span class="tk-res-unit">核</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="内存" prop="max_memory" class="tk-res-item">
|
||||
<el-input-number v-model="tokenMemDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="tokenMemUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="磁盘" prop="max_disk" class="tk-res-item">
|
||||
<el-input-number v-model="tokenDiskDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="tokenDiskUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="下行带宽" class="tk-res-item">
|
||||
<el-input-number v-model="tokenForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="上行带宽" class="tk-res-item">
|
||||
<el-input-number v-model="tokenForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">令牌有效期</div>
|
||||
<el-form-item label="有效期" prop="expire_hours">
|
||||
<el-input-number v-model="tokenForm.expire_hours" :min="1" :max="8760" controls-position="right" style="width: 100%" />
|
||||
<div class="form-hint">单位:小时。默认 24 小时,最大 8760 小时(365天)</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="tokenDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="tokenSubmitLoading" @click="handleTokenSubmit">
|
||||
<el-icon><Key /></el-icon>创建令牌
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 令牌结果弹窗 -->
|
||||
<el-dialog v-model="tokenResultVisible" title="注册令牌已生成" width="560px" :close-on-click-modal="false" class="token-result-dialog">
|
||||
<div class="tk-result-wrapper">
|
||||
<div class="tk-result-header">
|
||||
<el-icon class="tk-result-icon"><Key /></el-icon>
|
||||
<div>
|
||||
<div class="tk-result-name">{{ tokenResultInfo.name }}</div>
|
||||
<div class="tk-result-meta">有效期 {{ tokenResultInfo.expire_hours }} 小时</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">
|
||||
<template #title>请立即复制并保存此令牌,关闭后将无法再次查看</template>
|
||||
</el-alert>
|
||||
<div class="tk-token-block">
|
||||
<div class="tk-token-label">后端地址</div>
|
||||
<div class="tk-token-value">{{ baseUrl }}</div>
|
||||
</div>
|
||||
<div class="tk-token-block">
|
||||
<div class="tk-token-label">service_id(主控服务ID)</div>
|
||||
<div class="tk-token-value">{{ tokenResultInfo.service_id }}</div>
|
||||
</div>
|
||||
<div class="tk-token-block">
|
||||
<div class="tk-token-label">注册令牌</div>
|
||||
<div class="tk-token-value">{{ tokenResultInfo.token }}</div>
|
||||
</div>
|
||||
<el-button type="primary" class="tk-copy-btn" @click="copyToken">
|
||||
<el-icon><CopyDocument /></el-icon>复制令牌到剪贴板
|
||||
</el-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="tokenResultVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 令牌用宿主机组选择器 -->
|
||||
<HostGroupSelectorPopup v-model="showTokenGroupSelector" :service-id="serviceId" :current-id="tokenForm.host_group_id" @confirm="handleTokenGroupSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -396,13 +527,15 @@
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Box, Connection, Search, Plus } from '@element-plus/icons-vue'
|
||||
import { ArrowLeft, Refresh, Edit, Delete, Monitor, Coin, Connection, Search, Plus, Key, CopyDocument } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost,
|
||||
getRemoteHostDetail, updateRemoteHost, deleteRemoteHost,
|
||||
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
|
||||
assignUserNetworking, removeUserNetworkingNetwork
|
||||
assignUserNetworking, removeUserNetworkingNetwork,
|
||||
createHostToken, getMetricsHistory
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { baseUrl } from '@/config/env'
|
||||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||||
import ImageManage from '@/views/virtualization/ImageManage.vue'
|
||||
import NetworkManage from '@/views/virtualization/NetworkManage.vue'
|
||||
@@ -442,14 +575,16 @@ watch(activeTab, (tab) => {
|
||||
nextTick(() => { tabRefMap[tab]?.value?.loadList?.() })
|
||||
}
|
||||
}
|
||||
if (tab === 'monitor' && detail.value) { loadMetrics(); startPolling() }
|
||||
else stopPolling()
|
||||
if (tab === 'monitor' && detail.value) {
|
||||
if (!historicalMetricsData.value) {
|
||||
loadHistoricalMetrics()
|
||||
}
|
||||
}
|
||||
if (tab === 'networking') loadNetworkingList()
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const metricsLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
|
||||
provide('embedded', true)
|
||||
@@ -480,7 +615,8 @@ const fallbackCopy = (text) => {
|
||||
} catch { ElMessage.error('复制失败') }
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
const metricsData = ref(null)
|
||||
const historicalMetricsData = ref(null)
|
||||
const historicalMetricsLoading = ref(false)
|
||||
const editDialogVisible = ref(false)
|
||||
const showGroupSelector = ref(false)
|
||||
const formRef = ref(null)
|
||||
@@ -561,65 +697,77 @@ const loadDetail = async () => {
|
||||
const cpuChartRef = ref(null)
|
||||
const memChartRef = ref(null)
|
||||
const netChartRef = ref(null)
|
||||
const inetChartRef = ref(null)
|
||||
let cpuChart = null
|
||||
let memChart = null
|
||||
let netChart = null
|
||||
|
||||
const MAX_HISTORY = 60
|
||||
const metricsHistory = reactive({
|
||||
times: [],
|
||||
cpu: [],
|
||||
memPercent: [],
|
||||
netRx: [],
|
||||
netTx: []
|
||||
})
|
||||
const pollingActive = ref(false)
|
||||
let pollTimer = null
|
||||
let inetChart = null
|
||||
let isPageActive = false
|
||||
|
||||
const loadMetrics = async () => {
|
||||
if (!serviceId.value || !hostId.value || !isPageActive) return
|
||||
metricsLoading.value = true
|
||||
const latestMetrics = computed(() => {
|
||||
const arr = historicalMetricsData.value
|
||||
if (!Array.isArray(arr) || !arr.length) return null
|
||||
return arr[arr.length - 1]
|
||||
})
|
||||
|
||||
// 历史指标时间范围
|
||||
const historyTimeRange = ref('1m') // 1m, 5m, 1h, 1d
|
||||
const historyTimeOptions = [
|
||||
{ label: '最近1分钟', value: '1m' },
|
||||
{ label: '最近5分钟', value: '5m' },
|
||||
{ label: '最近1小时', value: '1h' },
|
||||
{ label: '最近1天', value: '1d' },
|
||||
]
|
||||
|
||||
// 加载历史指标数据
|
||||
const loadHistoricalMetrics = async () => {
|
||||
if (!serviceId.value || !hostId.value) return
|
||||
|
||||
historicalMetricsLoading.value = true
|
||||
try {
|
||||
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: hostId.value })
|
||||
// 计算时间范围
|
||||
const now = new Date()
|
||||
let startTime = new Date()
|
||||
|
||||
switch (historyTimeRange.value) {
|
||||
case '1m':
|
||||
startTime.setMinutes(now.getMinutes() - 1)
|
||||
break
|
||||
case '5m':
|
||||
startTime.setMinutes(now.getMinutes() - 5)
|
||||
break
|
||||
case '1h':
|
||||
startTime.setHours(now.getHours() - 1)
|
||||
break
|
||||
case '1d':
|
||||
startTime.setDate(now.getDate() - 1)
|
||||
break
|
||||
}
|
||||
|
||||
const params = {
|
||||
service_id: serviceId.value,
|
||||
host_id: hostId.value,
|
||||
start: startTime.toISOString(),
|
||||
end_time: now.toISOString(),
|
||||
interval: { '1m': '1m', '5m': '5m', '1h': '1h', '1d': '1d' }[historyTimeRange.value] || '5m'
|
||||
}
|
||||
|
||||
const res = await getMetricsHistory(params)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
metricsData.value = body.data.data ?? body.data
|
||||
pushHistory(metricsData.value)
|
||||
historicalMetricsData.value = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
||||
await nextTick()
|
||||
renderCharts()
|
||||
renderHistoricalCharts()
|
||||
} else {
|
||||
ElMessage.error(extractApiError(body, '加载历史指标失败'))
|
||||
}
|
||||
} catch { /* silent for polling */ } finally { metricsLoading.value = false }
|
||||
}
|
||||
|
||||
const pushHistory = (d) => {
|
||||
const now = new Date().toLocaleTimeString('zh-CN', { hour12: false })
|
||||
metricsHistory.times.push(now)
|
||||
metricsHistory.cpu.push(d.cpu?.cpu_usage_percent ?? 0)
|
||||
metricsHistory.memPercent.push(d.memory?.percent ?? 0)
|
||||
metricsHistory.netRx.push(d.internet_speed?.rx_bytes ?? 0)
|
||||
metricsHistory.netTx.push(d.internet_speed?.tx_bytes ?? 0)
|
||||
if (metricsHistory.times.length > MAX_HISTORY) {
|
||||
metricsHistory.times.shift()
|
||||
metricsHistory.cpu.shift()
|
||||
metricsHistory.memPercent.shift()
|
||||
metricsHistory.netRx.shift()
|
||||
metricsHistory.netTx.shift()
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e?.response?.data, '加载历史指标失败'))
|
||||
} finally {
|
||||
historicalMetricsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const makeLineOption = (title, seriesData, color, yFormatter) => ({
|
||||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||||
const p = params[0]
|
||||
return `${p.axisValue}<br/>${p.marker} ${p.seriesName}: ${yFormatter ? yFormatter(p.value) : p.value}`
|
||||
}},
|
||||
grid: { top: 10, right: 16, bottom: 24, left: 50 },
|
||||
xAxis: { type: 'category', data: metricsHistory.times, boundaryGap: false, axisLabel: { fontSize: 10 } },
|
||||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: yFormatter || (v => v) } },
|
||||
series: Array.isArray(seriesData)
|
||||
? seriesData.map(s => ({ name: s.name, type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2 }, data: s.data, itemStyle: { color: s.color } }))
|
||||
: [{ name: title, type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color }, itemStyle: { color }, data: seriesData }]
|
||||
})
|
||||
|
||||
const formatNetLabel = (v) => {
|
||||
if (!v) return '0 B/s'
|
||||
@@ -629,33 +777,78 @@ const formatNetLabel = (v) => {
|
||||
return v + ' B/s'
|
||||
}
|
||||
|
||||
const renderCharts = () => {
|
||||
const times = [...metricsHistory.times]
|
||||
const cpuData = [...metricsHistory.cpu]
|
||||
const memData = [...metricsHistory.memPercent]
|
||||
const rxData = [...metricsHistory.netRx]
|
||||
const txData = [...metricsHistory.netTx]
|
||||
// 渲染历史指标图表
|
||||
const renderHistoricalCharts = () => {
|
||||
const metrics = historicalMetricsData.value
|
||||
if (!Array.isArray(metrics) || !metrics.length) return
|
||||
|
||||
const range = historyTimeRange.value
|
||||
const showDate = range === '7d' || range === '24h'
|
||||
const symbolType = range === '7d' ? 'circle' : 'none'
|
||||
const labelRotate = showDate ? 45 : 0
|
||||
|
||||
const times = metrics.map(m => {
|
||||
const date = new Date(m.bucket)
|
||||
if (range === '7d') return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
return date.toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit' })
|
||||
})
|
||||
|
||||
const cpuData = metrics.map(m => m.cpu_usage ?? 0)
|
||||
const memData = metrics.map(m => m.mem_percent ?? 0)
|
||||
const inetRxData = metrics.map(m => m.inet_rx ?? 0)
|
||||
const inetTxData = metrics.map(m => m.inet_tx ?? 0)
|
||||
|
||||
const netRxRate = []
|
||||
const netTxRate = []
|
||||
for (let i = 0; i < metrics.length; i++) {
|
||||
if (i === 0) { netRxRate.push(0); netTxRate.push(0); continue }
|
||||
const dt = (new Date(metrics[i].bucket) - new Date(metrics[i - 1].bucket)) / 1000
|
||||
if (dt > 0) {
|
||||
netRxRate.push(Math.max(0, ((metrics[i].net_rx ?? 0) - (metrics[i - 1].net_rx ?? 0)) / dt))
|
||||
netTxRate.push(Math.max(0, ((metrics[i].net_tx ?? 0) - (metrics[i - 1].net_tx ?? 0)) / dt))
|
||||
} else {
|
||||
netRxRate.push(0); netTxRate.push(0)
|
||||
}
|
||||
}
|
||||
|
||||
const baseGrid = { top: 10, right: 16, bottom: 24, left: 50 }
|
||||
const makeXAxis = () => ({ type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10, rotate: labelRotate } })
|
||||
const makeSeries = (name, data, color) => ({ name, type: 'line', smooth: true, symbol: symbolType, areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color }, itemStyle: { color }, data })
|
||||
|
||||
if (cpuChartRef.value) {
|
||||
if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value)
|
||||
cpuChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} CPU: ${p[0].value.toFixed(1)}%` },
|
||||
grid: { top: 10, right: 16, bottom: 24, left: 50 },
|
||||
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
|
||||
series: [{ name: 'CPU', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#409eff' }, itemStyle: { color: '#409eff' }, data: cpuData }]
|
||||
series: [makeSeries('CPU', cpuData, '#409eff')]
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (memChartRef.value) {
|
||||
if (!memChart) memChart = echarts.init(memChartRef.value)
|
||||
memChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` },
|
||||
grid: { top: 10, right: 16, bottom: 24, left: 50 },
|
||||
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } },
|
||||
series: [{ name: '内存', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#67c23a' }, itemStyle: { color: '#67c23a' }, data: memData }]
|
||||
series: [makeSeries('内存', memData, '#67c23a')]
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (inetChartRef.value) {
|
||||
if (!inetChart) inetChart = echarts.init(inetChartRef.value)
|
||||
inetChart.setOption({
|
||||
tooltip: { trigger: 'axis', formatter: (params) => {
|
||||
let s = params[0].axisValue
|
||||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
|
||||
return s
|
||||
}},
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: formatNetLabel } },
|
||||
series: [makeSeries('接收', inetRxData, '#409eff'), makeSeries('发送', inetTxData, '#e6a23c')]
|
||||
}, true)
|
||||
}
|
||||
|
||||
if (netChartRef.value) {
|
||||
if (!netChart) netChart = echarts.init(netChartRef.value)
|
||||
netChart.setOption({
|
||||
@@ -664,33 +857,18 @@ const renderCharts = () => {
|
||||
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
|
||||
return s
|
||||
}},
|
||||
grid: { top: 10, right: 16, bottom: 24, left: 50 },
|
||||
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
|
||||
grid: baseGrid, xAxis: makeXAxis(),
|
||||
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: formatNetLabel } },
|
||||
series: [
|
||||
{ name: '接收', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#409eff' }, itemStyle: { color: '#409eff' }, data: rxData },
|
||||
{ name: '发送', type: 'line', smooth: true, symbol: 'none', areaStyle: { opacity: 0.15 }, lineStyle: { width: 2, color: '#e6a23c' }, itemStyle: { color: '#e6a23c' }, data: txData }
|
||||
]
|
||||
series: [makeSeries('接收', netRxRate, '#409eff'), makeSeries('发送', netTxRate, '#e6a23c')]
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
const startPolling = () => {
|
||||
if (!serviceId.value || !hostId.value || !isPageActive) return
|
||||
stopPolling()
|
||||
pollingActive.value = true
|
||||
pollTimer = setInterval(() => { loadMetrics() }, 3000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
pollingActive.value = false
|
||||
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||
}
|
||||
|
||||
const disposeCharts = () => {
|
||||
cpuChart?.dispose(); cpuChart = null
|
||||
memChart?.dispose(); memChart = null
|
||||
netChart?.dispose(); netChart = null
|
||||
inetChart?.dispose(); inetChart = null
|
||||
}
|
||||
|
||||
const handleEdit = () => {
|
||||
@@ -736,6 +914,115 @@ const handleDelete = () => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ========== 创建注册令牌 ==========
|
||||
const tokenDialogVisible = ref(false)
|
||||
const tokenSubmitLoading = ref(false)
|
||||
const tokenResultVisible = ref(false)
|
||||
const showTokenGroupSelector = ref(false)
|
||||
const tokenFormRef = ref(null)
|
||||
const tokenMemUnit = ref('GB')
|
||||
const tokenDiskUnit = ref('GB')
|
||||
|
||||
const tokenForm = reactive({
|
||||
name: '', host_group_id: 0, max_cpu: 4,
|
||||
max_memory: 4194304, max_disk: 100,
|
||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||
description: '', expire_hours: 24
|
||||
})
|
||||
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
||||
const tokenRules = {
|
||||
name: [{ required: true, message: '请输入宿主机名称', trigger: 'blur' }],
|
||||
host_group_id: [{ required: true, type: 'number', min: 1, message: '请选择宿主机组', trigger: 'change' }],
|
||||
max_cpu: [{ required: true, type: 'number', min: 1, message: '请设置最大CPU核数', trigger: 'change' }],
|
||||
max_memory: [{ required: true, type: 'number', min: 1, message: '请设置最大内存', trigger: 'change' }],
|
||||
max_disk: [{ required: true, type: 'number', min: 1, message: '请设置最大磁盘', trigger: 'change' }],
|
||||
expire_hours: [{ required: true, type: 'number', min: 1, message: '请设置有效期', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const getTokenMemFactor = () => memoryUnitOptions.find(u => u.label === tokenMemUnit.value)?.factor || 1048576
|
||||
const getTokenDiskFactor = () => diskUnitOptions.find(u => u.label === tokenDiskUnit.value)?.factor || 1
|
||||
const tokenMemDisplay = computed({
|
||||
get: () => tokenForm.max_memory ? +(tokenForm.max_memory / getTokenMemFactor()).toFixed(2) : 0,
|
||||
set: (v) => { tokenForm.max_memory = Math.round((v || 0) * getTokenMemFactor()) }
|
||||
})
|
||||
const tokenDiskDisplay = computed({
|
||||
get: () => tokenForm.max_disk ? +(tokenForm.max_disk / getTokenDiskFactor()).toFixed(2) : 0,
|
||||
set: (v) => { tokenForm.max_disk = Math.round((v || 0) * getTokenDiskFactor()) }
|
||||
})
|
||||
|
||||
const openTokenDialog = () => {
|
||||
const d = detail.value
|
||||
Object.assign(tokenForm, {
|
||||
name: '', host_group_id: d?.host_group_id || 0,
|
||||
max_cpu: d?.max_cpu || 4,
|
||||
max_memory: d?.max_memory || 4194304,
|
||||
max_disk: d?.max_disk || 100,
|
||||
rx_bandwidth: d?.rx_bandwidth || 100,
|
||||
tx_bandwidth: d?.tx_bandwidth || 100,
|
||||
description: '', expire_hours: 24
|
||||
})
|
||||
tokenMemUnit.value = 'GB'
|
||||
tokenDiskUnit.value = 'GB'
|
||||
tokenDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleTokenGroupSelected = (group) => {
|
||||
tokenForm.host_group_id = group.id
|
||||
}
|
||||
|
||||
const handleTokenSubmit = () => {
|
||||
tokenFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
tokenSubmitLoading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('name', tokenForm.name)
|
||||
fd.append('host_group_id', tokenForm.host_group_id)
|
||||
fd.append('max_cpu', tokenForm.max_cpu)
|
||||
fd.append('max_memory', tokenForm.max_memory)
|
||||
fd.append('max_disk', tokenForm.max_disk)
|
||||
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
||||
fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
|
||||
fd.append('description', tokenForm.description || '')
|
||||
fd.append('expire_hours', tokenForm.expire_hours)
|
||||
const res = await createHostToken(fd)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
tokenResultInfo.name = tokenForm.name
|
||||
tokenResultInfo.expire_hours = tokenForm.expire_hours
|
||||
tokenResultInfo.token = body.data.token || body.data.Token || JSON.stringify(body.data)
|
||||
tokenResultInfo.service_id = serviceId.value
|
||||
tokenDialogVisible.value = false
|
||||
tokenResultVisible.value = true
|
||||
ElMessage.success('注册令牌创建成功')
|
||||
} else {
|
||||
ElMessage.error(extractApiError(body, '创建令牌失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e?.response?.data, '创建令牌失败'))
|
||||
} finally {
|
||||
tokenSubmitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const copyToken = async () => {
|
||||
const text = `后端地址:${baseUrl}\nservice_id:${tokenResultInfo.service_id}\n注册令牌:${tokenResultInfo.token}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('令牌信息已复制到剪贴板')
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
ElMessage.success('令牌信息已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
tagsViewStore.delVisitedView(route)
|
||||
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } })
|
||||
@@ -916,26 +1203,20 @@ const initPage = () => {
|
||||
showToken.value = false
|
||||
showPassword.value = false
|
||||
showPrivateKey.value = false
|
||||
metricsData.value = null
|
||||
metricsHistory.times.length = 0
|
||||
metricsHistory.cpu.length = 0
|
||||
metricsHistory.memPercent.length = 0
|
||||
metricsHistory.netRx.length = 0
|
||||
metricsHistory.netTx.length = 0
|
||||
historicalMetricsData.value = null
|
||||
disposeCharts()
|
||||
loadDetail()
|
||||
if (activeTab.value === 'monitor') loadMetrics().then(() => startPolling())
|
||||
if (activeTab.value === 'monitor') loadHistoricalMetrics()
|
||||
}
|
||||
|
||||
watch(hostId, () => { if (isPageActive) initPage() })
|
||||
onActivated(() => {
|
||||
isPageActive = true
|
||||
if (loadedHostId !== hostId.value) initPage()
|
||||
else if (activeTab.value === 'monitor') startPolling()
|
||||
})
|
||||
onMounted(() => { isPageActive = true; initPage() })
|
||||
onDeactivated(() => { isPageActive = false; stopPolling() })
|
||||
onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() })
|
||||
onDeactivated(() => { isPageActive = false })
|
||||
onBeforeUnmount(() => { isPageActive = false; disposeCharts() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -981,16 +1262,16 @@ onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() })
|
||||
.secret-cell { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||
.secret-cell code { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mono-text { font-family: 'Consolas', 'Monaco', monospace; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
|
||||
.metrics-card { margin-bottom: 0; }
|
||||
.metrics-title { font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||||
.chart-container { width: 100%; height: 220px; }
|
||||
.disk-item { margin-bottom: 8px; }
|
||||
.disk-path { font-weight: 500; color: #409eff; font-size: 13px; margin-bottom: 4px; font-family: 'Consolas', monospace; }
|
||||
|
||||
.unit-input-row { display: flex; gap: 6px; width: 100%; }
|
||||
.wide-number { flex: 1; min-width: 140px; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.metric-summary-row { display: flex; gap: 16px; margin-bottom: 16px; }
|
||||
.metric-summary-card { flex: 1; min-width: 0; background: #f7f8fa; border-radius: 6px; padding: 14px 16px; border: 1px solid #e8e8e8; display: flex; flex-direction: column; }
|
||||
.metric-summary-label { font-size: 12px; color: #86909c; margin-bottom: 8px; }
|
||||
.metric-summary-value { font-size: 22px; font-weight: 600; color: #1d2129; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.metric-summary-sub { font-size: 12px; color: #86909c; margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
</style>
|
||||
|
||||
@@ -727,27 +727,4 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
|
||||
.text-muted {
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 绑定选择器行 */
|
||||
.bind-selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
--el-table-border-color: #ebeef5;
|
||||
--el-table-header-bg-color: #fafafa;
|
||||
--el-table-row-hover-bg-color: #f5f7fa;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="warning" @click="openTokenDialog">
|
||||
<el-icon><Key /></el-icon>创建注册令牌
|
||||
</el-button>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>新增宿主机
|
||||
</el-button>
|
||||
@@ -18,6 +21,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="embedded-toolbar" v-if="embedded">
|
||||
<el-button type="warning" @click="openTokenDialog"><el-icon><Key /></el-icon>创建注册令牌</el-button>
|
||||
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新增宿主机</el-button>
|
||||
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
@@ -95,85 +99,85 @@
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="宿主机名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务地址" prop="base_url">
|
||||
<el-input v-model="formData.base_url" placeholder="宿主机服务 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP 地址" prop="ip">
|
||||
<el-input v-model="formData.ip" placeholder="宿主机 IP" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证Token">
|
||||
<el-input v-model="formData.token" placeholder="宿主机服务 Token(可选)" show-password />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">SSH 配置</el-divider>
|
||||
<el-form-item label="SSH 端口">
|
||||
<el-input-number v-model="formData.port" :min="0" :max="65535" placeholder="22" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 用户名">
|
||||
<el-input v-model="formData.user" placeholder="默认 tunneluser" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 密码">
|
||||
<el-input v-model="formData.password" placeholder="SSH 密码(可选)" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 私钥">
|
||||
<el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">资源限制</el-divider>
|
||||
<el-form-item label="最大CPU(核)">
|
||||
<el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大内存">
|
||||
<div class="unit-input-row">
|
||||
<el-select v-model="memoryUnit" style="width: 70px; flex-shrink: 0;" size="default">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="wide-number" />
|
||||
</div>
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close class="tk-dialog">
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="宿主机名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务地址" prop="base_url">
|
||||
<el-input v-model="formData.base_url" placeholder="宿主机服务 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP 地址" prop="ip">
|
||||
<el-input v-model="formData.ip" placeholder="宿主机 IP" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证Token">
|
||||
<el-input v-model="formData.token" placeholder="宿主机服务 Token(可选)" show-password />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">SSH 配置</div>
|
||||
<el-form-item label="端口">
|
||||
<div class="tk-inline-unit">
|
||||
<el-input-number v-model="formData.port" :min="0" :max="65535" placeholder="22" controls-position="right" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="formData.user" placeholder="默认 tunneluser" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="formData.password" placeholder="SSH 密码(可选)" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="私钥">
|
||||
<el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">资源限制</div>
|
||||
<div class="tk-resource-grid">
|
||||
<el-form-item label="CPU">
|
||||
<el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大磁盘">
|
||||
<div class="unit-input-row">
|
||||
<el-select v-model="diskUnit" style="width: 70px; flex-shrink: 0;" size="default">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" class="wide-number" />
|
||||
</div>
|
||||
<el-form-item label="内存">
|
||||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="memoryUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下行带宽(Mbps)">
|
||||
<el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||
<el-form-item label="磁盘">
|
||||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="diskUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="上行带宽(Mbps)">
|
||||
<el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||
<el-form-item label="下行带宽">
|
||||
<el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="上行带宽">
|
||||
<el-input-number v-model="formData.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="宿主机组">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}${formData._groupName ? ' - ' + formData._groupName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<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 label="介绍">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="宿主机介绍(可选)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">其他配置</div>
|
||||
<el-form-item label="宿主机组">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="formData.host_group_id ? `宿主机组 #${formData.host_group_id}${formData._groupName ? ' - ' + formData._groupName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<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 label="介绍">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="宿主机介绍(可选)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -220,53 +224,144 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建注册令牌弹窗 -->
|
||||
<el-dialog v-model="tokenDialogVisible" title="创建宿主机注册令牌" width="700px" destroy-on-close class="token-dialog">
|
||||
<el-form ref="tokenFormRef" :model="tokenForm" :rules="tokenRules" label-width="120px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="宿主机名称" prop="name">
|
||||
<el-input v-model="tokenForm.name" placeholder="为该宿主机命名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属宿主机组" prop="host_group_id">
|
||||
<div class="bind-selector-row">
|
||||
<el-input
|
||||
:model-value="tokenForm.host_group_id ? `宿主机组 #${tokenForm.host_group_id}${tokenForm._groupName ? ' - ' + tokenForm._groupName : ''}` : ''"
|
||||
placeholder="请选择宿主机组" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showTokenGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="tokenForm.host_group_id" @click="tokenForm.host_group_id = 0; tokenForm._groupName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机描述">
|
||||
<el-input v-model="tokenForm.description" type="textarea" :rows="2" placeholder="宿主机描述(可选)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">资源配额</div>
|
||||
<div class="tk-resource-grid">
|
||||
<el-form-item label="CPU" prop="max_cpu" class="tk-res-item">
|
||||
<el-input-number v-model="tokenForm.max_cpu" :min="1" controls-position="right" /><span class="tk-res-unit">核</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="内存" prop="max_memory" class="tk-res-item">
|
||||
<el-input-number v-model="tokenMemDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="tokenMemUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="磁盘" prop="max_disk" class="tk-res-item">
|
||||
<el-input-number v-model="tokenDiskDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="tokenDiskUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="下行带宽" class="tk-res-item">
|
||||
<el-input-number v-model="tokenForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="上行带宽" class="tk-res-item">
|
||||
<el-input-number v-model="tokenForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">令牌有效期</div>
|
||||
<el-form-item label="有效期" prop="expire_hours">
|
||||
<el-input-number v-model="tokenForm.expire_hours" :min="1" :max="8760" controls-position="right" style="width: 100%" />
|
||||
<div class="form-hint">单位:小时。默认 24 小时,最大 8760 小时(365天)</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="tokenDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="tokenSubmitLoading" @click="handleTokenSubmit">
|
||||
<el-icon><Key /></el-icon>创建令牌
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 令牌结果弹窗 -->
|
||||
<el-dialog v-model="tokenResultVisible" title="注册令牌已生成" width="560px" :close-on-click-modal="false" class="token-result-dialog">
|
||||
<div class="tk-result-wrapper">
|
||||
<div class="tk-result-header">
|
||||
<el-icon class="tk-result-icon"><Key /></el-icon>
|
||||
<div>
|
||||
<div class="tk-result-name">{{ tokenResultInfo.name }}</div>
|
||||
<div class="tk-result-meta">有效期 {{ tokenResultInfo.expire_hours }} 小时</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">
|
||||
<template #title>请立即复制并保存此令牌,关闭后将无法再次查看</template>
|
||||
</el-alert>
|
||||
<div class="tk-token-block">
|
||||
<div class="tk-token-label">后端地址</div>
|
||||
<div class="tk-token-value">{{ baseUrl }}</div>
|
||||
</div>
|
||||
<div class="tk-token-block">
|
||||
<div class="tk-token-label">service_id(主控服务ID)</div>
|
||||
<div class="tk-token-value">{{ tokenResultInfo.service_id }}</div>
|
||||
</div>
|
||||
<div class="tk-token-block">
|
||||
<div class="tk-token-label">注册令牌</div>
|
||||
<div class="tk-token-value">{{ tokenResultInfo.token }}</div>
|
||||
</div>
|
||||
<el-button type="primary" class="tk-copy-btn" @click="copyToken">
|
||||
<el-icon><CopyDocument /></el-icon>复制令牌到剪贴板
|
||||
</el-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="tokenResultVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 令牌用宿主机组选择器 -->
|
||||
<HostGroupSelectorPopup
|
||||
v-model="showTokenGroupSelector"
|
||||
:service-id="serviceId"
|
||||
:current-id="tokenForm.host_group_id"
|
||||
@confirm="handleTokenGroupSelected"
|
||||
/>
|
||||
|
||||
<!-- 指标弹窗 -->
|
||||
<el-dialog v-model="metricsVisible" title="宿主机指标" width="700px" destroy-on-close>
|
||||
<div v-loading="metricsLoading">
|
||||
<template v-if="metricsData">
|
||||
<div class="metrics-time">数据时间:{{ formatBucket(metricsData.bucket) }}</div>
|
||||
<!-- CPU -->
|
||||
<el-card shadow="never" class="metrics-card" v-if="metricsData.cpu">
|
||||
<el-card shadow="never" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="使用率">{{ (metricsData.cpu.cpu_usage_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="核心数">{{ metricsData.cpu.cpu_count ?? '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ (metricsData.cpu_usage ?? 0).toFixed(1) }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="核心数">{{ metricsData.cpu_count ?? '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<!-- 内存 -->
|
||||
<el-card shadow="never" class="metrics-card" v-if="metricsData.memory">
|
||||
<el-card shadow="never" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.memory.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.memory.used) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(metricsData.memory.free) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ metricsData.memory.percent ?? '-' }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.mem_total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.mem_used) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(metricsData.mem_free) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ (metricsData.mem_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<!-- 磁盘 -->
|
||||
<el-card shadow="never" class="metrics-card" v-if="metricsData.disk">
|
||||
<template #header><span class="metrics-title"><el-icon><Box /></el-icon> 磁盘</span></template>
|
||||
<div v-for="(info, path) in metricsData.disk" :key="path" class="disk-item">
|
||||
<div class="disk-path">{{ path }}</div>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="总计">{{ formatBytesRaw(info.total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已用">{{ formatBytesRaw(info.used) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="空闲">{{ formatBytesRaw(info.free) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="使用率">{{ info.percent ?? '-' }}%</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-card>
|
||||
<!-- 网络 -->
|
||||
<el-card shadow="never" class="metrics-card" v-if="metricsData.network || metricsData.internet_speed">
|
||||
<el-card shadow="never" class="metrics-card">
|
||||
<template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<template v-if="metricsData.network">
|
||||
<el-descriptions-item label="接收">{{ formatBytesRaw(metricsData.network.rx_bytes) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发送">{{ formatBytesRaw(metricsData.network.tx_bytes) }}</el-descriptions-item>
|
||||
</template>
|
||||
<template v-if="metricsData.internet_speed">
|
||||
<el-descriptions-item label="实时接收速率">{{ formatBytesRaw(metricsData.internet_speed.rx_bytes) }}/s</el-descriptions-item>
|
||||
<el-descriptions-item label="实时发送速率">{{ formatBytesRaw(metricsData.internet_speed.tx_bytes) }}/s</el-descriptions-item>
|
||||
</template>
|
||||
<el-descriptions-item label="公网接收速率">{{ formatNetSpeed(metricsData.inet_rx) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="公网发送速率">{{ formatNetSpeed(metricsData.inet_tx) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="内网接收(累积)">{{ formatBytesRaw(metricsData.net_rx) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="内网发送(累积)">{{ formatBytesRaw(metricsData.net_tx) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
@@ -283,13 +378,14 @@
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft, Monitor, Coin, Box, Connection } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, Search, ArrowLeft, Monitor, Coin, Connection, Key, CopyDocument } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getRemoteHostDetail, getRemoteHostMetrics,
|
||||
getRemoteHostList, getRemoteHostDetail,
|
||||
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
||||
getHostGroupList
|
||||
getHostGroupList, createHostToken, getMetricsHistory
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { baseUrl } from '@/config/env'
|
||||
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -385,6 +481,21 @@ const formatBytesRaw = (val) => {
|
||||
return val + ' B'
|
||||
}
|
||||
|
||||
const formatBucket = (bucket) => {
|
||||
if (!bucket) return '-'
|
||||
const d = new Date(bucket)
|
||||
return isNaN(d.getTime()) ? String(bucket) : d.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const formatNetSpeed = (v) => {
|
||||
if (!v && v !== 0) return '0 B/s'
|
||||
v = Number(v)
|
||||
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
|
||||
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
|
||||
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
|
||||
return v.toFixed(0) + ' B/s'
|
||||
}
|
||||
|
||||
/** 格式化后端 {seconds, nanos} 时间戳 */
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
@@ -551,10 +662,20 @@ const handleMetrics = async (row) => {
|
||||
metricsLoading.value = true
|
||||
metricsData.value = null
|
||||
try {
|
||||
const res = await getRemoteHostMetrics({ service_id: serviceId.value, host_id: row.id })
|
||||
const now = new Date()
|
||||
const start = new Date(now.getTime() - 60 * 60 * 1000)
|
||||
const res = await getMetricsHistory({
|
||||
service_id: serviceId.value,
|
||||
host_id: row.id,
|
||||
start: start.toISOString(),
|
||||
end_time: now.toISOString(),
|
||||
interval: '1m'
|
||||
})
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
metricsData.value = body.data.data ?? body.data
|
||||
const arr = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
||||
metricsData.value = arr.length ? arr[arr.length - 1] : null
|
||||
if (!metricsData.value) ElMessage.warning('暂无指标数据')
|
||||
} else {
|
||||
ElMessage.warning('暂无指标数据')
|
||||
}
|
||||
@@ -584,6 +705,118 @@ const handleDelete = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ========== 创建注册令牌 ==========
|
||||
const tokenDialogVisible = ref(false)
|
||||
const tokenSubmitLoading = ref(false)
|
||||
const tokenResultVisible = ref(false)
|
||||
const showTokenGroupSelector = ref(false)
|
||||
const tokenFormRef = ref(null)
|
||||
const tokenMemUnit = ref('GB')
|
||||
const tokenDiskUnit = ref('GB')
|
||||
|
||||
const tokenForm = reactive({
|
||||
name: '', host_group_id: 0, max_cpu: 4,
|
||||
max_memory: 4194304, max_disk: 100,
|
||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||
description: '', expire_hours: 24,
|
||||
_groupName: ''
|
||||
})
|
||||
|
||||
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
||||
|
||||
const tokenRules = {
|
||||
name: [{ required: true, message: '请输入宿主机名称', trigger: 'blur' }],
|
||||
host_group_id: [{ required: true, type: 'number', min: 1, message: '请选择宿主机组', trigger: 'change' }],
|
||||
max_cpu: [{ required: true, type: 'number', min: 1, message: '请设置最大CPU核数', trigger: 'change' }],
|
||||
max_memory: [{ required: true, type: 'number', min: 1, message: '请设置最大内存', trigger: 'change' }],
|
||||
max_disk: [{ required: true, type: 'number', min: 1, message: '请设置最大磁盘', trigger: 'change' }],
|
||||
expire_hours: [{ required: true, type: 'number', min: 1, message: '请设置有效期', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const getTokenMemFactor = () => memoryUnitOptions.find(u => u.label === tokenMemUnit.value)?.factor || 1048576
|
||||
const getTokenDiskFactor = () => diskUnitOptions.find(u => u.label === tokenDiskUnit.value)?.factor || 1
|
||||
|
||||
const tokenMemDisplay = computed({
|
||||
get: () => tokenForm.max_memory ? +(tokenForm.max_memory / getTokenMemFactor()).toFixed(2) : 0,
|
||||
set: (v) => { tokenForm.max_memory = Math.round((v || 0) * getTokenMemFactor()) }
|
||||
})
|
||||
const tokenDiskDisplay = computed({
|
||||
get: () => tokenForm.max_disk ? +(tokenForm.max_disk / getTokenDiskFactor()).toFixed(2) : 0,
|
||||
set: (v) => { tokenForm.max_disk = Math.round((v || 0) * getTokenDiskFactor()) }
|
||||
})
|
||||
|
||||
const openTokenDialog = () => {
|
||||
Object.assign(tokenForm, {
|
||||
name: '', host_group_id: 0, max_cpu: 4,
|
||||
max_memory: 4194304, max_disk: 100,
|
||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||
description: '', expire_hours: 24, _groupName: ''
|
||||
})
|
||||
tokenMemUnit.value = 'GB'
|
||||
tokenDiskUnit.value = 'GB'
|
||||
tokenDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleTokenGroupSelected = (group) => {
|
||||
tokenForm.host_group_id = group.id
|
||||
tokenForm._groupName = group.name || ''
|
||||
}
|
||||
|
||||
const handleTokenSubmit = () => {
|
||||
tokenFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
tokenSubmitLoading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('name', tokenForm.name)
|
||||
fd.append('host_group_id', tokenForm.host_group_id)
|
||||
fd.append('max_cpu', tokenForm.max_cpu)
|
||||
fd.append('max_memory', tokenForm.max_memory)
|
||||
fd.append('max_disk', tokenForm.max_disk)
|
||||
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
||||
fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
|
||||
fd.append('description', tokenForm.description || '')
|
||||
fd.append('expire_hours', tokenForm.expire_hours)
|
||||
|
||||
const res = await createHostToken(fd)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const data = body.data
|
||||
tokenResultInfo.name = tokenForm.name
|
||||
tokenResultInfo.expire_hours = tokenForm.expire_hours
|
||||
tokenResultInfo.token = data.token || data.Token || JSON.stringify(data)
|
||||
tokenResultInfo.service_id = serviceId.value
|
||||
tokenDialogVisible.value = false
|
||||
tokenResultVisible.value = true
|
||||
ElMessage.success('注册令牌创建成功')
|
||||
} else {
|
||||
ElMessage.error(extractApiError(body, '创建令牌失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e?.response?.data, '创建令牌失败'))
|
||||
} finally {
|
||||
tokenSubmitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const copyToken = async () => {
|
||||
const text = `后端地址:${baseUrl}\nservice_id:${tokenResultInfo.service_id}\n注册令牌:${tokenResultInfo.token}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('令牌信息已复制到剪贴板')
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
ElMessage.success('令牌信息已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(() => {
|
||||
@@ -592,27 +825,11 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.unit-input-row { display: flex; gap: 6px; width: 100%; }
|
||||
.wide-number { flex: 1; min-width: 140px; }
|
||||
.host-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.host-addr { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
|
||||
.host-url { font-size: 12px; color: #909399; margin-top: 2px; }
|
||||
.resource-info { display: flex; flex-wrap: wrap; gap: 4px; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.bind-selector-row { display: flex; align-items: center; width: 100%; }
|
||||
.metrics-time { font-size: 12px; color: #86909c; margin-bottom: 12px; }
|
||||
.metrics-card { margin-bottom: 12px; }
|
||||
.metrics-title { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
|
||||
.metrics-title .el-icon { font-size: 16px; color: #409eff; }
|
||||
.disk-item { margin-bottom: 8px; }
|
||||
.disk-path { font-weight: 500; color: #409eff; font-size: 13px; margin-bottom: 4px; font-family: 'Consolas', monospace; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAddGroup"><el-icon><FolderAdd /></el-icon>新建宿主机组</el-button>
|
||||
<el-button type="success" @click="handleAddHost"><el-icon><Plus /></el-icon>新增宿主机</el-button>
|
||||
<el-button type="warning" @click="openTokenDialog"><el-icon><Key /></el-icon>创建注册令牌</el-button>
|
||||
<el-button @click="loadTreeData"><el-icon><Refresh /></el-icon>刷新</el-button>
|
||||
</div>
|
||||
|
||||
@@ -86,25 +87,126 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 新建/编辑宿主机组弹窗 -->
|
||||
<el-dialog v-model="groupDialogVisible" :title="groupDialogType === 'add' ? '新建宿主机组' : '编辑宿主机组'" width="480px" destroy-on-close>
|
||||
<el-form ref="groupFormRef" :model="groupForm" :rules="groupFormRules" label-width="80px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="groupForm.name" placeholder="宿主机组名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="groupForm.note" type="textarea" :rows="3" placeholder="备注(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级组">
|
||||
<el-select v-model="groupForm.parent_id" placeholder="选择父级" style="width: 100%" clearable @clear="groupForm.parent_id = 0">
|
||||
<el-option :value="0" label="无(顶级分组)" />
|
||||
<el-option v-for="g in parentGroupOptions" :key="g.id" :value="g.id" :label="`${g.name} (ID: ${g.id})`" :disabled="g.id === groupForm.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<!-- 创建注册令牌弹窗 -->
|
||||
<el-dialog v-model="tokenDialogVisible" title="创建宿主机注册令牌" width="700px" destroy-on-close class="token-dialog">
|
||||
<el-form ref="tokenFormRef" :model="tokenForm" :rules="tokenRules" label-width="120px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="宿主机名称" prop="name">
|
||||
<el-input v-model="tokenForm.name" placeholder="为该宿主机命名" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属宿主机组" prop="host_group_id">
|
||||
<el-select v-model="tokenForm.host_group_id" placeholder="请选择宿主机组" filterable style="width: 100%">
|
||||
<el-option :value="0" label="请选择" disabled />
|
||||
<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 label="宿主机描述">
|
||||
<el-input v-model="tokenForm.description" type="textarea" :rows="2" placeholder="宿主机描述(可选)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">资源配额</div>
|
||||
<div class="tk-resource-grid">
|
||||
<el-form-item label="CPU" prop="max_cpu" class="tk-res-item">
|
||||
<el-input-number v-model="tokenForm.max_cpu" :min="1" controls-position="right" /><span class="tk-res-unit">核</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="内存" prop="max_memory" class="tk-res-item">
|
||||
<el-input-number v-model="tokenMemDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="tokenMemUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="磁盘" prop="max_disk" class="tk-res-item">
|
||||
<el-input-number v-model="tokenDiskDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="tokenDiskUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="下行带宽" class="tk-res-item">
|
||||
<el-input-number v-model="tokenForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="上行带宽" class="tk-res-item">
|
||||
<el-input-number v-model="tokenForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">令牌有效期</div>
|
||||
<el-form-item label="有效期" prop="expire_hours">
|
||||
<el-input-number v-model="tokenForm.expire_hours" :min="1" :max="8760" controls-position="right" style="width: 100%" />
|
||||
<div class="form-hint">单位:小时。默认 24 小时,最大 8760 小时(365天)</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="groupDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitGroupForm">确定</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="tokenDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="tokenSubmitLoading" @click="handleTokenSubmit">
|
||||
<el-icon><Key /></el-icon>创建令牌
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 令牌结果弹窗 -->
|
||||
<el-dialog v-model="tokenResultVisible" title="注册令牌已生成" width="560px" :close-on-click-modal="false" class="token-result-dialog">
|
||||
<div class="tk-result-wrapper">
|
||||
<div class="tk-result-header">
|
||||
<el-icon class="tk-result-icon"><Key /></el-icon>
|
||||
<div>
|
||||
<div class="tk-result-name">{{ tokenResultInfo.name }}</div>
|
||||
<div class="tk-result-meta">有效期 {{ tokenResultInfo.expire_hours }} 小时</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom: 16px">
|
||||
<template #title>请立即复制并保存此令牌,关闭后将无法再次查看</template>
|
||||
</el-alert>
|
||||
<div class="tk-token-block">
|
||||
<div class="tk-token-label">后端地址</div>
|
||||
<div class="tk-token-value">{{ baseUrl }}</div>
|
||||
</div>
|
||||
<div class="tk-token-block">
|
||||
<div class="tk-token-label">service_id(主控服务ID)</div>
|
||||
<div class="tk-token-value">{{ tokenResultInfo.service_id }}</div>
|
||||
</div>
|
||||
<div class="tk-token-block">
|
||||
<div class="tk-token-label">注册令牌</div>
|
||||
<div class="tk-token-value">{{ tokenResultInfo.token }}</div>
|
||||
</div>
|
||||
<el-button type="primary" class="tk-copy-btn" @click="copyToken">
|
||||
<el-icon><CopyDocument /></el-icon>复制令牌到剪贴板
|
||||
</el-button>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="tokenResultVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建/编辑宿主机组弹窗 -->
|
||||
<el-dialog v-model="groupDialogVisible" :title="groupDialogType === 'add' ? '新建宿主机组' : '编辑宿主机组'" width="480px" destroy-on-close class="tk-dialog">
|
||||
<el-form ref="groupFormRef" :model="groupForm" :rules="groupFormRules" label-width="80px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="groupForm.name" placeholder="宿主机组名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="groupForm.note" type="textarea" :rows="3" placeholder="备注(可选)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="父级组">
|
||||
<el-select v-model="groupForm.parent_id" placeholder="选择父级" style="width: 100%" clearable @clear="groupForm.parent_id = 0">
|
||||
<el-option :value="0" label="无(顶级分组)" />
|
||||
<el-option v-for="g in parentGroupOptions" :key="g.id" :value="g.id" :label="`${g.name} (ID: ${g.id})`" :disabled="g.id === groupForm.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="groupDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitGroupForm">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -166,84 +268,82 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新建/编辑宿主机弹窗 -->
|
||||
<el-dialog v-model="hostDialogVisible" :title="hostDialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close>
|
||||
<el-form ref="hostFormRef" :model="hostForm" :rules="hostFormRules" label-width="120px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="hostForm.name" placeholder="宿主机名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务地址" prop="base_url">
|
||||
<el-input v-model="hostForm.base_url" placeholder="宿主机服务 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP 地址" prop="ip">
|
||||
<el-input v-model="hostForm.ip" placeholder="宿主机 IP" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证Token">
|
||||
<el-input v-model="hostForm.token" placeholder="可选" show-password />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">SSH 配置</el-divider>
|
||||
<el-form-item label="SSH 端口">
|
||||
<el-input-number v-model="hostForm.port" :min="0" :max="65535" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 用户名">
|
||||
<el-input v-model="hostForm.user" placeholder="默认 tunneluser" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 密码">
|
||||
<el-input v-model="hostForm.password" placeholder="可选" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 私钥">
|
||||
<el-input v-model="hostForm.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">资源限制</el-divider>
|
||||
<el-form-item label="最大CPU(核)">
|
||||
<el-input-number v-model="hostForm.max_cpu" :min="0" controls-position="right" style="width: 240px" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大内存">
|
||||
<div class="unit-input-row">
|
||||
<el-select v-model="memoryUnit" style="width: 70px; flex-shrink: 0;" size="default">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="wide-number" />
|
||||
</div>
|
||||
<el-dialog v-model="hostDialogVisible" :title="hostDialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close class="tk-dialog">
|
||||
<el-form ref="hostFormRef" :model="hostForm" :rules="hostFormRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="hostForm.name" placeholder="宿主机名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务地址" prop="base_url">
|
||||
<el-input v-model="hostForm.base_url" placeholder="宿主机服务 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP 地址" prop="ip">
|
||||
<el-input v-model="hostForm.ip" placeholder="宿主机 IP" />
|
||||
</el-form-item>
|
||||
<el-form-item label="认证Token">
|
||||
<el-input v-model="hostForm.token" placeholder="可选" show-password />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">SSH 配置</div>
|
||||
<el-form-item label="端口">
|
||||
<el-input-number v-model="hostForm.port" :min="0" :max="65535" controls-position="right" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="hostForm.user" placeholder="默认 tunneluser" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="hostForm.password" placeholder="可选" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="私钥">
|
||||
<el-input v-model="hostForm.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">资源限制</div>
|
||||
<div class="tk-resource-grid">
|
||||
<el-form-item label="CPU">
|
||||
<el-input-number v-model="hostForm.max_cpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="最大磁盘">
|
||||
<div class="unit-input-row">
|
||||
<el-select v-model="diskUnit" style="width: 70px; flex-shrink: 0;" size="default">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" class="wide-number" />
|
||||
</div>
|
||||
<el-form-item label="内存">
|
||||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="memoryUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="下行带宽(Mbps)">
|
||||
<el-input-number v-model="hostForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||
<el-form-item label="磁盘">
|
||||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="diskUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="上行带宽(Mbps)">
|
||||
<el-input-number v-model="hostForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
|
||||
<el-form-item label="下行带宽">
|
||||
<el-input-number v-model="hostForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="宿主机组">
|
||||
<el-select v-model="hostForm.host_group_id" placeholder="选择宿主机组" clearable filterable style="width: 100%">
|
||||
<el-option :value="0" label="不选择" />
|
||||
<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 label="介绍">
|
||||
<el-input v-model="hostForm.description" type="textarea" :rows="2" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上行带宽">
|
||||
<el-input-number v-model="hostForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">其他配置</div>
|
||||
<el-form-item label="宿主机组">
|
||||
<el-select v-model="hostForm.host_group_id" placeholder="选择宿主机组" clearable filterable style="width: 100%">
|
||||
<el-option :value="0" label="不选择" />
|
||||
<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 label="介绍">
|
||||
<el-input v-model="hostForm.description" type="textarea" :rows="2" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="hostDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitHostForm">确定</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="hostDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitHostForm">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -253,15 +353,17 @@
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, ArrowLeft, ArrowRight, Loading, FolderAdd } from '@element-plus/icons-vue'
|
||||
import { Plus, Refresh, ArrowLeft, ArrowRight, Loading, FolderAdd, Key, CopyDocument } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostGroupList, getRemoteHostGroupTree, getRemoteHostGroupDetail,
|
||||
createRemoteHostGroup, updateRemoteHostGroup, deleteRemoteHostGroup,
|
||||
getOptimalHostInfo,
|
||||
getRemoteHostList, getRemoteHostDetail,
|
||||
addRemoteHost, updateRemoteHost, deleteRemoteHost
|
||||
addRemoteHost, updateRemoteHost, deleteRemoteHost,
|
||||
createHostToken
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import { baseUrl } from '@/config/env'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -609,6 +711,106 @@ const handleDeleteHost = (row) => {
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ========== 创建注册令牌 ==========
|
||||
const tokenDialogVisible = ref(false)
|
||||
const tokenSubmitLoading = ref(false)
|
||||
const tokenResultVisible = ref(false)
|
||||
const tokenFormRef = ref(null)
|
||||
const tokenMemUnit = ref('GB')
|
||||
const tokenDiskUnit = ref('GB')
|
||||
|
||||
const tokenForm = reactive({
|
||||
name: '', host_group_id: 0, max_cpu: 4,
|
||||
max_memory: 4194304, max_disk: 100,
|
||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||
description: '', expire_hours: 24
|
||||
})
|
||||
const tokenResultInfo = reactive({ name: '', expire_hours: 24, token: '', service_id: 0 })
|
||||
const tokenRules = {
|
||||
name: [{ required: true, message: '请输入宿主机名称', trigger: 'blur' }],
|
||||
host_group_id: [{ required: true, type: 'number', min: 1, message: '请选择宿主机组', trigger: 'change' }],
|
||||
max_cpu: [{ required: true, type: 'number', min: 1, message: '请设置最大CPU核数', trigger: 'change' }],
|
||||
max_memory: [{ required: true, type: 'number', min: 1, message: '请设置最大内存', trigger: 'change' }],
|
||||
max_disk: [{ required: true, type: 'number', min: 1, message: '请设置最大磁盘', trigger: 'change' }],
|
||||
expire_hours: [{ required: true, type: 'number', min: 1, message: '请设置有效期', trigger: 'change' }]
|
||||
}
|
||||
|
||||
const getTokenMemFactor = () => memoryUnitOptions.find(u => u.label === tokenMemUnit.value)?.factor || 1048576
|
||||
const getTokenDiskFactor = () => diskUnitOptions.find(u => u.label === tokenDiskUnit.value)?.factor || 1
|
||||
const tokenMemDisplay = computed({
|
||||
get: () => tokenForm.max_memory ? +(tokenForm.max_memory / getTokenMemFactor()).toFixed(2) : 0,
|
||||
set: (v) => { tokenForm.max_memory = Math.round((v || 0) * getTokenMemFactor()) }
|
||||
})
|
||||
const tokenDiskDisplay = computed({
|
||||
get: () => tokenForm.max_disk ? +(tokenForm.max_disk / getTokenDiskFactor()).toFixed(2) : 0,
|
||||
set: (v) => { tokenForm.max_disk = Math.round((v || 0) * getTokenDiskFactor()) }
|
||||
})
|
||||
|
||||
const openTokenDialog = () => {
|
||||
Object.assign(tokenForm, {
|
||||
name: '', host_group_id: 0, max_cpu: 4,
|
||||
max_memory: 4194304, max_disk: 100,
|
||||
rx_bandwidth: 100, tx_bandwidth: 100,
|
||||
description: '', expire_hours: 24
|
||||
})
|
||||
tokenMemUnit.value = 'GB'
|
||||
tokenDiskUnit.value = 'GB'
|
||||
tokenDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleTokenSubmit = () => {
|
||||
tokenFormRef.value?.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
tokenSubmitLoading.value = true
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('service_id', serviceId.value)
|
||||
fd.append('name', tokenForm.name)
|
||||
fd.append('host_group_id', tokenForm.host_group_id)
|
||||
fd.append('max_cpu', tokenForm.max_cpu)
|
||||
fd.append('max_memory', tokenForm.max_memory)
|
||||
fd.append('max_disk', tokenForm.max_disk)
|
||||
fd.append('rx_bandwidth', tokenForm.rx_bandwidth)
|
||||
fd.append('tx_bandwidth', tokenForm.tx_bandwidth)
|
||||
fd.append('description', tokenForm.description || '')
|
||||
fd.append('expire_hours', tokenForm.expire_hours)
|
||||
const res = await createHostToken(fd)
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
tokenResultInfo.name = tokenForm.name
|
||||
tokenResultInfo.expire_hours = tokenForm.expire_hours
|
||||
tokenResultInfo.token = body.data.token || body.data.Token || JSON.stringify(body.data)
|
||||
tokenResultInfo.service_id = serviceId.value
|
||||
tokenDialogVisible.value = false
|
||||
tokenResultVisible.value = true
|
||||
ElMessage.success('注册令牌创建成功')
|
||||
} else {
|
||||
ElMessage.error(extractApiError(body, '创建令牌失败'))
|
||||
}
|
||||
} catch (e) {
|
||||
ElMessage.error(extractApiError(e?.response?.data, '创建令牌失败'))
|
||||
} finally {
|
||||
tokenSubmitLoading.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const copyToken = async () => {
|
||||
const text = `后端地址:${baseUrl}\nservice_id:${tokenResultInfo.service_id}\n注册令牌:${tokenResultInfo.token}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('令牌信息已复制到剪贴板')
|
||||
} catch {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
ElMessage.success('令牌信息已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => { router.push('/virtualization/kvm-service') }
|
||||
|
||||
onMounted(() => { if (serviceId.value) loadTreeData() })
|
||||
@@ -616,11 +818,6 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
|
||||
|
||||
<style scoped>
|
||||
.host-tree-container { padding: 0; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
|
||||
.tree-name-cell {
|
||||
display: flex;
|
||||
@@ -640,10 +837,6 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
|
||||
.expand-placeholder { width: 20px; display: inline-block; }
|
||||
.row-name { font-weight: 500; color: #303133; }
|
||||
|
||||
.unit-input-row { display: flex; gap: 6px; width: 100%; }
|
||||
.wide-number { flex: 1; min-width: 140px; }
|
||||
.host-addr { color: #409eff; font-size: 13px; }
|
||||
.host-url { color: #909399; font-size: 12px; }
|
||||
.resource-info { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.text-muted { color: #c0c4cc; font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="同步状态" width="100">
|
||||
<el-table-column v-if="isEmbeddedHost" label="同步状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="syncStatusType(row.sync_status)" size="small">{{ syncStatusLabel(row.sync_status) }}</el-tag>
|
||||
</template>
|
||||
@@ -87,42 +87,45 @@
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建镜像' : '编辑镜像'" width="560px" destroy-on-close>
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建镜像' : '编辑镜像'" width="560px" destroy-on-close class="tk-dialog">
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="镜像名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="路径" prop="path">
|
||||
<el-input v-model="formData.path" placeholder="URL 或服务器文件路径" />
|
||||
</el-form-item>
|
||||
<el-form-item label="系统类型" prop="os_type">
|
||||
<el-select v-model="formData.os_type" style="width: 100%">
|
||||
<el-option label="Linux" value="linux" />
|
||||
<el-option label="Windows" value="windows" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="镜像类型" prop="type">
|
||||
<el-select v-model="formData.type" style="width: 100%">
|
||||
<el-option label="系统镜像" value="system" />
|
||||
<el-option label="数据镜像" value="data" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="介绍">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍(可选)" />
|
||||
</el-form-item>
|
||||
<template v-if="dialogType === 'edit'">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="formData.status" style="width: 100%">
|
||||
<el-option label="等待中" value="pending" />
|
||||
<el-option label="下载中" value="downloading" />
|
||||
<el-option label="就绪" value="ready" />
|
||||
<el-option label="错误" value="error" />
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="镜像名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="路径" prop="path">
|
||||
<el-input v-model="formData.path" placeholder="URL 或服务器文件路径" />
|
||||
</el-form-item>
|
||||
<el-form-item label="系统类型" prop="os_type">
|
||||
<el-select v-model="formData.os_type" style="width: 100%">
|
||||
<el-option label="Linux" value="linux" />
|
||||
<el-option label="Windows" value="windows" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="大小">
|
||||
<el-input-number v-model="formData.size" :min="0" style="width: 100%" />
|
||||
<el-form-item label="镜像类型" prop="type">
|
||||
<el-select v-model="formData.type" style="width: 100%">
|
||||
<el-option label="系统镜像" value="system" />
|
||||
<el-option label="数据镜像" value="data" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="介绍">
|
||||
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍(可选)" />
|
||||
</el-form-item>
|
||||
<template v-if="dialogType === 'edit'">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="formData.status" style="width: 100%">
|
||||
<el-option label="等待中" value="pending" />
|
||||
<el-option label="下载中" value="downloading" />
|
||||
<el-option label="就绪" value="ready" />
|
||||
<el-option label="错误" value="error" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="大小">
|
||||
<el-input-number v-model="formData.size" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
@@ -177,37 +180,47 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 同步到宿主机弹窗 -->
|
||||
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="440px" destroy-on-close>
|
||||
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="480px" destroy-on-close class="tk-dialog">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
|
||||
<el-select v-else v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%" v-loading="hostOptionsLoading">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">同步配置</div>
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
|
||||
<el-select v-else v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%" v-loading="hostOptionsLoading">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="syncDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="syncLoading" @click="submitSyncToHost">确定同步</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="syncDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="syncLoading" @click="submitSyncToHost">确定同步</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重下载到宿主机弹窗 -->
|
||||
<el-dialog v-model="reloadDialogVisible" title="重新下载镜像到宿主机" width="440px" destroy-on-close>
|
||||
<el-dialog v-model="reloadDialogVisible" title="重新下载镜像到宿主机" width="480px" destroy-on-close class="tk-dialog">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="镜像">
|
||||
<el-input :model-value="reloadTarget?.name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
|
||||
<el-select v-else v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%" v-loading="hostOptionsLoading">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">重下载配置</div>
|
||||
<el-form-item label="镜像">
|
||||
<el-input :model-value="reloadTarget?.name" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标宿主机" required>
|
||||
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
|
||||
<el-select v-else v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%" v-loading="hostOptionsLoading">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="reloadDialogVisible = false">取消</el-button>
|
||||
<el-button type="warning" :loading="reloadLoading" @click="submitReloadOnHost">确定重下载</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="reloadDialogVisible = false">取消</el-button>
|
||||
<el-button type="warning" :loading="reloadLoading" @click="submitReloadOnHost">确定重下载</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -345,7 +358,7 @@ const loadList = async () => {
|
||||
if (hostId) {
|
||||
res = await getImageCompareHost({ service_id: serviceId.value, host_id: hostId })
|
||||
} else {
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.page_size }
|
||||
if (keyword.value) params.keyword = keyword.value
|
||||
if (filterOsType.value) params.os_type = filterOsType.value
|
||||
if (filterType.value) params.type = filterType.value
|
||||
@@ -611,16 +624,5 @@ defineExpose({ loadList })
|
||||
|
||||
<style scoped>
|
||||
.image-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
|
||||
.host-status-section { margin-top: 8px; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
@@ -379,25 +379,4 @@ onMounted(() => {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
:deep(.el-table) {
|
||||
--el-table-border-color: #ebeef5;
|
||||
--el-table-header-bg-color: #fafafa;
|
||||
--el-table-row-hover-bg-color: #f5f7fa;
|
||||
}
|
||||
|
||||
:deep(.el-table th) {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -71,48 +71,55 @@
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑弹窗 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close>
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px">
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="网络名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机" prop="host_id">
|
||||
<el-select v-model="formData.host_id" placeholder="选择宿主机" filterable style="width: 100%">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="网络类型" prop="type">
|
||||
<el-select v-model="formData.type" style="width: 100%">
|
||||
<el-option label="网桥(Bridge/外网)" value="bridge" />
|
||||
<el-option label="内网(NAT)" value="nat" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="IP 地址(CIDR)" prop="address">
|
||||
<el-input v-model="formData.address" placeholder="例如 192.168.1.0/24" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关地址" prop="gateway">
|
||||
<el-input v-model="formData.gateway" placeholder="例如 192.168.1.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="DNS 服务器">
|
||||
<el-input v-model="formData.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">高级配置(可选)</el-divider>
|
||||
<el-form-item label="MAC 地址">
|
||||
<el-input v-model="formData.mac_address" placeholder="不填则随机" />
|
||||
</el-form-item>
|
||||
<el-form-item label="虚拟网桥名">
|
||||
<el-input v-model="formData.bridge_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
<el-form-item label="逻辑网桥名">
|
||||
<el-input v-model="formData.ls_bridge_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
<el-form-item label="逻辑端口名">
|
||||
<el-input v-model="formData.ls_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close class="tk-dialog">
|
||||
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="名称" prop="name">
|
||||
<el-input v-model="formData.name" placeholder="网络名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机" prop="host_id">
|
||||
<el-select v-model="formData.host_id" placeholder="选择宿主机" filterable style="width: 100%">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="网络类型" prop="type">
|
||||
<el-select v-model="formData.type" style="width: 100%">
|
||||
<el-option label="网桥(Bridge/外网)" value="bridge" />
|
||||
<el-option label="内网(NAT)" value="nat" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="IP(CIDR)" prop="address">
|
||||
<el-input v-model="formData.address" placeholder="例如 192.168.1.0/24" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关地址" prop="gateway">
|
||||
<el-input v-model="formData.gateway" placeholder="例如 192.168.1.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="DNS 服务器">
|
||||
<el-input v-model="formData.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">高级配置</div>
|
||||
<el-form-item label="MAC 地址">
|
||||
<el-input v-model="formData.mac_address" placeholder="不填则随机" />
|
||||
</el-form-item>
|
||||
<el-form-item label="虚拟网桥名">
|
||||
<el-input v-model="formData.bridge_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
<el-form-item label="逻辑网桥名">
|
||||
<el-input v-model="formData.ls_bridge_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
<el-form-item label="逻辑端口名">
|
||||
<el-input v-model="formData.ls_name" placeholder="不填使用默认" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -140,42 +147,50 @@
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量创建弹窗 -->
|
||||
<el-dialog v-model="batchDialogVisible" title="批量创建网络" width="560px" destroy-on-close>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 16px">通过指定 IP 范围(start_ip ~ end_ip)批量创建网络条目</el-alert>
|
||||
<el-form ref="batchFormRef" :model="batchForm" :rules="batchFormRules" label-width="120px">
|
||||
<el-form-item label="宿主机" prop="host_id">
|
||||
<el-select v-model="batchForm.host_id" placeholder="选择宿主机" filterable style="width: 100%">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="起始IP" prop="start_ip">
|
||||
<el-input v-model="batchForm.start_ip" placeholder="如 192.168.1.10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束IP" prop="end_ip">
|
||||
<el-input v-model="batchForm.end_ip" placeholder="如 192.168.1.50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网关">
|
||||
<el-input v-model="batchForm.gateway" placeholder="可选,如 192.168.1.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="子网掩码">
|
||||
<el-input v-model="batchForm.mask" placeholder="可选,如 24" />
|
||||
</el-form-item>
|
||||
<el-form-item label="DNS">
|
||||
<el-input v-model="batchForm.nameservers" placeholder="可选,如 114.114.114.114,8.8.8.8" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网桥名称">
|
||||
<el-input v-model="batchForm.bridge_name" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网络类型">
|
||||
<el-select v-model="batchForm.type" style="width: 100%">
|
||||
<el-option label="网桥(Bridge)" value="bridge" />
|
||||
<el-option label="内网(NAT)" value="nat" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-dialog v-model="batchDialogVisible" title="批量创建网络" width="560px" destroy-on-close class="tk-dialog">
|
||||
<el-form ref="batchFormRef" :model="batchForm" :rules="batchFormRules" label-width="100px">
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px">通过指定 IP 范围(start_ip ~ end_ip)批量创建网络条目</el-alert>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">IP 范围</div>
|
||||
<el-form-item label="宿主机" prop="host_id">
|
||||
<el-select v-model="batchForm.host_id" placeholder="选择宿主机" filterable style="width: 100%">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="起始IP" prop="start_ip">
|
||||
<el-input v-model="batchForm.start_ip" placeholder="如 192.168.1.10" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束IP" prop="end_ip">
|
||||
<el-input v-model="batchForm.end_ip" placeholder="如 192.168.1.50" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">网络配置</div>
|
||||
<el-form-item label="网关">
|
||||
<el-input v-model="batchForm.gateway" placeholder="可选,如 192.168.1.1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="子网掩码">
|
||||
<el-input v-model="batchForm.mask" placeholder="可选,如 24" />
|
||||
</el-form-item>
|
||||
<el-form-item label="DNS">
|
||||
<el-input v-model="batchForm.nameservers" placeholder="可选,如 114.114.114.114,8.8.8.8" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网桥名称">
|
||||
<el-input v-model="batchForm.bridge_name" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="网络类型">
|
||||
<el-select v-model="batchForm.type" style="width: 100%">
|
||||
<el-option label="网桥(Bridge)" value="bridge" />
|
||||
<el-option label="内网(NAT)" value="nat" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleBatchSubmit">确定创建</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="batchDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleBatchSubmit">确定创建</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -256,7 +271,7 @@ const loadList = async () => {
|
||||
if (!hid) { ElMessage.warning('请先选择宿主机'); return }
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, page_size: queryParams.page_size }
|
||||
const params = { service_id: serviceId.value, host_id: hid, page: queryParams.page, count: queryParams.page_size }
|
||||
if (keyword.value) params.key = keyword.value
|
||||
if (filterType.value) params.type = filterType.value
|
||||
if (filterIpVersion.value) params.ip_version = filterIpVersion.value
|
||||
@@ -426,14 +441,4 @@ defineExpose({ loadList })
|
||||
|
||||
<style scoped>
|
||||
.network-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
@@ -335,13 +335,4 @@ onMounted(() => { if (serviceId.value) loadList() })
|
||||
|
||||
<style scoped>
|
||||
.remote-hg-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
@@ -533,17 +533,7 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.sg-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.rules-section { margin-top: 8px; }
|
||||
.rules-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.rules-header h4 { margin: 0; font-size: 15px; color: #303133; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
@@ -125,7 +125,7 @@ const vmOptionsLoading = ref(false)
|
||||
const loadVmOptions = async () => {
|
||||
vmOptionsLoading.value = true
|
||||
try {
|
||||
const res = await getVmList({ service_id: serviceId.value, page: 1, page_size: 10 })
|
||||
const res = await getVmList({ service_id: serviceId.value, page: 1, count: 10 })
|
||||
if (res?.data?.code === 200 && res?.data?.data) {
|
||||
const inner = res.data.data
|
||||
vmOptions.value = inner.vms || inner.data || inner.list || (Array.isArray(inner) ? inner : [])
|
||||
@@ -230,6 +230,5 @@ defineExpose({ loadList })
|
||||
|
||||
<style scoped>
|
||||
.snapshot-manage { padding: 0; }
|
||||
.toolbar { display: flex; gap: 8px; margin-top: 12px; margin-bottom: 16px; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.toolbar { margin-top: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -430,18 +430,7 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.networking-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.networks-section { margin-top: 8px; }
|
||||
.networks-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.networks-header h4 { margin: 0; font-size: 15px; color: #303133; }
|
||||
.mono-text { font-family: 'Cascadia Code', 'Consolas', 'Monaco', monospace; font-size: 12px; color: #303133; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,9 +49,10 @@
|
||||
<span v-else class="text-muted">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<el-table-column label="状态" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="vmStatusType(row.status)" size="small">{{ vmStatusLabel(row.status) }}</el-tag>
|
||||
<el-tag v-if="row.data_migrate_status && !['completed','failed','aborted','cancelled'].includes(row.data_migrate_status)" type="warning" size="small" effect="dark" style="margin-left:4px">迁移中</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<!-- <el-table-column label="宿主机" width="140">
|
||||
@@ -90,130 +91,142 @@
|
||||
</div>
|
||||
|
||||
<!-- 创建弹窗 -->
|
||||
<el-dialog v-model="createDialogVisible" title="创建虚拟机" width="800px" destroy-on-close>
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px">
|
||||
<el-form-item label="名称"><el-input v-model="createForm.name" placeholder="不填随机生成" /></el-form-item>
|
||||
<el-form-item label="镜像" prop="image_id">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showCreateImageSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户" prop="user_id">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="createForm.user_id ? `${createForm._userName || ''} (ID: ${createForm.user_id})` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showUserSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.user_id" @click="createForm.user_id = 0; createForm._userName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">宿主机配置(二选一)</el-divider>
|
||||
<template v-if="isEmbeddedHost">
|
||||
<el-form-item label="宿主机">
|
||||
<el-input :model-value="embeddedHostLabel" disabled />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="分配方式">
|
||||
<el-radio-group v-model="hostMode">
|
||||
<el-radio value="host">指定宿主机</el-radio>
|
||||
<el-radio value="group">指定宿主机组</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机" v-if="hostMode === 'host'">
|
||||
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%" @change="(v) => loadNetworkOptions(v)">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机组" v-if="hostMode === 'group'">
|
||||
<el-dialog v-model="createDialogVisible" title="创建虚拟机" width="800px" destroy-on-close class="tk-dialog">
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">基本信息</div>
|
||||
<el-form-item label="名称"><el-input v-model="createForm.name" placeholder="不填随机生成" /></el-form-item>
|
||||
<el-form-item label="镜像" prop="image_id">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = null; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
|
||||
<el-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showCreateImageSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.image_id" @click="createForm.image_id = 0; createForm._imageName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户" prop="user_id">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="createForm.user_id ? `${createForm._userName || ''} (ID: ${createForm.user_id})` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showUserSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.user_id" @click="createForm.user_id = 0; createForm._userName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-divider content-position="left">资源配置</el-divider>
|
||||
<div class="resource-row">
|
||||
<div class="resource-item">
|
||||
<span class="resource-label">* 内存</span>
|
||||
<el-select v-model="memoryUnit" class="resource-unit-select">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="resource-input" />
|
||||
</div>
|
||||
<div class="resource-item">
|
||||
<span class="resource-label">* 系统盘</span>
|
||||
<el-select v-model="diskUnit" class="resource-unit-select">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" class="resource-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="resource-row">
|
||||
<div class="resource-item">
|
||||
<span class="resource-label">* CPU(核)</span>
|
||||
<el-input-number v-model="createForm.vcpu" :min="0" controls-position="right" class="resource-input" />
|
||||
</div>
|
||||
<div class="resource-item">
|
||||
<span class="resource-label">下行带宽(Mbps)</span>
|
||||
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" class="resource-input" />
|
||||
</div>
|
||||
<div class="resource-item">
|
||||
<span class="resource-label">上行带宽(Mbps)</span>
|
||||
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" class="resource-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider content-position="left">网络配置(二选一)</el-divider>
|
||||
<el-form-item label="IP分配方式">
|
||||
<el-radio-group v-model="ipMode">
|
||||
<el-radio value="num">按IP数量分配</el-radio>
|
||||
<el-radio value="ids">选择网络IP</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-row :gutter="16" v-if="ipMode === 'num'">
|
||||
<el-col :span="12">
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">宿主机配置</div>
|
||||
<template v-if="isEmbeddedHost">
|
||||
<el-form-item label="宿主机">
|
||||
<el-input :model-value="embeddedHostLabel" disabled />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="分配方式">
|
||||
<el-radio-group v-model="hostMode">
|
||||
<el-radio value="host">指定宿主机</el-radio>
|
||||
<el-radio value="group">指定宿主机组</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机" v-if="hostMode === 'host'">
|
||||
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%" @change="(v) => loadNetworkOptions(v)">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机组" v-if="hostMode === 'group'">
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="createForm.host_group_id ? `${createForm._groupName || ''} (ID: ${createForm.host_group_id})` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
<el-button v-if="createForm.host_group_id" @click="createForm.host_group_id = null; createForm._groupName = ''" style="margin-left: 4px">清除</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">资源配置</div>
|
||||
<div class="tk-resource-grid">
|
||||
<el-form-item label="CPU" required>
|
||||
<el-input-number v-model="createForm.vcpu" :min="0" controls-position="right" /><span class="tk-res-unit">核</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="内存" required>
|
||||
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="memoryUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="系统盘" required>
|
||||
<el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
|
||||
<el-select v-model="diskUnit" class="tk-unit-select">
|
||||
<el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="下行带宽">
|
||||
<el-input-number v-model="createForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="上行带宽">
|
||||
<el-input-number v-model="createForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="额外数据卷">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<el-input-number v-model="createForm.data_volume_size" :min="0" controls-position="right" style="width:200px" />
|
||||
<span class="tk-res-unit">GB</span>
|
||||
<span style="font-size:12px;color:#909399;margin-left:8px">0 表示不创建</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">网络配置</div>
|
||||
<el-form-item label="IP分配方式">
|
||||
<el-radio-group v-model="ipMode">
|
||||
<el-radio value="num">按IP数量分配</el-radio>
|
||||
<el-radio value="ids">选择网络IP</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<div class="tk-resource-grid" v-if="ipMode === 'num'">
|
||||
<el-form-item label="IPv4数量">
|
||||
<el-input-number v-model="createForm.ipv4_num" :min="0" controls-position="right" style="width: 100%" />
|
||||
<el-input-number v-model="createForm.ipv4_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="IPv6数量">
|
||||
<el-input-number v-model="createForm.ipv6_num" :min="0" controls-position="right" style="width: 100%" />
|
||||
<el-input-number v-model="createForm.ipv6_num" :min="0" controls-position="right" /><span class="tk-res-unit">个</span>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-form-item label="网络IP列表" v-if="ipMode === 'ids'">
|
||||
<el-select v-model="createForm.network_ids" multiple filterable placeholder="选择可用网络IP" style="width: 100%">
|
||||
<el-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''}`" :value="n.id" />
|
||||
</el-select>
|
||||
<div class="form-tip" v-if="!networkOptions.length">请先选择宿主机以加载可用网络(仅显示未使用的网络)</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="网络IP列表" v-if="ipMode === 'ids'">
|
||||
<el-select v-model="createForm.network_ids" multiple filterable placeholder="选择可用网络IP" style="width: 100%">
|
||||
<el-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''}`" :value="n.id" />
|
||||
</el-select>
|
||||
<div class="form-tip" v-if="!networkOptions.length">请先选择宿主机以加载可用网络(仅显示未使用的网络)</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="createDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重装弹窗 -->
|
||||
<el-dialog v-model="rebuildDialogVisible" title="重装虚拟机" width="440px" destroy-on-close>
|
||||
<el-alert title="重装会使用新镜像重置虚拟机,原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
|
||||
<el-dialog v-model="rebuildDialogVisible" title="重装虚拟机" width="480px" destroy-on-close class="tk-dialog">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="虚拟机">{{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }})</el-form-item>
|
||||
<el-form-item label="新镜像" required>
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="rebuildImageId ? `镜像 #${rebuildImageId}${rebuildImageName ? ' - ' + rebuildImageName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showRebuildImageSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<div class="tk-section">
|
||||
<div class="tk-section-title">重装信息</div>
|
||||
<el-alert title="重装会使用新镜像重置虚拟机,原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
|
||||
<el-form-item label="虚拟机">{{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }})</el-form-item>
|
||||
<el-form-item label="新镜像" required>
|
||||
<div class="bind-selector-row">
|
||||
<el-input :model-value="rebuildImageId ? `镜像 #${rebuildImageId}${rebuildImageName ? ' - ' + rebuildImageName : ''}` : '未选择'" disabled style="flex: 1" />
|
||||
<el-button type="primary" @click="showRebuildImageSelector = true" style="margin-left: 8px">选择</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="rebuildDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重装</el-button>
|
||||
<div class="tk-dialog-footer">
|
||||
<el-button @click="rebuildDialogVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重装</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -294,19 +307,18 @@
|
||||
|
||||
<!-- 指标 -->
|
||||
<template v-if="vmMetricsData">
|
||||
<h4 style="margin: 16px 0 8px">实时指标</h4>
|
||||
<h4 style="margin: 16px 0 8px">最新指标 <span style="font-size:12px; font-weight:400; color:#86909c">{{ formatBucket(vmMetricsData.bucket) }}</span></h4>
|
||||
<el-descriptions :column="2" border size="small">
|
||||
<el-descriptions-item label="虚拟机">{{ vmMetricsData.vm_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="CPU使用率">
|
||||
<span :style="{ color: vmMetricsData.cpu_usage_percent > 90 ? '#F56C6C' : vmMetricsData.cpu_usage_percent > 60 ? '#E6A23C' : '#67C23A' }">
|
||||
{{ (vmMetricsData.cpu_usage_percent ?? 0).toFixed(1) }}%
|
||||
<span :style="{ color: (vmMetricsData.cpu_usage ?? 0) > 90 ? '#F56C6C' : (vmMetricsData.cpu_usage ?? 0) > 60 ? '#E6A23C' : '#67C23A' }">
|
||||
{{ (vmMetricsData.cpu_usage ?? 0).toFixed(1) }}%
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<template v-if="vmMetricsData.internet_speed && Object.keys(vmMetricsData.internet_speed).length">
|
||||
<el-descriptions-item label="网络速率" :span="2">
|
||||
<div v-for="(val, key) in vmMetricsData.internet_speed" :key="key">{{ key }}: {{ val }}</div>
|
||||
</el-descriptions-item>
|
||||
</template>
|
||||
<el-descriptions-item label="内存">{{ formatMemKB(vmMetricsData.mem_used) }} / {{ formatMemKB(vmMetricsData.mem_total) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="磁盘读取">{{ formatBytesRaw(vmMetricsData.disk_read) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="磁盘写入">{{ formatBytesRaw(vmMetricsData.disk_write) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网络接收">{{ formatNetSpeed(vmMetricsData.net_rx) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="网络发送">{{ formatNetSpeed(vmMetricsData.net_tx) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
</div>
|
||||
@@ -350,9 +362,9 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getRemoteHostList, getVmList, getVmDetail, getVmStatus, getVmMetrics,
|
||||
getRemoteHostList, getVmList, getVmDetail, getVmStatus,
|
||||
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
|
||||
resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList
|
||||
resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList, getMetricsHistory
|
||||
} from '@/api/admin/kvmService'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
|
||||
@@ -427,7 +439,7 @@ const diskDisplay = computed({
|
||||
const loadNetworkOptions = async (hid) => {
|
||||
if (!hid) return
|
||||
try {
|
||||
const params = { service_id: serviceId.value, host_id: hid, used: false, page: 1, page_size: 10 }
|
||||
const params = { service_id: serviceId.value, host_id: hid, used: false, page: 1, count: 10 }
|
||||
if (injectedHostId?.value) params.type = 'bridge'
|
||||
const res = await getNetworkList(params)
|
||||
const body = res?.data
|
||||
@@ -476,7 +488,7 @@ const vmMetricsData = ref(null)
|
||||
|
||||
const createForm = reactive({
|
||||
name: '', host_id: null, image_id: 0, vcpu: 0, memory: 0,
|
||||
system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0,
|
||||
system_size: 0, rx_bandwidth: 0, tx_bandwidth: 0, data_volume_size: 0,
|
||||
host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [],
|
||||
_imageName: '', _groupName: '', _userName: ''
|
||||
})
|
||||
@@ -501,7 +513,7 @@ const vmStatusLabel = (s) => ({
|
||||
reboot: '重启中', poweroff: '已关机', unknown: '未知'
|
||||
}[s] || s || '-')
|
||||
|
||||
const formatMemory = (kb) => {
|
||||
const formatMemKB = (kb) => {
|
||||
if (!kb) return '-'
|
||||
kb = Number(kb)
|
||||
if (kb >= 1073741824) return (kb / 1073741824).toFixed(1) + ' TB'
|
||||
@@ -509,6 +521,7 @@ const formatMemory = (kb) => {
|
||||
if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
|
||||
return kb + ' KB'
|
||||
}
|
||||
const formatMemory = formatMemKB
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return '-'
|
||||
@@ -532,6 +545,21 @@ const formatBytesRaw = (val) => {
|
||||
return val + ' B'
|
||||
}
|
||||
|
||||
const formatNetSpeed = (v) => {
|
||||
if (!v && v !== 0) return '0 B/s'
|
||||
v = Number(v)
|
||||
if (v >= 1073741824) return (v / 1073741824).toFixed(1) + ' GB/s'
|
||||
if (v >= 1048576) return (v / 1048576).toFixed(1) + ' MB/s'
|
||||
if (v >= 1024) return (v / 1024).toFixed(1) + ' KB/s'
|
||||
return v.toFixed(0) + ' B/s'
|
||||
}
|
||||
|
||||
const formatBucket = (bucket) => {
|
||||
if (!bucket) return '-'
|
||||
const d = new Date(bucket)
|
||||
return isNaN(d.getTime()) ? String(bucket) : d.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 选择器回调
|
||||
const handleCreateImageSelected = (img) => { createForm.image_id = img.id; createForm._imageName = img.name }
|
||||
const handleRebuildImageSelected = (img) => { rebuildImageId.value = img.id; rebuildImageName.value = img.name }
|
||||
@@ -542,7 +570,7 @@ const loadList = async () => {
|
||||
if (!serviceId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
|
||||
const params = { service_id: serviceId.value, page: queryParams.page, count: queryParams.page_size }
|
||||
if (hostId.value) params.host_id = hostId.value
|
||||
if (keyword.value) params.key = keyword.value
|
||||
if (filterStatus.value) params.status = filterStatus.value
|
||||
@@ -562,7 +590,8 @@ const handleAdd = () => {
|
||||
Object.assign(createForm, {
|
||||
name: '', host_id: injectedHostId?.value || null, image_id: 0,
|
||||
vcpu: 0, memory: 0, system_size: 0,
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [],
|
||||
rx_bandwidth: 0, tx_bandwidth: 0, data_volume_size: 0,
|
||||
host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [],
|
||||
_imageName: '', _groupName: '', _userName: ''
|
||||
})
|
||||
memoryUnit.value = 'GB'
|
||||
@@ -570,6 +599,7 @@ const handleAdd = () => {
|
||||
hostMode.value = 'host'
|
||||
ipMode.value = 'num'
|
||||
networkOptions.value = []
|
||||
loadHostOptions()
|
||||
createDialogVisible.value = true
|
||||
if (injectedHostId?.value) {
|
||||
loadNetworkOptions(injectedHostId.value)
|
||||
@@ -596,6 +626,7 @@ const submitCreate = () => {
|
||||
if (createForm.name) fd.append('name', createForm.name)
|
||||
if (createForm.rx_bandwidth) fd.append('rx_bandwidth', createForm.rx_bandwidth)
|
||||
if (createForm.tx_bandwidth) fd.append('tx_bandwidth', createForm.tx_bandwidth)
|
||||
if (createForm.data_volume_size > 0) fd.append('data_volume_size', createForm.data_volume_size)
|
||||
if (hostMode.value === 'host') fd.append('host_id', createForm.host_id)
|
||||
else fd.append('host_group_id', createForm.host_group_id)
|
||||
if (ipMode.value === 'num') {
|
||||
@@ -737,9 +768,24 @@ const fetchVmStatus = async (vm) => {
|
||||
|
||||
const fetchVmMetrics = async (vm) => {
|
||||
try {
|
||||
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: vm.name })
|
||||
if (res?.data?.code === 200) vmMetricsData.value = res.data.data?.data ?? res.data.data
|
||||
else ElMessage.warning('暂无指标数据')
|
||||
const now = new Date()
|
||||
const start = new Date(now.getTime() - 60 * 60 * 1000)
|
||||
const res = await getMetricsHistory({
|
||||
service_id: serviceId.value,
|
||||
host_id: vm.host_id || hostId.value,
|
||||
vm_name: vm.name,
|
||||
start: start.toISOString(),
|
||||
end_time: now.toISOString(),
|
||||
interval: '1m'
|
||||
})
|
||||
const body = res?.data
|
||||
if (body?.code === 200 && body?.data) {
|
||||
const arr = Array.isArray(body.data) ? body.data : (body.data.data || [])
|
||||
vmMetricsData.value = arr.length ? arr[arr.length - 1] : null
|
||||
if (!vmMetricsData.value) ElMessage.warning('暂无指标数据')
|
||||
} else {
|
||||
ElMessage.warning('暂无指标数据')
|
||||
}
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取指标失败')) }
|
||||
}
|
||||
|
||||
@@ -773,23 +819,5 @@ defineExpose({ loadList })
|
||||
|
||||
<style scoped>
|
||||
.vm-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.vm-config { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.detail-actions { margin-top: 16px; display: flex; gap: 8px; }
|
||||
.bind-selector-row { display: flex; align-items: center; width: 100%; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
.resource-row { display: flex; gap: 20px; margin-bottom: 18px; }
|
||||
.resource-item { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; }
|
||||
.resource-label { white-space: nowrap; font-size: 14px; color: #606266; flex-shrink: 0; }
|
||||
.resource-unit-select { width: 72px; flex-shrink: 0; }
|
||||
.resource-input { flex: 1; min-width: 0; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="vnc-command-manage">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" :icon="Plus" @click="openCreateGroup">新建分组</el-button>
|
||||
<el-button :icon="Refresh" @click="loadList">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="group-list">
|
||||
<el-empty v-if="!groups.length && !loading" description="暂无指令分组" />
|
||||
|
||||
<el-card v-for="group in groups" :key="group.id" shadow="never" class="group-card">
|
||||
<!-- 分组头部 -->
|
||||
<div class="group-header">
|
||||
<div class="group-title">
|
||||
<div v-if="group.icon" class="group-icon-img">
|
||||
<img :src="group.icon" :alt="group.name" />
|
||||
</div>
|
||||
<span v-else class="group-icon">{{ group.defaultIcon }}</span>
|
||||
<span class="group-name">{{ group.name }}</span>
|
||||
<el-tag size="small" type="info" style="margin-left:8px">{{ (group.items || []).length }} 条指令</el-tag>
|
||||
</div>
|
||||
<div class="group-actions">
|
||||
<el-button link type="primary" size="small" @click="openCreateItem(group)">添加指令</el-button>
|
||||
<el-button link type="primary" size="small" @click="openEditGroup(group)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteGroup(group)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 指令列表 -->
|
||||
<el-table v-if="group.items && group.items.length" :data="group.items" size="small" stripe style="margin-top:8px">
|
||||
<el-table-column prop="label" label="指令名称" min-width="140" />
|
||||
<el-table-column prop="cmd" label="指令内容" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<code class="cmd-text">{{ row.cmd }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变量" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="parseVars(row.vars).length" size="small" type="warning">{{ parseVars(row.vars).length }} 个</el-tag>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort" label="排序" width="70" align="center" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="openEditItem(row, group)">编辑</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDeleteItem(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-else :image-size="40" description="暂无指令" style="padding:12px 0" />
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 新建/编辑分组弹窗 -->
|
||||
<el-dialog v-model="groupDialogVisible" :title="groupForm.id ? '编辑分组' : '新建分组'" width="440px" destroy-on-close>
|
||||
<el-form :model="groupForm" label-width="90px">
|
||||
<el-form-item label="分组名称" required>
|
||||
<el-input v-model="groupForm.name" placeholder="请输入分组名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="文本图标">
|
||||
<el-input v-model="groupForm.defaultIcon" placeholder="如 📚,无文件图标时显示" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标文件">
|
||||
<div class="icon-file-row">
|
||||
<div v-if="groupForm._iconUrl" class="icon-preview">
|
||||
<img :src="groupForm._iconUrl" alt="图标预览" />
|
||||
</div>
|
||||
<el-button size="small" @click="showIconSelector = true">
|
||||
{{ groupForm.iconFileId ? '更换图标' : '选择图标文件' }}
|
||||
</el-button>
|
||||
<el-button v-if="groupForm.iconFileId" size="small" type="danger" plain
|
||||
@click="groupForm.iconFileId = ''; groupForm._iconUrl = ''">清除</el-button>
|
||||
<span v-if="groupForm._iconName" style="font-size:12px;color:#909399;margin-left:6px">{{ groupForm._iconName }}</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="groupForm.sort" :min="0" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="groupDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitGroup">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 图标文件选择器 -->
|
||||
<ImageSelector v-model="showIconSelector" @confirm="onIconSelected" />
|
||||
|
||||
<!-- 新建/编辑指令项弹窗 -->
|
||||
<el-dialog v-model="itemDialogVisible" :title="itemForm.id ? '编辑指令' : '添加指令'" width="560px" destroy-on-close>
|
||||
<el-form :model="itemForm" label-width="90px">
|
||||
<el-form-item label="指令名称" required>
|
||||
<el-input v-model="itemForm.label" placeholder="如:重启 Nginx" />
|
||||
</el-form-item>
|
||||
<el-form-item label="指令内容" required>
|
||||
<el-input v-model="itemForm.cmd" type="textarea" :rows="3"
|
||||
placeholder="支持 %var% 变量占位符,如:cd %path% && ls" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="itemForm.sort" :min="0" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<!-- 变量配置 -->
|
||||
<el-form-item label="变量列表">
|
||||
<div class="vars-editor">
|
||||
<div v-for="(v, i) in varsList" :key="i" class="var-row">
|
||||
<el-input v-model="v.k" placeholder="变量名(如 path)" style="width:140px" />
|
||||
<el-input v-model="v.p" placeholder="提示文字(如 目录路径)" style="flex:1;margin:0 8px" />
|
||||
<el-button :icon="Delete" circle size="small" type="danger" plain @click="varsList.splice(i, 1)" />
|
||||
</div>
|
||||
<el-button size="small" :icon="Plus" @click="varsList.push({ k: '', p: '' })" style="margin-top:6px">添加变量</el-button>
|
||||
<div style="font-size:12px;color:#909399;margin-top:4px">变量在指令中以 %变量名% 形式使用</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="itemDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="submitItem">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, Refresh, Delete } from '@element-plus/icons-vue'
|
||||
import ImageSelector from '@/components/admin/ImageSelector.vue'
|
||||
import {
|
||||
getVncCommandGroupList, createVncCommandGroup, updateVncCommandGroup, deleteVncCommandGroup,
|
||||
createVncCommandItem, updateVncCommandItem, deleteVncCommandItem
|
||||
} from '@/api/admin/vncCommand'
|
||||
import { extractApiError } from '@/utils/kvmErrorUtil'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitLoading = ref(false)
|
||||
const groups = ref([])
|
||||
|
||||
const loadList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getVncCommandGroupList()
|
||||
if (res?.data?.code === 200) {
|
||||
const d = res.data.data
|
||||
groups.value = Array.isArray(d) ? d : (d?.data || d?.list || [])
|
||||
} else { groups.value = [] }
|
||||
} catch { groups.value = [] } finally { loading.value = false }
|
||||
}
|
||||
|
||||
const parseVars = (vars) => {
|
||||
if (!vars) return []
|
||||
try { const v = typeof vars === 'string' ? JSON.parse(vars) : vars; return Array.isArray(v) ? v : [] } catch { return [] }
|
||||
}
|
||||
|
||||
// ---- 分组 ----
|
||||
const groupDialogVisible = ref(false)
|
||||
const showIconSelector = ref(false)
|
||||
const groupForm = reactive({ id: 0, name: '', defaultIcon: '', iconFileId: '', sort: 0 })
|
||||
|
||||
const openCreateGroup = () => {
|
||||
Object.assign(groupForm, { id: 0, name: '', defaultIcon: '', iconFileId: '', sort: 0 })
|
||||
groupDialogVisible.value = true
|
||||
}
|
||||
const openEditGroup = (g) => {
|
||||
Object.assign(groupForm, {
|
||||
id: g.id,
|
||||
name: g.name,
|
||||
defaultIcon: g.defaultIcon || '',
|
||||
iconFileId: g.iconFileId || '',
|
||||
sort: g.sort || 0
|
||||
})
|
||||
// 设置预览数据
|
||||
if (g.icon) {
|
||||
groupForm._iconUrl = g.icon
|
||||
}
|
||||
groupDialogVisible.value = true
|
||||
}
|
||||
const submitGroup = async () => {
|
||||
if (!groupForm.name) { ElMessage.warning('请填写分组名称'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const res = groupForm.id
|
||||
? await updateVncCommandGroup({ id: groupForm.id, name: groupForm.name, defaultIcon: groupForm.defaultIcon, icon_file_id: groupForm.iconFileId, sort: groupForm.sort })
|
||||
: await createVncCommandGroup({ name: groupForm.name, defaultIcon: groupForm.defaultIcon, icon_file_id: groupForm.iconFileId, sort: groupForm.sort })
|
||||
if (res?.data?.code === 200) { ElMessage.success(groupForm.id ? '修改成功' : '创建成功'); groupDialogVisible.value = false; loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
|
||||
}
|
||||
const handleDeleteGroup = (g) => {
|
||||
ElMessageBox.confirm(`确定删除分组「${g.name}」?将同时删除该分组下所有指令!`, '删除确认', { type: 'error' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await deleteVncCommandGroup({ id: g.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// ---- 指令项 ----
|
||||
const itemDialogVisible = ref(false)
|
||||
const itemForm = reactive({ id: 0, group_id: 0, label: '', cmd: '', sort: 0 })
|
||||
const varsList = ref([])
|
||||
|
||||
const openCreateItem = (group) => {
|
||||
Object.assign(itemForm, { id: 0, group_id: group.id, label: '', cmd: '', sort: 0 })
|
||||
varsList.value = []
|
||||
itemDialogVisible.value = true
|
||||
}
|
||||
const openEditItem = (item, group) => {
|
||||
Object.assign(itemForm, { id: item.id, group_id: group.id, label: item.label, cmd: item.cmd, sort: item.sort || 0 })
|
||||
varsList.value = parseVars(item.vars).map(v => ({ k: v.k, p: v.p }))
|
||||
itemDialogVisible.value = true
|
||||
}
|
||||
const submitItem = async () => {
|
||||
if (!itemForm.label) { ElMessage.warning('请填写指令名称'); return }
|
||||
if (!itemForm.cmd) { ElMessage.warning('请填写指令内容'); return }
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const validVars = varsList.value.filter(v => v.k)
|
||||
const varsJson = validVars.length ? JSON.stringify(validVars) : ''
|
||||
const res = itemForm.id
|
||||
? await updateVncCommandItem({ id: itemForm.id, label: itemForm.label, cmd: itemForm.cmd, sort: itemForm.sort, vars: varsJson })
|
||||
: await createVncCommandItem({ group_id: itemForm.group_id, label: itemForm.label, cmd: itemForm.cmd, sort: itemForm.sort, vars: varsJson })
|
||||
if (res?.data?.code === 200) { ElMessage.success(itemForm.id ? '修改成功' : '创建成功'); itemDialogVisible.value = false; loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, '操作失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) } finally { submitLoading.value = false }
|
||||
}
|
||||
const handleDeleteItem = (item) => {
|
||||
ElMessageBox.confirm(`确定删除指令「${item.label}」?`, '删除确认', { type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await deleteVncCommandItem({ id: item.id })
|
||||
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, '删除失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// 图标选择处理
|
||||
const onIconSelected = (selectedFile) => {
|
||||
if (selectedFile && selectedFile.id) {
|
||||
groupForm.iconFileId = selectedFile.id
|
||||
// 添加预览URL和文件名用于显示
|
||||
groupForm._iconUrl = selectedFile.url
|
||||
groupForm._iconName = selectedFile.realName
|
||||
}
|
||||
showIconSelector.value = false
|
||||
}
|
||||
|
||||
loadList()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vnc-command-manage { padding: 20px; }
|
||||
.toolbar { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
.group-list { display: flex; flex-direction: column; gap: 16px; }
|
||||
.group-card { border-radius: 8px; }
|
||||
.group-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.group-title { display: flex; align-items: center; gap: 6px; }
|
||||
.group-icon { font-size: 18px; }
|
||||
.group-icon-img { width: 24px; height: 24px; border-radius: 4px; overflow: hidden; display: flex; align-items: center; justify-content: center; }
|
||||
.group-icon-img img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.group-name { font-size: 15px; font-weight: 600; color: #303133; }
|
||||
.group-actions { display: flex; gap: 4px; }
|
||||
.cmd-text { font-family: monospace; font-size: 12px; color: #409eff; background: #f0f7ff; padding: 2px 6px; border-radius: 4px; }
|
||||
.vars-editor { width: 100%; }
|
||||
.var-row { display: flex; align-items: center; margin-bottom: 8px; }
|
||||
.icon-file-row { display: flex; align-items: center; gap: 8px; }
|
||||
.icon-preview { width: 32px; height: 32px; border-radius: 4px; overflow: hidden; border: 1px solid #dcdfe6; }
|
||||
.icon-preview img { width: 100%; height: 100%; object-fit: cover; }
|
||||
</style>
|
||||
@@ -357,21 +357,10 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
.vnc-node-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.host-addr { font-family: 'Consolas', 'Monaco', monospace; color: #409eff; font-size: 13px; }
|
||||
.token-mask { font-family: 'Consolas', 'Monaco', monospace; color: #909399; font-size: 13px; }
|
||||
.text-muted { color: #c0c4cc; }
|
||||
.test-result { display: flex; align-items: center; gap: 8px; padding: 12px; border-radius: 4px; margin-top: 12px; font-size: 14px; }
|
||||
.test-result.success { background: #f0f9eb; color: #67c23a; }
|
||||
.test-result.error { background: #fef0f0; color: #f56c6c; }
|
||||
.vnc-result { margin-top: 12px; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
</style>
|
||||
|
||||
@@ -56,8 +56,11 @@
|
||||
<el-dialog v-model="resizeDialogVisible" title="调整数据卷大小" width="400px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="当前大小">{{ detail?.size || 0 }} GB</el-form-item>
|
||||
<el-form-item label="新大小(GB)">
|
||||
<el-input-number v-model="newSize" :min="1" controls-position="right" style="width: 100%" />
|
||||
<el-form-item label="新大小">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="resizeForm.size" :min="1" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="resizeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -107,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
|
||||
@@ -165,16 +168,18 @@ const loadDetail = async () => {
|
||||
|
||||
// 调整大小
|
||||
const resizeDialogVisible = ref(false)
|
||||
const newSize = ref(1)
|
||||
const resizeForm = reactive({ size: 1, _sizeUnit: 'GB' })
|
||||
const handleResize = () => {
|
||||
if (!detail.value) return
|
||||
newSize.value = detail.value.size || 10
|
||||
resizeForm.size = detail.value.size || 10
|
||||
resizeForm._sizeUnit = 'GB'
|
||||
resizeDialogVisible.value = true
|
||||
}
|
||||
const submitResize = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const res = await resizeVolume({ service_id: serviceId.value, volume_id: volumeId.value, size: newSize.value })
|
||||
const sizeGb = resizeForm._sizeUnit === 'TB' ? resizeForm.size * 1024 : resizeForm.size
|
||||
const res = await resizeVolume({ service_id: serviceId.value, volume_id: volumeId.value, size: sizeGb })
|
||||
if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadDetail() }
|
||||
else ElMessage.error(extractApiError(res?.data, '调整失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { actionLoading.value = false }
|
||||
@@ -285,7 +290,7 @@ onMounted(() => { isPageActive = true; initPage() })
|
||||
.main-content { padding: 20px; }
|
||||
.info-card { margin-bottom: 20px; }
|
||||
.card-title { font-weight: 600; font-size: 15px; color: #303133; }
|
||||
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
|
||||
.bind-selector-row { display: flex; align-items: center; width: 100%; }
|
||||
.action-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-select { width: 90px; flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
@@ -77,7 +77,12 @@
|
||||
<el-dialog v-model="createDialogVisible" title="创建数据卷" width="560px" destroy-on-close>
|
||||
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px">
|
||||
<el-form-item label="名称" prop="name"><el-input v-model="createForm.name" placeholder="数据卷名称" /></el-form-item>
|
||||
<el-form-item label="大小(GB)" prop="size"><el-input-number v-model="createForm.size" :min="1" controls-position="right" style="width: 100%" /></el-form-item>
|
||||
<el-form-item label="大小" prop="size">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="createForm.size" :min="1" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="createForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="宿主机" prop="host_id">
|
||||
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%">
|
||||
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
|
||||
@@ -110,8 +115,11 @@
|
||||
<el-dialog v-model="resizeDialogVisible" title="调整数据卷大小" width="400px" destroy-on-close>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="当前大小">{{ resizeTarget?.size || 0 }} GB</el-form-item>
|
||||
<el-form-item label="新大小(GB)">
|
||||
<el-input-number v-model="newSize" :min="1" controls-position="right" style="width: 100%" />
|
||||
<el-form-item label="新大小">
|
||||
<div class="unit-input-row">
|
||||
<el-input-number v-model="resizeForm.size" :min="1" controls-position="right" style="flex:1" />
|
||||
<el-select v-model="resizeForm._sizeUnit" class="unit-select"><el-option label="GB" value="GB" /><el-option label="TB" value="TB" /></el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -236,7 +244,7 @@ const createFormRef = ref(null)
|
||||
const resizeDialogVisible = ref(false)
|
||||
const mountDialogVisible = ref(false)
|
||||
const resizeTarget = ref(null)
|
||||
const newSize = ref(1)
|
||||
const resizeForm = reactive({ size: 1, _sizeUnit: 'GB' })
|
||||
const mountTarget = ref(null)
|
||||
const mountVmId = ref(0)
|
||||
const mountVmName = ref('')
|
||||
@@ -262,7 +270,7 @@ const showVmSelector = ref(false)
|
||||
const showMountVmSelector = ref(false)
|
||||
|
||||
const createForm = reactive({
|
||||
name: '', size: 10, host_id: 0, is_system: false,
|
||||
name: '', size: 10, _sizeUnit: 'GB', host_id: 0, is_system: false,
|
||||
image_id: 0, vm_id: 0, target_device: '',
|
||||
_imageName: '', _vmName: ''
|
||||
})
|
||||
@@ -301,7 +309,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
|
||||
|
||||
const handleAdd = () => {
|
||||
Object.assign(createForm, {
|
||||
name: '', size: 10, host_id: hostId.value || '',
|
||||
name: '', size: 10, _sizeUnit: 'GB', host_id: hostId.value || '',
|
||||
is_system: false, image_id: '', vm_id: '', target_device: '',
|
||||
_imageName: '', _vmName: ''
|
||||
})
|
||||
@@ -313,9 +321,10 @@ const submitCreate = () => {
|
||||
if (!valid) return
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const sizeGb = createForm._sizeUnit === 'TB' ? createForm.size * 1024 : createForm.size
|
||||
const payload = {
|
||||
service_id: serviceId.value,
|
||||
name: createForm.name, size: createForm.size,
|
||||
name: createForm.name, size: sizeGb,
|
||||
host_id: createForm.host_id, is_system: createForm.is_system
|
||||
}
|
||||
if (createForm.image_id) payload.image_id = createForm.image_id
|
||||
@@ -328,12 +337,18 @@ const submitCreate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleResize = (row) => { resizeTarget.value = row; newSize.value = row.size || 10; resizeDialogVisible.value = true }
|
||||
const handleResize = (row) => {
|
||||
resizeTarget.value = row
|
||||
resizeForm.size = row.size || 10
|
||||
resizeForm._sizeUnit = 'GB'
|
||||
resizeDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitResize = async () => {
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const res = await resizeVolume({ service_id: serviceId.value, volume_id: resizeTarget.value.id, size: newSize.value })
|
||||
const sizeGb = resizeForm._sizeUnit === 'TB' ? resizeForm.size * 1024 : resizeForm.size
|
||||
const res = await resizeVolume({ service_id: serviceId.value, volume_id: resizeTarget.value.id, size: sizeGb })
|
||||
if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadList() }
|
||||
else ElMessage.error(extractApiError(res?.data, '调整失败'))
|
||||
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { submitLoading.value = false }
|
||||
@@ -434,16 +449,6 @@ defineExpose({ loadList })
|
||||
|
||||
<style scoped>
|
||||
.volume-manage-container { padding: 20px; }
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
|
||||
.sub-info { font-size: 13px; color: #909399; }
|
||||
.header-right { display: flex; gap: 8px; }
|
||||
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
|
||||
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.bind-selector-row { display: flex; align-items: center; width: 100%; }
|
||||
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
|
||||
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
|
||||
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
|
||||
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
|
||||
.unit-select { width: 90px; flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
**使用场景:** 将 `openapi.json` 拖入 Cursor,打开你的项目根目录,发送以下 Prompt:
|
||||
|
||||
> **Prompt:**
|
||||
> 我现在正在对接用户商品管理模块,API 定义在 `@ApiServer-web-admin_dashboard_pc/默认模块.openapi.json` 中。
|
||||
> 我现在正在对接虚拟化平台管理模块,API 定义在 `@ApiServer-web-admin_dashboard_pc/默认模块.openapi.json` 中。
|
||||
>
|
||||
> 请你执行以下任务:
|
||||
> 1. **接口完整性对比:** 分析该 OpenAPI 文件中所有以 `/product` 或 `user` 开头的接口。对比我当前项目中 `src/api/product.ts`(或对应目录)的实现,列出缺少实现的接口、参数定义不一致的接口。
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
# 新增接口对接文档
|
||||
|
||||
> 来源:`默认模块.openapi.json` 与 `src/api/admin/kvmService.js` 对比
|
||||
> 生成时间:2026-03-21
|
||||
|
||||
---
|
||||
|
||||
## 一、新增接口总览
|
||||
|
||||
| # | 模块 | 接口路径 | 方法 | 说明 | 前端状态 |
|
||||
|---|------|---------|------|------|---------|
|
||||
| 1 | 快照管理 | `/api/v1/admin/server/host_service/point/snapshot/count` | GET | 获取快照数量与上限 | **新增** |
|
||||
| 2 | 快照管理 | `/api/v1/admin/server/host_service/point/snapshot/set_limit` | POST | 设置快照数量上限 | **新增** |
|
||||
| 3 | 备份管理 | `/api/v1/admin/server/host_service/point/backup/count` | GET | 获取备份数量与上限 | **新增** |
|
||||
| 4 | 备份管理 | `/api/v1/admin/server/host_service/point/backup/set_limit` | POST | 设置备份数量上限 | **新增** |
|
||||
| 5 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/create` | POST | 创建用户组网 | **新增(全新模块)** |
|
||||
| 6 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/assign` | POST | 为虚拟机分配组网IP | **新增(全新模块)** |
|
||||
| 7 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/list` | GET | 获取组网列表 | **新增(全新模块)** |
|
||||
| 8 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/detail` | GET | 获取组网详情 | **新增(全新模块)** |
|
||||
| 9 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/delete` | DELETE | 删除组网 | **新增(全新模块)** |
|
||||
| 10 | 用户组网 | `/api/v1/admins/service/host_service/point/networking/remove_network` | POST | 删除组网下指定网络 | **新增(全新模块)** |
|
||||
|
||||
> **注意**:用户组网(Networking)接口的URL前缀为 `/api/v1/admins/service/`(admins复数、service单数),与其他接口 `/api/v1/admin/server/` 不同。
|
||||
|
||||
---
|
||||
|
||||
## 二、接口详细说明
|
||||
|
||||
### 2.1 快照管理 - 新增接口
|
||||
|
||||
#### 2.1.1 获取快照数量与上限
|
||||
|
||||
- **路径**:`GET /api/v1/admin/server/host_service/point/snapshot/count`
|
||||
- **标签**:管理员-快照
|
||||
- **请求参数**(Query):
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| service_id | integer | 是 | KVM服务ID |
|
||||
| vm_id | integer | 是 | 虚拟机ID |
|
||||
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"vm_id": 1,
|
||||
"count": 3,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| vm_id | int64 | 虚拟机ID |
|
||||
| count | uint32 | 当前快照数量 |
|
||||
| limit | uint32 | 快照上限 |
|
||||
|
||||
- **前端函数名**:`getSnapshotCount`
|
||||
|
||||
---
|
||||
|
||||
#### 2.1.2 设置快照数量上限
|
||||
|
||||
- **路径**:`POST /api/v1/admin/server/host_service/point/snapshot/set_limit`
|
||||
- **标签**:管理员-快照
|
||||
- **Content-Type**:`multipart/form-data`
|
||||
- **请求参数**(FormData):
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| service_id | integer | 是 | KVM服务ID |
|
||||
| vm_id | integer | 是 | 虚拟机ID |
|
||||
| limit | integer | 是 | 快照数量上限 |
|
||||
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"vm_id": 1,
|
||||
"count": 3,
|
||||
"limit": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **前端函数名**:`setSnapshotLimit`
|
||||
|
||||
---
|
||||
|
||||
### 2.2 备份管理 - 新增接口
|
||||
|
||||
#### 2.2.1 获取备份数量与上限
|
||||
|
||||
- **路径**:`GET /api/v1/admin/server/host_service/point/backup/count`
|
||||
- **标签**:管理员-备份
|
||||
- **请求参数**(Query):
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| service_id | integer | 是 | KVM服务ID |
|
||||
| vm_id | integer | 是 | 虚拟机ID |
|
||||
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"vm_id": 1,
|
||||
"count": 2,
|
||||
"limit": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| vm_id | int64 | 虚拟机ID |
|
||||
| count | uint32 | 当前备份数量 |
|
||||
| limit | uint32 | 备份上限 |
|
||||
|
||||
- **前端函数名**:`getBackupCount`
|
||||
|
||||
---
|
||||
|
||||
#### 2.2.2 设置备份数量上限
|
||||
|
||||
- **路径**:`POST /api/v1/admin/server/host_service/point/backup/set_limit`
|
||||
- **标签**:管理员-备份
|
||||
- **Content-Type**:`multipart/form-data`
|
||||
- **请求参数**(FormData):
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| service_id | integer | 是 | KVM服务ID |
|
||||
| vm_id | integer | 是 | 虚拟机ID |
|
||||
| limit | integer | 是 | 备份数量上限 |
|
||||
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"vm_id": 1,
|
||||
"count": 2,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **前端函数名**:`setBackupLimit`
|
||||
|
||||
---
|
||||
|
||||
### 2.3 用户组网管理(全新模块 KVM-UserNetworking)
|
||||
|
||||
> **URL 前缀注意**:此模块所有接口使用 `/api/v1/admins/service/` 前缀
|
||||
|
||||
#### 2.3.1 获取组网列表
|
||||
|
||||
- **路径**:`GET /api/v1/admins/service/host_service/point/networking/list`
|
||||
- **请求参数**(Query):
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| service_id | integer | 是 | KVM 服务 ID |
|
||||
| page | integer | 否 | 页码 |
|
||||
| count | integer | 否 | 每页数量 |
|
||||
| host_id | integer | 否 | 按宿主机 ID 筛选 |
|
||||
| user_id | integer | 否 | 按用户 ID 筛选 |
|
||||
| keyword | string | 否 | 关键词搜索 |
|
||||
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": { "count": 10 },
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "组网名称",
|
||||
"description": "描述",
|
||||
"user_id": 1,
|
||||
"host_id": 1,
|
||||
"bridge_name": "br0",
|
||||
"gateway": "192.168.1.1",
|
||||
"created_at": "2026-03-21T00:00:00Z",
|
||||
"updated_at": "2026-03-21T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **前端函数名**:`getUserNetworkingList`
|
||||
|
||||
---
|
||||
|
||||
#### 2.3.2 获取组网详情
|
||||
|
||||
- **路径**:`GET /api/v1/admins/service/host_service/point/networking/detail`
|
||||
- **请求参数**(Query):
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| service_id | integer | 是 | KVM 服务 ID |
|
||||
| networking_id | integer | 是 | 组网 ID |
|
||||
|
||||
- **响应**:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": { "id": 1, "name": "...", "..." },
|
||||
"networks": [
|
||||
{
|
||||
"network": {},
|
||||
"vm_id": 1,
|
||||
"vm_name": "vm-1",
|
||||
"vm_status": "running"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- **前端函数名**:`getUserNetworkingDetail`
|
||||
|
||||
---
|
||||
|
||||
#### 2.3.3 创建用户组网
|
||||
|
||||
- **路径**:`POST /api/v1/admins/service/host_service/point/networking/create`
|
||||
- **Content-Type**:`multipart/form-data`
|
||||
- **请求参数**(FormData):
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| service_id | integer | 是 | KVM 服务 ID |
|
||||
| name | string | 是 | 组网名称 |
|
||||
| description | string | 否 | 组网描述 |
|
||||
| user_id | integer | 是 | 用户 ID |
|
||||
| host_id | integer | 是 | 宿主机 ID |
|
||||
| bridge_name | string | 是 | 网桥名称 |
|
||||
| gateway | string | 是 | 网关地址 |
|
||||
|
||||
- **响应**:返回 `UserNetworkingData` 对象
|
||||
- **前端函数名**:`createUserNetworking`
|
||||
|
||||
---
|
||||
|
||||
#### 2.3.4 为虚拟机分配组网 IP
|
||||
|
||||
- **路径**:`POST /api/v1/admins/service/host_service/point/networking/assign`
|
||||
- **Content-Type**:`multipart/form-data`
|
||||
- **请求参数**(FormData):
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| service_id | integer | 是 | KVM 服务 ID |
|
||||
| networking_id | integer | 是 | 组网 ID |
|
||||
| vm_id | integer | 是 | 虚拟机 ID |
|
||||
| ip | string | 否 | 指定 IP,不传则自动分配 |
|
||||
|
||||
- **响应**:返回 `networking`、`network`、`task` 三个对象
|
||||
- **前端函数名**:`assignUserNetworking`
|
||||
|
||||
---
|
||||
|
||||
#### 2.3.5 删除组网
|
||||
|
||||
- **路径**:`DELETE /api/v1/admins/service/host_service/point/networking/delete`
|
||||
- **请求参数**(Query):
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| service_id | integer | 是 | KVM 服务 ID |
|
||||
| networking_id | integer | 是 | 组网 ID |
|
||||
|
||||
- **前端函数名**:`deleteUserNetworking`
|
||||
|
||||
---
|
||||
|
||||
#### 2.3.6 删除组网下的指定网络
|
||||
|
||||
- **路径**:`POST /api/v1/admins/service/host_service/point/networking/remove_network`
|
||||
- **Content-Type**:`multipart/form-data`
|
||||
- **请求参数**(FormData):
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| service_id | integer | 是 | KVM 服务 ID |
|
||||
| networking_id | integer | 是 | 组网 ID |
|
||||
| network_id | integer | 是 | 网络 ID |
|
||||
| vm_id | integer | 是 | 虚拟机 ID(用于从 VM 中移除网络) |
|
||||
|
||||
- **前端函数名**:`removeUserNetworkingNetwork`
|
||||
|
||||
---
|
||||
|
||||
## 三、数据模型
|
||||
|
||||
### UserNetworkingData
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | 组网ID |
|
||||
| name | string | 组网名称 |
|
||||
| description | string | 描述 |
|
||||
| user_id | int64 | 用户ID |
|
||||
| host_id | int64 | 宿主机ID |
|
||||
| bridge_name | string | 网桥名称 |
|
||||
| gateway | string | 网关地址 |
|
||||
| created_at | datetime | 创建时间 |
|
||||
| updated_at | datetime | 更新时间 |
|
||||
|
||||
### NetworkingNetworkItem
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| network | Network | 网络对象 |
|
||||
| vm_id | int64 | 虚拟机ID |
|
||||
| vm_name | string | 虚拟机名称 |
|
||||
| vm_status | string | 虚拟机状态 |
|
||||
|
||||
---
|
||||
|
||||
## 四、已有接口(已对接,无需修改)
|
||||
|
||||
以下接口在 `kvmService.js` 中已存在,无需新增:
|
||||
|
||||
- 主控服务管理:list / detail / create / update / delete(5个)
|
||||
- 宿主机组映射管理:list / sync / bind / update / generate_goods / delete(6个)
|
||||
- 远程宿主机组管理:list / detail / tree / optimal_host / create / update / delete(7个)
|
||||
- 宿主机管理:list / detail / metrics / add / update / delete(6个)
|
||||
- 镜像管理:list / detail / host_status / create / update / delete / reload / sync / reload_host / compare_host(10个)
|
||||
- 网络管理:list / detail / create / update / delete(5个)
|
||||
- 数据卷管理:list / detail / create / resize / mount / unmount / transfer / delete(8个)
|
||||
- 虚拟机管理:list / detail / status / metrics / create / update / rebuild / refactor / update_traffic / start / stop / reboot / suspend / resume / rescue / exit_rescue / delete(17个)
|
||||
- 安全组管理:list / detail / create / update / sync / bind / unbind / delete / enable_whitelist / disable_whitelist / create_rule / update_rule / delete_rule / apply / set_shared(15个)
|
||||
- VNC节点管理:list / vm_vnc / add / test / update / delete(6个)
|
||||
- 快照管理:list / progress / create / restore / delete(5个)
|
||||
- 备份管理:list / progress / create / restore / delete(5个)
|
||||
|
||||
**共计已对接 95 个接口,新增 10 个接口待对接。**
|
||||
@@ -1,96 +1,17 @@
|
||||
✅已完成、⚠️部分完成、❌未完成这样显示
|
||||
-----------------------------------------------------------------------------------------------需要解决
|
||||
|
||||
接口路径 方法 功能描述 已实现 潜在风险 / 待修复点
|
||||
一、ApiServer-web-admin_dashboard_pc
|
||||
|
||||
.../snapshot/progress GET 快照进度 是 getUserVmSnapshotProgress task_id 类型为 string,前端需确保传字符串而非整数
|
||||
.../snapshot/count GET 快照数量 是 getUserVmSnapshotCount 无
|
||||
.../snapshot/set_limit POST 设置快照上限 是 setUserVmSnapshotLimit 无
|
||||
五、备份 (Backup) — 7 个接口
|
||||
接口路径 方法 功能描述 已实现 潜在风险 / 待修复点
|
||||
.../backup/list GET 备份列表 是 getUserVmBackupList 同快照 list,无分页参数
|
||||
.../backup/create POST 创建备份 是 createUserVmBackup 无
|
||||
.../backup/restore POST 恢复备份 是 restoreUserVmBackup 无
|
||||
.../backup/delete POST 删除备份 是 deleteUserVmBackup 同快照 delete,用 POST 而非 DELETE
|
||||
.../backup/progress GET 备份进度 是 getUserVmBackupProgress task_id 类型为 string
|
||||
.../backup/count GET 备份数量 是 getUserVmBackupCount 无
|
||||
.../backup/set_limit POST 设置备份上限 是 setUserVmBackupLimit 无
|
||||
六、安全组 (PostGroup) — 15 个接口
|
||||
接口路径 方法 功能描述 已实现 潜在风险 / 待修复点
|
||||
.../post_group/list GET 安全组列表 是 getUserVmPostGroupList 无分页参数,仅有 keyword 搜索
|
||||
.../post_group/detail GET 安全组详情 是 getUserVmPostGroupDetail 无
|
||||
.../post_group/user_list GET 用户安全组列表 是 getUserVmPostGroupUserList 注意:分页参数名为 page_size(非 count),与其他接口不一致
|
||||
.../post_group/create POST 创建安全组 是 createUserVmPostGroup lock/drop_all 为 boolean,但 FormData 会序列化为字符串 "true"/"false",需后端兼容
|
||||
.../post_group/update POST 修改安全组 是 updateUserVmPostGroup 同上 boolean 问题
|
||||
.../post_group/bind POST 绑定安全组 是 bindUserVmPostGroup 无
|
||||
.../post_group/unbind POST 解绑安全组 是 unbindUserVmPostGroup 无
|
||||
.../post_group/apply POST 应用安全组 是 applyUserVmPostGroup 无
|
||||
.../post_group/set_shared POST 设置共享 是 setSharedUserVmPostGroup shared 为 boolean,同上 FormData 序列化问题
|
||||
.../post_group/delete DELETE 删除安全组 是 deleteUserVmPostGroup 无
|
||||
.../post_group/enable_whitelist POST 启用白名单 是 enableUserVmPostGroupWhitelist 无
|
||||
.../post_group/disable_whitelist POST 禁用白名单 是 disableUserVmPostGroupWhitelist 无
|
||||
.../post_group/create_rule POST 创建规则 是 createUserVmPostGroupRule 无
|
||||
.../post_group/update_rule POST 修改规则 是 updateUserVmPostGroupRule 无
|
||||
.../post_group/delete_rule DELETE 删除规则 是 deleteUserVmPostGroupRule 无
|
||||
七、网络 & 组网 (Network / Networking) — 7 个接口
|
||||
接口路径 方法 功能描述 已实现 潜在风险 / 待修复点
|
||||
.../network/list GET 网络列表 是 getUserVmNetworkList 无
|
||||
.../network/detail GET 网络详情 是 getUserVmNetworkDetail OpenAPI 有可选参数 host_id,前端需视情况传递
|
||||
.../networking/list GET 组网列表 是 getUserVmNetworkingList 无
|
||||
.../networking/detail GET 组网详情 是 getUserVmNetworkingDetail 无
|
||||
.../networking/create POST 创建组网 是 createUserVmNetworking 无
|
||||
.../networking/assign POST 分配组网 IP 是 assignUserVmNetworking 无
|
||||
.../networking/remove_network POST 移除组网网络 是 removeUserVmNetworkingNetwork 无
|
||||
.../networking/delete DELETE 删除组网 是 deleteUserVmNetworking 无
|
||||
八、总结
|
||||
接口完整性
|
||||
OpenAPI 定义的 68 个接口全部已在 src/api/admin/userVm.js 中实现,HTTP 方法和路径均一致,无缺失接口。
|
||||
需要关注的潜在风险(按严重程度排序)
|
||||
优先级 风险点 涉及位置 说明 状态
|
||||
✅高 user_goods/create 缺少 item_id UserGoodsList.vue 新增/编辑表单 已添加 item_id 归属项字段,普通商品直接赋值商品ID,云服务器弹出虚拟机列表选择 ✅已完成
|
||||
✅高 user_goods/list 缺少筛选参数 UserGoodsList.vue 已添加 user_id 和 good_id 筛选输入框,支持按用户ID和商品ID过滤 ✅已完成
|
||||
✅中 post_group/user_list 分页参数名不一致 UserVmSecurityGroupSelector.vue 前端调用处已正确使用 page_size 参数名 ✅已完成(无需修改)
|
||||
✅中 Boolean 字段通过 FormData 传递 安全组 create/update/set_shared fd() 已增加 boolean 类型显式转换为 "true"/"false";SecurityGroupDetail.vue 和 VmDetail.vue 手动 append 处也已修复 ✅已完成
|
||||
⚠️中 snapshot/list 和 backup/list 无分页 快照/备份相关页面 OpenAPI 未定义分页参数,如数据量大可能一次返回全部 ⚠️后端限制,待后端支持分页
|
||||
✅低 task_id 类型为 string 快照/备份进度查询 所有调用处已使用 String() 确保传字符串 ✅已完成(无需修改)
|
||||
✅低 UserGoodsDetail.vue 基础价格显示用了 renewPrice UserGoodsDetail.vue 第 62 行 已修复为 basePrice ✅已完成
|
||||
✅低 fd() 过滤空字符串 userVm.js fd() 函数 已移除 v === '' 过滤条件,允许空字符串传递(用于清除字段) ✅已完成
|
||||
|
||||
第二阶段:功能开发与组件化审查结果
|
||||
二、ApiServer-web-user_dashboard_pc
|
||||
|
||||
一、请求实现
|
||||
✅ OpenAPI 定义的 69 个接口(user_vm 64 + user_goods 5)全部在 src/api/admin/userVm.js 中实现
|
||||
✅ 商品管理 33 个接口全部在 src/api/admin/product.js 中实现(group 6 + goods 5 + spec 8 + plan 9 + group_tag 5)
|
||||
✅ 无缺失接口
|
||||
|
||||
二、组件化拆分
|
||||
已创建的公共组件:
|
||||
|
||||
| 组件 | 路径 | Props | 复用场景 |
|
||||
|------|------|-------|----------|
|
||||
| FormSelectorField | src/components/common/FormSelectorField.vue | modelValue, displayText, placeholder, buttonText, disabled, clearable, hint, hintType | 所有"只读输入框+选择按钮+清除按钮"的选择器行(50+处) |
|
||||
三、ApiServer-Web-home
|
||||
1.图一, 用户首页购买页的参数的判断依据全部根据参数里面的 key 进行标识,然后检查一下 选择系统盘大小(GB) 为什么是空的,内存的显示改成GB但是传入还是要KB
|
||||
|
||||
已补充的工具函数(src/utils/tool.js):
|
||||
|
||||
| 函数 | 用途 | 复用场景 |
|
||||
|------|------|----------|
|
||||
| formatPrice(fen) | 分→元显示 ¥xx.xx | 25+个文件的价格展示 |
|
||||
| yuanToFen(yuan) | 元→分转换 | 所有提交价格的表单 |
|
||||
| formatExpireTime(t) | 到期时间格式化(<2000年显示永久) | 28+个文件 |
|
||||
|
||||
三、嵌套与快捷入口评估
|
||||
⚠️ ProductGroup.vue(3281行)是当前最大的单体文件,集中了 5 个 CRUD 模块和 11 个弹窗:
|
||||
1. 商品分组管理(树形/列表 + CRUD)
|
||||
2. 分组标签管理(列表 + CRUD)
|
||||
3. 商品表单(新增/编辑弹窗)
|
||||
4. 商品参数管理(参数列表 + 参数值管理 2级嵌套弹窗)
|
||||
5. 商品套餐管理(套餐列表 + 套餐表单 + 参数配置预览)
|
||||
|
||||
推荐拆分方案(待确认后执行):
|
||||
❌ ProductParameterManager.vue - 提取参数管理弹窗(参数列表+参数值管理+参数表单,约 300 行模板 + 400 行逻辑)
|
||||
❌ ProductPlanManager.vue - 提取套餐管理弹窗(套餐列表+套餐表单+参数配置,约 250 行模板 + 500 行逻辑)
|
||||
❌ GroupTagManager.vue - 提取分组标签Tab页(标签列表+标签表单,约 80 行模板 + 150 行逻辑)
|
||||
拆分后 ProductGroup.vue 可从 3281 行降至约 1600 行
|
||||
|
||||
-----------------------------------------------------------------------------------------------需要解决
|
||||
1.请求接口的带有page-size或者是count参数的都只能是10
|
||||
|
||||
+220
-4334
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user