2e797f86b7
批量读 Redis 心跳返回 status,前端与主服务器列表样式一致。 Co-authored-by: Cursor <cursoragent@cursor.com>
284 lines
9.2 KiB
Vue
284 lines
9.2 KiB
Vue
<template>
|
||
<v-container fluid class="pa-6">
|
||
<div class="d-flex flex-wrap align-center ga-3 mb-4">
|
||
<div>
|
||
<h1 class="text-h6 font-weight-bold">宝塔 · 服务器列表</h1>
|
||
<p class="text-caption text-medium-emphasis mb-0">
|
||
先批量 SSH 获取 API,再一键登录;未配置 API 时登录链接易 404
|
||
</p>
|
||
</div>
|
||
<v-spacer />
|
||
<v-btn
|
||
color="secondary"
|
||
variant="tonal"
|
||
prepend-icon="mdi-download-network"
|
||
:disabled="!unconfiguredCount"
|
||
:loading="batchLoading"
|
||
@click="openBatchConfirm('all')"
|
||
>
|
||
全部未配置({{ unconfiguredCount }})
|
||
</v-btn>
|
||
<v-btn
|
||
color="primary"
|
||
variant="tonal"
|
||
prepend-icon="mdi-playlist-check"
|
||
:disabled="!selectedUnconfiguredCount"
|
||
:loading="batchLoading"
|
||
@click="openBatchConfirm('selected')"
|
||
>
|
||
选中获取 API({{ selectedUnconfiguredCount }})
|
||
</v-btn>
|
||
<v-text-field
|
||
v-model="search"
|
||
density="compact"
|
||
hide-details
|
||
clearable
|
||
prepend-inner-icon="mdi-magnify"
|
||
label="搜索名称 / IP / 分类"
|
||
style="max-width: 320px; min-width: 200px"
|
||
/>
|
||
</div>
|
||
|
||
<v-data-table
|
||
v-model="selectedIds"
|
||
:headers="headers"
|
||
:items="filteredServers"
|
||
:loading="loading"
|
||
density="compact"
|
||
item-value="id"
|
||
show-select
|
||
hover
|
||
>
|
||
<template #item.status="{ item }">
|
||
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label>
|
||
{{ statusLabel(item.status) }}
|
||
</v-chip>
|
||
</template>
|
||
<template #item.configured="{ item }">
|
||
<v-chip size="x-small" :color="item.configured ? 'success' : 'warning'" variant="tonal">
|
||
{{ item.configured ? 'API 已配置' : '未配置' }}
|
||
</v-chip>
|
||
</template>
|
||
<template #item.bootstrap_state="{ item }">
|
||
<v-chip
|
||
v-if="item.bootstrap_state"
|
||
size="x-small"
|
||
:color="bootstrapChipColor(item)"
|
||
variant="tonal"
|
||
>
|
||
{{ bootstrapLabel(item.bootstrap_state) }}
|
||
</v-chip>
|
||
<span v-else class="text-medium-emphasis text-caption">—</span>
|
||
</template>
|
||
<template #item.base_url="{ item }">
|
||
<span class="text-caption text-truncate d-inline-block" style="max-width: 220px">
|
||
{{ item.base_url || '—' }}
|
||
</span>
|
||
</template>
|
||
<template #item.actions="{ item }">
|
||
<v-btn
|
||
size="small"
|
||
variant="tonal"
|
||
color="primary"
|
||
prepend-icon="mdi-open-in-new"
|
||
:disabled="!item.configured"
|
||
:loading="loginLoadingId === item.id"
|
||
:title="item.configured ? '打开宝塔临时登录' : '请先 SSH 获取 API'"
|
||
@click="openLogin(item)"
|
||
>
|
||
一键登录
|
||
</v-btn>
|
||
<v-btn
|
||
size="small"
|
||
variant="text"
|
||
class="ml-1"
|
||
:to="`/btpanel/settings?server_id=${item.id}`"
|
||
>
|
||
连接配置
|
||
</v-btn>
|
||
</template>
|
||
</v-data-table>
|
||
|
||
<v-dialog v-model="batchConfirmOpen" max-width="520">
|
||
<v-card title="批量 SSH 获取宝塔 API">
|
||
<v-card-text>
|
||
<p class="mb-2">
|
||
将对 <strong>{{ batchTargetCount }}</strong> 台服务器执行 SSH 引导:开启子机
|
||
<code>api.json</code>、写入 Nexus 加密凭据、追加中心 IP 白名单。
|
||
</p>
|
||
<p
|
||
v-if="batchMode === 'selected' && selectedConfiguredSkipCount > 0"
|
||
class="text-caption text-medium-emphasis mb-2"
|
||
>
|
||
已选 {{ selectedIds.length }} 台,其中 {{ selectedConfiguredSkipCount }} 台 API 已配置将自动跳过。
|
||
</p>
|
||
<p class="text-caption text-medium-emphasis mb-0">
|
||
前提:子机 root SSH 可达、已装宝塔(未装会标记为安装中并每 5 分钟重试)。
|
||
连接配置中需已设置「中心调用 IP」。
|
||
</p>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="batchConfirmOpen = false">取消</v-btn>
|
||
<v-btn color="primary" :loading="batchLoading" @click="confirmBatchBootstrap">开始</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</v-container>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
|
||
import {
|
||
batchBootstrapBtServers,
|
||
createBtLoginUrl,
|
||
listBtServers,
|
||
type BtPanelServerRow,
|
||
} from '@/api/btpanel'
|
||
import { statusChipColor, statusLabel } from '@/utils/status'
|
||
|
||
defineOptions({ name: 'BtPanelLoginPage' })
|
||
|
||
const loading = ref(false)
|
||
const batchLoading = ref(false)
|
||
const batchConfirmOpen = ref(false)
|
||
const batchMode = ref<'all' | 'selected'>('all')
|
||
const loginLoadingId = ref<number | null>(null)
|
||
const search = ref('')
|
||
const servers = ref<BtPanelServerRow[]>([])
|
||
/** v-data-table 勾选值为 item-value(id)数组,不是行对象 */
|
||
const selectedIds = ref<number[]>([])
|
||
|
||
const headers = [
|
||
{ title: '名称', key: 'name', minWidth: 120 },
|
||
{ title: '地址', key: 'domain', minWidth: 120 },
|
||
{ title: '状态', key: 'status', width: 88, sortable: true },
|
||
{ title: '分类', key: 'category', minWidth: 80 },
|
||
{ title: 'API', key: 'configured', width: 110, sortable: false },
|
||
{ title: '引导', key: 'bootstrap_state', width: 100, sortable: false },
|
||
{ title: '面板地址', key: 'base_url', minWidth: 160, sortable: false },
|
||
{ title: '操作', key: 'actions', width: 200, sortable: false, align: 'end' as const },
|
||
]
|
||
|
||
const bootstrapStateLabels: Record<string, string> = {
|
||
pending: '等待获取',
|
||
installing: '安装中',
|
||
ready: '已完成',
|
||
failed: 'SSH 失败',
|
||
disabled: '已关闭',
|
||
}
|
||
|
||
const unconfiguredCount = computed(() => servers.value.filter(s => !s.configured).length)
|
||
|
||
function serverById(id: number) {
|
||
return servers.value.find(s => s.id === id)
|
||
}
|
||
|
||
function selectedUnconfiguredIds(): number[] {
|
||
return selectedIds.value.filter((id) => {
|
||
const row = serverById(id)
|
||
return Boolean(row && !row.configured)
|
||
})
|
||
}
|
||
|
||
const selectedUnconfiguredCount = computed(() => selectedUnconfiguredIds().length)
|
||
|
||
const selectedConfiguredSkipCount = computed(() => {
|
||
if (!selectedIds.value.length) return 0
|
||
return selectedIds.value.length - selectedUnconfiguredCount.value
|
||
})
|
||
|
||
const batchTargetCount = computed(() =>
|
||
batchMode.value === 'all' ? unconfiguredCount.value : selectedUnconfiguredCount.value,
|
||
)
|
||
|
||
function bootstrapLabel(state: string) {
|
||
return bootstrapStateLabels[state] ?? state
|
||
}
|
||
|
||
function bootstrapChipColor(item: BtPanelServerRow) {
|
||
if (item.configured || item.bootstrap_state === 'ready') return 'success'
|
||
if (item.bootstrap_state === 'failed') return 'error'
|
||
if (item.bootstrap_state === 'installing') return 'warning'
|
||
return 'default'
|
||
}
|
||
|
||
const filteredServers = computed(() => {
|
||
const q = search.value.trim().toLowerCase()
|
||
if (!q) return servers.value
|
||
return servers.value.filter(s =>
|
||
[s.name, s.domain, s.category ?? '', s.base_url ?? '', statusLabel(s.status)]
|
||
.join(' ')
|
||
.toLowerCase()
|
||
.includes(q),
|
||
)
|
||
})
|
||
|
||
function openBatchConfirm(mode: 'all' | 'selected') {
|
||
batchMode.value = mode
|
||
if (mode === 'selected') {
|
||
if (!selectedUnconfiguredCount.value) {
|
||
window.$snackbar?.('所选服务器均已配置 API,无需重复获取', 'info')
|
||
return
|
||
}
|
||
} else if (!unconfiguredCount.value) {
|
||
window.$snackbar?.('没有未配置的服务器', 'info')
|
||
return
|
||
}
|
||
batchConfirmOpen.value = true
|
||
}
|
||
|
||
async function loadServers() {
|
||
loading.value = true
|
||
try {
|
||
servers.value = await listBtServers()
|
||
selectedIds.value = selectedIds.value.filter(id => servers.value.some(row => row.id === id))
|
||
} catch (e: unknown) {
|
||
window.$snackbar?.(e instanceof Error ? e.message : '加载服务器列表失败', 'error')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function openLogin(item: BtPanelServerRow) {
|
||
if (!item.configured) {
|
||
window.$snackbar?.('请先批量或单机「SSH 获取 API」', 'warning')
|
||
return
|
||
}
|
||
loginLoadingId.value = item.id
|
||
try {
|
||
const { url } = await createBtLoginUrl(item.id)
|
||
window.open(url, '_blank', 'noopener,noreferrer')
|
||
window.$snackbar?.('已打开宝塔登录链接', 'success')
|
||
} catch (e: unknown) {
|
||
window.$snackbar?.(e instanceof Error ? e.message : '生成链接失败', 'error')
|
||
} finally {
|
||
loginLoadingId.value = null
|
||
}
|
||
}
|
||
|
||
async function confirmBatchBootstrap() {
|
||
batchLoading.value = true
|
||
try {
|
||
let job
|
||
if (batchMode.value === 'all') {
|
||
job = await batchBootstrapBtServers([], { allUnconfigured: true })
|
||
} else {
|
||
const ids = selectedUnconfiguredIds()
|
||
job = await batchBootstrapBtServers(ids)
|
||
}
|
||
batchConfirmOpen.value = false
|
||
window.$snackbar?.(`已启动批量任务 #${job.job_id}`, 'success')
|
||
await openServerBatchJobResult(job.job_id, job.label)
|
||
void loadServers()
|
||
} catch (e: unknown) {
|
||
window.$snackbar?.(e instanceof Error ? e.message : '批量任务启动失败', 'error')
|
||
} finally {
|
||
batchLoading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => { void loadServers() })
|
||
</script>
|