fix: 提交修改
Build and Deploy Vue3 / build (push) Successful in 1m31s
Build and Deploy Vue3 / deploy (push) Successful in 1m9s

This commit is contained in:
2026-04-15 16:02:36 +08:00
parent 2f06aa9f5f
commit b3ed406f84
61 changed files with 7476 additions and 7226 deletions
+72 -1
View File
@@ -226,11 +226,16 @@ html, body {
color: #3498db !important; color: #3498db !important;
} }
/* 卡片扁平化 */ /* 卡片扁平化 + 层次感 */
.el-card { .el-card {
border-radius: 0 !important; border-radius: 0 !important;
border: 1px solid #e1e8ed !important; border: 1px solid #e1e8ed !important;
box-shadow: none !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 { .el-dialog .el-form-item {
margin-bottom: 20px; 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> </style>
+22 -3
View File
@@ -145,6 +145,11 @@ export const getRemoteHostMetrics = (params) => {
return http2.get('/api/v1/admin/server/host_service/point/host/metrics', { 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) => { export const addRemoteHost = (data) => {
return http2.post('/api/v1/admin/server/host_service/point/host/add', 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 }) 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) => { 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' } headers: { 'Content-Type': 'multipart/form-data' }
}) })
} }
/** 发起虚拟机数据迁移 */ /** 发起虚拟机数据迁移 */
export const dataMigrateVm = (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' } headers: { 'Content-Type': 'multipart/form-data' }
}) })
} }
/** 获取虚拟机数据迁移进度 */ /** 获取虚拟机数据迁移进度 */
export const getDataMigrateProgress = (params) => { 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' }
})
} }
/** /**
+7
View File
@@ -23,6 +23,7 @@ export const getUserVmList = (params) => http2.get(`${BASE}/list`, { params })
export const getUserVmDetail = (params) => http2.get(`${BASE}/detail`, { params }) export const getUserVmDetail = (params) => http2.get(`${BASE}/detail`, { params })
export const getUserVmVnc = (params) => http2.get(`${BASE}/vnc`, { params }) export const getUserVmVnc = (params) => http2.get(`${BASE}/vnc`, { params })
export const getUserVmHostImages = (params) => http2.get(`${BASE}/host_images`, { 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 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 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' } }) 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 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 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 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' } })
+24
View File
@@ -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 })
+11 -7
View File
@@ -1,20 +1,20 @@
<template> <template>
<el-dialog <el-dialog
v-model="visible" v-model="visible"
title="选择头像" :title="title"
width="800px" width="800px"
append-to-body append-to-body
@close="handleClose" @close="handleClose"
> >
<div class="avatar-selector"> <div class="avatar-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick"> <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-container">
<div class="file-list-header"> <div class="file-list-header">
<h4>用户文件列表</h4> <h4>文件列表</h4>
<el-button type="primary" @click="switchToUpload" :icon="Upload"> <el-button type="primary" @click="switchToUpload" :icon="Upload">
上传新头像 上传新文件
</el-button> </el-button>
</div> </div>
<div class="file-grid" v-loading="loading"> <div class="file-grid" v-loading="loading">
@@ -58,8 +58,8 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<!-- 上传头像 --> <!-- 上传文件 -->
<el-tab-pane label="上传头像" name="upload"> <el-tab-pane label="上传文件" name="upload">
<div class="upload-section"> <div class="upload-section">
<el-upload <el-upload
:http-request="handleUpload" :http-request="handleUpload"
@@ -118,6 +118,10 @@ import { closeAllMessage } from '../../utils/message'
currentCoverId: { currentCoverId: {
type: [String, Number], type: [String, Number],
default: '' default: ''
},
title: {
type: String,
default: '选择文件'
} }
}) })
+26 -6
View File
@@ -40,11 +40,14 @@
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue' import { Search, Refresh } from '@element-plus/icons-vue'
import { getImageList } from '@/api/admin/kvmService' import { getImageList } from '@/api/admin/kvmService'
import { getUserVmHostImages, getGoodHostGroupImages } from '@/api/admin/userVm'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
serviceId: { type: Number, default: 0 }, 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']) const emit = defineEmits(['update:modelValue', 'confirm'])
@@ -70,14 +73,31 @@ const handleSearch = () => { page.value = 1; loadList() }
const loadList = async () => { const loadList = async () => {
loading.value = true loading.value = true
try { try {
const params = { service_id: props.serviceId, page: page.value, count: pageSize } let res
if (keyword.value) params.keyword = keyword.value if (props.goodId > 0) {
if (filterOsType.value) params.os_type = filterOsType.value const params = { good_id: props.goodId, page: page.value, count: pageSize }
const res = await getImageList(params) 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 const body = res?.data
if (body?.code === 200 && body?.data) { if (body?.code === 200 && body?.data) {
const inner = 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 total.value = inner.total ?? inner.all_count ?? list.value.length
} }
} catch { /* ignore */ } } catch { /* ignore */ }
+11 -2
View File
@@ -38,7 +38,9 @@
<el-table-column prop="bridge_name" label="网桥名称" width="100" /> <el-table-column prop="bridge_name" label="网桥名称" width="100" />
<el-table-column label="状态" width="80" align="center"> <el-table-column label="状态" width="80" align="center">
<template #default="{ row }"> <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> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -119,7 +121,14 @@ const loadList = async () => {
const res = await getNetworkList(params) const res = await getNetworkList(params)
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const inner = 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 total.value = inner.meta?.count ?? inner.total ?? list.value.length
} else { list.value = []; total.value = 0 } } else { list.value = []; total.value = 0 }
} catch { list.value = []; total.value = 0 } finally { loading.value = false } } catch { list.value = []; total.value = 0 } finally { loading.value = false }
+55 -7
View File
@@ -52,9 +52,22 @@
</el-radio-group> </el-radio-group>
</template> </template>
<template v-else-if="spec.type === 'number'"> <template v-else-if="spec.type === 'number'">
<div style="display:flex;align-items:center;gap:10px"> <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
<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" /> <el-input-number
<span style="font-size:12px;color:#909399">范围: {{ spec.min || 0 }} ~ {{ spec.max || 9999 }},步长: {{ spec.step || 1 }}</span> 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> </div>
</template> </template>
<template v-else> <template v-else>
@@ -82,6 +95,7 @@ import { ref, reactive, watch } from 'vue'
import { Refresh, Plus } from '@element-plus/icons-vue' import { Refresh, Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { getProductPlanList, createProductPlan, getProductParameterList } from '@/api/admin/product' import { getProductPlanList, createProductPlan, getProductParameterList } from '@/api/admin/product'
import { hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit, toBaseUnit, fromBaseUnit } from '@/utils/dynamicUnit'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, modelValue: { type: Boolean, default: false },
@@ -102,6 +116,8 @@ const createForm = reactive({ name: '', note: '', index: 0, args: '' })
const createSpecList = ref([]) const createSpecList = ref([])
const createSpecLoading = ref(false) const createSpecLoading = ref(false)
const createSpecValues = reactive({}) const createSpecValues = reactive({})
const createDisplayValues = reactive({})
const createDisplayUnits = reactive({})
watch(showCreate, (v) => { watch(showCreate, (v) => {
if (v && props.goodId) loadCreateSpec() if (v && props.goodId) loadCreateSpec()
@@ -114,12 +130,42 @@ const loadCreateSpec = async () => {
if (res?.data?.code === 200) { if (res?.data?.code === 200) {
createSpecList.value = res.data.data || [] createSpecList.value = res.data.data || []
for (const spec of createSpecList.value) { 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 } } 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 buildCreateArgsJson = () => {
const result = [] const result = []
for (const spec of createSpecList.value) { for (const spec of createSpecList.value) {
@@ -127,11 +173,11 @@ const buildCreateArgsJson = () => {
if (val === undefined || val === null || val === '') continue if (val === undefined || val === null || val === '') continue
if (spec.type === 'select') { if (spec.type === 'select') {
const attr = spec.attrs?.find(a => a.id === val) 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') { } 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 { } 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) : '' createForm.args = result.length > 0 ? JSON.stringify(result) : ''
@@ -171,6 +217,8 @@ const submitCreate = async () => {
showCreate.value = false showCreate.value = false
Object.assign(createForm, { name: '', note: '', index: 0, args: '' }) Object.assign(createForm, { name: '', note: '', index: 0, args: '' })
for (const k in createSpecValues) delete createSpecValues[k] 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() loadList()
} else ElMessage.error(res?.data?.message || '创建失败') } else ElMessage.error(res?.data?.message || '创建失败')
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false } } catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
+45 -2
View File
@@ -29,6 +29,22 @@
/> />
</el-select> </el-select>
</el-form-item> </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-form-item>
<el-button type="primary" @click="handleSearch" :icon="Search"> <el-button type="primary" @click="handleSearch" :icon="Search">
搜索 搜索
@@ -116,7 +132,7 @@
import { ref, reactive, watch } from 'vue' import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue' import { Search, Refresh } from '@element-plus/icons-vue'
import { getProductList, getProductGroupList } from '@/api/admin/product' import { getProductList, getProductGroupList, getProductTagList } from '@/api/admin/product'
// Props // Props
const props = defineProps({ const props = defineProps({
@@ -128,6 +144,11 @@ const props = defineProps({
currentProductId: { currentProductId: {
type: [String, Number], type: [String, Number],
default: '' default: ''
},
// 默认标签过滤(设置后自动锁定该标签)
defaultTag: {
type: String,
default: ''
} }
}) })
@@ -140,12 +161,14 @@ const activeTab = ref('selectProduct')
const loading = ref(false) const loading = ref(false)
const productList = ref([]) const productList = ref([])
const groupOptions = ref([]) const groupOptions = ref([])
const tagOptions = ref([])
const total = ref(0) const total = ref(0)
const selectedProduct = ref(null) const selectedProduct = ref(null)
// 搜索参数 // 搜索参数
const searchParams = reactive({ const searchParams = reactive({
good_group_id: '', good_group_id: '',
tag: '',
page: 1, page: 1,
count: 10 count: 10
}) })
@@ -154,11 +177,14 @@ const searchParams = reactive({
watch(() => props.modelValue, (newVal) => { watch(() => props.modelValue, (newVal) => {
visible.value = newVal visible.value = newVal
if (newVal) { if (newVal) {
// 重置状态
activeTab.value = 'selectProduct' activeTab.value = 'selectProduct'
selectedProduct.value = null selectedProduct.value = null
searchParams.page = 1 searchParams.page = 1
if (props.defaultTag) {
searchParams.tag = props.defaultTag
}
fetchGroupList() fetchGroupList()
fetchTagList()
fetchProductList() 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 () => { const fetchProductList = async () => {
loading.value = true loading.value = true
@@ -193,6 +231,9 @@ const fetchProductList = async () => {
if (searchParams.good_group_id) { if (searchParams.good_group_id) {
params.good_group_id = searchParams.good_group_id params.good_group_id = searchParams.good_group_id
} }
if (searchParams.tag) {
params.tag = searchParams.tag
}
const res = await getProductList(params) const res = await getProductList(params)
@@ -243,6 +284,7 @@ const handleSearch = () => {
// 重置搜索 // 重置搜索
const handleReset = () => { const handleReset = () => {
searchParams.good_group_id = '' searchParams.good_group_id = ''
searchParams.tag = props.defaultTag || ''
searchParams.page = 1 searchParams.page = 1
fetchProductList() fetchProductList()
} }
@@ -278,6 +320,7 @@ const handleClose = () => {
selectedProduct.value = null selectedProduct.value = null
productList.value = [] productList.value = []
searchParams.good_group_id = '' searchParams.good_group_id = ''
searchParams.tag = props.defaultTag || ''
searchParams.page = 1 searchParams.page = 1
total.value = 0 total.value = 0
} }
+42 -6
View File
@@ -1,7 +1,7 @@
<template> <template>
<el-dialog <el-dialog
v-model="visible" v-model="visible"
title="选择用户组" :title="adminGroup ? '选择管理员组' : '选择用户组'"
width="900px" width="900px"
append-to-body append-to-body
@close="handleClose" @close="handleClose"
@@ -9,7 +9,7 @@
<div class="user-group-selector"> <div class="user-group-selector">
<el-tabs v-model="activeTab" @tab-click="handleTabClick"> <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="group-list-container">
<!-- 搜索筛选区域 --> <!-- 搜索筛选区域 -->
<div class="filter-section"> <div class="filter-section">
@@ -17,7 +17,7 @@
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input <el-input
v-model="searchParams.key" v-model="searchParams.key"
placeholder="搜索用户组名称" :placeholder="adminGroup ? '搜索管理员组名称' : '搜索用户组名称'"
clearable clearable
@keyup.enter="handleSearch" @keyup.enter="handleSearch"
style="width: 200px" style="width: 200px"
@@ -38,8 +38,35 @@
</el-form> </el-form>
</div> </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 <el-table
v-else
v-loading="loading" v-loading="loading"
:data="groupList" :data="groupList"
highlight-current-row highlight-current-row
@@ -102,7 +129,7 @@
/> />
</div> </div>
<el-empty v-if="groupList.length === 0 && !loading" description="暂无用户组数据" /> <el-empty v-if="groupList.length === 0 && !loading" :description="adminGroup ? '暂无管理员组数据' : '暂无用户组数据'" />
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -128,6 +155,7 @@ import { ref, reactive, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue' import { Search, Refresh } from '@element-plus/icons-vue'
import { getUserGroupList } from '@/api/admin/user' import { getUserGroupList } from '@/api/admin/user'
import { getAdminGroupList } from '@/api/admin/group'
// Props // Props
const props = defineProps({ const props = defineProps({
@@ -144,6 +172,11 @@ const props = defineProps({
excludeGroupId: { excludeGroupId: {
type: [String, Number], type: [String, Number],
default: '' default: ''
},
// 是否请求管理员组接口
adminGroup: {
type: Boolean,
default: false
} }
}) })
@@ -193,12 +226,15 @@ const fetchGroupList = async () => {
count: searchParams.count count: searchParams.count
} }
const res = await getUserGroupList(params) const res = props.adminGroup ? await getAdminGroupList(params) : await getUserGroupList(params)
if (res.data.code === 200) { if (res.data.code === 200) {
let responseData = res.data?.data || res.data 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 groupList.value = responseData
total.value = responseData.length total.value = responseData.length
} else if (responseData.list) { } else if (responseData.list) {
+100 -92
View File
@@ -1,71 +1,67 @@
<template> <template>
<el-dialog v-model="visible" title="选择公网网络(网桥)" width="680px" append-to-body @close="handleClose"> <el-dialog v-model="visible" title="选择网络" width="800px" append-to-body @close="handleClose">
<div class="selector-toolbar"> <div class="selector-container">
<el-button :icon="Refresh" @click="loadList" :loading="loading">刷新</el-button> <div class="filter-bar">
<el-button type="primary" :icon="Plus" @click="showCreate = true">创建组网</el-button> <el-input v-model="keyword" placeholder="搜索网络" clearable style="width: 200px" @keyup.enter="handleSearch" @clear="handleSearch">
<span style="color:#909399;font-size:13px">仅显示网桥(bridge)类型网络</span> <template #prefix><el-icon><Search /></el-icon></template>
</div> </el-input>
<el-table :data="list" v-loading="loading" highlight-current-row <el-tag v-if="filterType" :type="filterType === 'bridge' ? 'success' : 'warning'" size="small" effect="dark">{{ filterType === 'bridge' ? '网桥' : 'NAT' }}</el-tag>
@current-change="row => selected = row" :height="280" stripe size="small"> <el-tag v-if="filterUnused" type="success" size="small" effect="dark">仅未占用</el-tag>
<el-table-column prop="id" label="ID" width="70" /> <el-select v-model="ipVersionFilter" placeholder="IP版本" clearable style="width: 110px" @change="handleSearch">
<el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip /> <el-option label="IPv4" value="ipv4" />
<el-table-column prop="address" label="地址(CIDR)" min-width="150" show-overflow-tooltip /> <el-option label="IPv6" value="ipv6" />
<el-table-column prop="gateway" label="网关" min-width="120" /> </el-select>
<el-table-column prop="mac_address" label="MAC" min-width="150" show-overflow-tooltip /> <el-button :icon="Refresh" @click="loadList" circle />
<el-table-column label="类型" width="80"> </div>
<template #default> <el-table v-loading="loading" :data="list" highlight-current-row @current-change="handleCurrentChange"
<el-tag type="success" size="small">网桥</el-tag> :height="340" :row-class-name="rowClassName" size="small" stripe>
</template> <el-table-column prop="id" label="ID" width="60" />
</el-table-column> <el-table-column prop="name" label="名称" min-width="120" show-overflow-tooltip />
</el-table> <el-table-column label="类型" width="80">
<el-empty v-if="!list.length && !loading" :image-size="60" description="暂无网桥网络" /> <template #default="{ row }">
<div class="selector-footer-bar"> <el-tag :type="row.type === 'bridge' ? 'success' : 'warning'" size="small">
<span v-if="selected" style="color:#606266;font-size:13px">已选:{{ selected.name }} (ID: {{ selected.id }})</span> {{ row.type === 'bridge' ? '网桥' : 'NAT' }}
<el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10,20]" :total="total" </el-tag>
layout="total,sizes,prev,pager,next" small background </template>
@size-change="s => { pageSize = s; page = 1; loadList() }" </el-table-column>
@current-change="p => { page = p; loadList() }" /> <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> </div>
<template #footer> <template #footer>
<el-button @click="handleClose">取消</el-button> <div style="display: flex; justify-content: space-between; width: 100%">
<el-button type="primary" :disabled="!selected" @click="handleConfirm">确定选择</el-button> <el-button v-if="props.showCreateButton" type="success" @click="handleCreate">创建网络</el-button>
</template> <div style="display: flex; gap: 8px">
</el-dialog> <el-button @click="visible = false">取消</el-button>
<el-button type="primary" :disabled="!selectedItem" @click="handleConfirm">确认选择</el-button>
<!-- 创建组网弹窗 --> </div>
<el-dialog v-model="showCreate" title="创建组网" width="440px" append-to-body destroy-on-close> </div>
<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>
</template> </template>
</el-dialog> </el-dialog>
</template> </template>
<script setup> <script setup>
import { ref, reactive, watch } from 'vue' import { ref, watch } from 'vue'
import { Refresh, Plus } from '@element-plus/icons-vue' import { Search, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { getUserVmNetworkList } from '@/api/admin/userVm'
import { getUserVmNetworkList, createUserVmNetworking } from '@/api/admin/userVm'
const props = defineProps({ const props = defineProps({
modelValue: { type: Boolean, default: false }, 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 visible = ref(false)
const loading = ref(false) const loading = ref(false)
@@ -73,54 +69,66 @@ const list = ref([])
const total = ref(0) const total = ref(0)
const page = ref(1) const page = ref(1)
const pageSize = ref(10) const pageSize = ref(10)
const selected = ref(null) const keyword = ref('')
const ipVersionFilter = ref('')
const selectedItem = ref(null)
const showCreate = ref(false) watch(() => props.modelValue, (val) => {
const createLoading = ref(false) visible.value = val
const createForm = reactive({ name: '', bridge_name: '', gateway: '', description: '' }) 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() } }) const handleSearch = () => { page.value = 1; loadList() }
watch(visible, (v) => emit('update:modelValue', v))
const loadList = async () => { const loadList = async () => {
if (!props.userGoodsId) return if (!props.userGoodsId) return
loading.value = true loading.value = true
try { 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) { if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data const inner = res.data.data
const all = d.data || (Array.isArray(d) ? d : []) let all = inner.data || (Array.isArray(inner) ? inner : [])
list.value = all.filter(n => n.type === 'bridge') if (props.filterType) {
total.value = list.value.length all = all.filter(n => n.type === props.filterType)
} }
} catch { /* */ } finally { loading.value = false } 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 () => { const rowClassName = ({ row }) => row.id === selectedItem.value?.id ? 'selected-row' : ''
if (!createForm.name || !createForm.bridge_name) { ElMessage.warning('请填写名称和网桥名称'); return } const handleCurrentChange = (row) => { selectedItem.value = row }
createLoading.value = true const handleConfirm = () => {
try { if (selectedItem.value) {
const res = await createUserVmNetworking({ emit('confirm', selectedItem.value)
user_goods_id: props.userGoodsId, visible.value = false
name: createForm.name, }
bridge_name: createForm.bridge_name, }
gateway: createForm.gateway, const handleClose = () => { selectedItem.value = null }
description: createForm.description const handleCreate = () => {
}) visible.value = false
if (res?.data?.code === 200) { emit('create')
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 handleClose = () => { visible.value = false }
const handleConfirm = () => { if (selected.value) { emit('confirm', selected.value); handleClose() } }
</script> </script>
<style scoped> <style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; } .selector-container { min-height: 200px; }
.selector-footer-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 12px; } .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> </style>
+17 -4
View File
@@ -51,7 +51,12 @@
<el-dialog v-model="showCreate" title="新建数据卷" width="440px" append-to-body destroy-on-close> <el-dialog v-model="showCreate" title="新建数据卷" width="440px" append-to-body destroy-on-close>
<el-form :model="createForm" label-width="100px"> <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="名称" 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-item label="目标设备名"><el-input v-model="createForm.target_device" placeholder="不填自动生成" /></el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -84,7 +89,7 @@ const selected = ref(null)
const showCreate = ref(false) const showCreate = ref(false)
const createLoading = 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(() => props.modelValue, (v) => { visible.value = v; if (v) { selected.value = null; loadList() } })
watch(visible, (v) => emit('update:modelValue', v)) watch(visible, (v) => emit('update:modelValue', v))
@@ -106,11 +111,17 @@ const submitCreate = async () => {
if (!createForm.name) { ElMessage.warning('请输入名称'); return } if (!createForm.name) { ElMessage.warning('请输入名称'); return }
createLoading.value = true createLoading.value = true
try { 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) { if (res?.data?.code === 200) {
ElMessage.success('创建成功') ElMessage.success('创建成功')
showCreate.value = false showCreate.value = false
Object.assign(createForm, { name: '', size: 10, target_device: '' }) Object.assign(createForm, { name: '', size: 10, _sizeUnit: 'GB', target_device: '' })
loadList() loadList()
} else ElMessage.error(res?.data?.message || '创建失败') } else ElMessage.error(res?.data?.message || '创建失败')
} catch { ElMessage.error('创建失败') } finally { createLoading.value = false } } catch { ElMessage.error('创建失败') } finally { createLoading.value = false }
@@ -128,4 +139,6 @@ const handleConfirm = () => {
<style scoped> <style scoped>
.selector-toolbar { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; } .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-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> </style>
+5 -1
View File
@@ -48,7 +48,7 @@ export const menus = [
icon: 'ShoppingCart', icon: 'ShoppingCart',
children: [ children: [
{ path: '/user-goods/list', title: '所有商品' }, { 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', path: '/virtualization/host-group-mapping',
title: '宿主机组映射管理' title: '宿主机组映射管理'
},
{
path: '/virtualization/vnc-command',
title: 'VNC指令管理'
} }
] ]
}, },
+10 -2
View File
@@ -255,7 +255,7 @@ const routes = [
meta: { title: '所有商品' } meta: { title: '所有商品' }
}, },
{ {
path: 'detail', path: 'detail/:id',
name: 'UserGoodsDetail', name: 'UserGoodsDetail',
component: () => import('../views/product/UserGoodsDetail.vue'), component: () => import('../views/product/UserGoodsDetail.vue'),
meta: { title: '用户商品详情', hidden: true, activeMenu: '/user-goods/list' } meta: { title: '用户商品详情', hidden: true, activeMenu: '/user-goods/list' }
@@ -264,7 +264,7 @@ const routes = [
path: 'vm-list', path: 'vm-list',
name: 'UserVmList', name: 'UserVmList',
component: () => import('../views/user-vm/UserVmList.vue'), component: () => import('../views/user-vm/UserVmList.vue'),
meta: { title: '云计算平台' } meta: { title: '云服务器' }
}, },
{ {
path: 'vm-detail', path: 'vm-detail',
@@ -515,6 +515,14 @@ const routes = [
activeMenu: '/virtualization/kvm-service' activeMenu: '/virtualization/kvm-service'
} }
}, },
{
path: 'vnc-command',
name: 'VncCommandManage',
component: () => import('../views/virtualization/VncCommandManage.vue'),
meta: {
title: 'VNC指令管理'
}
},
{ {
path: 'host-detail', path: 'host-detail',
name: 'VirtHostDetail', name: 'VirtHostDetail',
+357 -1
View File
@@ -131,11 +131,367 @@ body {
cursor: pointer; 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) { @media (max-width: 768px) {
.hidden-xs { .hidden-xs {
display: none !important; 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) { @media (min-width: 768px) and (max-width: 992px) {
+164
View File
@@ -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)
}
+21 -7
View File
@@ -467,7 +467,7 @@
<h3 class="tab-title">数据卷列表</h3> <h3 class="tab-title">数据卷列表</h3>
<el-button <el-button
type="primary" type="primary"
@click="showAddVolumeDialog = true" @click="handleAddVolume"
:icon="Plus" :icon="Plus"
:disabled="vmInfo.state != 2" :disabled="vmInfo.state != 2"
> >
@@ -671,8 +671,11 @@
width="500px" width="500px"
> >
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef"> <el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
<el-form-item label="大小(GB)" prop="size"> <el-form-item label="大小" prop="size">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" /> <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-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -693,8 +696,11 @@
> >
<el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef"> <el-form :model="volumeForm" label-width="120px" :rules="volumeRules" ref="volumeFormRef">
<el-form-item label="大小(GB)" prop="size"> <el-form-item label="大小" prop="size">
<el-input-number v-model="volumeForm.size" :min="1" :max="1000" /> <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-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -1067,6 +1073,7 @@ const showMigrateVolumeDialog = ref(false);
const currentVolumeToEdit = ref(null); const currentVolumeToEdit = ref(null);
const volumeForm = reactive({ const volumeForm = reactive({
size: 10, size: 10,
_sizeUnit: 'GB'
}); });
const volumeFormRef = ref(null); const volumeFormRef = ref(null);
const volumeRules = { const volumeRules = {
@@ -2371,6 +2378,7 @@ const handleAddVolume = () => {
showAddVolumeDialog.value = true; showAddVolumeDialog.value = true;
// 重置表单 // 重置表单
volumeForm.size = 10; volumeForm.size = 10;
volumeForm._sizeUnit = 'GB';
}; };
// 编辑数据卷 // 编辑数据卷
@@ -2378,6 +2386,7 @@ const handleEditVolume = (volume) => {
currentVolumeToEdit.value = volume; currentVolumeToEdit.value = volume;
// 填充表单 // 填充表单
volumeForm.size = volume.size; volumeForm.size = volume.size;
volumeForm._sizeUnit = 'GB';
showEditVolumeDialog.value = true; showEditVolumeDialog.value = true;
}; };
@@ -2404,9 +2413,10 @@ const submitAddVolume = async () => {
if (valid) { if (valid) {
addingVolume.value = true; addingVolume.value = true;
try { try {
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
const res = await addVolume({ const res = await addVolume({
instance_id: route.query.instance_id, instance_id: route.query.instance_id,
size: String(volumeForm.size), size: String(sizeGb),
user_id: user_id.value user_id: user_id.value
}); });
console.log("添加数据卷112",res) console.log("添加数据卷112",res)
@@ -2438,9 +2448,10 @@ const submitEditVolume = async () => {
editingVolume.value = true; editingVolume.value = true;
try { try {
// 这里应该调用修改数据卷的API // 这里应该调用修改数据卷的API
const sizeGb = volumeForm._sizeUnit === 'TB' ? volumeForm.size * 1024 : volumeForm.size
const res = await updateVolume({ const res = await updateVolume({
volume_id: currentVolumeToEdit.value.id, volume_id: currentVolumeToEdit.value.id,
size: volumeForm.size size: sizeGb
}); });
console.log("编辑数据卷数据:",res) console.log("编辑数据卷数据:",res)
@@ -2770,4 +2781,7 @@ const fetchServersList = async () => {
font-weight: 600; font-weight: 600;
color: #303133; color: #303133;
} }
.unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.unit-select { width: 90px; flex-shrink: 0; }
</style> </style>
+5 -1
View File
@@ -315,7 +315,11 @@
class="data-table" class="data-table"
> >
<el-table-column prop="id" label="ID" width="80" /> <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="mount_path" label="挂载路径" min-width="200" />
<el-table-column prop="created_at" label="创建时间" min-width="160" /> <el-table-column prop="created_at" label="创建时间" min-width="160" />
</el-table> </el-table>
+13 -4
View File
@@ -211,11 +211,17 @@
<el-form-item label="名称" prop="name"> <el-form-item label="名称" prop="name">
<el-input v-model="typeForm.name" placeholder="请输入名称" /> <el-input v-model="typeForm.name" placeholder="请输入名称" />
</el-form-item> </el-form-item>
<el-form-item label="价格(分)" prop="price"> <el-form-item label="价格" prop="price">
<el-input-number v-model="typeForm.price" :min="0" style="width: 100%" /> <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>
<el-form-item label="续费价格(分)" prop="renewPrice"> <el-form-item label="续费价格" prop="renewPrice">
<el-input-number v-model="typeForm.renewPrice" :min="0" style="width: 100%" /> <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>
<el-form-item label="拼团人数" prop="maxPerson"> <el-form-item label="拼团人数" prop="maxPerson">
<el-input-number v-model="typeForm.maxPerson" :min="2" :max="100" style="width: 100%" /> <el-input-number v-model="typeForm.maxPerson" :min="2" :max="100" style="width: 100%" />
@@ -903,4 +909,7 @@ onMounted(() => {
:deep(.el-tabs__active-bar) { :deep(.el-tabs__active-bar) {
background-color: #2c3e50; 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> </style>
+12 -4
View File
@@ -52,11 +52,17 @@
<el-form-item label="名称" prop="name"> <el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="请输入名称" /> <el-input v-model="form.name" placeholder="请输入名称" />
</el-form-item> </el-form-item>
<el-form-item label="价格(分)" prop="price"> <el-form-item label="价格" prop="price">
<el-input-number v-model="form.price" :min="0" style="width: 100%" /> <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>
<el-form-item label="续费价格(分)" prop="renewPrice"> <el-form-item label="续费价格" prop="renewPrice">
<el-input-number v-model="form.renewPrice" :min="0" style="width: 100%" /> <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>
<el-form-item label="拼团人数" prop="maxPerson"> <el-form-item label="拼团人数" prop="maxPerson">
<el-input-number v-model="form.maxPerson" :min="2" :max="100" style="width: 100%" /> <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); } .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; } .pagination-wrapper { margin-top: 20px; display: flex; justify-content: flex-end; }
.note-fields-container { width: 100%; } .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> </style>
+15 -16
View File
@@ -823,15 +823,19 @@ watch(salesRange, (newVal) => {
/* 统计卡片样式 */ /* 统计卡片样式 */
.stat-card { .stat-card {
margin-bottom: 24px; margin-bottom: 24px;
border-radius: 12px;
border: none;
transition: all 0.3s; transition: all 0.3s;
overflow: hidden; 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 { .stat-card:hover {
transform: translateY(-5px); transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08) !important;
} }
.card-top { .card-top {
@@ -862,10 +866,10 @@ watch(salesRange, (newVal) => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 56px; width: 48px;
height: 56px; height: 48px;
border-radius: 12px; border-radius: 4px;
font-size: 28px; font-size: 24px;
color: #fff; color: #fff;
} }
@@ -927,8 +931,6 @@ watch(salesRange, (newVal) => {
.chart-card { .chart-card {
margin-bottom: 24px; margin-bottom: 24px;
border-radius: 12px;
border: none;
overflow: hidden; overflow: hidden;
} }
@@ -1036,8 +1038,6 @@ watch(salesRange, (newVal) => {
.activity-card, .todo-card { .activity-card, .todo-card {
height: 100%; height: 100%;
border-radius: 12px;
border: none;
} }
.card-header-custom { .card-header-custom {
@@ -1169,8 +1169,6 @@ watch(salesRange, (newVal) => {
.list-card { .list-card {
margin-bottom: 24px; margin-bottom: 24px;
border-radius: 12px;
border: none;
height: 410px; height: 410px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1209,8 +1207,9 @@ watch(salesRange, (newVal) => {
.empty-tip { .empty-tip {
text-align: center; text-align: center;
color: #8c8c8c; color: #909399;
padding: 40px 0; padding: 60px 0;
font-size: 14px;
} }
.list-item { .list-item {
+18 -6
View File
@@ -141,17 +141,26 @@
<el-radio label="percentage">百分比折扣</el-radio> <el-radio label="percentage">百分比折扣</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item v-if="discountForm.discount_mode === 'amount'" label="优惠金额(元)" prop="amount"> <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%" /> <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>
<el-form-item v-if="discountForm.discount_mode === 'percentage'" label="优惠百分比(%)" prop="percentage"> <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-input-number v-model="discountForm.percentage" :min="0" :max="100" :precision="0" placeholder="请输入百分比(1-100)" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="最低消费(元)" prop="min_amount"> <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%" /> <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>
<el-form-item label="最大抵扣(元)" prop="max_amount"> <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%" /> <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>
<el-form-item label="最大使用次数" prop="max_times"> <el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="discountForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" /> <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; } 0% { background-position: 200% 0; }
100% { 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>
<style> <style>
-27
View File
@@ -798,33 +798,6 @@ onMounted(() => {
padding: 0; 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) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
-27
View File
@@ -803,33 +803,6 @@ onMounted(() => {
padding: 0; 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) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
+23 -8
View File
@@ -105,14 +105,23 @@
<el-form-item label="备注" prop="note"> <el-form-item label="备注" prop="note">
<el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" /> <el-input v-model="voucherForm.note" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item> </el-form-item>
<el-form-item label="面额(元)" prop="amount"> <el-form-item label="面额" prop="amount">
<el-input-number v-model="voucherForm.amount" :min="0" :precision="2" :step="0.01" placeholder="请输入面额" style="width: 100%" /> <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>
<el-form-item label="最低消费(元)" prop="min_amount"> <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%" /> <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>
<el-form-item label="最大抵扣(元)" prop="max_amount"> <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%" /> <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>
<el-form-item label="最大使用次数" prop="max_times"> <el-form-item label="最大使用次数" prop="max_times">
<el-input-number v-model="voucherForm.max_times" :min="0" placeholder="0表示无限制" style="width: 100%" /> <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-form-item label="单用户最大次数" prop="user_times">
<el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" /> <el-input-number v-model="voucherForm.user_times" :min="0" placeholder="0表示无限制" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="有效期(天)" prop="duration_days"> <el-form-item label="有效期" prop="duration_days">
<el-input-number v-model="voucherForm.duration_days" :min="1" placeholder="代金券有效天数" style="width: 100%" /> <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> <div class="form-tip">代金券领取后的有效持续时间</div>
</el-form-item> </el-form-item>
<el-form-item label="发放时间范围" prop="timeRange"> <el-form-item label="发放时间范围" prop="timeRange">
@@ -539,6 +551,9 @@ onMounted(() => {
margin-top: 24px; margin-top: 24px;
justify-content: flex-end; 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>
<style> <style>
+13 -4
View File
@@ -248,11 +248,17 @@
<el-form-item label="购买数量" prop="pay_num"> <el-form-item label="购买数量" prop="pay_num">
<el-input-number v-model="orderForm.pay_num" :min="1" placeholder="请输入数量" style="width: 100%" /> <el-input-number v-model="orderForm.pay_num" :min="1" placeholder="请输入数量" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="价格(分)" prop="price"> <el-form-item label="价格" prop="price">
<el-input-number v-model="orderForm.price" :min="0" placeholder="请输入价格(分)" style="width: 100%" /> <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>
<el-form-item label="续费价格(分)" prop="renew_price"> <el-form-item label="续费价格" prop="renew_price">
<el-input-number v-model="orderForm.renew_price" :min="0" placeholder="请输入续费价格(分)" style="width: 100%" /> <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>
<el-form-item label="过期时间" prop="expire_time"> <el-form-item label="过期时间" prop="expire_time">
<el-date-picker <el-date-picker
@@ -957,4 +963,7 @@ onMounted(() => {
.clear-icon:hover { .clear-icon:hover {
color: #f56c6c; 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> </style>
+42 -7
View File
@@ -168,7 +168,7 @@
/> />
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200" fixed="right"> <el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="action-buttons"> <div class="action-buttons">
<template v-if="row.isGroup"> <template v-if="row.isGroup">
@@ -437,7 +437,7 @@
<el-dialog <el-dialog
v-model="showTagSelector" v-model="showTagSelector"
title="选择分组标签" title="选择分组标签"
width="600px" width="650px"
append-to-body append-to-body
> >
<div class="tag-selector-header"> <div class="tag-selector-header">
@@ -467,6 +467,11 @@
<el-tag type="primary">{{ row.name }}</el-tag> <el-tag type="primary">{{ row.name }}</el-tag>
</template> </template>
</el-table-column> </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> <template #empty>
<el-empty description="暂无标签数据" :image-size="60" /> <el-empty description="暂无标签数据" :image-size="60" />
</template> </template>
@@ -551,6 +556,7 @@
v-model="coverSelectorVisible" v-model="coverSelectorVisible"
:user-id="1" :user-id="1"
:current-cover-id="productForm.cover_id" :current-cover-id="productForm.cover_id"
title="选择封面"
@confirm="handleProductCoverSelect" @confirm="handleProductCoverSelect"
/> />
@@ -560,14 +566,20 @@
<el-form-item label="库存数量" prop="inventory"> <el-form-item label="库存数量" prop="inventory">
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" /> <el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="商品价格(元)" prop="price"> <el-form-item label="商品价格" prop="price">
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="width: 100%" /> <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>
<el-form-item label="单个商品数量" prop="pay_num"> <el-form-item label="单个商品数量" prop="pay_num">
<el-input-number v-model="productForm.pay_num" :min="1" placeholder="请输入单个商品数量" style="width: 100%" /> <el-input-number v-model="productForm.pay_num" :min="1" placeholder="请输入单个商品数量" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="有效期(天)" prop="expire_time"> <el-form-item label="有效期" prop="expire_time">
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="width: 100%" /> <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>
<el-form-item label="推荐" prop="recommend"> <el-form-item label="推荐" prop="recommend">
<el-switch v-model="productForm.recommend" active-text="启用" inactive-text="禁用" /> <el-switch v-model="productForm.recommend" active-text="启用" inactive-text="禁用" />
@@ -651,6 +663,7 @@ import {
hideProductGroup, hideProductGroup,
startProductGroup, startProductGroup,
getProductGroupTagList, getProductGroupTagList,
deleteProductGroupTag,
getProductList, getProductList,
createProduct, createProduct,
updateProduct, updateProduct,
@@ -1117,6 +1130,25 @@ const clearTag = () => {
groupForm.tag_id = undefined 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) => { watch(showTagSelector, (val) => {
if (val) { if (val) {
tagSelectorSearch.value = '' tagSelectorSearch.value = ''
@@ -1468,7 +1500,7 @@ const clearProductGroup = () => {
} }
const handleProductCoverSelect = (file) => { const handleProductCoverSelect = (file) => {
productForm.cover_id = file.id productForm.cover_id = file.cover_id
coverSelectorVisible.value = false coverSelectorVisible.value = false
} }
@@ -1791,6 +1823,9 @@ onMounted(() => {
margin-top: 4px; 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 { .recommend-user-selector {
display: flex; display: flex;
align-items: center; align-items: center;
+378 -153
View File
@@ -73,7 +73,11 @@
/> />
</template> </template>
</el-table-column> </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"> <el-table-column label="标签" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag v-if="row.tag" size="small" type="success">{{ row.tag }}</el-tag> <el-tag v-if="row.tag" size="small" type="success">{{ row.tag }}</el-tag>
@@ -221,6 +225,7 @@
v-model="coverSelectorVisible" v-model="coverSelectorVisible"
:user-id="1" :user-id="1"
:current-cover-id="productForm.cover_id" :current-cover-id="productForm.cover_id"
title="选择封面"
@confirm="handleCoverSelect" @confirm="handleCoverSelect"
/> />
<el-form-item label="库存控制" prop="inventory_control"> <el-form-item label="库存控制" prop="inventory_control">
@@ -229,14 +234,20 @@
<el-form-item label="库存数量" prop="inventory"> <el-form-item label="库存数量" prop="inventory">
<el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" /> <el-input-number v-model="productForm.inventory" :min="0" placeholder="请输入库存" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="商品价格(元)" prop="price"> <el-form-item label="商品价格" prop="price">
<el-input-number v-model="productForm.price" :min="0" :precision="2" :step="0.01" placeholder="请输入价格(元)" style="width: 100%" /> <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>
<el-form-item label="单个商品数量" prop="pay_num"> <el-form-item label="单个商品数量" prop="pay_num">
<el-input-number v-model="productForm.pay_num" :min="1" placeholder="请输入单个商品数量" style="width: 100%" /> <el-input-number v-model="productForm.pay_num" :min="1" placeholder="请输入单个商品数量" style="width: 100%" />
</el-form-item> </el-form-item>
<el-form-item label="有效期(天)" prop="expire_time"> <el-form-item label="有效期" prop="expire_time">
<el-input-number v-model="productForm.expire_time" :min="0" placeholder="请输入有效期" style="width: 100%" /> <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>
<el-form-item label="推荐" prop="recommend"> <el-form-item label="推荐" prop="recommend">
<el-switch v-model="productForm.recommend" active-text="启用" inactive-text="禁用" /> <el-switch v-model="productForm.recommend" active-text="启用" inactive-text="禁用" />
@@ -252,6 +263,13 @@
</el-select> </el-select>
<div class="form-tip">all: 所有参数 / plan: 套餐 / customize: 自定义</div> <div class="form-tip">all: 所有参数 / plan: 套餐 / customize: 自定义</div>
</el-form-item> </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> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
@@ -320,61 +338,57 @@
:title="paramFormType === 'add' ? '新增商品参数' : '编辑商品参数'" :title="paramFormType === 'add' ? '新增商品参数' : '编辑商品参数'"
width="600px" width="600px"
append-to-body append-to-body
class="tk-dialog"
> >
<el-form <el-form ref="paramFormRef" :model="paramForm" :rules="paramRules" label-width="100px">
ref="paramFormRef" <div class="tk-section">
:model="paramForm" <div class="tk-section-title">基本信息</div>
:rules="paramRules" <el-form-item label="参数名称" prop="arg_name">
label-width="120px" <el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
> </el-form-item>
<el-form-item label="参数名称" prop="arg_name"> <el-form-item label="参数类型" prop="arg_type">
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" /> <el-radio-group v-model="paramForm.arg_type">
</el-form-item> <el-radio label="string">字符串</el-radio>
<el-form-item label="参数类型" prop="arg_type"> <el-radio label="number">数字</el-radio>
<el-radio-group v-model="paramForm.arg_type"> <el-radio label="select">选择</el-radio>
<el-radio label="string">字符串</el-radio> </el-radio-group>
<el-radio label="number">数字</el-radio> </el-form-item>
<el-radio label="select">选择</el-radio> <el-form-item label="是否必选" prop="must">
</el-radio-group> <el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
</el-form-item> </el-form-item>
<el-form-item label="是否必选" prop="must"> </div>
<el-switch <div class="tk-section">
v-model="paramForm.must" <div class="tk-section-title">权限控制</div>
:active-value="true" <el-form-item label="允许单独购买">
:inactive-value="false" <el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
active-text="必选" <div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
inactive-text="可选" </el-form-item>
/> <el-form-item label="用户组优惠">
</el-form-item> <el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
<el-divider content-position="left">权限控制</el-divider> <div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div>
<el-form-item label="允许单独购买"> </el-form-item>
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" /> <el-form-item label="用户优惠">
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div> <el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" />
</el-form-item> <div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠代金券与优惠码</div>
<el-form-item label="用户组优惠"> </el-form-item>
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" /> </div>
<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 类型参数的额外配置 -->
<template v-if="paramForm.arg_type === 'number'"> <template v-if="paramForm.arg_type === 'number'">
<el-divider content-position="left">数值参数配置</el-divider> <div class="tk-section">
<el-form-item label="步进值" prop="arg_step"> <div class="tk-section-title">数值参数配置</div>
<el-input-number v-model="paramForm.arg_step" :min="1" placeholder="步进值" style="width: 100%" /> <el-form-item label="步进值" prop="arg_step">
</el-form-item> <el-input-number v-model="paramForm.arg_step" :min="1" placeholder="步进值" style="width: 100%" />
<el-form-item label="最小值" prop="arg_min"> </el-form-item>
<el-input-number v-model="paramForm.arg_min" placeholder="最小值" style="width: 100%" /> <el-form-item label="最小值" prop="arg_min">
</el-form-item> <el-input-number v-model="paramForm.arg_min" placeholder="最小值" style="width: 100%" />
<el-form-item label="最大值" prop="arg_max"> </el-form-item>
<el-input-number v-model="paramForm.arg_max" placeholder="最大值" style="width: 100%" /> <el-form-item label="最大值" prop="arg_max">
</el-form-item> <el-input-number v-model="paramForm.arg_max" placeholder="最大值" style="width: 100%" />
</el-form-item>
</div>
</template> </template>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="tk-dialog-footer">
<el-button @click="paramFormDialogVisible = false">取消</el-button> <el-button @click="paramFormDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitParamForm">确定</el-button> <el-button type="primary" @click="submitParamForm">确定</el-button>
</div> </div>
@@ -440,45 +454,44 @@
:title="paramValueFormType === 'add' ? '添加参数值' : '编辑参数值'" :title="paramValueFormType === 'add' ? '添加参数值' : '编辑参数值'"
width="550px" width="550px"
append-to-body append-to-body
class="tk-dialog"
> >
<el-form <el-form ref="paramValueFormRef" :model="paramValueForm" :rules="paramValueRules" label-width="100px">
ref="paramValueFormRef" <div class="tk-section">
:model="paramValueForm" <div class="tk-section-title">参数值信息</div>
:rules="paramValueRules" <el-form-item label="值名称" prop="attr_name">
label-width="120px" <el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
> </el-form-item>
<el-form-item label="值名称" prop="attr_name"> <el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value">
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" /> <el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" />
</el-form-item> </el-form-item>
<!-- select 类型显示参数值 --> <el-form-item label="排序索引" prop="index">
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value"> <el-input-number v-model="paramValueForm.index" :min="0" placeholder="排序索引" style="width: 100%" />
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" /> </el-form-item>
</el-form-item> <el-form-item label="价格" prop="attr_price">
<!-- number 类型显示范围配置 --> <el-input-number v-model="paramValueForm.attr_price" :min="0" placeholder="请输入价格" style="width: 100%" />
</el-form-item>
</div>
<template v-if="currentParam?.type === 'number'"> <template v-if="currentParam?.type === 'number'">
<el-divider content-position="left">数值范围配置phase</el-divider> <div class="tk-section">
<el-form-item label="范围类型" prop="range_type"> <div class="tk-section-title">数值范围配置</div>
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%"> <el-form-item label="范围类型" prop="range_type">
<el-option label="小于等于 (before)" value="before" /> <el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
<el-option label="大于等于 (after)" value="after" /> <el-option label="小于 (before)" value="before" />
<el-option label="于 (equal)" value="equal" /> <el-option label="于 (after)" value="after" />
</el-select> <el-option label="等于 (equal)" value="equal" />
<div class="form-tip">before: 数值 phase 时匹配 | after: 数值 phase 时匹配</div> </el-select>
</el-form-item> <div class="form-tip">before: 数值 &lt; phase 时匹配 | after: 数值 &gt; phase 时匹配</div>
<el-form-item label="阈值" prop="attr_range"> </el-form-item>
<el-input-number v-model="paramValueForm.attr_range" :min="0" placeholder="范围阈值" style="width: 100%" /> <el-form-item label="阈值" prop="attr_range">
<div class="form-tip">例如phase=100, rangeType=before 表示 0-100 范围</div> <el-input-number v-model="paramValueForm.attr_range" :min="0" placeholder="范围阈值" style="width: 100%" />
</el-form-item> <div class="form-tip">例如phase=100, rangeType=before 表示 0-100 范围</div>
</el-form-item>
</div>
</template> </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> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="tk-dialog-footer">
<el-button @click="paramValueFormDialogVisible = false">取消</el-button> <el-button @click="paramValueFormDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitParamValueForm">确定</el-button> <el-button type="primary" @click="submitParamValueForm">确定</el-button>
</div> </div>
@@ -541,6 +554,13 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </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"> <el-table-column label="操作" width="180">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="handleEditPlan(row)">编辑</el-button> <el-button type="primary" link @click="handleEditPlan(row)">编辑</el-button>
@@ -631,16 +651,26 @@
<template v-else-if="spec.type === 'number'"> <template v-else-if="spec.type === 'number'">
<div class="number-input-wrapper"> <div class="number-input-wrapper">
<el-input-number <el-input-number
v-model="selectedArgs[spec.id]" v-model="displayValues[spec.id]"
:min="spec.min || 0" :min="getSpecDisplayMin(spec)"
:max="spec.max || 9999" :max="getSpecDisplayMax(spec)"
:step="spec.step || 1" :step="getSpecDisplayStep(spec)"
:step-strictly="true" :step-strictly="true"
size="small" 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"> <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> </span>
</div> </div>
<!-- 显示匹配的价格区间 --> <!-- 显示匹配的价格区间 -->
@@ -666,15 +696,18 @@
<el-empty v-else-if="planSpecList.length > 0" description="请先选择需要配置的参数" :image-size="60" /> <el-empty v-else-if="planSpecList.length > 0" description="请先选择需要配置的参数" :image-size="60" />
<el-empty v-else description="暂无参数配置,请先为商品添加参数" :image-size="60" /> <el-empty v-else description="暂无参数配置,请先为商品添加参数" :image-size="60" />
<!-- 查看JSON按钮 -->
<div class="args-actions" v-if="selectedArgSpecs.length > 0"> <div class="args-actions" v-if="selectedArgSpecs.length > 0">
<el-button type="info" plain size="small" @click="showArgsPreview = true"> <el-button type="info" plain size="small" @click="showArgsPreview = true">
<el-icon><View /></el-icon> <el-icon><View /></el-icon>查看配置JSON
查看配置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>
<el-button type="warning" plain size="small" @click="clearArgsSelection"> <el-button type="warning" plain size="small" @click="clearArgsSelection">
<el-icon><Delete /></el-icon> <el-icon><Delete /></el-icon>清空选择
清空选择
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -728,8 +761,11 @@
/> />
<div class="form-tip">启用后套餐价格将使用固定价格不再根据参数计算</div> <div class="form-tip">启用后套餐价格将使用固定价格不再根据参数计算</div>
</el-form-item> </el-form-item>
<el-form-item label="固定价格(元)" prop="fixed_price" v-if="planForm.enable_fixed_price === true"> <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="请输入固定价格(元)" /> <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>
<el-form-item label="排序索引" prop="index"> <el-form-item label="排序索引" prop="index">
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" /> <el-input-number v-model="planForm.index" :min="0" style="width: 100%" />
@@ -748,6 +784,10 @@
/> />
<div class="form-tip">控制商品套餐是否在首页显示</div> <div class="form-tip">控制商品套餐是否在首页显示</div>
</el-form-item> </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> </el-form>
</div> </div>
<template #footer> <template #footer>
@@ -789,6 +829,15 @@
</template> </template>
</el-dialog> </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 <el-dialog
v-model="showGroupSelector" v-model="showGroupSelector"
@@ -851,9 +900,14 @@
<script setup> <script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue' import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' 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 AvatarSelector from '@/components/admin/AvatarSelector.vue'
import { getProductList, createProduct, updateProduct, deleteProduct, getProductGroupList, import {
getProductList,
createProduct,
updateProduct,
deleteProduct,
getProductGroupList,
getProductTagList, getProductTagList,
getProductParameterList, getProductParameterList,
getProductParameterDetail, getProductParameterDetail,
@@ -873,6 +927,11 @@ import { getProductList, createProduct, updateProduct, deleteProduct, getProduct
disablePlanFixedPrice, disablePlanFixedPrice,
enablePlanFixedPrice enablePlanFixedPrice
} from '@/api/admin/product' } 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({ const queryParams = reactive({
@@ -897,7 +956,8 @@ const productForm = reactive({
expire_time: 0, expire_time: 0,
recommend: false, recommend: false,
recommend_rebate: 0, recommend_rebate: 0,
arg_type: 'all' // all/plan/customize arg_type: 'all', // all/plan/customize
attribution_id: '' // ID
}) })
const productRules = { const productRules = {
@@ -1170,6 +1230,7 @@ const handleAdd = () => {
good_group_id: undefined, good_group_id: undefined,
inventory_control: false, inventory_control: false,
inventory: 0, inventory: 0,
attribution_id: '',
price: 0, price: 0,
pay_num: 1, pay_num: 1,
expire_time: 0, expire_time: 0,
@@ -1295,7 +1356,8 @@ const submitForm = () => {
pay_num: productForm.pay_num || 1, pay_num: productForm.pay_num || 1,
expire_time: productForm.expire_time || 0, expire_time: productForm.expire_time || 0,
recommend_rebate: productForm.recommend_rebate || 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) // 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 = () => { const openCoverSelector = () => {
coverSelectorVisible.value = true coverSelectorVisible.value = true
@@ -1436,7 +1519,7 @@ const getArgTypeTag = (type) => {
// //
const getRangeTypeText = (type) => { const getRangeTypeText = (type) => {
const typeMap = { 'after': '大于 >', 'before': '小于 <', 'equal': '等于 =' } const typeMap = { 'after': '大于 ', 'before': '小于 ', 'equal': '等于 ' }
return typeMap[type] || type || '-' return typeMap[type] || type || '-'
} }
@@ -1566,19 +1649,17 @@ const fetchParamValuesList = async () => {
const handleAddParamValue = () => { const handleAddParamValue = () => {
paramValueFormType.value = 'add' paramValueFormType.value = 'add'
paramValueFormDialogVisible.value = true 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(() => { nextTick(() => {
paramValueFormRef.value?.resetFields() 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, enable_fixed_price: false,
index: 0, index: 0,
disable: false, disable: false,
show_home: false show_home: false,
can_update: false
}) })
const planFormRules = { const planFormRules = {
@@ -1692,8 +1774,12 @@ const planFormRules = {
// //
const planSpecList = ref([]) // const planSpecList = ref([]) //
const selectedArgIds = ref([]) // ID const selectedArgIds = ref([]) // ID
const selectedArgs = reactive({}) // { arg_id: value_id value } const selectedArgs = reactive({}) //
const showArgsPreview = ref(false) // const displayValues = reactive({}) //
const displayUnits = reactive({}) //
const showArgsPreview = ref(false)
const showPasteDialog = ref(false)
const pasteJsonText = ref('')
// //
const selectedExtraArgIds = ref([]) // ID const selectedExtraArgIds = ref([]) // ID
@@ -1724,13 +1810,19 @@ const fetchPlanSpecList = async () => {
// //
const onSelectedArgsChange = () => { const onSelectedArgsChange = () => {
//
for (const key in selectedArgs) { for (const key in selectedArgs) {
if (!selectedArgIds.value.includes(Number(key))) { if (!selectedArgIds.value.includes(Number(key))) {
delete selectedArgs[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( selectedExtraArgIds.value = selectedExtraArgIds.value.filter(
id => !selectedArgIds.value.includes(id) id => !selectedArgIds.value.includes(id)
) )
@@ -1738,6 +1830,49 @@ const onSelectedArgsChange = () => {
updateExtraArgIds() 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 = () => { const onSelectedExtraArgsChange = () => {
updateExtraArgIds() updateExtraArgIds()
@@ -1775,34 +1910,25 @@ const updateArgsJson = () => {
if (selectedValue === undefined || selectedValue === '') continue if (selectedValue === undefined || selectedValue === '') continue
if (spec.type === 'select') { if (spec.type === 'select') {
// select
const attrObj = spec.attrs?.find(a => a.id === selectedValue) const attrObj = spec.attrs?.find(a => a.id === selectedValue)
if (attrObj) { if (attrObj) {
argsArray.push({ argsArray.push({
arg_id: spec.id, arg_id: spec.id, name: spec.name, attr_id: attrObj.id,
name: spec.name, value: attrObj.value || '', key: getArgKey(spec) || undefined
attr_id: attrObj.id,
value: attrObj.value || ''
}) })
} }
} else if (spec.type === 'number') { } else if (spec.type === 'number') {
// number ID
const numValue = Number(selectedValue) const numValue = Number(selectedValue)
const matchedAttr = findMatchingNumberAttr(spec, numValue) const matchedAttr = findMatchingNumberAttr(spec, numValue)
argsArray.push({ argsArray.push({
arg_id: spec.id, arg_id: spec.id, name: spec.name,
name: spec.name,
attr_id: matchedAttr ? matchedAttr.id : 0, attr_id: matchedAttr ? matchedAttr.id : 0,
number: numValue number: numValue, key: getArgKey(spec) || undefined
}) })
} else { } else {
// string
argsArray.push({ argsArray.push({
arg_id: spec.id, arg_id: spec.id, name: spec.name, attr_id: 0,
name: spec.name, value: String(selectedValue), key: getArgKey(spec) || undefined
attr_id: 0,
value: String(selectedValue)
}) })
} }
} }
@@ -1821,12 +1947,9 @@ const findMatchingNumberAttr = (spec, numValue) => {
const phase = attr.phase || 0 const phase = attr.phase || 0
const rangeType = attr.rangeType || 'before' const rangeType = attr.rangeType || 'before'
// rangeType: before phase if (rangeType === 'before' && numValue < phase) {
// rangeType: after phase
// rangeType: equal phase
if (rangeType === 'before' && numValue <= phase) {
return attr return attr
} else if (rangeType === 'after' && numValue >= phase) { } else if (rangeType === 'after' && numValue > phase) {
return attr return attr
} else if (rangeType === 'equal' && numValue === phase) { } else if (rangeType === 'equal' && numValue === phase) {
return attr return attr
@@ -1858,24 +1981,108 @@ const generateArgId = (argId, value) => {
// //
const clearArgsSelection = () => { const clearArgsSelection = () => {
selectedArgIds.value = [] selectedArgIds.value = []
for (const key in selectedArgs) { for (const key in selectedArgs) delete selectedArgs[key]
delete selectedArgs[key] for (const key in displayValues) delete displayValues[key]
} for (const key in displayUnits) delete displayUnits[key]
selectedExtraArgIds.value = [] selectedExtraArgIds.value = []
planForm.args = '' planForm.args = ''
planForm.extra_arg_ids = '' planForm.extra_arg_ids = ''
planForm.extra_arg_ids_array = [] 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 getSelectedValueDisplay = (spec) => {
const selectedValue = selectedArgs[spec.id] const selectedValue = selectedArgs[spec.id]
if (selectedValue === undefined || selectedValue === '') return null if (selectedValue === undefined || selectedValue === '') return null
if (spec.type === 'select') { if (spec.type === 'select') {
const attrObj = spec.attrs?.find(a => a.id === selectedValue) const attrObj = spec.attrs?.find(a => a.id === selectedValue)
return attrObj ? attrObj.name : null 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) return String(selectedValue)
} }
@@ -1928,8 +2135,15 @@ const initSelectedArgsFromJson = (argsJson, extraArgIds = []) => {
} }
} }
} else if (spec.type === 'number') { } else if (spec.type === 'number') {
// number 使 number value const numVal = Number(arg.number !== undefined ? arg.number : arg.value)
selectedArgs[spec.id] = 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 { } else {
selectedArgs[spec.id] = arg.value selectedArgs[spec.id] = arg.value
} }
@@ -2033,6 +2247,11 @@ const handleAddPlan = async () => {
// //
selectedArgIds.value = planSpecList.value.map(spec => spec.id) 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, { Object.assign(planForm, {
plan_id: undefined, plan_id: undefined,
@@ -2046,7 +2265,8 @@ const handleAddPlan = async () => {
enable_fixed_price: false, enable_fixed_price: false,
index: 0, index: 0,
disable: false, disable: false,
show_home: false show_home: false,
can_update: false
}) })
planFormDialogVisible.value = true planFormDialogVisible.value = true
@@ -2102,7 +2322,8 @@ const handleEditPlan = async (row) => {
enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price), // enable_fixed_price: !!(data.enableFixedPrice || data.enable_fixed_price), //
index: data.index || 0, index: data.index || 0,
disable: data.disable || false, 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 // args
@@ -2202,7 +2423,8 @@ const submitPlanForm = () => {
inventory: Number(planForm.inventory) || 0, inventory: Number(planForm.inventory) || 0,
fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0, // fixed_price: Math.round(Number(planForm.fixed_price) * 100) || 0, //
index: Number(planForm.index) || 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 // enable_fixed_price
@@ -2674,5 +2896,8 @@ const submitPlanForm = () => {
word-break: break-all; word-break: break-all;
margin: 0; 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> </style>
+15 -4
View File
@@ -94,10 +94,18 @@
<el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close> <el-dialog v-model="editVisible" title="编辑用户商品" width="520px" destroy-on-close>
<el-form :model="editForm" label-width="110px"> <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 v-model="editForm.note" /></el-form-item>
<el-form-item label="续费价格(元)"> <el-form-item label="续费价格">
<el-input-number v-model="editForm.renew_price" :min="0" :precision="2" controls-position="right" style="width:100%" /> <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>
<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="到期时间"><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="归属项"> <el-form-item label="归属项">
<div style="width:100%"> <div style="width:100%">
@@ -146,7 +154,7 @@ import dayjs from 'dayjs'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const goodsId = computed(() => parseInt(route.query.id) || 0) const goodsId = computed(() => parseInt(route.params.id) || 0)
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
@@ -404,4 +412,7 @@ watch(goodsId, (newId, oldId) => {
font-weight: 500; font-weight: 500;
color: #606266; 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> </style>
+263 -60
View File
@@ -6,12 +6,16 @@
<div class="filter-content"> <div class="filter-content">
<el-form :inline="true" class="search-form"> <el-form :inline="true" class="search-form">
<el-form-item label="用户ID"> <el-form-item label="用户ID">
<el-input v-model="query.user_id" placeholder="筛选用户" clearable style="width:120px" <el-input :model-value="filterUserName || (query.user_id ? `${query.user_id}` : '')"
@keyup.enter="handleSearch" @clear="handleSearch" /> readonly placeholder="筛选用户" clearable style="width:140px;cursor:pointer"
@click="showFilterUserSelector = true"
@clear="query.user_id = ''; filterUserName = ''; handleSearch()" />
</el-form-item> </el-form-item>
<el-form-item label="商品ID"> <el-form-item label="商品ID">
<el-input v-model="query.good_id" placeholder="筛选商品" clearable style="width:120px" <el-input :model-value="filterGoodName || (query.good_id ? `${query.good_id}` : '')"
@keyup.enter="handleSearch" @clear="handleSearch" /> readonly placeholder="筛选商品" clearable style="width:140px;cursor:pointer"
@click="showFilterProductSelector = true"
@clear="query.good_id = ''; filterGoodName = ''; handleSearch()" />
</el-form-item> </el-form-item>
<el-form-item label="关键词"> <el-form-item label="关键词">
<el-input v-model="query.key" placeholder="搜索关键词" clearable style="width:180px" <el-input v-model="query.key" placeholder="搜索关键词" clearable style="width:180px"
@@ -23,7 +27,7 @@
<el-button type="primary" @click="handleSearch"> <el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>查询 <el-icon><Search /></el-icon>查询
</el-button> </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-item>
</el-form> </el-form>
<div class="action-bar"> <div class="action-bar">
@@ -69,8 +73,8 @@
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="商品" min-width="160" show-overflow-tooltip> <el-table-column label="商品" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.good?.name || '-' }}</template> <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>
<el-table-column label="标签" width="100"> <el-table-column label="标签" width="100">
<template #default="{ row }"> <template #default="{ row }">
@@ -97,11 +101,13 @@
</span> </span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="170" fixed="right"> <el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="action-buttons"> <div class="action-buttons">
<el-button link type="primary" size="small" @click="handleDetail(row)">详情</el-button> <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="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> <el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div> </div>
</template> </template>
@@ -189,11 +195,17 @@
<div v-else class="form-hint">普通商品点击将商品ID赋值为归属项</div> <div v-else class="form-hint">普通商品点击将商品ID赋值为归属项</div>
</el-form-item> </el-form-item>
<el-form-item label="续费价格(元)"> <el-form-item label="续费价格">
<el-input-number v-model="createForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" /> <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>
<el-form-item label="基础价格(元)"> <el-form-item label="基础价格">
<el-input-number v-model="createForm._baseYuan" :min="0" :precision="2" controls-position="right" style="width:100%" /> <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>
<el-form-item label="到期时间"> <el-form-item label="到期时间">
<el-date-picker v-model="createForm.expire_time" type="datetime" <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-if="editForm._goodTag === '云服务器'" class="form-hint">云服务器商品点击选择用户虚拟机作为归属项</div>
<div v-else class="form-hint">普通商品点击将商品ID赋值为归属项</div> <div v-else class="form-hint">普通商品点击将商品ID赋值为归属项</div>
</el-form-item> </el-form-item>
<el-form-item label="续费价格(元)"> <el-form-item label="续费价格">
<el-input-number v-model="editForm._renewYuan" :min="0" :precision="2" controls-position="right" style="width:100%" /> <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>
<el-form-item label="基础价格(元)"> <el-form-item label="基础价格">
<el-input-number v-model="editForm._baseYuan" :min="0" :precision="2" controls-position="right" style="width:100%" /> <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>
<el-form-item label="到期时间"> <el-form-item label="到期时间">
<el-date-picker v-model="editForm.expire_time" type="datetime" <el-date-picker v-model="editForm.expire_time" type="datetime"
@@ -250,9 +268,9 @@
<ProductSelector v-model="showProductSelector" @confirm="handleProductSelected" /> <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="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 }" /> <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" /> <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> <el-dialog v-model="showVmListDialog" title="选择用户虚拟机" width="800px" append-to-body destroy-on-close>
<div style="margin-bottom:12px"> <div style="margin-bottom:12px">
@@ -261,8 +279,24 @@
<el-input v-model="vmListQuery.key" placeholder="搜索" clearable style="width:180px" <el-input v-model="vmListQuery.key" placeholder="搜索" clearable style="width:180px"
@keyup.enter="loadVmListForItem" @clear="loadVmListForItem" /> @keyup.enter="loadVmListForItem" @clear="loadVmListForItem" />
</el-form-item> </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-form-item>
<el-button type="primary" @click="loadVmListForItem">搜索</el-button> <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-item>
</el-form> </el-form>
</div> </div>
@@ -270,17 +304,36 @@
@current-change="r => vmListSelected = r" :height="350" style="width:100%" @current-change="r => vmListSelected = r" :height="350" style="width:100%"
:header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600 }"> :header-cell-style="{ background: '#f8f9fa', color: '#2c3e50', fontWeight: 600 }">
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column label="用户" min-width="120"> <el-table-column label="虚拟机名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.user?.UserName || row.user?.username || '-' }}</template> <template #default="{ row }">{{ row.name || '-' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="商品" min-width="140" show-overflow-tooltip> <el-table-column label="配置" min-width="120">
<template #default="{ row }">{{ row.good?.name || '-' }}</template> <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>
<el-table-column label="归属项ID" width="100"> <el-table-column label="状态" width="80">
<template #default="{ row }">{{ row.itemId || row.item_id || '-' }}</template> <template #default="{ row }">
<el-tag :type="getStatusType(row.status)" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column> </el-table-column>
<el-table-column label="到期时间" width="170"> <el-table-column label="绑定状态" width="90">
<template #default="{ row }">{{ formatExpireTime(row.expireTime || row.expire_time) }}</template> <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> </el-table-column>
<template #empty> <template #empty>
<el-empty description="暂无虚拟机数据" :image-size="80" /> <el-empty description="暂无虚拟机数据" :image-size="80" />
@@ -316,9 +369,21 @@
</el-radio-group> </el-radio-group>
</template> </template>
<template v-else-if="spec.type === 'number'"> <template v-else-if="spec.type === 'number'">
<div style="display:flex;align-items:center;gap:12px"> <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<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" /> <el-input-number
<span class="form-hint" style="margin-top:0">范围: {{ spec.min || 0 }} ~ {{ spec.max || 9999 }}步长: {{ spec.step || 1 }}</span> 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> </div>
</template> </template>
<template v-else> <template v-else>
@@ -337,6 +402,40 @@
</div> </div>
</template> </template>
</el-dialog> </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> </div>
</template> </template>
@@ -345,10 +444,11 @@ import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search } from '@element-plus/icons-vue' 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 { extractApiError } from '@/utils/kvmErrorUtil'
import { formatToApiTime } from '@/utils/tool' import { formatToApiTime } from '@/utils/tool'
import { getProductParameterList, getProductPlanDetail } from '@/api/admin/product' 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 ProductSelector from '@/components/admin/ProductSelector.vue'
import UserSelector from '@/components/UserSelector/index.vue' import UserSelector from '@/components/UserSelector/index.vue'
import OrderSelector from '@/components/admin/OrderSelector.vue' import OrderSelector from '@/components/admin/OrderSelector.vue'
@@ -360,6 +460,10 @@ const loading = ref(false)
const list = ref([]) const list = ref([])
const total = ref(0) const total = ref(0)
const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '' }) const query = reactive({ page: 1, count: 10, key: '', user_id: '', good_id: '' })
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') : '-' 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') 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 () => { const loadList = async () => {
loading.value = true loading.value = true
try { try {
@@ -392,6 +521,8 @@ const handleSearch = () => { query.page = 1; loadList() }
const argsSpecList = ref([]) const argsSpecList = ref([])
const argsSpecLoading = ref(false) const argsSpecLoading = ref(false)
const argsValues = reactive({}) const argsValues = reactive({})
const argsDisplayValues = reactive({})
const argsDisplayUnits = reactive({})
const showArgsDialog = ref(false) const showArgsDialog = ref(false)
const argsCount = computed(() => { const argsCount = computed(() => {
@@ -419,13 +550,38 @@ const loadArgsSpec = async (goodId) => {
argsSpecList.value = res.data.data || [] argsSpecList.value = res.data.data || []
for (const spec of argsSpecList.value) { for (const spec of argsSpecList.value) {
if (spec.type === 'number' && argsValues[spec.id] === undefined) { 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 } } 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 buildArgsJson = () => {
const argsArray = [] const argsArray = []
for (const spec of argsSpecList.value) { for (const spec of argsSpecList.value) {
@@ -515,7 +671,8 @@ const vmListForItem = ref([])
const vmListLoading = ref(false) const vmListLoading = ref(false)
const vmListSelected = ref(null) const vmListSelected = ref(null)
const vmListTotal = ref(0) 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 = () => { const handleItemSelect = () => {
if (createForm._goodTag === '云服务器') { if (createForm._goodTag === '云服务器') {
@@ -534,6 +691,14 @@ const loadVmListForItem = async () => {
try { try {
const params = { page: vmListQuery.page, count: vmListQuery.count } const params = { page: vmListQuery.page, count: vmListQuery.count }
if (vmListQuery.key) params.key = vmListQuery.key 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) const res = await getUserVmList(params)
if (res?.data?.code === 200 && res?.data?.data) { if (res?.data?.code === 200 && res?.data?.data) {
const d = res.data.data const d = res.data.data
@@ -555,7 +720,23 @@ const confirmVmForItem = () => {
} }
showVmListDialog.value = false showVmListDialog.value = false
vmListSelected.value = null 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 () => { 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) => { const handleDelete = (row) => {
ElMessageBox.confirm(`确定删除该用户商品吗?`, '删除确认', { type: 'warning' }) ElMessageBox.confirm(`确定删除该用户商品吗?`, '删除确认', { type: 'warning' })
@@ -651,6 +839,45 @@ const handleDelete = (row) => {
}).catch(() => {}) }).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) onMounted(loadList)
</script> </script>
@@ -708,28 +935,6 @@ onMounted(loadList)
padding: 0; 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 { .user-cell {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -746,11 +951,6 @@ onMounted(loadList)
font-size: 12px; font-size: 12px;
} }
.text-muted {
color: #c0c4cc;
font-size: 12px;
}
.price-text { .price-text {
color: #e74c3c; color: #e74c3c;
font-weight: 600; font-weight: 600;
@@ -882,4 +1082,7 @@ onMounted(loadList)
justify-content: flex-start; 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> </style>
@@ -29,10 +29,18 @@
<el-tag :type="getArgTypeTag(row.type)">{{ getArgTypeText(row.type) }}</el-tag> <el-tag :type="getArgTypeTag(row.type)">{{ getArgTypeText(row.type) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="数值配置" min-width="180"> <el-table-column label="数值配置" min-width="220">
<template #default="{ row }"> <template #default="{ row }">
<template v-if="row.type === 'number'"> <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> </template>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</template> </template>
@@ -58,49 +66,88 @@
:title="paramFormType === 'add' ? '新增商品参数' : '编辑商品参数'" :title="paramFormType === 'add' ? '新增商品参数' : '编辑商品参数'"
width="600px" width="600px"
append-to-body append-to-body
class="tk-dialog"
> >
<el-form ref="paramFormRef" :model="paramForm" :rules="paramRules" label-width="120px"> <el-form ref="paramFormRef" :model="paramForm" :rules="paramRules" label-width="100px">
<el-form-item label="参数名称" prop="arg_name"> <div class="tk-section">
<el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" /> <div class="tk-section-title">基本信息</div>
</el-form-item> <el-form-item label="参数名称" prop="arg_name">
<el-form-item label="参数类型" prop="arg_type"> <el-input v-model="paramForm.arg_name" placeholder="请输入参数名称" />
<el-radio-group v-model="paramForm.arg_type"> </el-form-item>
<el-radio label="string">字符串</el-radio> <el-form-item label="参数类型" prop="arg_type">
<el-radio label="number">数字</el-radio> <el-radio-group v-model="paramForm.arg_type">
<el-radio label="select">选择</el-radio> <el-radio label="string">字符串</el-radio>
</el-radio-group> <el-radio label="number">数字</el-radio>
</el-form-item> <el-radio label="select">选择</el-radio>
<el-form-item label="是否必选" prop="must"> </el-radio-group>
<el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" /> </el-form-item>
</el-form-item> <el-form-item label="是否必选" prop="must">
<el-divider content-position="left">权限控制</el-divider> <el-switch v-model="paramForm.must" :active-value="true" :inactive-value="false" active-text="必选" inactive-text="可选" />
<el-form-item label="允许单独购买"> </el-form-item>
<el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" /> </div>
<div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div> <div class="tk-section">
</el-form-item> <div class="tk-section-title">权限控制</div>
<el-form-item label="用户组优惠"> <el-form-item label="允许单独购买">
<el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" /> <el-switch v-model="paramForm.user_add" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户组优惠</div> <div style="font-size: 12px; color: #909399; margin-top: 4px">购买后是否允许单独追加购买</div>
</el-form-item> </el-form-item>
<el-form-item label="用户优惠"> <el-form-item label="用户优惠">
<el-switch v-model="paramForm.use_user_discount" active-text="允许" inactive-text="不允许" /> <el-switch v-model="paramForm.use_user_group_discount" active-text="允许" inactive-text="不允许" />
<div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠代金券与优惠码</div> <div style="font-size: 12px; color: #909399; margin-top: 4px">是否允许使用用户优惠</div>
</el-form-item> </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'"> <template v-if="paramForm.arg_type === 'number'">
<el-divider content-position="left">数值参数配置</el-divider> <div class="tk-section">
<el-form-item label="步进值" prop="arg_step"> <div class="tk-section-title">数值参数配置</div>
<el-input-number v-model="paramForm.arg_step" :min="1" placeholder="步进值" style="width: 100%" /> <el-form-item label="步进值" prop="arg_step">
</el-form-item> <div class="unit-input-row">
<el-form-item label="最小值" prop="arg_min"> <el-input-number v-model="paramForm.step_display" :min="1" placeholder="步进值" style="flex: 1" />
<el-input-number v-model="paramForm.arg_min" placeholder="最小值" style="width: 100%" /> <el-select v-if="paramForm.enable_unit && paramForm.arg_key" :model-value="paramForm.step_unit" style="width: 100px" @change="(v) => onFieldUnitChange('step', v)">
</el-form-item> <el-option v-for="u in formUnits" :key="u" :label="u" :value="u" />
<el-form-item label="最大值" prop="arg_max"> </el-select>
<el-input-number v-model="paramForm.arg_max" placeholder="最大值" style="width: 100%" /> </div>
</el-form-item> </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> </template>
</el-form> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="tk-dialog-footer">
<el-button @click="paramFormDialogVisible = false">取消</el-button> <el-button @click="paramFormDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitParamForm">确定</el-button> <el-button type="primary" @click="submitParamForm">确定</el-button>
</div> </div>
@@ -110,7 +157,12 @@
<!-- 参数值管理对话框 --> <!-- 参数值管理对话框 -->
<el-dialog v-model="paramValuesDialogVisible" title="参数值管理" width="800px" append-to-body> <el-dialog v-model="paramValuesDialogVisible" title="参数值管理" width="800px" append-to-body>
<div class="values-header"> <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-button type="primary" @click="handleAddParamValue">
<el-icon><Plus /></el-icon>添加参数值 <el-icon><Plus /></el-icon>添加参数值
</el-button> </el-button>
@@ -123,11 +175,13 @@
> >
<el-table-column prop="id" label="值ID" width="80" /> <el-table-column prop="id" label="值ID" width="80" />
<el-table-column prop="name" label="值名称" min-width="120" /> <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 #default="{ row }">
<template v-if="currentParam?.type === 'select'">{{ row.value || '-' }}</template> <template v-if="currentParam?.type === 'select'">{{ row.value || '-' }}</template>
<template v-else-if="currentParam?.type === 'number'"> <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>
<template v-else>{{ row.value || '-' }}</template> <template v-else>{{ row.value || '-' }}</template>
</template> </template>
@@ -156,37 +210,65 @@
:title="paramValueFormType === 'add' ? '添加参数值' : '编辑参数值'" :title="paramValueFormType === 'add' ? '添加参数值' : '编辑参数值'"
width="550px" width="550px"
append-to-body append-to-body
class="tk-dialog"
> >
<el-form ref="paramValueFormRef" :model="paramValueForm" :rules="paramValueRules" label-width="120px"> <el-form ref="paramValueFormRef" :model="paramValueForm" :rules="paramValueRules" label-width="100px">
<el-form-item label="值名称" prop="attr_name"> <div class="tk-section">
<el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" /> <div class="tk-section-title">参数值信息</div>
</el-form-item> <el-form-item label="值名称" prop="attr_name">
<el-form-item v-if="currentParam?.type === 'select'" label="参数值" prop="attr_value"> <el-input v-model="paramValueForm.attr_name" placeholder="请输入值名称" />
<el-input v-model="paramValueForm.attr_value" placeholder="请输入参数值" /> </el-form-item>
</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'"> <template v-if="currentParam?.type === 'number'">
<el-divider content-position="left">数值范围配置phase</el-divider> <div class="tk-section">
<el-form-item label="范围类型" prop="range_type"> <div class="tk-section-title">数值范围配置</div>
<el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%"> <el-form-item label="范围类型" prop="range_type">
<el-option label="小于等于 (before)" value="before" /> <el-select v-model="paramValueForm.range_type" placeholder="请选择范围类型" style="width: 100%">
<el-option label="大于等于 (after)" value="after" /> <el-option label="小于 (before)" value="before" />
<el-option label="于 (equal)" value="equal" /> <el-option label="于 (after)" value="after" />
</el-select> <el-option label="等于 (equal)" value="equal" />
<div class="form-tip">before: 数值 phase 时匹配 | after: 数值 phase 时匹配</div> </el-select>
</el-form-item> <div class="form-tip">before: 数值 &lt; phase 时匹配 | after: 数值 &gt; phase 时匹配</div>
<el-form-item label="阈值" prop="attr_range"> </el-form-item>
<el-input-number v-model="paramValueForm.attr_range" :min="0" placeholder="范围阈值" style="width: 100%" /> <el-form-item label="阈值" prop="attr_range">
</el-form-item> <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> </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> </el-form>
<template #footer> <template #footer>
<div class="dialog-footer"> <div class="tk-dialog-footer">
<el-button @click="paramValueFormDialogVisible = false">取消</el-button> <el-button @click="paramValueFormDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitParamValueForm">确定</el-button> <el-button type="primary" @click="submitParamValueForm">确定</el-button>
</div> </div>
@@ -195,7 +277,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, watch, nextTick } from 'vue' import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh } from '@element-plus/icons-vue' import { Plus, Refresh } from '@element-plus/icons-vue'
import { import {
@@ -208,6 +290,11 @@ import {
updateProductParameterValue, updateProductParameterValue,
deleteProductParameterValue deleteProductParameterValue
} from '@/api/admin/product' } from '@/api/admin/product'
import {
getAvailableUnits, getArgKeyOptions, hasUnit, getArgKey,
getBaseUnit, getDefaultDisplayUnit, getParamDefaultUnit, getParamUnits,
toBaseUnit, fromBaseUnit, formatValueWithUnit
} from '@/utils/dynamicUnit'
const props = defineProps({ const props = defineProps({
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
@@ -215,6 +302,8 @@ const props = defineProps({
}) })
const emit = defineEmits(['update:visible']) const emit = defineEmits(['update:visible'])
const argKeyOptions = getArgKeyOptions()
const paramLoading = ref(false) const paramLoading = ref(false)
const parameterList = ref([]) const parameterList = ref([])
@@ -229,15 +318,70 @@ const paramForm = reactive({
arg_step: 1, arg_step: 1,
arg_min: 0, arg_min: 0,
arg_max: 100, arg_max: 100,
step_display: 1,
min_display: 0,
max_display: 100,
step_unit: '',
min_unit: '',
max_unit: '',
user_add: false, user_add: false,
use_user_group_discount: false, use_user_group_discount: false,
use_user_discount: false use_user_discount: false,
enable_unit: false,
arg_key: ''
}) })
const paramRules = { const paramRules = {
arg_name: [{ required: true, message: '请输入参数名称', trigger: 'blur' }], arg_name: [{ required: true, message: '请输入参数名称', trigger: 'blur' }],
arg_type: [{ required: true, message: '请选择参数类型', trigger: 'change' }] 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 paramValuesDialogVisible = ref(false)
const paramValuesLoading = ref(false) const paramValuesLoading = ref(false)
const paramValueList = ref([]) const paramValueList = ref([])
@@ -253,12 +397,58 @@ const paramValueForm = reactive({
attr_price: 0, attr_price: 0,
index: 0, index: 0,
attr_range: 0, attr_range: 0,
attr_range_display: 0,
display_unit: '',
range_type: 'equal' range_type: 'equal'
}) })
const paramValueRules = { const paramValueRules = {
attr_name: [{ required: true, message: '请输入值名称', trigger: 'blur' }] 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 getArgTypeText = (type) => {
const typeMap = { 'string': '字符串', 'number': '数字', 'select': '选择' } const typeMap = { 'string': '字符串', 'number': '数字', 'select': '选择' }
return typeMap[type] || '未知' return typeMap[type] || '未知'
@@ -268,7 +458,7 @@ const getArgTypeTag = (type) => {
return tagMap[type] || 'info' return tagMap[type] || 'info'
} }
const getRangeTypeText = (type) => { const getRangeTypeText = (type) => {
const typeMap = { 'after': '大于 >', 'before': '小于 <', 'equal': '等于 =' } const typeMap = { 'after': '', 'before': '', 'equal': '' }
return typeMap[type] || type || '-' return typeMap[type] || type || '-'
} }
@@ -290,14 +480,45 @@ const fetchParameterList = async () => {
const handleAddParameter = () => { const handleAddParameter = () => {
paramFormType.value = 'add' paramFormType.value = 'add'
paramFormDialogVisible.value = true 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() }) nextTick(() => { paramFormRef.value?.resetFields() })
} }
const handleEditParameter = (row) => { const handleEditParameter = (row) => {
paramFormType.value = 'edit' paramFormType.value = 'edit'
paramFormDialogVisible.value = true 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) => { const handleDeleteParameter = (row) => {
@@ -315,11 +536,27 @@ const submitParamForm = () => {
paramFormRef.value?.validate(async (valid) => { paramFormRef.value?.validate(async (valid) => {
if (valid) { if (valid) {
try { 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') { if (paramForm.arg_type === 'number') {
submitData.arg_step = Number(paramForm.arg_step) if (paramForm.enable_unit && paramForm.arg_key) {
submitData.arg_min = Number(paramForm.arg_min) submitData.arg_step = calcBase(paramForm.step_display, paramForm.step_unit)
submitData.arg_max = Number(paramForm.arg_max) 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 if (paramFormType.value === 'edit') submitData.arg_id = paramForm.arg_id
const res = paramFormType.value === 'add' ? await createProductParameter(submitData) : await updateProductParameter(submitData) const res = paramFormType.value === 'add' ? await createProductParameter(submitData) : await updateProductParameter(submitData)
@@ -348,14 +585,30 @@ const fetchParamValuesList = async () => {
const handleAddParamValue = () => { const handleAddParamValue = () => {
paramValueFormType.value = 'add' paramValueFormType.value = 'add'
paramValueFormDialogVisible.value = true paramValueFormDialogVisible.value = true
Object.assign(paramValueForm, { attr_id: undefined, attr_name: '', attr_value: '', attr_price: 0, index: 0, attr_range: 0, range_type: 'equal' }) const defaultUnit = hasUnit(currentParam.value) ? getParamDefaultUnit(currentParam.value) : ''
nextTick(() => { paramValueFormRef.value?.resetFields() }) 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) => { const handleEditParamValue = (row) => {
paramValueFormType.value = 'edit' paramValueFormType.value = 'edit'
paramValueFormDialogVisible.value = true 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) => { const handleDeleteParamValue = (row) => {
@@ -375,7 +628,27 @@ const submitParamValueForm = () => {
try { 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) } 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 === '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 if (paramValueFormType.value === 'edit') submitData.attr_id = paramValueForm.attr_id
const res = paramValueFormType.value === 'add' ? await addProductParameterValue(submitData) : await updateProductParameterValue(submitData) 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() } 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; } .action-buttons .el-button { padding: 4px 8px; }
.text-muted { color: #c0c4cc; font-size: 12px; } .text-muted { color: #c0c4cc; font-size: 12px; }
.number-config { color: #909399; font-size: 13px; } .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; } .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; } .dialog-footer { display: flex; justify-content: flex-end; gap: 12px; padding: 0; }
.form-tip { font-size: 12px; color: #909399; margin-top: 4px; } .form-tip { font-size: 12px; color: #909399; margin-top: 4px; }
.unit-input-row { display: flex; align-items: center; gap: 8px; width: 100%; }
</style> </style>
@@ -24,11 +24,11 @@
> >
<el-table-column prop="id" label="ID" width="80" /> <el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="套餐名称" min-width="120" /> <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 }"> <template #default="{ row }">
<div v-if="row.argsParsed && row.argsParsed.length > 0" class="args-list"> <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;"> <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> </el-tag>
</div> </div>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
@@ -48,6 +48,13 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </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"> <el-table-column label="操作" width="180">
<template #default="{ row }"> <template #default="{ row }">
<el-button type="primary" link @click="handleEditPlan(row)">编辑</el-button> <el-button type="primary" link @click="handleEditPlan(row)">编辑</el-button>
@@ -84,12 +91,14 @@
<div class="args-config-container"> <div class="args-config-container">
<div class="args-select-row"> <div class="args-select-row">
<el-select v-model="selectedArgIds" multiple placeholder="请选择需要配置的参数" style="width: 100%" @change="onSelectedArgsChange"> <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> </el-select>
</div> </div>
<div v-if="selectedArgSpecs.length > 0" class="args-selector"> <div v-if="selectedArgSpecs.length > 0" class="args-selector">
<div v-for="spec in selectedArgSpecs" :key="spec.id" class="spec-item"> <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"> <div class="spec-values">
<template v-if="spec.type === 'select' && spec.attrs && spec.attrs.length > 0"> <template v-if="spec.type === 'select' && spec.attrs && spec.attrs.length > 0">
<el-radio-group v-model="selectedArgs[spec.id]" size="small" @change="updateArgsJson"> <el-radio-group v-model="selectedArgs[spec.id]" size="small" @change="updateArgsJson">
@@ -98,8 +107,26 @@
</template> </template>
<template v-else-if="spec.type === 'number'"> <template v-else-if="spec.type === 'number'">
<div class="number-input-wrapper"> <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" /> <el-input-number
<span class="number-range">(范围: {{ spec.min || 0 }} - {{ spec.max || 9999 }}步长: {{ spec.step || 1 }})</span> 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>
<div v-if="spec.attrs && spec.attrs.length > 0 && selectedArgs[spec.id]" class="matched-attr-info"> <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> <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-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>
<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-button type="warning" plain size="small" @click="clearArgsSelection">
<el-icon><Delete /></el-icon>清空选择 <el-icon><Delete /></el-icon>清空选择
</el-button> </el-button>
@@ -127,7 +160,7 @@
<div class="args-config-container"> <div class="args-config-container">
<div class="form-tip" style="margin-bottom: 8px;">选择参数配置中未选择的参数作为额外参数</div> <div class="form-tip" style="margin-bottom: 8px;">选择参数配置中未选择的参数作为额外参数</div>
<el-select v-model="selectedExtraArgIds" multiple placeholder="请选择额外参数" style="width: 100%" @change="onSelectedExtraArgsChange"> <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-select>
<el-empty v-if="extraSpecList.length === 0" description="所有参数已在参数配置中选择" :image-size="40" /> <el-empty v-if="extraSpecList.length === 0" description="所有参数已在参数配置中选择" :image-size="40" />
</div> </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" /> <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> <div class="form-tip">启用后套餐价格将使用固定价格不再根据参数计算</div>
</el-form-item> </el-form-item>
<el-form-item label="固定价格(元)" prop="fixed_price" v-if="planForm.enable_fixed_price === true"> <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="请输入固定价格(元)" /> <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>
<el-form-item label="排序索引" prop="index"> <el-form-item label="排序索引" prop="index">
<el-input-number v-model="planForm.index" :min="0" style="width: 100%" /> <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="不展示" /> <el-switch v-model="planForm.show_home" active-text="展示" inactive-text="不展示" />
<div class="form-tip">控制商品套餐是否在首页显示</div> <div class="form-tip">控制商品套餐是否在首页显示</div>
</el-form-item> </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> </el-form>
</div> </div>
<template #footer> <template #footer>
@@ -166,6 +206,15 @@
</template> </template>
</el-dialog> </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> <el-dialog v-model="showArgsPreview" title="参数配置预览" width="500px" append-to-body>
<div class="args-preview"> <div class="args-preview">
@@ -192,7 +241,7 @@
<script setup> <script setup>
import { ref, reactive, computed, watch, nextTick } from 'vue' import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { import {
getProductParameterList, getProductParameterList,
getProductPlanList, getProductPlanList,
@@ -205,6 +254,10 @@ import {
disablePlanFixedPrice, disablePlanFixedPrice,
enablePlanFixedPrice enablePlanFixedPrice
} from '@/api/admin/product' } from '@/api/admin/product'
import {
hasUnit, getArgKey, getBaseUnit, getParamUnits, getParamDefaultUnit,
toBaseUnit, fromBaseUnit, formatValueWithUnit
} from '@/utils/dynamicUnit'
const props = defineProps({ const props = defineProps({
visible: { type: Boolean, default: false }, visible: { type: Boolean, default: false },
@@ -232,7 +285,8 @@ const planForm = reactive({
enable_fixed_price: false, enable_fixed_price: false,
index: 0, index: 0,
disable: false, disable: false,
show_home: false show_home: false,
can_update: false
}) })
const planFormRules = { const planFormRules = {
name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }] name: [{ required: true, message: '请输入套餐名称', trigger: 'blur' }]
@@ -241,12 +295,73 @@ const planFormRules = {
const planSpecList = ref([]) const planSpecList = ref([])
const selectedArgIds = ref([]) const selectedArgIds = ref([])
const selectedArgs = reactive({}) const selectedArgs = reactive({})
const displayValues = reactive({})
const displayUnits = reactive({})
const showArgsPreview = ref(false) const showArgsPreview = ref(false)
const showPasteDialog = ref(false)
const pasteJsonText = ref('')
const selectedExtraArgIds = ref([]) const selectedExtraArgIds = ref([])
const selectedArgSpecs = computed(() => planSpecList.value.filter(spec => selectedArgIds.value.includes(spec.id))) 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 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) => { const parseArgs = (argsStr) => {
if (!argsStr) return [] if (!argsStr) return []
try { const parsed = JSON.parse(argsStr); return Array.isArray(parsed) ? parsed : [] } try { const parsed = JSON.parse(argsStr); return Array.isArray(parsed) ? parsed : [] }
@@ -262,8 +377,14 @@ const fetchPlanSpecList = async () => {
} }
const onSelectedArgsChange = () => { 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)) 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() updateArgsJson()
updateExtraArgIds() updateExtraArgIds()
} }
@@ -280,13 +401,13 @@ const updateArgsJson = () => {
if (selectedValue === undefined || selectedValue === '') continue if (selectedValue === undefined || selectedValue === '') continue
if (spec.type === 'select') { if (spec.type === 'select') {
const attrObj = spec.attrs?.find(a => a.id === selectedValue) 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') { } else if (spec.type === 'number') {
const numValue = Number(selectedValue) const numValue = Number(selectedValue)
const matchedAttr = findMatchingNumberAttr(spec, numValue) 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 { } 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) : '' planForm.args = argsArray.length > 0 ? JSON.stringify(argsArray) : ''
@@ -298,8 +419,8 @@ const findMatchingNumberAttr = (spec, numValue) => {
for (const attr of sortedAttrs) { for (const attr of sortedAttrs) {
const phase = attr.phase || 0 const phase = attr.phase || 0
const rangeType = attr.rangeType || 'before' const rangeType = attr.rangeType || 'before'
if (rangeType === 'before' && numValue <= phase) return attr if (rangeType === 'before' && numValue < phase) return attr
else if (rangeType === 'after' && numValue >= phase) return attr else if (rangeType === 'after' && numValue > phase) return attr
else if (rangeType === 'equal' && numValue === phase) return attr else if (rangeType === 'equal' && numValue === phase) return attr
} }
return sortedAttrs[sortedAttrs.length - 1] return sortedAttrs[sortedAttrs.length - 1]
@@ -314,6 +435,8 @@ const getMatchedAttrName = (spec, numValue) => {
const clearArgsSelection = () => { const clearArgsSelection = () => {
selectedArgIds.value = [] 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 = [] selectedExtraArgIds.value = []
planForm.args = '' planForm.args = ''
planForm.extra_arg_ids = '' planForm.extra_arg_ids = ''
@@ -324,6 +447,12 @@ const getSelectedValueDisplay = (spec) => {
const selectedValue = selectedArgs[spec.id] const selectedValue = selectedArgs[spec.id]
if (selectedValue === undefined || selectedValue === '') return null 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 (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) return String(selectedValue)
} }
@@ -334,6 +463,100 @@ const formatArgsJsonPreview = () => {
try { return JSON.stringify(JSON.parse(planForm.args), null, 2) } catch { return planForm.args } 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 = []) => { const initSelectedArgsFromJson = (argsJson, extraArgIds = []) => {
clearArgsSelection() clearArgsSelection()
const argsParamIds = [] const argsParamIds = []
@@ -349,8 +572,19 @@ const initSelectedArgsFromJson = (argsJson, extraArgIds = []) => {
if (arg.attr_id) selectedArgs[spec.id] = arg.attr_id if (arg.attr_id) selectedArgs[spec.id] = arg.attr_id
else if (arg.id) selectedArgs[spec.id] = arg.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 { 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 if (spec.type === 'number') {
else { selectedArgs[spec.id] = 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
}
} }
} }
} catch (e) { console.error('解析args失败:', e) } } catch (e) { console.error('解析args失败:', e) }
@@ -382,7 +616,12 @@ const handleAddPlan = async () => {
await fetchPlanSpecList() await fetchPlanSpecList()
clearArgsSelection() clearArgsSelection()
selectedArgIds.value = planSpecList.value.map(spec => spec.id) 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 planFormDialogVisible.value = true
nextTick(() => { planFormRef.value?.resetFields() }) nextTick(() => { planFormRef.value?.resetFields() })
} }
@@ -408,7 +647,8 @@ const handleEditPlan = async (row) => {
extra_arg_ids: extraArgIdsArray.join(','), extra_arg_ids_array: extraArgIdsArray, 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, 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), 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) initSelectedArgsFromJson(data.args, extraArgIdsArray)
planFormDialogVisible.value = true planFormDialogVisible.value = true
@@ -451,13 +691,21 @@ const handleFixedPriceChange = async (value) => {
const submitPlanForm = () => { const submitPlanForm = () => {
planFormRef.value?.validate(async (valid) => { planFormRef.value?.validate(async (valid) => {
if (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 { try {
const extraArgIdsStr = selectedExtraArgIds.value.join(',') const extraArgIdsStr = selectedExtraArgIds.value.join(',')
const submitData = { const submitData = {
good_id: String(props.goodId), name: planForm.name, note: planForm.note || '', good_id: String(props.goodId), name: planForm.name, note: planForm.note || '',
args: planForm.args || '', extra_arg_ids: extraArgIdsStr || planForm.extra_arg_ids || '', 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, 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 if (planFormType.value === 'add') submitData.enable_fixed_price = planForm.enable_fixed_price === true
let res let res
@@ -490,16 +738,17 @@ watch(() => props.visible, (val) => {
.args-config-container { width: 100%; } .args-config-container { width: 100%; }
.args-select-row { margin-bottom: 12px; } .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; } .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-item:last-child { border-bottom: none; }
.spec-label { width: 100px; flex-shrink: 0; font-weight: 500; color: #606266; padding-top: 4px; } .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; }
.spec-values { flex: 1; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } .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-group) { display: flex; flex-wrap: wrap; gap: 6px; }
.spec-values :deep(.el-radio-button__inner) { padding: 6px 12px; } .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; } .number-range { color: #909399; font-size: 12px; }
.matched-attr-info { margin-top: 6px; } .matched-attr-info { margin-top: 2px; }
.args-actions { margin-top: 12px; display: flex; gap: 8px; } .args-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; }
.args-preview { padding: 0; } .args-preview { padding: 0; }
.preview-header { display: flex; justify-content: space-between; align-items: center; } .preview-header { display: flex; justify-content: space-between; align-items: center; }
.preview-list { max-height: 200px; overflow-y: auto; } .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 { color: #409eff; font-weight: 500; }
.preview-value.not-selected { color: #c0c4cc; font-style: italic; } .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; } .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> </style>
-27
View File
@@ -349,33 +349,6 @@ onMounted(() => {
gap: 12px; 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) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
+9 -5
View File
@@ -132,6 +132,7 @@
<UserGroupSelector <UserGroupSelector
v-model="groupSelectorVisible" v-model="groupSelectorVisible"
:current-group-id="selectorType === 'query' ? queryParams.admin_group_id : permissionForm.admin_group_id" :current-group-id="selectorType === 'query' ? queryParams.admin_group_id : permissionForm.admin_group_id"
admin-group
@confirm="handleGroupSelectorConfirm" @confirm="handleGroupSelectorConfirm"
/> />
@@ -465,12 +466,15 @@ const handleGroupSelectorConfirm = (group) => {
if (selectorType.value === 'query') { if (selectorType.value === 'query') {
queryParams.admin_group_id = groupId queryParams.admin_group_id = groupId
if (!GroupOptions.value.find(g => g.id === groupId)) {
GroupOptions.value.push({ id: groupId, name: groupName })
}
fetchAdminPermissionList()
} else { } else {
permissionForm.admin_group_id = groupId 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 groupSelectorVisible.value = false
@@ -492,10 +496,10 @@ const handleUserSelectorConfirm = (user) => {
if (selectorType.value === 'query') { if (selectorType.value === 'query') {
queryParams.user_id = userId queryParams.user_id = userId
// UserOptions
if (!UserOptions.value.find(u => u.UserId === userId)) { if (!UserOptions.value.find(u => u.UserId === userId)) {
UserOptions.value.push({ UserId: userId, UserName: userName }) UserOptions.value.push({ UserId: userId, UserName: userName })
} }
fetchAdminPermissionList()
} else if (selectorType.value === 'form') { } else if (selectorType.value === 'form') {
permissionForm.user_id = userId permissionForm.user_id = userId
if (!UserOptions.value.find(u => u.UserId === userId)) { if (!UserOptions.value.find(u => u.UserId === userId)) {
-27
View File
@@ -528,33 +528,6 @@ onMounted(() => {
gap: 12px; 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) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
+74 -90
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="ticket-list-page"> <div class="ticket-list-page">
<!-- 顶部工具 --> <!-- 顶部状态标签 -->
<div class="toolbar"> <div class="status-bar">
<div class="status-tabs"> <div class="status-tabs">
<div class="tab-item pending" :class="{ active: activeStatus === 'pending' }" @click="filterByStatus('pending')"> <div class="tab-item pending" :class="{ active: activeStatus === 'pending' }" @click="filterByStatus('pending')">
待处理 <span class="count">{{ stats.pending }}</span> 待处理 <span class="count">{{ stats.pending }}</span>
@@ -19,48 +19,47 @@
全部 <span class="count">{{ stats.total }}</span> 全部 <span class="count">{{ stats.total }}</span>
</div> </div>
</div> </div>
<div class="toolbar-right"> </div>
<el-select v-model="sortBy" placeholder="排序方式" clearable style="width: 140px" @change="handleSortChange"> <!-- 筛选工具栏 -->
<el-option label="不排序" value="" /> <div class="filter-bar">
<el-option label="创建时间" value="created_at" /> <el-select v-model="sortBy" placeholder="排序方式" clearable style="width: 140px" @change="handleSortChange">
<el-option label="更新时间" value="updated_at" /> <el-option label="不排序" value="" />
<el-option label="工单号" value="id" /> <el-option label="创建时间" value="created_at" />
</el-select> <el-option label="更新时间" value="updated_at" />
<el-select v-model="sortOrder" placeholder="排序顺序" clearable style="width: 100px" @change="handleSortChange"> <el-option label="工单号" value="id" />
<el-option label="默认" value="" /> </el-select>
<el-option label="降序" value="desc" /> <el-select v-model="sortOrder" placeholder="排序顺序" clearable style="width: 100px" @change="handleSortChange">
<el-option label="升序" value="asc" /> <el-option label="默认" value="" />
</el-select> <el-option label="降序" value="desc" />
<!-- 用户筛选输入框 --> <el-option label="升序" value="asc" />
<el-input </el-select>
:model-value="selectedUser ? selectedUser.user_name : ''" <el-input
placeholder="点击选择用户筛选" :model-value="selectedUser ? selectedUser.user_name : ''"
readonly placeholder="点击选择用户筛选"
style="width: 180px; cursor: pointer" readonly
@click="showUserDialog = true" style="width: 180px; cursor: pointer"
> @click="showUserDialog = true"
<template #prefix> >
<el-icon><User /></el-icon> <template #prefix>
</template> <el-icon><User /></el-icon>
<template #suffix v-if="selectedUser"> </template>
<el-icon @click.stop="clearUserFilter" style="cursor: pointer"><Close /></el-icon> <template #suffix v-if="selectedUser">
</template> <el-icon @click.stop="clearUserFilter" style="cursor: pointer"><Close /></el-icon>
</el-input> </template>
<!-- 关键词搜索 --> </el-input>
<el-input <el-input
v-model="searchKeyword" v-model="searchKeyword"
placeholder="搜索工单标题/内容" placeholder="搜索工单标题/内容"
clearable clearable
style="width: 200px" style="width: 200px"
@input="handleKeywordSearch" @input="handleKeywordSearch"
@clear="handleKeywordSearch" @clear="handleKeywordSearch"
> >
<template #prefix> <template #prefix>
<el-icon><Search /></el-icon> <el-icon><Search /></el-icon>
</template> </template>
</el-input> </el-input>
<el-button icon="Refresh" @click="refreshList">刷新</el-button> <el-button icon="Refresh" @click="refreshList">刷新</el-button>
</div>
</div> </div>
<!-- 工单表格PC端 --> <!-- 工单表格PC端 -->
@@ -559,36 +558,36 @@ onBeforeUnmount(() => {
background: #fff; background: #fff;
} }
.toolbar { .status-bar {
display: flex; display: flex;
justify-content: space-between;
align-items: center; align-items: center;
padding: 0 20px; justify-content: flex-start;
height: 50px; padding: 14px 20px 0;
border-bottom: 1px solid #ebeef5;
} }
.status-tabs { .status-tabs {
display: flex; display: flex;
gap: 8px; gap: 6px;
} }
.tab-item { .tab-item {
padding: 6px 12px; padding: 6px 16px;
border-radius: 4px; border-radius: 20px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
color: #606266; color: #606266;
transition: all 0.2s; transition: all 0.2s;
user-select: none;
} }
.tab-item:hover { .tab-item:hover {
background: #f5f7fa; background: #f0f2f5;
} }
.tab-item.active { .tab-item.active {
background: #409eff; background: #409eff;
color: #fff; color: #fff;
font-weight: 500;
} }
.tab-item.pending.active { background: #e6a23c; } .tab-item.pending.active { background: #e6a23c; }
@@ -601,10 +600,13 @@ onBeforeUnmount(() => {
font-weight: 500; font-weight: 500;
} }
.toolbar-right { .filter-bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
flex-wrap: wrap;
padding: 12px 20px;
border-bottom: 1px solid #ebeef5;
} }
.user-dialog-content { .user-dialog-content {
@@ -682,8 +684,6 @@ onBeforeUnmount(() => {
} }
.pagination-wrapper { .pagination-wrapper {
display: flex;
justify-content: flex-end;
padding: 12px 20px; padding: 12px 20px;
border-top: 1px solid #ebeef5; border-top: 1px solid #ebeef5;
} }
@@ -771,25 +771,19 @@ onBeforeUnmount(() => {
/* 大屏平板尺寸响应式样式 (1020px - 1280px) */ /* 大屏平板尺寸响应式样式 (1020px - 1280px) */
@media (max-width: 1280px) and (min-width: 1021px) { @media (max-width: 1280px) and (min-width: 1021px) {
.toolbar { .filter-bar {
padding: 12px 16px; padding: 10px 16px;
gap: 12px;
}
.toolbar-right {
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.toolbar-right .el-select { .filter-bar .el-select {
width: 120px !important; width: 120px !important;
} }
.toolbar-right .el-input { .filter-bar .el-input {
min-width: 160px; min-width: 160px;
} }
/* 表格列宽调整 */
:deep(.el-table) { :deep(.el-table) {
font-size: 13px; font-size: 13px;
} }
@@ -801,12 +795,8 @@ onBeforeUnmount(() => {
/* 平板尺寸响应式样式 (769px - 1020px) */ /* 平板尺寸响应式样式 (769px - 1020px) */
@media (max-width: 1020px) and (min-width: 769px) { @media (max-width: 1020px) and (min-width: 769px) {
.toolbar { .status-bar {
flex-direction: column; padding: 10px 16px 0;
height: auto;
padding: 12px 16px;
gap: 12px;
align-items: stretch;
} }
.status-tabs { .status-tabs {
@@ -825,22 +815,20 @@ onBeforeUnmount(() => {
border-radius: 2px; border-radius: 2px;
} }
.toolbar-right { .filter-bar {
width: 100%; padding: 10px 16px;
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.toolbar-right .el-select { .filter-bar .el-select {
width: 120px !important; width: 120px !important;
} }
.toolbar-right .el-input { .filter-bar .el-input {
flex: 1; flex: 1;
min-width: 150px; min-width: 150px;
} }
/* 表格列宽调整 */
:deep(.el-table) { :deep(.el-table) {
font-size: 13px; font-size: 13px;
} }
@@ -857,11 +845,8 @@ onBeforeUnmount(() => {
min-height: calc(100vh - 60px); min-height: calc(100vh - 60px);
} }
.toolbar { .status-bar {
flex-direction: column; padding: 10px 12px 0;
height: auto;
padding: 12px;
gap: 12px;
} }
.status-tabs { .status-tabs {
@@ -881,19 +866,18 @@ onBeforeUnmount(() => {
font-size: 13px; font-size: 13px;
} }
.toolbar-right { .filter-bar {
width: 100%; padding: 10px 12px;
flex-wrap: wrap;
gap: 8px; gap: 8px;
} }
.toolbar-right .el-select, .filter-bar .el-select,
.toolbar-right .el-input { .filter-bar .el-input {
flex: 1; flex: 1;
min-width: 120px; min-width: 120px;
} }
.toolbar-right .el-button { .filter-bar .el-button {
flex-shrink: 0; flex-shrink: 0;
} }
+533 -61
View File
@@ -19,6 +19,7 @@
<div class="name-row"> <div class="name-row">
<h2 class="vm-name">{{ vm?.name || userGoods.good?.name || `用户虚拟机 #${userGoodsId}` }}</h2> <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?.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> <el-tag v-if="userGoods.tag" size="small" type="info" style="margin-left:4px">{{ userGoods.tag }}</el-tag>
</div> </div>
<div class="meta-row"> <div class="meta-row">
@@ -43,8 +44,8 @@
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="suspend">暂停</el-dropdown-item> <el-dropdown-item command="suspend">暂停</el-dropdown-item>
<el-dropdown-item command="resume">恢复</el-dropdown-item> <el-dropdown-item command="resume">恢复</el-dropdown-item>
<el-dropdown-item command="rescue">救援模式</el-dropdown-item> <el-dropdown-item command="rescue" :disabled="!!vm?.rescue">救援模式</el-dropdown-item>
<el-dropdown-item command="exitRescue">退出救援</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 divided command="rebuild">重装系统</el-dropdown-item>
<el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item> <el-dropdown-item command="updateVm">编辑虚拟机</el-dropdown-item>
<el-dropdown-item command="refactorVm">重构虚拟机</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 class="config-cell"><span class="config-label">上行带宽</span><span class="config-value">{{ vm.tx_bandwidth || 0 }} Mbps</span></div>
</div> </div>
<div class="config-row"> <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" style="font-weight:500">{{ isWindows ? 'Administrator' : 'root' }}</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">{{ 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">{{ vm.ips || '-' }}</span></div> <div class="config-cell">
<div class="config-cell"><span class="config-label">续费价格</span><span class="config-value">¥{{ (userGoods.renewPrice / 100).toFixed(2) }}</span></div> <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>
<div class="config-row"> <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 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.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"><span class="config-label">备注</span><span class="config-value">{{ userGoods.note || '-' }}</span></div>
<div class="config-cell"> <div class="config-cell">
@@ -84,8 +129,6 @@
<span v-else style="color:#c0c4cc">未绑定</span> <span v-else style="color:#c0c4cc">未绑定</span>
</span> </span>
</div> </div>
</div>
<div class="config-row">
<div class="config-cell"> <div class="config-cell">
<span class="config-label">出站安全组</span> <span class="config-label">出站安全组</span>
<span class="config-value"> <span class="config-value">
@@ -93,7 +136,7 @@
<span v-else style="color:#c0c4cc">未绑定</span> <span v-else style="color:#c0c4cc">未绑定</span>
</span> </span>
</div> </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-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> <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> </div>
@@ -229,6 +272,7 @@
<!-- 网络 --> <!-- 网络 -->
<el-tab-pane v-if="isVmGoods" label="网络" name="network"> <el-tab-pane v-if="isVmGoods" label="网络" name="network">
<div class="tab-toolbar"> <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> <el-button size="small" :icon="Refresh" @click="loadDetail">刷新</el-button>
</div> </div>
<el-table :data="vmNetworks" stripe size="small"> <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() }" /> @size-change="s => { networkingPageSize = s; networkingPage = 1; loadNetworkings() }" @current-change="p => { networkingPage = p; loadNetworkings() }" />
</div> </div>
</el-tab-pane> </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">&nbsp;</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-tabs>
</el-card> </el-card>
</div> </div>
@@ -302,7 +419,7 @@
</div> </div>
<template #footer> <template #footer>
<el-button @click="vncVisible = false">关闭</el-button> <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> </template>
</el-dialog> </el-dialog>
@@ -328,7 +445,12 @@
<el-dialog v-model="volCreateVisible" title="创建数据卷" width="440px" destroy-on-close> <el-dialog v-model="volCreateVisible" title="创建数据卷" width="440px" destroy-on-close>
<el-form :model="volCreateForm" label-width="100px"> <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="名称" 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-item label="目标设备名"><el-input v-model="volCreateForm.target_device" placeholder="不填自动生成" /></el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -341,7 +463,12 @@
<el-dialog v-model="volResizeVisible" title="扩容数据卷" width="400px" destroy-on-close> <el-dialog v-model="volResizeVisible" title="扩容数据卷" width="400px" destroy-on-close>
<el-form label-width="100px"> <el-form label-width="100px">
<el-form-item label="当前大小">{{ volResizeTarget?.size || 0 }} GB</el-form-item> <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> </el-form>
<template #footer> <template #footer>
<el-button @click="volResizeVisible = false">取消</el-button> <el-button @click="volResizeVisible = false">取消</el-button>
@@ -394,8 +521,8 @@
<!-- 创建组网 --> <!-- 创建组网 -->
<el-dialog v-model="networkingCreateVisible" title="创建组网" width="440px" destroy-on-close> <el-dialog v-model="networkingCreateVisible" title="创建组网" width="440px" destroy-on-close>
<el-form :model="networkingCreateForm" label-width="90px"> <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="名称"><el-input v-model="networkingCreateForm.name" placeholder="不填则随机生成" /></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.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.gateway" placeholder="可选" /></el-form-item>
<el-form-item label="描述"><el-input v-model="networkingCreateForm.description" /></el-form-item> <el-form-item label="描述"><el-input v-model="networkingCreateForm.description" /></el-form-item>
</el-form> </el-form>
@@ -462,10 +589,31 @@
<!-- 修改带宽 --> <!-- 修改带宽 -->
<el-dialog v-model="trafficVisible" title="修改带宽" width="440px" destroy-on-close> <el-dialog v-model="trafficVisible" title="修改带宽" width="440px" destroy-on-close>
<el-form :model="trafficForm" label-width="130px"> <el-form :model="trafficForm" label-width="100px">
<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="下行带宽">
<el-form-item label="上行带宽(Mbps)"><el-input-number v-model="trafficForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" /></el-form-item> <div class="unit-input-row">
<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-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> </el-form>
<template #footer> <template #footer>
<el-button @click="trafficVisible = false">取消</el-button> <el-button @click="trafficVisible = false">取消</el-button>
@@ -602,17 +750,27 @@
@confirm="vol => handleMountVolume(vol)" /> @confirm="vol => handleMountVolume(vol)" />
<!-- 编辑虚拟机弹窗对接 /user_vm/update --> <!-- 编辑虚拟机弹窗对接 /user_vm/update -->
<el-dialog v-model="editVmVisible" title="编辑虚拟机配置" width="560px" destroy-on-close class="scrollable-dialog"> <el-dialog v-model="editVmVisible" title="编辑虚拟机配置" width="680px" destroy-on-close class="scrollable-dialog">
<el-form :model="editVmForm" label-width="130px" v-loading="editVmLoading"> <el-form :model="editVmForm" label-width="90px" v-loading="editVmLoading">
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="下行带宽(Mbps)"> <el-form-item label="下行带宽">
<el-input-number v-model="editVmForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" /> <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-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="上行带宽(Mbps)"> <el-form-item label="上行带宽">
<el-input-number v-model="editVmForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" /> <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-form-item>
</el-col> </el-col>
</el-row> </el-row>
@@ -666,30 +824,48 @@
@confirm="net => { editVmForm.internet_network_id = net.id; editVmForm._networkName = net.name }" /> @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-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-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="内存(MB)"> <el-form-item label="内存">
<el-input-number v-model="refactorForm._memoryMB" :min="0" controls-position="right" style="width:100%" /> <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-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="vCPU"> <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-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="16"> <el-row :gutter="16">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="下行带宽(Mbps)"> <el-form-item label="下行带宽">
<el-input-number v-model="refactorForm.rx_bandwidth" :min="0" controls-position="right" style="width:100%" /> <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-form-item>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-form-item label="上行带宽(Mbps)"> <el-form-item label="上行带宽">
<el-input-number v-model="refactorForm.tx_bandwidth" :min="0" controls-position="right" style="width:100%" /> <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-form-item>
</el-col> </el-col>
</el-row> </el-row>
@@ -732,14 +908,24 @@
<UserVmNetworkSelector v-model="showRefactorNetSelector" :user-goods-id="userGoodsId" <UserVmNetworkSelector v-model="showRefactorNetSelector" :user-goods-id="userGoodsId"
@confirm="net => { refactorForm.internet_network_id = net.id; refactorForm._networkName = net.name }" /> @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-dialog v-model="editGoodsVisible" title="编辑商品信息" width="480px" destroy-on-close>
<el-form :model="editGoodsForm" label-width="110px"> <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 v-model="editGoodsForm.note" /></el-form-item>
<el-form-item label="续费价格(元)"> <el-form-item label="续费价格">
<el-input-number v-model="editGoodsForm.renew_price" :min="0" :precision="2" controls-position="right" style="width:100%" /> <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>
<el-form-item label="基础价格(元)"> <el-form-item label="基础价格">
<el-input-number v-model="editGoodsForm.base_price" :min="0" :precision="2" controls-position="right" style="width:100%" /> <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>
<el-form-item label="到期时间"> <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%" /> <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> </template>
<script setup> <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 { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { import {
getUserVmDetail, getUserVmVnc, getUserVmHostImages, getUserVmDetail, getUserVmVnc, getUserVmHostImages,
startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm, startUserVm, stopUserVm, rebootUserVm, suspendUserVm, resumeUserVm, rescueUserVm, exitRescueUserVm, rebuildUserVm, deleteUserVm,
@@ -788,7 +974,8 @@ import {
createUserVmPostGroupRule, updateUserVmPostGroupRule, deleteUserVmPostGroupRule, createUserVmPostGroupRule, updateUserVmPostGroupRule, deleteUserVmPostGroupRule,
getUserVmPostGroupDetail, getUserVmPostGroupDetail,
getUserVmNetworkList, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking, getUserVmNetworkList, getUserVmNetworkingList, createUserVmNetworking, assignUserVmNetworking, removeUserVmNetworkingNetwork, deleteUserVmNetworking,
getUserGoodsDetail getUserGoodsDetail,
getUserVmMetricsHistory
} from '@/api/admin/userVm' } from '@/api/admin/userVm'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import { vmStatusLabel as vmStatusLabelUtil, vmStatusType as vmStatusTypeUtil, volumeStatusLabel, volumeStatusType } from '@/utils/tool' 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 UserVmVolumeSelector from '@/components/admin/UserVmVolumeSelector.vue'
import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue' import UserVmNetworkSelector from '@/components/admin/UserVmNetworkSelector.vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import * as echarts from 'echarts'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userGoodsId = computed(() => parseInt(route.query.id) || 0) const userGoodsId = ref(parseInt(route.query.id) || 0)
const loading = ref(false) const loading = ref(false)
const actionLoading = ref(false) const actionLoading = ref(false)
@@ -814,7 +1002,39 @@ const vmVolumes = ref([])
const vmImage = ref(null) const vmImage = ref(null)
const inPortGroup = ref(null) const inPortGroup = ref(null)
const outPortGroup = 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(() => { const inPortGroupList = computed(() => {
@@ -906,6 +1126,7 @@ const handleTabChange = (tab) => {
if (tab === 'backup') { loadBackups(); loadBackupQuota() } if (tab === 'backup') { loadBackups(); loadBackupQuota() }
if (tab === 'security') loadSgLockInfo() if (tab === 'security') loadSgLockInfo()
if (tab === 'networking') loadNetworkings() if (tab === 'networking') loadNetworkings()
if (tab === 'monitor' && !metricsData.value) loadMetricsHistory()
} }
// lock 使 // lock 使
@@ -935,6 +1156,7 @@ const handleVnc = async () => {
else ElMessage.error(extractApiError(res?.data, '获取VNC失败')) else ElMessage.error(extractApiError(res?.data, '获取VNC失败'))
} catch { /* */ } finally { vncLoading.value = false } } catch { /* */ } finally { vncLoading.value = false }
} }
const openVncUrl = () => { if (vncResult.value?.url) window.open(vncResult.value.url, '_blank') }
// ---- ---- // ---- ----
const powerVisible = ref(false) const powerVisible = ref(false)
@@ -955,7 +1177,7 @@ const submitPower = async () => {
const handleMoreCmd = (cmd) => { const handleMoreCmd = (cmd) => {
if (powerLabels[cmd]) { handlePower(cmd); return } if (powerLabels[cmd]) { handlePower(cmd); return }
if (cmd === 'rebuild') { rebuildImageId.value = 0; rebuildImageName.value = ''; rebuildImages.value = []; rebuildVisible.value = true; loadRebuildImages() } 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 === 'transfer') { Object.assign(transferForm, { target_user_id: 0, _userName: '' }); transferVisible.value = true }
if (cmd === 'updateVm') openEditVm() if (cmd === 'updateVm') openEditVm()
if (cmd === 'refactorVm') openRefactorVm() if (cmd === 'refactorVm') openRefactorVm()
@@ -1282,6 +1504,45 @@ const loadNetworks = async () => {
} catch { /* */ } finally { networkLoading.value = false } } 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 networkings = ref([])
const networkingLoading = ref(false) 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 handleCreateNetworking = () => { Object.assign(networkingCreateForm, { name: '', bridge_name: '', gateway: '', description: '' }); networkingCreateVisible.value = true }
const submitCreateNetworking = async () => { const submitCreateNetworking = async () => {
if (!networkingCreateForm.name || !networkingCreateForm.bridge_name) { ElMessage.warning('请填写名称和网桥名称'); return }
actionLoading.value = true actionLoading.value = true
try { try {
const res = await createUserVmNetworking({ user_goods_id: userGoodsId.value, ...networkingCreateForm }) const res = await createUserVmNetworking({ user_goods_id: userGoodsId.value, ...networkingCreateForm })
@@ -1463,7 +1724,8 @@ const refactorVisible = ref(false)
const showRefactorSgSelector = ref(false) const showRefactorSgSelector = ref(false)
const showRefactorNetSelector = ref(false) const showRefactorNetSelector = ref(false)
const refactorForm = reactive({ 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: '', root_password: '', uuid: '', mate_data_id: '', physical_name: '', config_path: '',
ssh_port: 0, vnc_port: 0, vnc_password: '', ssh_port: 0, vnc_port: 0, vnc_password: '',
port_group_id: 0, _sgName: '', internet_network_id: 0, _networkName: '' port_group_id: 0, _sgName: '', internet_network_id: 0, _networkName: ''
@@ -1471,10 +1733,12 @@ const refactorForm = reactive({
const openRefactorVm = () => { const openRefactorVm = () => {
Object.assign(refactorForm, { 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, vcpu: vm.value?.vcpu || 0,
rx_bandwidth: vm.value?.rx_bandwidth || 0, rx_bandwidth: vm.value?.rx_bandwidth || 0,
tx_bandwidth: vm.value?.tx_bandwidth || 0, tx_bandwidth: vm.value?.tx_bandwidth || 0,
_rxUnit: 'Mbps', _txUnit: 'Mbps',
root_password: '', uuid: vm.value?.uuid || '', root_password: '', uuid: vm.value?.uuid || '',
mate_data_id: vm.value?.mate_data_id || '', mate_data_id: vm.value?.mate_data_id || '',
physical_name: '', config_path: '', physical_name: '', config_path: '',
@@ -1491,10 +1755,15 @@ const submitRefactorVm = async () => {
actionLoading.value = true actionLoading.value = true
try { try {
const payload = { user_goods_id: userGoodsId.value } 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.vcpu) payload.vcpu = refactorForm.vcpu
if (refactorForm.rx_bandwidth) payload.rx_bandwidth = refactorForm.rx_bandwidth const refRx = refactorForm._rxUnit === 'Gbps' ? refactorForm.rx_bandwidth * 1000 : refactorForm.rx_bandwidth
if (refactorForm.tx_bandwidth) payload.tx_bandwidth = refactorForm.tx_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.root_password) payload.root_password = refactorForm.root_password
if (refactorForm.uuid) payload.uuid = refactorForm.uuid if (refactorForm.uuid) payload.uuid = refactorForm.uuid
if (refactorForm.mate_data_id) payload.mate_data_id = refactorForm.mate_data_id 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 showEditVmNetSelector = ref(false)
const editVmForm = reactive({ const editVmForm = reactive({
rx_bandwidth: 0, tx_bandwidth: 0, rx_bandwidth: 0, tx_bandwidth: 0,
_rxUnit: 'Mbps', _txUnit: 'Mbps',
root_password: '', root_password: '',
ssh_port: 22, ssh_port: 22,
port_group_id: 0, _sgName: '', port_group_id: 0, _sgName: '',
@@ -1529,6 +1799,7 @@ const openEditVm = async () => {
Object.assign(editVmForm, { Object.assign(editVmForm, {
rx_bandwidth: vm.value?.rx_bandwidth || 0, rx_bandwidth: vm.value?.rx_bandwidth || 0,
tx_bandwidth: vm.value?.tx_bandwidth || 0, tx_bandwidth: vm.value?.tx_bandwidth || 0,
_rxUnit: 'Mbps', _txUnit: 'Mbps',
root_password: '', root_password: '',
ssh_port: vm.value?.ssh_port || 22, ssh_port: vm.value?.ssh_port || 22,
port_group_id: inPortGroup.value?.id || 0, port_group_id: inPortGroup.value?.id || 0,
@@ -1547,8 +1818,10 @@ const submitEditVm = async () => {
actionLoading.value = true actionLoading.value = true
try { try {
const payload = { user_goods_id: userGoodsId.value } const payload = { user_goods_id: userGoodsId.value }
if (editVmForm.rx_bandwidth) payload.rx_bandwidth = editVmForm.rx_bandwidth const editRx = editVmForm._rxUnit === 'Gbps' ? editVmForm.rx_bandwidth * 1000 : editVmForm.rx_bandwidth
if (editVmForm.tx_bandwidth) payload.tx_bandwidth = editVmForm.tx_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.root_password) payload.root_password = editVmForm.root_password
if (editVmForm.ssh_port) payload.ssh_port = editVmForm.ssh_port if (editVmForm.ssh_port) payload.ssh_port = editVmForm.ssh_port
if (editVmForm.port_group_id) payload.port_group_id = editVmForm.port_group_id 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 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 () => { const submitUpdateTraffic = async () => {
actionLoading.value = true actionLoading.value = true
try { 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 = { const payload = {
user_goods_id: userGoodsId.value, user_goods_id: userGoodsId.value,
rx_bandwidth: trafficForm.rx_bandwidth, rx_bandwidth: Math.round(rxBw),
tx_bandwidth: trafficForm.tx_bandwidth, tx_bandwidth: Math.round(txBw),
traffic_max: Math.round((trafficForm._trafficGB || 0) * 1024) // GB Mb traffic_max: Math.round(trafficMb)
} }
const res = await updateUserVmTraffic(payload) const res = await updateUserVmTraffic(payload)
if (res?.data?.code === 200) { ElMessage.success('修改成功'); trafficVisible.value = false; loadDetail() } 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 } } 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) => { const formatBytesRaw = (val) => {
if (newId && newId !== oldId) { 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 userGoods.value = null
vm.value = null vm.value = null
vmNetworks.value = [] vmNetworks.value = []
@@ -1640,9 +2085,13 @@ watch(userGoodsId, (newId, oldId) => {
inPortGroup.value = null inPortGroup.value = null
outPortGroup.value = null outPortGroup.value = null
isVmGoods.value = false isVmGoods.value = false
metricsData.value = null
disposeCharts()
loadDetail() loadDetail()
} }
}) })
onBeforeUnmount(() => { disposeCharts() })
</script> </script>
<style scoped> <style scoped>
@@ -1661,9 +2110,7 @@ watch(userGoodsId, (newId, oldId) => {
.overview-actions { display: flex; gap: 8px; flex-wrap: wrap; } .overview-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.tabs-card { } .tabs-card { }
.tab-toolbar { display: flex; gap: 8px; align-items: center; margin: 12px 0; } .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%; } .selector-row { display: flex; align-items: center; width: 100%; }
.mono-text { font-family: 'Cascadia Code', Consolas, monospace; font-size: 12px; }
/* VM 配置网格 */ /* VM 配置网格 */
.vm-config-grid { border: 1px solid #e8e8e8; border-radius: 4px; overflow: hidden; } .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-cell:last-child { border-right: none; }
.config-label { font-size: 12px; color: #86909c; line-height: 1; } .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; } .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> </style>
File diff suppressed because it is too large Load Diff
+15 -54
View File
@@ -17,12 +17,15 @@
</div> </div>
<div class="search-group"> <div class="search-group">
<span class="search-label">用户ID</span> <span class="search-label">用户ID</span>
<el-input <el-input
v-model="jumpUserId" :model-value="jumpUserName || (jumpUserId ? jumpUserId : '')"
placeholder="输入ID跳转" placeholder="输入ID跳转"
clearable readonly
clearable
class="search-input-small" class="search-input-small"
@keyup.enter="handleJumpToUser" style="cursor:pointer"
@click="showJumpUserSelector = true"
@clear="jumpUserId = ''; jumpUserName = ''"
/> />
</div> </div>
<div class="search-buttons"> <div class="search-buttons">
@@ -331,6 +334,11 @@
:current-user-id="userForm.recommend_id" :current-user-id="userForm.recommend_id"
@confirm="handleRecommendUserConfirm" @confirm="handleRecommendUserConfirm"
/> />
<!-- 筛选用户ID选择器 -->
<UserListSelector
v-model="showJumpUserSelector"
@confirm="u => { jumpUserId = String(u.user_id); jumpUserName = u.user_name || `用户 #${u.user_id}` }"
/>
<!-- 修改头像对话框 --> <!-- 修改头像对话框 -->
<el-dialog <el-dialog
@@ -655,6 +663,8 @@ const router = useRouter()
// ID // ID
const jumpUserId = ref('') const jumpUserId = ref('')
const jumpUserName = ref('')
const showJumpUserSelector = ref(false)
// //
const queryParams = reactive({ const queryParams = reactive({
@@ -1946,55 +1956,6 @@ onMounted(() => {
color: #606266; 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) { :deep(.el-card__body) {
padding: 0; padding: 0;
} }
+2 -3
View File
@@ -125,7 +125,7 @@ const vmOptionsLoading = ref(false)
const loadVmOptions = async () => { const loadVmOptions = async () => {
vmOptionsLoading.value = true vmOptionsLoading.value = true
try { 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) { if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data const inner = res.data.data
vmOptions.value = inner.vms || inner.data || inner.list || (Array.isArray(inner) ? inner : []) vmOptions.value = inner.vms || inner.data || inner.list || (Array.isArray(inner) ? inner : [])
@@ -230,6 +230,5 @@ defineExpose({ loadList })
<style scoped> <style scoped>
.backup-manage { padding: 0; } .backup-manage { padding: 0; }
.toolbar { display: flex; gap: 8px; margin-top: 12px; margin-bottom: 16px; } .toolbar { margin-top: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
</style> </style>
+477 -196
View File
@@ -17,6 +17,7 @@
<h2 class="instance-name">{{ detail.name || '-' }} <span class="instance-id">{{ detail.id }}</span></h2> <h2 class="instance-name">{{ detail.name || '-' }} <span class="instance-id">{{ detail.id }}</span></h2>
</div> </div>
<div class="overview-actions"> <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="primary" plain @click="handleEdit">编辑宿主机</el-button>
<el-button type="danger" plain @click="handleDelete">删除</el-button> <el-button type="danger" plain @click="handleDelete">删除</el-button>
</div> </div>
@@ -135,51 +136,69 @@
<el-tab-pane label="监控" name="monitor"> <el-tab-pane label="监控" name="monitor">
<div class="section-block"> <div class="section-block">
<div class="section-header"> <div class="section-header">
<h3 class="section-title">实时指标</h3> <h3 class="section-title">监控指标</h3>
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<el-tag v-if="pollingActive" type="success" size="small" effect="plain">自动刷新中</el-tag> <el-select v-model="historyTimeRange" size="small" style="width: 120px" @change="loadHistoricalMetrics">
<el-button size="small" :icon="Refresh" @click="loadMetrics" :loading="metricsLoading">刷新指标</el-button> <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>
</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-row :gutter="16">
<el-col :span="12" v-if="metricsData.cpu"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <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> <div ref="cpuChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :span="12" v-if="metricsData.memory"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <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> <div ref="memChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<el-row :gutter="16" style="margin-top: 16px"> <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"> <el-card shadow="hover" class="metrics-card">
<template #header><span class="metrics-title"><el-icon><Box /></el-icon> 磁盘</span></template> <template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 公网流量</span></template>
<div v-for="(info, path) in metricsData.disk" :key="path" class="disk-item"> <div ref="inetChartRef" class="chart-container"></div>
<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>
</el-col> </el-col>
<el-col :span="12" v-if="metricsData.network || metricsData.internet_speed"> <el-col :span="12">
<el-card shadow="hover" class="metrics-card"> <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> <div ref="netChartRef" class="chart-container"></div>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
</template> </template>
<el-empty v-else description="加载指标数据中..." /> <el-empty v-else-if="!historicalMetricsLoading" description="加载监控数据中..." :image-size="80" />
</div> </div>
</el-tab-pane> </el-tab-pane>
@@ -287,45 +306,55 @@
</el-dialog> </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 ref="nwCreateFormRef" :model="nwCreateForm" :rules="nwCreateRules" label-width="100px">
<el-form-item label="用户" prop="user_id"> <div class="tk-section">
<div style="display: flex; gap: 8px; width: 100%"> <div class="tk-section-title">组网信息</div>
<el-input :model-value="nwCreateForm.user_id ? `${nwCreateUserName} (ID: ${nwCreateForm.user_id})` : '未选择'" disabled style="flex: 1" /> <el-form-item label="用户" prop="user_id">
<el-button type="primary" @click="showNwUserSelector = true">选择</el-button> <div style="display: flex; gap: 8px; width: 100%">
<el-button v-if="nwCreateForm.user_id" @click="nwCreateForm.user_id = 0; nwCreateUserName = ''">清除</el-button> <el-input :model-value="nwCreateForm.user_id ? `${nwCreateUserName} (ID: ${nwCreateForm.user_id})` : '未选择'" disabled style="flex: 1" />
</div> <el-button type="primary" @click="showNwUserSelector = true">选择</el-button>
</el-form-item> <el-button v-if="nwCreateForm.user_id" @click="nwCreateForm.user_id = 0; nwCreateUserName = ''">清除</el-button>
<el-form-item label="网桥名称"> </div>
<el-input v-model="nwCreateForm.bridge_name" placeholder="可选" /> </el-form-item>
</el-form-item> <el-form-item label="网桥名称">
<el-form-item label="网关"> <el-input v-model="nwCreateForm.bridge_name" placeholder="可选" />
<el-input v-model="nwCreateForm.gateway" placeholder="可选,如 10.0.0.1" /> </el-form-item>
</el-form-item> <el-form-item label="网关">
<el-input v-model="nwCreateForm.gateway" placeholder="可选,如 10.0.0.1" />
</el-form-item>
</div>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="nwCreateVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwCreate">创建</el-button> <el-button @click="nwCreateVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwCreate">创建</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
<!-- 分配IP弹窗 --> <!-- 分配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 label-width="100px">
<el-form-item label="组网">{{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }})</el-form-item> <div class="tk-section">
<el-form-item label="虚拟机" required> <div class="tk-section-title">分配信息</div>
<div style="display: flex; gap: 8px; width: 100%"> <el-form-item label="组网">{{ nwAssignTarget?.name || '-' }} (ID: {{ nwAssignTarget?.id }})</el-form-item>
<el-input :model-value="nwAssignVmId ? `${nwAssignVmName} (ID: ${nwAssignVmId})` : '未选择'" disabled style="flex: 1" /> <el-form-item label="虚拟机" required>
<el-button type="primary" @click="showNwVmSelector = true">选择</el-button> <div style="display: flex; gap: 8px; width: 100%">
</div> <el-input :model-value="nwAssignVmId ? `${nwAssignVmName} (ID: ${nwAssignVmId})` : '未选择'" disabled style="flex: 1" />
</el-form-item> <el-button type="primary" @click="showNwVmSelector = true">选择</el-button>
<el-form-item label="指定IP"> </div>
<el-input v-model="nwAssignIp" placeholder="留空自动分配" /> </el-form-item>
</el-form-item> <el-form-item label="指定IP">
<el-input v-model="nwAssignIp" placeholder="留空自动分配" />
</el-form-item>
</div>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="nwAssignVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwAssign" :disabled="!nwAssignVmId">分配</el-button> <el-button @click="nwAssignVisible = false">取消</el-button>
<el-button type="primary" :loading="nwSubmitLoading" @click="submitNwAssign" :disabled="!nwAssignVmId">分配</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
@@ -334,61 +363,163 @@
</div> </div>
<!-- 编辑弹窗 --> <!-- 编辑弹窗 -->
<el-dialog v-model="editDialogVisible" title="编辑宿主机" width="890px" destroy-on-close> <el-dialog v-model="editDialogVisible" title="编辑宿主机" width="890px" destroy-on-close class="tk-dialog">
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="120px"> <el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item> <div class="tk-section">
<el-form-item label="服务地址" prop="base_url"><el-input v-model="formData.base_url" /></el-form-item> <div class="tk-section-title">基本信息</div>
<el-form-item label="IP 地址" prop="ip"><el-input v-model="formData.ip" /></el-form-item> <el-form-item label="名称" prop="name"><el-input v-model="formData.name" /></el-form-item>
<el-form-item label="认证Token"><el-input v-model="formData.token" show-password /></el-form-item> <el-form-item label="服务地址" prop="base_url"><el-input v-model="formData.base_url" /></el-form-item>
<el-divider content-position="left">SSH 配置</el-divider> <el-form-item label="IP 地址" prop="ip"><el-input v-model="formData.ip" /></el-form-item>
<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="认证Token"><el-input v-model="formData.token" show-password /></el-form-item>
<el-form-item label="SSH 用户名"><el-input v-model="formData.user" /></el-form-item> </div>
<el-form-item label="SSH 密码"><el-input v-model="formData.password" show-password /></el-form-item> <div class="tk-section">
<el-form-item label="私钥"><el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容" /></el-form-item> <div class="tk-section-title">SSH 配置</div>
<el-divider content-position="left">资源限制</el-divider> <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="最大CPU(核)"><el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" style="width: 100%" /></el-form-item> <el-form-item label="用户名"><el-input v-model="formData.user" /></el-form-item>
<el-row :gutter="16"> <el-form-item label="密码"><el-input v-model="formData.password" show-password /></el-form-item>
<el-col :span="12"> <el-form-item label="私钥"><el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容" /></el-form-item>
<el-form-item label="最大内存"> </div>
<div class="unit-input-row"> <div class="tk-section">
<el-select v-model="memoryUnit" style="width: 70px; flex-shrink: 0;" size="default"> <div class="tk-section-title">资源限制</div>
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" /> <div class="tk-resource-grid">
</el-select> <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-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="wide-number" /> <el-form-item label="内存">
</div> <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>
</el-col> <el-form-item label="磁盘">
<el-col :span="12"> <el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
<el-form-item label="最大磁盘"> <el-select v-model="diskUnit" class="tk-unit-select">
<div class="unit-input-row"> <el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
<el-select v-model="diskUnit" style="width: 70px; flex-shrink: 0;" size="default"> </el-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="wide-number" />
</div>
</el-form-item> </el-form-item>
</el-col> <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-row> <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-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>
</div> </div>
</el-form-item> </div>
<el-form-item label="介绍"><el-input v-model="formData.description" type="textarea" :rows="3" /></el-form-item> <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> </el-form>
<template #footer> <template #footer>
<el-button @click="editDialogVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button> <el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
<HostGroupSelectorPopup v-model="showGroupSelector" :service-id="serviceId" :current-id="formData.host_group_id" @confirm="g => formData.host_group_id = g.id" /> <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> </div>
</template> </template>
@@ -396,13 +527,15 @@
import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue' import { ref, reactive, computed, onMounted, onActivated, onDeactivated, onBeforeUnmount, watch, nextTick, provide } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { import {
getRemoteHostDetail, getRemoteHostMetrics, updateRemoteHost, deleteRemoteHost, getRemoteHostDetail, updateRemoteHost, deleteRemoteHost,
getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking, getUserNetworkingList, getUserNetworkingDetail, createUserNetworking, deleteUserNetworking,
assignUserNetworking, removeUserNetworkingNetwork assignUserNetworking, removeUserNetworkingNetwork,
createHostToken, getMetricsHistory
} from '@/api/admin/kvmService' } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import { baseUrl } from '@/config/env'
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue' import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
import ImageManage from '@/views/virtualization/ImageManage.vue' import ImageManage from '@/views/virtualization/ImageManage.vue'
import NetworkManage from '@/views/virtualization/NetworkManage.vue' import NetworkManage from '@/views/virtualization/NetworkManage.vue'
@@ -442,14 +575,16 @@ watch(activeTab, (tab) => {
nextTick(() => { tabRefMap[tab]?.value?.loadList?.() }) nextTick(() => { tabRefMap[tab]?.value?.loadList?.() })
} }
} }
if (tab === 'monitor' && detail.value) { loadMetrics(); startPolling() } if (tab === 'monitor' && detail.value) {
else stopPolling() if (!historicalMetricsData.value) {
loadHistoricalMetrics()
}
}
if (tab === 'networking') loadNetworkingList() if (tab === 'networking') loadNetworkingList()
}) })
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const metricsLoading = ref(false)
const detail = ref(null) const detail = ref(null)
provide('embedded', true) provide('embedded', true)
@@ -480,7 +615,8 @@ const fallbackCopy = (text) => {
} catch { ElMessage.error('复制失败') } } catch { ElMessage.error('复制失败') }
document.body.removeChild(ta) document.body.removeChild(ta)
} }
const metricsData = ref(null) const historicalMetricsData = ref(null)
const historicalMetricsLoading = ref(false)
const editDialogVisible = ref(false) const editDialogVisible = ref(false)
const showGroupSelector = ref(false) const showGroupSelector = ref(false)
const formRef = ref(null) const formRef = ref(null)
@@ -561,65 +697,77 @@ const loadDetail = async () => {
const cpuChartRef = ref(null) const cpuChartRef = ref(null)
const memChartRef = ref(null) const memChartRef = ref(null)
const netChartRef = ref(null) const netChartRef = ref(null)
const inetChartRef = ref(null)
let cpuChart = null let cpuChart = null
let memChart = null let memChart = null
let netChart = null let netChart = null
let inetChart = null
const MAX_HISTORY = 60
const metricsHistory = reactive({
times: [],
cpu: [],
memPercent: [],
netRx: [],
netTx: []
})
const pollingActive = ref(false)
let pollTimer = null
let isPageActive = false let isPageActive = false
const loadMetrics = async () => { const latestMetrics = computed(() => {
if (!serviceId.value || !hostId.value || !isPageActive) return const arr = historicalMetricsData.value
metricsLoading.value = true 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 { 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 const body = res?.data
if (body?.code === 200 && body?.data) { if (body?.code === 200 && body?.data) {
metricsData.value = body.data.data ?? body.data historicalMetricsData.value = Array.isArray(body.data) ? body.data : (body.data.data || [])
pushHistory(metricsData.value)
await nextTick() await nextTick()
renderCharts() renderHistoricalCharts()
} else {
ElMessage.error(extractApiError(body, '加载历史指标失败'))
} }
} catch { /* silent for polling */ } finally { metricsLoading.value = false } } catch (e) {
} ElMessage.error(extractApiError(e?.response?.data, '加载历史指标失败'))
} finally {
const pushHistory = (d) => { historicalMetricsLoading.value = false
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()
} }
} }
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) => { const formatNetLabel = (v) => {
if (!v) return '0 B/s' if (!v) return '0 B/s'
@@ -629,33 +777,78 @@ const formatNetLabel = (v) => {
return v + ' B/s' return v + ' B/s'
} }
const renderCharts = () => { //
const times = [...metricsHistory.times] const renderHistoricalCharts = () => {
const cpuData = [...metricsHistory.cpu] const metrics = historicalMetricsData.value
const memData = [...metricsHistory.memPercent] if (!Array.isArray(metrics) || !metrics.length) return
const rxData = [...metricsHistory.netRx]
const txData = [...metricsHistory.netTx] 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 (cpuChartRef.value) {
if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value) if (!cpuChart) cpuChart = echarts.init(cpuChartRef.value)
cpuChart.setOption({ cpuChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} CPU: ${p[0].value.toFixed(1)}%` }, 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 }, grid: baseGrid, xAxis: makeXAxis(),
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } }, 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) }, true)
} }
if (memChartRef.value) { if (memChartRef.value) {
if (!memChart) memChart = echarts.init(memChartRef.value) if (!memChart) memChart = echarts.init(memChartRef.value)
memChart.setOption({ memChart.setOption({
tooltip: { trigger: 'axis', formatter: (p) => `${p[0].axisValue}<br/>${p[0].marker} 内存: ${p[0].value.toFixed(1)}%` }, 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 }, grid: baseGrid, xAxis: makeXAxis(),
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { fontSize: 10, formatter: v => v + '%' } }, 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) }, 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 (netChartRef.value) {
if (!netChart) netChart = echarts.init(netChartRef.value) if (!netChart) netChart = echarts.init(netChartRef.value)
netChart.setOption({ netChart.setOption({
@@ -664,33 +857,18 @@ const renderCharts = () => {
params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` }) params.forEach(p => { s += `<br/>${p.marker} ${p.seriesName}: ${formatNetLabel(p.value)}` })
return s return s
}}, }},
grid: { top: 10, right: 16, bottom: 24, left: 50 }, grid: baseGrid, xAxis: makeXAxis(),
xAxis: { type: 'category', data: times, boundaryGap: false, axisLabel: { fontSize: 10 } },
yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: formatNetLabel } }, yAxis: { type: 'value', min: 0, axisLabel: { fontSize: 10, formatter: formatNetLabel } },
series: [ series: [makeSeries('接收', netRxRate, '#409eff'), makeSeries('发送', netTxRate, '#e6a23c')]
{ 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 }
]
}, true) }, 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 = () => { const disposeCharts = () => {
cpuChart?.dispose(); cpuChart = null cpuChart?.dispose(); cpuChart = null
memChart?.dispose(); memChart = null memChart?.dispose(); memChart = null
netChart?.dispose(); netChart = null netChart?.dispose(); netChart = null
inetChart?.dispose(); inetChart = null
} }
const handleEdit = () => { const handleEdit = () => {
@@ -736,6 +914,115 @@ const handleDelete = () => {
}).catch(() => {}) }).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 = () => { const goBack = () => {
tagsViewStore.delVisitedView(route) tagsViewStore.delVisitedView(route)
router.push({ path: '/virtualization/kvm-service-detail', query: { service_id: serviceId.value, service_name: serviceName.value } }) 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 showToken.value = false
showPassword.value = false showPassword.value = false
showPrivateKey.value = false showPrivateKey.value = false
metricsData.value = null historicalMetricsData.value = null
metricsHistory.times.length = 0
metricsHistory.cpu.length = 0
metricsHistory.memPercent.length = 0
metricsHistory.netRx.length = 0
metricsHistory.netTx.length = 0
disposeCharts() disposeCharts()
loadDetail() loadDetail()
if (activeTab.value === 'monitor') loadMetrics().then(() => startPolling()) if (activeTab.value === 'monitor') loadHistoricalMetrics()
} }
watch(hostId, () => { if (isPageActive) initPage() }) watch(hostId, () => { if (isPageActive) initPage() })
onActivated(() => { onActivated(() => {
isPageActive = true isPageActive = true
if (loadedHostId !== hostId.value) initPage() if (loadedHostId !== hostId.value) initPage()
else if (activeTab.value === 'monitor') startPolling()
}) })
onMounted(() => { isPageActive = true; initPage() }) onMounted(() => { isPageActive = true; initPage() })
onDeactivated(() => { isPageActive = false; stopPolling() }) onDeactivated(() => { isPageActive = false })
onBeforeUnmount(() => { isPageActive = false; stopPolling(); disposeCharts() }) onBeforeUnmount(() => { isPageActive = false; disposeCharts() })
</script> </script>
<style scoped> <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 { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.secret-cell code { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .secret-cell code { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mono-text { font-family: 'Consolas', 'Monaco', monospace; } .mono-text { font-family: 'Consolas', 'Monaco', monospace; }
.text-muted { color: #c0c4cc; }
.metrics-card { margin-bottom: 0; } .metrics-card { margin-bottom: 0; }
.metrics-title { font-weight: 600; font-size: 13px; display: inline-flex; align-items: center; gap: 6px; } .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; } .metrics-title .el-icon { font-size: 16px; color: #409eff; }
.chart-container { width: 100%; height: 220px; } .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%; } .metric-summary-row { display: flex; gap: 16px; margin-bottom: 16px; }
.wide-number { flex: 1; min-width: 140px; } .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; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; } .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> </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> </style>
+341 -124
View File
@@ -9,6 +9,9 @@
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<el-button type="warning" @click="openTokenDialog">
<el-icon><Key /></el-icon>创建注册令牌
</el-button>
<el-button type="primary" @click="handleAdd"> <el-button type="primary" @click="handleAdd">
<el-icon><Plus /></el-icon>新增宿主机 <el-icon><Plus /></el-icon>新增宿主机
</el-button> </el-button>
@@ -18,6 +21,7 @@
</div> </div>
</div> </div>
<div class="embedded-toolbar" v-if="embedded"> <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 type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>新增宿主机</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button> <el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div> </div>
@@ -95,85 +99,85 @@
</div> </div>
<!-- 新建/编辑弹窗 --> <!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close> <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="120px"> <el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="名称" prop="name"> <div class="tk-section">
<el-input v-model="formData.name" placeholder="宿主机名称" /> <div class="tk-section-title">基本信息</div>
</el-form-item> <el-form-item label="名称" prop="name">
<el-form-item label="服务地址" prop="base_url"> <el-input v-model="formData.name" placeholder="宿主机名称" />
<el-input v-model="formData.base_url" placeholder="宿主机服务 URL" /> </el-form-item>
</el-form-item> <el-form-item label="服务地址" prop="base_url">
<el-form-item label="IP 地址" prop="ip"> <el-input v-model="formData.base_url" placeholder="宿主机服务 URL" />
<el-input v-model="formData.ip" placeholder="宿主机 IP" /> </el-form-item>
</el-form-item> <el-form-item label="IP 地址" prop="ip">
<el-form-item label="认证Token"> <el-input v-model="formData.ip" placeholder="宿主机 IP" />
<el-input v-model="formData.token" placeholder="宿主机服务 Token(可选)" show-password /> </el-form-item>
</el-form-item> <el-form-item label="认证Token">
<el-divider content-position="left">SSH 配置</el-divider> <el-input v-model="formData.token" placeholder="宿主机服务 Token(可选)" show-password />
<el-form-item label="SSH 端口"> </el-form-item>
<el-input-number v-model="formData.port" :min="0" :max="65535" placeholder="22" style="width: 100%" /> </div>
</el-form-item> <div class="tk-section">
<el-form-item label="SSH 用户名"> <div class="tk-section-title">SSH 配置</div>
<el-input v-model="formData.user" placeholder="默认 tunneluser" /> <el-form-item label="端口">
</el-form-item> <div class="tk-inline-unit">
<el-form-item label="SSH 密码"> <el-input-number v-model="formData.port" :min="0" :max="65535" placeholder="22" controls-position="right" />
<el-input v-model="formData.password" placeholder="SSH 密码(可选)" show-password /> </div>
</el-form-item> </el-form-item>
<el-form-item label="SSH 私钥"> <el-form-item label="用户名">
<el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" /> <el-input v-model="formData.user" placeholder="默认 tunneluser" />
</el-form-item> </el-form-item>
<el-divider content-position="left">资源限制</el-divider> <el-form-item label="密码">
<el-form-item label="最大CPU(核)"> <el-input v-model="formData.password" placeholder="SSH 密码(可选)" show-password />
<el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" style="width: 100%" /> </el-form-item>
</el-form-item> <el-form-item label="私钥">
<el-row :gutter="16"> <el-input v-model="formData.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
<el-col :span="12"> </el-form-item>
<el-form-item label="最大内存"> </div>
<div class="unit-input-row"> <div class="tk-section">
<el-select v-model="memoryUnit" style="width: 70px; flex-shrink: 0;" size="default"> <div class="tk-section-title">资源限制</div>
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" /> <div class="tk-resource-grid">
</el-select> <el-form-item label="CPU">
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="wide-number" /> <el-input-number v-model="formData.max_cpu" :min="0" controls-position="right" /><span class="tk-res-unit"></span>
</div>
</el-form-item> </el-form-item>
</el-col> <el-form-item label="内存">
<el-col :span="12"> <el-input-number v-model="memoryDisplay" :min="0" controls-position="right" />
<el-form-item label="最大磁盘"> <el-select v-model="memoryUnit" class="tk-unit-select">
<div class="unit-input-row"> <el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
<el-select v-model="diskUnit" style="width: 70px; flex-shrink: 0;" size="default"> </el-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="wide-number" />
</div>
</el-form-item> </el-form-item>
</el-col> <el-form-item label="磁盘">
</el-row> <el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
<el-row :gutter="16"> <el-select v-model="diskUnit" class="tk-unit-select">
<el-col :span="12"> <el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
<el-form-item label="下行带宽(Mbps)"> </el-select>
<el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item> </el-form-item>
</el-col> <el-form-item label="下行带宽">
<el-col :span="12"> <el-input-number v-model="formData.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
<el-form-item label="上行带宽(Mbps)"> </el-form-item>
<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.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
</el-form-item> </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> </div>
</el-form-item> </div>
<el-form-item label="介绍"> <div class="tk-section">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="宿主机介绍(可选)" /> <div class="tk-section-title">其他配置</div>
</el-form-item> <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> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
@@ -220,53 +224,144 @@
</template> </template>
</el-dialog> </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> <el-dialog v-model="metricsVisible" title="宿主机指标" width="700px" destroy-on-close>
<div v-loading="metricsLoading"> <div v-loading="metricsLoading">
<template v-if="metricsData"> <template v-if="metricsData">
<div class="metrics-time">数据时间{{ formatBucket(metricsData.bucket) }}</div>
<!-- CPU --> <!-- 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> <template #header><span class="metrics-title"><el-icon><Monitor /></el-icon> CPU</span></template>
<el-descriptions :column="2" border size="small"> <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_usage ?? 0).toFixed(1) }}%</el-descriptions-item>
<el-descriptions-item label="核心数">{{ metricsData.cpu.cpu_count ?? '-' }}</el-descriptions-item> <el-descriptions-item label="核心数">{{ metricsData.cpu_count ?? '-' }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </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> <template #header><span class="metrics-title"><el-icon><Coin /></el-icon> 内存</span></template>
<el-descriptions :column="2" border size="small"> <el-descriptions :column="2" border size="small">
<el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.memory.total) }}</el-descriptions-item> <el-descriptions-item label="总计">{{ formatBytesRaw(metricsData.mem_total) }}</el-descriptions-item>
<el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.memory.used) }}</el-descriptions-item> <el-descriptions-item label="已用">{{ formatBytesRaw(metricsData.mem_used) }}</el-descriptions-item>
<el-descriptions-item label="空闲">{{ formatBytesRaw(metricsData.memory.free) }}</el-descriptions-item> <el-descriptions-item label="空闲">{{ formatBytesRaw(metricsData.mem_free) }}</el-descriptions-item>
<el-descriptions-item label="使用率">{{ metricsData.memory.percent ?? '-' }}%</el-descriptions-item> <el-descriptions-item label="使用率">{{ (metricsData.mem_percent ?? 0).toFixed(1) }}%</el-descriptions-item>
</el-descriptions> </el-descriptions>
</el-card> </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> <template #header><span class="metrics-title"><el-icon><Connection /></el-icon> 网络</span></template>
<el-descriptions :column="2" border size="small"> <el-descriptions :column="2" border size="small">
<template v-if="metricsData.network"> <el-descriptions-item label="公网接收速率">{{ formatNetSpeed(metricsData.inet_rx) }}</el-descriptions-item>
<el-descriptions-item label="接收">{{ formatBytesRaw(metricsData.network.rx_bytes) }}</el-descriptions-item> <el-descriptions-item label="公网发送速率">{{ formatNetSpeed(metricsData.inet_tx) }}</el-descriptions-item>
<el-descriptions-item label="发送">{{ formatBytesRaw(metricsData.network.tx_bytes) }}</el-descriptions-item> <el-descriptions-item label="内网接收(累积)">{{ formatBytesRaw(metricsData.net_rx) }}</el-descriptions-item>
</template> <el-descriptions-item label="内网发送(累积)">{{ formatBytesRaw(metricsData.net_tx) }}</el-descriptions-item>
<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> </el-descriptions>
</el-card> </el-card>
</template> </template>
@@ -283,13 +378,14 @@
import { ref, reactive, computed, inject, onMounted } from 'vue' import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { import {
getRemoteHostList, getRemoteHostDetail, getRemoteHostMetrics, getRemoteHostList, getRemoteHostDetail,
addRemoteHost, updateRemoteHost, deleteRemoteHost, addRemoteHost, updateRemoteHost, deleteRemoteHost,
getHostGroupList getHostGroupList, createHostToken, getMetricsHistory
} from '@/api/admin/kvmService' } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import { baseUrl } from '@/config/env'
import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue' import HostGroupSelectorPopup from '@/components/admin/HostGroupSelectorPopup.vue'
const route = useRoute() const route = useRoute()
@@ -385,6 +481,21 @@ const formatBytesRaw = (val) => {
return val + ' B' 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} 时间戳 */ /** 格式化后端 {seconds, nanos} 时间戳 */
const formatTimestamp = (ts) => { const formatTimestamp = (ts) => {
if (!ts) return '-' if (!ts) return '-'
@@ -551,10 +662,20 @@ const handleMetrics = async (row) => {
metricsLoading.value = true metricsLoading.value = true
metricsData.value = null metricsData.value = null
try { 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 const body = res?.data
if (body?.code === 200 && body?.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 { } else {
ElMessage.warning('暂无指标数据') ElMessage.warning('暂无指标数据')
} }
@@ -584,6 +705,118 @@ const handleDelete = (row) => {
}).catch(() => {}) }).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') } const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(() => { onMounted(() => {
@@ -592,27 +825,11 @@ onMounted(() => {
</script> </script>
<style scoped> <style scoped>
.unit-input-row { display: flex; gap: 6px; width: 100%; }
.wide-number { flex: 1; min-width: 140px; }
.host-manage-container { padding: 20px; } .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-addr { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
.host-url { font-size: 12px; color: #909399; margin-top: 2px; } .host-url { font-size: 12px; color: #909399; margin-top: 2px; }
.resource-info { display: flex; flex-wrap: wrap; gap: 4px; } .metrics-time { font-size: 12px; color: #86909c; margin-bottom: 12px; }
.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-card { 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 { font-weight: 600; font-size: 14px; display: inline-flex; align-items: center; gap: 6px; }
.metrics-title .el-icon { font-size: 16px; color: #409eff; } .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> </style>
+293 -100
View File
@@ -13,6 +13,7 @@
<div class="toolbar"> <div class="toolbar">
<el-button type="primary" @click="handleAddGroup"><el-icon><FolderAdd /></el-icon>新建宿主机组</el-button> <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="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> <el-button @click="loadTreeData"><el-icon><Refresh /></el-icon>刷新</el-button>
</div> </div>
@@ -86,25 +87,126 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
<!-- 新建/编辑宿主机组弹窗 --> <!-- 创建注册令牌弹窗 -->
<el-dialog v-model="groupDialogVisible" :title="groupDialogType === 'add' ? '新建宿主机组' : '编辑宿主机组'" width="480px" destroy-on-close> <el-dialog v-model="tokenDialogVisible" title="创建宿主机注册令牌" width="700px" destroy-on-close class="token-dialog">
<el-form ref="groupFormRef" :model="groupForm" :rules="groupFormRules" label-width="80px"> <el-form ref="tokenFormRef" :model="tokenForm" :rules="tokenRules" label-width="120px">
<el-form-item label="名称" prop="name"> <div class="tk-section">
<el-input v-model="groupForm.name" placeholder="宿主机组名称" /> <div class="tk-section-title">基本信息</div>
</el-form-item> <el-form-item label="宿主机名称" prop="name">
<el-form-item label="备注"> <el-input v-model="tokenForm.name" placeholder="为该宿主机命名" />
<el-input v-model="groupForm.note" type="textarea" :rows="3" placeholder="备注(可选)" /> </el-form-item>
</el-form-item> <el-form-item label="所属宿主机组" prop="host_group_id">
<el-form-item label="父级组"> <el-select v-model="tokenForm.host_group_id" placeholder="请选择宿主机组" filterable style="width: 100%">
<el-select v-model="groupForm.parent_id" placeholder="选择父级" style="width: 100%" clearable @clear="groupForm.parent_id = 0"> <el-option :value="0" label="请选择" disabled />
<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-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-select> </el-form-item>
</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> </el-form>
<template #footer> <template #footer>
<el-button @click="groupDialogVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="primary" :loading="submitLoading" @click="submitGroupForm">确定</el-button> <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> </template>
</el-dialog> </el-dialog>
@@ -166,84 +268,82 @@
</el-dialog> </el-dialog>
<!-- 新建/编辑宿主机弹窗 --> <!-- 新建/编辑宿主机弹窗 -->
<el-dialog v-model="hostDialogVisible" :title="hostDialogType === 'add' ? '新增宿主机' : '编辑宿主机'" width="800px" destroy-on-close> <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="120px"> <el-form ref="hostFormRef" :model="hostForm" :rules="hostFormRules" label-width="100px">
<el-form-item label="名称" prop="name"> <div class="tk-section">
<el-input v-model="hostForm.name" placeholder="宿主机名称" /> <div class="tk-section-title">基本信息</div>
</el-form-item> <el-form-item label="名称" prop="name">
<el-form-item label="服务地址" prop="base_url"> <el-input v-model="hostForm.name" placeholder="宿主机名称" />
<el-input v-model="hostForm.base_url" placeholder="宿主机服务 URL" /> </el-form-item>
</el-form-item> <el-form-item label="服务地址" prop="base_url">
<el-form-item label="IP 地址" prop="ip"> <el-input v-model="hostForm.base_url" placeholder="宿主机服务 URL" />
<el-input v-model="hostForm.ip" placeholder="宿主机 IP" /> </el-form-item>
</el-form-item> <el-form-item label="IP 地址" prop="ip">
<el-form-item label="认证Token"> <el-input v-model="hostForm.ip" placeholder="宿主机 IP" />
<el-input v-model="hostForm.token" placeholder="可选" show-password /> </el-form-item>
</el-form-item> <el-form-item label="认证Token">
<el-divider content-position="left">SSH 配置</el-divider> <el-input v-model="hostForm.token" placeholder="可选" show-password />
<el-form-item label="SSH 端口"> </el-form-item>
<el-input-number v-model="hostForm.port" :min="0" :max="65535" style="width: 100%" /> </div>
</el-form-item> <div class="tk-section">
<el-form-item label="SSH 用户名"> <div class="tk-section-title">SSH 配置</div>
<el-input v-model="hostForm.user" placeholder="默认 tunneluser" /> <el-form-item label="端口">
</el-form-item> <el-input-number v-model="hostForm.port" :min="0" :max="65535" controls-position="right" style="width: 100%" />
<el-form-item label="SSH 密码"> </el-form-item>
<el-input v-model="hostForm.password" placeholder="可选" show-password /> <el-form-item label="用户名">
</el-form-item> <el-input v-model="hostForm.user" placeholder="默认 tunneluser" />
<el-form-item label="SSH 私钥"> </el-form-item>
<el-input v-model="hostForm.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" /> <el-form-item label="密码">
</el-form-item> <el-input v-model="hostForm.password" placeholder="可选" show-password />
<el-divider content-position="left">资源限制</el-divider> </el-form-item>
<el-form-item label="最大CPU(核)"> <el-form-item label="私钥">
<el-input-number v-model="hostForm.max_cpu" :min="0" controls-position="right" style="width: 240px" /> <el-input v-model="hostForm.private_key" type="textarea" :rows="4" placeholder="SSH 私钥内容(可选)" />
</el-form-item> </el-form-item>
<el-row :gutter="16"> </div>
<el-col :span="12"> <div class="tk-section">
<el-form-item label="最大内存"> <div class="tk-section-title">资源限制</div>
<div class="unit-input-row"> <div class="tk-resource-grid">
<el-select v-model="memoryUnit" style="width: 70px; flex-shrink: 0;" size="default"> <el-form-item label="CPU">
<el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" /> <el-input-number v-model="hostForm.max_cpu" :min="0" controls-position="right" /><span class="tk-res-unit"></span>
</el-select>
<el-input-number v-model="memoryDisplay" :min="0" controls-position="right" class="wide-number" />
</div>
</el-form-item> </el-form-item>
</el-col> <el-form-item label="内存">
<el-col :span="12"> <el-input-number v-model="memoryDisplay" :min="0" controls-position="right" />
<el-form-item label="最大磁盘"> <el-select v-model="memoryUnit" class="tk-unit-select">
<div class="unit-input-row"> <el-option v-for="u in memoryUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
<el-select v-model="diskUnit" style="width: 70px; flex-shrink: 0;" size="default"> </el-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="wide-number" />
</div>
</el-form-item> </el-form-item>
</el-col> <el-form-item label="磁盘">
</el-row> <el-input-number v-model="diskDisplay" :min="0" controls-position="right" />
<el-row :gutter="16"> <el-select v-model="diskUnit" class="tk-unit-select">
<el-col :span="12"> <el-option v-for="u in diskUnitOptions" :key="u.label" :label="u.label" :value="u.label" />
<el-form-item label="下行带宽(Mbps)"> </el-select>
<el-input-number v-model="hostForm.rx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item> </el-form-item>
</el-col> <el-form-item label="下行带宽">
<el-col :span="12"> <el-input-number v-model="hostForm.rx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
<el-form-item label="上行带宽(Mbps)">
<el-input-number v-model="hostForm.tx_bandwidth" :min="0" controls-position="right" style="width: 100%" />
</el-form-item> </el-form-item>
</el-col> <el-form-item label="上行带宽">
</el-row> <el-input-number v-model="hostForm.tx_bandwidth" :min="0" controls-position="right" /><span class="tk-res-unit">Mbps</span>
<el-form-item label="宿主机组"> </el-form-item>
<el-select v-model="hostForm.host_group_id" placeholder="选择宿主机组" clearable filterable style="width: 100%"> </div>
<el-option :value="0" label="不选择" /> </div>
<el-option v-for="g in allGroups" :key="g.id" :value="g.id" :label="`${g.name} (ID: ${g.id})`" /> <div class="tk-section">
</el-select> <div class="tk-section-title">其他配置</div>
</el-form-item> <el-form-item label="宿主机组">
<el-form-item label="介绍"> <el-select v-model="hostForm.host_group_id" placeholder="选择宿主机组" clearable filterable style="width: 100%">
<el-input v-model="hostForm.description" type="textarea" :rows="2" placeholder="可选" /> <el-option :value="0" label="不选择" />
</el-form-item> <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> </el-form>
<template #footer> <template #footer>
<el-button @click="hostDialogVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="primary" :loading="submitLoading" @click="submitHostForm">确定</el-button> <el-button @click="hostDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitHostForm">确定</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -253,15 +353,17 @@
import { ref, reactive, computed, inject, onMounted } from 'vue' import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { import {
getRemoteHostGroupList, getRemoteHostGroupTree, getRemoteHostGroupDetail, getRemoteHostGroupList, getRemoteHostGroupTree, getRemoteHostGroupDetail,
createRemoteHostGroup, updateRemoteHostGroup, deleteRemoteHostGroup, createRemoteHostGroup, updateRemoteHostGroup, deleteRemoteHostGroup,
getOptimalHostInfo, getOptimalHostInfo,
getRemoteHostList, getRemoteHostDetail, getRemoteHostList, getRemoteHostDetail,
addRemoteHost, updateRemoteHost, deleteRemoteHost addRemoteHost, updateRemoteHost, deleteRemoteHost,
createHostToken
} from '@/api/admin/kvmService' } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import { baseUrl } from '@/config/env'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -609,6 +711,106 @@ const handleDeleteHost = (row) => {
}).catch(() => {}) }).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') } const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(() => { if (serviceId.value) loadTreeData() }) onMounted(() => { if (serviceId.value) loadTreeData() })
@@ -616,11 +818,6 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
<style scoped> <style scoped>
.host-tree-container { padding: 0; } .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 { .tree-name-cell {
display: flex; display: flex;
@@ -640,10 +837,6 @@ onMounted(() => { if (serviceId.value) loadTreeData() })
.expand-placeholder { width: 20px; display: inline-block; } .expand-placeholder { width: 20px; display: inline-block; }
.row-name { font-weight: 500; color: #303133; } .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-addr { color: #409eff; font-size: 13px; }
.host-url { color: #909399; font-size: 12px; } .host-url { color: #909399; font-size: 12px; }
.resource-info { display: flex; gap: 4px; flex-wrap: wrap; }
.text-muted { color: #c0c4cc; font-size: 12px; }
</style> </style>
+68 -66
View File
@@ -59,7 +59,7 @@
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag> <el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="同步状态" width="100"> <el-table-column v-if="isEmbeddedHost" label="同步状态" width="100">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="syncStatusType(row.sync_status)" size="small">{{ syncStatusLabel(row.sync_status) }}</el-tag> <el-tag :type="syncStatusType(row.sync_status)" size="small">{{ syncStatusLabel(row.sync_status) }}</el-tag>
</template> </template>
@@ -87,42 +87,45 @@
</div> </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 ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="名称" prop="name"> <div class="tk-section">
<el-input v-model="formData.name" placeholder="镜像名称" /> <div class="tk-section-title">基本信息</div>
</el-form-item> <el-form-item label="名称" prop="name">
<el-form-item label="路径" prop="path"> <el-input v-model="formData.name" placeholder="镜像名称" />
<el-input v-model="formData.path" placeholder="URL 或服务器文件路径" /> </el-form-item>
</el-form-item> <el-form-item label="路径" prop="path">
<el-form-item label="系统类型" prop="os_type"> <el-input v-model="formData.path" placeholder="URL 或服务器文件路径" />
<el-select v-model="formData.os_type" style="width: 100%"> </el-form-item>
<el-option label="Linux" value="linux" /> <el-form-item label="系统类型" prop="os_type">
<el-option label="Windows" value="windows" /> <el-select v-model="formData.os_type" style="width: 100%">
</el-select> <el-option label="Linux" value="linux" />
</el-form-item> <el-option label="Windows" value="windows" />
<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" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="大小"> <el-form-item label="镜像类型" prop="type">
<el-input-number v-model="formData.size" :min="0" style="width: 100%" /> <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>
</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> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <el-button @click="dialogVisible = false">取消</el-button>
@@ -177,37 +180,47 @@
</el-dialog> </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 label-width="100px">
<el-form-item label="目标宿主机" required> <div class="tk-section">
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" /> <div class="tk-section-title">同步配置</div>
<el-select v-else v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%" v-loading="hostOptionsLoading"> <el-form-item label="目标宿主机" required>
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" /> <el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
</el-select> <el-select v-else v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%" v-loading="hostOptionsLoading">
</el-form-item> <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> </el-form>
<template #footer> <template #footer>
<el-button @click="syncDialogVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="primary" :loading="syncLoading" @click="submitSyncToHost">确定同步</el-button> <el-button @click="syncDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="syncLoading" @click="submitSyncToHost">确定同步</el-button>
</div>
</template> </template>
</el-dialog> </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 label-width="100px">
<el-form-item label="镜像"> <div class="tk-section">
<el-input :model-value="reloadTarget?.name" disabled /> <div class="tk-section-title">重下载配置</div>
</el-form-item> <el-form-item label="镜像">
<el-form-item label="目标宿主机" required> <el-input :model-value="reloadTarget?.name" disabled />
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" /> </el-form-item>
<el-select v-else v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%" v-loading="hostOptionsLoading"> <el-form-item label="目标宿主机" required>
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" /> <el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
</el-select> <el-select v-else v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%" v-loading="hostOptionsLoading">
</el-form-item> <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> </el-form>
<template #footer> <template #footer>
<el-button @click="reloadDialogVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="warning" :loading="reloadLoading" @click="submitReloadOnHost">确定重下载</el-button> <el-button @click="reloadDialogVisible = false">取消</el-button>
<el-button type="warning" :loading="reloadLoading" @click="submitReloadOnHost">确定重下载</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -345,7 +358,7 @@ const loadList = async () => {
if (hostId) { if (hostId) {
res = await getImageCompareHost({ service_id: serviceId.value, host_id: hostId }) res = await getImageCompareHost({ service_id: serviceId.value, host_id: hostId })
} else { } 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 (keyword.value) params.keyword = keyword.value
if (filterOsType.value) params.os_type = filterOsType.value if (filterOsType.value) params.os_type = filterOsType.value
if (filterType.value) params.type = filterType.value if (filterType.value) params.type = filterType.value
@@ -611,16 +624,5 @@ defineExpose({ loadList })
<style scoped> <style scoped>
.image-manage-container { padding: 20px; } .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; } .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> </style>
-21
View File
@@ -379,25 +379,4 @@ onMounted(() => {
font-size: 13px; 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> </style>
+90 -85
View File
@@ -71,48 +71,55 @@
</div> </div>
<!-- 新建/编辑弹窗 --> <!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建网络' : '编辑网络'" width="600px" destroy-on-close> <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="120px"> <el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="名称" prop="name"> <div class="tk-section">
<el-input v-model="formData.name" placeholder="网络名称" /> <div class="tk-section-title">基本信息</div>
</el-form-item> <el-form-item label="名称" prop="name">
<el-form-item label="宿主机" prop="host_id"> <el-input v-model="formData.name" placeholder="网络名称" />
<el-select v-model="formData.host_id" placeholder="选择宿主机" filterable style="width: 100%"> </el-form-item>
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" /> <el-form-item label="宿主机" prop="host_id">
</el-select> <el-select v-model="formData.host_id" placeholder="选择宿主机" filterable style="width: 100%">
</el-form-item> <el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
<el-form-item label="网络类型" prop="type"> </el-select>
<el-select v-model="formData.type" style="width: 100%"> </el-form-item>
<el-option label="网桥(Bridge/外网)" value="bridge" /> <el-form-item label="网络类型" prop="type">
<el-option label="内网(NAT)" value="nat" /> <el-select v-model="formData.type" style="width: 100%">
</el-select> <el-option label="网桥(Bridge/外网)" value="bridge" />
</el-form-item> <el-option label="内网(NAT)" value="nat" />
<el-form-item label="IP 地址(CIDR)" prop="address"> </el-select>
<el-input v-model="formData.address" placeholder="例如 192.168.1.0/24" /> </el-form-item>
</el-form-item> <el-form-item label="IP(CIDR)" prop="address">
<el-form-item label="网关地址" prop="gateway"> <el-input v-model="formData.address" placeholder="例如 192.168.1.0/24" />
<el-input v-model="formData.gateway" placeholder="例如 192.168.1.1" /> </el-form-item>
</el-form-item> <el-form-item label="网关地址" prop="gateway">
<el-form-item label="DNS 服务器"> <el-input v-model="formData.gateway" placeholder="例如 192.168.1.1" />
<el-input v-model="formData.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" /> </el-form-item>
</el-form-item> <el-form-item label="DNS 服务器">
<el-divider content-position="left">高级配置可选</el-divider> <el-input v-model="formData.nameservers" placeholder="默认 114.114.114.114,8.8.8.8" />
<el-form-item label="MAC 地址"> </el-form-item>
<el-input v-model="formData.mac_address" placeholder="不填则随机" /> </div>
</el-form-item> <div class="tk-section">
<el-form-item label="虚拟网桥名"> <div class="tk-section-title">高级配置</div>
<el-input v-model="formData.bridge_name" placeholder="不填使用默认" /> <el-form-item label="MAC 地址">
</el-form-item> <el-input v-model="formData.mac_address" placeholder="不填则随机" />
<el-form-item label="逻辑网桥名"> </el-form-item>
<el-input v-model="formData.ls_bridge_name" placeholder="不填使用默认" /> <el-form-item label="虚拟网桥名">
</el-form-item> <el-input v-model="formData.bridge_name" placeholder="不填使用默认" />
<el-form-item label="逻辑端口名"> </el-form-item>
<el-input v-model="formData.ls_name" placeholder="不填使用默认" /> <el-form-item label="逻辑网桥名">
</el-form-item> <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> </el-form>
<template #footer> <template #footer>
<el-button @click="dialogVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button> <el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
@@ -140,42 +147,50 @@
</el-dialog> </el-dialog>
<!-- 批量创建弹窗 --> <!-- 批量创建弹窗 -->
<el-dialog v-model="batchDialogVisible" title="批量创建网络" width="560px" destroy-on-close> <el-dialog v-model="batchDialogVisible" title="批量创建网络" width="560px" destroy-on-close class="tk-dialog">
<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="100px">
<el-form ref="batchFormRef" :model="batchForm" :rules="batchFormRules" label-width="120px"> <el-alert type="info" :closable="false" show-icon style="margin-bottom: 16px">通过指定 IP 范围start_ip ~ end_ip批量创建网络条目</el-alert>
<el-form-item label="宿主机" prop="host_id"> <div class="tk-section">
<el-select v-model="batchForm.host_id" placeholder="选择宿主机" filterable style="width: 100%"> <div class="tk-section-title">IP 范围</div>
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" /> <el-form-item label="宿主机" prop="host_id">
</el-select> <el-select v-model="batchForm.host_id" placeholder="选择宿主机" filterable style="width: 100%">
</el-form-item> <el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || h.id})`" :value="h.id" />
<el-form-item label="起始IP" prop="start_ip"> </el-select>
<el-input v-model="batchForm.start_ip" placeholder="如 192.168.1.10" /> </el-form-item>
</el-form-item> <el-form-item label="起始IP" prop="start_ip">
<el-form-item label="结束IP" prop="end_ip"> <el-input v-model="batchForm.start_ip" placeholder="如 192.168.1.10" />
<el-input v-model="batchForm.end_ip" placeholder="如 192.168.1.50" /> </el-form-item>
</el-form-item> <el-form-item label="结束IP" prop="end_ip">
<el-form-item label="网关"> <el-input v-model="batchForm.end_ip" placeholder="如 192.168.1.50" />
<el-input v-model="batchForm.gateway" placeholder="可选,如 192.168.1.1" /> </el-form-item>
</el-form-item> </div>
<el-form-item label="子网掩码"> <div class="tk-section">
<el-input v-model="batchForm.mask" placeholder="可选,如 24" /> <div class="tk-section-title">网络配置</div>
</el-form-item> <el-form-item label="网关">
<el-form-item label="DNS"> <el-input v-model="batchForm.gateway" placeholder="可选,如 192.168.1.1" />
<el-input v-model="batchForm.nameservers" placeholder="可选,如 114.114.114.114,8.8.8.8" /> </el-form-item>
</el-form-item> <el-form-item label="子网掩码">
<el-form-item label="网桥名称"> <el-input v-model="batchForm.mask" placeholder="可选,如 24" />
<el-input v-model="batchForm.bridge_name" placeholder="可选" /> </el-form-item>
</el-form-item> <el-form-item label="DNS">
<el-form-item label="网络类型"> <el-input v-model="batchForm.nameservers" placeholder="可选,如 114.114.114.114,8.8.8.8" />
<el-select v-model="batchForm.type" style="width: 100%"> </el-form-item>
<el-option label="网桥(Bridge)" value="bridge" /> <el-form-item label="网桥名称">
<el-option label="内网(NAT)" value="nat" /> <el-input v-model="batchForm.bridge_name" placeholder="可选" />
</el-select> </el-form-item>
</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> </el-form>
<template #footer> <template #footer>
<el-button @click="batchDialogVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="primary" :loading="submitLoading" @click="handleBatchSubmit">确定创建</el-button> <el-button @click="batchDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleBatchSubmit">确定创建</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
</div> </div>
@@ -256,7 +271,7 @@ const loadList = async () => {
if (!hid) { ElMessage.warning('请先选择宿主机'); return } if (!hid) { ElMessage.warning('请先选择宿主机'); return }
loading.value = true loading.value = true
try { 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 (keyword.value) params.key = keyword.value
if (filterType.value) params.type = filterType.value if (filterType.value) params.type = filterType.value
if (filterIpVersion.value) params.ip_version = filterIpVersion.value if (filterIpVersion.value) params.ip_version = filterIpVersion.value
@@ -426,14 +441,4 @@ defineExpose({ loadList })
<style scoped> <style scoped>
.network-manage-container { padding: 20px; } .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> </style>
@@ -335,13 +335,4 @@ onMounted(() => { if (serviceId.value) loadList() })
<style scoped> <style scoped>
.remote-hg-container { padding: 20px; } .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> </style>
@@ -533,17 +533,7 @@ onMounted(async () => {
<style scoped> <style scoped>
.sg-manage-container { padding: 20px; } .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-section { margin-top: 8px; }
.rules-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .rules-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.rules-header h4 { margin: 0; font-size: 15px; color: #303133; } .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> </style>
+2 -3
View File
@@ -125,7 +125,7 @@ const vmOptionsLoading = ref(false)
const loadVmOptions = async () => { const loadVmOptions = async () => {
vmOptionsLoading.value = true vmOptionsLoading.value = true
try { 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) { if (res?.data?.code === 200 && res?.data?.data) {
const inner = res.data.data const inner = res.data.data
vmOptions.value = inner.vms || inner.data || inner.list || (Array.isArray(inner) ? inner : []) vmOptions.value = inner.vms || inner.data || inner.list || (Array.isArray(inner) ? inner : [])
@@ -230,6 +230,5 @@ defineExpose({ loadList })
<style scoped> <style scoped>
.snapshot-manage { padding: 0; } .snapshot-manage { padding: 0; }
.toolbar { display: flex; gap: 8px; margin-top: 12px; margin-bottom: 16px; } .toolbar { margin-top: 12px; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
</style> </style>
@@ -430,18 +430,7 @@ onMounted(async () => {
<style scoped> <style scoped>
.networking-manage-container { padding: 20px; } .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-section { margin-top: 8px; }
.networks-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .networks-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.networks-header h4 { margin: 0; font-size: 15px; color: #303133; } .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> </style>
File diff suppressed because it is too large Load Diff
+172 -144
View File
@@ -49,9 +49,10 @@
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="状态" width="100"> <el-table-column label="状态" width="140">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="vmStatusType(row.status)" size="small">{{ vmStatusLabel(row.status) }}</el-tag> <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> </template>
</el-table-column> </el-table-column>
<!-- <el-table-column label="宿主机" width="140"> <!-- <el-table-column label="宿主机" width="140">
@@ -90,130 +91,142 @@
</div> </div>
<!-- 创建弹窗 --> <!-- 创建弹窗 -->
<el-dialog v-model="createDialogVisible" title="创建虚拟机" width="800px" destroy-on-close> <el-dialog v-model="createDialogVisible" title="创建虚拟机" width="800px" destroy-on-close class="tk-dialog">
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="120px"> <el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="100px">
<el-form-item label="名称"><el-input v-model="createForm.name" placeholder="不填随机生成" /></el-form-item> <div class="tk-section">
<el-form-item label="镜像" prop="image_id"> <div class="tk-section-title">基本信息</div>
<div class="bind-selector-row"> <el-form-item label="名称"><el-input v-model="createForm.name" placeholder="不填随机生成" /></el-form-item>
<el-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" /> <el-form-item label="镜像" prop="image_id">
<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'">
<div class="bind-selector-row"> <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-input :model-value="createForm.image_id ? `镜像 #${createForm.image_id}${createForm._imageName ? ' - ' + createForm._imageName : ''}` : '未选择'" disabled style="flex: 1" />
<el-button type="primary" @click="showHostGroupSelector = true" style="margin-left: 8px">选择</el-button> <el-button type="primary" @click="showCreateImageSelector = 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-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> </div>
</el-form-item> </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> </div>
<el-divider content-position="left">网络配置二选一</el-divider> <div class="tk-section">
<el-form-item label="IP分配方式"> <div class="tk-section-title">宿主机配置</div>
<el-radio-group v-model="ipMode"> <template v-if="isEmbeddedHost">
<el-radio value="num">按IP数量分配</el-radio> <el-form-item label="宿主机">
<el-radio value="ids">选择网络IP</el-radio> <el-input :model-value="embeddedHostLabel" disabled />
</el-radio-group> </el-form-item>
</el-form-item> </template>
<el-row :gutter="16" v-if="ipMode === 'num'"> <template v-else>
<el-col :span="12"> <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-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-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="IPv6数量"> <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-form-item>
</el-col> </div>
</el-row> <el-form-item label="网络IP列表" v-if="ipMode === 'ids'">
<el-form-item label="网络IP列表" v-if="ipMode === 'ids'"> <el-select v-model="createForm.network_ids" multiple filterable placeholder="选择可用网络IP" style="width: 100%">
<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-option v-for="n in networkOptions" :key="n.id" :label="`${n.name || ''} - ${n.address || n.ip || ''}`" :value="n.id" /> </el-select>
</el-select> <div class="form-tip" v-if="!networkOptions.length">请先选择宿主机以加载可用网络仅显示未使用的网络</div>
<div class="form-tip" v-if="!networkOptions.length">请先选择宿主机以加载可用网络仅显示未使用的网络</div> </el-form-item>
</el-form-item> </div>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="createDialogVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button> <el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitCreate">创建</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
<!-- 重装弹窗 --> <!-- 重装弹窗 -->
<el-dialog v-model="rebuildDialogVisible" title="重装虚拟机" width="440px" destroy-on-close> <el-dialog v-model="rebuildDialogVisible" title="重装虚拟机" width="480px" destroy-on-close class="tk-dialog">
<el-alert title="重装会使用新镜像重置虚拟机,原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<el-form label-width="100px"> <el-form label-width="100px">
<el-form-item label="虚拟机">{{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }})</el-form-item> <div class="tk-section">
<el-form-item label="新镜像" required> <div class="tk-section-title">重装信息</div>
<div class="bind-selector-row"> <el-alert title="重装会使用新镜像重置虚拟机,原数据可能丢失" type="warning" :closable="false" show-icon style="margin-bottom: 16px" />
<el-input :model-value="rebuildImageId ? `镜像 #${rebuildImageId}${rebuildImageName ? ' - ' + rebuildImageName : ''}` : '未选择'" disabled style="flex: 1" /> <el-form-item label="虚拟机">{{ rebuildTarget?.name }} (#{{ rebuildTarget?.id }})</el-form-item>
<el-button type="primary" @click="showRebuildImageSelector = true" style="margin-left: 8px">选择</el-button> <el-form-item label="新镜像" required>
</div> <div class="bind-selector-row">
</el-form-item> <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> </el-form>
<template #footer> <template #footer>
<el-button @click="rebuildDialogVisible = false">取消</el-button> <div class="tk-dialog-footer">
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重装</el-button> <el-button @click="rebuildDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="submitLoading" @click="submitRebuild">确认重装</el-button>
</div>
</template> </template>
</el-dialog> </el-dialog>
@@ -294,19 +307,18 @@
<!-- 指标 --> <!-- 指标 -->
<template v-if="vmMetricsData"> <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 :column="2" border size="small">
<el-descriptions-item label="虚拟机">{{ vmMetricsData.vm_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="CPU使用率"> <el-descriptions-item label="CPU使用率">
<span :style="{ color: vmMetricsData.cpu_usage_percent > 90 ? '#F56C6C' : vmMetricsData.cpu_usage_percent > 60 ? '#E6A23C' : '#67C23A' }"> <span :style="{ color: (vmMetricsData.cpu_usage ?? 0) > 90 ? '#F56C6C' : (vmMetricsData.cpu_usage ?? 0) > 60 ? '#E6A23C' : '#67C23A' }">
{{ (vmMetricsData.cpu_usage_percent ?? 0).toFixed(1) }}% {{ (vmMetricsData.cpu_usage ?? 0).toFixed(1) }}%
</span> </span>
</el-descriptions-item> </el-descriptions-item>
<template v-if="vmMetricsData.internet_speed && Object.keys(vmMetricsData.internet_speed).length"> <el-descriptions-item label="内存">{{ formatMemKB(vmMetricsData.mem_used) }} / {{ formatMemKB(vmMetricsData.mem_total) }}</el-descriptions-item>
<el-descriptions-item label="网络速率" :span="2"> <el-descriptions-item label="磁盘读取">{{ formatBytesRaw(vmMetricsData.disk_read) }}</el-descriptions-item>
<div v-for="(val, key) in vmMetricsData.internet_speed" :key="key">{{ key }}: {{ val }}</div> <el-descriptions-item label="磁盘写入">{{ formatBytesRaw(vmMetricsData.disk_write) }}</el-descriptions-item>
</el-descriptions-item> <el-descriptions-item label="网络接收">{{ formatNetSpeed(vmMetricsData.net_rx) }}</el-descriptions-item>
</template> <el-descriptions-item label="网络发送">{{ formatNetSpeed(vmMetricsData.net_tx) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</template> </template>
</div> </div>
@@ -350,9 +362,9 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue' import { Plus, Refresh, Search, ArrowLeft, ArrowDown, WarningFilled } from '@element-plus/icons-vue'
import { import {
getRemoteHostList, getVmList, getVmDetail, getVmStatus, getVmMetrics, getRemoteHostList, getVmList, getVmDetail, getVmStatus,
createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm, createVm, rebuildVm, startVm, stopVm, rebootVm, suspendVm,
resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList resumeVm, rescueVm, exitRescueVm, deleteVm, getNetworkList, getMetricsHistory
} from '@/api/admin/kvmService' } from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil' import { extractApiError } from '@/utils/kvmErrorUtil'
import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue' import ImageSelectorPopup from '@/components/admin/ImageSelectorPopup.vue'
@@ -427,7 +439,7 @@ const diskDisplay = computed({
const loadNetworkOptions = async (hid) => { const loadNetworkOptions = async (hid) => {
if (!hid) return if (!hid) return
try { 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' if (injectedHostId?.value) params.type = 'bridge'
const res = await getNetworkList(params) const res = await getNetworkList(params)
const body = res?.data const body = res?.data
@@ -476,7 +488,7 @@ const vmMetricsData = ref(null)
const createForm = reactive({ const createForm = reactive({
name: '', host_id: null, image_id: 0, vcpu: 0, memory: 0, 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: [], host_group_id: null, user_id: 0, ipv4_num: 0, ipv6_num: 0, network_ids: [],
_imageName: '', _groupName: '', _userName: '' _imageName: '', _groupName: '', _userName: ''
}) })
@@ -501,7 +513,7 @@ const vmStatusLabel = (s) => ({
reboot: '重启中', poweroff: '已关机', unknown: '未知' reboot: '重启中', poweroff: '已关机', unknown: '未知'
}[s] || s || '-') }[s] || s || '-')
const formatMemory = (kb) => { const formatMemKB = (kb) => {
if (!kb) return '-' if (!kb) return '-'
kb = Number(kb) kb = Number(kb)
if (kb >= 1073741824) return (kb / 1073741824).toFixed(1) + ' TB' 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' if (kb >= 1024) return (kb / 1024).toFixed(0) + ' MB'
return kb + ' KB' return kb + ' KB'
} }
const formatMemory = formatMemKB
const formatTimestamp = (ts) => { const formatTimestamp = (ts) => {
if (!ts) return '-' if (!ts) return '-'
@@ -532,6 +545,21 @@ const formatBytesRaw = (val) => {
return val + ' B' 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 handleCreateImageSelected = (img) => { createForm.image_id = img.id; createForm._imageName = img.name }
const handleRebuildImageSelected = (img) => { rebuildImageId.value = img.id; rebuildImageName.value = img.name } const handleRebuildImageSelected = (img) => { rebuildImageId.value = img.id; rebuildImageName.value = img.name }
@@ -542,7 +570,7 @@ const loadList = async () => {
if (!serviceId.value) return if (!serviceId.value) return
loading.value = true loading.value = true
try { 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 (hostId.value) params.host_id = hostId.value
if (keyword.value) params.key = keyword.value if (keyword.value) params.key = keyword.value
if (filterStatus.value) params.status = filterStatus.value if (filterStatus.value) params.status = filterStatus.value
@@ -562,7 +590,8 @@ const handleAdd = () => {
Object.assign(createForm, { Object.assign(createForm, {
name: '', host_id: injectedHostId?.value || null, image_id: 0, name: '', host_id: injectedHostId?.value || null, image_id: 0,
vcpu: 0, memory: 0, system_size: 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: '' _imageName: '', _groupName: '', _userName: ''
}) })
memoryUnit.value = 'GB' memoryUnit.value = 'GB'
@@ -570,6 +599,7 @@ const handleAdd = () => {
hostMode.value = 'host' hostMode.value = 'host'
ipMode.value = 'num' ipMode.value = 'num'
networkOptions.value = [] networkOptions.value = []
loadHostOptions()
createDialogVisible.value = true createDialogVisible.value = true
if (injectedHostId?.value) { if (injectedHostId?.value) {
loadNetworkOptions(injectedHostId.value) loadNetworkOptions(injectedHostId.value)
@@ -596,6 +626,7 @@ const submitCreate = () => {
if (createForm.name) fd.append('name', createForm.name) if (createForm.name) fd.append('name', createForm.name)
if (createForm.rx_bandwidth) fd.append('rx_bandwidth', createForm.rx_bandwidth) if (createForm.rx_bandwidth) fd.append('rx_bandwidth', createForm.rx_bandwidth)
if (createForm.tx_bandwidth) fd.append('tx_bandwidth', createForm.tx_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) if (hostMode.value === 'host') fd.append('host_id', createForm.host_id)
else fd.append('host_group_id', createForm.host_group_id) else fd.append('host_group_id', createForm.host_group_id)
if (ipMode.value === 'num') { if (ipMode.value === 'num') {
@@ -737,9 +768,24 @@ const fetchVmStatus = async (vm) => {
const fetchVmMetrics = async (vm) => { const fetchVmMetrics = async (vm) => {
try { try {
const res = await getVmMetrics({ service_id: serviceId.value, vm_name: vm.name }) const now = new Date()
if (res?.data?.code === 200) vmMetricsData.value = res.data.data?.data ?? res.data.data const start = new Date(now.getTime() - 60 * 60 * 1000)
else ElMessage.warning('暂无指标数据') 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, '获取指标失败')) } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '获取指标失败')) }
} }
@@ -773,23 +819,5 @@ defineExpose({ loadList })
<style scoped> <style scoped>
.vm-manage-container { padding: 20px; } .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; } .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> </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> <style scoped>
.vnc-node-container { padding: 20px; } .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; } .host-addr { font-family: 'Consolas', 'Monaco', monospace; color: #409eff; font-size: 13px; }
.token-mask { font-family: 'Consolas', 'Monaco', monospace; color: #909399; 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 { 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.success { background: #f0f9eb; color: #67c23a; }
.test-result.error { background: #fef0f0; color: #f56c6c; } .test-result.error { background: #fef0f0; color: #f56c6c; }
.vnc-result { margin-top: 12px; } .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> </style>
+13 -8
View File
@@ -56,8 +56,11 @@
<el-dialog v-model="resizeDialogVisible" title="调整数据卷大小" width="400px" destroy-on-close> <el-dialog v-model="resizeDialogVisible" title="调整数据卷大小" width="400px" destroy-on-close>
<el-form label-width="100px"> <el-form label-width="100px">
<el-form-item label="当前大小">{{ detail?.size || 0 }} GB</el-form-item> <el-form-item label="当前大小">{{ detail?.size || 0 }} GB</el-form-item>
<el-form-item label="新大小(GB)"> <el-form-item label="新大小">
<el-input-number v-model="newSize" :min="1" controls-position="right" style="width: 100%" /> <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-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -107,7 +110,7 @@
</template> </template>
<script setup> <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 { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, Refresh } from '@element-plus/icons-vue' import { ArrowLeft, Refresh } from '@element-plus/icons-vue'
@@ -165,16 +168,18 @@ const loadDetail = async () => {
// //
const resizeDialogVisible = ref(false) const resizeDialogVisible = ref(false)
const newSize = ref(1) const resizeForm = reactive({ size: 1, _sizeUnit: 'GB' })
const handleResize = () => { const handleResize = () => {
if (!detail.value) return if (!detail.value) return
newSize.value = detail.value.size || 10 resizeForm.size = detail.value.size || 10
resizeForm._sizeUnit = 'GB'
resizeDialogVisible.value = true resizeDialogVisible.value = true
} }
const submitResize = async () => { const submitResize = async () => {
actionLoading.value = true actionLoading.value = true
try { 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() } if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadDetail() }
else ElMessage.error(extractApiError(res?.data, '调整失败')) else ElMessage.error(extractApiError(res?.data, '调整失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { actionLoading.value = false } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { actionLoading.value = false }
@@ -285,7 +290,7 @@ onMounted(() => { isPageActive = true; initPage() })
.main-content { padding: 20px; } .main-content { padding: 20px; }
.info-card { margin-bottom: 20px; } .info-card { margin-bottom: 20px; }
.card-title { font-weight: 600; font-size: 15px; color: #303133; } .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; } .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> </style>
+26 -21
View File
@@ -77,7 +77,12 @@
<el-dialog v-model="createDialogVisible" title="创建数据卷" width="560px" destroy-on-close> <el-dialog v-model="createDialogVisible" title="创建数据卷" width="560px" destroy-on-close>
<el-form ref="createFormRef" :model="createForm" :rules="createRules" label-width="110px"> <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="名称" 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-form-item label="宿主机" prop="host_id">
<el-select v-model="createForm.host_id" placeholder="选择宿主机" filterable style="width: 100%"> <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" /> <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-dialog v-model="resizeDialogVisible" title="调整数据卷大小" width="400px" destroy-on-close>
<el-form label-width="100px"> <el-form label-width="100px">
<el-form-item label="当前大小">{{ resizeTarget?.size || 0 }} GB</el-form-item> <el-form-item label="当前大小">{{ resizeTarget?.size || 0 }} GB</el-form-item>
<el-form-item label="新大小(GB)"> <el-form-item label="新大小">
<el-input-number v-model="newSize" :min="1" controls-position="right" style="width: 100%" /> <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-item>
</el-form> </el-form>
<template #footer> <template #footer>
@@ -236,7 +244,7 @@ const createFormRef = ref(null)
const resizeDialogVisible = ref(false) const resizeDialogVisible = ref(false)
const mountDialogVisible = ref(false) const mountDialogVisible = ref(false)
const resizeTarget = ref(null) const resizeTarget = ref(null)
const newSize = ref(1) const resizeForm = reactive({ size: 1, _sizeUnit: 'GB' })
const mountTarget = ref(null) const mountTarget = ref(null)
const mountVmId = ref(0) const mountVmId = ref(0)
const mountVmName = ref('') const mountVmName = ref('')
@@ -262,7 +270,7 @@ const showVmSelector = ref(false)
const showMountVmSelector = ref(false) const showMountVmSelector = ref(false)
const createForm = reactive({ 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: '', image_id: 0, vm_id: 0, target_device: '',
_imageName: '', _vmName: '' _imageName: '', _vmName: ''
}) })
@@ -301,7 +309,7 @@ const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => { const handleAdd = () => {
Object.assign(createForm, { 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: '', is_system: false, image_id: '', vm_id: '', target_device: '',
_imageName: '', _vmName: '' _imageName: '', _vmName: ''
}) })
@@ -313,9 +321,10 @@ const submitCreate = () => {
if (!valid) return if (!valid) return
submitLoading.value = true submitLoading.value = true
try { try {
const sizeGb = createForm._sizeUnit === 'TB' ? createForm.size * 1024 : createForm.size
const payload = { const payload = {
service_id: serviceId.value, 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 host_id: createForm.host_id, is_system: createForm.is_system
} }
if (createForm.image_id) payload.image_id = createForm.image_id 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 () => { const submitResize = async () => {
submitLoading.value = true submitLoading.value = true
try { 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() } if (res?.data?.code === 200) { ElMessage.success('调整成功'); resizeDialogVisible.value = false; loadList() }
else ElMessage.error(extractApiError(res?.data, '调整失败')) else ElMessage.error(extractApiError(res?.data, '调整失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { submitLoading.value = false } } catch (e) { ElMessage.error(extractApiError(e?.response?.data, '调整失败')) } finally { submitLoading.value = false }
@@ -434,16 +449,6 @@ defineExpose({ loadList })
<style scoped> <style scoped>
.volume-manage-container { padding: 20px; } .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; } .unit-input-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.header-left { display: flex; align-items: center; gap: 16px; } .unit-select { width: 90px; flex-shrink: 0; }
.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; }
</style> </style>
+1 -1
View File
@@ -9,7 +9,7 @@
**使用场景:** 将 `openapi.json` 拖入 Cursor,打开你的项目根目录,发送以下 Prompt: **使用场景:** 将 `openapi.json` 拖入 Cursor,打开你的项目根目录,发送以下 Prompt:
> **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. **接口完整性对比:** 分析该 OpenAPI 文件中所有以 `/product``user` 开头的接口。对比我当前项目中 `src/api/product.ts`(或对应目录)的实现,列出缺少实现的接口、参数定义不一致的接口。
-348
View File
@@ -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 / delete5个)
- 宿主机组映射管理:list / sync / bind / update / generate_goods / delete6个)
- 远程宿主机组管理:list / detail / tree / optimal_host / create / update / delete7个)
- 宿主机管理:list / detail / metrics / add / update / delete6个)
- 镜像管理:list / detail / host_status / create / update / delete / reload / sync / reload_host / compare_host10个)
- 网络管理:list / detail / create / update / delete5个)
- 数据卷管理:list / detail / create / resize / mount / unmount / transfer / delete8个)
- 虚拟机管理:list / detail / status / metrics / create / update / rebuild / refactor / update_traffic / start / stop / reboot / suspend / resume / rescue / exit_rescue / delete17个)
- 安全组管理:list / detail / create / update / sync / bind / unbind / delete / enable_whitelist / disable_whitelist / create_rule / update_rule / delete_rule / apply / set_shared15个)
- VNC节点管理:list / vm_vnc / add / test / update / delete6个)
- 快照管理:list / progress / create / restore / delete5个)
- 备份管理:list / progress / create / restore / delete5个)
**共计已对接 95 个接口,新增 10 个接口待对接。**
+4 -83
View File
@@ -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 | 复用场景 | 三、ApiServer-Web-home
|------|------|-------|----------| 1.图一, 用户首页购买页的参数的判断依据全部根据参数里面的 key 进行标识,然后检查一下 选择系统盘大小(GB) 为什么是空的,内存的显示改成GB但是传入还是要KB
| FormSelectorField | src/components/common/FormSelectorField.vue | modelValue, displayText, placeholder, buttonText, disabled, clearable, hint, hintType | 所有"只读输入框+选择按钮+清除按钮"的选择器行(50+处) |
已补充的工具函数(src/utils/tool.js):
| 函数 | 用途 | 复用场景 |
|------|------|----------|
| formatPrice(fen) | 分→元显示 ¥xx.xx | 25+个文件的价格展示 |
| yuanToFen(yuan) | 元→分转换 | 所有提交价格的表单 |
| formatExpireTime(t) | 到期时间格式化(<2000年显示永久) | 28+个文件 |
三、嵌套与快捷入口评估
⚠️ ProductGroup.vue3281行)是当前最大的单体文件,集中了 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 1.请求接口的带有page-size或者是count参数的都只能是10
+220 -4334
View File
File diff suppressed because it is too large Load Diff