Files
ApiServer-Web-admin_dashboa…/src/views/virtualization/ImageManage.vue
T
lin c07e09c151
Build and Deploy Vue3 / build (push) Successful in 1m40s
Build and Deploy Vue3 / deploy (push) Successful in 1m8s
feat: 添加用户虚拟机商品管理
2026-03-31 15:13:04 +08:00

627 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="image-manage-container">
<div class="page-header" v-if="!embedded">
<div class="header-left">
<el-button @click="goBack" :icon="ArrowLeft">返回</el-button>
<div class="header-info">
<h3>镜像管理</h3>
<span class="sub-info" v-if="serviceName">所属主控服务{{ serviceName }}</span>
</div>
</div>
<div class="header-right">
<el-button type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建镜像</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
</div>
<div class="embedded-toolbar" v-if="embedded">
<el-button v-if="isEmbeddedHost" type="primary" @click="handleSyncToHostBatch"><el-icon><Refresh /></el-icon>同步镜像</el-button>
<el-button v-else type="primary" @click="handleAdd"><el-icon><Plus /></el-icon>创建镜像</el-button>
<el-button @click="loadList"><el-icon><Refresh /></el-icon>刷新</el-button>
</div>
<!-- 筛选栏宿主机详情下不显示 -->
<div class="filter-bar" v-if="!injectedHostId?.value">
<el-input v-model="keyword" placeholder="搜索镜像名称" clearable style="width: 220px" @keyup.enter="handleSearch" @clear="handleSearch">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<!-- <el-select v-model="filterOsType" placeholder="系统类型" clearable style="width: 130px" @change="handleSearch">
<el-option label="Linux" value="linux" />
<el-option label="Windows" value="windows" />
</el-select>
<el-select v-model="filterType" placeholder="镜像类型" clearable style="width: 130px" @change="handleSearch">
<el-option label="系统镜像" value="system" />
<el-option label="数据镜像" value="data" />
</el-select>
<el-select v-model="filterStatus" placeholder="状态" clearable style="width: 130px" @change="handleSearch">
<el-option label="等待中" value="pending" />
<el-option label="下载中" value="downloading" />
<el-option label="就绪" value="ready" />
<el-option label="错误" value="error" />
</el-select> -->
</div>
<!-- 镜像列表 -->
<el-table :data="imageList" v-loading="loading" stripe>
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="name" label="名称" min-width="160" show-overflow-tooltip />
<el-table-column label="系统类型" width="100">
<template #default="{ row }">
<el-tag :type="row.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ row.os_type || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="镜像类型" width="100">
<template #default="{ row }">
<el-tag :type="row.type === 'system' ? '' : 'warning'" size="small">{{ row.type === 'system' ? '系统' : '数据' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="主控状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="同步状态" width="100">
<template #default="{ row }">
<el-tag :type="syncStatusType(row.sync_status)" size="small">{{ syncStatusLabel(row.sync_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="路径" min-width="200" show-overflow-tooltip />
<el-table-column label="大小" width="90">
<template #default="{ row }">{{ row.size ? formatSize(row.size) : '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="handleGoDetail(row)">详情</el-button>
<el-button v-if="!isEmbeddedHost" link type="success" @click="handleSyncToHost()">同步</el-button>
<el-button link type="warning" @click="handleReloadOnHost(row)">重新下载</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="total > 0">
<el-pagination v-model:current-page="queryParams.page" v-model:page-size="queryParams.page_size"
:page-sizes="[10, 20, 50]" :total="total" layout="total, sizes, prev, pager, next"
@size-change="s => { queryParams.page_size = s; queryParams.page = 1; loadList() }"
@current-change="p => { queryParams.page = p; loadList() }" />
</div>
<!-- 新建/编辑弹窗 -->
<el-dialog v-model="dialogVisible" :title="dialogType === 'add' ? '创建镜像' : '编辑镜像'" width="560px" destroy-on-close>
<el-form ref="formRef" :model="formData" :rules="formRules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" placeholder="镜像名称" />
</el-form-item>
<el-form-item label="路径" prop="path">
<el-input v-model="formData.path" placeholder="URL 或服务器文件路径" />
</el-form-item>
<el-form-item label="系统类型" prop="os_type">
<el-select v-model="formData.os_type" style="width: 100%">
<el-option label="Linux" value="linux" />
<el-option label="Windows" value="windows" />
</el-select>
</el-form-item>
<el-form-item label="镜像类型" prop="type">
<el-select v-model="formData.type" style="width: 100%">
<el-option label="系统镜像" value="system" />
<el-option label="数据镜像" value="data" />
</el-select>
</el-form-item>
<el-form-item label="介绍">
<el-input v-model="formData.description" type="textarea" :rows="3" placeholder="镜像介绍可选" />
</el-form-item>
<template v-if="dialogType === 'edit'">
<el-form-item label="状态">
<el-select v-model="formData.status" style="width: 100%">
<el-option label="等待中" value="pending" />
<el-option label="下载中" value="downloading" />
<el-option label="就绪" value="ready" />
<el-option label="错误" value="error" />
</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>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 详情弹窗 -->
<el-dialog v-model="detailVisible" title="镜像详情" width="680px" destroy-on-close>
<div v-loading="detailLoading">
<el-descriptions :column="2" border v-if="currentDetail">
<el-descriptions-item label="ID">{{ currentDetail.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ currentDetail.name }}</el-descriptions-item>
<el-descriptions-item label="系统类型">
<el-tag :type="currentDetail.os_type === 'linux' ? 'success' : 'primary'" size="small">{{ currentDetail.os_type || '-' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="镜像类型">
<el-tag :type="currentDetail.type === 'system' ? '' : 'warning'" size="small">{{ currentDetail.type === 'system' ? '系统镜像' : '数据镜像' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusType(currentDetail.status)" size="small">{{ statusLabel(currentDetail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="大小">{{ currentDetail.size ? formatSize(currentDetail.size) : '-' }}</el-descriptions-item>
<el-descriptions-item label="路径" :span="2">
<span class="mono-text">{{ currentDetail.path || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="介绍" :span="2">{{ currentDetail.description || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTimestamp(currentDetail.created_at) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatTimestamp(currentDetail.updated_at) }}</el-descriptions-item>
</el-descriptions>
<!-- 宿主机同步状态 -->
<div class="host-status-section" v-if="hostStatusList.length > 0">
<h4 style="margin: 16px 0 8px">宿主机同步状态</h4>
<el-table :data="hostStatusList" size="small" stripe border>
<el-table-column prop="host_id" label="宿主机ID" width="100" />
<el-table-column label="宿主机" min-width="120">
<template #default="{ row }">{{ getHostName(row.host_id) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="path" label="本地路径" min-width="200" show-overflow-tooltip />
</el-table>
</div>
</div>
<template #footer>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 同步到宿主机弹窗 -->
<el-dialog v-model="syncDialogVisible" title="同步镜像到宿主机" width="440px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="目标宿主机" required>
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
<el-select v-else v-model="syncHostId" placeholder="请选择宿主机" filterable style="width: 100%" v-loading="hostOptionsLoading">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="syncDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="syncLoading" @click="submitSyncToHost">确定同步</el-button>
</template>
</el-dialog>
<!-- 重下载到宿主机弹窗 -->
<el-dialog v-model="reloadDialogVisible" title="重新下载镜像到宿主机" width="440px" destroy-on-close>
<el-form label-width="100px">
<el-form-item label="镜像">
<el-input :model-value="reloadTarget?.name" disabled />
</el-form-item>
<el-form-item label="目标宿主机" required>
<el-input v-if="isEmbeddedHost" :model-value="currentHostLabel" disabled style="width: 100%" />
<el-select v-else v-model="reloadHostId" placeholder="请选择宿主机" style="width: 100%" v-loading="hostOptionsLoading">
<el-option v-for="h in hostOptions" :key="h.id" :label="`${h.name} (${h.ip || '#' + h.id})`" :value="h.id" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="reloadDialogVisible = false">取消</el-button>
<el-button type="warning" :loading="reloadLoading" @click="submitReloadOnHost">确定重下载</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, inject, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Refresh, Search, ArrowLeft } from '@element-plus/icons-vue'
import {
getImageList, getImageCompareHost, getImageDetail, getImageHostStatus, createImage, updateImage, deleteImage,
reloadImage, syncImageToHost, reloadImageOnHost, getRemoteHostList
} from '@/api/admin/kvmService'
import { extractApiError } from '@/utils/kvmErrorUtil'
const route = useRoute()
const router = useRouter()
const embedded = inject('embedded', false)
const injectedServiceId = inject('serviceId', null)
const injectedServiceName = inject('serviceName', null)
const injectedHostId = inject('hostId', null)
const injectedHostDetail = inject('hostDetail', null)
const serviceId = computed(() => injectedServiceId?.value || parseInt(route.query.service_id) || 0)
const serviceName = computed(() => injectedServiceName?.value || route.query.service_name || '')
const loading = ref(false)
const submitLoading = ref(false)
const detailLoading = ref(false)
const imageList = ref([])
const total = ref(0)
const keyword = ref('')
const filterOsType = ref('')
const filterType = ref('')
const filterStatus = ref('')
const filterHostId = ref('')
const hostOptions = ref([])
const queryParams = reactive({ page: 1, page_size: 10 })
const dialogVisible = ref(false)
const dialogType = ref('add')
const formRef = ref(null)
const detailVisible = ref(false)
const currentDetail = ref(null)
const hostStatusList = ref([])
// 同步到宿主机
const syncDialogVisible = ref(false)
const syncHostId = ref('')
const syncLoading = ref(false)
// 重下载到宿主机
const reloadDialogVisible = ref(false)
const reloadTarget = ref(null)
const reloadHostId = ref('')
const reloadLoading = ref(false)
const hostOptionsLoading = ref(false)
const isEmbeddedHost = computed(() => !!(embedded && injectedHostId?.value))
const currentHostLabel = computed(() => {
const hid = injectedHostId?.value
if (!hid) return '-'
const hd = injectedHostDetail?.value
if (hd) return `${hd.name || '宿主机'} (${hd.ip || '#' + hid})`
const h = hostOptions.value.find(x => x.id === hid)
return h ? `${h.name} (${h.ip || '#' + h.id})` : `宿主机 #${hid}`
})
const formData = reactive({
image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system',
description: '', status: '', size: 0, image_name: ''
})
const formRules = {
name: [{ required: true, message: '请输入镜像名称', trigger: 'blur' }],
path: [{ required: true, message: '请输入镜像路径', trigger: 'blur' }]
}
const statusType = (s) => ({ ready: 'success', success: 'success', downloading: 'warning', pending: 'info', error: 'danger', failed: 'danger', not_found: 'warning' }[s] || 'info')
const statusLabel = (s) => ({ ready: '就绪', success: '已同步', downloading: '下载中', pending: '等待中', error: '错误', failed: '失败', not_found: '未同步' }[s] || s || '-')
const syncStatusType = (s) => ({ synced: 'success', not_synced: 'warning', downloading: 'primary', error: 'danger', pending: 'info' }[s] || 'info')
const syncStatusLabel = (s) => ({ synced: '已同步', not_synced: '未同步', downloading: '同步中', error: '同步错误', pending: '等待同步' }[s] || s || '-')
const formatSize = (bytes) => {
if (!bytes) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let i = 0
let size = Number(bytes)
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++ }
return size.toFixed(i > 0 ? 1 : 0) + ' ' + units[i]
}
const formatTimestamp = (ts) => {
if (!ts) return '-'
if (typeof ts === 'object' && ts.seconds) {
return new Date(Number(ts.seconds) * 1000).toLocaleString('zh-CN')
}
if (typeof ts === 'string' || typeof ts === 'number') {
const d = new Date(ts)
return isNaN(d.getTime()) ? String(ts) : d.toLocaleString('zh-CN')
}
return '-'
}
const getHostName = (hid) => {
if (!hid) return '-'
const h = hostOptions.value.find(x => x.id === hid)
return h ? `${h.name}` : `#${hid}`
}
// 加载宿主机列表
const loadHostOptions = async () => {
try {
const res = await getRemoteHostList({ service_id: serviceId.value, page: 1, page_size: 10 })
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
const items = Array.isArray(inner) ? inner : (inner.hosts || inner.data || inner.list || [])
hostOptions.value = items
}
} catch (e) { /* ignore */ }
}
const resolveHostId = () => {
if (injectedHostId?.value) return injectedHostId.value
return null
}
const loadList = async () => {
if (!serviceId.value) return
loading.value = true
try {
const hostId = resolveHostId()
let res
if (hostId) {
res = await getImageCompareHost({ service_id: serviceId.value, host_id: hostId })
} else {
const params = { service_id: serviceId.value, page: queryParams.page, page_size: queryParams.page_size }
if (keyword.value) params.keyword = keyword.value
if (filterOsType.value) params.os_type = filterOsType.value
if (filterType.value) params.type = filterType.value
if (filterStatus.value) params.status = filterStatus.value
res = await getImageList(params)
}
const body = res?.data
if (body?.code === 200 && body?.data) {
const inner = body.data
if (hostId && Array.isArray(inner.data)) {
let items = inner.data.map(item => ({
...(item.image || {}),
sync_status: item.sync_status || 'not_synced',
}))
if (keyword.value) {
const kw = keyword.value.toLowerCase()
items = items.filter(img => img.name?.toLowerCase().includes(kw))
}
imageList.value = items
total.value = inner.total ?? items.length
} else {
const items = Array.isArray(inner) ? inner : (inner.images || inner.data || inner.list || [])
imageList.value = items
total.value = inner.meta?.count ?? inner.all_count ?? inner.total ?? items.length
}
} else {
imageList.value = []
total.value = 0
}
} catch (e) {
console.error('获取镜像列表失败:', e)
ElMessage.error('获取镜像列表失败')
} finally {
loading.value = false
}
}
const handleSearch = () => { queryParams.page = 1; loadList() }
const handleAdd = () => {
dialogType.value = 'add'
Object.assign(formData, { image_id: undefined, name: '', path: '', os_type: 'linux', type: 'system', description: '', status: '', size: 0 })
dialogVisible.value = true
}
const handleEdit = (row) => {
dialogType.value = 'edit'
Object.assign(formData, {
image_id: row.id, name: row.name, image_name: row.name, path: row.path || '',
os_type: row.os_type || 'linux', type: row.type || 'system',
description: row.description || '', status: row.status || '', size: row.size || 0
})
dialogVisible.value = true
}
const handleSubmit = () => {
formRef.value?.validate(async (valid) => {
if (!valid) return
submitLoading.value = true
try {
let res
if (dialogType.value === 'add') {
res = await createImage({
service_id: serviceId.value, name: formData.name, path: formData.path,
os_type: formData.os_type, type: formData.type, description: formData.description || undefined
})
} else {
const payload = {
image_id: formData.image_id, service_id: serviceId.value,
image_name: formData.name, path: formData.path,
os_type: formData.os_type, type: formData.type,
description: formData.description || undefined,
status: formData.status || undefined, size: formData.size || undefined
}
// 清除 undefined
Object.keys(payload).forEach(k => { if (payload[k] === undefined) delete payload[k] })
res = await updateImage(payload)
}
if (res?.data?.code === 200) {
ElMessage.success(dialogType.value === 'add' ? '创建成功' : '修改成功')
dialogVisible.value = false
loadList()
} else {
ElMessage.error(extractApiError(res?.data, '操作失败'))
}
} catch (e) {
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
} finally {
submitLoading.value = false
}
})
}
const fetchHostStatusList = async (imageId) => {
if (!hostOptions.value.length) await loadHostOptions()
if (!hostOptions.value.length) return []
const results = await Promise.allSettled(
hostOptions.value.map(h =>
getImageHostStatus({ service_id: serviceId.value, image_id: imageId, host_id: h.id })
.then(res => {
const body = res?.data
if (body?.code === 200 && body?.data) {
const d = body.data
const img = d.image || {}
return { host_id: h.id, host_name: h.name || h.ip, status: d.status || 'not_found', path: img.path || '', image_name: img.name || '', image_status: img.status || '' }
}
return { host_id: h.id, host_name: h.name || h.ip, status: 'not_found' }
})
.catch(() => ({ host_id: h.id, host_name: h.name || h.ip, status: 'error' }))
)
)
return results.filter(r => r.status === 'fulfilled' && r.value).map(r => r.value)
}
const handleViewDetail = async (row) => {
detailVisible.value = true
detailLoading.value = true
currentDetail.value = row
hostStatusList.value = []
try {
const [detailRes, statusList] = await Promise.allSettled([
getImageDetail({ service_id: serviceId.value, image_id: row.id }),
fetchHostStatusList(row.id)
])
if (detailRes.status === 'fulfilled') {
const body = detailRes.value?.data
if (body?.code === 200 && body?.data) {
currentDetail.value = body.data.image ?? body.data.data ?? body.data
}
}
if (statusList.status === 'fulfilled') {
hostStatusList.value = statusList.value || []
}
} catch (e) {
console.error('获取镜像详情失败:', e)
} finally {
detailLoading.value = false
}
}
// 同步镜像到宿主机
const handleSyncToHost = () => {
if (embedded && injectedHostId?.value) {
syncHostId.value = injectedHostId.value
} else {
syncHostId.value = ''
}
syncDialogVisible.value = true
if (!embedded || !injectedHostId?.value) {
if (!hostOptions.value.length) {
hostOptionsLoading.value = true
loadHostOptions().finally(() => { hostOptionsLoading.value = false })
}
}
}
const handleSyncToHostBatch = () => {
handleSyncToHost()
}
const submitSyncToHost = async () => {
if (!syncHostId.value) return ElMessage.warning('请选择目标宿主机')
syncLoading.value = true
try {
const formPayload = new FormData()
formPayload.append('service_id', serviceId.value)
formPayload.append('host_id', syncHostId.value)
const res = await syncImageToHost(formPayload)
if (res?.data?.code === 200) {
ElMessage.success('已触发同步到宿主机')
syncDialogVisible.value = false
loadList()
} else {
ElMessage.error(extractApiError(res?.data, '同步失败'))
}
} catch (e) {
ElMessage.error(extractApiError(e?.response?.data, '同步失败'))
} finally {
syncLoading.value = false
}
}
const handleReloadMaster = (row) => {
ElMessageBox.confirm(`确定要在主控端重新下载镜像「${row.name}」吗?`, '重新下载确认', {
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const fd = new FormData()
fd.append('service_id', serviceId.value)
fd.append('image_id', row.id)
const res = await reloadImage(fd)
if (res?.data?.code === 200) { ElMessage.success('已触发主控端重新下载'); loadList() }
else ElMessage.error(extractApiError(res?.data, '操作失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '操作失败')) }
}).catch(() => {})
}
const handleReloadOnHost = (row) => {
reloadTarget.value = row
if (embedded && injectedHostId?.value) {
reloadHostId.value = injectedHostId.value
} else {
reloadHostId.value = ''
}
reloadDialogVisible.value = true
if (!embedded || !injectedHostId?.value) {
if (!hostOptions.value.length) {
hostOptionsLoading.value = true
loadHostOptions().finally(() => { hostOptionsLoading.value = false })
}
}
}
const submitReloadOnHost = async () => {
if (!reloadHostId.value) return ElMessage.warning('请选择目标宿主机')
reloadLoading.value = true
try {
const formPayload = new FormData()
formPayload.append('service_id', serviceId.value)
formPayload.append('image_id', reloadTarget.value.id)
formPayload.append('host_id', reloadHostId.value)
const res = await reloadImageOnHost(formPayload)
if (res?.data?.code === 200) {
ElMessage.success('已触发重新下载到宿主机')
reloadDialogVisible.value = false
loadList()
} else {
ElMessage.error(extractApiError(res?.data, '操作失败'))
}
} catch (e) {
ElMessage.error('操作失败: ' + (e?.response?.data?.message || e.message))
} finally {
reloadLoading.value = false
}
}
const handleGoDetail = (row) => {
router.push({ path: '/virtualization/image-detail', query: { service_id: serviceId.value, service_name: serviceName.value, id: row.id } })
}
const handleDelete = (row) => {
ElMessageBox.confirm(`确定要删除镜像「${row.name}」吗?`, '删除确认', {
confirmButtonText: '确定删除', cancelButtonText: '取消', type: 'warning'
}).then(async () => {
try {
const res = await deleteImage({ service_id: serviceId.value, image_id: row.id })
if (res?.data?.code === 200) { ElMessage.success('删除成功'); loadList() }
else ElMessage.error(extractApiError(res?.data, '删除失败'))
} catch (e) { ElMessage.error(extractApiError(e?.response?.data, '删除失败')) }
}).catch(() => {})
}
const goBack = () => { router.push('/virtualization/kvm-service') }
onMounted(() => {
if (serviceId.value) {
loadList()
}
})
defineExpose({ loadList })
</script>
<style scoped>
.image-manage-container { padding: 20px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
.header-left { display: flex; align-items: center; gap: 16px; }
.header-info h3 { margin: 0; font-size: 18px; color: #303133; }
.sub-info { font-size: 13px; color: #909399; }
.header-right { display: flex; gap: 8px; }
.embedded-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; }
.filter-bar { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.pagination-wrapper { display: flex; justify-content: flex-end; margin-top: 16px; }
.mono-text { font-family: 'Consolas', monospace; color: #409eff; font-size: 13px; }
.host-status-section { margin-top: 8px; }
:deep(.el-table) { --el-table-header-bg-color: #fafafa; }
:deep(.el-table th) { font-weight: 600; color: #303133; font-size: 13px; }
</style>