Files
Embalming_Girl_Server/admin/admin.go
T
2026-06-20 18:18:50 +08:00

219 lines
17 KiB
Go

package admin
import (
"Embalming_Girl_Server/db"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
)
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")
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/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", 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
}
defer rows.Close()
var users []map[string]interface{}
for rows.Next() {
var id int64
var username, nickname, bio, avatar string
var created, login interface{}
rows.Scan(&id, &username, &nickname, &bio, &avatar, &created, &login)
users = append(users, map[string]interface{}{
"id": id, "username": username, "nickname": nickname,
"bio": bio, "avatar": avatar, "created_at": created, "last_login": login,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
}))
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 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: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"><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>
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+' &nbsp;·&nbsp; '+formatSize(i.size)+' &nbsp;·&nbsp; 🕐 '+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>`