qiuyuan 1601c8dc5a 1
2026-02-09 17:47:58 +08:00

709 lines
19 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="">全部状态</a-select-option>
<a-select-option v-for="[key, config] in instanceStatus" :value="key">{{ config.text }}</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="mockData" :columns="columns" bordered :pagination="paginationState" :loading="loading"
@change="onTableChange" :scroll="{ x: 'max-content' }">
<!-- 状态列 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<span :style="{ color: instanceStatus.get(record.status)?.color }">{{
instanceStatus.get(record.status)?.text
}}</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 === 'price_type'">
<span>
{{ payTypeDic.get(record.price_type) }}
</span>
</template>
<template v-else-if="column.key === 'ssh_link'">
<span>
{{
record.ssh_link
? JSON.parse(record.ssh_link)?.root_password|| 'N/A'
: 'N/A'
}}
</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="jupyterlab">
<template #icon>
<CheckCircleOutlined />
</template>
jupyterlab
</a-menu-item>
<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>
</a-modal>
<!-- 释放确认模态框 -->
<a-modal v-model:open="releaseModalVisible" title="确认释放" @ok="confirmRelease" @cancel="releaseModalVisible = false">
<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, CheckCircleOutlined } from '@ant-design/icons-vue'
import type { TableColumnType } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { hostCaseList } from '@/apis/admin'
import { releaseCase, reStartCase } from '@/apis/home'
import { payTypeDic, instanceStatus } from '@/utils/dict'
const router = useRouter()
// 状态管理
const filterStatus = ref('')
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([])
// 表格列定义
const columns = ref<TableColumnType[]>([
{ title: '实例ID', dataIndex: 'id', key: 'id', width: 100, fixed: 'left' },
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 200 },
{ title: '规格详情', dataIndex: 'spec', key: 'spec', width: 250 },
{ title: '健康状态', dataIndex: 'health_status', key: 'health_status', width: 250 },
{ title: '付费方式', dataIndex: 'price_type', key: 'price_type', width: 250 },
{ title: '释放时间', dataIndex: 'release_at', key: 'release_at', width: 250 },
{ title: '停机时间', dataIndex: 'down_at', key: 'down_at', width: 250 },
{ title: 'SSH登录', dataIndex: 'ssh_link', key: 'ssh_link', width: 250 },
{ title: '操作', key: 'actions', width: 150, fixed: 'right' }
])
const getDataList = async () => {
// 模拟数据加载
try {
loading.value = true
const params = {
page_num: paginationState.value.current,
page_size: paginationState.value.pageSize,
status: filterStatus.value,
name: searchKeyword.value
}
const res = await hostCaseList(params)
mockData.value = res.data
loading.value = false
} catch (error) {
console.log('获取实例列表失败:', error)
message.error('数据加载失败')
loading.value = false
}
}
getDataList()
// 健康状态文本映射
const getHealthStatusText = (status: string) => {
const map: Record<string, string> = {
Normal: '正常',
Abnormal: '异常'
}
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
case 'jupyterlab':
handleJupyterLab(record)
break
}
}
// 确认重置
const confirmReset = async () => {
if (!currentInstance.value) return
try {
const res = await reStartCase(currentInstance.value.id)
message.success({ content: '实例重置成功', key: 'reset' })
resetModalVisible.value = false
getDataList()
} catch (error: any) {
message.error(error)
resetModalVisible.value = false
}
}
// 确认释放
const confirmRelease = async () => {
if (!currentInstance.value) return
try {
const res = await releaseCase(currentInstance.value.id)
message.success({ content: '实例释放成功', key: 'release' })
releaseModalVisible.value = false
getDataList()
} catch (error: any) {
message.error(error)
releaseModalVisible.value = false
}
}
// 批量续费
const handleBatchRenew = () => {
message.info('批量续费功能')
}
// 刷新数据
const refreshData = () => {
getDataList()
}
// 搜索
const handleSearch = () => {
paginationState.value.current = 1
getDataList()
}
// 分页变化
const onTableChange = (pag: any) => {
paginationState.value = {
...paginationState.value,
current: pag.current,
pageSize: pag.pageSize
}
}
// 新建实例
const handleRent = () => {
router.push('/layout/market')
}
const handleJupyterLab = async (record: any) => {
try {
if (record.ssh_link) {
const linkObj = JSON.parse(record.ssh_link)
if (linkObj.proxy_host && linkObj.jupyter_token && linkObj.jupyter_port) {
const baseUrl = `http://${linkObj.proxy_host}:${linkObj.jupyter_port}/jupyter/lab?token=${linkObj.jupyter_token}`
console.log("====", baseUrl)
window.open(baseUrl, '_blank', 'noopener,noreferrer')
}
}
} catch (error) {
console.error('打开 JupyterLab 失败:', error)
message.error('打开 JupyterLab 失败,请稍后重试')
}
}
onMounted(() => {
paginationState.value.total = mockData.value.length
})
</script>
<style scoped lang="scss">
.instance-list {
padding: 20px;
background: #ffffff;
// 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-Normal {
color: #52c41a;
background: #f6ffed;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
display: inline-block;
}
// .health-Abnormal {
// color: #faad14;
// background: #fffbe6;
// padding: 4px 8px;
// border-radius: 4px;
// font-size: 12px;
// display: inline-block;
// }
.health-Abnormal {
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>