# 简化推送模型 实现计划

> ⚠️ **已归档** — 2026-05-22
> 本文档引用的文件结构已过时：`server/database.py` 已拆分为 `server/domain/models/`；`server/api/tasks.py` 已删除；PHP 前端 (`servers.php`/`tasks.php`/`push.php`) 已迁移到 Tailwind CSS v4 + Alpine.js；sync_engine 已改为 asyncssh。功能已实现，保留本文档供历史参考。

> **面向 AI 代理的工作者：** 必需子技能：使用 superpowers:subagent-driven-development（推荐）或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框（`- [ ]`）语法来跟踪进度。

**目标：** 将复杂 SyncTask 模型简化为 Server.target_path + 批量 push API + 分类选择推送界面

**架构：** Server 模型新增 target_path/category 字段；删除 SyncTask 表；新增 POST /api/servers/push 批量推送端点；sync_engine 简化为纯单向 push；前端 servers.php 加入分类推送对话框

**技术栈：** Python FastAPI + SQLAlchemy + MySQL + PHP 7.4 + rsync

---

### 任务 1：数据库模型变更

**文件：**
- 修改：`server/database.py`（全文件）
- 修改：`server/config.py:27`（删 SYNC_INTERVAL_SECONDS）
- 修改：`.env.example:26-30`（删 trigger/sync 配置）

- [ ] **步骤 1：Server 模型加 target_path + category，删 SyncTask，调整 SyncLog**

修改 `server/database.py`，在 Server 模型的 `description` 字段后添加：

```python
    target_path = Column(String(500), nullable=True, comment="推送目标路径")
    category = Column(String(100), nullable=True, comment="服务器分类(如 Web服务器/API服务器)")
```

删除整个 SyncTask 类（第 112-141 行）。

修改 SyncLog 类：删除 `task_id` 列，添加 `source_path`、`target_path` 列：

```python
class SyncLog(Base):
    """Push operation log"""
    __tablename__ = "sync_logs"

    id = Column(Integer, primary_key=True, autoincrement=True)
    server_id = Column(Integer, ForeignKey("servers.id"), nullable=False)
    source_path = Column(String(500), nullable=False, comment="源目录")
    target_path = Column(String(500), nullable=False, comment="目标目录")
    trigger_type = Column(String(20), comment="触发类型: manual")
    status = Column(String(20), comment="pending/running/success/failed")
    files_total = Column(Integer, default=0)
    files_transferred = Column(Integer, default=0)
    files_skipped = Column(Integer, default=0)
    bytes_transferred = Column(Integer, default=0)
    duration_seconds = Column(Integer, default=0)
    diff_summary = Column(Text, nullable=True, comment="rsync输出摘要")
    error_message = Column(Text, nullable=True)
    started_at = Column(DateTime, default=datetime.datetime.utcnow)
    finished_at = Column(DateTime, nullable=True)

    server = relationship("Server", back_populates="sync_logs")
```

更新 Server 模型的 relationship（删 sync_tasks）：

```python
    sync_logs = relationship("SyncLog", back_populates="server", cascade="all, delete-orphan")
```

- [ ] **步骤 2：删 config.py 中 SYNC_INTERVAL_SECONDS**

```python
    # 删除以下行：
    # SYNC_INTERVAL_SECONDS: int = 300  # 5 minutes
```

- [ ] **步骤 3：更新 .env.example**

删 `NEXUS_SYNC_INTERVAL_SECONDS` 和 `NEXUS_TRIGGER_FILENAME` 行。

- [ ] **步骤 4：验证数据库初始化**

```bash
python -m py_compile server/database.py && python -m py_compile server/config.py && echo "OK"
```

- [ ] **步骤 5：Commit**

```bash
git add server/database.py server/config.py .env.example
git commit -m "refactor(db): 简化数据模型 — Server 加 target_path/category，删 SyncTask，调整 SyncLog"
```

---

### 任务 2：Python API 变更 — 删除 tasks 路由 + 新增 push 端点 + 清理 agent

**文件：**
- 删除：`server/api/tasks.py`
- 修改：`server/api/servers.py`（添加 push 端点 + ServerCreate/Update/Response 更新）
- 修改：`server/api/agent.py`（删除 /trigger 端点）
- 修改：`server/main.py`（删除 tasks 路由注册，更新 imports）

