1040 lines
32 KiB
Vue
1040 lines
32 KiB
Vue
<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="如 /abc123(1Panel 安全入口路径)" />
|
||
</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 = `https://${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>
|