完善了Web管理端
This commit is contained in:
+160
-60
@@ -2,35 +2,76 @@ package admin
|
||||
|
||||
import (
|
||||
"Embalming_Girl_Server/db"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const AdminPassword = "admin123"
|
||||
const AdminPassword = "Qwer123456789!"
|
||||
|
||||
var adminSession string
|
||||
|
||||
func genSession() string {
|
||||
b := make([]byte, 32)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func isAuth(r *http.Request) bool {
|
||||
c, err := r.Cookie("admin_session")
|
||||
return err == nil && c.Value != "" && c.Value == adminSession
|
||||
}
|
||||
|
||||
func authWrap(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !isAuth(r) {
|
||||
w.WriteHeader(401)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
h(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func Register(mux *http.ServeMux, getStats func() map[string]interface{}) {
|
||||
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, adminHTML)
|
||||
w.Write([]byte(adminHTML))
|
||||
})
|
||||
mux.HandleFunc("/api/admin/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if req.Password != AdminPassword {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false})
|
||||
return
|
||||
}
|
||||
adminSession = genSession()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "admin_session", Value: adminSession,
|
||||
Path: "/", MaxAge: 86400, HttpOnly: true,
|
||||
})
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
})
|
||||
mux.HandleFunc("/api/admin/stats", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/api/admin/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
adminSession = ""
|
||||
http.SetCookie(w, &http.Cookie{Name: "admin_session", Value: "", Path: "/", MaxAge: -1})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
})
|
||||
mux.HandleFunc("/api/admin/check_session", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": isAuth(r)})
|
||||
})
|
||||
mux.HandleFunc("/api/admin/stats", authWrap(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(getStats())
|
||||
})
|
||||
mux.HandleFunc("/api/admin/users", func(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := db.DB.Query("SELECT id, username, nickname, bio, avatar, created_at, last_login FROM users ORDER BY id DESC")
|
||||
}))
|
||||
mux.HandleFunc("/api/admin/users", authWrap(func(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := db.DB.Query("SELECT id,username,nickname,bio,avatar,created_at,last_login FROM users ORDER BY id DESC")
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode([]int{})
|
||||
return
|
||||
@@ -49,70 +90,129 @@ func Register(mux *http.ServeMux, getStats func() map[string]interface{}) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(users)
|
||||
})
|
||||
mux.HandleFunc("/api/admin/delete_user", func(w http.ResponseWriter, r *http.Request) {
|
||||
}))
|
||||
mux.HandleFunc("/api/admin/delete_user", authWrap(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
db.DB.Exec("DELETE FROM sessions WHERE user_id=?", req.ID)
|
||||
db.DB.Exec("DELETE FROM users WHERE id=?", req.ID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
const adminHTML = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>管理面板</title>
|
||||
const adminHTML = `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>Embalming Girl - Admin</title>
|
||||
<style>
|
||||
:root{--bg:#0f1117;--bg2:#161923;--bg3:#1e2231;--bg4:#272d3d;--border:#2a3148;--text:#d1d5e0;--text2:#8891a5;--accent:#6c9fff;--accent2:#4a7adc;--danger:#e5534b;--success:#56d364;--gold:#d4a72c;--radius:10px;--shadow:0 2px 12px rgba(0,0,0,0.3)}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#1a1f2e;color:#e0e0e0;font-family:system-ui;padding:20px}
|
||||
h1{color:#5eb3e6;margin-bottom:20px;text-align:center}
|
||||
.card{background:#252b3d;border-radius:12px;padding:20px;margin-bottom:16px}
|
||||
.card h2{color:#d4c5a3;margin-bottom:12px;font-size:18px}
|
||||
.stats{display:flex;gap:16px;flex-wrap:wrap}
|
||||
.stat{background:#1a1f2e;border-radius:8px;padding:16px;text-align:center;min-width:120px}
|
||||
.stat .num{font-size:28px;color:#5eb3e6;font-weight:bold}
|
||||
.stat .label{font-size:12px;color:#8a8f9d;margin-top:4px}
|
||||
table{width:100%;border-collapse:collapse;margin-top:8px}
|
||||
th,td{padding:8px 12px;text-align:left;border-bottom:1px solid #2a3040}
|
||||
th{color:#8a8f9d;font-size:12px}
|
||||
.btn{padding:4px 12px;border:none;border-radius:4px;cursor:pointer;font-size:12px}
|
||||
.btn-danger{background:#d94f5c;color:white}
|
||||
.btn-danger:hover{background:#c0392b}
|
||||
#loginBox{max-width:300px;margin:100px auto;text-align:center}
|
||||
#loginBox input{width:100%;padding:10px;margin:8px 0;border-radius:6px;border:1px solid #3a3f4e;background:#1a1f2e;color:white}
|
||||
#loginBox button{width:100%;padding:10px;background:#5eb3e6;border:none;border-radius:6px;color:white;cursor:pointer;font-size:14px}
|
||||
#main{display:none}
|
||||
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;display:flex;min-height:100vh;font-size:14px;line-height:1.5}
|
||||
::selection{background:var(--accent);color:#fff}
|
||||
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||
.sidebar{width:220px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
|
||||
.sidebar-header{padding:20px;border-bottom:1px solid var(--border)}
|
||||
.sidebar-header h1{font-size:15px;color:var(--accent);font-weight:600}
|
||||
.sidebar-header p{font-size:11px;color:var(--text2);margin-top:2px}
|
||||
.sidebar-nav{flex:1;padding:8px}
|
||||
.sidebar-nav a{display:flex;align-items:center;gap:10px;padding:10px 14px;color:var(--text2);text-decoration:none;border-radius:8px;font-size:13px;cursor:pointer;transition:all .15s;margin-bottom:2px}
|
||||
.sidebar-nav a:hover{background:var(--bg3);color:var(--text)}
|
||||
.sidebar-nav a.active{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 2px 8px rgba(108,159,255,0.3)}
|
||||
.sidebar-nav a .icon{font-size:16px;width:20px;text-align:center}
|
||||
.sidebar-footer{padding:12px 8px;border-top:1px solid var(--border)}
|
||||
.sidebar-footer a{display:flex;align-items:center;gap:10px;padding:10px 14px;color:var(--danger);text-decoration:none;border-radius:8px;font-size:13px;cursor:pointer;transition:all .15s}
|
||||
.sidebar-footer a:hover{background:rgba(229,83,75,0.1)}
|
||||
.main{flex:1;overflow-y:auto}.main-header{padding:20px 28px 0;margin-bottom:20px}
|
||||
.main-header h2{font-size:20px;font-weight:600}.main-header p{font-size:12px;color:var(--text2);margin-top:2px}
|
||||
.main-body{padding:0 28px 28px}
|
||||
.page{display:none;animation:fadeIn .2s}.page.active{display:block}
|
||||
@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
|
||||
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:20px}
|
||||
.stat-card{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius);padding:18px;transition:transform .15s,box-shadow .15s}
|
||||
.stat-card:hover{transform:translateY(-2px);box-shadow:var(--shadow)}
|
||||
.stat-card .value{font-size:30px;font-weight:700;color:var(--accent);line-height:1.2}
|
||||
.stat-card .label{font-size:12px;color:var(--text2);margin-top:4px}
|
||||
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:16px;overflow:hidden}
|
||||
.card-header{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
|
||||
.card-header h3{font-size:14px;font-weight:600}
|
||||
.card-body{padding:18px}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th{text-align:left;padding:10px 14px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text2);border-bottom:1px solid var(--border);font-weight:500}
|
||||
td{padding:10px 14px;border-bottom:1px solid rgba(42,49,72,0.5);font-size:13px}
|
||||
tr:hover td{background:rgba(108,159,255,0.03)}tr:last-child td{border-bottom:none}
|
||||
.btn{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500;transition:all .15s;display:inline-flex;align-items:center;gap:5px}
|
||||
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent2);box-shadow:0 2px 8px rgba(108,159,255,0.25)}
|
||||
.btn-danger{background:transparent;color:var(--danger);border:1px solid var(--danger)}.btn-danger:hover{background:var(--danger);color:#fff}
|
||||
.btn-sm{padding:4px 10px;font-size:11px}
|
||||
.btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border)}.btn-ghost:hover{background:var(--bg3);color:var(--text)}
|
||||
input,textarea{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:9px 13px;color:var(--text);font-size:13px;width:100%;transition:border-color .15s;outline:none;font-family:inherit}
|
||||
input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(108,159,255,0.1)}
|
||||
textarea{resize:vertical;min-height:80px}
|
||||
label{display:block;font-size:12px;color:var(--text2);margin:12px 0 5px;font-weight:500}
|
||||
input[type=file]{padding:8px}input[type=checkbox]{width:auto}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500}
|
||||
.badge-green{background:rgba(86,211,100,0.12);color:var(--success)}
|
||||
.badge-red{background:rgba(229,83,75,0.12);color:var(--danger)}
|
||||
.badge-blue{background:rgba(108,159,255,0.12);color:var(--accent)}
|
||||
.update-box{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:18px}
|
||||
.update-box .ver{font-size:22px;font-weight:700;color:var(--accent)}
|
||||
.update-box .meta{color:var(--text2);font-size:12px;margin:6px 0}
|
||||
.update-box pre{background:var(--bg3);padding:12px;border-radius:8px;font-size:12px;color:var(--text);white-space:pre-wrap;margin:10px 0;border:1px solid var(--border);font-family:Consolas,Monaco,monospace}
|
||||
.mono{font-family:Consolas,Monaco,monospace}
|
||||
#loginBox{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg);z-index:99}
|
||||
.login-card{background:var(--bg2);border:1px solid var(--border);padding:36px;border-radius:16px;width:340px;box-shadow:0 8px 32px rgba(0,0,0,0.4)}
|
||||
.login-card h2{font-size:18px;color:var(--accent);text-align:center;margin-bottom:4px}
|
||||
.login-card p{font-size:12px;color:var(--text2);text-align:center;margin-bottom:20px}
|
||||
.login-card .btn{width:100%;justify-content:center;padding:10px;font-size:14px;margin-top:12px}
|
||||
.login-card .error{color:var(--danger);font-size:12px;text-align:center;margin-top:8px;min-height:16px}
|
||||
#app{display:none;width:100%}
|
||||
</style></head><body>
|
||||
<div id="loginBox"><h1>管理面板</h1><input id="pw" type="password" placeholder="管理密码">
|
||||
<button onclick="doLogin()">登录</button></div>
|
||||
<div id="main"><h1>Embalming Girl 管理面板</h1>
|
||||
<div class="card"><h2>服务器状态</h2><div class="stats" id="stats"></div></div>
|
||||
<div class="card"><h2>注册用户</h2><table><thead><tr><th>ID</th><th>用户名</th><th>昵称</th><th>头像</th><th>简介</th><th>注册时间</th><th>操作</th></tr></thead><tbody id="users"></tbody></table></div>
|
||||
</div>
|
||||
<div id="loginBox"><div class="login-card">
|
||||
<h2>Embalming Girl</h2><p>Server Admin Panel</p>
|
||||
<input id="pw" type="password" placeholder="管理密码" onkeydown="if(event.key==='Enter')doLogin()">
|
||||
<button class="btn btn-primary" onclick="doLogin()">登 录</button>
|
||||
<div class="error" id="loginMsg"></div></div></div>
|
||||
<div id="app">
|
||||
<div class="sidebar"><div class="sidebar-header"><h1>Embalming Girl</h1><p>管理面板 v1.0</p></div>
|
||||
<nav class="sidebar-nav">
|
||||
<a class="active" onclick="showPage('dashboard',this)"><span class="icon">📊</span>仪表盘</a>
|
||||
<a onclick="showPage('users',this)"><span class="icon">👥</span>用户管理</a>
|
||||
<a onclick="showPage('updates',this)"><span class="icon">📦</span>更新管理</a>
|
||||
</nav><div class="sidebar-footer"><a onclick="doLogout()"><span class="icon">🚪</span>退出登录</a></div></div>
|
||||
<div class="main">
|
||||
<div id="dashboard" class="page active">
|
||||
<div class="main-header"><h2>仪表盘</h2><p>服务器运行状态总览</p></div>
|
||||
<div class="main-body"><div class="stats-grid" id="stats"></div>
|
||||
<div class="card"><div class="card-header"><h3>在线用户</h3><button class="btn btn-ghost btn-sm" onclick="loadOnline()">↻ 刷新</button></div>
|
||||
<div class="card-body" style="padding:0"><table><thead><tr><th>连接ID</th><th>用户名</th><th>昵称</th><th>版本</th><th>状态</th></tr></thead><tbody id="onlineTable"></tbody></table></div></div></div></div>
|
||||
<div id="users" class="page">
|
||||
<div class="main-header"><h2>用户管理</h2><p>管理已注册的用户账号</p></div>
|
||||
<div class="main-body"><div class="card"><div class="card-header"><h3>用户列表</h3><button class="btn btn-ghost btn-sm" onclick="loadUsers()">↻ 刷新</button></div>
|
||||
<div class="card-body" style="padding:0"><table><thead><tr><th>ID</th><th>用户名</th><th>昵称</th><th>头像</th><th>注册时间</th><th>操作</th></tr></thead><tbody id="userTable"></tbody></table></div></div></div></div>
|
||||
<div id="updates" class="page">
|
||||
<div class="main-header"><h2>更新管理</h2><p>管理客户端 OTA 更新</p></div>
|
||||
<div class="main-body"><div class="card"><div class="card-header"><h3>当前已发布版本</h3></div><div class="card-body" id="currentUpdate"></div></div>
|
||||
<div class="card"><div class="card-header"><h3>发布新版本</h3></div><div class="card-body">
|
||||
<label>版本号</label><input id="upVer" placeholder="例: 1.1.0">
|
||||
<label>更新包 (.zip)</label><input id="upFile" type="file" accept=".zip">
|
||||
<label>更新日志</label><textarea id="upNotes" placeholder="- 修复了...\n- 新增了..."></textarea>
|
||||
<div style="margin-top:14px;display:flex;align-items:center;gap:14px">
|
||||
<label style="margin:0;display:flex;align-items:center;gap:6px;cursor:pointer"><input type="checkbox" id="upMandatory"> 强制更新</label>
|
||||
<button class="btn btn-primary" onclick="publishUpdate()">发布更新</button></div></div></div></div></div>
|
||||
</div></div>
|
||||
<script>
|
||||
async function doLogin(){
|
||||
let r=await fetch('/api/admin/login',{method:'POST',body:JSON.stringify({password:document.getElementById('pw').value})});
|
||||
let d=await r.json();
|
||||
if(d.ok){document.getElementById('loginBox').style.display='none';document.getElementById('main').style.display='block';loadData();}
|
||||
else alert('密码错误');
|
||||
}
|
||||
async function loadData(){
|
||||
let s=await(await fetch('/api/admin/stats')).json();
|
||||
document.getElementById('stats').innerHTML=
|
||||
'<div class="stat"><div class="num">'+s.total_users+'</div><div class="label">注册用户</div></div>'+
|
||||
'<div class="stat"><div class="num">'+s.active_rooms+'</div><div class="label">活跃房间</div></div>'+
|
||||
'<div class="stat"><div class="num">'+s.online_clients+'</div><div class="label">在线连接</div></div>';
|
||||
let u=await(await fetch('/api/admin/users')).json();
|
||||
let h='';(u||[]).forEach(x=>{
|
||||
h+='<tr><td>'+x.id+'</td><td>'+x.username+'</td><td>'+x.nickname+'</td><td>'+
|
||||
(x.avatar||'-')+'</td><td>'+(x.bio||'-')+'</td><td>'+(x.created_at||'-')+'</td>'+
|
||||
'<td><button class="btn btn-danger" onclick="delUser('+x.id+')">删除</button></td></tr>';
|
||||
});
|
||||
document.getElementById('users').innerHTML=h||'<tr><td colspan="7">暂无用户</td></tr>';
|
||||
}
|
||||
async function delUser(id){
|
||||
if(!confirm('确定删除此用户?'))return;
|
||||
await fetch('/api/admin/delete_user',{method:'POST',body:JSON.stringify({id})});
|
||||
loadData();
|
||||
}
|
||||
window.onload=async function(){try{let r=await fetch('/api/admin/check_session');let d=await r.json();if(d.ok)enterApp();}catch(e){}};
|
||||
async function doLogin(){let pw=document.getElementById('pw').value;if(!pw){document.getElementById('loginMsg').textContent='请输入密码';return}let r=await fetch('/api/admin/login',{method:'POST',body:JSON.stringify({password:pw}),headers:{'Content-Type':'application/json'}});let d=await r.json();if(d.ok)enterApp();else document.getElementById('loginMsg').textContent='密码错误';}
|
||||
function enterApp(){document.getElementById('loginBox').style.display='none';document.getElementById('app').style.display='flex';loadAll();}
|
||||
async function doLogout(){await fetch('/api/admin/logout',{method:'POST'});location.reload();}
|
||||
function showPage(id,el){document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));document.getElementById(id).classList.add('active');document.querySelectorAll('.sidebar-nav a').forEach(a=>a.classList.remove('active'));if(el)el.classList.add('active');}
|
||||
async function loadAll(){loadStats();loadUsers();loadUpdate();loadOnline();}
|
||||
async function loadStats(){let r=await fetch('/api/admin/stats');if(!r.ok)return;let s=await r.json();document.getElementById('stats').innerHTML='<div class="stat-card"><div class="value">'+s.total_users+'</div><div class="label">注册用户</div></div><div class="stat-card"><div class="value">'+s.online_clients+'</div><div class="label">在线连接</div></div><div class="stat-card"><div class="value">'+s.active_rooms+'</div><div class="label">活跃房间</div></div>';}
|
||||
async function loadOnline(){let r=await fetch('/api/admin/online');if(!r.ok)return;let o=await r.json();let h='';(o||[]).forEach(x=>{let room=x.room_id?'<span class="badge badge-blue">'+x.room_id+'</span>':'<span style="color:var(--text2)">大厅</span>';let ver=x.version?'<span class="mono" style="font-size:12px">'+x.version+'</span>':'<span style="color:var(--text2)">-</span>';h+='<tr><td class="mono" style="font-size:12px;color:var(--text2)">'+x.id.substring(0,8)+'</td><td>'+(x.username||'<span style="color:var(--text2)">未登录</span>')+'</td><td>'+(x.nickname||'-')+'</td><td>'+ver+'</td><td>'+room+'</td></tr>';});document.getElementById('onlineTable').innerHTML=h||'<tr><td colspan="5" style="text-align:center;color:var(--text2);padding:24px">暂无在线用户</td></tr>';}
|
||||
async function loadUsers(){let r=await fetch('/api/admin/users');if(!r.ok)return;let u=await r.json();let h='';(u||[]).forEach(x=>{h+='<tr><td class="mono" style="color:var(--text2)">'+x.id+'</td><td><strong>'+x.username+'</strong></td><td>'+x.nickname+'</td><td>'+(x.avatar?'<span class="badge badge-blue">'+x.avatar+'</span>':'-')+'</td><td style="color:var(--text2);font-size:12px">'+(x.created_at||'-')+'</td><td><button class="btn btn-danger btn-sm" onclick="delUser('+x.id+')">删除</button></td></tr>';});document.getElementById('userTable').innerHTML=h||'<tr><td colspan="6" style="text-align:center;color:var(--text2);padding:24px">暂无注册用户</td></tr>';}
|
||||
async function delUser(id){if(!confirm('确定要删除此用户?此操作不可恢复。'))return;await fetch('/api/admin/delete_user',{method:'POST',body:JSON.stringify({id}),headers:{'Content-Type':'application/json'}});loadUsers();}
|
||||
async function loadUpdate(){let r=await fetch('/api/admin/update_info');if(!r.ok)return;let d=await r.json();let el=document.getElementById('currentUpdate');if(!d.published){el.innerHTML='<p style="color:var(--text2)">尚未发布任何客户端更新</p>';return;}let i=d.info;el.innerHTML='<div class="update-box"><div class="ver">v'+i.version+' <span class="badge '+(i.mandatory?'badge-red':'badge-green')+'">'+(i.mandatory?'强制更新':'可选更新')+'</span></div><div class="meta">📁 '+i.filename+' · '+formatSize(i.size)+' · 🕐 '+i.published_at+'</div><div class="meta mono" style="font-size:11px">SHA256: '+i.sha256.substring(0,32)+'...</div><pre>'+i.release_notes+'</pre><button class="btn btn-danger btn-sm" onclick="deleteUpdate()">删除此版本</button></div>';}
|
||||
async function deleteUpdate(){if(!confirm('确定删除已发布的更新?'))return;await fetch('/api/admin/delete_update',{method:'POST',headers:{'Content-Type':'application/json'}});loadUpdate();}
|
||||
async function publishUpdate(){let ver=document.getElementById('upVer').value,file=document.getElementById('upFile').files[0],notes=document.getElementById('upNotes').value,mandatory=document.getElementById('upMandatory').checked;if(!ver||!file){alert('请填写版本号并选择更新包文件');return;}let fd=new FormData();fd.append('version',ver);fd.append('file',file);fd.append('release_notes',notes);fd.append('mandatory',mandatory);let r=await fetch('/api/admin/publish_update',{method:'POST',body:fd});let d=await r.json();if(d.ok){alert('✅ 发布成功!');loadUpdate();document.getElementById('upVer').value='';document.getElementById('upNotes').value='';}else alert('发布失败: '+(d.message||'未知错误'));}
|
||||
function formatSize(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB';}
|
||||
</script></body></html>`
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
type UpdateInfo struct {
|
||||
Version string `json:"version"`
|
||||
Platform string `json:"platform"`
|
||||
FileName string `json:"filename"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Size int64 `json:"size"`
|
||||
ReleaseNotes string `json:"release_notes"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
}
|
||||
|
||||
var updatesDir string
|
||||
|
||||
func initUpdatesDir() {
|
||||
exe, _ := os.Executable()
|
||||
updatesDir = filepath.Join(filepath.Dir(exe), "data", "updates")
|
||||
os.MkdirAll(updatesDir, 0755)
|
||||
}
|
||||
|
||||
func RegisterUpdateRoutes(mux *http.ServeMux) {
|
||||
initUpdatesDir()
|
||||
|
||||
mux.HandleFunc("/api/update/check", handleUpdateCheck)
|
||||
mux.Handle("/updates/", http.StripPrefix("/updates/",
|
||||
http.FileServer(http.Dir(updatesDir))))
|
||||
mux.HandleFunc("/api/admin/publish_update", authWrap(handlePublishUpdate))
|
||||
mux.HandleFunc("/api/admin/update_info", handleGetUpdateInfo)
|
||||
mux.HandleFunc("/api/admin/delete_update", authWrap(handleDeleteUpdate))
|
||||
}
|
||||
|
||||
func getUpdateInfoPath() string {
|
||||
return filepath.Join(updatesDir, "latest.json")
|
||||
}
|
||||
|
||||
func loadUpdateInfo() *UpdateInfo {
|
||||
data, err := os.ReadFile(getUpdateInfoPath())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var info UpdateInfo
|
||||
if json.Unmarshal(data, &info) != nil {
|
||||
return nil
|
||||
}
|
||||
return &info
|
||||
}
|
||||
|
||||
func saveUpdateInfo(info *UpdateInfo) error {
|
||||
data, _ := json.MarshalIndent(info, "", " ")
|
||||
return os.WriteFile(getUpdateInfoPath(), data, 0644)
|
||||
}
|
||||
|
||||
func handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
|
||||
clientVersion := r.URL.Query().Get("version")
|
||||
info := loadUpdateInfo()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if info == nil || info.Version == clientVersion {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"update_available": false,
|
||||
"current_version": clientVersion,
|
||||
})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"update_available": true,
|
||||
"latest_version": info.Version,
|
||||
"download_url": "/updates/" + info.FileName,
|
||||
"sha256": info.SHA256,
|
||||
"size": info.Size,
|
||||
"release_notes": info.ReleaseNotes,
|
||||
"mandatory": info.Mandatory,
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetUpdateInfo(w http.ResponseWriter, r *http.Request) {
|
||||
info := loadUpdateInfo()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if info == nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"published": false})
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"published": true, "info": info,
|
||||
})
|
||||
}
|
||||
|
||||
func handleDeleteUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
info := loadUpdateInfo()
|
||||
if info != nil {
|
||||
os.Remove(filepath.Join(updatesDir, info.FileName))
|
||||
os.Remove(getUpdateInfoPath())
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
func handlePublishUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", 405)
|
||||
return
|
||||
}
|
||||
r.ParseMultipartForm(200 << 20)
|
||||
|
||||
version := r.FormValue("version")
|
||||
notes := r.FormValue("release_notes")
|
||||
mandatory := r.FormValue("mandatory") == "true"
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "文件上传失败"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
dstPath := filepath.Join(updatesDir, header.Filename)
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "无法创建文件"})
|
||||
return
|
||||
}
|
||||
|
||||
hasher := sha256.New()
|
||||
size, _ := io.Copy(io.MultiWriter(dst, hasher), file)
|
||||
dst.Close()
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
info := &UpdateInfo{
|
||||
Version: version, Platform: "win64", FileName: header.Filename,
|
||||
SHA256: hash, Size: size, ReleaseNotes: notes, Mandatory: mandatory,
|
||||
PublishedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
saveUpdateInfo(info)
|
||||
log.Printf("Update published: v%s (%s, %d bytes)", version, header.Filename, size)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "info": info})
|
||||
}
|
||||
|
||||
func FormatSize(b int64) string {
|
||||
if b < 1024 {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
} else if b < 1024*1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(b)/1024)
|
||||
}
|
||||
return fmt.Sprintf("%.1f MB", float64(b)/1024/1024)
|
||||
}
|
||||
Reference in New Issue
Block a user