- [ ] **步骤 1：删除 tasks.py + 清理 main.py 中 tasks 路由**

```bash
rm server/api/tasks.py
```

修改 `server/main.py`，删除 `from server.api import servers, tasks, agent, auth` 中的 `tasks` → 改为 `from server.api import servers, agent, auth`。删除 `tasks.router` 的 `app.include_router` 调用（含 dependencies 行）。

- [ ] **步骤 2：更新 servers.py — Pydantic 模型加 target_path/category**

修改 `server/api/servers.py` 的 `ServerCreate`、`ServerUpdate`、`ServerResponse`：

```python
class ServerCreate(BaseModel):
    name: str
    domain: str
    port: int = 22
    username: str = "root"
    auth_method: str = "key"
    password: Optional[str] = None
    ssh_key_path: Optional[str] = None
    agent_port: int = 8601
    agent_api_key: Optional[str] = None
    description: Optional[str] = None
    target_path: Optional[str] = None       # 新增
    category: Optional[str] = None           # 新增

class ServerUpdate(BaseModel):
    # ... 所有已有字段 ...
    target_path: Optional[str] = None       # 新增
    category: Optional[str] = None           # 新增

class ServerResponse(BaseModel):
    id: int
    name: str
    domain: str
    port: int
    username: str
    auth_method: str
    agent_port: int
    description: Optional[str]
    target_path: Optional[str]              # 新增
    category: Optional[str]                 # 新增
    is_online: bool
    last_heartbeat: Optional[datetime]
    system_info: Optional[str]
    created_at: datetime
    updated_at: datetime
    class Config:
        from_attributes = True
```

- [ ] **步骤 3：servers.py 添加 POST /push 批量推送端点**

在 `server/api/servers.py` 末尾添加：

```python
from pydantic import BaseModel as PydanticBase

class PushRequest(PydanticBase):
    source_path: str
    server_ids: List[int]

@router.post("/push")
async def push_to_servers(req: PushRequest, db: Session = Depends(get_db)):
    """批量推送到多台服务器"""
    if not req.server_ids:
        raise HTTPException(status_code=400, detail="No servers selected")

    servers_list = db.query(Server).filter(Server.id.in_(req.server_ids)).all()
    if not servers_list:
        raise HTTPException(status_code=404, detail="No servers found")

    from server.services.sync_engine import push_to_server
    import asyncio

    tasks = []
    for server in servers_list:
        target = server.target_path or "/"
        tasks.append(push_to_server(server, req.source_path, target))

    results = await asyncio.gather(*tasks, return_exceptions=True)

    successes = sum(1 for r in results if isinstance(r, dict) and r.get("status") == "success")
    return {
        "total": len(servers_list),
        "success": successes,
        "failed": len(servers_list) - successes,
        "results": [
            {"server_id": s.id, "server_name": s.name, **r}
            if isinstance(r, dict) else
            {"server_id": s.id, "server_name": s.name, "status": "error", "message": str(r)}
            for s, r in zip(servers_list, results)
        ],
    }
```

- [ ] **步骤 4：删除 agent.py 中 /trigger 端点**

修改 `server/api/agent.py`，删除 `TriggerPayload` 类和 `@router.post("/trigger")` 整个函数。

- [ ] **步骤 5：验证编译**

```bash
python -m py_compile server/api/servers.py server/api/agent.py server/main.py && echo "OK"
```

- [ ] **步骤 6：Commit**

```bash
git add server/api/ server/main.py
git rm server/api/tasks.py 2>/dev/null
git commit -m "refactor(api): 删除 tasks 路由，新增 POST /api/servers/push，清理 agent trigger"
```

---

### 任务 3：服务层简化 — sync_engine 纯 push + 清理 ssh_direct + 删除 trigger_poller

**文件：**
- 修改：`server/services/sync_engine.py`
- 修改：`server/services/ssh_direct.py`
- 删除：`server/services/trigger_poller.py`
- 修改：`server/main.py`（启动逻辑中删除 trigger_poller 引用）

