Files
tiantian-system/app/pages/developer/resources/servers.vue
2026-04-08 17:10:58 +08:00

1040 lines
32 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="dev-page">
<!-- 工具栏 -->
<div class="page-toolbar">
<div class="toolbar-left">
<h3 class="page-title">服务器管理</h3>
<a-tag color="blue">{{ list.length }} </a-tag>
<a-select
v-model:value="selectedAppId"
placeholder="全部应用"
allow-clear
style="width: 180px"
@change="loadList"
>
<a-select-option v-for="app in appOptions" :key="app.productId" :value="app.productId">
{{ app.productName }}
</a-select-option>
</a-select>
</div>
<div class="toolbar-right">
<a-input-search
v-model:value="searchText"
placeholder="搜索名称 / IP"
style="width: 200px"
allow-clear
@search="loadList"
/>
<PermissionGuard :app-id="selectedAppId" permission="canEditResource" :show-tip="true">
<a-button type="primary" @click="showAdd = true">
<template #icon><PlusOutlined /></template>
添加服务器
</a-button>
</PermissionGuard>
</div>
</div>
<!-- 服务器卡片列表 -->
<div v-if="loading" class="card-loading">
<a-spin size="large" />
</div>
<div v-else-if="list.length === 0" class="card-empty">
<a-empty description="暂无服务器">
<template v-if="!selectedAppId || getAppPermission(selectedAppId)?.canEditResource">
<a-button type="primary" @click="showAdd = true">
<template #icon><PlusOutlined /></template>
添加服务器
</a-button>
</template>
</a-empty>
</div>
<div v-else class="server-grid">
<div
v-for="item in list"
:key="item.resourceId"
class="server-card"
:class="{ 'card-expired': item.status === 'expired' }"
>
<!-- 卡片头部 -->
<div class="card-header">
<div class="card-header-left">
<div class="server-name">{{ item.name }}</div>
<a-badge
:status="item.status === 'running' ? 'success' : item.status === 'stopped' ? 'default' : 'warning'"
:text="statusLabel[item.status] || item.status"
/>
</div>
<a-dropdown :trigger="['click']">
<a-button type="text" size="small">
<template #icon><EllipsisOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click="({ key }: any) => handleMenuAction(key, item)">
<a-menu-item v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" key="edit">
<template #icon><EditOutlined />编辑</template>
</a-menu-item>
<a-menu-item v-if="item.sshUsername && (item.accessLevel ?? 1) >= 2" key="ssh">
<template #icon><CodeOutlined />SSH 连接</template>
</a-menu-item>
<a-menu-item v-if="item.panelPort && (item.accessLevel ?? 1) >= 2" key="panel">
<template #icon><DashboardOutlined />1Panel 面板</template>
</a-menu-item>
<a-menu-divider v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" />
<a-menu-item v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" key="delete" danger>
<template #icon><DeleteOutlined />移除</template>
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
<!-- 卡片信息区 -->
<div class="card-info">
<div class="info-row">
<span class="info-label"><GlobalOutlined /></span>
<span class="info-value ip-value" @click="copyText(item.ip!)">{{ item.ip }}</span>
</div>
<div v-if="item.provider" class="info-row">
<span class="info-label"><CloudOutlined /></span>
<span class="info-value">{{ providerLabel[item.provider!] || item.provider }}</span>
<span v-if="item.appName" class="info-extra">{{ item.appName }}</span>
</div>
<!-- 连接信息权限 >= 2 可见用户名 -->
<div v-if="item.sshUsername && (item.accessLevel ?? 1) >= 2" class="info-row">
<span class="info-label"><CodeOutlined /></span>
<span class="info-value">{{ item.sshUsername }}@{{ item.ip }}</span>
</div>
<div v-if="item.expireAt" class="info-row">
<span class="info-label"><ClockCircleOutlined /></span>
<span class="info-value" :class="{ 'text-danger': isExpiringSoon(item.expireAt) }">{{ item.expireAt }}</span>
</div>
<!-- 权限提示 Owner 时显示 -->
<div v-if="!item.isOwner" class="info-row access-hint">
<LockOutlined style="color: #faad14; font-size: 12px;" />
<span class="access-hint-text">{{ (item.accessLevel ?? 1) >= 2 ? '协作者(开发者)' : '协作者(只读)' }}</span>
</div>
</div>
<!-- 端口概览 -->
<div class="card-ports">
<a-tooltip title="SSH 端口">
<a-tag>SSH:{{ item.sshPort || 22 }}</a-tag>
</a-tooltip>
<a-tooltip title="MySQL 端口">
<a-tag color="orange">MySQL:{{ item.mysqlPort || 3306 }}</a-tag>
</a-tooltip>
<a-tooltip title="PostgreSQL 端口">
<a-tag color="geekblue">PG:{{ item.pgPort || 5432 }}</a-tag>
</a-tooltip>
<a-tooltip v-if="item.panelPort" title="1Panel 端口">
<a-tag color="purple">Panel:{{ item.panelPort }}</a-tag>
</a-tooltip>
</div>
<!-- 1Panel 状态信息Owner developer 角色可见 -->
<div v-if="(item.accessLevel ?? 1) >= 2 && item.panelPort" class="card-status">
<div class="status-header">
<span class="status-title">运行状态</span>
<a-button type="link" size="small" :loading="statusLoading[item.resourceId!]" @click="refreshStatus(item)">
<template #icon><ReloadOutlined :spin="statusLoading[item.resourceId!]" /></template>
{{ serverStatusMap[item.resourceId!] ? '刷新' : '获取' }}
</a-button>
</div>
<div v-if="serverStatusMap[item.resourceId!]" class="status-content">
<!-- 在线状态 -->
<div class="status-online" :class="{ 'offline': serverStatusMap[item.resourceId!].online === 0 }">
<span class="status-dot" :class="{ 'offline': serverStatusMap[item.resourceId!].online === 0 }"></span>
<span>{{ serverStatusMap[item.resourceId!].online === 1 ? '在线' : '离线' }}</span>
<span v-if="serverStatusMap[item.resourceId!].uptime" class="status-uptime">{{ serverStatusMap[item.resourceId!].uptime }}</span>
</div>
<!-- 资源使用条 -->
<div class="status-bars">
<div class="status-bar-item">
<div class="status-bar-label">
<DesktopOutlined /> CPU
<span class="status-bar-value">{{ serverStatusMap[item.resourceId!].cpu }}%</span>
</div>
<a-progress :percent="Number(serverStatusMap[item.resourceId!].cpu)" :show-info="false" size="small" stroke-color="#1890ff" />
</div>
<div class="status-bar-item">
<div class="status-bar-label">
<DatabaseFilled /> 内存
<span class="status-bar-value">{{ serverStatusMap[item.resourceId!].memory }}%</span>
</div>
<a-progress :percent="Number(serverStatusMap[item.resourceId!].memory)" :show-info="false" size="small" stroke-color="#722ed1" />
</div>
<div class="status-bar-item">
<div class="status-bar-label">
<FolderOutlined /> 磁盘
<span class="status-bar-value">{{ serverStatusMap[item.resourceId!].disk }}%</span>
</div>
<a-progress :percent="Number(serverStatusMap[item.resourceId!].disk)" :show-info="false" size="small" stroke-color="#52c41a" />
</div>
</div>
</div>
<div v-else-if="statusErrorMap[item.resourceId!]" class="status-error">
<ExclamationCircleOutlined /> {{ statusErrorMap[item.resourceId!] }}
</div>
<div v-else class="status-empty">
点击获取查看服务器状态
</div>
</div>
<!-- 卡片操作区 -->
<div class="card-actions">
<!-- 1Panel需要有连接权限才能打开 -->
<a-button
v-if="item.panelPort && (item.accessLevel ?? 1) >= 2"
type="primary"
size="small"
ghost
@click="open1Panel(item)"
>
<template #icon><DashboardOutlined /></template>
1Panel
</a-button>
<!-- SSH 连接需要有连接权限用户名可见 -->
<a-button
v-if="item.sshUsername && (item.accessLevel ?? 1) >= 2"
size="small"
@click="openSsh(item)"
>
<template #icon><CodeOutlined /></template>
SSH
</a-button>
<!-- 编辑/删除Owner 或应用有资源编辑权限 -->
<a-button v-if="item.isOwner || getAppPermission(item.appId)?.canEditResource" size="small" @click="handleEdit(item)">
<template #icon><EditOutlined /></template>
编辑
</a-button>
</div>
</div>
</div>
<!-- 添加/编辑弹窗 -->
<a-modal
v-model:open="showAdd"
:title="editRecord ? '编辑服务器' : '添加服务器'"
ok-text="保存"
cancel-text="取消"
:confirm-loading="saveLoading"
width="580px"
@ok="handleSave"
@cancel="resetForm"
>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical" style="margin-top: 8px">
<!-- 基本信息 -->
<div class="form-section-header">
<SettingOutlined />
<span>基本信息</span>
</div>
<a-row :gutter="16">
<a-col :span="14">
<a-form-item label="服务器名称" name="name">
<a-input v-model:value="form.name" placeholder="如prod-server-01" />
</a-form-item>
</a-col>
<a-col :span="10">
<a-form-item label="IP 地址" name="ip">
<a-input v-model:value="form.ip" placeholder="公网 IP" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="服务商" name="provider">
<a-select v-model:value="form.provider" placeholder="请选择" allow-clear>
<a-select-option value="tencent">腾讯云</a-select-option>
<a-select-option value="aliyun">阿里云</a-select-option>
<a-select-option value="huawei">华为云</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="关联应用" name="appId">
<a-select v-model:value="form.appId" placeholder="可选" allow-clear>
<a-select-option v-for="app in appOptions" :key="app.productId" :value="app.productId">
{{ app.productName }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="到期时间" name="expireAt">
<a-date-picker v-model:value="form.expireAt" value-format="YYYY-MM-DD" style="width: 100%" placeholder="可选" />
</a-form-item>
</a-col>
</a-row>
<!-- SSH 连接信息 -->
<div class="form-section-header">
<CodeOutlined />
<span>SSH 连接</span>
<a-button
v-if="!editRecord"
type="link"
size="small"
:loading="testingSsh"
style="margin-left: auto"
@click="handleTestSsh"
>
测试连接
</a-button>
</div>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="SSH 端口" name="sshPort">
<a-input-number v-model:value="form.sshPort" :min="1" :max="65535" :precision="0" style="width: 100%" placeholder="22" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="用户名" name="sshUsername">
<a-input v-model:value="form.sshUsername" placeholder="root" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="密码" name="sshPassword">
<a-input-password v-model:value="form.sshPassword" placeholder="SSH 密码" />
</a-form-item>
</a-col>
</a-row>
<!-- 1Panel 面板 -->
<div class="form-section-header">
<DashboardOutlined />
<span>1Panel 面板</span>
<span class="form-section-hint">可选填写后可一键打开面板</span>
</div>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="面板端口" name="panelPort">
<a-input-number v-model:value="form.panelPort" :min="1" :max="65535" :precision="0" style="width: 100%" placeholder="默认 8888" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="用户名" name="panelUsername">
<a-input v-model:value="form.panelUsername" placeholder="面板登录用户名" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="密码" name="panelPassword">
<a-input-password v-model:value="form.panelPassword" placeholder="面板登录密码" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="24">
<a-form-item label="安全入口" name="panelPath">
<a-input v-model:value="form.panelPath" placeholder="如 /abc1231Panel 安全入口路径)" />
</a-form-item>
</a-col>
</a-row>
<!-- 数据库连接信息 -->
<div class="form-section-header">
<DatabaseOutlined />
<span>数据库连接</span>
<a-button
v-if="!editRecord"
type="link"
size="small"
:loading="testingConnection"
style="margin-left: 4px"
@click="handleTestConnection"
>
测试 MySQL
</a-button>
<a-button
v-if="!editRecord"
type="link"
size="small"
:loading="testingPgConnection"
@click="handleTestPgConnection"
>
测试 PG
</a-button>
</div>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="MySQL 端口" name="mysqlPort">
<a-input-number v-model:value="form.mysqlPort" :min="1" :max="65535" :precision="0" style="width: 100%" placeholder="3306" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="PG 端口" name="pgPort">
<a-input-number v-model:value="form.pgPort" :min="1" :max="65535" :precision="0" style="width: 100%" placeholder="5432" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="管理员用户名" name="adminUsername">
<a-input v-model:value="form.adminUsername" placeholder="如 root / postgres" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="管理员密码" name="adminPassword">
<a-input-password v-model:value="form.adminPassword" placeholder="管理员密码(用于远程建库)" />
</a-form-item>
</a-col>
</a-row>
<!-- 备注 -->
<a-form-item label="备注" name="remark" style="margin-bottom: 0">
<a-textarea v-model:value="form.remark" :rows="2" placeholder="可选备注" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script setup lang="ts">
import {
PlusOutlined, EditOutlined, DeleteOutlined, EllipsisOutlined,
CodeOutlined, DashboardOutlined, GlobalOutlined, CloudOutlined,
ClockCircleOutlined, SettingOutlined, DatabaseOutlined, LockOutlined,
ReloadOutlined, DesktopOutlined, DatabaseFilled, FolderOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import dayjs from 'dayjs'
import { pageAppResource, addAppResource, updateAppResource, removeAppResource, testConnection, testSshConnection, getServerStatus, type ServerStatus } from '@/api/app/appResource'
import { getMyAccessibleApps } from '@/api/app/appProduct'
import type { AppResource } from '@/api/app/appResource/model'
import { enrichResourcesWithPermission } from '@/composables/useResourceAccess'
import { useAppPermission } from '@/composables/useAppPermission'
import PermissionGuard from '@/components/developer/PermissionGuard.vue'
definePageMeta({ layout: 'developer' })
useHead({ title: '服务器管理 - 开发者中心' })
const IPV4_REGEX = /^(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)){3}$/
function isValidIpAddress(value: string) {
const input = value.trim()
if (!input) return false
if (IPV4_REGEX.test(input)) return true
try { new URL(`http://[${input}]`); return true } catch { return false }
}
const loading = ref(false)
const saveLoading = ref(false)
const showAdd = ref(false)
const testingConnection = ref(false)
const testingPgConnection = ref(false)
const testingSsh = ref(false)
const searchText = ref('')
const editRecord = ref<AppResource | null>(null)
const formRef = ref()
// 服务器状态管理
const serverStatusMap = ref<Record<number, ServerStatus>>({})
const statusLoading = ref<Record<number, boolean>>({})
const statusErrorMap = ref<Record<number, string>>({})
let statusRefreshTimer: ReturnType<typeof setInterval> | null = null
const form = reactive({
name: '',
ip: '',
sshPort: 22 as number | undefined,
sshUsername: '',
sshPassword: '',
mysqlPort: 3306 as number | undefined,
pgPort: 5432 as number | undefined,
panelPort: undefined as number | undefined,
panelUsername: '',
panelPassword: '',
panelPath: '',
adminUsername: '',
adminPassword: '',
provider: undefined as string | undefined,
appId: undefined as number | undefined,
expireAt: null as any,
remark: '',
})
const rules = {
name: [{ required: true, message: '请输入服务器名称' }],
ip: [
{ required: true, message: '请输入 IP 地址' },
{
validator: async (_rule: any, value: string) => {
if (!value || !value.trim()) return Promise.resolve()
if (isValidIpAddress(value)) return Promise.resolve()
return Promise.reject(new Error('请输入合法的 IP 地址'))
},
trigger: ['blur', 'change'],
},
],
}
const statusLabel: Record<string, string> = {
running: '运行中',
stopped: '已停止',
expired: '已过期',
pending: '配置中',
}
const providerLabel: Record<string, string> = {
tencent: '腾讯云',
aliyun: '阿里云',
huawei: '华为云',
other: '其他',
}
const list = ref<AppResource[]>([])
const appOptions = ref<any[]>([])
const { getAppPermission, accessibleAppIds } = useAppPermission()
const selectedAppId = ref<number | undefined>(undefined)
// 判断是否即将到期30天内
function isExpiringSoon(dateStr: string) {
if (!dateStr) return false
return dayjs(dateStr).diff(dayjs(), 'day') <= 30
}
// 复制文本到剪贴板
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text)
message.success('已复制: ' + text)
} catch {
message.error('复制失败')
}
}
// 打开 1Panel 面板
function open1Panel(item: AppResource) {
const port = item.panelPort || 8888
const path = item.panelPath || ''
const url = `http://${item.ip}:${port}${path}`
window.open(url, '_blank')
}
// 打开 SSH 链接
function openSsh(item: AppResource) {
const port = item.sshPort || 22
const user = item.sshUsername || 'root'
const sshUrl = `ssh://${user}@${item.ip}:${port}`
window.open(sshUrl, '_blank')
}
// 菜单操作
function handleMenuAction(key: string, item: AppResource) {
if (key === 'edit') handleEdit(item)
else if (key === 'ssh') openSsh(item)
else if (key === 'panel') open1Panel(item)
else if (key === 'delete') {
Modal.confirm({
title: '确定要移除此服务器?',
content: `将移除「${item.name}」(${item.ip})`,
okText: '确定移除',
okType: 'danger',
cancelText: '取消',
onOk: () => handleDelete(item.resourceId!),
})
}
}
// 加载可访问的应用下拉列表(按权限过滤)
async function loadAppOptions() {
try {
appOptions.value = await getMyAccessibleApps()
}
catch {
appOptions.value = []
}
}
async function loadList() {
loading.value = true
try {
const result = await pageAppResource({
resourceType: 'server',
keywords: searchText.value || undefined,
appId: selectedAppId.value,
page: 1,
limit: 200,
})
list.value = enrichResourcesWithPermission(result?.list ?? [])
// 加载完成后自动获取有1Panel配置的服务器状态
autoFetchServerStatus()
} catch (e: any) {
message.error(e.message || '加载失败')
} finally {
loading.value = false
}
}
// 自动获取所有有1Panel配置的服务器状态developer 及以上角色可查看)
async function autoFetchServerStatus() {
const serversWithPanel = list.value.filter(s => (s.accessLevel ?? 1) >= 2 && s.panelPort)
for (const server of serversWithPanel) {
if (serverStatusMap.value[server.resourceId!]) {
// 已有状态的服务器30秒自动刷新
} else {
// 没有状态的服务器,获取一次
await fetchServerStatus(server)
}
}
}
// 获取单个服务器状态
async function fetchServerStatus(item: AppResource) {
if (!item.resourceId || !item.panelPort) return
statusLoading.value[item.resourceId] = true
delete statusErrorMap.value[item.resourceId]
try {
const status = await getServerStatus(item.resourceId)
serverStatusMap.value[item.resourceId] = status
} catch (e: any) {
statusErrorMap.value[item.resourceId] = e.message || '获取状态失败'
} finally {
statusLoading.value[item.resourceId] = false
}
}
// 手动刷新单个服务器状态
async function refreshStatus(item: AppResource) {
await fetchServerStatus(item)
}
// 清理状态(资源删除时)
function clearServerStatus(resourceId: number) {
delete serverStatusMap.value[resourceId]
delete statusLoading.value[resourceId]
delete statusErrorMap.value[resourceId]
}
function handleEdit(record: AppResource) {
if (!record.isOwner) {
message.warning('只有资源创建者才能编辑')
return
}
editRecord.value = record
Object.assign(form, {
name: record.name,
ip: record.ip,
sshPort: record.sshPort || 22,
sshUsername: record.sshUsername || '',
sshPassword: record.sshPassword || '',
mysqlPort: record.mysqlPort || 3306,
pgPort: record.pgPort || 5432,
panelPort: record.panelPort || undefined,
panelUsername: record.panelUsername || '',
panelPassword: record.panelPassword || '',
panelPath: record.panelPath || '',
adminUsername: record.adminUsername || '',
adminPassword: record.adminPassword || '',
provider: record.provider,
appId: record.appId ? Number(record.appId) : undefined,
expireAt: record.expireAt || null,
remark: record.remark,
resourceId: record.resourceId,
})
showAdd.value = true
}
async function handleDelete(resourceId: number) {
const item = list.value.find(r => r.resourceId === resourceId)
if (item && !item.isOwner) {
message.warning('只有资源创建者才能删除')
return
}
try {
await removeAppResource(resourceId)
message.success('已移除')
clearServerStatus(resourceId) // 清理状态缓存
loadList()
} catch (e: any) {
message.error(e.message || '删除失败')
}
}
async function handleSave() {
await formRef.value?.validate()
saveLoading.value = true
try {
const payload: AppResource = {
resourceType: 'server',
name: form.name,
ip: form.ip.trim(),
sshPort: form.sshPort || 22,
sshUsername: form.sshUsername || undefined,
sshPassword: form.sshPassword || undefined,
mysqlPort: form.mysqlPort || 3306,
pgPort: form.pgPort || 5432,
panelPort: form.panelPort || undefined,
panelUsername: form.panelUsername || undefined,
panelPassword: form.panelPassword || undefined,
panelPath: form.panelPath || undefined,
adminUsername: form.adminUsername || undefined,
adminPassword: form.adminPassword || undefined,
provider: form.provider,
appId: form.appId ? Number(form.appId) : undefined,
expireAt: form.expireAt ? dayjs(form.expireAt).format('YYYY-MM-DD') : undefined,
remark: form.remark,
}
if (editRecord.value) {
payload.resourceId = editRecord.value.resourceId
await updateAppResource(payload)
message.success('保存成功')
} else {
await addAppResource(payload)
message.success('添加成功')
}
showAdd.value = false
resetForm()
loadList()
} catch (e: any) {
message.error(e.message || '操作失败')
} finally {
saveLoading.value = false
}
}
async function handleTestConnection() {
if (!form.ip) { message.warning('请先填写 IP 地址'); return }
testingConnection.value = true
try {
const result = await testConnection({
host: form.ip.trim(),
port: form.mysqlPort || 3306,
dbType: 'MySQL',
username: form.adminUsername,
password: form.adminPassword,
})
message[result.success ? 'success' : 'error'](result.detail || result.message)
} catch (e: any) {
message.error(e.message || '连接测试失败')
} finally {
testingConnection.value = false
}
}
async function handleTestPgConnection() {
if (!form.ip) { message.warning('请先填写 IP 地址'); return }
testingPgConnection.value = true
try {
const result = await testConnection({
host: form.ip.trim(),
port: form.pgPort || 5432,
dbType: 'PostgreSQL',
username: form.adminUsername,
password: form.adminPassword,
})
message[result.success ? 'success' : 'error'](result.detail || result.message)
} catch (e: any) {
message.error(e.message || '连接测试失败')
} finally {
testingPgConnection.value = false
}
}
async function handleTestSsh() {
if (!form.ip) { message.warning('请先填写 IP 地址'); return }
if (!form.sshUsername || !form.sshPassword) { message.warning('请先填写 SSH 用户名和密码'); return }
testingSsh.value = true
try {
const result = await testSshConnection({
host: form.ip.trim(),
port: form.sshPort || 22,
username: form.sshUsername,
password: form.sshPassword,
})
message[result.success ? 'success' : 'error'](result.detail || result.message)
} catch (e: any) {
message.error(e.message || 'SSH 连接测试失败')
} finally {
testingSsh.value = false
}
}
function resetForm() {
editRecord.value = null
formRef.value?.resetFields()
Object.assign(form, {
name: '', ip: '', sshPort: 22, sshUsername: '', sshPassword: '',
mysqlPort: 3306, pgPort: 5432, panelPort: undefined,
panelUsername: '', panelPassword: '', panelPath: '',
adminUsername: '', adminPassword: '', provider: undefined, appId: undefined,
expireAt: null, remark: '',
})
}
onMounted(() => {
loadAppOptions()
loadList()
// 启动定时刷新每30秒刷新一次已有状态的服务器developer 及以上角色)
statusRefreshTimer = setInterval(() => {
const serversWithStatus = list.value.filter(s => (s.accessLevel ?? 1) >= 2 && s.panelPort && serverStatusMap.value[s.resourceId!])
serversWithStatus.forEach(server => {
fetchServerStatus(server)
})
}, 30000)
})
onUnmounted(() => {
// 清理定时器
if (statusRefreshTimer) {
clearInterval(statusRefreshTimer)
statusRefreshTimer = null
}
})
</script>
<style scoped>
.dev-page {
padding: 24px;
max-width: 1200px;
margin: 0 auto;
}
/* 工具栏 */
.page-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 12px;
}
/* 加载和空状态 */
.card-loading,
.card-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
/* 服务器卡片网格 */
.server-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
}
/* 单个卡片 */
.server-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 10px;
padding: 16px 20px;
transition: all 0.2s;
display: flex;
flex-direction: column;
gap: 12px;
}
.server-card:hover {
border-color: #91caff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.card-expired {
opacity: 0.7;
}
/* 卡片头部 */
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.card-header-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.server-name {
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
/* 卡片信息区 */
.card-info {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
color: #666;
}
.info-row {
display: flex;
align-items: center;
gap: 8px;
}
.info-label {
color: #999;
font-size: 14px;
display: flex;
align-items: center;
}
.info-value {
color: #333;
}
.ip-value {
font-family: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
cursor: pointer;
padding: 1px 6px;
border-radius: 4px;
transition: background 0.15s;
}
.ip-value:hover {
background: #e6f4ff;
color: #1677ff;
}
.info-extra {
color: #999;
font-size: 12px;
}
.text-danger {
color: #ff4d4f;
}
/* 端口标签区 */
.card-ports {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.card-ports .ant-tag {
margin: 0;
font-size: 11px;
font-family: 'SF Mono', 'Menlo', monospace;
}
/* 服务器状态区 */
.card-status {
background: #fafafa;
border-radius: 6px;
padding: 10px 12px;
font-size: 12px;
}
.status-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.status-title {
font-weight: 500;
color: #333;
}
.status-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.status-online {
display: flex;
align-items: center;
gap: 6px;
color: #52c41a;
}
.status-online.offline {
color: #ff4d4f;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #52c41a;
}
.status-dot.offline {
background: #ff4d4f;
}
.status-uptime {
color: #999;
font-size: 11px;
margin-left: 4px;
}
.status-bars {
display: flex;
flex-direction: column;
gap: 4px;
}
.status-bar-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.status-bar-label {
display: flex;
align-items: center;
gap: 4px;
color: #666;
font-size: 11px;
}
.status-bar-value {
margin-left: auto;
font-weight: 500;
color: #333;
}
.status-error {
color: #ff4d4f;
text-align: center;
padding: 8px 0;
}
.status-empty {
color: #999;
text-align: center;
padding: 8px 0;
}
/* 卡片操作区 */
.card-actions {
display: flex;
gap: 8px;
padding-top: 4px;
border-top: 1px solid #f5f5f5;
}
/* 权限提示 */
.access-hint {
margin-top: 2px;
}
.access-hint-text {
font-size: 11px;
color: #faad14;
}
/* 表单分区标题 */
.form-section-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0 4px;
font-size: 14px;
font-weight: 500;
color: #333;
border-top: 1px solid #f0f0f0;
margin-top: 12px;
}
.form-section-header:first-child {
border-top: none;
margin-top: 0;
}
.form-section-hint {
font-size: 12px;
font-weight: 400;
color: #999;
}
</style>