feat(btpanel): 服务器列表批量 SSH 获取 API

支持全部未配置与多选批量引导;未配置 API 时禁用一键登录。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
r
2026-06-21 09:34:02 +08:00
parent 7c99298419
commit b4436143a6
7 changed files with 163 additions and 15 deletions
@@ -0,0 +1,29 @@
# Changelog — 宝塔批量 SSH 获取 API2026-06-21
**日期**2026-06-21
## 摘要
服务器列表页增加批量/全量「SSH 获取 API」;未配置 API 时禁用一键登录(避免 404 临时链)。
## 动机
- 2000+ 子机需先写入 `api.json` 才能稳定一键登录
- 原批量入口仅在连接配置页,不易发现
- `all_unconfigured` 服务端解析未配置列表,突破请求体 500 台上限
## 涉及文件
- `server/api/btpanel_schemas.py``all_unconfigured`
- `server/application/services/btpanel_service.py`
- `frontend/src/pages/btpanel/BtPanelLoginPage.vue`
- `frontend/src/pages/btpanel/BtPanelSettingsPage.vue`
- `frontend/src/api/btpanel.ts`
## 验证
```bash
.venv/bin/pytest tests/test_btpanel_*.py -q
cd frontend && npx vite build
# 服务器列表 → 全部未配置 / 勾选 → 查看批量任务进度
```
+8 -2
View File
@@ -85,10 +85,16 @@ export function bootstrapBtServer(serverId: number) {
return api<BtPanelConfig>(`/btpanel/servers/${serverId}/bootstrap`, { method: 'POST', body: '{}' })
}
export function batchBootstrapBtServers(serverIds: number[]) {
export function batchBootstrapBtServers(
serverIds: number[],
options?: { allUnconfigured?: boolean },
) {
return api<BatchJobResponse>('/btpanel/servers/batch-bootstrap', {
method: 'POST',
body: JSON.stringify({ server_ids: serverIds }),
body: JSON.stringify({
server_ids: serverIds,
all_unconfigured: options?.allUnconfigured ?? false,
}),
})
}
+107 -6
View File
@@ -4,10 +4,30 @@
<div>
<h1 class="text-h6 font-weight-bold">宝塔 · 服务器列表</h1>
<p class="text-caption text-medium-emphasis mb-0">
选择服务器一键打开宝塔面板临时链接约 3 小时有效
先批量 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="!selected.length"
:loading="batchLoading"
@click="openBatchConfirm('selected')"
>
选中获取 API{{ selected.length }}
</v-btn>
<v-text-field
v-model="search"
density="compact"
@@ -20,11 +40,13 @@
</div>
<v-data-table
v-model="selected"
:headers="headers"
:items="filteredServers"
:loading="loading"
density="compact"
item-value="id"
show-select
hover
>
<template #item.configured="{ item }">
@@ -54,8 +76,10 @@
variant="tonal"
color="primary"
prepend-icon="mdi-open-in-new"
:disabled="!item.configured"
:loading="loginLoadingId === item.id"
@click="openLogin(item.id)"
:title="item.configured ? '打开宝塔临时登录' : '请先 SSH 获取 API'"
@click="openLogin(item)"
>
一键登录
</v-btn>
@@ -69,19 +93,49 @@
</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 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 { createBtLoginUrl, listBtServers, type BtPanelServerRow } from '@/api/btpanel'
import { openServerBatchJobResult } from '@/composables/useServerBatchJobViewer'
import {
batchBootstrapBtServers,
createBtLoginUrl,
listBtServers,
type BtPanelServerRow,
} from '@/api/btpanel'
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[]>([])
const selected = ref<BtPanelServerRow[]>([])
const headers = [
{ title: '名称', key: 'name', minWidth: 120 },
@@ -101,6 +155,12 @@ const bootstrapStateLabels: Record<string, string> = {
disabled: '已关闭',
}
const unconfiguredCount = computed(() => servers.value.filter(s => !s.configured).length)
const batchTargetCount = computed(() =>
batchMode.value === 'all' ? unconfiguredCount.value : selected.value.filter(s => !s.configured).length,
)
function bootstrapLabel(state: string) {
return bootstrapStateLabels[state] ?? state
}
@@ -123,10 +183,26 @@ const filteredServers = computed(() => {
)
})
function openBatchConfirm(mode: 'all' | 'selected') {
batchMode.value = mode
if (mode === 'selected') {
const targets = selected.value.filter(s => !s.configured)
if (!targets.length) {
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()
selected.value = selected.value.filter(s => servers.value.some(row => row.id === s.id))
} catch (e: unknown) {
window.$snackbar?.(e instanceof Error ? e.message : '加载服务器列表失败', 'error')
} finally {
@@ -134,10 +210,14 @@ async function loadServers() {
}
}
async function openLogin(serverId: number) {
loginLoadingId.value = serverId
async function openLogin(item: BtPanelServerRow) {
if (!item.configured) {
window.$snackbar?.('请先批量或单机「SSH 获取 API」', 'warning')
return
}
loginLoadingId.value = item.id
try {
const { url } = await createBtLoginUrl(serverId)
const { url } = await createBtLoginUrl(item.id)
window.open(url, '_blank', 'noopener,noreferrer')
window.$snackbar?.('已打开宝塔登录链接', 'success')
} catch (e: unknown) {
@@ -147,5 +227,26 @@ async function openLogin(serverId: number) {
}
}
async function confirmBatchBootstrap() {
batchLoading.value = true
try {
let job
if (batchMode.value === 'all') {
job = await batchBootstrapBtServers([], { allUnconfigured: true })
} else {
const ids = selected.value.filter(s => !s.configured).map(s => s.id)
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>
@@ -311,11 +311,10 @@ async function runBootstrap(id: number) {
}
async function confirmBatchBootstrap() {
const ids = unconfiguredIds.value
if (!ids.length) return
if (!unconfiguredIds.value.length) return
batchLoading.value = true
try {
const job = await batchBootstrapBtServers(ids)
const job = await batchBootstrapBtServers([], { allUnconfigured: true })
batchConfirmOpen.value = false
window.$snackbar?.(`已启动批量任务 #${job.job_id}`, 'success')
} catch (e: unknown) {
+1
View File
@@ -151,6 +151,7 @@ async def batch_bootstrap_bt_servers(
try:
return await _svc(db).batch_bootstrap(
body.server_ids,
all_unconfigured=body.all_unconfigured,
admin_username=admin.username,
ip_address=_client_ip(request),
)
+9 -2
View File
@@ -2,7 +2,7 @@
from typing import Optional
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class BtPanelConfigUpdate(BaseModel):
@@ -18,7 +18,14 @@ class BtPanelGlobalSettingsUpdate(BaseModel):
class BtPanelBatchBootstrap(BaseModel):
server_ids: list[int] = Field(..., min_length=1, max_length=500)
server_ids: list[int] = Field(default_factory=list, max_length=500)
all_unconfigured: bool = False
@model_validator(mode="after")
def _require_targets(self) -> "BtPanelBatchBootstrap":
if not self.all_unconfigured and not self.server_ids:
raise ValueError("server_ids 不能为空,或设置 all_unconfigured=true")
return self
class BtPanelSiteAction(BaseModel):
@@ -330,14 +330,19 @@ class BtPanelService:
self,
server_ids: list[int],
*,
all_unconfigured: bool = False,
admin_username: str,
ip_address: str | None,
) -> dict[str, Any]:
from server.application.services.server_batch_service import start_batch_job
ids = [int(x) for x in server_ids if x]
if all_unconfigured:
statuses = await self.list_server_statuses()
ids = [int(s["id"]) for s in statuses if not s.get("configured")]
else:
ids = [int(x) for x in server_ids if x]
if not ids:
raise ValueError("server_ids 不能为空")
raise ValueError("没有可批量初始化的服务器(均已配置或未录入)")
return await start_batch_job(
op="bt-panel-bootstrap",
server_ids=ids,