- [ ] **步骤 1：重写 sync_engine.py 为纯 push**

```python
"""Sync engine — push source directory to target server via rsync"""
import asyncio
from datetime import datetime
from typing import Dict, Any

from server.database import SessionLocal, SyncLog, Server
from server.config import settings


async def push_to_server(server: Server, source_path: str, target_path: str) -> Dict[str, Any]:
    """Push source_path to server:target_path via rsync over SSH"""
    db = SessionLocal()
    try:
        log = SyncLog(
            server_id=server.id,
            source_path=source_path,
            target_path=target_path,
            trigger_type="manual",
            status="running",
            started_at=datetime.utcnow(),
        )
        db.add(log)
        db.commit()
        db.refresh(log)

        ssh_opts = f"-p {server.port} -o ConnectTimeout={settings.HEALTH_CHECK_TIMEOUT}"
        if not settings.SSH_STRICT_HOST_CHECKING:
            ssh_opts += " -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
        if server.auth_method == "key" and server.ssh_key_path:
            ssh_opts += f" -i {server.ssh_key_path}"

        cmd = [
            "rsync", "-avz", "--stats",
            "-e", f"ssh {ssh_opts}",
            f"{source_path.rstrip('/')}/",
            f"{server.username}@{server.domain}:{target_path.rstrip('/')}/",
        ]

        start = datetime.utcnow()
        proc = await asyncio.create_subprocess_exec(
            *cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await proc.communicate()
        end = datetime.utcnow()

        output = stdout.decode()
        err = stderr.decode()
        duration = int((end - start).total_seconds())

        # 解析统计
        import re
        files_total = files_transferred = bytes_transferred = 0
        for line in output.split("\n"):
            if m := re.search(r'Number of files(?:\s+transferred)?:\s*([\d,]+)', line):
                if "transferred" in line:
                    files_transferred = int(m.group(1).replace(",", ""))
                else:
                    files_total = int(m.group(1).replace(",", ""))
            elif m := re.search(r'Total (?:transferred )?file size:\s*([\d,.]+)\s*bytes', line):
                bytes_transferred = int(m.group(1).replace(",", "").replace(".", ""))

        success = proc.returncode == 0
        status = "success" if success else "failed"

        log.status = status
        log.files_total = files_total
        log.files_transferred = files_transferred
        log.files_skipped = files_total - files_transferred
        log.bytes_transferred = bytes_transferred
        log.duration_seconds = duration
        log.diff_summary = output[-2000:] if len(output) > 2000 else output
        log.error_message = err if err else None
        log.finished_at = end
        db.commit()

        summary = f"Transferred {files_transferred} files ({bytes_transferred} bytes) in {duration}s"
        return {
            "status": status,
            "message": summary if success else f"Failed: {err[:200]}",
            "log_id": log.id,
            "duration": duration,
            "files_transferred": files_transferred,
            "bytes_transferred": bytes_transferred,
        }
    except Exception as e:
        if 'log' in locals():
            try:
                log.status = "failed"
                log.error_message = str(e)
                log.finished_at = datetime.utcnow()
                db.commit()
            except Exception:
                pass
        return {"status": "failed", "message": str(e)}
    finally:
        db.close()
```

这完全替换原有的 `compute_diff`、`execute_sync`、`trigger_sync_task` 函数。

- [ ] **步骤 2：清理 ssh_direct.py — 删除触发文件轮询**

删除 `ssh_trigger_poll_loop` 函数、`check_trigger_file`、`remove_trigger_file`、`check_triggers_on_server`、`list_remote_dir` 函数，以及 `_trigger_locks`、`_trigger_lock_key` 变量和 import `Dict`。

保留：`ssh_health_check_loop`、`check_server_health_ssh`、`SSHConfig`、`get_ssh_config`、`create_ssh_client`、`ssh_connection`、`exec_command`。

- [ ] **步骤 3：删除 trigger_poller.py**

```bash
rm server/services/trigger_poller.py
```

- [ ] **步骤 4：更新 main.py 启动逻辑**

修改 `server/main.py` 第 79-103 行，三种模式都删除 `trigger_poll_loop` 相关：

