6183047fd6
前端修复: - P0: App.vue http 未导入导致全局搜索崩溃 - P1: TOTP 登录流程断裂 (TotpRequiredError 非 ApiError 子类) - P1: ServersPage 编辑服务器 domain 被端口字符串污染 - P1: PushPage Set[0] 无效 → values().next().value - P1: SettingsPage API 返回字符串未转数字 - P2: api/index.ts getList 重复定义移除 - P2: LoginPage 锁定倒计时 now ref + watch 更新 - P2: TerminalPage 路径验证正则放松 (允许空格/中文) - P2: useWebSocket CONNECTING/CLOSING 状态关闭旧连接 后端修复: - P1: websocket.py redis.keys() → scan_iter() 避免阻塞 - P1: auth_service.py TTL 仅在 key 无 TTL 时设置 - P1: servers.py 批量操作加 asyncio.Lock 避免并发 session commit - P2: settings.py asyncio.get_event_loop() → get_running_loop() - P2: settings.py datetime.utcfromtimestamp() → datetime.fromtimestamp(tz=utc) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
634 lines
22 KiB
Vue
634 lines
22 KiB
Vue
<template>
|
||
<v-container fluid class="pa-6">
|
||
<!-- ZIP Upload -->
|
||
<v-card elevation="0" border rounded="lg" class="mb-4">
|
||
<v-card-title class="text-subtitle-1">源文件</v-card-title>
|
||
<v-card-text>
|
||
<div v-if="!uploadedZip">
|
||
<div
|
||
class="border-dashed border rounded pa-6 text-center cursor-pointer"
|
||
:class="{ 'border-primary bg-primary-lighten-5': isDragging }"
|
||
@dragover.prevent="isDragging = true"
|
||
@dragleave="isDragging = false"
|
||
@drop.prevent="handleZipDrop"
|
||
@click="triggerZipInput"
|
||
>
|
||
<v-icon size="48" color="grey">mdi-cloud-upload-outline</v-icon>
|
||
<div class="text-body-2 mt-2">拖拽 ZIP 文件到此处,或点击选择</div>
|
||
<div class="text-caption text-medium-emphasis">支持 .zip 格式</div>
|
||
</div>
|
||
<input ref="zipInput" type="file" accept=".zip" hidden @change="handleZipSelect" />
|
||
</div>
|
||
<div v-else class="d-flex align-center">
|
||
<v-icon class="mr-2">mdi-folder-zip</v-icon>
|
||
<span>{{ uploadedZip.name }} ({{ uploadedZip.fileCount }} 个文件, {{ formatSize(uploadedZip.size) }})</span>
|
||
<v-spacer />
|
||
<v-btn size="small" variant="text" color="error" @click="clearZipUpload">清除</v-btn>
|
||
</div>
|
||
<v-progress-linear v-if="zipUploading" indeterminate class="mt-2" />
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- Toolbar -->
|
||
<v-card elevation="0" border rounded="lg" class="mb-4 pa-4">
|
||
<v-row align="center" dense>
|
||
<v-col cols="12" sm="4">
|
||
<v-text-field v-model="sourcePath" label="推送源路径" variant="outlined" density="compact" prepend-inner-icon="mdi-folder" />
|
||
</v-col>
|
||
<v-col cols="12" sm="4">
|
||
<v-text-field v-model="targetPath" label="目标路径" variant="outlined" density="compact" prepend-inner-icon="mdi-folder-marker" />
|
||
</v-col>
|
||
<v-col cols="12" sm="4" class="d-flex ga-2 justify-end">
|
||
<v-btn size="small" variant="tonal" prepend-icon="mdi-eye" @click="doPreview">预览</v-btn>
|
||
<v-btn size="small" color="primary" variant="flat" prepend-icon="mdi-upload" @click="doPush" :loading="pushing">推送</v-btn>
|
||
</v-col>
|
||
</v-row>
|
||
</v-card>
|
||
|
||
<!-- Server Selection -->
|
||
<v-card elevation="0" border rounded="lg" class="mb-4">
|
||
<v-card-title class="d-flex align-center">
|
||
<v-checkbox v-model="selectAll" label="全选" density="compact" hide-details class="mr-4" @update:model-value="toggleAll" />
|
||
<v-text-field
|
||
v-model="serverSearch"
|
||
prepend-inner-icon="mdi-magnify"
|
||
label="搜索服务器..."
|
||
density="compact"
|
||
hide-details
|
||
variant="outlined"
|
||
rounded
|
||
style="max-width: 240px"
|
||
/>
|
||
</v-card-title>
|
||
<v-divider />
|
||
<v-card-text class="pa-2">
|
||
<template v-if="filteredServers.length === 0">
|
||
<div class="text-center text-medium-emphasis py-6">暂无服务器</div>
|
||
</template>
|
||
<template v-else>
|
||
<div v-for="group in serversByCategory" :key="group.category" class="mb-4">
|
||
<div class="d-flex align-center px-2 mb-2">
|
||
<v-btn
|
||
size="x-small"
|
||
variant="text"
|
||
:color="isCategoryAllSelected(group.category) ? 'primary' : 'default'"
|
||
@click="toggleCategory(group.category)"
|
||
>
|
||
<v-icon size="16" class="mr-1">
|
||
{{ isCategoryAllSelected(group.category) ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline' }}
|
||
</v-icon>
|
||
{{ group.category }}
|
||
</v-btn>
|
||
<span class="text-caption text-medium-emphasis ml-1">
|
||
({{ group.servers.filter(s => selectedIds.has(s.id)).length }}/{{ group.servers.length }})
|
||
</span>
|
||
</div>
|
||
<v-row dense>
|
||
<v-col cols="6" sm="4" md="3" lg="2" v-for="s in group.servers" :key="s.id">
|
||
<v-card
|
||
:color="selectedIds.has(s.id) ? 'primary' : undefined"
|
||
:variant="selectedIds.has(s.id) ? 'tonal' : 'outlined'"
|
||
rounded="lg"
|
||
class="pa-3 cursor-pointer"
|
||
@click="toggleServer(s.id)"
|
||
>
|
||
<div class="d-flex align-center ga-2">
|
||
<v-icon :color="s.status === 'online' ? 'success' : 'error'" size="12">mdi-circle</v-icon>
|
||
<span class="text-body-2 font-weight-medium text-truncate">{{ s.name }}</span>
|
||
</div>
|
||
<div class="text-caption text-medium-emphasis text-truncate">{{ s.domain }}</div>
|
||
<div v-if="pushStatus[s.id]" class="text-caption mt-1">
|
||
<v-chip :color="pushColor(pushStatus[s.id])" size="x-small" variant="tonal" label border="current sm">{{ pushStatus[s.id] }}</v-chip>
|
||
</div>
|
||
</v-card>
|
||
</v-col>
|
||
</v-row>
|
||
</div>
|
||
</template>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- Sync Mode -->
|
||
<v-card elevation="0" border rounded="lg" class="mb-4">
|
||
<v-card-title class="text-subtitle-1">同步模式</v-card-title>
|
||
<v-card-text>
|
||
<v-radio-group v-model="syncMode" inline density="compact">
|
||
<v-radio label="增量同步" value="incremental" />
|
||
<v-radio label="全量同步" value="full" />
|
||
<v-radio label="校验和同步" value="checksum" />
|
||
</v-radio-group>
|
||
<v-alert v-if="syncMode === 'full'" type="warning" density="compact" variant="tonal" class="mt-2">
|
||
全量同步会删除目标目录中不存在于源目录的文件
|
||
</v-alert>
|
||
<div class="text-caption text-medium-emphasis mt-1">
|
||
{{ syncModeDesc }}
|
||
</div>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- Push Progress -->
|
||
<v-card v-if="pushing" elevation="0" border rounded="lg" class="mb-4 pa-4">
|
||
<div class="text-subtitle-2 mb-2">推送进度</div>
|
||
<v-progress-linear :model-value="pushProgress" color="primary" height="8" rounded />
|
||
<div class="text-caption text-medium-emphasis mt-1">{{ completedCount }} / {{ selectedIds.size }} 台完成</div>
|
||
</v-card>
|
||
|
||
<!-- WebSocket Per-Server Progress -->
|
||
<v-card v-if="wsProgressItems.length > 0" class="mb-4" elevation="0" border rounded="lg">
|
||
<v-card-title class="d-flex align-center text-subtitle-1">
|
||
推送进度
|
||
<v-spacer />
|
||
<v-btn
|
||
v-if="pushBatchId && wsProgressItems.some(s => s.status === 'running' || s.status === 'pending')"
|
||
size="small"
|
||
variant="tonal"
|
||
color="error"
|
||
class="mr-2"
|
||
:loading="cancelling"
|
||
@click="cancelPush"
|
||
>
|
||
取消推送
|
||
</v-btn>
|
||
<v-chip size="small" variant="tonal" color="primary">
|
||
{{ wsProgressItems.filter(s => s.status === 'success').length }}/{{ wsProgressItems.length }} 完成
|
||
</v-chip>
|
||
</v-card-title>
|
||
<v-divider />
|
||
<v-card-text>
|
||
<v-list density="compact">
|
||
<v-list-item v-for="s in wsProgressItems" :key="s.id" :title="s.name">
|
||
<template #append>
|
||
<v-icon v-if="s.status === 'pending'" color="grey">mdi-clock-outline</v-icon>
|
||
<v-icon v-else-if="s.status === 'running'" color="blue">mdi-loading</v-icon>
|
||
<v-icon v-else-if="s.status === 'success'" color="green">mdi-check-circle</v-icon>
|
||
<v-icon v-else color="red">mdi-alert-circle</v-icon>
|
||
</template>
|
||
<template v-if="s.detail" #subtitle>
|
||
<span :class="s.status === 'failed' ? 'text-error' : 'text-medium-emphasis'">{{ s.detail }}</span>
|
||
</template>
|
||
</v-list-item>
|
||
</v-list>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- Preview Dialog -->
|
||
<v-dialog v-model="showPreview" max-width="600">
|
||
<v-card border>
|
||
<v-card-title>推送预览</v-card-title>
|
||
<v-card-text>
|
||
<v-alert v-if="previewData.error" type="error" class="mb-2">{{ previewData.error }}</v-alert>
|
||
<div v-else>
|
||
<div class="mb-2">将推送到 <strong>{{ selectedIds.size }}</strong> 台服务器</div>
|
||
<div v-if="previewData.files" class="text-body-2">{{ previewData.files }} 个文件,{{ previewData.size }}</div>
|
||
</div>
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn variant="text" @click="showPreview = false">关闭</v-btn>
|
||
<v-btn color="primary" variant="flat" @click="showPreview = false; doPush()">确认推送</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<!-- Push Log -->
|
||
<v-card elevation="0" border rounded="lg">
|
||
<v-card-title>推送历史</v-card-title>
|
||
<v-data-table-server
|
||
:items="logs"
|
||
:headers="logHeaders"
|
||
:items-length="logTotal"
|
||
:loading="logLoading"
|
||
:page="logPage"
|
||
:items-per-page="15"
|
||
hover
|
||
density="comfortable"
|
||
@update:page="logPage = $event; loadLogs()"
|
||
>
|
||
<template #item.status="{ item }">
|
||
<v-chip :color="item.status === 'success' ? 'success' : item.status === 'failed' ? 'error' : 'warning'" size="x-small" variant="tonal" label border="current sm">
|
||
{{ item.status === 'success' ? '成功' : item.status === 'failed' ? '失败' : item.status }}
|
||
</v-chip>
|
||
</template>
|
||
<template #item.created_at="{ item }">
|
||
<span class="text-caption text-medium-emphasis">{{ item.created_at }}</span>
|
||
</template>
|
||
<template #no-data>
|
||
<div class="text-center text-medium-emphasis py-6">暂无数据</div>
|
||
</template>
|
||
</v-data-table-server>
|
||
</v-card>
|
||
</v-container>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||
import { http } from '@/api'
|
||
import { useSnackbar } from '@/composables/useSnackbar'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import type { PaginatedResponse, PushItem } from '@/types/api'
|
||
|
||
const snackbar = useSnackbar()
|
||
const auth = useAuthStore()
|
||
|
||
// ── Server list with category ──
|
||
interface ServerItem {
|
||
id: number
|
||
name: string
|
||
domain?: string
|
||
status?: string
|
||
category?: string
|
||
}
|
||
const servers = ref<ServerItem[]>([])
|
||
|
||
|
||
async function loadServers() {
|
||
try {
|
||
const res = await http.get<{ items: ServerItem[] }>('/servers/', { per_page: 200 })
|
||
servers.value = res.items || []
|
||
} catch {
|
||
servers.value = []
|
||
}
|
||
}
|
||
|
||
// ── State ──
|
||
const sourcePath = ref('')
|
||
const targetPath = ref('')
|
||
const serverSearch = ref('')
|
||
const selectedIds = ref<Set<number>>(new Set())
|
||
const pushing = ref(false)
|
||
const pushProgress = ref(0)
|
||
const completedCount = ref(0)
|
||
const _pushTimers: ReturnType<typeof setTimeout>[] = []
|
||
const pushStatus = ref<Record<number, string>>({})
|
||
|
||
// ── Sync Mode ──
|
||
const syncMode = ref<'incremental' | 'full' | 'checksum'>('incremental')
|
||
const syncModeDesc = computed(() => {
|
||
switch (syncMode.value) {
|
||
case 'incremental': return '仅传输新增或修改的文件'
|
||
case 'full': return '使目标目录与源目录完全一致,会删除不存在于源的文件'
|
||
case 'checksum': return '通过文件内容校验和判断是否需要传输'
|
||
default: return ''
|
||
}
|
||
})
|
||
|
||
// ── ZIP Upload ──
|
||
const isDragging = ref(false)
|
||
const uploadedZip = ref<{ name: string; sourcePath: string; fileCount: number; size: number } | null>(null)
|
||
const zipUploading = ref(false)
|
||
const zipInput = ref<HTMLInputElement | null>(null)
|
||
|
||
function triggerZipInput() {
|
||
zipInput.value?.click()
|
||
}
|
||
|
||
async function handleZipDrop(e: DragEvent) {
|
||
isDragging.value = false
|
||
const file = e.dataTransfer?.files[0]
|
||
if (file && file.name.endsWith('.zip')) await uploadZip(file)
|
||
}
|
||
|
||
function handleZipSelect(e: Event) {
|
||
const file = (e.target as HTMLInputElement).files?.[0]
|
||
if (file) uploadZip(file)
|
||
}
|
||
|
||
async function uploadZip(file: File) {
|
||
zipUploading.value = true
|
||
try {
|
||
const form = new FormData()
|
||
form.append('file', file)
|
||
const res = await http.upload<{ source_path: string; file_count: number; size: number }>('/sync/upload-zip', form)
|
||
uploadedZip.value = { name: file.name, sourcePath: res.source_path, fileCount: res.file_count, size: res.size }
|
||
snackbar('ZIP 上传成功')
|
||
} catch (e: any) { snackbar(e.message || '上传失败', 'error') }
|
||
finally { zipUploading.value = false }
|
||
}
|
||
|
||
function clearZipUpload() { uploadedZip.value = null }
|
||
|
||
function formatSize(bytes: number) {
|
||
if (bytes < 1024) return bytes + ' B'
|
||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
|
||
return (bytes / 1048576).toFixed(1) + ' MB'
|
||
}
|
||
|
||
// ── WebSocket Progress ──
|
||
interface ServerProgress {
|
||
id: number
|
||
name: string
|
||
status: 'pending' | 'running' | 'success' | 'failed'
|
||
detail?: string
|
||
}
|
||
|
||
const wsProgressItems = ref<ServerProgress[]>([])
|
||
const pushBatchId = ref('')
|
||
let wsSocket: WebSocket | null = null
|
||
|
||
function getWsUrl(): string {
|
||
const base = import.meta.env.VITE_API_BASE || ''
|
||
if (base) {
|
||
const wsBase = base.replace(/^http/, 'ws')
|
||
return `${wsBase}/ws/alerts`
|
||
}
|
||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||
return `${proto}//${location.host}/ws/alerts`
|
||
}
|
||
|
||
function connectProgressWs() {
|
||
if (!auth.token) return
|
||
const url = `${getWsUrl()}?token=${auth.token}`
|
||
const socket = new WebSocket(url)
|
||
|
||
socket.onmessage = (event) => {
|
||
if (event.data === 'pong') return
|
||
try {
|
||
const msg = JSON.parse(event.data)
|
||
if (msg.type === 'ping') {
|
||
socket.send('pong')
|
||
return
|
||
}
|
||
if (msg.type === 'sync_progress' && msg.batch_id === pushBatchId.value) {
|
||
const idx = wsProgressItems.value.findIndex(p => p.id === msg.server_id)
|
||
if (idx !== -1) {
|
||
const item = wsProgressItems.value[idx]
|
||
if (msg.status === 'running') {
|
||
item.status = 'running'
|
||
item.detail = msg.detail || '同步中...'
|
||
} else if (msg.status === 'success') {
|
||
item.status = 'success'
|
||
item.detail = msg.detail || '完成'
|
||
} else if (msg.status === 'failed') {
|
||
item.status = 'failed'
|
||
item.detail = msg.detail || '失败'
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Ignore malformed messages
|
||
}
|
||
}
|
||
|
||
socket.onclose = () => {
|
||
wsSocket = null
|
||
}
|
||
|
||
wsSocket = socket
|
||
}
|
||
|
||
function disconnectProgressWs() {
|
||
if (wsSocket) {
|
||
wsSocket.close()
|
||
wsSocket = null
|
||
}
|
||
}
|
||
|
||
// ── Push Cancel ──
|
||
const cancelling = ref(false)
|
||
|
||
async function cancelPush() {
|
||
if (!pushBatchId.value) return
|
||
cancelling.value = true
|
||
try {
|
||
await http.post('/sync/cancel', { batch_id: pushBatchId.value })
|
||
// Mark all pending/running as cancelled
|
||
wsProgressItems.value.forEach(s => {
|
||
if (s.status === 'pending' || s.status === 'running') {
|
||
s.status = 'failed'
|
||
s.detail = '已取消'
|
||
}
|
||
})
|
||
snackbar('推送已取消')
|
||
} catch (e: unknown) {
|
||
const msg = e instanceof Error ? e.message : '取消失败'
|
||
snackbar(msg, 'error')
|
||
} finally {
|
||
cancelling.value = false
|
||
}
|
||
}
|
||
|
||
// Preview
|
||
const showPreview = ref(false)
|
||
interface PreviewData { error?: string; files?: number; size?: string }
|
||
const previewData = ref<PreviewData>({})
|
||
|
||
// Logs
|
||
const logs = ref<PushItem[]>([])
|
||
const logTotal = ref(0)
|
||
const logLoading = ref(false)
|
||
const logPage = ref(1)
|
||
|
||
const logHeaders = [
|
||
{ title: '时间', key: 'created_at', width: 180 },
|
||
{ title: '服务器', key: 'server_name' },
|
||
{ title: '状态', key: 'status', width: 100 },
|
||
{ title: '源路径', key: 'source_path' },
|
||
{ title: '目标路径', key: 'target_path' },
|
||
]
|
||
|
||
// ── Computed ──
|
||
const selectAll = computed({
|
||
get: () => servers.value.length > 0 && selectedIds.value.size === servers.value.length,
|
||
set: () => {},
|
||
})
|
||
|
||
const filteredServers = computed(() => {
|
||
if (!serverSearch.value) return servers.value
|
||
const q = serverSearch.value.toLowerCase()
|
||
return servers.value.filter(s => s.name.toLowerCase().includes(q) || (s.domain || '').toLowerCase().includes(q))
|
||
})
|
||
|
||
// ── Server Grouping by Category ──
|
||
const serversByCategory = computed(() => {
|
||
const groups: Record<string, ServerItem[]> = {}
|
||
for (const s of filteredServers.value) {
|
||
const cat = s.category || '未分类'
|
||
if (!groups[cat]) groups[cat] = []
|
||
groups[cat].push(s)
|
||
}
|
||
return Object.entries(groups).map(([category, servers]) => ({ category, servers }))
|
||
})
|
||
|
||
function isCategoryAllSelected(category: string): boolean {
|
||
const group = serversByCategory.value.find(g => g.category === category)
|
||
if (!group || group.servers.length === 0) return false
|
||
return group.servers.every(s => selectedIds.value.has(s.id))
|
||
}
|
||
|
||
function toggleCategory(category: string) {
|
||
const group = serversByCategory.value.find(g => g.category === category)
|
||
if (!group) return
|
||
const s = new Set(selectedIds.value)
|
||
const allSelected = group.servers.every(srv => s.has(srv.id))
|
||
for (const srv of group.servers) {
|
||
if (allSelected) s.delete(srv.id)
|
||
else s.add(srv.id)
|
||
}
|
||
selectedIds.value = s
|
||
}
|
||
|
||
// ── Actions ──
|
||
/** Effective source path: uploaded ZIP path takes priority over manual input */
|
||
function effectiveSourcePath(): string {
|
||
return uploadedZip.value?.sourcePath || sourcePath.value
|
||
}
|
||
|
||
function toggleServer(id: number) {
|
||
const s = new Set(selectedIds.value)
|
||
if (s.has(id)) s.delete(id); else s.add(id)
|
||
selectedIds.value = s
|
||
}
|
||
|
||
function toggleAll() {
|
||
if (selectedIds.value.size === servers.value.length) {
|
||
selectedIds.value = new Set()
|
||
} else {
|
||
selectedIds.value = new Set(servers.value.map(s => s.id))
|
||
}
|
||
}
|
||
|
||
async function doPreview() {
|
||
if (!effectiveSourcePath() || selectedIds.value.size === 0) {
|
||
snackbar('请选择服务器和源路径(或上传 ZIP 文件)', 'warning')
|
||
return
|
||
}
|
||
try {
|
||
previewData.value = await http.post('/sync/preview', {
|
||
source_path: effectiveSourcePath(),
|
||
target_path: targetPath.value,
|
||
server_id: selectedIds.value.values().next().value,
|
||
sync_mode: syncMode.value,
|
||
})
|
||
showPreview.value = true
|
||
} catch (e: any) {
|
||
previewData.value = { error: e.message }
|
||
showPreview.value = true
|
||
}
|
||
}
|
||
|
||
async function doPush() {
|
||
if (!effectiveSourcePath() || selectedIds.value.size === 0) {
|
||
snackbar('请选择服务器和源路径(或上传 ZIP 文件)', 'warning')
|
||
return
|
||
}
|
||
pushing.value = true
|
||
pushProgress.value = 0
|
||
completedCount.value = 0
|
||
pushStatus.value = {}
|
||
|
||
// Initialize WebSocket progress tracking
|
||
const ids = [...selectedIds.value]
|
||
wsProgressItems.value = ids.map(id => {
|
||
const srv = servers.value.find(s => s.id === id)
|
||
return { id, name: srv?.name || `#${id}`, status: 'pending' as const }
|
||
})
|
||
|
||
// Fire batch push and capture batch_id
|
||
try {
|
||
const res = await http.post<{ batch_id: string }>('/sync/files', {
|
||
source_path: effectiveSourcePath(),
|
||
target_path: targetPath.value,
|
||
server_ids: ids,
|
||
sync_mode: syncMode.value,
|
||
})
|
||
pushBatchId.value = res.batch_id || ''
|
||
if (pushBatchId.value) {
|
||
connectProgressWs()
|
||
}
|
||
} catch (e: any) {
|
||
// Batch push failed — fall back to per-server push (parallel, not sequential)
|
||
const total = ids.length
|
||
const results = await Promise.allSettled(
|
||
ids.map(async (sid) => {
|
||
try {
|
||
await http.post('/sync/files', {
|
||
source_path: effectiveSourcePath(),
|
||
target_path: targetPath.value,
|
||
server_ids: [sid],
|
||
sync_mode: syncMode.value,
|
||
})
|
||
pushStatus.value[sid] = '成功'
|
||
const idx = wsProgressItems.value.findIndex(p => p.id === sid)
|
||
if (idx !== -1) wsProgressItems.value[idx].status = 'success'
|
||
} catch (innerErr) {
|
||
pushStatus.value[sid] = '失败'
|
||
const idx = wsProgressItems.value.findIndex(p => p.id === sid)
|
||
if (idx !== -1) {
|
||
wsProgressItems.value[idx].status = 'failed'
|
||
wsProgressItems.value[idx].detail = (innerErr instanceof Error ? innerErr.message : '推送失败')
|
||
}
|
||
}
|
||
completedCount.value++
|
||
pushProgress.value = Math.round((completedCount.value / total) * 100)
|
||
})
|
||
)
|
||
pushing.value = false
|
||
loadLogs()
|
||
const succeeded = results.filter(r => r.status === 'fulfilled').length
|
||
snackbar(`推送完成:${succeeded}/${total} 台成功`)
|
||
return
|
||
}
|
||
|
||
// Monitor WS progress completion
|
||
const checkInterval = setInterval(() => {
|
||
const done = wsProgressItems.value.filter(p => p.status === 'success' || p.status === 'failed').length
|
||
completedCount.value = done
|
||
pushProgress.value = Math.round((done / wsProgressItems.value.length) * 100)
|
||
|
||
if (done >= wsProgressItems.value.length) {
|
||
clearInterval(checkInterval)
|
||
pushing.value = false
|
||
disconnectProgressWs()
|
||
loadLogs()
|
||
const failed = wsProgressItems.value.filter(p => p.status === 'failed').length
|
||
const success = wsProgressItems.value.filter(p => p.status === 'success').length
|
||
snackbar(`推送完成:${success} 成功,${failed} 失败`)
|
||
}
|
||
}, 500)
|
||
_pushTimers.push(checkInterval as unknown as ReturnType<typeof setTimeout>)
|
||
|
||
// Safety timeout: stop waiting after 5 minutes
|
||
const safetyTimer = setTimeout(() => {
|
||
if (pushing.value) {
|
||
clearInterval(checkInterval)
|
||
pushing.value = false
|
||
disconnectProgressWs()
|
||
loadLogs()
|
||
const done = wsProgressItems.value.filter(p => p.status === 'success' || p.status === 'failed').length
|
||
snackbar(`推送超时,${done}/${wsProgressItems.value.length} 台已完成`, 'warning')
|
||
}
|
||
}, 300000)
|
||
_pushTimers.push(safetyTimer)
|
||
}
|
||
|
||
async function loadLogs() {
|
||
logLoading.value = true
|
||
try {
|
||
const res = await http.get<PaginatedResponse<PushItem>>('/servers/logs', { page: logPage.value, per_page: 15 })
|
||
logs.value = res.items || []
|
||
logTotal.value = res.total || 0
|
||
} catch {
|
||
logs.value = []
|
||
} finally {
|
||
logLoading.value = false
|
||
}
|
||
}
|
||
|
||
function pushColor(status: string) {
|
||
return status === '成功' ? 'success' : status === '失败' ? 'error' : 'warning'
|
||
}
|
||
|
||
|
||
onMounted(() => {
|
||
loadServers()
|
||
loadLogs()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
disconnectProgressWs()
|
||
_pushTimers.forEach(t => clearTimeout(t))
|
||
_pushTimers.length = 0
|
||
})
|
||
|
||
</script>
|