# 2026-05-29 推送页面迭代 Round 3 — 技术文档

## 涉及文件清单

| 文件 | 改动类型 | 涉及功能 |
|------|---------|---------|
| `server/application/services/sync_engine_v2.py` | 修改 | G1 取消检查 |
| `server/api/sync_v2.py` | 新增端点 | G1 取消端点 |
| `server/api/schemas.py` | 新增模型 | G1 取消请求 |
| `server/api/servers.py` | 修改参数 | G4 历史筛选分页 |
| `web/app/push.html` | 修改 | G1-G6 全部前端 |
| `docs/changelog/2026-05-29-push-round3.md` | 新增 | changelog |

## 实现步骤

### Step 1: G1 推送取消（后端+前端）

**后端**:

1. `server/api/schemas.py` — 新增:
   ```python
   class SyncCancel(BaseModel):
       batch_id: str = Field(..., min_length=1, max_length=32)
   ```

2. `server/api/sync_v2.py` — 新增 `POST /api/sync/cancel`:
   - Redis `SET sync:cancel:{batch_id} 1 EX 3600`
   - 审计日志
   - 返回 `{"cancelled": true, "batch_id": ...}`

3. `server/application/services/sync_engine_v2.py`:
   - `_rsync_push()`: 执行前检查 `sync:cancel:{batch_id}`，存在则返回 `{"exit_code": -1, "stderr": "推送已取消"}`
   - `sync_files()`: `_sync_one()` 任务启动前（`async with sem` 之后、rsync 之前）检查取消标记
   - 取消的服务器 status=`"cancelled"`，不创建 retry job

**前端**:

1. `doPush()` 执行期间显示"取消推送"按钮
2. `cancelPush()` — 调用 `POST /api/sync/cancel`
3. `updateProgressFromWS()` — 处理 `status=cancelled`
4. 取消后按钮隐藏，已完成的服务器结果保留

### Step 2: G6 拖拽上传 ZIP（纯前端）

**文件**: `web/app/push.html`

1. 将 `#sourceUpload` 区域改为拖拽区：
   - `<div>` 加 `dragover/dragleave/drop` 事件
   - `dragover`: 高亮边框 `border-brand`
   - `drop`: 取 `ev.dataTransfer.files[0]`，调用 `uploadZip()` 逻辑
2. 保留 `<input type="file">` 隐藏，拖拽区 click 触发 `zipFile.click()`
3. 文件类型校验：仅 `.zip`

### Step 3: G5 浏览器推送完成通知（纯前端）

**文件**: `web/app/push.html`

1. `doPush()` 开始时请求 `Notification.requestPermission()`
2. 推送完成（`doPush()` finally 块前）发送通知:
   ```js
   if (Notification.permission === 'granted') {
     new Notification('Nexus 推送完成', {body: `${completed} 成功, ${failed} 失败`});
   }
   ```
3. 降级：授权拒绝时 `document.title` 闪烁（3 次交替 `🔔 Nexus` / `Nexus — 推送`）

### Step 4: G2 推送页面直接调度（纯前端）

**文件**: `web/app/push.html`

1. "开始推送"按钮旁加"⏰ 定时推送"按钮
2. 点击弹出简单模态框：`datetime-local` 选择器 + 确认按钮
3. 确认后调用 `POST /api/schedules/`，body 包含:
   - `name`: "手动推送-{时间}"
   - `schedule_type`: "push"
   - `run_mode`: "once"
   - `fire_at`: 选择的时间
   - `source_path`, `server_ids`, `sync_mode`: 从当前表单取
4. 成功后 toast 提示 + 链接到调度页面

### Step 5: G3 推送模板/收藏（纯前端）

**文件**: `web/app/push.html`

1. localStorage key: `nexus_push_templates` — JSON 数组
2. 模板结构: `{name, source_path, target_path, sync_mode, server_ids[]}`
3. UI:
   - 表单上方"模板"下拉框 + "保存当前配置"按钮
   - 选择模板 → 自动填充 source_path/target_path/sync_mode/server_ids
   - 下拉旁"删除"按钮删除模板
4. `saveTemplate()`: 弹出 prompt 输入名称 → 存入 localStorage
5. `loadTemplate()`: 从 localStorage 读取模板列表 → 渲染下拉框
6. `applyTemplate()`: 填充表单字段
7. `deleteTemplate()`: 从 localStorage 移除

### Step 6: G4 推送历史筛选（后端+前端）

**后端**:

1. `server/api/servers.py` — `list_sync_logs()`:
   - 新增参数: `status: Optional[str]`, `server_id: Optional[int]`, `page: int = 1`
   - 增加过滤条件到 SQLAlchemy 查询
   - 增加 `COUNT(*)` 查询获取总数
   - 返回增加: `total`, `page`, `pages`

**前端**:

1. 历史区增加筛选控件:
   - 状态下拉: 全部/成功/失败
   - 服务器搜索输入框
2. `loadHistory()` 增加参数传递
3. 分页控件: 上一页/下一页 + 页码显示

## 依赖与配置变更

无新依赖。无数据库 schema 迁移。
G1 需要已有的 Redis 连接。

## 测试要点

1. G1: 推送 10 台 → 取消 → 仅已完成的有结果，其余显示"已取消"
2. G1: 推送完成后再取消 → 无效果（batch_id 已失效）
3. G2: 定时推送 → 调度页出现对应任务 → 到时间自动执行
4. G3: 保存模板 → 刷新 → 选择模板 → 表单填充正确
5. G3: 删除模板 → 下拉框更新
6. G4: 筛选"失败" → 仅显示失败记录 → 分页翻页
7. G5: 推送完成 → 浏览器通知弹出 → 点击通知回到页面
8. G6: 拖拽 ZIP → 上传解压成功 → 文件管理器显示
9. G6: 拖拽非 ZIP → 提示仅支持 .zip

## 回滚方式

所有改动均为增量（新增端点/前端元素），回滚只需：
1. `git revert` 提交
2. `supervisorctl restart nexus`
