# 推送页重构设计文档

**日期**: 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` 派生：

```typescript
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 — 修复旋转图标

```html
<!-- 修复前 -->
<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`：

```typescript
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`（与搜索对齐）：

```typescript
const selectAll = computed(() =>
  filteredServers.value.length > 0 &&
  filteredServers.value.every(s => selectedIds.value.has(s.id))
)
```

### C5 — 新增 `cancelled` 状态

```typescript
type ServerProgressStatus = 'pending' | 'running' | 'success' | 'failed' | 'cancelled'

// cancelPush 中：
item.status = 'cancelled'
item.detail = '已取消'
```

模板增加对应图标和颜色：

```html
<v-icon v-else-if="s.status === 'cancelled'" color="grey">mdi-cancel</v-icon>
```

### C6 — toggleAll 只操作 filteredServers

```typescript
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`：

```typescript
import { buildWebSocketUrl } from '@/utils/wsUrl'

function connectProgressWs() {
  if (!auth.token) return
  const url = buildWebSocketUrl('/ws/alerts', { token: auth.token })
  // ...
}
```

### C7 — 修复 WS 字段名映射

```typescript
// 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`：

```python
# 在 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 — 修复预览对话框数据读取

```typescript
// 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`：
```html
<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 重入保护

```typescript
async function doPush() {
  if (pushing.value) return   // ← 新增
  if (!validateForm()) return
  // ...
}
```

### M4 — 分离 timers

```typescript
const _pushIntervals: ReturnType<typeof setInterval>[] = []
const _pushTimeouts: ReturnType<typeof setTimeout>[] = []

// onUnmounted:
_pushIntervals.forEach(t => clearInterval(t))
_pushTimeouts.forEach(t => clearTimeout(t))
```

### M5 — wsSocket 改为 ref

```typescript
const wsSocket = ref<WebSocket | null>(null)
```

### E1 — 目标路径验证

```typescript
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

```python
# sync_engine_v2.py
servers = await self.server_repo.get_by_ids(server_ids)  # 单次查询
```

需在 `ServerRepositoryImpl` 增加 `get_by_ids(ids: list[int])` 方法：
```python
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 推送完成后主动删除：

```python
# 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 驱动完成检测

```typescript
// 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 意外断线兜底

```typescript
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 次
