完善了Web管理端
This commit is contained in:
@@ -24,4 +24,7 @@ cmake-build-debug
|
|||||||
|
|
||||||
server.exe
|
server.exe
|
||||||
|
|
||||||
|
embalming_girl_server.exe
|
||||||
|
embalming_girl_server_linux
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+159
-59
@@ -2,34 +2,75 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"Embalming_Girl_Server/db"
|
"Embalming_Girl_Server/db"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"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{}) {
|
func Register(mux *http.ServeMux, getStats func() map[string]interface{}) {
|
||||||
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
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) {
|
mux.HandleFunc("/api/admin/login", func(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
if req.Password != AdminPassword {
|
if req.Password != AdminPassword {
|
||||||
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false})
|
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false})
|
||||||
return
|
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})
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(getStats())
|
json.NewEncoder(w).Encode(getStats())
|
||||||
})
|
}))
|
||||||
mux.HandleFunc("/api/admin/users", func(w http.ResponseWriter, r *http.Request) {
|
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")
|
rows, err := db.DB.Query("SELECT id,username,nickname,bio,avatar,created_at,last_login FROM users ORDER BY id DESC")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
json.NewEncoder(w).Encode([]int{})
|
json.NewEncoder(w).Encode([]int{})
|
||||||
@@ -49,70 +90,129 @@ func Register(mux *http.ServeMux, getStats func() map[string]interface{}) {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(users)
|
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 {
|
var req struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
db.DB.Exec("DELETE FROM sessions WHERE user_id=?", req.ID)
|
db.DB.Exec("DELETE FROM sessions WHERE user_id=?", req.ID)
|
||||||
db.DB.Exec("DELETE FROM users WHERE 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})
|
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>
|
<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}
|
*{margin:0;padding:0;box-sizing:border-box}
|
||||||
body{background:#1a1f2e;color:#e0e0e0;font-family:system-ui;padding:20px}
|
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}
|
||||||
h1{color:#5eb3e6;margin-bottom:20px;text-align:center}
|
::selection{background:var(--accent);color:#fff}
|
||||||
.card{background:#252b3d;border-radius:12px;padding:20px;margin-bottom:16px}
|
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
|
||||||
.card h2{color:#d4c5a3;margin-bottom:12px;font-size:18px}
|
.sidebar{width:220px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
|
||||||
.stats{display:flex;gap:16px;flex-wrap:wrap}
|
.sidebar-header{padding:20px;border-bottom:1px solid var(--border)}
|
||||||
.stat{background:#1a1f2e;border-radius:8px;padding:16px;text-align:center;min-width:120px}
|
.sidebar-header h1{font-size:15px;color:var(--accent);font-weight:600}
|
||||||
.stat .num{font-size:28px;color:#5eb3e6;font-weight:bold}
|
.sidebar-header p{font-size:11px;color:var(--text2);margin-top:2px}
|
||||||
.stat .label{font-size:12px;color:#8a8f9d;margin-top:4px}
|
.sidebar-nav{flex:1;padding:8px}
|
||||||
table{width:100%;border-collapse:collapse;margin-top: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}
|
||||||
th,td{padding:8px 12px;text-align:left;border-bottom:1px solid #2a3040}
|
.sidebar-nav a:hover{background:var(--bg3);color:var(--text)}
|
||||||
th{color:#8a8f9d;font-size:12px}
|
.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)}
|
||||||
.btn{padding:4px 12px;border:none;border-radius:4px;cursor:pointer;font-size:12px}
|
.sidebar-nav a .icon{font-size:16px;width:20px;text-align:center}
|
||||||
.btn-danger{background:#d94f5c;color:white}
|
.sidebar-footer{padding:12px 8px;border-top:1px solid var(--border)}
|
||||||
.btn-danger:hover{background:#c0392b}
|
.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}
|
||||||
#loginBox{max-width:300px;margin:100px auto;text-align:center}
|
.sidebar-footer a:hover{background:rgba(229,83,75,0.1)}
|
||||||
#loginBox input{width:100%;padding:10px;margin:8px 0;border-radius:6px;border:1px solid #3a3f4e;background:#1a1f2e;color:white}
|
.main{flex:1;overflow-y:auto}.main-header{padding:20px 28px 0;margin-bottom:20px}
|
||||||
#loginBox button{width:100%;padding:10px;background:#5eb3e6;border:none;border-radius:6px;color:white;cursor:pointer;font-size:14px}
|
.main-header h2{font-size:20px;font-weight:600}.main-header p{font-size:12px;color:var(--text2);margin-top:2px}
|
||||||
#main{display:none}
|
.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>
|
</style></head><body>
|
||||||
<div id="loginBox"><h1>管理面板</h1><input id="pw" type="password" placeholder="管理密码">
|
<div id="loginBox"><div class="login-card">
|
||||||
<button onclick="doLogin()">登录</button></div>
|
<h2>Embalming Girl</h2><p>Server Admin Panel</p>
|
||||||
<div id="main"><h1>Embalming Girl 管理面板</h1>
|
<input id="pw" type="password" placeholder="管理密码" onkeydown="if(event.key==='Enter')doLogin()">
|
||||||
<div class="card"><h2>服务器状态</h2><div class="stats" id="stats"></div></div>
|
<button class="btn btn-primary" onclick="doLogin()">登 录</button>
|
||||||
<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 class="error" id="loginMsg"></div></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>
|
<script>
|
||||||
async function doLogin(){
|
window.onload=async function(){try{let r=await fetch('/api/admin/check_session');let d=await r.json();if(d.ok)enterApp();}catch(e){}};
|
||||||
let r=await fetch('/api/admin/login',{method:'POST',body:JSON.stringify({password:document.getElementById('pw').value})});
|
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='密码错误';}
|
||||||
let d=await r.json();
|
function enterApp(){document.getElementById('loginBox').style.display='none';document.getElementById('app').style.display='flex';loadAll();}
|
||||||
if(d.ok){document.getElementById('loginBox').style.display='none';document.getElementById('main').style.display='block';loadData();}
|
async function doLogout(){await fetch('/api/admin/logout',{method:'POST'});location.reload();}
|
||||||
else alert('密码错误');
|
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 loadData(){
|
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>';}
|
||||||
let s=await(await fetch('/api/admin/stats')).json();
|
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>';}
|
||||||
document.getElementById('stats').innerHTML=
|
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>';}
|
||||||
'<div class="stat"><div class="num">'+s.total_users+'</div><div class="label">注册用户</div></div>'+
|
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();}
|
||||||
'<div class="stat"><div class="num">'+s.active_rooms+'</div><div class="label">活跃房间</div></div>'+
|
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>';}
|
||||||
'<div class="stat"><div class="num">'+s.online_clients+'</div><div class="label">在线连接</div></div>';
|
async function deleteUpdate(){if(!confirm('确定删除已发布的更新?'))return;await fetch('/api/admin/delete_update',{method:'POST',headers:{'Content-Type':'application/json'}});loadUpdate();}
|
||||||
let u=await(await fetch('/api/admin/users')).json();
|
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||'未知错误'));}
|
||||||
let h='';(u||[]).forEach(x=>{
|
function formatSize(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB';}
|
||||||
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();
|
|
||||||
}
|
|
||||||
</script></body></html>`
|
</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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env pwsh
|
||||||
|
# Embalming Girl Server - Build Script
|
||||||
|
# Usage:
|
||||||
|
# .\build.ps1 # Build for Windows
|
||||||
|
# .\build.ps1 linux # Cross-compile for Linux
|
||||||
|
# .\build.ps1 all # Build for both platforms
|
||||||
|
|
||||||
|
param([string]$Target = "windows")
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$ProjectName = "embalming_girl_server"
|
||||||
|
|
||||||
|
Write-Host "=== Embalming Girl Server Build ===" -ForegroundColor Cyan
|
||||||
|
|
||||||
|
function Build-Windows {
|
||||||
|
Write-Host "[Building] Windows amd64..." -ForegroundColor Yellow
|
||||||
|
$env:GOOS = "windows"
|
||||||
|
$env:GOARCH = "amd64"
|
||||||
|
go build -o "$ProjectName.exe" .
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "[OK] $ProjectName.exe" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "[FAILED] Windows build" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Build-Linux {
|
||||||
|
Write-Host "[Building] Linux amd64..." -ForegroundColor Yellow
|
||||||
|
$env:GOOS = "linux"
|
||||||
|
$env:GOARCH = "amd64"
|
||||||
|
$env:CGO_ENABLED = "0"
|
||||||
|
go build -o "${ProjectName}_linux" .
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
Write-Host "[OK] ${ProjectName}_linux" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "[FAILED] Linux build" -ForegroundColor Red
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ($Target.ToLower()) {
|
||||||
|
"windows" { Build-Windows }
|
||||||
|
"linux" { Build-Linux }
|
||||||
|
"all" { Build-Windows; Build-Linux }
|
||||||
|
default {
|
||||||
|
Write-Host "Usage: .\build.ps1 [windows|linux|all]" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up environment
|
||||||
|
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
|
||||||
|
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
|
||||||
|
|
||||||
|
Write-Host "=== Build Complete ===" -ForegroundColor Cyan
|
||||||
@@ -20,6 +20,8 @@ type Card struct {
|
|||||||
TypeID string
|
TypeID string
|
||||||
Name string
|
Name string
|
||||||
MP int
|
MP int
|
||||||
|
PlacedBy string
|
||||||
|
PlacedByNick string
|
||||||
}
|
}
|
||||||
|
|
||||||
var AllCardDefs = []CardDef{
|
var AllCardDefs = []CardDef{
|
||||||
|
|||||||
+40
-1
@@ -11,6 +11,7 @@ type PlayerState struct {
|
|||||||
ID string
|
ID string
|
||||||
Nickname string
|
Nickname string
|
||||||
IsBot bool
|
IsBot bool
|
||||||
|
BotControlled bool
|
||||||
Hand []*Card
|
Hand []*Card
|
||||||
SkillZone []*Card
|
SkillZone []*Card
|
||||||
ChallengeZone []*Card
|
ChallengeZone []*Card
|
||||||
@@ -31,6 +32,8 @@ type Engine struct {
|
|||||||
Effect *EffectState
|
Effect *EffectState
|
||||||
ChaosMode bool
|
ChaosMode bool
|
||||||
ActionLogs []string
|
ActionLogs []string
|
||||||
|
TurnID int
|
||||||
|
RoundNum int
|
||||||
rng *rand.Rand
|
rng *rand.Rand
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +70,8 @@ func (e *Engine) Deal() {
|
|||||||
e.CurrentTurn = e.findStartingPlayer()
|
e.CurrentTurn = e.findStartingPlayer()
|
||||||
e.Phase = "playing"
|
e.Phase = "playing"
|
||||||
e.TurnPhase = "select_card"
|
e.TurnPhase = "select_card"
|
||||||
|
e.RoundNum = 1
|
||||||
|
e.TurnID = 0
|
||||||
e.startTurn()
|
e.startTurn()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +87,11 @@ func (e *Engine) findStartingPlayer() int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) startTurn() {
|
func (e *Engine) startTurn() {
|
||||||
|
e.TurnID++
|
||||||
|
cp := e.CurrentPlayer()
|
||||||
|
if cp != nil && !cp.IsBot && !cp.BotControlled {
|
||||||
|
e.log(fmt.Sprintf("轮到 %s 的回合 (第%d轮)", cp.Nickname, e.RoundNum))
|
||||||
|
}
|
||||||
if e.CheckInfectedEffect() {
|
if e.CheckInfectedEffect() {
|
||||||
e.TurnPhase = "effect_resolving"
|
e.TurnPhase = "effect_resolving"
|
||||||
} else {
|
} else {
|
||||||
@@ -190,6 +200,8 @@ func (e *Engine) doChallenge(p *PlayerState, targetID string) error {
|
|||||||
}
|
}
|
||||||
card := e.SelectedCard
|
card := e.SelectedCard
|
||||||
e.removeFromHand(p, card.UID)
|
e.removeFromHand(p, card.UID)
|
||||||
|
card.PlacedBy = p.ID
|
||||||
|
card.PlacedByNick = p.Nickname
|
||||||
target.ChallengeZone = append(target.ChallengeZone, card)
|
target.ChallengeZone = append(target.ChallengeZone, card)
|
||||||
e.log(p.Nickname + " 质疑了 " + target.Nickname)
|
e.log(p.Nickname + " 质疑了 " + target.Nickname)
|
||||||
e.SelectedCard = nil
|
e.SelectedCard = nil
|
||||||
@@ -214,10 +226,14 @@ func (e *Engine) finishTurn(p *PlayerState) {
|
|||||||
|
|
||||||
func (e *Engine) advanceTurn() {
|
func (e *Engine) advanceTurn() {
|
||||||
n := len(e.Players)
|
n := len(e.Players)
|
||||||
|
old := e.CurrentTurn
|
||||||
next := (e.CurrentTurn + 1) % n
|
next := (e.CurrentTurn + 1) % n
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
if !e.Players[next].IsExited {
|
if !e.Players[next].IsExited {
|
||||||
e.CurrentTurn = next
|
e.CurrentTurn = next
|
||||||
|
if next <= old {
|
||||||
|
e.RoundNum++
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next = (next + 1) % n
|
next = (next + 1) % n
|
||||||
@@ -256,6 +272,29 @@ func (e *Engine) IsHandTargetable(p *PlayerState) bool {
|
|||||||
return !p.IsExited
|
return !p.IsExited
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (e *Engine) UpdatePlayerID(oldID, newID string) {
|
||||||
|
for _, p := range e.Players {
|
||||||
|
if p.ID == oldID {
|
||||||
|
p.ID = newID
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) SetBotControlled(playerID string, v bool) {
|
||||||
|
for _, p := range e.Players {
|
||||||
|
if p.ID == playerID {
|
||||||
|
p.BotControlled = v
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) IsPlayerBotLike(playerID string) bool {
|
||||||
|
p := e.GetPlayer(playerID)
|
||||||
|
return p != nil && (p.IsBot || p.BotControlled)
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) EffectNeedsResponseFrom(playerID string) bool {
|
func (e *Engine) EffectNeedsResponseFrom(playerID string) bool {
|
||||||
if !e.IsEffectResolving() {
|
if !e.IsEffectResolving() {
|
||||||
return false
|
return false
|
||||||
@@ -271,7 +310,7 @@ func (e *Engine) EffectNeedsResponseFrom(playerID string) bool {
|
|||||||
|
|
||||||
func (e *Engine) BotAutoPlay(playerID string) (string, string) {
|
func (e *Engine) BotAutoPlay(playerID string) (string, string) {
|
||||||
p := e.GetPlayer(playerID)
|
p := e.GetPlayer(playerID)
|
||||||
if p == nil || !p.IsBot || p.IsExited {
|
if p == nil || (!p.IsBot && !p.BotControlled) || p.IsExited {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
var playable []*Card
|
var playable []*Card
|
||||||
|
|||||||
+14
-2
@@ -13,6 +13,8 @@ func (e *Engine) Snapshot(forPlayerID string) map[string]interface{} {
|
|||||||
"current_turn": currentTurnID,
|
"current_turn": currentTurnID,
|
||||||
"your_player_id": forPlayerID,
|
"your_player_id": forPlayerID,
|
||||||
"harmony_target": e.HarmonyTarget,
|
"harmony_target": e.HarmonyTarget,
|
||||||
|
"round_num": e.RoundNum,
|
||||||
|
"turn_id": e.TurnID,
|
||||||
}
|
}
|
||||||
|
|
||||||
var players []map[string]interface{}
|
var players []map[string]interface{}
|
||||||
@@ -47,10 +49,20 @@ func (e *Engine) Snapshot(forPlayerID string) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
snap["hand_cards"] = hand
|
snap["hand_cards"] = hand
|
||||||
|
|
||||||
|
var myChallenge []map[string]interface{}
|
||||||
|
if me != nil {
|
||||||
|
for _, c := range me.ChallengeZone {
|
||||||
|
myChallenge = append(myChallenge, map[string]interface{}{
|
||||||
|
"uid": c.UID, "placed_by": c.PlacedByNick,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
snap["my_challenge_zone"] = myChallenge
|
||||||
|
|
||||||
var harmony []map[string]interface{}
|
var harmony []map[string]interface{}
|
||||||
for _, c := range e.HarmonyZone {
|
for i, c := range e.HarmonyZone {
|
||||||
harmony = append(harmony, map[string]interface{}{
|
harmony = append(harmony, map[string]interface{}{
|
||||||
"uid": c.UID, "face_up": false,
|
"uid": c.UID, "face_up": false, "order": i + 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
snap["harmony_zone"] = harmony
|
snap["harmony_zone"] = harmony
|
||||||
|
|||||||
+198
-5
@@ -5,6 +5,7 @@ import (
|
|||||||
"Embalming_Girl_Server/game"
|
"Embalming_Girl_Server/game"
|
||||||
"Embalming_Girl_Server/room"
|
"Embalming_Girl_Server/room"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -25,6 +26,7 @@ type Client struct {
|
|||||||
Username string
|
Username string
|
||||||
Nickname string
|
Nickname string
|
||||||
Token string
|
Token string
|
||||||
|
Version string
|
||||||
Conn *websocket.Conn
|
Conn *websocket.Conn
|
||||||
Send chan []byte
|
Send chan []byte
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -51,6 +53,18 @@ func (h *Hub) OnlineCount() int {
|
|||||||
return len(h.clients)
|
return len(h.clients)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Hub) OnlineClientsList() []map[string]interface{} {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
var list []map[string]interface{}
|
||||||
|
for _, c := range h.clients {
|
||||||
|
list = append(list, map[string]interface{}{
|
||||||
|
"id": c.ID, "username": c.Username, "nickname": c.Nickname, "room_id": c.RoomID, "version": c.Version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Hub) InitTestRooms() {
|
func (h *Hub) InitTestRooms() {
|
||||||
for _, n := range []int{3, 4, 5, 6} {
|
for _, n := range []int{3, 4, 5, 6} {
|
||||||
rid := "TEST" + string(rune('0'+n))
|
rid := "TEST" + string(rune('0'+n))
|
||||||
@@ -109,15 +123,59 @@ func (h *Hub) removeClient(c *Client) {
|
|||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(h.clients, c.ID)
|
delete(h.clients, c.ID)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
if c.RoomID != "" {
|
if c.RoomID != "" {
|
||||||
|
h.mu.RLock()
|
||||||
|
_, hasGame := h.engines[c.RoomID]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
if hasGame && c.Username != "" {
|
||||||
|
h.RoomMgr.DisconnectPlayer(c.RoomID, c.ID)
|
||||||
|
h.broadcastToRoom(c.RoomID, "player_disconnected", map[string]interface{}{
|
||||||
|
"nickname": c.Nickname, "message": c.Nickname + " 断线了",
|
||||||
|
})
|
||||||
|
log.Printf("Player %s disconnected, slot preserved, 60s takeover timer started", c.Username)
|
||||||
|
|
||||||
|
go func(roomID, playerID, nick string) {
|
||||||
|
time.Sleep(60 * time.Second)
|
||||||
|
if !h.RoomMgr.IsPlayerDisconnected(roomID, playerID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.RoomMgr.SetPlayerBotControlled(roomID, playerID, true)
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
eng := h.engines[roomID]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if eng != nil {
|
||||||
|
eng.SetBotControlled(playerID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.broadcastToRoom(roomID, "player_bot_takeover", map[string]interface{}{
|
||||||
|
"nickname": nick, "message": nick + " 已被人机接管",
|
||||||
|
})
|
||||||
|
log.Printf("Player %s taken over by bot in room %s", nick, roomID)
|
||||||
|
|
||||||
|
if eng != nil && eng.Phase == "playing" {
|
||||||
|
cp := eng.CurrentPlayer()
|
||||||
|
if cp != nil && cp.ID == playerID {
|
||||||
|
h.processBotTurns(roomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(c.RoomID, c.ID, c.Nickname)
|
||||||
|
} else {
|
||||||
h.RoomMgr.LeaveRoom(c.RoomID, c.ID)
|
h.RoomMgr.LeaveRoom(c.RoomID, c.ID)
|
||||||
rm := h.RoomMgr.GetRoom(c.RoomID)
|
rm := h.RoomMgr.GetRoom(c.RoomID)
|
||||||
if rm != nil {
|
if rm != nil {
|
||||||
h.broadcastRoomState(rm)
|
h.broadcastRoomState(rm)
|
||||||
|
} else {
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.engines, c.RoomID)
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
close(c.Send)
|
close(c.Send)
|
||||||
log.Printf("Disconnected: %s", c.ID)
|
log.Printf("Disconnected: %s (%s)", c.ID, c.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) {
|
func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) {
|
||||||
@@ -136,6 +194,8 @@ func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) {
|
|||||||
h.handleCreateRoom(c, p)
|
h.handleCreateRoom(c, p)
|
||||||
case "join_room":
|
case "join_room":
|
||||||
h.handleJoinRoom(c, p)
|
h.handleJoinRoom(c, p)
|
||||||
|
case "rejoin_room":
|
||||||
|
h.handleRejoin(c, p)
|
||||||
case "leave_room":
|
case "leave_room":
|
||||||
h.handleLeaveRoom(c)
|
h.handleLeaveRoom(c)
|
||||||
case "set_ready":
|
case "set_ready":
|
||||||
@@ -154,6 +214,8 @@ func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) {
|
|||||||
h.handleChat(c, p)
|
h.handleChat(c, p)
|
||||||
case "set_chaos":
|
case "set_chaos":
|
||||||
h.handleSetChaos(c, p)
|
h.handleSetChaos(c, p)
|
||||||
|
case "create_bot_room":
|
||||||
|
h.handleCreateBotRoom(c, p)
|
||||||
case "heartbeat":
|
case "heartbeat":
|
||||||
h.sendTo(c, "heartbeat_ack", nil)
|
h.sendTo(c, "heartbeat_ack", nil)
|
||||||
}
|
}
|
||||||
@@ -203,12 +265,20 @@ func (h *Hub) handleLogin(c *Client, p map[string]interface{}) {
|
|||||||
c.Username = user.Username
|
c.Username = user.Username
|
||||||
c.Nickname = user.Nickname
|
c.Nickname = user.Nickname
|
||||||
c.Token = token
|
c.Token = token
|
||||||
|
if v, ok := p["version"].(string); ok {
|
||||||
|
c.Version = v
|
||||||
|
}
|
||||||
log.Printf("User logged in: %s (%s)", user.Username, c.ID)
|
log.Printf("User logged in: %s (%s)", user.Username, c.ID)
|
||||||
|
|
||||||
h.sendTo(c, "login_result", map[string]interface{}{
|
result := map[string]interface{}{
|
||||||
"success": true, "token": token,
|
"success": true, "token": token,
|
||||||
"username": user.Username, "nickname": user.Nickname, "avatar": user.Avatar, "bio": user.Bio,
|
"username": user.Username, "nickname": user.Nickname, "avatar": user.Avatar, "bio": user.Bio,
|
||||||
})
|
}
|
||||||
|
if roomID, playerID, found := h.RoomMgr.FindRoomByUsername(user.Username); found {
|
||||||
|
result["active_room"] = roomID
|
||||||
|
result["active_player_id"] = playerID
|
||||||
|
}
|
||||||
|
h.sendTo(c, "login_result", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) handleUpdateProfile(c *Client, p map[string]interface{}) {
|
func (h *Hub) handleUpdateProfile(c *Client, p map[string]interface{}) {
|
||||||
@@ -239,6 +309,41 @@ func (h *Hub) handleGetProfile(c *Client, p map[string]interface{}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Hub) handleRejoin(c *Client, p map[string]interface{}) {
|
||||||
|
roomID, _ := p["room_id"].(string)
|
||||||
|
oldPID, _ := p["player_id"].(string)
|
||||||
|
if roomID == "" || oldPID == "" {
|
||||||
|
h.sendTo(c, "error", map[string]interface{}{"message": "缺少参数"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.RoomMgr.ReconnectPlayer(roomID, oldPID, c.ID, c.Send) {
|
||||||
|
h.sendTo(c, "error", map[string]interface{}{"message": "无法重连到对局"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.RoomID = roomID
|
||||||
|
log.Printf("Player %s rejoined room %s", c.Username, roomID)
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
eng := h.engines[roomID]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
if eng != nil {
|
||||||
|
eng.UpdatePlayerID(oldPID, c.ID)
|
||||||
|
eng.SetBotControlled(c.ID, false)
|
||||||
|
h.sendToChannel(c.Send, "game_start", map[string]interface{}{
|
||||||
|
"your_player_id": c.ID, "harmony_target": eng.HarmonyTarget,
|
||||||
|
"player_count": len(eng.Players), "rejoin": true,
|
||||||
|
})
|
||||||
|
snap := eng.Snapshot(c.ID)
|
||||||
|
h.sendToChannel(c.Send, "state_snapshot", snap)
|
||||||
|
}
|
||||||
|
rm := h.RoomMgr.GetRoom(roomID)
|
||||||
|
if rm != nil {
|
||||||
|
h.broadcastRoomState(rm)
|
||||||
|
}
|
||||||
|
h.sendTo(c, "rejoin_result", map[string]interface{}{"success": true, "room_id": roomID})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Hub) handleCreateRoom(c *Client, p map[string]interface{}) {
|
func (h *Hub) handleCreateRoom(c *Client, p map[string]interface{}) {
|
||||||
nick, _ := p["nickname"].(string)
|
nick, _ := p["nickname"].(string)
|
||||||
mp := 4
|
mp := 4
|
||||||
@@ -251,6 +356,7 @@ func (h *Hub) handleCreateRoom(c *Client, p map[string]interface{}) {
|
|||||||
rm := h.RoomMgr.CreateRoom(c.ID, nick, mp)
|
rm := h.RoomMgr.CreateRoom(c.ID, nick, mp)
|
||||||
c.RoomID = rm.ID
|
c.RoomID = rm.ID
|
||||||
rm.Players[c.ID].Send = c.Send
|
rm.Players[c.ID].Send = c.Send
|
||||||
|
rm.Players[c.ID].Username = c.Username
|
||||||
h.sendTo(c, "room_created", map[string]interface{}{"room_id": rm.ID})
|
h.sendTo(c, "room_created", map[string]interface{}{"room_id": rm.ID})
|
||||||
h.broadcastRoomState(rm)
|
h.broadcastRoomState(rm)
|
||||||
}
|
}
|
||||||
@@ -268,6 +374,8 @@ func (h *Hub) handleJoinRoom(c *Client, p map[string]interface{}) {
|
|||||||
}
|
}
|
||||||
c.RoomID = rm.ID
|
c.RoomID = rm.ID
|
||||||
rm.Players[c.ID].Send = c.Send
|
rm.Players[c.ID].Send = c.Send
|
||||||
|
rm.Players[c.ID].Username = c.Username
|
||||||
|
|
||||||
h.sendTo(c, "room_joined", map[string]interface{}{"room_id": rm.ID})
|
h.sendTo(c, "room_joined", map[string]interface{}{"room_id": rm.ID})
|
||||||
h.broadcastRoomState(rm)
|
h.broadcastRoomState(rm)
|
||||||
}
|
}
|
||||||
@@ -298,6 +406,32 @@ func (h *Hub) handleSetChaos(c *Client, p map[string]interface{}) {
|
|||||||
h.broadcastRoomState(rm)
|
h.broadcastRoomState(rm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Hub) handleCreateBotRoom(c *Client, p map[string]interface{}) {
|
||||||
|
count := 4
|
||||||
|
if v, ok := p["player_count"].(float64); ok {
|
||||||
|
count = int(v)
|
||||||
|
}
|
||||||
|
if count < 3 || count > 6 {
|
||||||
|
h.sendTo(c, "error", map[string]interface{}{"message": "人数必须在3-6之间"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rid := fmt.Sprintf("BOT_%s", generateClientID()[:6])
|
||||||
|
h.RoomMgr.CreateTestRoom(rid, count)
|
||||||
|
|
||||||
|
rm, ok := h.RoomMgr.JoinRoom(rid, c.ID, c.Nickname)
|
||||||
|
if !ok {
|
||||||
|
h.sendTo(c, "error", map[string]interface{}{"message": "创建失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.RoomID = rm.ID
|
||||||
|
rm.Players[c.ID].Send = c.Send
|
||||||
|
rm.Players[c.ID].Username = c.Username
|
||||||
|
|
||||||
|
h.sendTo(c, "room_joined", map[string]interface{}{"room_id": rm.ID})
|
||||||
|
h.broadcastRoomState(rm)
|
||||||
|
log.Printf("Bot room %s created by %s (%d players)", rid, c.Username, count)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Hub) handleChat(c *Client, p map[string]interface{}) {
|
func (h *Hub) handleChat(c *Client, p map[string]interface{}) {
|
||||||
if c.RoomID == "" {
|
if c.RoomID == "" {
|
||||||
return
|
return
|
||||||
@@ -528,7 +662,7 @@ func (h *Hub) processBotTurns(roomID string) {
|
|||||||
|
|
||||||
for eng.Phase == "playing" && !eng.IsEffectResolving() {
|
for eng.Phase == "playing" && !eng.IsEffectResolving() {
|
||||||
cp := eng.CurrentPlayer()
|
cp := eng.CurrentPlayer()
|
||||||
if cp == nil || !cp.IsBot {
|
if cp == nil || (!cp.IsBot && !cp.BotControlled) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,6 +690,7 @@ func (h *Hub) processBotTurns(roomID string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
h.startTurnTimer(roomID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) handleAllExited(roomID string) {
|
func (h *Hub) handleAllExited(roomID string) {
|
||||||
@@ -624,7 +759,65 @@ func (h *Hub) handleAllExited(roomID string) {
|
|||||||
h.broadcastRoomState(rm)
|
h.broadcastRoomState(rm)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Audio relay ----
|
func (h *Hub) startTurnTimer(roomID string) {
|
||||||
|
h.mu.RLock()
|
||||||
|
eng := h.engines[roomID]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if eng == nil || eng.Phase != "playing" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cp := eng.CurrentPlayer()
|
||||||
|
if cp == nil || cp.IsBot || cp.BotControlled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
turnID := eng.TurnID
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
h.mu.RLock()
|
||||||
|
eng2 := h.engines[roomID]
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if eng2 == nil || eng2.Phase != "playing" || eng2.TurnID != turnID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cp2 := eng2.CurrentPlayer()
|
||||||
|
if cp2 == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eng2.ActionLogs = append(eng2.ActionLogs, cp2.Nickname+" 超时,自动出牌")
|
||||||
|
action, target := eng2.BotAutoPlay(cp2.ID)
|
||||||
|
if action == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
eng2.ConfirmAction(cp2.ID, action, target)
|
||||||
|
h.broadcastSnapshots(roomID)
|
||||||
|
h.flushGameLogs(roomID)
|
||||||
|
if eng2.Phase == "all_exited" {
|
||||||
|
h.handleAllExited(roomID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.processBotTurns(roomID)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Broadcast helpers ----
|
||||||
|
|
||||||
|
func (h *Hub) broadcastToRoom(roomID, msgType string, data map[string]interface{}) {
|
||||||
|
rm := h.RoomMgr.GetRoom(roomID)
|
||||||
|
if rm == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, pid := range rm.PlayerOrder {
|
||||||
|
p := rm.Players[pid]
|
||||||
|
if p.IsBot || p.Send == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
h.sendToChannel(p.Send, msgType, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Hub) flushGameLogs(roomID string) {
|
func (h *Hub) flushGameLogs(roomID string) {
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"Embalming_Girl_Server/admin"
|
"Embalming_Girl_Server/admin"
|
||||||
"Embalming_Girl_Server/db"
|
"Embalming_Girl_Server/db"
|
||||||
"Embalming_Girl_Server/handler"
|
"Embalming_Girl_Server/handler"
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -33,6 +34,11 @@ func main() {
|
|||||||
"online_clients": hub.OnlineCount(),
|
"online_clients": hub.OnlineCount(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
admin.RegisterUpdateRoutes(mux)
|
||||||
|
mux.HandleFunc("/api/admin/online", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(hub.OnlineClientsList())
|
||||||
|
})
|
||||||
|
|
||||||
addr := ":8080"
|
addr := ":8080"
|
||||||
log.Printf("Server started on %s", addr)
|
log.Printf("Server started on %s", addr)
|
||||||
|
|||||||
@@ -9,10 +9,13 @@ import (
|
|||||||
|
|
||||||
type Player struct {
|
type Player struct {
|
||||||
ID string
|
ID string
|
||||||
|
Username string
|
||||||
Nickname string
|
Nickname string
|
||||||
IsReady bool
|
IsReady bool
|
||||||
IsHost bool
|
IsHost bool
|
||||||
IsBot bool
|
IsBot bool
|
||||||
|
BotControlled bool
|
||||||
|
Disconnected bool
|
||||||
Send chan []byte
|
Send chan []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +301,85 @@ func (r *Room) SendToPlayer(playerID string, data []byte) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) FindRoomByUsername(username string) (string, string, bool) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
for _, rm := range m.rooms {
|
||||||
|
rm.mu.RLock()
|
||||||
|
for _, p := range rm.Players {
|
||||||
|
if p.Username == username && !p.IsBot {
|
||||||
|
rm.mu.RUnlock()
|
||||||
|
return rm.ID, p.ID, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rm.mu.RUnlock()
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) DisconnectPlayer(roomID, playerID string) {
|
||||||
|
rm := m.GetRoom(roomID)
|
||||||
|
if rm == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rm.mu.Lock()
|
||||||
|
defer rm.mu.Unlock()
|
||||||
|
if p, ok := rm.Players[playerID]; ok {
|
||||||
|
p.Disconnected = true
|
||||||
|
p.Send = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) IsPlayerDisconnected(roomID, playerID string) bool {
|
||||||
|
rm := m.GetRoom(roomID)
|
||||||
|
if rm == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rm.mu.RLock()
|
||||||
|
defer rm.mu.RUnlock()
|
||||||
|
p, ok := rm.Players[playerID]
|
||||||
|
return ok && p.Disconnected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetPlayerBotControlled(roomID, playerID string, v bool) {
|
||||||
|
rm := m.GetRoom(roomID)
|
||||||
|
if rm == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rm.mu.Lock()
|
||||||
|
defer rm.mu.Unlock()
|
||||||
|
if p, ok := rm.Players[playerID]; ok {
|
||||||
|
p.BotControlled = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ReconnectPlayer(roomID, oldPID, newPID string, send chan []byte) bool {
|
||||||
|
rm := m.GetRoom(roomID)
|
||||||
|
if rm == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rm.mu.Lock()
|
||||||
|
defer rm.mu.Unlock()
|
||||||
|
p, ok := rm.Players[oldPID]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
p.ID = newPID
|
||||||
|
p.Disconnected = false
|
||||||
|
p.BotControlled = false
|
||||||
|
p.Send = send
|
||||||
|
delete(rm.Players, oldPID)
|
||||||
|
rm.Players[newPID] = p
|
||||||
|
for i, id := range rm.PlayerOrder {
|
||||||
|
if id == oldPID {
|
||||||
|
rm.PlayerOrder[i] = newPID
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func generateRoomID() string {
|
func generateRoomID() string {
|
||||||
src := rand.NewSource(time.Now().UnixNano())
|
src := rand.NewSource(time.Now().UnixNano())
|
||||||
r := rand.New(src)
|
r := rand.New(src)
|
||||||
|
|||||||
BIN
Binary file not shown.
Reference in New Issue
Block a user