feat: WebUI 版本检测 — 顶栏按钮 + 设置页备份/回滚/更新管理
顶栏: - 版本号按钮, 有更新时变绿色并显示新版本号 - 每10分钟自动检查 设置页新增「版本与更新」区域: - 当前版本 + 更新状态徽章 - 检查更新 / 立即更新 按钮 - 备份列表 + 手动回滚 API: - GET /api/updates/backups — 备份列表 - POST /api/updates/rollback — 执行回滚 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,8 @@ def setup_routes(app, prefix=''):
|
|||||||
app.router.add_get(f'{prefix}/api/updates', panel_auth(get_updates))
|
app.router.add_get(f'{prefix}/api/updates', panel_auth(get_updates))
|
||||||
app.router.add_post(f'{prefix}/api/updates/check', panel_auth(check_now))
|
app.router.add_post(f'{prefix}/api/updates/check', panel_auth(check_now))
|
||||||
app.router.add_post(f'{prefix}/api/updates/apply', panel_auth(apply_update))
|
app.router.add_post(f'{prefix}/api/updates/apply', panel_auth(apply_update))
|
||||||
|
app.router.add_get(f'{prefix}/api/updates/backups', panel_auth(list_backups))
|
||||||
|
app.router.add_post(f'{prefix}/api/updates/rollback', panel_auth(do_rollback))
|
||||||
logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws (已加认证)")
|
logger.info(f"📡 系统状态WS端点已注册: {prefix}/api/system/ws (已加认证)")
|
||||||
|
|
||||||
def _read_version():
|
def _read_version():
|
||||||
@@ -160,3 +162,58 @@ async def apply_update(req):
|
|||||||
return web.json_response({"error": "更新服务未就绪"}, status=503)
|
return web.json_response({"error": "更新服务未就绪"}, status=503)
|
||||||
result = await us.apply_update()
|
result = await us.apply_update()
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_backups(req):
|
||||||
|
"""列出可用备份"""
|
||||||
|
import glob, os, time as _time
|
||||||
|
backup_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "backups")
|
||||||
|
backups = []
|
||||||
|
if os.path.isdir(backup_dir):
|
||||||
|
for f in sorted(glob.glob(os.path.join(backup_dir, "SenSu_backup_*.zip")), key=os.path.getmtime, reverse=True):
|
||||||
|
st = os.stat(f)
|
||||||
|
backups.append({
|
||||||
|
"name": os.path.basename(f),
|
||||||
|
"size_mb": round(st.st_size / 1048576, 1),
|
||||||
|
"time": _time.strftime("%Y-%m-%d %H:%M", _time.localtime(st.st_mtime)),
|
||||||
|
})
|
||||||
|
return web.json_response({"backups": backups})
|
||||||
|
|
||||||
|
|
||||||
|
async def do_rollback(req):
|
||||||
|
"""执行回滚"""
|
||||||
|
import glob, os, zipfile, shutil, tempfile
|
||||||
|
try:
|
||||||
|
data = await req.json()
|
||||||
|
backup_name = data.get("name", "")
|
||||||
|
except Exception:
|
||||||
|
backup_name = ""
|
||||||
|
backup_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "backups")
|
||||||
|
if backup_name:
|
||||||
|
backup_path = os.path.join(backup_dir, backup_name)
|
||||||
|
if not os.path.exists(backup_path):
|
||||||
|
return web.json_response({"ok": False, "error": "备份文件不存在"}, status=404)
|
||||||
|
else:
|
||||||
|
# 使用最新备份
|
||||||
|
backups = sorted(glob.glob(os.path.join(backup_dir, "SenSu_backup_*.zip")), key=os.path.getmtime, reverse=True)
|
||||||
|
if not backups:
|
||||||
|
return web.json_response({"ok": False, "error": "没有可用的备份"}, status=404)
|
||||||
|
backup_path = backups[0]
|
||||||
|
try:
|
||||||
|
project_root = os.path.join(os.path.dirname(__file__), "..", "..", "..")
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
with zipfile.ZipFile(backup_path, "r") as zf:
|
||||||
|
zf.extractall(tmp)
|
||||||
|
for item in os.listdir(tmp):
|
||||||
|
src = os.path.join(tmp, item)
|
||||||
|
dst = os.path.join(project_root, item)
|
||||||
|
if os.path.isdir(src):
|
||||||
|
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
# 写重启标记
|
||||||
|
restart_flag = os.path.join(project_root, ".restart_flag")
|
||||||
|
Path(restart_flag).touch()
|
||||||
|
return web.json_response({"ok": True, "msg": "回滚完成, 框架将重启"})
|
||||||
|
except Exception as e:
|
||||||
|
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="app" class="app-frame">
|
<div id="app" class="app-frame">
|
||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<div class="title">🐱 SenSu Alpha <span id="ver-badge" style="font-size:0.7em; opacity:0.5; margin-left:4px;"></span></div>
|
<div class="title" style="display:flex;align-items:center;gap:8px">
|
||||||
|
🐱 SenSu Alpha
|
||||||
|
<button id="top-ver-btn" class="btn btn-sm btn-text" style="font-size:.7em;padding:1px 6px" onclick="checkTopUpdate()" title="检查更新">v?</button>
|
||||||
|
</div>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span id="uname">Loading...</span>
|
<span id="uname">Loading...</span>
|
||||||
<button class="theme-toggle" onclick="toggleTheme()" title="切换日夜间模式">🌓</button>
|
<button class="theme-toggle" onclick="toggleTheme()" title="切换日夜间模式">🌓</button>
|
||||||
|
|||||||
@@ -12,6 +12,25 @@ function getCookie(name) {
|
|||||||
return match ? match[2] : '';
|
return match ? match[2] : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 顶部版本检查 ──
|
||||||
|
async function checkTopUpdate() {
|
||||||
|
var btn = document.getElementById("top-ver-btn");
|
||||||
|
try {
|
||||||
|
var r = await fetch("./api/updates"), d = await r.json();
|
||||||
|
btn.textContent = d.current_version || d.latest_version || "v?";
|
||||||
|
if (d.update_available) {
|
||||||
|
btn.textContent = "🔄 " + d.latest_version;
|
||||||
|
btn.style.color = "var(--success)";
|
||||||
|
btn.title = "新版本可用! " + d.latest_version;
|
||||||
|
} else {
|
||||||
|
btn.style.color = "";
|
||||||
|
btn.title = "已是最新";
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
setTimeout(checkTopUpdate, 3000);
|
||||||
|
setInterval(checkTopUpdate, 600000); // 10min
|
||||||
|
|
||||||
window.toggleTheme = function(){
|
window.toggleTheme = function(){
|
||||||
var t = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light";
|
var t = document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light";
|
||||||
document.documentElement.setAttribute("data-theme", t);
|
document.documentElement.setAttribute("data-theme", t);
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
<div class="plugin-page-root">
|
<div class="plugin-page-root">
|
||||||
<h2>⚙ 框架设置</h2>
|
<h2>⚙ 框架设置</h2>
|
||||||
|
|
||||||
|
<!-- ── 版本与更新 ── -->
|
||||||
|
<div class="card" style="margin-bottom:24px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||||
|
<h3 style="margin:0">🔄 版本与更新</h3>
|
||||||
|
</div>
|
||||||
|
<div id="update-area">
|
||||||
|
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
||||||
|
<span style="font-size:1.1rem;font-weight:600">SenSu <span id="ver-current">v?</span></span>
|
||||||
|
<span id="ver-badge" style="font-size:.85rem;padding:2px 8px;border-radius:12px;background:var(--md-sys-color-surface-container-lowest)">检查中...</span>
|
||||||
|
<button class="btn btn-sm btn-filled" onclick="checkUpdate()">检查更新</button>
|
||||||
|
<button id="btn-apply" class="btn btn-sm btn-filled" style="display:none;background:var(--success,#4caf50)" onclick="applyUpdate()">⬆ 立即更新</button>
|
||||||
|
</div>
|
||||||
|
<div id="update-notes" style="margin-top:8px;font-size:.85rem;color:var(--text-dim)"></div>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:16px;border-top:1px solid var(--border);padding-top:12px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||||
|
<h4 style="margin:0">📦 备份管理</h4>
|
||||||
|
<button class="btn btn-sm btn-outlined" onclick="loadBackups()">刷新列表</button>
|
||||||
|
</div>
|
||||||
|
<div id="backup-list"><span style="color:var(--text-dim);font-size:.85rem">加载中...</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── API Key 管理 ── -->
|
<!-- ── API Key 管理 ── -->
|
||||||
<div class="card" style="margin-bottom:24px">
|
<div class="card" style="margin-bottom:24px">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||||
@@ -58,9 +81,77 @@
|
|||||||
.input:focus { outline:1px solid var(--primary) }
|
.input:focus { outline:1px solid var(--primary) }
|
||||||
#apikey-tbody td { padding:8px; border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
|
#apikey-tbody td { padding:8px; border-bottom:1px solid var(--md-sys-color-surface-container-lowest) }
|
||||||
#apikey-tbody tr:hover { background:var(--md-sys-color-surface-container-lowest) }
|
#apikey-tbody tr:hover { background:var(--md-sys-color-surface-container-lowest) }
|
||||||
|
.backup-row { display:flex;align-items:center;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--md-sys-color-surface-container-lowest);gap:8px }
|
||||||
|
.backup-name { font-size:.85rem;word-break:break-all;flex:1;min-width:0 }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// ── 版本与更新 ──
|
||||||
|
async function loadVersion() {
|
||||||
|
try {
|
||||||
|
var r = await fetch("./api/updates"), d = await r.json();
|
||||||
|
document.getElementById("ver-current").textContent = d.current_version || d.latest_version || "?";
|
||||||
|
var badge = document.getElementById("ver-badge");
|
||||||
|
if (d.update_available) {
|
||||||
|
badge.textContent = "🔄 " + d.latest_version + " 可用";
|
||||||
|
badge.style.background = "var(--success,#4caf50)";
|
||||||
|
badge.style.color = "#fff";
|
||||||
|
document.getElementById("btn-apply").style.display = "inline-block";
|
||||||
|
document.getElementById("update-notes").innerHTML = d.latest_version ? "最新版本: <b>"+esc(d.latest_version)+"</b>" : "";
|
||||||
|
} else if (d.checked) {
|
||||||
|
badge.textContent = "✅ 已是最新";
|
||||||
|
badge.style.background = "var(--md-sys-color-surface-container-lowest)";
|
||||||
|
badge.style.color = "var(--text)";
|
||||||
|
document.getElementById("btn-apply").style.display = "none";
|
||||||
|
} else {
|
||||||
|
badge.textContent = "⏳ 等待检查...";
|
||||||
|
}
|
||||||
|
if (d.last_check) {
|
||||||
|
document.getElementById("update-notes").innerHTML += " <span style='font-size:.75rem;color:var(--text-dim)'>上次检查: "+new Date(d.last_check*1000).toLocaleString()+"</span>";
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkUpdate() {
|
||||||
|
document.getElementById("ver-badge").textContent = "⏳ 检查中...";
|
||||||
|
await fetch("./api/updates/check", {method:"POST"});
|
||||||
|
setTimeout(loadVersion, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyUpdate() {
|
||||||
|
if (!confirm("确认更新 SenSu?更新前会自动备份当前版本。")) return;
|
||||||
|
document.getElementById("btn-apply").disabled = true;
|
||||||
|
document.getElementById("btn-apply").textContent = "⏳ 更新中...";
|
||||||
|
try {
|
||||||
|
var r = await fetch("./api/updates/apply", {method:"POST"}), d = await r.json();
|
||||||
|
if (d.ok) alert("✅ " + d.msg);
|
||||||
|
else alert("❌ " + (d.error||"失败"));
|
||||||
|
} catch(e) { alert("❌ " + e); }
|
||||||
|
loadVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 备份管理 ──
|
||||||
|
async function loadBackups() {
|
||||||
|
var el = document.getElementById("backup-list");
|
||||||
|
try {
|
||||||
|
var r = await fetch("./api/updates/backups"), d = await r.json();
|
||||||
|
if (!d.backups.length) { el.innerHTML = '<span style="color:var(--text-dim);font-size:.85rem">暂无备份</span>'; return; }
|
||||||
|
el.innerHTML = d.backups.map(function(b,i){
|
||||||
|
return '<div class="backup-row"><span class="backup-name">'+esc(b.name)+'</span><span style="font-size:.8rem;color:var(--text-dim)">'+b.size_mb+'MB | '+b.time+'</span>'+
|
||||||
|
'<button class="btn btn-sm btn-outlined" onclick="doRollback(\''+esc(b.name)+'\')">回滚</button></div>';
|
||||||
|
}).join("");
|
||||||
|
} catch(e) { el.innerHTML = '<span style="color:var(--error)">加载失败</span>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRollback(name) {
|
||||||
|
if (!confirm("确认回滚到 "+name+"? 当前版本将被覆盖。")) return;
|
||||||
|
try {
|
||||||
|
var r = await fetch("./api/updates/rollback", {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({name:name})}), d = await r.json();
|
||||||
|
if (d.ok) alert("✅ " + d.msg);
|
||||||
|
else alert("❌ " + (d.error||"失败"));
|
||||||
|
} catch(e) { alert("❌ " + e); }
|
||||||
|
}
|
||||||
|
|
||||||
// ── 加载 API Keys ──
|
// ── 加载 API Keys ──
|
||||||
async function loadKeys() {
|
async function loadKeys() {
|
||||||
var tbody = document.getElementById("apikey-tbody");
|
var tbody = document.getElementById("apikey-tbody");
|
||||||
@@ -146,5 +237,7 @@
|
|||||||
|
|
||||||
// ── 初始加载 ──
|
// ── 初始加载 ──
|
||||||
loadKeys();
|
loadKeys();
|
||||||
|
loadVersion();
|
||||||
|
loadBackups();
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user