AdminDashboard.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { createSignal, createResource, onMount, Show, For } from "solid-js"
  2. import { useNavigate } from "@solidjs/router"
  3. const API_BASE = "http://localhost:4097/api/v1"
  4. const LS_KEY = "kirincode_personal_token"
  5. async function api(path: string, method = "GET", body?: any): Promise<any> {
  6. const token = localStorage.getItem(LS_KEY)
  7. const headers: Record<string, string> = { "Content-Type": "application/json" }
  8. if (token) headers["Authorization"] = `Bearer ${token}`
  9. const res = await fetch(`${API_BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined })
  10. return res.json()
  11. }
  12. export function PersonalDashboard() {
  13. const navigate = useNavigate()
  14. const [token, setToken] = createSignal(localStorage.getItem(LS_KEY) || "")
  15. const [loggedIn, setLoggedIn] = createSignal(!!token())
  16. const [email, setEmail] = createSignal("")
  17. const [password, setPassword] = createSignal("")
  18. const [displayName, setDisplayName] = createSignal("")
  19. const [isRegister, setIsRegister] = createSignal(false)
  20. const [error, setError] = createSignal("")
  21. const [user, setUser] = createSignal<any>(null)
  22. const [tab, setTab] = createSignal<"summary" | "trend" | "detail">("summary")
  23. const [detailPage, setDetailPage] = createSignal(1)
  24. async function doAuth() {
  25. setError("")
  26. const endpoint = isRegister() ? "/auth/register" : "/auth/login"
  27. const body = isRegister() ? { email: email(), password: password(), display_name: displayName() } : { email: email(), password: password() }
  28. try {
  29. const data = await api(endpoint, "POST", body)
  30. if (data.error) { setError(data.error); return }
  31. localStorage.setItem(LS_KEY, data.token)
  32. setToken(data.token)
  33. setUser(data.user)
  34. setLoggedIn(true)
  35. } catch { setError("连接失败 — 请确认 Admin Server 已启动 (port 4097)") }
  36. }
  37. function logout() {
  38. localStorage.removeItem(LS_KEY)
  39. setToken(""); setLoggedIn(false); setUser(null)
  40. }
  41. const [summary] = createResource(loggedIn, () => api("/personal/summary"))
  42. const [trend] = createResource(() => loggedIn() && tab() === "trend", () => api("/personal/trend"))
  43. const [details] = createResource(() => loggedIn() && tab() === "detail", () => api(`/personal/detail?page=${detailPage()}`))
  44. onMount(() => {
  45. const i = setInterval(() => { if (loggedIn() && tab() === "summary") api("/personal/summary") }, 30000)
  46. return () => clearInterval(i)
  47. })
  48. if (!loggedIn()) {
  49. return (
  50. <div style={styles.loginPage}>
  51. <button onClick={() => navigate("/")} style={styles.backBtn}>← 返回</button>
  52. <h1 style={styles.title}>KirinCode 个人中心</h1>
  53. <div style={{ display: "flex", "flex-direction": "column", gap: "12px", width: "340px" }}>
  54. <input type="email" placeholder="企业邮箱" value={email()} onInput={e => setEmail(e.currentTarget.value)} style={styles.input} />
  55. <input type="password" placeholder="密码" value={password()} onInput={e => setPassword(e.currentTarget.value)} style={styles.input} onKeyDown={e => e.key === "Enter" && doAuth()} />
  56. <Show when={isRegister()}>
  57. <input type="text" placeholder="显示名称" value={displayName()} onInput={e => setDisplayName(e.currentTarget.value)} style={styles.input} />
  58. </Show>
  59. <button onClick={doAuth} style={styles.btn}>{isRegister() ? "注 册" : "登 录"}</button>
  60. <Show when={error()}><div style={styles.error}>{error()}</div></Show>
  61. <button onClick={() => { setIsRegister(!isRegister()); setError("") }} style={{ ...styles.btn, background: "transparent", border: "1px solid #2c3e50", color: "#7f8c8d" }}>
  62. {isRegister() ? "已有账号?登录" : "没有账号?注册"}
  63. </button>
  64. </div>
  65. </div>
  66. )
  67. }
  68. const tabs = [
  69. { key: "summary" as const, label: "今日概览" },
  70. { key: "trend" as const, label: "趋势图表" },
  71. { key: "detail" as const, label: "用量明细" },
  72. ]
  73. return (
  74. <div style={styles.page}>
  75. <div style={styles.header}>
  76. <button onClick={() => navigate("/")} style={styles.backBtn}>← 返回</button>
  77. <span style={styles.headerTitle}>👤 {user()?.display_name || user()?.email || "个人中心"}</span>
  78. <div style={{ display: "flex", gap: "4px" }}>
  79. {tabs.map(t => (
  80. <button onClick={() => setTab(t.key)} style={{ ...styles.tabBtn, background: tab() === t.key ? "#e74c3c" : "transparent", color: tab() === t.key ? "#fff" : "#7f8c8d" }}>
  81. {t.label}
  82. </button>
  83. ))}
  84. </div>
  85. <div style={{ "margin-left": "auto", display: "flex", gap: "8px", "align-items": "center" }}>
  86. <a href="http://localhost:4097/personal.html" target="_blank" style={{ color: "#7f8c8d", "font-size": "12px", "text-decoration": "none" }}>📄 独立页面</a>
  87. <button onClick={logout} style={styles.logoutBtn}>退出</button>
  88. </div>
  89. </div>
  90. <div style={styles.content}>
  91. <Show when={tab() === "summary"}>
  92. <h2 style={styles.sectionTitle}>今日Token消耗</h2>
  93. <div style={styles.cards}>
  94. <Card label="今日 Token" value={((summary()?.input || 0) + (summary()?.output || 0)).toLocaleString()} />
  95. <Card label="请求次数" value={(summary()?.requests || 0).toLocaleString()} />
  96. <Card label="今日费用" value={`$${(summary()?.cost || 0).toFixed(4)}`} />
  97. </div>
  98. </Show>
  99. <Show when={tab() === "trend"}>
  100. <h2 style={styles.sectionTitle}>分钟级消耗趋势(近24小时)</h2>
  101. <div style={styles.chart}>
  102. <TrendChart data={trend() || []} />
  103. </div>
  104. </Show>
  105. <Show when={tab() === "detail"}>
  106. <h2 style={styles.sectionTitle}>用量明细</h2>
  107. <DetailTable data={details()?.data || []} />
  108. <Show when={details()}>
  109. <div style={{ display: "flex", "justify-content": "center", gap: "8px", "margin-top": "16px" }}>
  110. <button disabled={detailPage() <= 1} onClick={() => setDetailPage(p => p - 1)} style={{ ...styles.tabBtn, opacity: detailPage() <= 1 ? 0.5 : 1 }}>上一页</button>
  111. <span style={{ color: "#7f8c8d", padding: "6px" }}>第 {details()?.page}/{details()?.pages} 页</span>
  112. <button disabled={detailPage() >= (details()?.pages || 1)} onClick={() => setDetailPage(p => p + 1)} style={{ ...styles.tabBtn, opacity: detailPage() >= (details()?.pages || 1) ? 0.5 : 1 }}>下一页</button>
  113. </div>
  114. </Show>
  115. </Show>
  116. </div>
  117. </div>
  118. )
  119. }
  120. function Card(p: { label: string; value: string }) {
  121. return <div style={styles.card}><div style={styles.cardLabel}>{p.label}</div><div style={styles.cardValue}>{p.value}</div></div>
  122. }
  123. function DetailTable(p: { data: any[] }) {
  124. return (
  125. <table style={{ width: "100%", "border-collapse": "collapse" }}>
  126. <thead><tr style={{ color: "#7f8c8d", "font-size": "11px", "text-align": "left" }}><th style={{ padding: "10px 8px" }}>时间</th><th style={{ padding: "10px 8px" }}>模型</th><th style={{ padding: "10px 8px", "text-align": "right" }}>Token(入)</th><th style={{ padding: "10px 8px", "text-align": "right" }}>Token(出)</th><th style={{ padding: "10px 8px", "text-align": "right" }}>费用</th></tr></thead>
  127. <For each={p.data}>{(row: any) => (
  128. <tr style={{ "border-top": "1px solid #1e293b", "font-size": "12px" }}>
  129. <td style={{ padding: "8px", "font-size": "11px", color: "#7f8c8d" }}>{new Date(row.created_at).toLocaleString()}</td>
  130. <td style={{ padding: "8px" }}>{row.provider}/{row.model}</td>
  131. <td style={{ padding: "8px", "text-align": "right" }}>{row.input_tokens?.toLocaleString()}</td>
  132. <td style={{ padding: "8px", "text-align": "right" }}>{row.output_tokens?.toLocaleString()}</td>
  133. <td style={{ padding: "8px", "text-align": "right", color: "#f39c12" }}>${row.cost_usd?.toFixed(5)}</td>
  134. </tr>
  135. )}</For>
  136. </table>
  137. )
  138. }
  139. function TrendChart(p: { data: any[] }) {
  140. if (p.data.length < 2) return <div style={{ color: "#7f8c8d", "text-align": "center", padding: "40px" }}>数据不足</div>
  141. const max = Math.max(...p.data.map((d: any) => d.input + d.output), 1)
  142. const w = 700; const h = 200
  143. const pts = p.data.map((d: any, i: number) => `${(i / (p.data.length - 1)) * w},${h - ((d.input + d.output) / max) * h}`).join(" ")
  144. return (
  145. <svg viewBox={`0 0 ${w} ${h + 24}`} style={{ width: "100%", height: "auto" }}>
  146. <polyline points={pts} fill="none" stroke="#e74c3c" stroke-width="2" />
  147. <polygon points={`0,${h} ${pts} ${w},${h}`} fill="url(#g)" opacity="0.15" />
  148. <defs><linearGradient id="g"><stop offset="0%" stop-color="#e74c3c" /><stop offset="100%" stop-color="#e74c3c" stop-opacity="0" /></linearGradient></defs>
  149. </svg>
  150. )
  151. }
  152. const styles: Record<string, any> = {
  153. loginPage: { display: "flex", "flex-direction": "column", "align-items": "center", "justify-content": "center", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif" },
  154. title: { color: "#e74c3c", "margin-bottom": "24px" },
  155. input: { padding: "10px 14px", "border-radius": "8px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "14px" },
  156. btn: { padding: "10px", "border-radius": "8px", border: "none", background: "#e74c3c", color: "#fff", "font-size": "14px", cursor: "pointer", width: "100%" },
  157. error: { color: "#e74c3c", "font-size": "13px", "text-align": "center" },
  158. page: { display: "flex", "flex-direction": "column", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif", overflow: "auto" },
  159. header: { display: "flex", "align-items": "center", padding: "12px 24px", background: "#1a1a2e", "border-bottom": "1px solid #2c3e50", gap: "16px" },
  160. headerTitle: { "font-size": "18px", "font-weight": "700", color: "#e74c3c" },
  161. backBtn: { padding: "6px 14px", "border-radius": "6px", border: "1px solid #2c3e50", background: "transparent", color: "#7f8c8d", cursor: "pointer", "font-size": "13px", position: "absolute", left: "24px", top: "12px" },
  162. tabBtn: { padding: "6px 14px", "border-radius": "6px", border: "none", "font-size": "13px", cursor: "pointer", whiteSpace: "nowrap" },
  163. logoutBtn: { padding: "6px 14px", "border-radius": "6px", border: "1px solid #2c3e50", background: "transparent", color: "#7f8c8d", cursor: "pointer", "font-size": "12px" },
  164. content: { flex: 1, padding: "24px", overflow: "auto" },
  165. sectionTitle: { "font-size": "16px", "font-weight": "600", "margin-bottom": "16px" },
  166. cards: { display: "grid", "grid-template-columns": "repeat(auto-fit, minmax(160px, 1fr))", gap: "16px", "margin-bottom": "24px" },
  167. card: { background: "#1a1a2e", padding: "20px", "border-radius": "12px", border: "1px solid #2c3e50" },
  168. cardLabel: { color: "#7f8c8d", "font-size": "12px", "margin-bottom": "8px" },
  169. cardValue: { "font-size": "22px", "font-weight": "700", color: "#f39c12" },
  170. chart: { background: "#1a1a2e", padding: "16px", "border-radius": "10px", border: "1px solid #2c3e50" },
  171. }