| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184 |
- <!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:#0f172a;color:#ecf0f1;min-height:100vh}
- header{background:#1a1a2e;padding:12px 24px;border-bottom:1px solid #2c3e50;display:flex;align-items:center;gap:16px}
- h1{font-size:18px;color:#e74c3c}
- .tabs{display:flex;gap:4px;margin-left:24px}
- .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:1000px;margin:0 auto}
- .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}
- .chart{background:#1a1a2e;padding:16px;border-radius:10px;border:1px solid #2c3e50;margin-bottom:24px}
- table{width:100%;border-collapse:collapse}
- 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}
- .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}
- .input{padding:10px 14px;border-radius:8px;border:1px solid #2c3e50;background:#1a1a2e;color:#ecf0f1;font-size:14px;width:100%}
- .btn{padding:10px;border-radius:8px;border:none;background:#e74c3c;color:#fff;font-size:14px;cursor:pointer;width:100%}
- .btn:hover{opacity:.9}
- .link{color:#7f8c8d;text-align:center;cursor:pointer;font-size:13px;margin-top:4px}
- .logout-btn{margin-left:auto;padding:6px 14px;border-radius:6px;border:1px solid #2c3e50;background:transparent;color:#7f8c8d;cursor:pointer;font-size:12px}
- .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}
- .error{color:#e74c3c;font-size:13px;text-align:center}
- </style>
- </head>
- <body>
- <div id="login" style="display:none">
- <div class="login-page">
- <div class="login-box">
- <h1 style="color:#e74c3c;text-align:center;margin-bottom:12px">KirinCode 个人中心</h1>
- <input type="email" id="lemail" class="input" placeholder="企业邮箱">
- <input type="password" id="lpass" class="input" placeholder="密码">
- <input type="text" id="lname" class="input" placeholder="显示名称(注册)" style="display:none">
- <button class="btn" id="lbtn">登 录</button>
- <div class="error" id="lerror"></div>
- <div class="link" id="ltoggle">没有账号?注册</div>
- </div>
- </div>
- </div>
- <div id="app" style="display:none">
- <header>
- <h1 id="dispName">KirinCode 个人中心</h1>
- <div class="tabs">
- <button class="tab active" data-tab="summary">今日概览</button>
- <button class="tab" data-tab="trend">趋势图表</button>
- <button class="tab" data-tab="detail">用量明细</button>
- </div>
- <button class="logout-btn" onclick="logout()">退出</button>
- </header>
- <main>
- <div id="tab-summary">
- <div class="cards" id="cards"></div>
- <div class="charts" id="summaryChart"></div>
- </div>
- <div id="tab-trend" style="display:none">
- <div class="chart"><svg id="trendSvg" viewBox="0 0 700 220" style="width:100%;height:auto"></svg></div>
- </div>
- <div id="tab-detail" style="display:none">
- <table><thead><tr><th>时间</th><th>模型</th><th>Token(入)</th><th>Token(出)</th><th>费用</th></tr></thead><tbody id="detailBody"></tbody></table>
- <div class="pagination" id="pagination"></div>
- </div>
- </main>
- </div>
- <script>
- const API = "http://localhost:4097/api/v1"
- let isRegister = false, detailPage = 1, currentTab = "summary"
- document.getElementById("ltoggle").onclick = () => {
- isRegister = !isRegister
- document.getElementById("lname").style.display = isRegister ? "block" : "none"
- document.getElementById("lbtn").textContent = isRegister ? "注 册" : "登 录"
- document.getElementById("ltoggle").textContent = isRegister ? "已有账号?登录" : "没有账号?注册"
- }
- document.getElementById("lbtn").onclick = async () => {
- const email = document.getElementById("lemail").value
- const pass = document.getElementById("lpass").value
- const name = document.getElementById("lname").value
- const ep = isRegister ? "register" : "login"
- const body = isRegister ? {email,password:pass,display_name:name} : {email,password:pass}
- try {
- const r = await fetch(`${API}/auth/${ep}`, {method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)})
- const d = await r.json()
- if (d.error) { document.getElementById("lerror").textContent = d.error; return }
- localStorage.setItem("kc_token", d.token)
- localStorage.setItem("kc_user", JSON.stringify(d.user))
- showApp(d.user)
- } catch(e) { document.getElementById("lerror").textContent = "连接失败 — 请确认 Admin Server 已启动" }
- }
- function showApp(user) {
- document.getElementById("login").style.display = "none"
- document.getElementById("app").style.display = "block"
- document.getElementById("dispName").textContent = "👤 " + (user.display_name || user.email)
- loadTab("summary")
- }
- async function api(path) {
- const t = localStorage.getItem("kc_token")
- const r = await fetch(`${API}${path}`, {headers:{Authorization:`Bearer ${t}`}})
- return r.json()
- }
- async function loadTab(tab) {
- currentTab = tab
- document.querySelectorAll(".tab").forEach(e => e.classList.toggle("active", e.dataset.tab === tab))
- document.querySelectorAll("[id^='tab-']").forEach(e => e.style.display = "none")
- document.getElementById("tab-"+tab).style.display = "block"
- if (tab === "summary") {
- const s = await api("/personal/summary")
- document.getElementById("cards").innerHTML = `
- <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>`
- const t = await api("/personal/trend")
- if (t.length) drawTrend(t)
- }
- if (tab === "trend") {
- const t = await api("/personal/trend")
- if (t.length) drawTrend(t)
- else document.getElementById("trendSvg").outerHTML = '<div style="color:#7f8c8d;text-align:center;padding:40px">暂无数据</div>'
- }
- if (tab === "detail") {
- detailPage = 1; loadDetail()
- }
- }
- async function loadDetail() {
- const d = await api(`/personal/detail?page=${detailPage}`)
- document.getElementById("detailBody").innerHTML = d.data.map(e => `
- <tr><td style="font-size:11px;color:#7f8c8d">${new Date(e.created_at).toLocaleString()}</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("pagination").innerHTML = `
- <button ${detailPage<=1?"disabled":""} onclick="detailPage--;loadDetail()">上一页</button>
- <span style="color:#7f8c8d;font-size:13px">${d.page}/${d.pages} (${d.total}条)</span>
- <button ${detailPage>=d.pages?"disabled":""} onclick="detailPage++;loadDetail()">下一页</button>`
- }
- function drawTrend(data) {
- const max = Math.max(...data.map(d=>d.input+d.output),1); const w=700,h=200
- const pts = data.map((d,i) => `${(i/(data.length-1))*w},${h-((d.input+d.output)/max)*h}`).join(" ")
- const labels = data.filter((_,i)=>i%Math.max(1,Math.floor(data.length/6))===0).map((d,i)=>{
- const x = (i*Math.max(1,Math.floor(data.length/6))/(data.length-1))*w
- return `<text x="${x}" y="${h+20}" fill="#7f8c8d" font-size="10" text-anchor="middle">${new Date(d.minute).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})}</text>`
- }).join("")
- document.getElementById("trendSvg").innerHTML = `
- <line x1="0" y1="0" x2="0" y2="${h}" stroke="#2c3e50" stroke-width="1"/>
- <line x1="0" y1="${h}" x2="${w}" y2="${h}" stroke="#2c3e50" stroke-width="1"/>
- <polyline points="${pts}" fill="none" stroke="#e74c3c" stroke-width="2"/>
- <polygon points="0,${h} ${pts} ${w},${h}" fill="url(#g)" opacity="0.15"/>
- <defs><linearGradient id="g"><stop offset="0%" stop-color="#e74c3c"/><stop offset="100%" stop-color="#e74c3c" stop-opacity="0"/></linearGradient></defs>
- ${labels}`
- }
- function logout() {
- localStorage.removeItem("kc_token"); localStorage.removeItem("kc_user")
- document.getElementById("login").style.display = "block"
- document.getElementById("app").style.display = "none"
- }
- document.querySelectorAll(".tab").forEach(e => e.onclick = () => loadTab(e.dataset.tab))
- // Auto-login
- if (localStorage.getItem("kc_token")) showApp(JSON.parse(localStorage.getItem("kc_user") || "{}"))
- else document.getElementById("login").style.display = "block"
- </script>
- </body>
- </html>
|