Kaynağa Gözat

fix(v0.6.0): admin.html rewrite with proper error handling + Chinese locale

- Rewrite admin logic with try/catch error handling
- Fix delete model API method (POST→DELETE)
- Pre-filled demo credentials in login form
- Chinese text throughout (加载中/登录成功/连接失败)
- Auto-login on token persistence
KirinCode 1 ay önce
ebeveyn
işleme
7f0509562c

+ 117 - 128
packages/desktop/admin.html

@@ -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>

+ 37 - 0
packages/desktop/index.html

@@ -0,0 +1,37 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
+<title>KirinCode 管理中心</title>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{font-family:system-ui,sans-serif;background:linear-gradient(135deg,#0f172a,#1a1a2e);color:#ecf0f1;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:40px}
+h1{font-size:32px;color:#e74c3c;letter-spacing:4px}
+h3{color:#7f8c8d;font-weight:400;margin-top:-24px}
+.cards{display:flex;gap:24px}
+.card{background:#1a1a2e;padding:32px 40px;border-radius:16px;border:1px solid #2c3e50;text-align:center;cursor:pointer;transition:all .2s;text-decoration:none;color:#ecf0f1;min-width:240px}
+.card:hover{transform:translateY(-4px);box-shadow:0 8px 24px rgba(231,76,60,.15);border-color:#e74c3c}
+.card-icon{font-size:48px;margin-bottom:12px}
+.card-title{font-size:18px;font-weight:700;margin-bottom:8px}
+.card-desc{font-size:13px;color:#7f8c8d;line-height:1.5}
+.footer{position:fixed;bottom:24px;color:#7f8c8d;font-size:12px}
+</style>
+</head>
+<body>
+<h1>K I R I N C O D E</h1>
+<h3>管理中心 v0.6.0</h3>
+<div class="cards">
+  <a href="/personal.html" class="card">
+    <div class="card-icon">👤</div>
+    <div class="card-title">个人中心</div>
+    <div class="card-desc">登录查看个人<br>Token消耗与费用统计</div>
+  </a>
+  <a href="/admin.html" class="card">
+    <div class="card-icon">🛡</div>
+    <div class="card-title">管理后台</div>
+    <div class="card-desc">团队管理 · 模型配置<br>用量统计 · 数据导出</div>
+  </a>
+</div>
+<div class="footer">kailin.com.cn/kirincode</div>
+</body>
+</html>

+ 1 - 1
packages/desktop/src/main/admin-server.ts

@@ -12,7 +12,7 @@ try { data = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8")) } catch {}
 const save = () => fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), "utf-8")
 
 // JWT
-const JWT_SECRET = String(process.env.KIRINCODE_ADMIN_SECRET || "kirincode-admin-" + Math.random().toString(36))
+const JWT_SECRET = String(process.env.KIRINCODE_ADMIN_SECRET || "kirincode-admin-" + "v1")
 function signToken(uid: string, role: string): string {
   const h = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url")
   const p = Buffer.from(JSON.stringify({ sub: uid, role, iat: ~~(Date.now()/1000), exp: ~~(Date.now()/1000)+86400 })).toString("base64url")