```python
if SYNC_MODE == "ssh_direct":
    from server.services.ssh_direct import ssh_health_check_loop
    background_tasks.append(asyncio.create_task(ssh_health_check_loop()))

elif SYNC_MODE == "agent":
    from server.services.health_checker import health_check_loop
    background_tasks.append(asyncio.create_task(health_check_loop()))

else:
    from server.services.ssh_direct import ssh_health_check_loop
    from server.services.health_checker import health_check_loop
    background_tasks.append(asyncio.create_task(ssh_health_check_loop()))
    background_tasks.append(asyncio.create_task(health_check_loop()))
```

- [ ] **步骤 5：验证编译**

```bash
python -m py_compile server/services/sync_engine.py server/services/ssh_direct.py server/main.py && echo "OK"
```

- [ ] **步骤 6：Commit**

```bash
git add server/services/sync_engine.py server/services/ssh_direct.py server/main.py
git rm server/services/trigger_poller.py
git commit -m "refactor(services): sync_engine 简化为纯 push，删除 trigger_poller，清理 ssh_direct"
```

---

### 任务 4：PHP API 客户端更新

**文件：**
- 修改：`web/api_client.php`
- 修改：`web/api_proxy.php`

- [ ] **步骤 1：api_client.php 删 task 方法，加 pushServers**

删除：`getTasks`、`getTask`、`createTask`、`updateTask`、`deleteTask`、`triggerSync`、`getTaskDiff`、`getTaskLogs` 方法。

删除 `getDashboardStats` 中的 tasks 部分，简化为只统计 servers。

新增方法：

```php
public function pushServers($sourcePath, $serverIds) {
    return $this->request('POST', '/api/servers/push', [
        'source_path' => $sourcePath,
        'server_ids' => $serverIds,
    ]);
}
```

- [ ] **步骤 2：api_proxy.php 删 task action，加 push**

删除 case：`get_tasks`、`get_task`、`trigger_sync`、`sync_all`、`get_diff`、`get_task_logs`、`get_logs`（logs 数据通过 sync_logs API 读取）。

新增 case：

```php
case 'push_servers':
    $input = json_decode(file_get_contents('php://input'), true);
    $result = api()->pushServers($input['source_path'] ?? '', $input['server_ids'] ?? []);
    break;
```

- [ ] **步骤 3：Commit**

```bash
git add web/api_client.php web/api_proxy.php
git commit -m "refactor(php): API 客户端删除 task 方法，新增 pushServers"
```

---

### 任务 5：前端 — servers.php 加入分类 + 目标路径 + 推送对话框

**文件：**
- 修改：`web/servers.php`

- [ ] **步骤 1：列表表头加分类和目标路径列**

在 servers.php 表格表头 `<th>Agent端口</th>` 后添加：

```php
<th>分类</th>
<th>目标路径</th>
```

在循环中对应位置添加：

```php
<td><?= htmlspecialchars($s['category'] ?? '-') ?></td>
<td class="path-cell"><?= htmlspecialchars($s['target_path'] ?? '-') ?></td>
```

- [ ] **步骤 2：编辑表单加分类下拉和目标路径输入框**

在 servers.php 的 `<form>` 中，`description` 字段前添加：

```php
<div class="form-row">
    <div class="form-group">
        <label>服务器分类</label>
        <input type="text" name="category" class="form-control"
               value="<?= htmlspecialchars($server['category'] ?? '') ?>"
               placeholder="如: Web服务器, API服务器"
               list="categoryList">
        <datalist id="categoryList">
            <option value="Web服务器">
            <option value="API服务器">
            <option value="数据库服务器">
            <option value="缓存服务器">
        </datalist>
    </div>
    <div class="form-group">
        <label>推送目标路径</label>
        <input type="text" name="target_path" class="form-control"
               value="<?= htmlspecialchars($server['target_path'] ?? '') ?>"
               placeholder="/var/www/html">
    </div>
</div>
```

- [ ] **步骤 3：添加 "推送" 按钮 + 推送对话框 CSS/HTML/JS**

在 servers.php 的顶部操作区添加：

```php
<button class="btn btn-success" onclick="openPushDialog()">
    <i class="fas fa-paper-plane"></i> 推送文件
</button>
```

