Add frontend backup upload and list-based restore with validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:03:18 +08:00
parent 481086eddc
commit 9379bc4f4f
7 changed files with 726 additions and 68 deletions
+134
View File
@@ -116,6 +116,140 @@
});
loadCtpFoldState();
function initBackupPanel() {
var uploadBtn = document.getElementById('backup-upload-btn');
var uploadInput = document.getElementById('backup-upload-file');
var statusEl = document.getElementById('backup-restore-status');
var pollTimer = null;
function setRestoreStatus(data) {
if (!statusEl || !data) return;
var state = data.state || 'idle';
statusEl.hidden = state === 'idle';
statusEl.classList.remove('is-running', 'is-error', 'is-done');
if (state === 'pending' || state === 'running') {
statusEl.classList.add('is-running');
statusEl.textContent = data.message || '恢复进行中…';
} else if (state === 'done') {
statusEl.classList.add('is-done');
statusEl.textContent = data.message || '恢复完成';
} else if (state === 'error') {
statusEl.classList.add('is-error');
statusEl.textContent = '恢复失败:' + (data.message || '未知错误');
} else {
statusEl.textContent = data.message || '';
}
}
function setBusy(busy) {
document.querySelectorAll('[data-backup-restore], #backup-upload-btn').forEach(function (btn) {
btn.disabled = !!busy;
});
}
function pollRestoreStatus() {
fetch('/api/backup/restore/status', { credentials: 'same-origin' })
.then(function (res) { return res.json(); })
.then(function (data) {
setRestoreStatus(data);
var active = data.state === 'pending' || data.state === 'running';
setBusy(active);
if (active) {
if (!pollTimer) {
pollTimer = window.setInterval(pollRestoreStatus, 2500);
}
} else if (pollTimer) {
window.clearInterval(pollTimer);
pollTimer = null;
if (data.state === 'done') {
window.setTimeout(function () { window.location.reload(); }, 1200);
}
}
})
.catch(function () { /* ignore */ });
}
if (uploadBtn && uploadInput && !uploadBtn.dataset.settingsBound) {
uploadBtn.dataset.settingsBound = '1';
uploadBtn.addEventListener('click', function () {
var file = uploadInput.files && uploadInput.files[0];
if (!file) {
window.alert('请先选择 .tar.gz 备份文件');
return;
}
if (!/\.tar\.gz$/i.test(file.name)) {
window.alert('仅支持 .tar.gz 格式');
return;
}
var form = new FormData();
form.append('file', file);
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中…';
fetch('/api/backup/upload', { method: 'POST', body: form, credentials: 'same-origin' })
.then(function (res) { return res.json().then(function (body) { return { ok: res.ok, body: body }; }); })
.then(function (result) {
if (!result.ok) {
throw new Error((result.body && result.body.error) || '上传失败');
}
window.alert('上传成功:' + (result.body.name || file.name));
window.location.reload();
})
.catch(function (err) {
window.alert(err.message || '上传失败');
})
.finally(function () {
uploadBtn.disabled = false;
uploadBtn.textContent = '上传并校验';
});
});
}
document.querySelectorAll('[data-backup-restore]').forEach(function (btn) {
if (btn.dataset.settingsBound) return;
btn.dataset.settingsBound = '1';
btn.addEventListener('click', function () {
var name = btn.getAttribute('data-backup-restore') || '';
if (!name) return;
var ok = window.confirm(
'确定要恢复备份「' + name + '」吗?\n\n'
+ '将停止服务并覆盖当前数据库、uploads 与 .env,完成后自动重启。\n'
+ '此操作不可撤销,请确认已做好当前数据备份。'
);
if (!ok) return;
var typed = window.prompt('请输入 RESTORE 确认恢复:');
if (typed !== 'RESTORE') {
if (typed !== null) window.alert('确认文字不正确,已取消');
return;
}
btn.disabled = true;
fetch('/api/backup/restore', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: name, confirm: 'RESTORE' })
})
.then(function (res) { return res.json().then(function (body) { return { ok: res.ok, body: body }; }); })
.then(function (result) {
if (!result.ok) {
throw new Error((result.body && result.body.error) || '恢复启动失败');
}
setRestoreStatus({ state: 'pending', message: result.body.message || '恢复已开始…' });
setBusy(true);
pollRestoreStatus();
})
.catch(function (err) {
window.alert(err.message || '恢复启动失败');
btn.disabled = false;
});
});
});
if (statusEl && !statusEl.hidden) {
pollRestoreStatus();
}
}
initBackupPanel();
var ctpForm = document.getElementById('ctp-settings-form');
if (ctpForm && !ctpForm.dataset.settingsBound) {
ctpForm.dataset.settingsBound = '1';
+56 -12
View File
@@ -72,6 +72,26 @@
.settings-backup-restore summary{cursor:pointer;color:var(--text-title);font-weight:600}
.settings-backup-meta{font-size:.82rem;color:var(--text-muted);line-height:1.55;margin:.35rem 0 .65rem}
.settings-backup-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .65rem}
.settings-backup-upload{
margin-top:.65rem;padding:.65rem .75rem;border-radius:8px;
border:1px dashed var(--border);background:var(--card-inner);
}
.settings-backup-upload label{font-size:.82rem;color:var(--text-muted);display:block;margin-bottom:.4rem}
.settings-backup-upload-row{display:flex;flex-wrap:wrap;align-items:center;gap:.45rem .6rem}
.settings-backup-upload input[type=file]{font-size:.78rem;max-width:100%}
.settings-backup-status{
margin-top:.55rem;padding:.55rem .7rem;border-radius:8px;font-size:.82rem;line-height:1.5;
border:1px solid var(--border);background:var(--card-inner);color:var(--text-muted);
}
.settings-backup-status.is-running{border-color:var(--accent);color:var(--text-title)}
.settings-backup-status.is-error{border-color:#c44;color:#c44}
.settings-backup-status.is-done{border-color:#3a8;color:#3a8}
.settings-backup-restore-btn{
border:1px solid #c44;background:transparent;color:#c44;cursor:pointer;
padding:.2rem .45rem;border-radius:6px;font-size:.75rem;font-weight:600;
}
.settings-backup-restore-btn:hover{background:rgba(204,68,68,.08)}
.settings-backup-restore-btn:disabled{opacity:.45;cursor:not-allowed}
.settings-backup-download{color:var(--accent);text-decoration:none;font-weight:600}
.settings-backup-download:hover{text-decoration:underline}
.settings-admin-row .settings-compact-card{font-size:.78rem}
@@ -508,6 +528,7 @@
自动备份目录:<code>{{ backup_dir }}</code>
{% if backup_last_at %} · 上次备份 {{ backup_last_at.replace('T', ' ') }}{% else %} · 尚未备份{% endif %}
{% if backup_running %} · <span style="color:var(--accent)">备份进行中…</span>{% endif %}
{% if backup_restore_running %} · <span style="color:#c44">恢复进行中…</span>{% endif %}
</p>
<form action="{{ url_for('settings') }}" method="post" style="margin-bottom:.55rem">
<input type="hidden" name="action" value="backup_config">
@@ -532,23 +553,47 @@
<div class="settings-backup-actions">
<form action="{{ url_for('settings') }}" method="post">
<input type="hidden" name="action" value="backup_now">
<button type="submit" class="btn-primary" {% if backup_running %}disabled{% endif %}>立即备份</button>
<button type="submit" class="btn-primary" {% if backup_running or backup_restore_running %}disabled{% endif %}>立即备份</button>
</form>
</div>
<p class="hint" style="margin:.5rem 0 0">备份含 <code>futures.db</code><code>uploads/</code><code>.env</code>,默认恢复至 <code>{{ backup_restore_dir }}</code></p>
<div class="settings-backup-upload" id="backup-upload-panel">
<label for="backup-upload-file">上传备份包(.tar.gz</label>
<div class="settings-backup-upload-row">
<input id="backup-upload-file" type="file" accept=".tar.gz,application/gzip,application/x-gzip">
<button type="button" class="btn-primary" id="backup-upload-btn" {% if backup_running or backup_restore_running %}disabled{% endif %}>上传并校验</button>
</div>
<p class="hint" style="margin:.45rem 0 0">上传后会校验 manifest 与包结构,通过后加入下方列表。</p>
</div>
<div class="settings-backup-status{% if restore_status.state in ('pending','running') %} is-running{% elif restore_status.state == 'error' %} is-error{% elif restore_status.state == 'done' %} is-done{% endif %}" id="backup-restore-status"{% if restore_status.state == 'idle' %} hidden{% endif %}>
{% if restore_status.state in ('pending','running') %}
{{ restore_status.message or '恢复进行中…' }}
{% elif restore_status.state == 'done' %}
{{ restore_status.message or '恢复完成' }}
{% elif restore_status.state == 'error' %}
恢复失败:{{ restore_status.message or '未知错误' }}
{% else %}
{{ restore_status.message }}
{% endif %}
</div>
<p class="hint" style="margin:.5rem 0 0">备份含 <code>futures.db</code> / <code>postgres_dump.sql</code><code>uploads/</code><code>.env</code>。网页恢复目标:<code>{{ backup_restore_dir }}</code></p>
{% if backup_items %}
<table class="settings-backup-table">
<table class="settings-backup-table" id="backup-items-table">
<thead>
<tr><th>文件名</th><th>大小</th><th>时间</th><th></th></tr>
<tr><th>文件名</th><th>类型</th><th>.env</th><th>大小</th><th>时间</th><th>操作</th></tr>
</thead>
<tbody>
{% for item in backup_items %}
<tr>
<tr data-backup-name="{{ item.name }}">
<td><code>{{ item.name }}</code></td>
<td>{{ item.backend_label or '—' }}</td>
<td>{% if item.includes_env %}有{% else %}—{% endif %}</td>
<td>{{ item.size_mb }} MB</td>
<td>{{ item.mtime.replace('T', ' ')[:16] }}</td>
<td><a href="{{ url_for('api_backup_download', filename=item.name) }}" class="settings-backup-download">下载</a></td>
<td>{{ (item.created_at or item.mtime).replace('T', ' ')[:16] }}</td>
<td class="settings-backup-actions" style="margin:0">
<a href="{{ url_for('api_backup_download', filename=item.name) }}" class="settings-backup-download">下载</a>
<button type="button" class="settings-backup-restore-btn" data-backup-restore="{{ item.name }}" {% if backup_running or backup_restore_running %}disabled{% endif %}>恢复此备份</button>
</td>
</tr>
{% endfor %}
</tbody>
@@ -560,11 +605,10 @@
<details class="settings-backup-restore">
<summary>备份恢复说明</summary>
<ol style="margin:.5rem 0 0 1rem;padding:0">
<li>下载 <code>.tar.gz</code> 到目标服务器(如 <code>/root/</code></li>
<li>解压:<code>tar -xzf qihuo_backup_*.tar.gz</code></li>
<li>执行:<code>chmod +x restore.sh &amp;&amp; ./restore.sh</code></li>
<li>指定目录:<code>RESTORE_DIR=/opt/qihuo ./restore.sh</code></li>
<li>恢复脚本会自动还原数据库、<code>uploads/</code><code>.env</code>,然后重启服务。</li>
<li>可在上方上传 <code>.tar.gz</code>,或从列表下载备份到本机</li>
<li>点击「恢复此备份」会停止服务、还原数据库 / <code>uploads/</code> / <code>.env</code>,然后自动重启。</li>
<li>恢复前请确认备份类型与当前服务一致(SQLite / PostgreSQL)。</li>
<li>也可在服务器手工执行包内 <code>restore.sh</code>(见 <code>RESTORE_DIR</code>)。</li>
</ol>
</details>
{% endcall %}