14 KiB
14 KiB
推送页重构设计文档
日期: 2026-06-02
版本: v2.0(深度审计补充)
1. 已发现问题(逐行审计)
🔴 Critical(功能性 Bug)
| # | 问题 | 位置 | 现象 |
|---|---|---|---|
| C1 | 双进度条同时显示 | L130–172 | v-if="pushing" 的进度条 + WS 进度列表同时渲染,信息重复且相互矛盾 |
| C2 | mdi-loading 不旋转 |
L162 | running 状态图标使用 mdi-loading,该图标是静态的,无法表达"进行中"。正确写法是 mdi-loading mdi-spin 或 mdi-progress-clock |
| C3 | 服务器卡片状态从不更新(WS 路径) | L100–102 | WS 推送时 pushStatus[s.id] 永远不被赋值(只有 fallback 路径才写),服务器卡片上的 chip 始终为空 |
| C4 | selectAll computed setter 为空 |
L430–433 | v-model="selectAll" 绑定的 computed 的 set 是 () => {},Vue 内部赋值被吞掉,checkbox 双向绑定失效,UI 和状态不同步 |
| C5 | 取消推送把状态标为 'failed' |
L395–399 | cancelPush 将 pending/running 的 items 标记为 status = 'failed',混淆了"失败"和"已取消",历史图表/统计数据被污染 |
| C6 | toggleAll 忽略搜索过滤 |
L482–488 | 搜索时 toggleAll 操作的是 servers.value(全部),而非 filteredServers,导致搜索过滤后「全选」会选中不可见的服务器 |
🔴 深度审计追加 Critical(Frontend↔Backend 契约断裂)
| # | 问题 | 位置 | 现象 |
|---|---|---|---|
| C7 | WS 字段名不匹配:error_message vs detail |
PushPage.vue L356/359/363 vs websocket.py L492 |
后端广播 error_message,前端读取 msg.detail,错误信息从不显示 |
| C8 | 后端从不广播 running 状态 |
sync_engine_v2.py L198–214 |
broadcast_sync_progress 只在任务完成后调用,pending → 直接跳 success/failed,loading 动画永远不激活 |
| C9 | 预览 API 响应结构完全错配 | PushPage.vue L182 vs sync_engine_v2.py L314–325 |
后端返回 { stats: { files_total, ... }, files: string[] },前端期望 { files: number, size: string },预览对话框内容永远为空 |
| C10 | doPush 无重入保护 |
PushPage.vue L509 |
无 if (pushing.value) return 快速失败,快速双击可并发触发两次推送 |
🟡 Medium(架构 / 可维护性问题)
| # | 问题 | 位置 |
|---|---|---|
| M1 | WS URL 构造重复实现 | L328–336:内联了 getWsUrl(),不使用已有的 buildWebSocketUrl 工具,逻辑不一致 |
| M2 | 复用告警 WS(/ws/alerts)传输推送进度 |
L338–377:推送进度通过全局告警频道过滤 batch_id,理论上两个用户同时推送会串扰;WS 断开后 pending 状态永远不更新 |
| M3 | 预览只对第一台服务器执行 | L499:selectedIds.value.values().next().value,多选时只预览第一台,误导性强 |
| M4 | _pushTimers 混用 setTimeout/setInterval |
L261/L588:类型声明 setTimeout[],实际 push 了 setInterval 返回值,onUnmounted 里 clearTimeout 无法清理 interval,导致定时器泄漏 |
| M5 | wsSocket 是模块级 let 变量 |
L326:组件多次挂载/卸载不会重置,可能引用旧 socket |
| M6 | PushItem 类型定义与 API 响应脱节 |
types/api.ts L176–185:缺少 sync_mode、error_message、duration_seconds、operator、trigger_type 字段,后端实际都返回了 |
| M7 | doPush fallback 路径逻辑矛盾 |
L539–569:批量 API 只在网络层失败时触发 fallback,而此 fallback 又重新调相同的 /sync/files 但没接 WS,进度追踪完全失效;实际上此 fallback 几乎永远不会触发 |
| M8 | 后端 N+1 DB 查询 | sync_engine_v2.py L75–78:for sid in server_ids: await repo.get_by_id(sid),100 台服务器 = 100 次 DB 往返 |
| M9 | 无 /tmp/nexus_upload_* 清理机制 |
sync_v2.py L949:ZIP 解压后临时目录从不自动删除,服务器 /tmp 会随时间积累 |
| M10 | setInterval 完成检测可由 WS onmessage 替代 |
L573–587:用 500ms polling 检测推送完成,完全可以在 WS 消息里直接判断,消除不必要的定时器 |
| M11 | 单文件 633 行,无 composable/组件拆分 | 整个页面逻辑扁平 |
🟢 Minor(体验问题)
| # | 问题 |
|---|---|
| E1 | 目标路径为空时可以静默推送(无校验) |
| E2 | 推送历史表格缺少 sync_mode、error_message、duration_seconds 列 |
| E3 | WS 意外断线时(非 5min 超时),pending 项目无任何提示 |
| E4 | 取消推送后进度卡片仍可能显示取消按钮(条件判断未包含 cancelled) |
| E5 | 上传 ZIP 后 sourcePath 输入框仍可编辑,但 effectiveSourcePath() 会忽略它,UI 与逻辑不一致 |
| E6 | 服务器在线状态显示 DB 静态值,非 Redis 实时心跳状态 |
2. 修复方案
C1 — 合并两个进度区域
删除旧的 v-if="pushing" 进度条(含 pushProgress + completedCount)。
所有进度统一由 wsProgressItems 驱动,始终显示 per-server 粒度进度。
总进度 computed 由 wsProgressItems 派生:
const doneCount = computed(() => wsProgressItems.value.filter(p => p.status === 'success' || p.status === 'failed' || p.status === 'cancelled').length)
const totalProgress = computed(() => wsProgressItems.value.length ? Math.round(doneCount.value / wsProgressItems.value.length * 100) : 0)
C2 — 修复旋转图标
<!-- 修复前 -->
<v-icon v-else-if="s.status === 'running'" color="blue">mdi-loading</v-icon>
<!-- 修复后 -->
<v-icon v-else-if="s.status === 'running'" color="blue" class="mdi-spin">mdi-loading</v-icon>
C3 — WS 推送时同步更新服务器卡片
在 WS onmessage 处理中,同步写入 pushStatus:
if (msg.status === 'success') {
item.status = 'success'
pushStatus.value[msg.server_id] = '成功'
} else if (msg.status === 'failed') {
item.status = 'failed'
pushStatus.value[msg.server_id] = '失败'
}
C4 — 修复 selectAll 双向绑定
将 v-model="selectAll" 改为 :model-value="selectAll" + @update:model-value="toggleAll",
同时修复 selectAll computed 使其只检查 filteredServers(与搜索对齐):
const selectAll = computed(() =>
filteredServers.value.length > 0 &&
filteredServers.value.every(s => selectedIds.value.has(s.id))
)
C5 — 新增 cancelled 状态
type ServerProgressStatus = 'pending' | 'running' | 'success' | 'failed' | 'cancelled'
// cancelPush 中:
item.status = 'cancelled'
item.detail = '已取消'
模板增加对应图标和颜色:
<v-icon v-else-if="s.status === 'cancelled'" color="grey">mdi-cancel</v-icon>
C6 — toggleAll 只操作 filteredServers
function toggleAll() {
const visible = filteredServers.value
const allSelected = visible.every(s => selectedIds.value.has(s.id))
const s = new Set(selectedIds.value)
for (const srv of visible) {
if (allSelected) s.delete(srv.id)
else s.add(srv.id)
}
selectedIds.value = s
}
M1 — 统一 WS URL 构造
删除 getWsUrl(),改用 buildWebSocketUrl:
import { buildWebSocketUrl } from '@/utils/wsUrl'
function connectProgressWs() {
if (!auth.token) return
const url = buildWebSocketUrl('/ws/alerts', { token: auth.token })
// ...
}
C7 — 修复 WS 字段名映射
// onmessage 中统一用 error_message,不用 detail
if (msg.status === 'running') {
item.status = 'running'
item.detail = '同步中...'
} else if (msg.status === 'success') {
item.status = 'success'
item.detail = `完成${msg.duration_seconds ? `(${msg.duration_seconds}s)` : ''}`
pushStatus.value[msg.server_id] = '成功'
} else if (msg.status === 'failed') {
item.status = 'failed'
item.detail = msg.error_message || '失败' // ← 修复:error_message not detail
pushStatus.value[msg.server_id] = '失败'
} else if (msg.status === 'cancelled') {
item.status = 'cancelled'
item.detail = '已取消'
}
C8 — 后端补发 running 广播
在 sync_engine_v2.py 的 _sync_one 创建 SyncLog 后立即广播 running:
# 在 sync_log = await self.sync_log_repo.create(sync_log) 之后
try:
await broadcast_sync_progress(
batch_id=batch_id,
server_id=server.id,
server_name=server.name,
status="running",
completed=completed,
failed=failed,
total=total,
)
except Exception as e:
logger.debug(f"WS running broadcast failed for server {server.id}: {e}")
C9 — 修复预览对话框数据读取
// PreviewData 类型对齐后端响应
interface PreviewData {
error?: string
stats?: {
files_total?: number
files_created?: number
files_deleted?: number
files_transferred?: number
total_size_bytes?: number
transfer_size_bytes?: number
}
files?: string[]
files_truncated?: boolean
server_name?: string
}
模板改为读取 previewData.stats:
<div v-if="previewData.stats" class="text-body-2">
{{ previewData.stats.files_total ?? 0 }} 个文件,
将传输 {{ previewData.stats.files_transferred ?? 0 }} 个,
{{ formatSize(previewData.stats.transfer_size_bytes ?? 0) }}
</div>
C10 — doPush 重入保护
async function doPush() {
if (pushing.value) return // ← 新增
if (!validateForm()) return
// ...
}
M4 — 分离 timers
const _pushIntervals: ReturnType<typeof setInterval>[] = []
const _pushTimeouts: ReturnType<typeof setTimeout>[] = []
// onUnmounted:
_pushIntervals.forEach(t => clearInterval(t))
_pushTimeouts.forEach(t => clearTimeout(t))
M5 — wsSocket 改为 ref
const wsSocket = ref<WebSocket | null>(null)
E1 — 目标路径验证
function validateForm(): boolean {
if (!effectiveSourcePath()) {
snackbar('请选择源路径或上传 ZIP 文件', 'warning')
return false
}
if (!targetPath.value.trim()) {
snackbar('请填写目标路径', 'warning')
return false
}
if (selectedIds.value.size === 0) {
snackbar('请选择至少一台服务器', 'warning')
return false
}
return true
}
M8 — 后端批量查询替代 N+1
# sync_engine_v2.py
servers = await self.server_repo.get_by_ids(server_ids) # 单次查询
需在 ServerRepositoryImpl 增加 get_by_ids(ids: list[int]) 方法:
async def get_by_ids(self, ids: list[int]) -> list[Server]:
from sqlalchemy import select
result = await self.session.execute(
select(Server).where(Server.id.in_(ids))
)
return list(result.scalars().all())
M9 — 临时目录定期清理
在 background/ 添加清理任务,或在 ZIP 推送完成后主动删除:
# sync_engine_v2.py sync_files() 执行完后
if source_path.startswith("/tmp/nexus_upload_"):
import shutil
shutil.rmtree(source_path, ignore_errors=True)
logger.info(f"Cleaned up upload temp dir: {source_path}")
M10 — 消除 setInterval,改为 onmessage 驱动完成检测
// onmessage 中调用
function _checkPushComplete() {
const total = wsProgressItems.value.length
const done = wsProgressItems.value.filter(
p => p.status === 'success' || p.status === 'failed' || p.status === 'cancelled'
).length
if (total > 0 && done >= total) {
pushing.value = false
disconnectProgressWs()
loadLogs()
const failed = wsProgressItems.value.filter(p => p.status === 'failed').length
const cancelled = wsProgressItems.value.filter(p => p.status === 'cancelled').length
snackbar(`推送完成:${done - failed - cancelled} 成功,${failed} 失败,${cancelled} 取消`)
}
}
E3 — WS 意外断线兜底
socket.onclose = (ev) => {
wsSocket.value = null
// 非正常关闭(code !== 1000)且仍有 pending/running 项目时提示
if (ev.code !== 1000) {
wsProgressItems.value.forEach(p => {
if (p.status === 'pending' || p.status === 'running') {
p.status = 'failed'
p.detail = '连接中断'
}
})
snackbar('推送进度连接中断,部分服务器状态未知', 'warning')
pushing.value = false
loadLogs()
}
}
3. 拆分规划(组件/composable)
frontend/src/
├── pages/
│ └── PushPage.vue (~120行,仅组合)
├── composables/push/
│ ├── usePushSessions.ts (WS 连接、进度追踪、取消)
│ ├── usePushForm.ts (源路径、目标路径、同步模式、校验)
│ └── usePushLogs.ts (历史记录分页加载)
└── components/push/
├── PushServerGrid.vue (服务器分组网格 + 搜索 + 全选)
├── PushProgressList.vue (进度列表 + 取消按钮)
└── PushHistoryTable.vue (历史记录表格)
4. 实施阶段
| 阶段 | 范围 | 涉及文件 | 优先级 |
|---|---|---|---|
| R1 | C1–C6:前端 Critical Bug | PushPage.vue |
最高 |
| R2 | C7–C10 + M1/M4/M5/M10 | PushPage.vue、websocket.py、sync_engine_v2.py |
高(含 Backend) |
| R3 | M6/M8/M9 + PushItem 类型补全 + 临时目录清理 |
types/api.ts、server_repo.py、sync_engine_v2.py |
中 |
| R4 | E1–E6 体验细节 + 推送历史表补列 | PushPage.vue |
中 |
| R5 | 组件/composable 拆分(M11/M7) | 新建 composables/push/、components/push/ |
低(架构优化) |
5. 验收标准
功能正确性
- 推送过程中只显示一个进度区域
running状态显示旋转动画(后端广播 + 前端渲染)- 进度列表中失败服务器显示具体错误信息
- 预览对话框正确显示文件数量和传输大小
- 推送时服务器卡片 chip 实时更新(WS 路径)
- 取消推送后状态为「已取消」而非「失败」
交互正确性
- 全选 checkbox 与 Vue 双向绑定正常工作
- 搜索后全选仅选中可见服务器
- 快速双击推送按钮不触发重复推送
- 目标路径为空时阻止推送并提示
资源管理
- WS 意外断线时标记未完成项目并提示用户
onUnmounted正确清理所有 interval 和 timeout(无 setInterval 泄漏)- ZIP 推送完成后临时目录自动清理
性能
- 批量推送 DB 查询从 N 次降到 1 次