添加推送对话框样式（参考 tasks.php 的 .fm-overlay 风格，更紧凑）：

```css
.push-overlay { display:none; position:fixed; top:0;left:0;right:0;bottom:0; background:rgba(0,0,0,0.4); z-index:9999; align-items:center;justify-content:center; }
.push-overlay.active { display:flex; }
.push-dialog { background:#fff; border-radius:8px; width:90%; max-width:700px; max-height:85vh; overflow-y:auto; box-shadow:0 20px 50px rgba(0,0,0,0.3); }
.push-dialog h3 { padding:16px 20px; margin:0; border-bottom:1px solid #e5e7eb; font-size:16px; }
.push-body { padding:20px; }
.push-body .src-row { display:flex; gap:8px; align-items:center; margin-bottom:16px; }
.push-body .src-row input { flex:1; }
.push-category { margin-bottom:12px; }
.push-cat-title { font-weight:600; font-size:14px; padding:8px 0; cursor:pointer; display:flex; align-items:center; gap:6px; }
.push-cat-title .count { font-weight:400; color:#9ca3af; font-size:12px; }
.push-cat-title .cat-check { margin-left:auto; font-weight:400; font-size:12px; color:#3b82f6; cursor:pointer; }
.push-server { display:flex; align-items:center; gap:8px; padding:6px 12px; margin-left:20px; font-size:13px; }
.push-server label { flex:1; cursor:pointer; }
.push-server .path { color:#6b7280; font-size:12px; }
.push-footer { padding:12px 20px; border-top:1px solid #e5e7eb; display:flex; align-items:center; gap:12px; }
.push-footer .btn-push { background:#22c55e; color:#fff; border:none; padding:10px 24px; border-radius:6px; font-weight:600; cursor:pointer; }
.push-footer .btn-push:hover { background:#16a34a; }
.push-footer .btn-push:disabled { background:#9ca3af; cursor:not-allowed; }
.push-footer .summary { flex:1; font-size:13px; color:#6b7280; }
```

推送对话框 HTML：

```html
<div class="push-overlay" id="pushOverlay" onclick="if(event.target===this)closePushDialog()">
    <div class="push-dialog">
        <h3>推送文件</h3>
        <div class="push-body">
            <div class="src-row">
                <input type="text" id="pushSourcePath" class="form-control"
                       placeholder="/www/wwwroot/你的项目" value="/www/wwwroot/">
                <button type="button" class="btn-browse" onclick="FM.open('source','pushSourcePath')"><i class="fas fa-folder-open"></i></button>
            </div>
            <div class="push-category" id="allCheckRow">
                <div class="push-cat-title">
                    <input type="checkbox" id="pushSelectAll" onchange="toggleAllServers()">
                    <label for="pushSelectAll">全选 (<span id="pushTotalCount">0</span> 台)</label>
                </div>
            </div>
            <div id="pushCategoryList"></div>
        </div>
        <div class="push-footer">
            <span class="summary" id="pushSummary">已选 0 台</span>
            <button class="btn btn-secondary" onclick="closePushDialog()">取消</button>
            <button class="btn-push" id="btnDoPush" onclick="doPush()">推送到 <span id="pushBtnCount">0</span> 台服务器</button>
        </div>
    </div>
</div>
```

推送 JS：

