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

## 涉及文件清单

| 文件 | 改动类型 | 涉及功能 |
|------|---------|---------|
| `server/infrastructure/telegram/__init__.py` | 新增函数 | F3 Telegram |
| `server/application/services/sync_engine_v2.py` | 修改 | F1 重试 + F3 Telegram |
| `server/api/websocket.py` | 修改参数 | F1 重试 |
| `server/api/sync_v2.py` | 新增端点 | F4 预览 |
| `server/api/schemas.py` | 新增模型 | F4 预览 |
| `web/app/push.html` | 修改 | F1+F2+F4+F5 前端 |
| `docs/changelog/2026-05-29-push-round2.md` | 新增 | changelog |

## 实现步骤

### Step 1: F5 同步模式详细说明（纯前端）

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

1. 同步模式 `</div>` 后增加 `<div id="syncModeDesc">` 说明文字
2. `onSyncModeChange()` 函数末尾增加说明文字更新逻辑
3. 默认显示增量同步说明

说明内容：
- 增量同步：仅传输源目录中新增或修改的文件，目标已有且未变的文件跳过。安全快速，适合日常更新。
- 全量同步：使目标目录与源目录完全一致，会删除目标中不存在于源的文件。⚠️ 可能造成数据丢失，建议先预览。
- 校验和：通过文件内容校验和（而非修改时间+大小）判断是否需要传输。更精确但更慢，适合时钟不同步的环境。

### Step 2: F2 服务器在线状态标识（纯前端）

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

1. `filterServers()` 中渲染 `<option>` 时，根据 `s.is_online` 加🟢/🔴前缀
2. 离线服务器选项加 `style="color: #666"` 提示
3. 数据来源：`/api/servers/` 已返回 `is_online`（从 Redis heartbeat 实时读取），无需改后端

### Step 3: F1 失败自动重试（后端+前端）

**后端**:

1. `server/application/services/sync_engine_v2.py` — `_sync_one()`:
   - 当 `sync_log.status == "failed"` 时，创建 `PushRetryJob`:
     ```python
     retry_job = PushRetryJob(
         server_id=server.id, server_name=server.name,
         source_path=source_path, target_path=dest,
         status="pending", retry_count=0, max_retries=3,
         next_retry_at=datetime.now(timezone.utc),
         last_error=sync_log.error_message[:500] if sync_log.error_message else None,
     )
     retry_job = await self.retry_repo.create(retry_job)
     ```
   - 将 `retry_job.id` 存入变量，传给 WS 广播

2. `server/api/websocket.py` — `broadcast_sync_progress()` 增加参数:
   - `retry_job_id: int = None`
   - WS 消息中增加 `retry_job_id` 字段

**前端**:

1. `updateProgressFromWS()` — 失败行渲染"🔄 重试"按钮
2. `updateProgressFromResult()` — 同上
3. 新增 `retryServer(retryJobId)` — 调用 `POST /api/retries/{id}/retry`
4. 存储映射 `_retryJobMap = {}` — `server_id → retry_job_id`

### Step 4: F3 推送完成 Telegram 通知（后端）

1. `server/infrastructure/telegram/__init__.py` — 新增函数:
   ```python
   async def send_telegram_sync_complete(
       completed: int, failed: int, total: int,
       source_path: str, operator: str,
       failed_servers: list[str] = None,
       duration_seconds: int = None,
   ) -> bool:
   ```
   - 消息格式：HTML，含 emoji 标识状态
   - 全部成功: 🟢 推送完成
   - 部分失败: 🟡 推送完成（部分失败）+ 失败服务器名列表
   - 全部失败: 🔴 推送失败
   - `source_path` 经 `sanitize_external_message()` 脱敏

2. `server/application/services/sync_engine_v2.py` — `sync_files()`:
   - `asyncio.gather()` 完成后，计算总耗时
   - 收集失败服务器名列表
   - 调用 `send_telegram_sync_complete()`
   - try/except 包裹，Telegram 失败不影响推送结果

### Step 5: F4 文件内容预览（后端+前端）

**后端**:

1. `server/api/schemas.py` — 新增:
   ```python
   class LocalFilePreview(BaseModel):
       path: str = Field(..., min_length=1)
   ```

2. `server/api/sync_v2.py` — 新增 `POST /api/sync/local-file-preview`:
   - 安全：`os.path.realpath()` + `startswith("/tmp/nexus_upload_")`
   - 限制：仅文件（非目录）+ 最多读 4KB
   - 返回：`{name, size, content_base64, truncated, encoding_hint}`
   - `encoding_hint`: 尝试 UTF-8 解码，成功 `"text"` 否则 `"binary"`
   - 审计日志

**前端**:

1. `renderFileManager()` — 文件名加 `cursor-pointer` + `onclick="previewLocalFile(path, name)"`
2. 新增 `previewLocalFile(path, name)`:
   - 调用 `/api/sync/local-file-preview`
   - 弹出模态框显示文件名、大小、内容
   - 二进制文件提示"无法预览"
3. 新增模态框 HTML：`#previewModal`

## 依赖与配置变更

无新依赖。无数据库 schema 迁移。

## 测试要点

1. F5: 切换同步模式 → 说明文字正确切换
2. F2: 在线服务器🟢、离线服务器🔴显示正确
3. F1: 推送失败 → 行内重试按钮 → 点击重试成功
4. F3: 推送完成 → Telegram 收到通知
5. F4: 点击文件 → 预览模态框显示内容
6. F4: 点击二进制文件 → 提示无法预览
7. F4 安全: 尝试预览 `/etc/passwd` → 403 拒绝

## 回滚方式

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