# 密码预设 实现计划

> ⚠️ **已归档** — 2026-05-22
> 本文档引用的文件结构已过时：`server/database.py` 已拆分为 `server/domain/models/`；PHP 前端 (`servers.php`/`settings.php`) 已迁移到 Tailwind CSS v4 + Alpine.js (`web/app/servers.html`/`settings.html`)；认证已从 X-API-Key 改为 JWT。功能已实现，保留本文档供历史参考。

> **面向 AI 代理的工作者：** 必需子技能：使用 superpowers:executing-plans 逐任务实现此计划。

**目标：** 提供加密存储的 SSH 密码预设列表，新建服务器时下拉选择自动填充

**架构：** PasswordPreset 模型复用 Fernet 加密；4 个 REST 端点挂在 servers 路由下；servers.php 加下拉联动；settings.php 加管理面板

**技术栈：** Python FastAPI + SQLAlchemy + PHP 7.4 + Fernet

---

### 任务 1：数据模型 + API 端点

**文件：** `server/database.py`、`server/api/servers.py`

- [ ] **步骤 1：database.py 添加 PasswordPreset 模型**

在 `Setting` 类之后、`init_db()` 之前插入：

```python
class PasswordPreset(Base):
    """SSH password preset — encrypted storage"""
    __tablename__ = "password_presets"

    id = Column(Integer, primary_key=True, autoincrement=True)
    name = Column(String(100), nullable=False, comment="预设名称")
    encrypted_pw = Column(String(500), nullable=False, comment="加密后的密码")
    created_at = Column(DateTime, default=datetime.datetime.utcnow)
```

- [ ] **步骤 2：servers.py 添加 4 个预设端点**

在 `servers.py` 导入 `PasswordPreset`：

```python
from server.database import get_db, Server, SyncLog, PasswordPreset, encrypt_value, decrypt_value
```

在文件末尾（`PushRequest` 类之前）添加：

```python
# --- Password Presets ---

class PresetCreate(BaseModel):
    name: str
    password: str


class PresetResponse(BaseModel):
    id: int
    name: str


@router.get("/password-presets", response_model=List[PresetResponse])
def list_presets(db: Session = Depends(get_db)):
    return db.query(PasswordPreset).order_by(PasswordPreset.id).all()


@router.post("/password-presets", response_model=PresetResponse)
def create_preset(data: PresetCreate, db: Session = Depends(get_db)):
    preset = PasswordPreset(name=data.name, encrypted_pw=encrypt_value(data.password))
    db.add(preset)
    db.commit()
    db.refresh(preset)
    return preset


@router.delete("/password-presets/{preset_id}")
def delete_preset(preset_id: int, db: Session = Depends(get_db)):
    preset = db.query(PasswordPreset).filter(PasswordPreset.id == preset_id).first()
    if not preset:
        raise HTTPException(status_code=404, detail="Preset not found")
    db.delete(preset)
    db.commit()
    return {"message": "deleted"}


@router.post("/password-presets/{preset_id}/reveal")
def reveal_preset(preset_id: int, db: Session = Depends(get_db)):
    preset = db.query(PasswordPreset).filter(PasswordPreset.id == preset_id).first()
    if not preset:
        raise HTTPException(status_code=404, detail="Preset not found")
    return {"password": decrypt_value(preset.encrypted_pw)}
```

- [ ] **步骤 3：验证编译 + Commit**

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

```bash
git add server/database.py server/api/servers.py
git commit -m "feat(presets): PasswordPreset 模型 + 4 个 CRUD/reveal 端点"
```

---

### 任务 2：settings.php 密码预设管理面板

**文件：** `web/settings.php`

- [ ] **步骤 1：在 settings.php 末尾（`</body>` 前）添加预设管理 HTML + JS**

找到 settings.php 中「修改密码」section 之后，添加新 section：