```javascript
let pushServersData = [];

function loadPushServers() {
    const API_BASE = <?= json_encode(defined('API_BASE_URL') ? API_BASE_URL : 'http://localhost:8600') ?>;
    const API_KEY = <?= json_encode(defined('API_KEY') ? API_KEY : '') ?>;
    fetch(API_BASE + '/api/servers/', { headers: API_KEY ? {'X-API-Key': API_KEY} : {} })
        .then(r => r.json()).then(data => {
            pushServersData = data.data || data;
            renderPushDialog();
        });
}

function renderPushDialog() {
    const grouped = {};
    pushServersData.forEach(s => {
        const cat = s.category || '未分类';
        if (!grouped[cat]) grouped[cat] = [];
        grouped[cat].push(s);
    });
    document.getElementById('pushTotalCount').textContent = pushServersData.length;

    let html = '';
    for (const [cat, servers] of Object.entries(grouped)) {
        html += '<div class="push-category"><div class="push-cat-title">' +
            '<input type="checkbox" class="cat-check-all" onchange="toggleCategory(this)" data-cat="' + esc(cat) + '">' +
            esc(cat) + ' <span class="count">(' + servers.length + ' 台)</span>' +
            '<span class="cat-check" onclick="checkCategory(\'' + esc(cat) + '\')">全选此分类</span>' +
            '</div>';
        servers.forEach(s => {
            html += '<div class="push-server">' +
                '<input type="checkbox" class="server-check" data-id="' + s.id + '" data-cat="' + esc(cat) + '" onchange="updatePushCount()">' +
                '<label>' + esc(s.name) + ' (' + esc(s.domain) + ')</label>' +
                '<span class="path">→ ' + esc(s.target_path || '/') + '</span>' +
                '</div>';
        });
        html += '</div>';
    }
    document.getElementById('pushCategoryList').innerHTML = html;
}

function openPushDialog() {
    document.getElementById('pushOverlay').classList.add('active');
    loadPushServers();
}
function closePushDialog() { document.getElementById('pushOverlay').classList.remove('active'); }

function toggleAllServers() {
    const all = document.getElementById('pushSelectAll').checked;
    document.querySelectorAll('.server-check').forEach(cb => cb.checked = all);
    document.querySelectorAll('.cat-check-all').forEach(cb => cb.checked = all);
    updatePushCount();
}

function toggleCategory(el) {
    const cat = el.dataset.cat;
    const checked = el.checked;
    document.querySelectorAll('.server-check[data-cat="' + esc(cat) + '"]').forEach(cb => cb.checked = checked);
    updatePushCount();
}

function checkCategory(cat) {
    document.querySelectorAll('.server-check[data-cat="' + esc(cat) + '"]').forEach(cb => cb.checked = true);
    document.querySelector('.cat-check-all[data-cat="' + esc(cat) + '"]').checked = true;
    updatePushCount();
}

function updatePushCount() {
    const checked = document.querySelectorAll('.server-check:checked').length;
    document.getElementById('pushSummary').textContent = '已选 ' + checked + ' 台';
    document.getElementById('pushBtnCount').textContent = checked;
    document.getElementById('pushSelectAll').checked = checked === pushServersData.length;
    document.getElementById('btnDoPush').disabled = checked === 0;
}

function doPush() {
    const ids = Array.from(document.querySelectorAll('.server-check:checked')).map(cb => parseInt(cb.dataset.id));
    if (!ids.length) return;
    const sp = document.getElementById('pushSourcePath').value.trim();
    if (!sp) { alert('请输入源目录'); return; }

    document.getElementById('btnDoPush').disabled = true;
    document.getElementById('btnDoPush').textContent = '推送中...';

    fetch('api_proxy.php?action=push_servers', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ source_path: sp, server_ids: ids })
    }).then(r => r.json()).then(data => {
        const d = data.data || data;
        alert('推送完成: 成功 ' + (d.success || 0) + ' / 失败 ' + (d.failed || 0));
        closePushDialog();
        location.reload();
    }).catch(e => {
        alert('推送失败: ' + e.message);
        document.getElementById('btnDoPush').disabled = false;
        document.getElementById('btnDoPush').innerHTML = '推送到 <span id="pushBtnCount">' + ids.length + '</span> 台服务器';
    });
}

function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
```

- [ ] **步骤 4：sidebar 导航删除 "同步任务" 链接，添加 "推送日志"**

```php
<li><a href="logs.php"><i class="fas fa-history"></i> <span>推送日志</span></a></li>
```

删掉 `<li><a href="tasks.php">...` 那一行。

- [ ] **步骤 5：Commit**

```bash
git add web/servers.php
git commit -m "feat(web): servers.php 加入分类/目标路径编辑 + 分类批量推送对话框"
```

---

### 任务 6：tasks.php → push.php + index.php + logs.php 更新

**文件：**
- 重命名：`web/tasks.php` → `web/push.php`
- 修改：`web/index.php`
- 修改：`web/logs.php`
- 修改：`web/servers.php`（sidebar 链接更新）

