Files
Nexus/frontend/src/pages/PushPage.vue
T
Your Name 6183047fd6
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
fix: 全站 bug 审查修复 (前端9项 + 后端5项)
前端修复:
- 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>
2026-06-01 11:24:55 +08:00

634 lines
22 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">
<!-- 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>