Files
Nexus/frontend/src/pages/ServersPage.vue
T
Nexus Agent a14fbb3df3 fix(ssh): 快速添加跳过主机密钥校验并中文化错误
凭据轮询在 SSH_STRICT 默认 true 时因 HostKeyNotVerifiable 在认证前失败;
探测始终 known_hosts=None,默认 strict 改为 false,错误信息统一中文。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 21:31:38 +08:00

723 lines
27 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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-4 pa-md-6">
<StatCardsRow :items="statItems" />
<!-- Server Table -->
<v-card elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center">
服务器列表
<v-spacer />
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="搜索..."
density="compact"
hide-details
rounded
style="max-width: 240px"
variant="outlined"
@update:model-value="onSearch"
/>
<v-btn color="primary" variant="flat" class="ml-3" prepend-icon="mdi-plus" size="small" @click="openAddServer">
添加
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-flash" size="small" @click="openQuickAdd">
快速添加(IP)
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-download" size="small" @click="exportCSV">
导出CSV
</v-btn>
<v-btn variant="tonal" class="ml-2" prepend-icon="mdi-file-import" size="small" @click="showImport = true">
导入
</v-btn>
</v-card-title>
<!-- Batch Actions -->
<v-card v-if="selectedIds.size > 0" class="mx-4 mb-2" color="primary" variant="tonal" rounded="lg">
<v-card-text class="d-flex align-center py-2">
<span class="text-body-2 mr-4">已选择 {{ selectedIds.size }} 台服务器</span>
<v-spacer />
<v-btn size="small" variant="text" @click="batchHealthCheck">健康检查</v-btn>
<v-btn size="small" variant="text" @click="batchInstallAgent" :loading="batchAgentLoading">安装Agent</v-btn>
<v-btn size="small" variant="text" @click="batchUpgradeAgent" :loading="batchAgentLoading">升级Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="batchUninstallAgent" :loading="batchAgentLoading">卸载Agent</v-btn>
<v-btn size="small" variant="text" color="error" @click="confirmBatchDelete">批量删除</v-btn>
<v-btn size="small" variant="text" @click="selectedItems = []">取消选择</v-btn>
</v-card-text>
</v-card>
<v-data-table-server
:items="servers"
:headers="headers"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="itemsPerPage"
hover
density="comfortable"
item-value="id"
show-select
v-model="selectedItems"
@update:page="onPageChange"
@update:items-per-page="onItemsPerPageChange"
@click:row="(_e: Event, { item }: { item: ServerApiItem }) => showDetail(item)"
>
<template #item.status="{ item }">
<v-chip :color="statusChipColor(item.status)" size="x-small" variant="tonal" label border="sm">
{{ statusLabel(item.status) }}
</v-chip>
</template>
<template #item.name="{ item }">
<span class="font-weight-medium">{{ item.name }}</span>
</template>
<template #item.address="{ item }">
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
</template>
<template #item.category="{ item }">
<v-chip v-if="item.category" color="primary" variant="tonal" size="small" label>
{{ item.category }}
</v-chip>
<span v-else class="text-medium-emphasis"></span>
</template>
<template #item.agent="{ item }">
<span class="text-medium-emphasis">{{ item.agent_version || '—' }}</span>
</template>
<template #item.heartbeat="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_heartbeat) }}</span>
</template>
<template #item.actions="{ item }">
<div class="d-flex ga-1">
<v-btn variant="text" size="x-small" color="primary" density="compact" @click.stop="openTerminal(item)">终端</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="openFiles(item)">文件</v-btn>
<v-btn variant="text" size="x-small" density="compact" @click.stop="editServer(item)">编辑</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click.stop="confirmDelete(item)">删除</v-btn>
</div>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无服务器数据</div>
</template>
</v-data-table-server>
</v-card>
<!-- Pending / failed SSH connections -->
<v-card elevation="0" rounded="lg" class="my-5" border>
<v-card-title class="d-flex align-center">
连接失败列表
<v-chip v-if="pendingServers.length" size="small" class="ml-2" color="error" variant="tonal" label>
{{ pendingServers.length }}
</v-chip>
<v-spacer />
<v-btn variant="text" size="small" prepend-icon="mdi-refresh" :loading="pendingLoading" @click="loadPendingServers">
刷新
</v-btn>
</v-card-title>
<v-data-table
:items="pendingServers"
:headers="pendingHeaders"
:loading="pendingLoading"
hover
density="comfortable"
:items-per-page="10"
>
<template #item.address="{ item }">
<span class="text-medium-emphasis">{{ item.domain }}:{{ item.port }}</span>
</template>
<template #item.last_attempt_at="{ item }">
<span class="text-caption text-medium-emphasis">{{ formatRelativeTime(item.last_attempt_at) }}</span>
</template>
<template #item.last_error="{ item }">
<span class="text-caption text-error text-truncate d-inline-block" style="max-width: 320px" :title="item.last_error">
{{ item.last_error || '—' }}
</span>
</template>
<template #item.actions="{ item }">
<v-btn variant="text" size="x-small" color="primary" density="compact" :loading="retryingId === item.id" @click="retryPending(item)">
重试
</v-btn>
<v-btn variant="text" size="x-small" color="error" density="compact" @click="confirmDeletePending(item)">删除</v-btn>
</template>
<template #no-data>
<div class="text-center text-medium-emphasis py-6">暂无连接失败记录</div>
</template>
</v-data-table>
</v-card>
<!-- Server Detail Panel -->
<v-expand-transition>
<v-card v-if="detailServer" class="mt-4" elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-information-outline</v-icon>
{{ detailServer.name }}
<v-chip :color="statusChipColor(detailServer.status)" size="small" variant="tonal" label class="ml-2">
{{ statusLabel(detailServer.status) }}
</v-chip>
<v-spacer />
<v-btn icon="mdi-close" size="small" variant="text" @click="detailServer = null" />
</v-card-title>
<v-divider />
<v-tabs v-model="detailTab" density="compact">
<v-tab value="info">系统信息</v-tab>
<v-tab value="logs">同步日志</v-tab>
</v-tabs>
<v-tabs-window v-model="detailTab">
<v-tabs-window-item value="info">
<v-card-text>
<v-row dense>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">CPU</div>
<div class="text-h6">{{ detailServer.system_info?.cpu_usage ?? '—' }}%</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">内存</div>
<div class="text-h6">{{ detailServer.system_info?.mem_usage ?? '—' }}%</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">磁盘</div>
<div class="text-h6">{{ detailServer.system_info?.disk_usage ?? '—' }}%</div>
</v-col>
<v-col cols="6" md="3">
<div class="text-caption text-medium-emphasis">Agent</div>
<div class="text-h6">{{ detailServer.agent_version || '未安装' }}</div>
</v-col>
</v-row>
</v-card-text>
</v-tabs-window-item>
<v-tabs-window-item value="logs">
<v-card-text>
<div v-if="syncLogs.length === 0" class="text-medium-emphasis text-center py-4">暂无同步日志</div>
<v-list v-else density="compact">
<v-list-item v-for="log in syncLogs" :key="log.id" :subtitle="log.created_at" :title="`${log.source_path} → ${log.target_path}`">
<template #append>
<v-chip size="x-small" :color="log.status === 'success' ? 'green' : 'red'" variant="tonal" label>{{ log.status }}</v-chip>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-tabs-window-item>
</v-tabs-window>
</v-card>
</v-expand-transition>
<ServerFormDialog
v-model="serverFormVisible"
:form="serverForm"
:is-editing="isEditingServer"
:saving="serverFormSaving"
:lookups-loading="serverFormLookupsLoading"
:password-presets="passwordPresets"
:ssh-key-presets="sshKeyPresets"
:category-options="categoryOptions"
:platforms="serverFormPlatforms"
:nodes="serverFormNodes"
:edit-hints="serverFormEditHints"
:files-elevation-items="filesElevationItems"
@save="saveServerForm"
/>
<!-- Delete Confirm -->
<v-dialog v-model="showDelete" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>确定要删除服务器 <strong>{{ deletingServer?.name }}</strong> </v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDelete = false">取消</v-btn>
<v-btn color="error" variant="flat" @click="doDelete" :loading="deleting">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Quick add by IP -->
<v-dialog v-model="showQuickAdd" max-width="480" persistent>
<v-card border>
<v-card-title>快速添加服务器 IP</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-3">
将按顺序轮询凭据管理中的全部密码预设与 SSH 密钥预设尝试登录首个成功即加入服务器列表
</p>
<v-text-field
v-model="quickAddForm.domain"
label="IP 或域名"
variant="outlined"
density="compact"
class="mb-2"
placeholder="192.168.1.10"
:rules="[v => !!v?.trim() || '必填']"
/>
<v-text-field
v-model.number="quickAddForm.port"
label="SSH 端口"
variant="outlined"
density="compact"
type="number"
class="mb-2"
/>
<v-text-field
v-model="quickAddForm.name"
label="显示名称(可选)"
variant="outlined"
density="compact"
hint="留空则使用 IP"
persistent-hint
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="quickAddLoading" @click="showQuickAdd = false">取消</v-btn>
<v-btn color="primary" variant="flat" :loading="quickAddLoading" @click="submitQuickAdd">开始轮询</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Poll failure detail -->
<v-dialog v-model="showPollFailure" max-width="560">
<v-card border>
<v-card-title class="text-error">SSH 连接失败</v-card-title>
<v-card-text>
<p class="text-body-2 mb-3">已尝试全部凭据预设均未登录成功该服务器已加入连接失败列表」,可稍后重试</p>
<v-list v-if="pollFailureErrors.length" density="compact" class="bg-grey-lighten-4 rounded">
<v-list-item v-for="(err, i) in pollFailureErrors" :key="i">
<v-list-item-title class="text-body-2">
[{{ err.preset_type === 'password' ? '密码' : err.preset_type === 'system' ? '系统' : '密钥' }}] {{ err.preset_name }} ({{ err.username }})
</v-list-item-title>
<v-list-item-subtitle class="text-error">{{ err.error }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<p v-else class="text-medium-emphasis">{{ pollFailureMessage }}</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="showPollFailure = false">知道了</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Delete pending confirm -->
<v-dialog v-model="showDeletePending" max-width="400">
<v-card border>
<v-card-title>确认删除</v-card-title>
<v-card-text>从失败列表移除 <strong>{{ deletingPending?.domain }}</strong></v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showDeletePending = false">取消</v-btn>
<v-btn color="error" variant="flat" :loading="deletingPendingLoading" @click="doDeletePending">删除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Import CSV Dialog -->
<v-dialog v-model="showImport" max-width="500">
<v-card border>
<v-card-title>导入服务器</v-card-title>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">
上传 CSV 文件批量导入服务器
<a href="/app/servers_import_template.csv" download class="text-primary">下载模板</a>
</div>
<v-file-input v-model="importFile" label="选择 CSV 文件" variant="outlined" density="compact" accept=".csv" show-size />
<v-alert v-if="importResult" :type="importResult.failed > 0 ? 'warning' : 'success'" density="compact" variant="tonal" class="mt-3">
成功 {{ importResult.created }}跳过 {{ importResult.skipped }}失败 {{ importResult.failed }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showImport = false">关闭</v-btn>
<v-btn color="primary" variant="flat" @click="doImport" :loading="importing" :disabled="!importFile?.length">开始导入</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { http } from '@/api'
import { useSnackbar } from '@/composables/useSnackbar'
import { useServerPagination } from '@/composables/useServerPagination'
import { statusChipColor, statusLabel, formatRelativeTime } from '@/utils/status'
import type { AddByIpResponse, PendingServerItem, PollErrorItem, ServerApiItem } from '@/types/api'
import { normalizeServerIds, countBatchSuccess, type BatchAgentResultItem } from '@/utils/serverSelection'
import { formatApiError } from '@/utils/apiError'
import StatCardsRow from '@/components/StatCardsRow.vue'
import ServerFormDialog from '@/components/servers/ServerFormDialog.vue'
import { useServerFormDialog } from '@/composables/servers/useServerFormDialog'
const snackbar = useSnackbar()
const router = useRouter()
// ── Paginated server list (composable) ──
const {
servers, loading, page, itemsPerPage, total, search,
loadServers: _load, onSearch, onPageChange, onItemsPerPageChange,
} = useServerPagination()
async function loadServers() {
try {
await _load()
} catch {
snackbar('加载服务器列表失败', 'error')
}
}
const showDelete = ref(false)
const deleting = ref(false)
const deletingServer = ref<ServerApiItem | null>(null)
// ── Quick add by IP + pending failure list ──
const showQuickAdd = ref(false)
const quickAddLoading = ref(false)
const quickAddForm = ref({ domain: '', port: 22, name: '' })
const pendingServers = ref<PendingServerItem[]>([])
const pendingLoading = ref(false)
const retryingId = ref<number | null>(null)
const showPollFailure = ref(false)
const pollFailureErrors = ref<PollErrorItem[]>([])
const pollFailureMessage = ref('')
const showDeletePending = ref(false)
const deletingPending = ref<PendingServerItem | null>(null)
const deletingPendingLoading = ref(false)
const pendingHeaders = [
{ title: '名称', key: 'name' },
{ title: '地址', key: 'address' },
{ title: '尝试次数', key: 'attempts', width: 100 },
{ title: '最后错误', key: 'last_error' },
{ title: '最后尝试', key: 'last_attempt_at', width: 160 },
{ title: '操作', key: 'actions', width: 140, align: 'end' as const },
]
function openQuickAdd() {
quickAddForm.value = { domain: '', port: 22, name: '' }
showQuickAdd.value = true
}
function showFailureDialog(errors: PollErrorItem[], message?: string) {
pollFailureErrors.value = errors
pollFailureMessage.value = message || ''
showPollFailure.value = true
}
async function loadPendingServers() {
pendingLoading.value = true
try {
const res = await http.get<{ items: PendingServerItem[]; total: number }>('/servers/pending')
pendingServers.value = res.items || []
} catch (e: unknown) {
pendingServers.value = []
snackbar(formatApiError(e, '加载失败列表失败'), 'error')
} finally {
pendingLoading.value = false
}
}
async function submitQuickAdd() {
const domain = quickAddForm.value.domain.trim()
if (!domain) {
snackbar('请填写 IP 或域名', 'error')
return
}
quickAddLoading.value = true
try {
const body: Record<string, unknown> = {
domain,
port: quickAddForm.value.port || 22,
}
const name = quickAddForm.value.name.trim()
if (name) body.name = name
const res = await http.post<AddByIpResponse>('/servers/add-by-ip', body)
if (res.success && res.server) {
showQuickAdd.value = false
snackbar(`添加成功:${res.matched_preset} (${res.matched_username})`)
loadServers()
loadStats()
} else {
showQuickAdd.value = false
showFailureDialog(res.errors || [], res.message)
loadPendingServers()
}
} catch (e: unknown) {
snackbar(formatApiError(e, '添加失败'), 'error')
} finally {
quickAddLoading.value = false
}
}
async function retryPending(item: PendingServerItem) {
retryingId.value = item.id
try {
const res = await http.post<AddByIpResponse>(`/servers/pending/${item.id}/retry`, {})
if (res.success && res.server) {
snackbar(`重试成功:${res.matched_preset} (${res.matched_username})`)
loadServers()
loadStats()
loadPendingServers()
} else {
showFailureDialog(res.errors || [], res.message)
loadPendingServers()
}
} catch (e: unknown) {
snackbar(formatApiError(e, '重试失败'), 'error')
} finally {
retryingId.value = null
}
}
function confirmDeletePending(item: PendingServerItem) {
deletingPending.value = item
showDeletePending.value = true
}
async function doDeletePending() {
if (!deletingPending.value) return
deletingPendingLoading.value = true
try {
await http.delete(`/servers/pending/${deletingPending.value.id}`)
showDeletePending.value = false
snackbar('已从失败列表移除')
loadPendingServers()
} catch (e: unknown) {
snackbar(formatApiError(e, '删除失败'), 'error')
} finally {
deletingPendingLoading.value = false
}
}
const {
visible: serverFormVisible,
form: serverForm,
isEditing: isEditingServer,
saving: serverFormSaving,
lookupsLoading: serverFormLookupsLoading,
passwordPresets,
sshKeyPresets,
categoryOptions,
platforms: serverFormPlatforms,
nodes: serverFormNodes,
editHints: serverFormEditHints,
filesElevationItems,
openAdd: openAddServer,
openEdit: openEditServerDialog,
close: closeServerForm,
save: saveServerForm,
} = useServerFormDialog(() => {
loadServers()
loadStats()
})
watch(serverFormVisible, (open, wasOpen) => {
if (wasOpen && !open) closeServerForm()
})
// ── Batch selection (Vuetify returns IDs when item-value="id", objects when return-object) ──
const selectedItems = ref<Array<ServerApiItem | number>>([])
const selectedIds = computed(() => new Set(normalizeServerIds(selectedItems.value)))
// ── Detail panel ──
const detailServer = ref<ServerApiItem | null>(null)
const detailTab = ref('info')
const syncLogs = ref<{ id: number; source_path: string; target_path: string; status: string; created_at: string }[]>([])
// ── Table config ──
const headers = [
{ title: '状态', key: 'status', width: 100 },
{ title: '名称', key: 'name' },
{ title: '地址', key: 'address' },
{ title: '分类', key: 'category', width: 100 },
{ title: 'Agent', key: 'agent', width: 100 },
{ title: '心跳', key: 'heartbeat', width: 120 },
{ title: '操作', key: 'actions', width: 200, align: 'end' as const },
]
// ── Stats(与仪表盘 StatCardsRow 一致)──
const statItems = ref([
{ subtitle: '服务器总数', title: '0', icon: 'mdi-server-network', color: 'blue' },
{ subtitle: '在线', title: '0', icon: 'mdi-check-circle-outline', color: 'green' },
{ subtitle: '离线', title: '0', icon: 'mdi-alert-circle-outline', color: 'orange' },
{ subtitle: '告警中', title: '0', icon: 'mdi-bell-alert-outline', color: 'red' },
])
async function loadStats() {
try {
const s = await http.get<{ total: number; online: number; offline: number; alerts: number }>('/servers/stats')
statItems.value[0].title = String(s.total)
statItems.value[1].title = String(s.online)
statItems.value[2].title = String(s.offline)
statItems.value[3].title = String(s.alerts ?? 0)
} catch { /* non-critical */ }
}
// ── Handlers ──
function openTerminal(item: ServerApiItem) {
router.push({ path: '/terminal', query: { server_id: String(item.id) } })
}
function openFiles(item: ServerApiItem) {
router.push({ path: '/files', query: { server_id: String(item.id) } })
}
// ── Batch operations ──
const batchAgentLoading = ref(false)
async function batchHealthCheck() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) return
snackbar(`正在检查 ${ids.length} 台服务器...`)
try {
await http.post('/servers/check', { server_ids: ids })
snackbar('健康检查完成')
loadServers()
} catch (e: unknown) { snackbar(formatApiError(e, '检查失败'), 'error') }
}
async function confirmBatchDelete() {
if (!confirm(`确定删除选中的 ${selectedIds.value.size} 台服务器?`)) return
for (const id of selectedIds.value) {
try { await http.delete(`/servers/${id}`) } catch { /* skip individual errors */ }
}
selectedItems.value = []
snackbar('批量删除完成')
loadServers()
loadStats()
}
async function batchInstallAgent() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) return
batchAgentLoading.value = true
try {
const res = await http.post<{ results: BatchAgentResultItem[] }>('/servers/batch/install-agent', { server_ids: ids })
const success = countBatchSuccess(res.results)
snackbar(`安装完成: ${success}/${ids.length} 成功`)
loadServers()
} catch (e: unknown) {
snackbar(formatApiError(e, '安装失败'), 'error')
} finally { batchAgentLoading.value = false }
}
async function batchUpgradeAgent() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) return
batchAgentLoading.value = true
try {
const res = await http.post<{ results: BatchAgentResultItem[] }>('/servers/batch/upgrade-agent', { server_ids: ids })
const success = countBatchSuccess(res.results)
snackbar(`升级完成: ${success}/${ids.length} 成功`)
loadServers()
} catch (e: unknown) {
snackbar(formatApiError(e, '升级失败'), 'error')
} finally { batchAgentLoading.value = false }
}
async function batchUninstallAgent() {
const ids = normalizeServerIds(selectedItems.value)
if (!ids.length) return
if (!confirm(`确定卸载选中的 ${ids.length} 台服务器的 Agent`)) return
batchAgentLoading.value = true
try {
const res = await http.post<{ results: BatchAgentResultItem[] }>('/servers/batch/uninstall-agent', { server_ids: ids })
const success = countBatchSuccess(res.results)
snackbar(`卸载完成: ${success}/${ids.length} 成功`)
loadServers()
} catch (e: unknown) {
snackbar(formatApiError(e, '卸载失败'), 'error')
} finally { batchAgentLoading.value = false }
}
// ── CSV export ──
async function exportCSV() {
try {
const res = await http.getList<ServerApiItem>('/servers/', { per_page: 5000 })
const headers = ['ID', '名称', '地址', '端口', '用户名', '分类', '状态', 'Agent版本', '最后心跳']
const rows = (res.items || []).map(s => [
s.id, s.name, s.domain, s.port, s.username, s.category || '', s.status,
s.agent_version || '', s.last_heartbeat || '',
])
const bom = ''
const csv = bom + [headers.join(','), ...rows.map(r => r.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))].join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `servers_${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(url)
snackbar('导出完成')
} catch (e: any) { snackbar(e.message || '导出失败', 'error') }
}
// ── CSV import ──
const showImport = ref(false)
const importFile = ref<File[]>([])
const importing = ref(false)
const importResult = ref<{ created: number; skipped: number; failed: number } | null>(null)
async function doImport() {
if (!importFile.value?.length) return
importing.value = true
importResult.value = null
try {
const form = new FormData()
form.append('file', importFile.value[0])
const res = await http.upload<{ created: number; skipped: number; failed: number }>('/servers/import', form)
importResult.value = res
snackbar('导入完成')
loadServers()
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '导入失败'
snackbar(msg, 'error')
} finally {
importing.value = false
}
}
// ── Server detail panel ──
async function showDetail(item: ServerApiItem) {
detailServer.value = item
detailTab.value = 'info'
try {
const res = await http.get<{ items: typeof syncLogs.value }>(`/servers/${item.id}/logs`, { limit: 20 })
syncLogs.value = res.items || []
} catch { syncLogs.value = [] }
}
function editServer(item: ServerApiItem) {
void openEditServerDialog(item)
}
function confirmDelete(item: ServerApiItem) {
deletingServer.value = item
showDelete.value = true
}
async function doDelete() {
if (!deletingServer.value) return
deleting.value = true
try {
await http.delete(`/servers/${deletingServer.value.id}`)
showDelete.value = false
loadServers()
loadStats()
snackbar('已删除')
} catch (e: any) {
snackbar(e.message || '删除失败', 'error')
} finally {
deleting.value = false
}
}
// ── Init ──
onMounted(() => {
loadStats()
loadServers()
loadPendingServers()
})
</script>