Files
Nexus/frontend/src/pages/btpanel/BtPanelLoginPage.vue
T
r 2e797f86b7 feat(btpanel): 服务器列表增加在线状态列
批量读 Redis 心跳返回 status,前端与主服务器列表样式一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 10:54:04 +08:00

284 lines
9.2 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>
<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-valueid)数组,不是行对象 */
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>