| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
- <title>KirinCode Admin</title>
- <style>
- *{margin:0;padding:0;box-sizing:border-box}
- body{font-family:system-ui,sans-serif;background:#0f172a;color:#ecf0f1;min-height:100vh}
- header{background:#1a1a2e;padding:12px 24px;border-bottom:1px solid #2c3e50;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
- h1{font-size:18px;color:#e74c3c;margin-right:24px}
- .tabs{display:flex;gap:4px;flex-wrap:wrap}
- .tab{padding:6px 14px;border-radius:6px;border:none;font-size:13px;cursor:pointer;background:transparent;color:#7f8c8d}
- .tab.active{background:#e74c3c;color:#fff}
- main{padding:24px;max-width:1200px;margin:0 auto}
- table{width:100%;border-collapse:collapse;margin-top:12px}
- th{color:#7f8c8d;font-size:11px;text-align:left;padding:10px 8px;border-bottom:1px solid #2c3e50}
- td{padding:10px 8px;font-size:13px;border-bottom:1px solid #1e293b}
- tr:hover{background:#1a1a2e22}
- .input{padding:8px 12px;border-radius:6px;border:1px solid #2c3e50;background:#1a1a2e;color:#ecf0f1;font-size:13px}
- .select{padding:6px 10px;border-radius:6px;border:1px solid #2c3e50;background:#1a1a2e;color:#ecf0f1;font-size:12px;cursor:pointer}
- .btn{padding:8px 16px;border-radius:6px;border:none;background:#e74c3c;color:#fff;font-size:13px;cursor:pointer}
- .btn:hover{opacity:.9}.btn-sm{padding:5px 10px;font-size:12px}
- .btn-green{background:#27ae60}.btn-orange{background:#e67e22}.btn-red{background:#c0392b}
- .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}
- .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}
- .card-value{font-size:22px;font-weight:700;color:#f39c12}
- .pagination{display:flex;justify-content:center;gap:8px;margin-top:16px;align-items:center}
- .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 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="密码">
- <button class="btn" onclick="doLogin()">登 录</button>
- <div class="error" id="lerror"></div>
- </div>
- </div>
- </div>
- <div id="app" style="display:none">
- <header>
- <h1>🛡 KirinCode Admin</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>
- </div>
- <button class="logout-btn" onclick="logout()">退出</button>
- </header>
- <main id="content"></main>
- </div>
- <script>
- const API = "http://localhost:4097/api/v1"
- let currentTab = "teams", usagePage = 1
- 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()
- }
- 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 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
- }
- function addMember() {
- alert("请通过注册页面让成员先注册账号,然后管理员通过 PUT /api/v1/admin/users/:id 分配 team_id")
- }
- async function updateUser(id, field, value) {
- await api(`/admin/users/${id}`, {method:"PUT", json:{[field]:value}})
- loadTeams()
- }
- 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 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 delModel(id) {
- await api("/admin/models/"+id, {method:"DELETE"})
- 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")
- }
- function logout() {
- localStorage.removeItem("kc_admin_token"); localStorage.removeItem("kc_admin_user")
- location.reload()
- }
- document.querySelectorAll(".tab").forEach(e => e.onclick = () => loadTab(e.dataset.tab))
- if (localStorage.getItem("kc_admin_token")) showApp()
- else document.getElementById("login").style.display = "block"
- </script>
- </body>
- </html>
|