787 lines
28 KiB
Markdown
787 lines
28 KiB
Markdown
# 简化推送模型 实现计划
|
||
|
||
> ⚠️ **已归档** — 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] 每个任务都有精确文件路径和完整代码
|