qiuyuan b55e6b26cc 1
2025-12-31 14:48:43 +08:00

825 lines
20 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="instance-list">
<!-- 顶部标题和操作区域 -->
<div class="header-section">
<div class="header-top">
<div class="header-left">
<span class="page-title">容器实例</span>
<span class="warning-tip">
<exclamation-circle-outlined class="warning-icon" />
实例连续关机15天会释放实例实例释放会导致数据清空且不可恢复释放前实例数据仍在
</span>
</div>
</div>
<div class="header-bottom">
<div class="header-actions">
<a-button type="primary" @click="handleRent" class="action-btn">新建实例</a-button>
<a-button @click="handleBatchRenew" class="action-btn">批量续费</a-button>
<a-button class="refresh-btn" @click="refreshData">
<reload-outlined />
</a-button>
</div>
<div class="header-filter">
<a-select
v-model:value="filterStatus"
placeholder="筛选状态"
style="width: 160px;"
size="large"
@change="handleSearch"
>
<a-select-option value="all">全部状态</a-select-option>
<a-select-option value="running">运行中</a-select-option>
<a-select-option value="stopped">已停止</a-select-option>
<a-select-option value="error">异常</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索实例名称/ID"
style="width: 240px; margin-left: 12px;"
size="large"
@search="handleSearch"
/>
</div>
</div>
</div>
<!-- 表格 -->
<div class="table-container">
<a-table
:dataSource="tableData"
:columns="columns"
bordered
:pagination="paginationState"
:loading="loading"
@change="onTableChange"
>
<!-- 状态列 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<span :class="`status-${record.status}`">
{{ getStatusText(record.status) }}
</span>
</template>
<!-- 规格详情列 -->
<template v-else-if="column.key === 'spec'">
<div class="spec-info">
<div class="spec-summary">
<div>{{ record.gpu_model }}</div>
<div class="spec-detail-trigger" @click="showSpecDetail(record)">
<eye-outlined /> 查看详情
</div>
</div>
</div>
</template>
<!-- 健康状态列 -->
<template v-else-if="column.key === 'health_status'">
<span :class="`health-${record.health_status}`">
{{ getHealthStatusText(record.health_status) }}
</span>
</template>
<!-- 操作列 -->
<template v-else-if="column.key === 'actions'">
<div class="action-buttons">
<a-button
v-if="record.status === 'running'"
type="link"
danger
size="small"
@click="handlePowerOff(record)"
>
关机
</a-button>
<a-button
v-else-if="record.status === 'stopped'"
type="link"
size="small"
@click="handlePowerOn(record)"
>
开机
</a-button>
<a-dropdown :trigger="['click']">
<template #overlay>
<a-menu @click="({ key }) => handleMenuClick(key, record)">
<a-menu-item key="reset">
<template #icon>
<redo-outlined />
</template>
容器重置
</a-menu-item>
<a-menu-item key="release" danger>
<template #icon>
<delete-outlined />
</template>
释放实例
</a-menu-item>
<!-- <a-menu-item key="monitor">
<template #icon>
<dashboard-outlined />
</template>
监控
</a-menu-item>
<a-menu-item key="log">
<template #icon>
<file-text-outlined />
</template>
日志
</a-menu-item> -->
</a-menu>
</template>
<a-button type="link" size="small">
更多
<down-outlined />
</a-button>
</a-dropdown>
</div>
</template>
</template>
</a-table>
</div>
<!-- 规格详情模态框 -->
<a-modal
v-model:open="specModalVisible"
title="规格详情"
width="500px"
:footer="null"
>
<div class="spec-detail-content">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="GPU型号">{{ specDetail.gpu_model }}</a-descriptions-item>
<a-descriptions-item label="GPU数量">{{ specDetail.gpu_count }}</a-descriptions-item>
<a-descriptions-item label="单卡显存">{{ specDetail.gpu_memory_gb }} GB</a-descriptions-item>
<a-descriptions-item label="CPU核数">{{ specDetail.cpu_cores }}</a-descriptions-item>
<a-descriptions-item label="内存">{{ specDetail.memory_mb }} MB</a-descriptions-item>
<a-descriptions-item label="系统盘">{{ specDetail.system_disk }} GB</a-descriptions-item>
<a-descriptions-item label="数据盘">{{ specDetail.data_disk || '无' }} GB</a-descriptions-item>
<a-descriptions-item label="网络带宽">{{ specDetail.bandwidth || '100' }} Mbps</a-descriptions-item>
</a-descriptions>
</div>
</a-modal>
<!-- 重置确认模态框 -->
<a-modal
v-model:open="resetModalVisible"
title="确认重置"
@ok="confirmReset"
@cancel="resetModalVisible = false"
>
<div class="modal-content">
<exclamation-circle-outlined class="warning-icon" style="color: #faad14; font-size: 24px; margin-right: 12px;" />
<div>
<p style="font-weight: bold; margin-bottom: 8px;">确认要重置容器实例吗</p>
<p style="color: #666; margin-bottom: 4px;"> 重置将清空容器内的所有数据</p>
<p style="color: #666; margin-bottom: 4px;"> 系统盘和数据盘将恢复初始状态</p>
<p style="color: #666;"> 实例配置和网络设置保持不变</p>
</div>
</div>
<template #okText>
<span style="color: #ff4d4f;">确认重置</span>
</template>
</a-modal>
<!-- 释放确认模态框 -->
<a-modal
v-model:open="releaseModalVisible"
title="确认释放"
@ok="confirmRelease"
@cancel="releaseModalVisible = false"
:ok-button-props="{ danger: true }"
>
<div class="modal-content">
<exclamation-circle-outlined class="warning-icon" style="color: #ff4d4f; font-size: 24px; margin-right: 12px;" />
<div>
<p style="font-weight: bold; margin-bottom: 8px;">确认要释放实例吗</p>
<p style="color: #ff4d4f; margin-bottom: 4px;"> 释放后实例将被永久删除</p>
<p style="color: #ff4d4f; margin-bottom: 4px;"> 所有数据将无法恢复</p>
<p style="color: #ff4d4f; margin-bottom: 4px;"> 网络配置IP地址将被回收</p>
<p style="color: #666;">请确保已备份重要数据</p>
</div>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import {
ExclamationCircleOutlined,
ReloadOutlined,
EyeOutlined,
DownOutlined,
RedoOutlined,
DeleteOutlined,
DashboardOutlined,
FileTextOutlined
} from '@ant-design/icons-vue'
import type { TableColumnType } from 'ant-design-vue'
import { message } from 'ant-design-vue'
const router = useRouter()
// 状态管理
const filterStatus = ref('all')
const searchKeyword = ref('')
const loading = ref(false)
const specModalVisible = ref(false)
const resetModalVisible = ref(false)
const releaseModalVisible = ref(false)
// 当前操作实例
const currentInstance = ref<any>(null)
const specDetail = ref({
gpu_model: '',
gpu_count: 0,
gpu_memory_gb: 0,
cpu_cores: 0,
memory_mb: 0,
system_disk: 0,
data_disk: 0,
bandwidth: 0
})
// 分页状态
const paginationState = ref({
current: 1,
pageSize: 10,
total: 0
})
// 模拟数据
const mockData = ref([
{
id: 'ins-2024010001',
name: 'AI训练实例-01',
status: 'running',
gpu_model: 'NVIDIA A100',
gpu_count: 2,
gpu_memory_gb: 80,
cpu_cores: 8,
memory_mb: 16384,
system_disk: 200,
data_disk: 500,
health_status: 'healthy',
price_type: '按量付费',
release_at: '2024-12-31',
down_at: '-',
ssh_link: 'ssh root@10.0.0.1',
bandwidth: 1000
},
{
id: 'ins-2024010002',
name: '推理服务实例',
status: 'stopped',
gpu_model: 'NVIDIA V100',
gpu_count: 1,
gpu_memory_gb: 32,
cpu_cores: 4,
memory_mb: 8192,
system_disk: 100,
data_disk: 200,
health_status: 'healthy',
price_type: '包月',
release_at: '2024-11-30',
down_at: '2024-10-15',
ssh_link: 'ssh root@10.0.0.2',
bandwidth: 500
},
{
id: 'ins-2024010003',
name: '开发测试实例',
status: 'running',
gpu_model: 'NVIDIA RTX 4090',
gpu_count: 1,
gpu_memory_gb: 24,
cpu_cores: 12,
memory_mb: 32768,
system_disk: 500,
data_disk: 1000,
health_status: 'warning',
price_type: '按量付费',
release_at: '2025-01-15',
down_at: '-',
ssh_link: 'ssh root@10.0.0.3',
bandwidth: 200
},
{
id: 'ins-2024010004',
name: '数据科学实例',
status: 'error',
gpu_model: 'NVIDIA T4',
gpu_count: 1,
gpu_memory_gb: 16,
cpu_cores: 8,
memory_mb: 16384,
system_disk: 150,
data_disk: 300,
health_status: 'error',
price_type: '包年',
release_at: '2024-12-15',
down_at: '2024-10-10',
ssh_link: 'ssh root@10.0.0.4',
bandwidth: 100
},
{
id: 'ins-2024010005',
name: '深度学习实例',
status: 'running',
gpu_model: 'NVIDIA A6000',
gpu_count: 4,
gpu_memory_gb: 48,
cpu_cores: 16,
memory_mb: 65536,
system_disk: 1000,
data_disk: 2000,
health_status: 'healthy',
price_type: '按量付费',
release_at: '2025-03-20',
down_at: '-',
ssh_link: 'ssh root@10.0.0.5',
bandwidth: 2000
}
])
// 表格列定义
const columns = ref<TableColumnType[]>([
{ title: '实例ID', dataIndex: 'id', key: 'id', width: 150 },
{ title: '名称', dataIndex: 'name', key: 'name', width: 180 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '规格详情', dataIndex: 'spec', key: 'spec', width: 200 },
{ title: '本地磁盘', dataIndex: 'system_disk', key: 'system_disk', width: 120 },
{ title: '健康状态', dataIndex: 'health_status', key: 'health_status', width: 120 },
{ title: '付费方式', dataIndex: 'price_type', key: 'price_type', width: 120 },
{ title: '释放时间', dataIndex: 'release_at', key: 'release_at', width: 120 },
{ title: '停机时间', dataIndex: 'down_at', key: 'down_at', width: 120 },
{ title: 'SSH登录', dataIndex: 'ssh_link', key: 'ssh_link', width: 150 },
{ title: '操作', key: 'actions', width: 150 }
])
// 计算表格数据(应用筛选)
const tableData = computed(() => {
let filtered = [...mockData.value]
// 状态筛选
if (filterStatus.value !== 'all') {
filtered = filtered.filter(item => item.status === filterStatus.value)
}
// 关键词搜索
if (searchKeyword.value) {
const keyword = searchKeyword.value.toLowerCase()
filtered = filtered.filter(item =>
item.id.toLowerCase().includes(keyword) ||
item.name.toLowerCase().includes(keyword)
)
}
// 更新分页总数
paginationState.value.total = filtered.length
// 分页处理
const start = (paginationState.value.current - 1) * paginationState.value.pageSize
const end = start + paginationState.value.pageSize
return filtered.slice(start, end)
})
// 状态文本映射
const getStatusText = (status: string) => {
const map: Record<string, string> = {
running: '运行中',
stopped: '已停止',
error: '异常'
}
return map[status] || status
}
// 健康状态文本映射
const getHealthStatusText = (status: string) => {
const map: Record<string, string> = {
healthy: '健康',
warning: '警告',
error: '异常'
}
return map[status] || status
}
// 显示规格详情
const showSpecDetail = (record: any) => {
specDetail.value = {
gpu_model: record.gpu_model,
gpu_count: record.gpu_count,
gpu_memory_gb: record.gpu_memory_gb,
cpu_cores: record.cpu_cores,
memory_mb: record.memory_mb,
system_disk: record.system_disk,
data_disk: record.data_disk,
bandwidth: record.bandwidth
}
specModalVisible.value = true
}
// 关机操作
const handlePowerOff = (record: any) => {
currentInstance.value = record
message.loading({ content: '正在关机...', key: 'powerOff', duration: 0 })
setTimeout(() => {
record.status = 'stopped'
record.down_at = new Date().toISOString().split('T')[0]
message.success({ content: '关机成功', key: 'powerOff' })
}, 1000)
}
// 开机操作
const handlePowerOn = (record: any) => {
currentInstance.value = record
message.loading({ content: '正在开机...', key: 'powerOn', duration: 0 })
setTimeout(() => {
record.status = 'running'
record.down_at = '-'
message.success({ content: '开机成功', key: 'powerOn' })
}, 1500)
}
// 更多菜单点击
const handleMenuClick = (key: string, record: any) => {
currentInstance.value = record
switch (key) {
case 'reset':
resetModalVisible.value = true
break
case 'release':
releaseModalVisible.value = true
break
case 'monitor':
message.info('跳转到监控页面')
break
case 'log':
message.info('跳转到日志页面')
break
}
}
// 确认重置
const confirmReset = () => {
if (!currentInstance.value) return
message.loading({ content: '正在重置实例...', key: 'reset', duration: 0 })
setTimeout(() => {
// 模拟重置操作
currentInstance.value.health_status = 'healthy'
message.success({ content: '实例重置成功', key: 'reset' })
resetModalVisible.value = false
}, 2000)
}
// 确认释放
const confirmRelease = () => {
if (!currentInstance.value) return
message.loading({ content: '正在释放实例...', key: 'release', duration: 0 })
setTimeout(() => {
// 从数据中移除
const index = mockData.value.findIndex(item => item.id === currentInstance.value.id)
if (index !== -1) {
mockData.value.splice(index, 1)
}
message.success({ content: '实例释放成功', key: 'release' })
releaseModalVisible.value = false
}, 2000)
}
// 批量续费
const handleBatchRenew = () => {
message.info('批量续费功能')
}
// 刷新数据
const refreshData = () => {
loading.value = true
setTimeout(() => {
loading.value = false
message.success('数据已刷新')
}, 1000)
}
// 搜索
const handleSearch = () => {
paginationState.value.current = 1
}
// 分页变化
const onTableChange = (pag: any) => {
paginationState.value = {
...paginationState.value,
current: pag.current,
pageSize: pag.pageSize
}
}
// 新建实例
const handleRent = () => {
router.push('/layout/admin/instanceCreate')
}
onMounted(() => {
paginationState.value.total = mockData.value.length
})
</script>
<style scoped lang="scss">
.instance-list {
padding: 24px;
background: #f5f7fa;
min-height: calc(100vh - 48px);
}
.header-section {
background: #fff;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid #e8e8e8;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 16px;
.header-left {
display: flex;
align-items: flex-start;
gap: 16px;
flex: 1;
min-width: 300px;
.page-title {
font-size: 20px;
font-weight: 600;
color: #1f2d3d;
line-height: 1.4;
white-space: nowrap;
}
.warning-tip {
display: flex;
align-items: flex-start;
color: #ff7f00;
font-size: 14px;
line-height: 1.4;
flex: 1;
margin-top: 4px;
.warning-icon {
margin-right: 8px;
font-size: 16px;
margin-top: 2px;
flex-shrink: 0;
}
}
}
}
.header-bottom {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
.header-actions {
display: flex;
gap: 12px;
align-items: center;
.action-btn {
height: 36px;
padding: 0 16px;
font-weight: 500;
}
.refresh-btn {
height: 36px;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d9d9d9;
border-radius: 6px;
&:hover {
border-color: #1890ff;
color: #1890ff;
}
}
}
.header-filter {
display: flex;
gap: 12px;
align-items: center;
}
}
.table-container {
background: #fff;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e8e8e8;
:deep(.ant-table) {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
color: #1f2d3d;
}
}
}
.spec-info {
.spec-summary {
// display: flex;
justify-content: space-between;
align-items: center;
.spec-detail-trigger {
color: #1890ff;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
&:hover {
color: #40a9ff;
}
}
}
}
.spec-detail-content {
padding: 8px 0;
:deep(.ant-descriptions-item-label) {
font-weight: 500;
background: #fafafa;
}
}
.action-buttons {
display: flex;
align-items: center;
gap: 8px;
:deep(.ant-btn-link) {
padding: 0;
height: auto;
}
}
// 状态样式
.status-running {
color: #52c41a;
background: #f6ffed;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: inline-block;
}
.status-stopped {
color: #fa8c16;
background: #fff7e6;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: inline-block;
}
.status-error {
color: #ff4d4f;
background: #fff2f0;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: inline-block;
}
// 健康状态样式
.health-healthy {
color: #52c41a;
background: #f6ffed;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: inline-block;
}
.health-warning {
color: #faad14;
background: #fffbe6;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: inline-block;
}
.health-error {
color: #ff4d4f;
background: #fff2f0;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: inline-block;
}
.pagination-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 24px;
padding: 16px 20px;
background: #fff;
border-radius: 8px;
border: 1px solid #e8e8e8;
.pagination-left {
.total-text {
color: #8c8c8c;
font-size: 14px;
}
}
.pagination-center {
flex: 1;
display: flex;
justify-content: center;
}
}
.modal-content {
display: flex;
align-items: flex-start;
}
// 响应式设计
@media (max-width: 1200px) {
.header-top {
.header-left {
flex-direction: column;
gap: 12px;
}
}
}
@media (max-width: 768px) {
.instance-list {
padding: 16px;
}
.header-top {
flex-direction: column;
align-items: stretch;
.header-left {
min-width: auto;
}
}
.header-bottom {
flex-direction: column;
align-items: stretch;
.header-actions {
justify-content: flex-start;
}
.header-filter {
justify-content: flex-start;
}
}
.pagination-container {
flex-direction: column;
gap: 16px;
.pagination-left {
width: 100%;
text-align: center;
}
}
}
</style>