- [ ] **步骤 1：tasks.php 改为推送历史页**

```bash
mv web/tasks.php web/push.php
```

修改 `push.php`：删掉所有任务表单/编辑/添加/删除相关代码。保留表格结构，改为显示 SyncLog（推送历史）：

```php
$logs = [];
$resp = api()->getAllLogs(100);
$logs = $resp['data'] ?? [];
```

表格列改为：ID、服务器、源目录、目标目录、状态、文件数、传输大小、耗时、时间。

- [ ] **步骤 2：index.php 仪表盘更新**

删除 "活跃同步任务" 卡片。新增 "推送目标服务器" 卡片（统计有 target_path 的服务器数）。

dashboard stats 简化为：服务器总数、在线服务器、离线服务器、已配置推送目标。

- [ ] **步骤 3：logs.php 简化**

删除 task_id 筛选，保留 server_id 和 status 筛选。

- [ ] **步骤 4：所有页面 sidebar 链接更新**

将 `<a href="tasks.php">` 改为 `<a href="push.php">推送日志</a>`。
在 servers.php、index.php、push.php、logs.php、settings.php、deploy.php 的 sidebar 中统一修改。

- [ ] **步骤 5：Commit**

```bash
git add web/
git commit -m "refactor(web): tasks.php→push.php 推送历史页，仪表盘/日志简化，sidebar 统一"
```

---

### 任务 7：脚本和配置文件清理

**文件：**
- 修改：`install.sh`
- 修改：`deploy/install_central.sh`
- 修改：`deploy/install_agent.sh`
- 修改：`agent/install.sh`
- 修改：`agent/agent.sh`
- 修改：`agent/config.example.yaml`
- 修改：`web/agent/install.sh`
- 修改：`web/agent/agent.sh`
- 修改：`web/deploy.php`

- [ ] **步骤 1：agent 脚本删除 trigger 相关**

`agent/agent.sh` 删除 `notify_central` 函数和 `watch_directory` 中对 `TRIGGER_FILE` 的检测逻辑。保留 heartbeat。

`agent/install.sh` 和 `web/agent/install.sh` 删除 `NEXUS_TRIGGER_FILE` 环境变量行。

`agent/config.example.yaml` 删除 `trigger_file` 字段。

- [ ] **步骤 2：install.sh 清理**

删除 `--db-path` 参数和 `DB_PATH` 变量。更新 help 文本删除 trigger/cron 相关说明。

- [ ] **步骤 3：deploy/install_central.sh 清理**

删除 trigger/sync 相关 echo 提示。

- [ ] **步骤 4：deploy/install_agent.sh 清理**

删除 `trigger_file` 配置项。

- [ ] **步骤 5：web/deploy.php 清理**

删除 `.sync-trigger` 命令示例文本。

- [ ] **步骤 6：Commit**

```bash
git add agent/ deploy/ install.sh web/agent/ web/deploy.php
git commit -m "chore: 清理 scripts/config 中的 trigger/task 引用"
```

---

### 任务 8：最终验证和文档更新

**文件：**
- 修改：`docs/CHANGELOG.md`
- 修改：`README.md`（部分）
- 修改：`宝塔部署指南.md`（部分）

- [ ] **步骤 1：全量 Python 编译验证**

```bash
find server -name "*.py" | xargs python -m py_compile && echo "All Python OK"
```

- [ ] **步骤 2：更新 CHANGELOG.md**

追加迭代 2 记录。

- [ ] **步骤 3：更新 README.md 架构图和特性列表**

删除 sync_tasks / trigger_file / cron 相关描述。更新系统架构图。

- [ ] **步骤 4：Commit**

```bash
git add docs/ README.md 宝塔部署指南.md
git commit -m "docs: 更新 CHANGELOG/README/宝塔指南 适配简化推送模型"
```

---

## 自检

- [x] 规格全覆盖：数据模型 ✓ API ✓ 服务层 ✓ 前端 ✓ 脚本 ✓ 文档 ✓
- [x] 无占位符/TODO
- [x] 类型一致性：`push_to_server(server, source_path, target_path)` 签名在 sync_engine 和 servers.py 一致
- [x] 每个任务都有精确文件路径和完整代码
