|
|
@@ -1,218 +1,333 @@
|
|
|
-import { createSignal, createResource, onMount, For, Show } from "solid-js"
|
|
|
+import { createSignal, createResource, onMount, For, Show, createEffect, createMemo } from "solid-js"
|
|
|
|
|
|
-const API_BASE = "http://localhost:3000/api/v1"
|
|
|
+const API_BASE = "http://localhost:4097/api/v1"
|
|
|
+const LS_TOKEN_KEY = "kirincode_admin_token"
|
|
|
|
|
|
-async function fetchJson(url: string, token: string) {
|
|
|
- const res = await fetch(url, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
|
|
|
+// Simple fetch wrapper
|
|
|
+async function api(path: string, method = "GET", body?: any): Promise<any> {
|
|
|
+ const token = localStorage.getItem(LS_TOKEN_KEY)
|
|
|
+ const headers: Record<string, string> = { "Content-Type": "application/json" }
|
|
|
+ if (token) headers["Authorization"] = `Bearer ${token}`
|
|
|
+ const res = await fetch(`${API_BASE}${path}`, { method, headers, body: body ? JSON.stringify(body) : undefined })
|
|
|
return res.json()
|
|
|
}
|
|
|
|
|
|
+type Tab = "summary" | "trend" | "users" | "detail" | "teams"
|
|
|
+type TimeRange = "24h" | "7d" | "30d" | "all"
|
|
|
+
|
|
|
+function timeRange(r: TimeRange): { from: string; to: string } {
|
|
|
+ const now = new Date()
|
|
|
+ const from = new Date(now)
|
|
|
+ if (r === "24h") from.setHours(from.getHours() - 24)
|
|
|
+ else if (r === "7d") from.setDate(from.getDate() - 7)
|
|
|
+ else if (r === "30d") from.setDate(from.getDate() - 30)
|
|
|
+ else from.setFullYear(2000)
|
|
|
+ return { from: from.toISOString(), to: now.toISOString() }
|
|
|
+}
|
|
|
+
|
|
|
export function AdminDashboard() {
|
|
|
- const [token, setToken] = createSignal(localStorage.getItem("kirincode_token") || "")
|
|
|
+ const [token, setToken] = createSignal(localStorage.getItem(LS_TOKEN_KEY) || "")
|
|
|
+ const [loggedIn, setLoggedIn] = createSignal(!!token())
|
|
|
const [email, setEmail] = createSignal("")
|
|
|
const [password, setPassword] = createSignal("")
|
|
|
- const [loggedIn, setLoggedIn] = createSignal(!!token())
|
|
|
- const [tab, setTab] = createSignal<"today" | "team" | "users" | "minute">("today")
|
|
|
-
|
|
|
- // Login
|
|
|
- async function login() {
|
|
|
- const res = await fetch(API_BASE + "/auth/login", {
|
|
|
- method: "POST",
|
|
|
- headers: { "Content-Type": "application/json" },
|
|
|
- body: JSON.stringify({ email: email(), password: password() }),
|
|
|
- })
|
|
|
- const data = await res.json()
|
|
|
- if (data.token) {
|
|
|
- localStorage.setItem("kirincode_token", data.token)
|
|
|
+ const [displayName, setDisplayName] = createSignal("")
|
|
|
+ const [isRegister, setIsRegister] = createSignal(false)
|
|
|
+ const [tab, setTab] = createSignal<Tab>("summary")
|
|
|
+ const [timeRange, setTimeRange] = createSignal<TimeRange>("24h")
|
|
|
+ const [userFilter, setUserFilter] = createSignal("")
|
|
|
+ const [modelFilter, setModelFilter] = createSignal("")
|
|
|
+ const [detailPage, setDetailPage] = createSignal(1)
|
|
|
+ const [teamName, setTeamName] = createSignal("")
|
|
|
+ const [inviteEmail, setInviteEmail] = createSignal("")
|
|
|
+ const [error, setError] = createSignal("")
|
|
|
+
|
|
|
+ async function doAuth() {
|
|
|
+ setError("")
|
|
|
+ const endpoint = isRegister() ? "/auth/register" : "/auth/login"
|
|
|
+ const body = isRegister() ? { email: email(), password: password(), display_name: displayName() } : { email: email(), password: password() }
|
|
|
+ try {
|
|
|
+ const data = await api(endpoint, "POST", body)
|
|
|
+ if (data.error) { setError(data.error); return }
|
|
|
+ localStorage.setItem(LS_TOKEN_KEY, data.token)
|
|
|
setToken(data.token)
|
|
|
setLoggedIn(true)
|
|
|
- }
|
|
|
+ } catch { setError("Connection failed — start admin server on port 4097") }
|
|
|
+ }
|
|
|
+
|
|
|
+ function logout() {
|
|
|
+ localStorage.removeItem(LS_TOKEN_KEY)
|
|
|
+ setToken(""); setLoggedIn(false)
|
|
|
}
|
|
|
|
|
|
- // Fetch data
|
|
|
- const [todayData] = createResource(loggedIn, () => fetchJson(`${API_BASE}/admin/usage/today`, token()))
|
|
|
- const [teamData] = createResource(loggedIn, () => fetchJson(`${API_BASE}/admin/usage/team`, token()))
|
|
|
- const [usersData] = createResource(loggedIn, () => fetchJson(`${API_BASE}/admin/usage/users`, token()))
|
|
|
- const [minuteData] = createResource(loggedIn, () => fetchJson(`${API_BASE}/admin/usage/minute`, token()))
|
|
|
+ // Data fetching
|
|
|
+ const tr = createMemo(() => timeRange())
|
|
|
+ const fromTo = createMemo(() => timeRange(tr()))
|
|
|
+ const fetcher = (path: string) => api(path)
|
|
|
|
|
|
- // Refs for auto-refresh
|
|
|
+ const [summary] = createResource(loggedIn, () => api(`/admin/usage/summary?from=${fromTo().from}&to=${fromTo().to}`))
|
|
|
+ const [trend] = createResource(() => loggedIn() && tab() === "trend", () => api(`/admin/usage/trend`))
|
|
|
+ const [users] = createResource(() => loggedIn() && (tab() === "users" || tab() === "summary"), () => {
|
|
|
+ const p: string[] = []
|
|
|
+ if (userFilter()) p.push(`user_id=${userFilter()}`)
|
|
|
+ if (modelFilter()) p.push(`model=${modelFilter()}`)
|
|
|
+ return api(`/admin/usage/users?from=${fromTo().from}&to=${fromTo().to}${p.length ? "&" + p.join("&") : ""}`)
|
|
|
+ })
|
|
|
+ const [details] = createResource(() => loggedIn() && tab() === "detail", () => {
|
|
|
+ const p: string[] = [`page=${detailPage()}`]
|
|
|
+ if (userFilter()) p.push(`user_id=${userFilter()}`)
|
|
|
+ if (modelFilter()) p.push(`model=${modelFilter()}`)
|
|
|
+ return api(`/admin/usage/detail?from=${fromTo().from}&to=${fromTo().to}&${p.join("&")}`)
|
|
|
+ })
|
|
|
+ const [teams] = createResource(() => loggedIn() && tab() === "teams", () => api("/admin/teams"))
|
|
|
+
|
|
|
+ // Auto-refresh
|
|
|
onMount(() => {
|
|
|
- const interval = setInterval(() => {
|
|
|
+ const i = setInterval(() => {
|
|
|
if (loggedIn()) {
|
|
|
- fetchJson(`${API_BASE}/admin/usage/today`, token()).then(d => { /* trigger re-fetch */ })
|
|
|
+ api(`/admin/usage/summary?from=${fromTo().from}&to=${fromTo().to}`)
|
|
|
}
|
|
|
}, 30000)
|
|
|
- return () => clearInterval(interval)
|
|
|
+ return () => clearInterval(i)
|
|
|
})
|
|
|
|
|
|
if (!loggedIn()) {
|
|
|
return (
|
|
|
- <div style={{ display: "flex", "flex-direction": "column", "align-items": "center", "justify-content": "center", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif" }}>
|
|
|
- <h1 style={{ color: "#e74c3c", "margin-bottom": "24px" }}>KirinCode Admin</h1>
|
|
|
+ <div style={styles.loginPage}>
|
|
|
+ <h1 style={styles.loginTitle}>🛡 KirinCode 管理</h1>
|
|
|
<div style={{ display: "flex", "flex-direction": "column", gap: "12px", width: "320px" }}>
|
|
|
- <input
|
|
|
- type="email" placeholder="Email" value={email()} onInput={e => setEmail(e.currentTarget.value)}
|
|
|
- style={{ padding: "10px 14px", "border-radius": "8px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "14px" }}
|
|
|
- />
|
|
|
- <input
|
|
|
- type="password" placeholder="Password" value={password()} onInput={e => setPassword(e.currentTarget.value)}
|
|
|
- style={{ padding: "10px 14px", "border-radius": "8px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "14px" }}
|
|
|
- />
|
|
|
- <button onClick={login}
|
|
|
- style={{ padding: "10px", "border-radius": "8px", border: "none", background: "#e74c3c", color: "#fff", "font-size": "14px", "font-weight": "600", cursor: "pointer" }}
|
|
|
- >Login</button>
|
|
|
+ <input type="email" placeholder="邮箱" value={email()} onInput={e => setEmail(e.currentTarget.value)} style={styles.input} />
|
|
|
+ <input type="password" placeholder="密码" value={password()} onInput={e => setPassword(e.currentTarget.value)} style={styles.input} onKeyDown={e => e.key === "Enter" && doAuth()} />
|
|
|
+ <Show when={isRegister()}>
|
|
|
+ <input type="text" placeholder="显示名称" value={displayName()} onInput={e => setDisplayName(e.currentTarget.value)} style={styles.input} />
|
|
|
+ </Show>
|
|
|
+ <button onClick={doAuth} style={styles.btn}>{isRegister() ? "注册" : "登录"}</button>
|
|
|
+ <div style={{ color: "#e74c3c", "font-size": "13px", "text-align": "center" }}>{error()}</div>
|
|
|
+ <a onClick={() => { setIsRegister(!isRegister()); setError("") }} style={{ color: "#7f8c8d", "text-align": "center", cursor: "pointer", "font-size": "13px" }}>
|
|
|
+ {isRegister() ? "已有账号?登录" : "没有账号?注册"}
|
|
|
+ </a>
|
|
|
</div>
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
|
|
|
+ const tabs: { key: Tab; label: string }[] = [
|
|
|
+ { key: "summary", label: "今日概览" },
|
|
|
+ { key: "trend", label: "趋势图表" },
|
|
|
+ { key: "users", label: "用户排行" },
|
|
|
+ { key: "detail", label: "分钟明细" },
|
|
|
+ { key: "teams", label: "团队管理" },
|
|
|
+ ]
|
|
|
+
|
|
|
return (
|
|
|
- <div style={{ display: "flex", "flex-direction": "column", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif" }}>
|
|
|
+ <div style={styles.page}>
|
|
|
{/* Header */}
|
|
|
- <div style={{ display: "flex", "align-items": "center", padding: "12px 20px", background: "#1a1a2e", "border-bottom": "1px solid #2c3e50" }}>
|
|
|
- <span style={{ "font-size": "18px", "font-weight": "700", color: "#e74c3c", "margin-right": "24px" }}>🦄 KirinCode Admin</span>
|
|
|
- <div style={{ display: "flex", gap: "4px" }}>
|
|
|
- {(["today", "team", "users", "minute"] as const).map(t => (
|
|
|
- <button onClick={() => setTab(t)}
|
|
|
- style={{
|
|
|
- padding: "6px 14px", "border-radius": "6px", border: "none", "font-size": "13px", cursor: "pointer",
|
|
|
- background: tab() === t ? "#e74c3c" : "transparent",
|
|
|
- color: tab() === t ? "#fff" : "#7f8c8d",
|
|
|
- }}
|
|
|
- >{t === "today" ? "Today" : t === "team" ? "Team" : t === "users" ? "Users" : "Minute"}</button>
|
|
|
+ <div style={styles.header}>
|
|
|
+ <span style={styles.headerTitle}>🛡 KirinCode 管理</span>
|
|
|
+ <div style={{ display: "flex", gap: "8px" }}>
|
|
|
+ {tabs.map(t => (
|
|
|
+ <button onClick={() => setTab(t.key)} style={{ ...styles.tabBtn, background: tab() === t.key ? "#e74c3c" : "transparent", color: tab() === t.key ? "#fff" : "#7f8c8d" }}>
|
|
|
+ {t.label}
|
|
|
+ </button>
|
|
|
))}
|
|
|
</div>
|
|
|
- <button onClick={() => { localStorage.removeItem("kirincode_token"); setToken(""); setLoggedIn(false) }}
|
|
|
- style={{ "margin-left": "auto", padding: "6px 14px", "border-radius": "6px", border: "1px solid #2c3e50", background: "transparent", color: "#7f8c8d", cursor: "pointer", "font-size": "12px" }}
|
|
|
- >Logout</button>
|
|
|
+ <button onClick={logout} style={styles.logoutBtn}>退出</button>
|
|
|
</div>
|
|
|
|
|
|
{/* Content */}
|
|
|
- <div style={{ flex: 1, overflow: "auto", padding: "24px" }}>
|
|
|
- <Show when={tab() === "today" && todayData()}>
|
|
|
- {() => {
|
|
|
- const d = todayData()?.data || {}
|
|
|
- return (
|
|
|
- <div>
|
|
|
- <h2 style={{ "margin-bottom": "20px" }}>Today's Usage</h2>
|
|
|
- <div style={{ display: "grid", "grid-template-columns": "repeat(auto-fit, minmax(200px, 1fr))", gap: "16px" }}>
|
|
|
- <MetricCard label="Total Tokens" value={(d.input_tokens + d.output_tokens).toLocaleString()} />
|
|
|
- <MetricCard label="Requests" value={d.request_count?.toLocaleString() || "0"} />
|
|
|
- <MetricCard label="Cost" value={`$${d.total_cost?.toFixed(4) || "0.0000"}`} />
|
|
|
- <MetricCard label="Active Users" value={d.active_users || "0"} />
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- )
|
|
|
- }}
|
|
|
+ <div style={styles.content}>
|
|
|
+ {/* Summary / Overview */}
|
|
|
+ <Show when={tab() === "summary"}>
|
|
|
+ <h2 style={styles.sectionTitle}>Token 消耗概览</h2>
|
|
|
+ <div style={styles.cards}>
|
|
|
+ <Card label="总 Token" value={((summary()?.input || 0) + (summary()?.output || 0)).toLocaleString()} />
|
|
|
+ <Card label="请求次数" value={(summary()?.requests || 0).toLocaleString()} />
|
|
|
+ <Card label="今日费用" value={`$${(summary()?.cost || 0).toFixed(4)}`} />
|
|
|
+ <Card label="活跃用户" value={String(summary()?.users || 0)} />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Mini trend chart preview */}
|
|
|
+ <Show when={users()}>
|
|
|
+ <h3 style={{ ...styles.sectionTitle, "margin-top": "24px" }}>用户用量排行</h3>
|
|
|
+ <UserTable data={(users() || []).slice(0, 5)} />
|
|
|
+ </Show>
|
|
|
</Show>
|
|
|
|
|
|
- <Show when={tab() === "team" && teamData()}>
|
|
|
- {() => (
|
|
|
- <div>
|
|
|
- <h2 style={{ "margin-bottom": "20px" }}>Team Usage</h2>
|
|
|
- <table style={{ width: "100%", "border-collapse": "collapse" }}>
|
|
|
- <thead>
|
|
|
- <tr style={{ "text-align": "left", color: "#7f8c8d", "font-size": "12px" }}>
|
|
|
- <th style={{ padding: "8px" }}>Day</th>
|
|
|
- <th style={{ padding: "8px" }}>Provider</th>
|
|
|
- <th style={{ padding: "8px" }}>Model</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Input</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Output</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Requests</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Cost</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <For each={(teamData()?.data || []).slice(0, 50)}>
|
|
|
- {(row: any) => (
|
|
|
- <tr style={{ "border-top": "1px solid #1e293b", "font-size": "13px" }}>
|
|
|
- <td style={{ padding: "8px" }}>{row.day}</td>
|
|
|
- <td style={{ padding: "8px" }}>{row.provider}</td>
|
|
|
- <td style={{ padding: "8px" }}>{row.model}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right" }}>{row.input_tokens?.toLocaleString()}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right" }}>{row.output_tokens?.toLocaleString()}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right" }}>{row.request_count}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right", color: "#f39c12" }}>${row.total_cost?.toFixed(4)}</td>
|
|
|
- </tr>
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </table>
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ {/* Trend Chart */}
|
|
|
+ <Show when={tab() === "trend"}>
|
|
|
+ <h2 style={styles.sectionTitle}>分钟级 Token 消耗趋势</h2>
|
|
|
+ <div style={styles.chart}>
|
|
|
+ <Show when={trend() && (trend() as any[]).length > 0} fallback={<div style={{ color: "#7f8c8d", padding: "40px" }}>暂无数据</div>}>
|
|
|
+ <TrendChart data={trend() || []} />
|
|
|
+ </Show>
|
|
|
+ </div>
|
|
|
</Show>
|
|
|
|
|
|
- <Show when={tab() === "users" && usersData()}>
|
|
|
- {() => (
|
|
|
- <div>
|
|
|
- <h2 style={{ "margin-bottom": "20px" }}>User Rankings</h2>
|
|
|
- <table style={{ width: "100%", "border-collapse": "collapse" }}>
|
|
|
- <thead>
|
|
|
- <tr style={{ "text-align": "left", color: "#7f8c8d", "font-size": "12px" }}>
|
|
|
- <th style={{ padding: "8px" }}>User</th>
|
|
|
- <th style={{ padding: "8px" }}>Email</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Input</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Output</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Requests</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Cost</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <For each={usersData()?.data || []}>
|
|
|
- {(row: any) => (
|
|
|
- <tr style={{ "border-top": "1px solid #1e293b", "font-size": "13px" }}>
|
|
|
- <td style={{ padding: "8px" }}>{row.display_name}</td>
|
|
|
- <td style={{ padding: "8px", color: "#7f8c8d" }}>{row.email}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right" }}>{row.input_tokens?.toLocaleString()}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right" }}>{row.output_tokens?.toLocaleString()}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right" }}>{row.request_count}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right", color: "#f39c12" }}>${row.total_cost?.toFixed(4)}</td>
|
|
|
- </tr>
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </table>
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ {/* Users */}
|
|
|
+ <Show when={tab() === "users"}>
|
|
|
+ <div style={{ display: "flex", "justify-content": "space-between", "align-items": "center" }}>
|
|
|
+ <h2 style={styles.sectionTitle}>用户用量排行</h2>
|
|
|
+ <select value={timeRange()} onInput={e => setTimeRange(e.currentTarget.value as TimeRange)} style={styles.select}>
|
|
|
+ <option value="24h">最近24小时</option><option value="7d">最近7天</option><option value="30d">最近30天</option><option value="all">全部</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <UserTable data={users() || []} />
|
|
|
</Show>
|
|
|
|
|
|
- <Show when={tab() === "minute" && minuteData()}>
|
|
|
- {() => (
|
|
|
- <div>
|
|
|
- <h2 style={{ "margin-bottom": "20px" }}>Minute-Level Detail</h2>
|
|
|
- <table style={{ width: "100%", "border-collapse": "collapse" }}>
|
|
|
- <thead>
|
|
|
- <tr style={{ "text-align": "left", color: "#7f8c8d", "font-size": "12px" }}>
|
|
|
- <th style={{ padding: "8px" }}>Time</th>
|
|
|
- <th style={{ padding: "8px" }}>Provider</th>
|
|
|
- <th style={{ padding: "8px" }}>Model</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Input</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Output</th>
|
|
|
- <th style={{ padding: "8px", "text-align": "right" }}>Cost</th>
|
|
|
- </tr>
|
|
|
- </thead>
|
|
|
- <For each={(minuteData()?.data || []).slice(0, 100)}>
|
|
|
- {(row: any) => (
|
|
|
- <tr style={{ "border-top": "1px solid #1e293b", "font-size": "12px" }}>
|
|
|
- <td style={{ padding: "8px" }}>{row.minute}</td>
|
|
|
- <td style={{ padding: "8px" }}>{row.provider}</td>
|
|
|
- <td style={{ padding: "8px" }}>{row.model}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right" }}>{row.input_tokens?.toLocaleString()}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right" }}>{row.output_tokens?.toLocaleString()}</td>
|
|
|
- <td style={{ padding: "8px", "text-align": "right", color: "#f39c12" }}>${row.cost_usd?.toFixed(5)}</td>
|
|
|
- </tr>
|
|
|
- )}
|
|
|
- </For>
|
|
|
- </table>
|
|
|
+ {/* Detail */}
|
|
|
+ <Show when={tab() === "detail"}>
|
|
|
+ <div style={{ display: "flex", "justify-content": "space-between", "align-items": "center", "flex-wrap": "wrap", gap: "12px" }}>
|
|
|
+ <h2 style={styles.sectionTitle}>分钟级明细</h2>
|
|
|
+ <div style={{ display: "flex", gap: "8px" }}>
|
|
|
+ <input type="text" placeholder="筛选用户ID" value={userFilter()} onInput={e => setUserFilter(e.currentTarget.value)} style={styles.filterInput} />
|
|
|
+ <input type="text" placeholder="筛选模型" value={modelFilter()} onInput={e => setModelFilter(e.currentTarget.value)} style={styles.filterInput} />
|
|
|
+ <select value={timeRange()} onInput={e => setTimeRange(e.currentTarget.value as TimeRange)} style={styles.select}>
|
|
|
+ <option value="24h">24h</option><option value="7d">7天</option><option value="30d">30天</option><option value="all">全部</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <DetailTable data={details()?.data || []} />
|
|
|
+ <Show when={details()}>
|
|
|
+ <div style={{ display: "flex", "justify-content": "center", gap: "8px", "margin-top": "16px" }}>
|
|
|
+ <button disabled={detailPage() <= 1} onClick={() => setDetailPage(p => p - 1)} style={{ ...styles.tabBtn, opacity: detailPage() <= 1 ? 0.5 : 1 }}>上一页</button>
|
|
|
+ <span style={{ color: "#7f8c8d", padding: "6px" }}>第 {details()?.page}/{details()?.pages} 页 (共 {details()?.total} 条)</span>
|
|
|
+ <button disabled={detailPage() >= (details()?.pages || 1)} onClick={() => setDetailPage(p => p + 1)} style={{ ...styles.tabBtn, opacity: detailPage() >= (details()?.pages || 1) ? 0.5 : 1 }}>下一页</button>
|
|
|
</div>
|
|
|
- )}
|
|
|
+ </Show>
|
|
|
+ </Show>
|
|
|
+
|
|
|
+ {/* Teams */}
|
|
|
+ <Show when={tab() === "teams"}>
|
|
|
+ <h2 style={styles.sectionTitle}>团队管理</h2>
|
|
|
+ <div style={{ display: "flex", gap: "12px", "margin-bottom": "16px" }}>
|
|
|
+ <input type="text" placeholder="团队名称" value={teamName()} onInput={e => setTeamName(e.currentTarget.value)} style={styles.input} />
|
|
|
+ <button onClick={async () => { await api("/admin/teams", "POST", { name: teamName() }); setTeamName("") }} style={{ ...styles.btn, width: "auto", padding: "8px 16px" }}>创建团队</button>
|
|
|
+ </div>
|
|
|
+ <div style={{ display: "flex", gap: "12px", "margin-bottom": "16px" }}>
|
|
|
+ <input type="email" placeholder="成员邮箱" value={inviteEmail()} onInput={e => setInviteEmail(e.currentTarget.value)} style={styles.input} />
|
|
|
+ <button onClick={async () => { await api("/admin/teams/invite", "POST", { email: inviteEmail() }); setInviteEmail("") }} style={{ ...styles.btn, width: "auto", padding: "8px 16px", background: "#27ae60" }}>邀请成员</button>
|
|
|
+ </div>
|
|
|
+ <div style={{ display: "grid", gap: "12px" }}>
|
|
|
+ <For each={(teams() as any[]) || []}>{(team: any) => (
|
|
|
+ <div style={{ background: "#1a1a2e", padding: "16px", "border-radius": "10px", border: "1px solid #2c3e50", display: "flex", "justify-content": "space-between", "align-items": "center" }}>
|
|
|
+ <div><span style={{ "font-weight": "600" }}>{team.name}</span><span style={{ color: "#7f8c8d", "margin-left": "12px", "font-size": "12px" }}>ID: {team.id?.slice(0, 8)}</span></div>
|
|
|
+ </div>
|
|
|
+ )}</For>
|
|
|
+ </div>
|
|
|
</Show>
|
|
|
</div>
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
|
|
|
-function MetricCard(props: { label: string; value: string }) {
|
|
|
+// Sub-components
|
|
|
+function Card(props: { label: string; value: string }) {
|
|
|
return (
|
|
|
- <div style={{ background: "#1a1a2e", padding: "20px", "border-radius": "12px", border: "1px solid #2c3e50" }}>
|
|
|
+ <div style={{ background: "#1a1a2e", padding: "20px", "border-radius": "12px", border: "1px solid #2c3e50", "min-width": "160px" }}>
|
|
|
<div style={{ color: "#7f8c8d", "font-size": "12px", "margin-bottom": "8px" }}>{props.label}</div>
|
|
|
- <div style={{ "font-size": "24px", "font-weight": "700", color: "#f39c12" }}>{props.value}</div>
|
|
|
+ <div style={{ "font-size": "22px", "font-weight": "700", color: "#f39c12" }}>{props.value}</div>
|
|
|
</div>
|
|
|
)
|
|
|
}
|
|
|
+
|
|
|
+function UserTable(props: { data: any[] }) {
|
|
|
+ return (
|
|
|
+ <table style={{ width: "100%", "border-collapse": "collapse" }}>
|
|
|
+ <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><th style={{ padding: "10px 8px", "text-align": "right" }}>费用</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <For each={props.data}>{(row: any) => (
|
|
|
+ <tr style={{ "border-top": "1px solid #1e293b", "font-size": "13px" }}>
|
|
|
+ <td style={{ padding: "10px 8px" }}>{row.display_name || row.user_id?.slice(0, 8)}</td>
|
|
|
+ <td style={{ padding: "10px 8px", color: "#7f8c8d" }}>{row.email}</td>
|
|
|
+ <td style={{ padding: "10px 8px", "text-align": "right" }}>{(row.input || row.input_tokens || 0).toLocaleString()}</td>
|
|
|
+ <td style={{ padding: "10px 8px", "text-align": "right" }}>{(row.output || row.output_tokens || 0).toLocaleString()}</td>
|
|
|
+ <td style={{ padding: "10px 8px", "text-align": "right" }}>{row.requests || 0}</td>
|
|
|
+ <td style={{ padding: "10px 8px", "text-align": "right", color: "#f39c12" }}>${(row.cost || row.cost_usd || 0).toFixed(4)}</td>
|
|
|
+ </tr>
|
|
|
+ )}</For>
|
|
|
+ </table>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function DetailTable(props: { data: any[] }) {
|
|
|
+ return (
|
|
|
+ <table style={{ width: "100%", "border-collapse": "collapse" }}>
|
|
|
+ <thead>
|
|
|
+ <tr style={{ color: "#7f8c8d", "font-size": "11px", "text-align": "left" }}>
|
|
|
+ <th style={{ padding: "8px" }}>时间</th><th style={{ padding: "8px" }}>用户</th><th style={{ padding: "8px" }}>模型</th><th style={{ padding: "8px", "text-align": "right" }}>输入</th><th style={{ padding: "8px", "text-align": "right" }}>输出</th><th style={{ padding: "8px", "text-align": "right" }}>费用</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <For each={props.data}>{(row: any) => (
|
|
|
+ <tr style={{ "border-top": "1px solid #1e293b", "font-size": "12px" }}>
|
|
|
+ <td style={{ padding: "8px", "font-size": "11px", color: "#7f8c8d" }}>{new Date(row.created_at).toLocaleString()}</td>
|
|
|
+ <td style={{ padding: "8px" }}>{row.display_name || row.user_id?.slice(0, 8)}</td>
|
|
|
+ <td style={{ padding: "8px" }}>{row.provider}/{row.model}</td>
|
|
|
+ <td style={{ padding: "8px", "text-align": "right" }}>{(row.input_tokens || 0).toLocaleString()}</td>
|
|
|
+ <td style={{ padding: "8px", "text-align": "right" }}>{(row.output_tokens || 0).toLocaleString()}</td>
|
|
|
+ <td style={{ padding: "8px", "text-align": "right", color: "#f39c12" }}>${(row.cost_usd || 0).toFixed(5)}</td>
|
|
|
+ </tr>
|
|
|
+ )}</For>
|
|
|
+ </table>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function TrendChart(props: { data: any[] }) {
|
|
|
+ if (props.data.length < 2) return <div style={{ color: "#7f8c8d", "text-align": "center", padding: "40px" }}>数据不足(需要至少2个数据点)</div>
|
|
|
+
|
|
|
+ const maxVal = Math.max(...props.data.map((d: any) => d.input + d.output), 1)
|
|
|
+ const h = 200
|
|
|
+ const w = 700
|
|
|
+ const points = props.data.map((d: any, i: number) => {
|
|
|
+ const x = (i / (props.data.length - 1)) * w
|
|
|
+ const y = h - ((d.input + d.output) / maxVal) * h
|
|
|
+ return `${x},${y}`
|
|
|
+ }).join(" ")
|
|
|
+
|
|
|
+ // Grid lines
|
|
|
+ const gridLines = []
|
|
|
+ for (let i = 0; i <= 4; i++) {
|
|
|
+ const y = (i / 4) * h
|
|
|
+ gridLines.push(<line x1={0} y1={y} x2={w} y2={y} stroke="#1e293b" stroke-width="0.5" /> as any)
|
|
|
+ const label = Math.round(maxVal * (1 - i / 4))
|
|
|
+ gridLines.push(<text x={4} y={y - 4} fill="#7f8c8d" font-size="10">{label.toLocaleString()}</text> as any)
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <svg viewBox={`0 0 ${w} ${h + 24}`} style={{ width: "100%", height: "auto" }}>
|
|
|
+ {gridLines}
|
|
|
+ <polyline points={points} fill="none" stroke="#e74c3c" stroke-width="2" />
|
|
|
+ <polygon points={`0,${h} ${points} ${w},${h}`} fill="url(#grad)" opacity="0.2" />
|
|
|
+ <defs>
|
|
|
+ <linearGradient id="grad" x1="0" y1="0" x2="0" y2="1">
|
|
|
+ <stop offset="0%" stop-color="#e74c3c" />
|
|
|
+ <stop offset="100%" stop-color="#e74c3c" stop-opacity="0" />
|
|
|
+ </linearGradient>
|
|
|
+ </defs>
|
|
|
+ {/* Time labels */}
|
|
|
+ {props.data.filter((_: any, i: number) => i % Math.max(1, Math.floor(props.data.length / 6)) === 0).map((d: any, i: number) => {
|
|
|
+ const x = (i * Math.max(1, Math.floor(props.data.length / 6)) / Math.max(1, props.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>
|
|
|
+ })}
|
|
|
+ </svg>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+const styles: Record<string, any> = {
|
|
|
+ loginPage: { display: "flex", "flex-direction": "column", "align-items": "center", "justify-content": "center", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif" },
|
|
|
+ loginTitle: { color: "#e74c3c", "margin-bottom": "24px" },
|
|
|
+ input: { padding: "10px", "border-radius": "8px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "14px", width: "100%", "box-sizing": "border-box" },
|
|
|
+ btn: { padding: "10px", "border-radius": "8px", border: "none", background: "#e74c3c", color: "#fff", "font-size": "14px", cursor: "pointer", width: "100%" },
|
|
|
+ page: { display: "flex", "flex-direction": "column", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif", overflow: "auto" },
|
|
|
+ header: { display: "flex", "align-items": "center", padding: "12px 24px", background: "#1a1a2e", "border-bottom": "1px solid #2c3e50", gap: "16px" },
|
|
|
+ headerTitle: { "font-size": "18px", "font-weight": "700", color: "#e74c3c", "min-width": "180px" },
|
|
|
+ tabBtn: { padding: "6px 14px", "border-radius": "6px", border: "none", "font-size": "13px", cursor: "pointer", whiteSpace: "nowrap" },
|
|
|
+ logoutBtn: { "margin-left": "auto", padding: "6px 14px", "border-radius": "6px", border: "1px solid #2c3e50", background: "transparent", color: "#7f8c8d", cursor: "pointer", "font-size": "12px" },
|
|
|
+ content: { flex: 1, padding: "24px", overflow: "auto" },
|
|
|
+ sectionTitle: { "font-size": "16px", "font-weight": "600", "margin-bottom": "16px" },
|
|
|
+ cards: { display: "grid", "grid-template-columns": "repeat(auto-fit, minmax(160px, 1fr))", gap: "16px" },
|
|
|
+ chart: { background: "#1a1a2e", padding: "16px", "border-radius": "10px", border: "1px solid #2c3e50", overflow: "auto" },
|
|
|
+ select: { padding: "6px 10px", "border-radius": "6px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "12px", cursor: "pointer" },
|
|
|
+ filterInput: { padding: "6px 10px", "border-radius": "6px", border: "1px solid #2c3e50", background: "#1a1a2e", color: "#ecf0f1", "font-size": "12px", width: "140px" },
|
|
|
+}
|