|
|
@@ -2,7 +2,7 @@
|
|
|
<html lang="zh-CN">
|
|
|
<head>
|
|
|
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
|
-<title>KirinCode Admin</title>
|
|
|
+<title>KirinCode 管理后台</title>
|
|
|
<style>
|
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
|
body{font-family:system-ui,sans-serif;background:#0f172a;color:#ecf0f1;min-height:100vh}
|
|
|
@@ -24,7 +24,8 @@ tr:hover{background:#1a1a2e22}
|
|
|
.login-page{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh}
|
|
|
.login-box{width:340px;display:flex;flex-direction:column;gap:12px}
|
|
|
.logout-btn{margin-left:auto;padding:6px 14px;border-radius:6px;border:1px solid #2c3e50;background:transparent;color:#7f8c8d;cursor:pointer;font-size:12px}
|
|
|
-.error{color:#e74c3c;font-size:13px;text-align:center}
|
|
|
+.msg{color:#e74c3c;font-size:13px;text-align:center;padding:16px}
|
|
|
+.msg-ok{color:#27ae60}
|
|
|
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:24px}
|
|
|
.card{background:#1a1a2e;padding:20px;border-radius:12px;border:1px solid #2c3e50}
|
|
|
.card-label{color:#7f8c8d;font-size:12px;margin-bottom:8px}
|
|
|
@@ -33,174 +34,162 @@ tr:hover{background:#1a1a2e22}
|
|
|
.pagination button{padding:6px 14px;border-radius:6px;border:none;cursor:pointer;font-size:13px;background:transparent;color:#7f8c8d}
|
|
|
.pagination button:disabled{opacity:.5}
|
|
|
.flex-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
|
|
|
-.modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:100}
|
|
|
-.modal{background:#1a1a2e;padding:24px;border-radius:12px;border:1px solid #2c3e50;width:400px;max-width:90vw;display:flex;flex-direction:column;gap:12px}
|
|
|
</style>
|
|
|
</head>
|
|
|
<body>
|
|
|
-<div id="login" style="display:none">
|
|
|
+<div id="login">
|
|
|
<div class="login-page">
|
|
|
<div class="login-box">
|
|
|
- <h1 style="color:#e74c3c;text-align:center">🛡 KirinCode Admin</h1>
|
|
|
- <input type="email" id="lemail" class="input" placeholder="管理员邮箱">
|
|
|
- <input type="password" id="lpass" class="input" placeholder="密码">
|
|
|
+ <h1 style="color:#e74c3c;text-align:center;margin-bottom:8px">KirinCode 管理后台</h1>
|
|
|
+ <input type="email" id="lemail" class="input" placeholder="管理员邮箱" value="admin@kailin.com.cn">
|
|
|
+ <input type="password" id="lpass" class="input" placeholder="密码" value="admin123">
|
|
|
<button class="btn" onclick="doLogin()">登 录</button>
|
|
|
- <div class="error" id="lerror"></div>
|
|
|
+ <div class="msg" id="lerror"></div>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div id="app" style="display:none">
|
|
|
<header>
|
|
|
- <h1>🛡 KirinCode Admin</h1>
|
|
|
+ <h1>KirinCode 管理后台</h1>
|
|
|
<div class="tabs">
|
|
|
- <button class="tab active" data-tab="teams">团队管理</button>
|
|
|
- <button class="tab" data-tab="models">模型配置</button>
|
|
|
- <button class="tab" data-tab="usage">用量统计</button>
|
|
|
+ <button class="tab active" data-tab="teams" onclick="loadTab('teams')">团队管理</button>
|
|
|
+ <button class="tab" data-tab="models" onclick="loadTab('models')">模型配置</button>
|
|
|
+ <button class="tab" data-tab="usage" onclick="loadTab('usage')">用量统计</button>
|
|
|
</div>
|
|
|
<button class="logout-btn" onclick="logout()">退出</button>
|
|
|
</header>
|
|
|
- <main id="content"></main>
|
|
|
+ <main id="content"><div class="msg">加载中...</div></main>
|
|
|
</div>
|
|
|
|
|
|
<script>
|
|
|
const API = "http://localhost:4097/api/v1"
|
|
|
-let currentTab = "teams", usagePage = 1
|
|
|
+let currentTab = "teams"
|
|
|
|
|
|
-async function api(path, opt?: any) {
|
|
|
- const t = localStorage.getItem("kc_admin_token")
|
|
|
- const r = await fetch(`${API}${path}`, {headers:{Authorization:`Bearer ${t}`,...(opt?.json?{"Content-Type":"application/json"}:{})},method:opt?.method||"GET",body:opt?.json?JSON.stringify(opt.json):undefined})
|
|
|
- return r.json()
|
|
|
-}
|
|
|
+function token(){return localStorage.getItem("kc_admin_token")||""}
|
|
|
|
|
|
-async function doLogin() {
|
|
|
- const body = {email:document.getElementById("lemail").value,password:document.getElementById("lpass").value}
|
|
|
- try {
|
|
|
- const d = await api("/auth/login",{method:"POST",json:body})
|
|
|
- if (d.error) { document.getElementById("lerror").textContent = d.error; return }
|
|
|
- if (d.user.role !== "admin") { document.getElementById("lerror").textContent = "仅限管理员登录"; return }
|
|
|
- localStorage.setItem("kc_admin_token", d.token)
|
|
|
- localStorage.setItem("kc_admin_user", JSON.stringify(d.user))
|
|
|
- showApp()
|
|
|
- } catch(e) { document.getElementById("lerror").textContent = "连接失败" }
|
|
|
+async function api(path, opt){
|
|
|
+ const t=token(); const h={Authorization:"Bearer "+t}
|
|
|
+ if(opt&&opt.method==="POST")h["Content-Type"]="application/json"
|
|
|
+ try{
|
|
|
+ const r=await fetch(API+path,{method:opt?opt.method||"GET":"GET",headers:h,body:opt&&opt.body?JSON.stringify(opt.body):undefined})
|
|
|
+ return await r.json()
|
|
|
+ }catch(e){return {error:"连接失败,请确认 API 服务已启动 (port 4097)"}}
|
|
|
}
|
|
|
|
|
|
-async function loadTeams() {
|
|
|
- const [users, teams] = await Promise.all([api("/admin/users"), api("/admin/teams")])
|
|
|
- let html = `<div class="flex-row"><input class="input" id="newEmail" placeholder="成员邮箱"><input class="input" id="newName" placeholder="显示名称"><button class="btn" onclick="addMember()">添加成员</button></div>`
|
|
|
- html += `<table><thead><tr><th>邮箱</th><th>名称</th><th>角色</th><th>Token</th><th>请求</th><th>费用</th><th>状态</th><th>操作</th></tr></thead><tbody>`
|
|
|
- for (const u of users) {
|
|
|
- html += `<tr><td>${u.email}</td><td>${u.display_name}</td>
|
|
|
- <td><select class="select" onchange="updateUser('${u.id}','role',this.value)" value="${u.role}"><option ${u.role==='member'?'selected':''}>member</option><option ${u.role==='admin'?'selected':''}>admin</option></select></td>
|
|
|
- <td style="text-align:right">${(u.usage.input+u.usage.output).toLocaleString()}</td>
|
|
|
- <td style="text-align:right">${u.usage.requests}</td>
|
|
|
- <td style="text-align:right;color:#f39c12">$${u.usage.cost.toFixed(4)}</td>
|
|
|
- <td>${u.status||'active'}</td>
|
|
|
- <td>
|
|
|
- <button class="btn btn-sm ${u.status==='disabled'?'btn-green':'btn-orange'}" onclick="updateUser('${u.id}','status','${u.status==='disabled'?'active':'disabled'}')">${u.status==='disabled'?'启用':'禁用'}</button>
|
|
|
- </td></tr>`
|
|
|
- }
|
|
|
- html += `</tbody></table>`
|
|
|
- document.getElementById("content").innerHTML = html
|
|
|
+async function doLogin(){
|
|
|
+ const e=document.getElementById("lemail").value
|
|
|
+ const p=document.getElementById("lpass").value
|
|
|
+ document.getElementById("lerror").innerHTML="登录中..."
|
|
|
+ const d=await api("/auth/login",{method:"POST",body:{email:e,password:p}})
|
|
|
+ if(d.error){document.getElementById("lerror").innerHTML=d.error;return}
|
|
|
+ if(d.user&&d.user.role!=="admin"){document.getElementById("lerror").innerHTML="此账号非管理员";return}
|
|
|
+ localStorage.setItem("kc_admin_token",d.token)
|
|
|
+ document.getElementById("lerror").innerHTML="<span class='msg-ok'>登录成功</span>"
|
|
|
+ setTimeout(showApp,300)
|
|
|
}
|
|
|
|
|
|
-function addMember() {
|
|
|
- alert("请通过注册页面让成员先注册账号,然后管理员通过 PUT /api/v1/admin/users/:id 分配 team_id")
|
|
|
+function showApp(){
|
|
|
+ document.getElementById("login").style.display="none"
|
|
|
+ document.getElementById("app").style.display="block"
|
|
|
+ loadTab("teams")
|
|
|
}
|
|
|
|
|
|
-async function updateUser(id, field, value) {
|
|
|
- await api(`/admin/users/${id}`, {method:"PUT", json:{[field]:value}})
|
|
|
- loadTeams()
|
|
|
+async function loadTab(tab){
|
|
|
+ currentTab=tab
|
|
|
+ document.querySelectorAll(".tab").forEach(e=>e.classList.toggle("active",e.dataset.tab===tab))
|
|
|
+ if(tab==="teams")await loadTeams()
|
|
|
+ else if(tab==="models")await loadModels()
|
|
|
+ else if(tab==="usage")await loadUsage()
|
|
|
}
|
|
|
|
|
|
-async function loadModels() {
|
|
|
- const models = await api("/admin/models")
|
|
|
- let html = `<div class="flex-row">
|
|
|
- <input class="input" id="mProvider" placeholder="Provider (如 anthropic)"><input class="input" id="mModel" placeholder="Model (如 claude-sonnet-4-5)"><input class="input" id="mKey" placeholder="API Key"><button class="btn" onclick="addModel()">添加模型</button></div>`
|
|
|
- html += `<table><thead><tr><th>Provider</th><th>Model</th><th>API Key</th><th>创建时间</th><th>操作</th></tr></thead><tbody>`
|
|
|
- for (const m of models) {
|
|
|
- html += `<tr><td>${m.provider}</td><td>${m.model}</td><td>${"*".repeat(m.api_key?.length||0)||"未设置"}</td><td>${new Date(m.created_at).toLocaleDateString()}</td>
|
|
|
- <td><button class="btn btn-sm btn-red" onclick="delModel('${m.id}')">删除</button></td></tr>`
|
|
|
- }
|
|
|
- html += `</tbody></table>`
|
|
|
- document.getElementById("content").innerHTML = html
|
|
|
+async function loadTeams(){
|
|
|
+ document.getElementById("content").innerHTML="<div class='msg'>加载中...</div>"
|
|
|
+ try{
|
|
|
+ const [users,teams]=await Promise.all([api("/admin/users"),api("/admin/teams")])
|
|
|
+ if(users.error){document.getElementById("content").innerHTML="<div class='msg'>"+users.error+"</div>";return}
|
|
|
+ let h="<h3 style='margin-bottom:12px'>团队成员</h3>"
|
|
|
+ h+="<table><thead><tr><th>邮箱</th><th>名称</th><th>角色</th><th>Token用量</th><th>请求次数</th><th>费用(USD)</th><th>状态</th></tr></thead><tbody>"
|
|
|
+ for(const u of users||[]){
|
|
|
+ h+=`<tr><td>${u.email}</td><td>${u.display_name||""}</td><td>${u.role}</td>
|
|
|
+ <td style="text-align:right">${(u.usage?.input+u.usage?.output||0).toLocaleString()}</td>
|
|
|
+ <td style="text-align:right">${u.usage?.requests||0}</td>
|
|
|
+ <td style="text-align:right;color:#f39c12">$${(u.usage?.cost||0).toFixed(4)}</td>
|
|
|
+ <td>${u.status||"active"}</td></tr>`
|
|
|
+ }
|
|
|
+ h+="</tbody></table>"
|
|
|
+ if(teams&&teams.length)h+="<p style='margin-top:16px;color:#7f8c8d'>团队: "+teams.map(t=>t.name).join(", ")+"</p>"
|
|
|
+ document.getElementById("content").innerHTML=h
|
|
|
+ }catch(e){document.getElementById("content").innerHTML="<div class='msg'>加载失败: "+e.message+"</div>"}
|
|
|
}
|
|
|
|
|
|
-async function addModel() {
|
|
|
- const p = document.getElementById("mProvider").value
|
|
|
- const m = document.getElementById("mModel").value
|
|
|
- const k = document.getElementById("mKey").value
|
|
|
- if (!p||!m) return alert("请填写 Provider 和 Model")
|
|
|
- await api("/admin/models", {method:"POST", json:{provider:p,model:m,api_key:k}})
|
|
|
- loadModels()
|
|
|
+async function loadModels(){
|
|
|
+ document.getElementById("content").innerHTML="<div class='msg'>加载中...</div>"
|
|
|
+ try{
|
|
|
+ const models=await api("/admin/models")
|
|
|
+ let h="<h3 style='margin-bottom:12px'>企业模型配置</h3>"
|
|
|
+ h+=`<div class='flex-row'><input class='input' id='mProvider' placeholder='Provider (如 anthropic)'><input class='input' id='mModel' placeholder='模型名'><input class='input' id='mKey' placeholder='API Key'><button class='btn' onclick='addModel()'>添加模型</button></div>`
|
|
|
+ h+="<table><thead><tr><th>Provider</th><th>模型</th><th>API Key</th><th>创建时间</th><th>操作</th></tr></thead><tbody>"
|
|
|
+ for(const m of models||[]){
|
|
|
+ h+=`<tr><td>${m.provider}</td><td>${m.model}</td><td>${"*".repeat(Math.min(m.api_key?.length||0,12))||"未设置"}</td><td>${new Date(m.created_at).toLocaleDateString("zh-CN")}</td>
|
|
|
+ <td><button class='btn btn-sm btn-red' onclick='delModel("${m.id}")'>删除</button></td></tr>`
|
|
|
+ }
|
|
|
+ h+="</tbody></table>"
|
|
|
+ if(!models||!models.length)h+="<p style='color:#7f8c8d;margin-top:12px'>暂无配置的模型,请添加</p>"
|
|
|
+ document.getElementById("content").innerHTML=h
|
|
|
+ }catch(e){document.getElementById("content").innerHTML="<div class='msg'>加载失败</div>"}
|
|
|
}
|
|
|
|
|
|
-async function delModel(id) {
|
|
|
- await api("/admin/models/"+id, {method:"DELETE"})
|
|
|
+async function addModel(){
|
|
|
+ const p=document.getElementById("mProvider").value
|
|
|
+ const m=document.getElementById("mModel").value
|
|
|
+ const k=document.getElementById("mKey").value
|
|
|
+ if(!p||!m){alert("请填写 Provider 和模型名");return}
|
|
|
+ await api("/admin/models",{method:"POST",body:{provider:p,model:m,api_key:k}})
|
|
|
loadModels()
|
|
|
}
|
|
|
|
|
|
-async function loadUsage() {
|
|
|
- const s = await api("/admin/usage/summary")
|
|
|
- let html = `<div class="cards">
|
|
|
- <div class="card"><div class="card-label">全队 Token</div><div class="card-value">${((s.input||0)+(s.output||0)).toLocaleString()}</div></div>
|
|
|
- <div class="card"><div class="card-label">请求次数</div><div class="card-value">${(s.requests||0).toLocaleString()}</div></div>
|
|
|
- <div class="card"><div class="card-label">今日费用</div><div class="card-value">$${(s.cost||0).toFixed(4)}</div></div>
|
|
|
- <div class="card"><div class="card-label">活跃用户</div><div class="card-value">${s.users||0}</div></div></div>`
|
|
|
- html += `<div class="flex-row"><input class="input" id="uFilter" placeholder="筛选用户ID" style="width:160px"><input class="input" id="mFilter" placeholder="筛选模型" style="width:140px"><button class="btn" onclick="loadUsagePage(1)">筛选</button><button class="btn btn-green" onclick="exportCSV()">导出CSV</button></div>`
|
|
|
- html += `<table><thead><tr><th>时间</th><th>用户</th><th>邮箱</th><th>模型</th><th>Token(入)</th><th>Token(出)</th><th>费用</th></tr></thead><tbody id="usageBody"></tbody></table>`
|
|
|
- html += `<div class="pagination" id="usagePage"></div>`
|
|
|
- document.getElementById("content").innerHTML = html
|
|
|
- loadUsagePage(1)
|
|
|
-}
|
|
|
-
|
|
|
-async function loadUsagePage(p) {
|
|
|
- usagePage = p
|
|
|
- const uid = document.getElementById("uFilter")?.value || ""
|
|
|
- const mdl = document.getElementById("mFilter")?.value || ""
|
|
|
- const qs = [`page=${p}`,uid?`user_id=${uid}`:"",mdl?`model=${mdl}`:""].filter(Boolean).join("&")
|
|
|
- const d = await api(`/admin/usage/detail?${qs}`)
|
|
|
- document.getElementById("usageBody").innerHTML = d.data.map(e => `
|
|
|
- <tr><td style="font-size:11px;color:#7f8c8d">${new Date(e.created_at).toLocaleString()}</td>
|
|
|
- <td>${e.display_name}</td><td style="color:#7f8c8d">${e.email}</td><td>${e.provider}/${e.model}</td>
|
|
|
- <td style="text-align:right">${e.input_tokens.toLocaleString()}</td><td style="text-align:right">${e.output_tokens.toLocaleString()}</td>
|
|
|
- <td style="text-align:right;color:#f39c12">$${e.cost_usd.toFixed(5)}</td></tr>`).join("")
|
|
|
- document.getElementById("usagePage").innerHTML = `
|
|
|
- <button ${p<=1?"disabled":""} onclick="loadUsagePage(${p-1})">上一页</button>
|
|
|
- <span style="color:#7f8c8d;font-size:13px">${d.page}/${d.pages} (${d.total}条)</span>
|
|
|
- <button ${p>=d.pages?"disabled":""} onclick="loadUsagePage(${p+1})">下一页</button>`
|
|
|
-}
|
|
|
-
|
|
|
-async function exportCSV() {
|
|
|
- const t = localStorage.getItem("kc_admin_token")
|
|
|
- const r = await fetch(`${API}/admin/usage/export`, {headers:{Authorization:`Bearer ${t}`}})
|
|
|
- const blob = await r.blob()
|
|
|
- const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "kirincode-usage.csv"; a.click()
|
|
|
-}
|
|
|
-
|
|
|
-function loadTab(tab) {
|
|
|
- currentTab = tab
|
|
|
- document.querySelectorAll(".tab").forEach(e => e.classList.toggle("active", e.dataset.tab === tab))
|
|
|
- if (tab === "teams") loadTeams()
|
|
|
- else if (tab === "models") loadModels()
|
|
|
- else if (tab === "usage") loadUsage()
|
|
|
-}
|
|
|
-
|
|
|
-function showApp() {
|
|
|
- document.getElementById("login").style.display = "none"
|
|
|
- document.getElementById("app").style.display = "block"
|
|
|
- loadTab("teams")
|
|
|
+async function delModel(id){await api("/admin/models/"+id,{method:"DELETE"});loadModels()}
|
|
|
+
|
|
|
+async function loadUsage(){
|
|
|
+ document.getElementById("content").innerHTML="<div class='msg'>加载中...</div>"
|
|
|
+ try{
|
|
|
+ const [s,d]=await Promise.all([
|
|
|
+ api("/admin/usage/summary"),
|
|
|
+ api("/admin/usage/detail?page=1")
|
|
|
+ ])
|
|
|
+ let h=""
|
|
|
+ if(!s.error){
|
|
|
+ h+=`<div class='cards'><div class='card'><div class='card-label'>全队 Token</div><div class='card-value'>${((s.input||0)+(s.output||0)).toLocaleString()}</div></div>
|
|
|
+ <div class='card'><div class='card-label'>请求次数</div><div class='card-value'>${(s.requests||0).toLocaleString()}</div></div>
|
|
|
+ <div class='card'><div class='card-label'>今日费用</div><div class='card-value'>$${(s.cost||0).toFixed(4)}</div></div>
|
|
|
+ <div class='card'><div class='card-label'>活跃用户</div><div class='card-value'>${s.users||0}</div></div></div>`
|
|
|
+ }
|
|
|
+ h+="<button class='btn btn-green' style='margin-bottom:12px' onclick='exportCSV()'>导出 CSV</button>"
|
|
|
+ h+="<table><thead><tr><th>时间</th><th>用户</th><th>模型</th><th>输入Token</th><th>输出Token</th><th>费用</th></tr></thead><tbody>"
|
|
|
+ for(const r of d.data||[]){
|
|
|
+ h+=`<tr><td style='font-size:11px;color:#7f8c8d'>${new Date(r.created_at).toLocaleString("zh-CN")}</td>
|
|
|
+ <td>${r.display_name||""}</td><td>${r.provider}/${r.model}</td>
|
|
|
+ <td style="text-align:right">${(r.input_tokens||0).toLocaleString()}</td>
|
|
|
+ <td style="text-align:right">${(r.output_tokens||0).toLocaleString()}</td>
|
|
|
+ <td style="text-align:right;color:#f39c12">$${(r.cost_usd||0).toFixed(5)}</td></tr>`
|
|
|
+ }
|
|
|
+ h+="</tbody></table>"
|
|
|
+ document.getElementById("content").innerHTML=h
|
|
|
+ }catch(e){document.getElementById("content").innerHTML="<div class='msg'>加载失败</div>"}
|
|
|
}
|
|
|
|
|
|
-function logout() {
|
|
|
- localStorage.removeItem("kc_admin_token"); localStorage.removeItem("kc_admin_user")
|
|
|
- location.reload()
|
|
|
+async function exportCSV(){
|
|
|
+ const t=token()
|
|
|
+ const r=await fetch(API+"/admin/usage/export",{headers:{Authorization:"Bearer "+t}})
|
|
|
+ const b=await r.blob()
|
|
|
+ const a=document.createElement("a");a.href=URL.createObjectURL(b);a.download="用量导出.csv";a.click()
|
|
|
}
|
|
|
|
|
|
-document.querySelectorAll(".tab").forEach(e => e.onclick = () => loadTab(e.dataset.tab))
|
|
|
+function logout(){localStorage.removeItem("kc_admin_token");location.reload()}
|
|
|
|
|
|
-if (localStorage.getItem("kc_admin_token")) showApp()
|
|
|
-else document.getElementById("login").style.display = "block"
|
|
|
+// Auto-login
|
|
|
+if(localStorage.getItem("kc_admin_token"))showApp()
|
|
|
</script>
|
|
|
</body>
|
|
|
</html>
|