```php
<!-- 密码预设管理 -->
<div class="settings-section">
    <h3><i class="fas fa-key"></i> SSH 密码预设</h3>
    <p style="font-size:13px;color:#64748b;margin-bottom:12px;">保存常用 SSH 密码，新建服务器时快速选择</p>

    <div id="presetList" style="margin-bottom:16px;">加载中...</div>

    <form onsubmit="addPreset(event)" style="display:flex;gap:8px;align-items:flex-end;">
        <div class="form-group" style="flex:1;margin:0;">
            <label>名称</label>
            <input type="text" id="newPresetName" class="form-control" placeholder="如: 生产环境" required>
        </div>
        <div class="form-group" style="flex:1;margin:0;">
            <label>密码</label>
            <input type="password" id="newPresetPw" class="form-control" placeholder="SSH 密码" required>
        </div>
        <button type="submit" class="btn btn-primary"><i class="fas fa-save"></i> 添加</button>
    </form>
</div>

<script>
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 : '') ?>;

function loadPresets() {
    fetch(API_BASE + '/api/servers/password-presets', { headers: API_KEY ? {'X-API-Key': API_KEY} : {} })
        .then(r => r.json()).then(data => {
            const presets = data.data || data;
            let html = '';
            if (!presets.length) html = '<p style="color:#9ca3af;">暂无预设</p>';
            presets.forEach(p => {
                html += '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid #f3f4f6;">' +
                    '<span style="flex:1;">' + esc(p.name) + '</span>' +
                    '<button class="btn btn-sm btn-info" onclick="revealPreset(' + p.id + ', this)"><i class="fas fa-eye"></i> 查看</button>' +
                    '<button class="btn btn-sm btn-danger" onclick="deletePreset(' + p.id + ')"><i class="fas fa-trash"></i></button></div>';
            });
            document.getElementById('presetList').innerHTML = html;
        });
}

function addPreset(e) {
    e.preventDefault();
    const name = document.getElementById('newPresetName').value.trim();
    const pw = document.getElementById('newPresetPw').value;
    if (!name || !pw) return;
    fetch(API_BASE + '/api/servers/password-presets', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
        body: JSON.stringify({ name, password: pw })
    }).then(r => r.json()).then(() => {
        document.getElementById('newPresetName').value = '';
        document.getElementById('newPresetPw').value = '';
        loadPresets();
    });
}

function revealPreset(id, btn) {
    fetch(API_BASE + '/api/servers/password-presets/' + id + '/reveal', {
        method: 'POST', headers: { 'X-API-Key': API_KEY }
    }).then(r => r.json()).then(data => {
        const pw = (data.data || data).password;
        btn.outerHTML = '<code style="background:#fef3c7;padding:2px 8px;border-radius:4px;">' + esc(pw) + '</code>';
        setTimeout(loadPresets, 5000);
    });
}

function deletePreset(id) {
    if (!confirm('确定删除此预设？')) return;
    fetch(API_BASE + '/api/servers/password-presets/' + id, {
        method: 'DELETE', headers: { 'X-API-Key': API_KEY }
    }).then(() => loadPresets());
}

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

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

```bash
git add web/settings.php
git commit -m "feat(presets): settings.php 密码预设管理面板"
```

---

### 任务 3：servers.php 预设下拉联动

**文件：** `web/servers.php`

- [ ] **步骤 1：在密码字段上方添加预设下拉**

在 `passwordField` div 内部，密码 input 之前添加：

```php
<div id="presetSelector" style="margin-bottom:8px;">
    <label style="font-size:12px;color:#6b7280;">密码预设</label>
    <select id="presetSelect" class="form-control" onchange="fillPresetPassword()" style="font-size:13px;">
        <option value="">自定义（手动输入）</option>
    </select>
</div>
```

- [ ] **步骤 2：添加 JS 加载预设列表并联动填充**

在现有 `<script>` 块末尾添加：

```javascript
function loadPresetOptions() {
    fetch(API_BASE + '/api/servers/password-presets', { headers: API_KEY ? {'X-API-Key': API_KEY} : {} })
        .then(r => r.json()).then(data => {
            const presets = data.data || data;
            const sel = document.getElementById('presetSelect');
            presets.forEach(p => {
                const opt = document.createElement('option');
                opt.value = p.id;
                opt.textContent = p.name;
                sel.appendChild(opt);
            });
        });
}

function fillPresetPassword() {
    const id = document.getElementById('presetSelect').value;
    if (!id) return;
    fetch(API_BASE + '/api/servers/password-presets/' + id + '/reveal', {
        method: 'POST', headers: { 'X-API-Key': API_KEY }
    }).then(r => r.json()).then(data => {
        const pw = (data.data || data).password;
        document.querySelector('[name="password"]').value = pw;
    });
}

loadPresetOptions();
```

- [ ] **步骤 3：删除旧的 localStorage 逻辑**

删除 servers.php 中现有的 localStorage 代码（`nexus_default_ssh_pw` 相关 4 行），已被预设替代。

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

```bash
git add web/servers.php
git commit -m "feat(presets): servers.php 预设下拉联动填充密码"
```

---

### 任务 4：验证 + 更新 CHANGELOG

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

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

- [ ] **步骤 2：docs/CHANGELOG.md 追加记录**

```markdown
### 密码预设功能
- 新增 PasswordPreset 表，Fernet 加密存储
- 4 个 API 端点（列表/创建/删除/解密查看）
- settings.php 密码预设管理面板
- servers.php 预设下拉自动填充密码
```

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

```bash
git add docs/CHANGELOG.md
git commit -m "docs: 更新 CHANGELOG — 密码预设功能"
```

---

## 自检

- [x] 规格全覆盖：模型 ✓ 4 端点 ✓ servers 下拉 ✓ settings 管理 ✓
- [x] 无占位符/TODO
- [x] 类型一致：`PresetCreate` / `PresetResponse` 与端点签名匹配
- [x] decrypt_value 导入正确（通过 database 模块）
