This commit is contained in:
qiuyuan 2025-12-31 14:48:43 +08:00
parent a23bff7a3d
commit b55e6b26cc
3 changed files with 601 additions and 170 deletions

View File

@ -1,8 +1,5 @@
<template>
<div class="withdrawal-management">
<!-- 页面标题 -->
<a-page-header title="提现管理" />
<!-- 提现账户信息卡片 -->
<a-row :gutter="24" class="mb-6">

View File

@ -68,11 +68,11 @@ const menuItems: MenuItem[] = [
{ path: '/layout/admin/exchange', name: '算力点兑换', visible: false, disabled: false },
{ path: '/layout/admin/myMoneyDetail', name: '消费明细', visible: false, disabled: false },
{ path: '/layout/admin/myOrder', name: '订单明细', visible: true, disabled: false },
{ path: '/layout/admin/flow', name: '账单明细', visible: true, disabled: false },
{ path: '/layout/admin/coupon', name: '优惠券(待开发)', disabled: true, visible: true },
// { path: '/layout/admin/flow', name: '', visible: true, disabled: false },
// { path: '/layout/admin/coupon', name: '()', disabled: true, visible: true },
{ path: '/layout/admin/voucher', name: '代金券(待开发)', disabled: true, visible: true },
{ path: '/layout/admin/contract', name: '合同(待开发)', disabled: true, visible: true },
// { path: '/layout/admin/voucher', name: '()', disabled: true, visible: true },
// { path: '/layout/admin/contract', name: '()', disabled: true, visible: true },
],
},
{

View File

@ -7,134 +7,538 @@
<span class="page-title">容器实例</span>
<span class="warning-tip">
<exclamation-circle-outlined class="warning-icon" />
实例连续关机15天会释放实例实例释放会导致数据清空且不可恢复释放前实例数据在
实例连续关机15天会释放实例实例释放会导致数据清空且不可恢复释放前实例数据
</span>
</div>
<!-- <div class="header-quick-actions">
<span class="quick-action-item">
<bell-outlined class="quick-action-icon" />
订阅GPU通知
</span>
<span class="quick-action-item">
<key-outlined class="quick-action-icon" />
设置密钥登录
</span>
<span class="quick-action-item">
<appstore-outlined class="quick-action-icon" />
小程序管理实例
</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="handleRenew" class="action-btn">批量续费</a-button> -->
<!-- <a-button class="refresh-btn">
<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> -->
</a-button>
</div>
<div class="header-filter">
<a-select placeholder="筛选标签" style="width: 160px;" size="large">
<a-select-option value="all">全部标签</a-select-option>
<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 placeholder="搜索实例名称/ID" style="width: 240px; margin-left: 12px;" size="large"
@search="onSearch" />
<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="listData" :columns="columns" bordered :pagination="paginationState" @change="onTableChange">
<a-table
:dataSource="tableData"
:columns="columns"
bordered
:pagination="paginationState"
:loading="loading"
@change="onTableChange"
>
<!-- 状态列 -->
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'gpu_model'">
<div>
{{ 'GPU型号:' + record.gpu_model,'GPU数量:' + record.gpu_count ,'单卡显存GB:' + record.gpu_memory_gb , 'CPU核数:' + record.cpu_cores , '内存MB:' + record.memory_mb}}
<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, onBeforeMount } from 'vue'
import { usePagination } from '@/hooks'
import { hostCaseList } from '@/apis/admin'
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()
import dayjs from 'dayjs'
const { listData, loading, showLoading, hideLoading, paginationState, resetPagination, searchFormData } = usePagination()
const columns = ref([
{ title: '实例ID', dataIndex: 'id', key: 'id' },
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '规格详情', dataIndex: 'gpu_model', key: 'gpu_model' },
{ title: '本地磁盘', dataIndex: 'system_disk', key: 'system_disk' },
{ title: '健康状态', dataIndex: 'health_status', key: 'health_status' },
{ title: '付费方式', dataIndex: 'price_type', key: 'price_type' },
{ title: '释放时间', dataIndex: 'release_at', key: 'release_at' },
{ title: '停机时间', dataIndex: 'down_at', key: 'down_at' },
{ title: 'SSH登陆', dataIndex: 'ssh_link', key: 'ssh_link' },
{ title: '操作', dataIndex: 'aciton', key: 'aciton' },
])
onBeforeMount(() => {
getPageList()
//
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 getPageList = async () => {
try {
const { pageSize, current } = paginationState
const res: any = await hostCaseList({ pageSize: pageSize, pageNum: current });
listData.value = res.list;
paginationState.total = res?.Total;
} catch (error: any) {
console.error('产品优势请求失败:', error);
//
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
}
}
/**
* 分页
*/
function onTableChange({ current, pageSize }) {
paginationState.current = current
paginationState.pageSize = pageSize
getPageList()
}
/**
* 搜索
*/
function handleSearch() {
resetPagination()
getPageList()
}
/**
* 重置
*/
function handleResetSearch() {
searchFormData.value = {}
resetPagination()
getPageList()
//
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 handleRent = () => {
router.push('/layout/admin/instanceCreate');
//
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: 100vh;
min-height: calc(100vh - 48px);
}
.header-section {
@ -185,33 +589,6 @@ const handleRent = () => {
}
}
}
.header-quick-actions {
display: flex;
gap: 24px;
align-items: center;
flex-wrap: wrap;
.quick-action-item {
display: flex;
align-items: center;
color: #1890ff;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: color 0.3s;
white-space: nowrap;
&:hover {
color: #40a9ff;
}
.quick-action-icon {
margin-right: 6px;
font-size: 14px;
}
}
}
}
.header-bottom {
@ -240,6 +617,13 @@ const handleRent = () => {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #d9d9d9;
border-radius: 6px;
&:hover {
border-color: #1890ff;
color: #1890ff;
}
}
}
@ -255,47 +639,111 @@ const handleRent = () => {
border-radius: 8px;
overflow: hidden;
border: 1px solid #e8e8e8;
:deep(.ant-table) {
.ant-table-thead > tr > th {
background: #fafafa;
font-weight: 600;
color: #1f2d3d;
}
}
}
.instance-table {
.empty-state {
padding: 60px 20px;
text-align: center;
.spec-info {
.spec-summary {
// display: flex;
justify-content: space-between;
align-items: center;
.empty-icon {
font-size: 64px;
color: #d9d9d9;
margin-bottom: 16px;
}
.spec-detail-trigger {
color: #1890ff;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
.empty-text {
font-size: 16px;
color: #8c8c8c;
margin-bottom: 8px;
}
.empty-desc {
font-size: 14px;
color: #bfbfbf;
&:hover {
color: #40a9ff;
}
}
}
}
//
:deep(.status-running) {
.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;
}
:deep(.status-stopped) {
.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 {
@ -320,16 +768,11 @@ const handleRent = () => {
display: flex;
justify-content: center;
}
}
.pagination-right {
.modal-content {
display: flex;
align-items: center;
.goto-text {
color: #8c8c8c;
font-size: 14px;
}
}
align-items: flex-start;
}
//
@ -339,10 +782,6 @@ const handleRent = () => {
flex-direction: column;
gap: 12px;
}
.header-quick-actions {
gap: 16px;
}
}
}
@ -358,10 +797,6 @@ const handleRent = () => {
.header-left {
min-width: auto;
}
.header-quick-actions {
justify-content: flex-start;
}
}
.header-bottom {
@ -381,10 +816,9 @@ const handleRent = () => {
flex-direction: column;
gap: 16px;
.pagination-left,
.pagination-right {
.pagination-left {
width: 100%;
justify-content: center;
text-align: center;
}
}
}