admin.html 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
  5. <title>KirinCode Admin</title>
  6. <style>
  7. *{margin:0;padding:0;box-sizing:border-box}
  8. body{font-family:system-ui,sans-serif;background:#0f172a;color:#ecf0f1;min-height:100vh}
  9. header{background:#1a1a2e;padding:12px 24px;border-bottom:1px solid #2c3e50;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
  10. h1{font-size:18px;color:#e74c3c;margin-right:24px}
  11. .tabs{display:flex;gap:4px;flex-wrap:wrap}
  12. .tab{padding:6px 14px;border-radius:6px;border:none;font-size:13px;cursor:pointer;background:transparent;color:#7f8c8d}
  13. .tab.active{background:#e74c3c;color:#fff}
  14. main{padding:24px;max-width:1200px;margin:0 auto}
  15. table{width:100%;border-collapse:collapse;margin-top:12px}
  16. th{color:#7f8c8d;font-size:11px;text-align:left;padding:10px 8px;border-bottom:1px solid #2c3e50}
  17. td{padding:10px 8px;font-size:13px;border-bottom:1px solid #1e293b}
  18. tr:hover{background:#1a1a2e22}
  19. .input{padding:8px 12px;border-radius:6px;border:1px solid #2c3e50;background:#1a1a2e;color:#ecf0f1;font-size:13px}
  20. .select{padding:6px 10px;border-radius:6px;border:1px solid #2c3e50;background:#1a1a2e;color:#ecf0f1;font-size:12px;cursor:pointer}
  21. .btn{padding:8px 16px;border-radius:6px;border:none;background:#e74c3c;color:#fff;font-size:13px;cursor:pointer}
  22. .btn:hover{opacity:.9}.btn-sm{padding:5px 10px;font-size:12px}
  23. .btn-green{background:#27ae60}.btn-orange{background:#e67e22}.btn-red{background:#c0392b}
  24. .login-page{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh}
  25. .login-box{width:340px;display:flex;flex-direction:column;gap:12px}
  26. .logout-btn{margin-left:auto;padding:6px 14px;border-radius:6px;border:1px solid #2c3e50;background:transparent;color:#7f8c8d;cursor:pointer;font-size:12px}
  27. .error{color:#e74c3c;font-size:13px;text-align:center}
  28. .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:16px;margin-bottom:24px}
  29. .card{background:#1a1a2e;padding:20px;border-radius:12px;border:1px solid #2c3e50}
  30. .card-label{color:#7f8c8d;font-size:12px;margin-bottom:8px}
  31. .card-value{font-size:22px;font-weight:700;color:#f39c12}
  32. .pagination{display:flex;justify-content:center;gap:8px;margin-top:16px;align-items:center}
  33. .pagination button{padding:6px 14px;border-radius:6px;border:none;cursor:pointer;font-size:13px;background:transparent;color:#7f8c8d}
  34. .pagination button:disabled{opacity:.5}
  35. .flex-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
  36. .modal-bg{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:100}
  37. .modal{background:#1a1a2e;padding:24px;border-radius:12px;border:1px solid #2c3e50;width:400px;max-width:90vw;display:flex;flex-direction:column;gap:12px}
  38. </style>
  39. </head>
  40. <body>
  41. <div id="login" style="display:none">
  42. <div class="login-page">
  43. <div class="login-box">
  44. <h1 style="color:#e74c3c;text-align:center">🛡 KirinCode Admin</h1>
  45. <input type="email" id="lemail" class="input" placeholder="管理员邮箱">
  46. <input type="password" id="lpass" class="input" placeholder="密码">
  47. <button class="btn" onclick="doLogin()">登 录</button>
  48. <div class="error" id="lerror"></div>
  49. </div>
  50. </div>
  51. </div>
  52. <div id="app" style="display:none">
  53. <header>
  54. <h1>🛡 KirinCode Admin</h1>
  55. <div class="tabs">
  56. <button class="tab active" data-tab="teams">团队管理</button>
  57. <button class="tab" data-tab="models">模型配置</button>
  58. <button class="tab" data-tab="usage">用量统计</button>
  59. </div>
  60. <button class="logout-btn" onclick="logout()">退出</button>
  61. </header>
  62. <main id="content"></main>
  63. </div>
  64. <script>
  65. const API = "http://localhost:4097/api/v1"
  66. let currentTab = "teams", usagePage = 1
  67. async function api(path, opt?: any) {
  68. const t = localStorage.getItem("kc_admin_token")
  69. 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})
  70. return r.json()
  71. }
  72. async function doLogin() {
  73. const body = {email:document.getElementById("lemail").value,password:document.getElementById("lpass").value}
  74. try {
  75. const d = await api("/auth/login",{method:"POST",json:body})
  76. if (d.error) { document.getElementById("lerror").textContent = d.error; return }
  77. if (d.user.role !== "admin") { document.getElementById("lerror").textContent = "仅限管理员登录"; return }
  78. localStorage.setItem("kc_admin_token", d.token)
  79. localStorage.setItem("kc_admin_user", JSON.stringify(d.user))
  80. showApp()
  81. } catch(e) { document.getElementById("lerror").textContent = "连接失败" }
  82. }
  83. async function loadTeams() {
  84. const [users, teams] = await Promise.all([api("/admin/users"), api("/admin/teams")])
  85. 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>`
  86. html += `<table><thead><tr><th>邮箱</th><th>名称</th><th>角色</th><th>Token</th><th>请求</th><th>费用</th><th>状态</th><th>操作</th></tr></thead><tbody>`
  87. for (const u of users) {
  88. html += `<tr><td>${u.email}</td><td>${u.display_name}</td>
  89. <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>
  90. <td style="text-align:right">${(u.usage.input+u.usage.output).toLocaleString()}</td>
  91. <td style="text-align:right">${u.usage.requests}</td>
  92. <td style="text-align:right;color:#f39c12">$${u.usage.cost.toFixed(4)}</td>
  93. <td>${u.status||'active'}</td>
  94. <td>
  95. <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>
  96. </td></tr>`
  97. }
  98. html += `</tbody></table>`
  99. document.getElementById("content").innerHTML = html
  100. }
  101. function addMember() {
  102. alert("请通过注册页面让成员先注册账号,然后管理员通过 PUT /api/v1/admin/users/:id 分配 team_id")
  103. }
  104. async function updateUser(id, field, value) {
  105. await api(`/admin/users/${id}`, {method:"PUT", json:{[field]:value}})
  106. loadTeams()
  107. }
  108. async function loadModels() {
  109. const models = await api("/admin/models")
  110. let html = `<div class="flex-row">
  111. <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>`
  112. html += `<table><thead><tr><th>Provider</th><th>Model</th><th>API Key</th><th>创建时间</th><th>操作</th></tr></thead><tbody>`
  113. for (const m of models) {
  114. 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>
  115. <td><button class="btn btn-sm btn-red" onclick="delModel('${m.id}')">删除</button></td></tr>`
  116. }
  117. html += `</tbody></table>`
  118. document.getElementById("content").innerHTML = html
  119. }
  120. async function addModel() {
  121. const p = document.getElementById("mProvider").value
  122. const m = document.getElementById("mModel").value
  123. const k = document.getElementById("mKey").value
  124. if (!p||!m) return alert("请填写 Provider 和 Model")
  125. await api("/admin/models", {method:"POST", json:{provider:p,model:m,api_key:k}})
  126. loadModels()
  127. }
  128. async function delModel(id) {
  129. await api("/admin/models/"+id, {method:"DELETE"})
  130. loadModels()
  131. }
  132. async function loadUsage() {
  133. const s = await api("/admin/usage/summary")
  134. let html = `<div class="cards">
  135. <div class="card"><div class="card-label">全队 Token</div><div class="card-value">${((s.input||0)+(s.output||0)).toLocaleString()}</div></div>
  136. <div class="card"><div class="card-label">请求次数</div><div class="card-value">${(s.requests||0).toLocaleString()}</div></div>
  137. <div class="card"><div class="card-label">今日费用</div><div class="card-value">$${(s.cost||0).toFixed(4)}</div></div>
  138. <div class="card"><div class="card-label">活跃用户</div><div class="card-value">${s.users||0}</div></div></div>`
  139. 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>`
  140. 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>`
  141. html += `<div class="pagination" id="usagePage"></div>`
  142. document.getElementById("content").innerHTML = html
  143. loadUsagePage(1)
  144. }
  145. async function loadUsagePage(p) {
  146. usagePage = p
  147. const uid = document.getElementById("uFilter")?.value || ""
  148. const mdl = document.getElementById("mFilter")?.value || ""
  149. const qs = [`page=${p}`,uid?`user_id=${uid}`:"",mdl?`model=${mdl}`:""].filter(Boolean).join("&")
  150. const d = await api(`/admin/usage/detail?${qs}`)
  151. document.getElementById("usageBody").innerHTML = d.data.map(e => `
  152. <tr><td style="font-size:11px;color:#7f8c8d">${new Date(e.created_at).toLocaleString()}</td>
  153. <td>${e.display_name}</td><td style="color:#7f8c8d">${e.email}</td><td>${e.provider}/${e.model}</td>
  154. <td style="text-align:right">${e.input_tokens.toLocaleString()}</td><td style="text-align:right">${e.output_tokens.toLocaleString()}</td>
  155. <td style="text-align:right;color:#f39c12">$${e.cost_usd.toFixed(5)}</td></tr>`).join("")
  156. document.getElementById("usagePage").innerHTML = `
  157. <button ${p<=1?"disabled":""} onclick="loadUsagePage(${p-1})">上一页</button>
  158. <span style="color:#7f8c8d;font-size:13px">${d.page}/${d.pages} (${d.total}条)</span>
  159. <button ${p>=d.pages?"disabled":""} onclick="loadUsagePage(${p+1})">下一页</button>`
  160. }
  161. async function exportCSV() {
  162. const t = localStorage.getItem("kc_admin_token")
  163. const r = await fetch(`${API}/admin/usage/export`, {headers:{Authorization:`Bearer ${t}`}})
  164. const blob = await r.blob()
  165. const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "kirincode-usage.csv"; a.click()
  166. }
  167. function loadTab(tab) {
  168. currentTab = tab
  169. document.querySelectorAll(".tab").forEach(e => e.classList.toggle("active", e.dataset.tab === tab))
  170. if (tab === "teams") loadTeams()
  171. else if (tab === "models") loadModels()
  172. else if (tab === "usage") loadUsage()
  173. }
  174. function showApp() {
  175. document.getElementById("login").style.display = "none"
  176. document.getElementById("app").style.display = "block"
  177. loadTab("teams")
  178. }
  179. function logout() {
  180. localStorage.removeItem("kc_admin_token"); localStorage.removeItem("kc_admin_user")
  181. location.reload()
  182. }
  183. document.querySelectorAll(".tab").forEach(e => e.onclick = () => loadTab(e.dataset.tab))
  184. if (localStorage.getItem("kc_admin_token")) showApp()
  185. else document.getElementById("login").style.display = "block"
  186. </script>
  187. </body>
  188. </html>