first commit

This commit is contained in:
xuyong
2026-04-15 21:35:26 +08:00
commit 7097fa6b44
69 changed files with 5642 additions and 0 deletions

56
frontend/index.html Normal file
View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SuperDream</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: "class",
theme: {
extend: {
colors: {
superdream: {
primary: "#8B5CF6",
bg: "var(--sd-bg)",
panel: "var(--sd-panel)",
accent: "var(--sd-accent)",
},
},
},
},
};
</script>
<style>
:root {
--sd-bg: #f8f9fa;
--sd-panel: #ffffff;
--sd-accent: #7c3aed;
}
.dark {
--sd-bg: #0f0f23;
--sd-panel: #1a1a2e;
--sd-accent: #a78bfa;
}
body {
margin: 0;
background-color: var(--sd-bg);
color: #e2e8f0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
}
</style>
<script>
// Apply saved theme before first paint to prevent flash
(function () {
var t = localStorage.getItem("sd_theme");
if (t === "light") document.documentElement.classList.remove("dark");
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

2285
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
frontend/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "superdream-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.14.1",
"recharts": "^3.8.1"
},
"devDependencies": {
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

44
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,44 @@
import { Routes, Route } from "react-router-dom";
import Header from "./components/Header";
import StatusBar from "./components/StatusBar";
import AuthGuard from "./components/AuthGuard";
import Home from "./pages/Home";
import Pricing from "./pages/Pricing";
import Docs from "./pages/Docs";
import Login from "./pages/Login";
import Register from "./pages/Register";
import ForgotPassword from "./pages/ForgotPassword";
import ResetPassword from "./pages/ResetPassword";
import DashboardLayout from "./pages/dashboard/Layout";
import Overview from "./pages/dashboard/Overview";
import Keys from "./pages/dashboard/Keys";
import Wallet from "./pages/dashboard/Wallet";
import Usage from "./pages/dashboard/Usage";
import Logs from "./pages/dashboard/Logs";
export default function App() {
return (
<div className="flex flex-col h-screen bg-superdream-bg text-gray-800 dark:text-gray-200">
<Header title="SuperDream" />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/pricing" element={<Pricing />} />
<Route path="/docs" element={<Docs />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<AuthGuard><DashboardLayout /></AuthGuard>}>
<Route index element={<Overview />} />
<Route path="keys" element={<Keys />} />
<Route path="wallet" element={<Wallet />} />
<Route path="usage" element={<Usage />} />
<Route path="logs" element={<Logs />} />
</Route>
</Routes>
<StatusBar />
</div>
);
}

View File

@@ -0,0 +1,9 @@
import { Navigate } from "react-router-dom";
import { authStore } from "../stores/authStore";
export default function AuthGuard({ children }: { children: React.ReactNode }) {
if (!authStore.isLoggedIn()) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@@ -0,0 +1,89 @@
import { Link, useNavigate } from "react-router-dom";
import { useEffect, useState } from "react";
import { authStore } from "../stores/authStore";
import { themeStore } from "../stores/themeStore";
interface HeaderProps {
title: string;
}
export default function Header({ title }: HeaderProps) {
const navigate = useNavigate();
const [loggedIn, setLoggedIn] = useState(authStore.isLoggedIn());
const [user, setUser] = useState(authStore.getUser());
const [dark, setDark] = useState(themeStore.isDark());
useEffect(() => {
if (authStore.isLoggedIn() && !authStore.getUser()) {
authStore.fetchUser();
}
const unsub1 = authStore.subscribe(() => {
setLoggedIn(authStore.isLoggedIn());
setUser(authStore.getUser());
});
const unsub2 = themeStore.subscribe(() => setDark(themeStore.isDark()));
return () => { unsub1(); unsub2(); };
}, []);
const handleLogout = () => {
authStore.clearTokens();
navigate("/login");
};
return (
<header className="flex items-center justify-between px-6 py-4 bg-superdream-panel border-b border-gray-200 dark:border-gray-800">
<Link to="/" className="text-xl font-bold text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
{title}
</Link>
<nav className="flex gap-4 text-sm text-gray-600 dark:text-gray-400 items-center">
<Link to="/pricing" className="hover:text-gray-900 dark:hover:text-white transition">
</Link>
<Link to="/docs" className="hover:text-gray-900 dark:hover:text-white transition">
</Link>
{loggedIn ? (
<>
<Link to="/dashboard" className="hover:text-gray-900 dark:hover:text-white transition">
</Link>
<span className="text-gray-700 dark:text-gray-300">{user?.email}</span>
<button
onClick={handleLogout}
className="hover:text-gray-900 dark:hover:text-white transition"
>
退
</button>
</>
) : (
<>
<Link to="/login" className="hover:text-gray-900 dark:hover:text-white transition">
</Link>
<Link
to="/register"
className="px-3 py-1 rounded-lg bg-superdream-primary text-white hover:bg-purple-600 transition"
>
</Link>
</>
)}
<button
onClick={() => themeStore.toggle()}
className="ml-1 p-1.5 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-800 transition text-gray-600 dark:text-gray-400"
title={dark ? "切换亮色模式" : "切换暗色模式"}
>
{dark ? (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zm0 13a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zm5-5a.75.75 0 01.75.75h1.5a.75.75 0 010 1.5h-1.5A.75.75 0 0115 10zM2.75 10a.75.75 0 010-1.5h1.5a.75.75 0 010 1.5h-1.5zm12.542-4.793a.75.75 0 010 1.06l-1.06 1.06a.75.75 0 11-1.06-1.06l1.06-1.06a.75.75 0 011.06 0zM6.828 14.232a.75.75 0 010 1.061l-1.06 1.06a.75.75 0 01-1.061-1.06l1.06-1.06a.75.75 0 011.06 0zm8.485 1.06a.75.75 0 01-1.06 0l-1.06-1.06a.75.75 0 111.06-1.06l1.06 1.06a.75.75 0 010 1.06zM6.828 5.768a.75.75 0 01-1.061 0l-1.06-1.06a.75.75 0 011.06-1.061l1.06 1.06a.75.75 0 010 1.06zM10 7a3 3 0 100 6 3 3 0 000-6z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path fillRule="evenodd" d="M7.455 2.004a.75.75 0 01.26.77 7 7 0 009.958 7.655.75.75 0 011.067.853A8.5 8.5 0 116.647 1.921a.75.75 0 01.808.083z" clipRule="evenodd" />
</svg>
)}
</button>
</nav>
</header>
);
}

View File

@@ -0,0 +1,34 @@
import { useFetch } from "../hooks/useFetch";
import { httpClient } from "../services/httpClient";
interface HealthStatus {
status: string;
service: string;
}
export default function StatusBar() {
const { data, loading, error } = useFetch<HealthStatus>(() =>
httpClient.get("/health")
);
return (
<div className="flex items-center gap-2 px-6 py-2 bg-superdream-panel border-t border-gray-200 dark:border-gray-800 text-xs text-gray-500">
<span
className={`w-2 h-2 rounded-full ${
loading
? "bg-yellow-400"
: error
? "bg-red-500"
: "bg-green-500"
}`}
/>
<span>
{loading
? "Connecting..."
: error
? `Error: ${error}`
: `${data?.service} - ${data?.status}`}
</span>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { useState, useEffect } from "react";
export function useFetch<T>(fetcher: () => Promise<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
fetcher()
.then((result) => {
if (!cancelled) setData(result);
})
.catch((err) => {
if (!cancelled) setError(err.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
return { data, loading, error };
}

12
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);

160
frontend/src/pages/Docs.tsx Normal file
View File

@@ -0,0 +1,160 @@
import { Link } from "react-router-dom";
const BASE_URL_EXAMPLE = "https://api.superdream.example.com";
export default function Docs() {
return (
<div className="flex-1 overflow-auto">
<div className="max-w-3xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">API </h1>
<p className="text-gray-600 dark:text-gray-400 mb-10">
SuperDream OpenAI API 使 OpenAI SDK
</p>
{/* Quick start */}
<Section title="快速开始">
<ol className="list-decimal list-inside text-sm text-gray-700 dark:text-gray-300 space-y-2">
<li>
<Link to="/register" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
</Link>
API Key
</li>
<li></li>
<li> API Key Key </li>
</ol>
</Section>
{/* Base URL */}
<Section title="Base URL">
<Code>{`${BASE_URL_EXAMPLE}/v1`}</Code>
<p className="text-xs text-gray-500 mt-2">
OpenAI SDK base_url
</p>
</Section>
{/* Auth */}
<Section title="认证方式">
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2">
Header API Key
</p>
<Code>Authorization: Bearer sk-sd-your-api-key</Code>
</Section>
{/* Python example */}
<Section title="Python 示例">
<CodeBlock>{`from openai import OpenAI
client = OpenAI(
api_key="sk-sd-your-api-key",
base_url="${BASE_URL_EXAMPLE}/v1",
)
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "user", "content": "Hello!"}
],
)
print(response.choices[0].message.content)`}</CodeBlock>
</Section>
{/* curl example */}
<Section title="cURL 示例">
<CodeBlock>{`curl ${BASE_URL_EXAMPLE}/v1/chat/completions \\
-H "Content-Type: application/json" \\
-H "Authorization: Bearer sk-sd-your-api-key" \\
-d '{
"model": "gpt-4o",
"messages": [
{"role": "user", "content": "Hello!"}
]
}'`}</CodeBlock>
</Section>
{/* Node.js example */}
<Section title="Node.js 示例">
<CodeBlock>{`import OpenAI from "openai";
const client = new OpenAI({
apiKey: "sk-sd-your-api-key",
baseURL: "${BASE_URL_EXAMPLE}/v1",
});
const response = await client.chat.completions.create({
model: "gpt-4o",
messages: [
{ role: "user", content: "Hello!" }
],
});
console.log(response.choices[0].message.content);`}</CodeBlock>
</Section>
{/* Supported models */}
<Section title="支持的模型">
<p className="text-sm text-gray-700 dark:text-gray-300 mb-2">
<code className="text-superdream-accent">model</code>
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
<Link to="/pricing" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition ml-1">
</Link>
</p>
</Section>
{/* FAQ */}
<Section title="常见问题">
<Faq q="和直接用 OpenAI API 有什么区别?">
SuperDream
</Faq>
<Faq q="计费方式是什么?">
token
</Faq>
<Faq q="API Key 丢失怎么办?">
Key Key
</Faq>
<Faq q="支持流式输出吗?">
<code className="text-superdream-accent">stream: true</code> 使 SSE
</Faq>
</Section>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="mb-10">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-4">{title}</h2>
{children}
</section>
);
}
function Code({ children }: { children: string }) {
return (
<code className="block bg-superdream-panel border border-gray-200 dark:border-gray-800 rounded-lg px-4 py-2 text-sm text-superdream-accent font-mono">
{children}
</code>
);
}
function CodeBlock({ children }: { children: string }) {
return (
<pre className="bg-superdream-panel border border-gray-200 dark:border-gray-800 rounded-lg px-4 py-3 text-sm text-gray-700 dark:text-gray-300 font-mono overflow-x-auto leading-relaxed whitespace-pre">
{children}
</pre>
);
}
function Faq({ q, children }: { q: string; children: React.ReactNode }) {
return (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-900 dark:text-white mb-1">{q}</h4>
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">{children}</p>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useState, FormEvent } from "react";
import { Link } from "react-router-dom";
import { authService } from "../services/authService";
export default function ForgotPassword() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await authService.forgotPassword(email);
setSent(true);
} catch (err: any) {
setError(err.message || "请求失败");
} finally {
setLoading(false);
}
};
if (sent) {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="w-full max-w-md bg-superdream-panel rounded-xl p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
</p>
<Link to="/login" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
</Link>
</div>
</div>
);
}
return (
<div className="flex-1 flex items-center justify-center p-8">
<form
onSubmit={handleSubmit}
className="w-full max-w-md bg-superdream-panel rounded-xl p-8"
>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2 text-center"></h2>
<p className="text-gray-500 text-sm mb-6 text-center">
</p>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
<div className="mb-6">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"></label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
placeholder="you@example.com"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-medium hover:bg-purple-600 transition disabled:opacity-50"
>
{loading ? "发送中..." : "发送重置链接"}
</button>
<div className="mt-4 text-center text-sm text-gray-500">
<Link to="/login" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
</Link>
</div>
</form>
</div>
);
}

108
frontend/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,108 @@
import { Link } from "react-router-dom";
const features = [
{
title: "统一接口",
desc: "兼容 OpenAI API 格式,一个接口调用所有主流大模型,无需逐家对接。",
},
{
title: "按量计费",
desc: "按实际 token 用量付费,无月费无绑定,用多少付多少。",
},
{
title: "多模型聚合",
desc: "GPT-4o、Claude、Gemini、DeepSeek 等主流模型一站接入,自由切换。",
},
{
title: "实时监控",
desc: "每笔调用实时记录,用量、费用一目了然,支持多维度统计分析。",
},
];
const models = [
{ name: "GPT-4o", provider: "OpenAI" },
{ name: "GPT-4o-mini", provider: "OpenAI" },
{ name: "Claude Sonnet 4", provider: "Anthropic" },
{ name: "Claude Haiku 3.5", provider: "Anthropic" },
{ name: "Gemini 2.5 Pro", provider: "Google" },
{ name: "DeepSeek V3", provider: "DeepSeek" },
];
export default function Home() {
return (
<div className="flex-1 overflow-auto">
{/* Hero */}
<section className="flex flex-col items-center justify-center text-center px-6 py-20">
<h1 className="text-5xl font-extrabold text-gray-900 dark:text-white mb-4 leading-tight">
API<span className="text-superdream-accent"></span>
</h1>
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mb-8">
SuperDream API OpenAI
</p>
<div className="flex gap-4">
<Link
to="/register"
className="px-6 py-3 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-semibold hover:bg-purple-600 transition text-sm"
>
</Link>
<Link
to="/pricing"
className="px-6 py-3 rounded-lg border border-gray-400 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-semibold hover:border-superdream-accent hover:text-gray-900 dark:hover:text-white transition text-sm"
>
</Link>
</div>
</section>
{/* Features */}
<section className="px-6 py-16 max-w-5xl mx-auto">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white text-center mb-10"> SuperDream</h2>
<div className="grid grid-cols-2 gap-6">
{features.map((f) => (
<div key={f.title} className="bg-superdream-panel rounded-xl p-6 border border-gray-200 dark:border-gray-800">
<h3 className="text-lg font-semibold text-superdream-accent mb-2">{f.title}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed">{f.desc}</p>
</div>
))}
</div>
</section>
{/* Supported models */}
<section className="px-6 py-16 max-w-5xl mx-auto">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white text-center mb-10"></h2>
<div className="grid grid-cols-3 gap-4">
{models.map((m) => (
<div key={m.name} className="bg-superdream-panel rounded-xl p-4 border border-gray-200 dark:border-gray-800 flex items-center justify-between">
<span className="text-gray-900 dark:text-white font-medium">{m.name}</span>
<span className="text-xs text-gray-500 bg-gray-200 dark:bg-gray-800 px-2 py-0.5 rounded">{m.provider}</span>
</div>
))}
</div>
<p className="text-center text-gray-500 text-sm mt-4">
...&nbsp;
<Link to="/pricing" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
</Link>
</p>
</section>
{/* CTA */}
<section className="px-6 py-16 text-center">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<p className="text-gray-600 dark:text-gray-400 mb-6"> API Key</p>
<Link
to="/register"
className="inline-block px-8 py-3 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-semibold hover:bg-purple-600 transition"
>
</Link>
</section>
{/* Footer */}
<footer className="px-6 py-6 border-t border-gray-200 dark:border-gray-800 text-center text-xs text-gray-400 dark:text-gray-600">
SuperDream &copy; {new Date().getFullYear()}
</footer>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useState, FormEvent } from "react";
import { useNavigate, Link } from "react-router-dom";
import { authService } from "../services/authService";
import { authStore } from "../stores/authStore";
export default function Login() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await authService.login(email, password);
authStore.setTokens(res.access_token, res.refresh_token);
await authStore.fetchUser();
navigate("/dashboard");
} catch (err: any) {
setError(err.message || "登录失败");
} finally {
setLoading(false);
}
};
return (
<div className="flex-1 flex items-center justify-center p-8">
<form
onSubmit={handleSubmit}
className="w-full max-w-md bg-superdream-panel rounded-xl p-8"
>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center"></h2>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"></label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
placeholder="you@example.com"
/>
</div>
<div className="mb-6">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"></label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
placeholder="••••••••"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-medium hover:bg-purple-600 transition disabled:opacity-50"
>
{loading ? "登录中..." : "登录"}
</button>
<div className="mt-4 text-center text-sm text-gray-500 space-x-4">
<Link to="/register" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition">
</Link>
<Link to="/forgot-password" className="hover:text-gray-900 dark:hover:text-white transition">
</Link>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { httpClient } from "../services/httpClient";
interface ModelPrice {
id: number;
model_name: string;
provider: string;
input_price_per_1k: string;
output_price_per_1k: string;
status: string;
}
export default function Pricing() {
const [models, setModels] = useState<ModelPrice[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
httpClient
.get<ModelPrice[]>("/models")
.then(setModels)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
// Group by provider
const grouped: Record<string, ModelPrice[]> = {};
models.forEach((m) => {
if (!grouped[m.provider]) grouped[m.provider] = [];
grouped[m.provider].push(m);
});
return (
<div className="flex-1 overflow-auto">
<section className="max-w-4xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white text-center mb-2"></h1>
<p className="text-gray-600 dark:text-gray-400 text-center mb-10">
token
</p>
{loading ? (
<p className="text-gray-500 text-center py-12">...</p>
) : models.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 mb-2"></p>
<p className="text-gray-400 dark:text-gray-600 text-sm"></p>
</div>
) : (
Object.entries(grouped).map(([provider, list]) => (
<div key={provider} className="mb-8">
<h2 className="text-lg font-semibold text-superdream-accent mb-3">{provider}</h2>
<div className="bg-superdream-panel rounded-xl overflow-hidden border border-gray-200 dark:border-gray-800">
<table className="w-full text-sm">
<thead>
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
<th className="text-left px-5 py-3 font-medium"></th>
<th className="text-right px-5 py-3 font-medium"> / 1K tokens</th>
<th className="text-right px-5 py-3 font-medium"> / 1K tokens</th>
</tr>
</thead>
<tbody>
{list.map((m) => (
<tr key={m.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
<td className="px-5 py-3 text-gray-900 dark:text-white font-medium">{m.model_name}</td>
<td className="px-5 py-3 text-gray-700 dark:text-gray-300 text-right">¥{m.input_price_per_1k}</td>
<td className="px-5 py-3 text-gray-700 dark:text-gray-300 text-right">¥{m.output_price_per_1k}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))
)}
{/* Billing notes */}
<div className="bg-superdream-panel rounded-xl p-6 border border-gray-200 dark:border-gray-800 mt-4">
<h3 className="text-gray-900 dark:text-white font-semibold mb-3"></h3>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2 list-disc list-inside">
<li>使 token </li>
<li> API </li>
<li></li>
<li></li>
</ul>
</div>
<div className="text-center mt-10">
<Link
to="/register"
className="inline-block px-6 py-3 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-semibold hover:bg-purple-600 transition text-sm"
>
使
</Link>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useState, FormEvent } from "react";
import { useNavigate, Link } from "react-router-dom";
import { authService } from "../services/authService";
import { authStore } from "../stores/authStore";
export default function Register() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirm) {
setError("两次密码输入不一致");
return;
}
if (password.length < 6) {
setError("密码至少 6 位");
return;
}
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
setError("密码需同时包含字母和数字");
return;
}
try {
await authService.register(email, password);
// Auto-login after registration
const res = await authService.login(email, password);
authStore.setTokens(res.access_token, res.refresh_token);
await authStore.fetchUser();
navigate("/dashboard");
} catch (err: any) {
setError(err.message || "注册失败");
} finally {
setLoading(false);
}
};
return (
<div className="flex-1 flex items-center justify-center p-8">
<form
onSubmit={handleSubmit}
className="w-full max-w-md bg-superdream-panel rounded-xl p-8"
>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center"></h2>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"></label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
placeholder="you@example.com"
/>
</div>
<div className="mb-4">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"></label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
placeholder="至少 6 位"
/>
</div>
<div className="mb-6">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"></label>
<input
type="password"
required
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
placeholder="再次输入密码"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-medium hover:bg-purple-600 transition disabled:opacity-50"
>
{loading ? "注册中..." : "注册"}
</button>
<div className="mt-4 text-center text-sm text-gray-500">
<span></span>
<Link to="/login" className="text-superdream-accent hover:text-gray-900 dark:hover:text-white transition ml-1">
</Link>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useState, FormEvent } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { authService } from "../services/authService";
export default function ResetPassword() {
const [searchParams] = useSearchParams();
const token = searchParams.get("token") || "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [done, setDone] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirm) {
setError("两次密码输入不一致");
return;
}
if (password.length < 6) {
setError("密码至少 6 位");
return;
}
if (!/[a-zA-Z]/.test(password) || !/[0-9]/.test(password)) {
setError("密码需同时包含字母和数字");
return;
}
if (!token) {
setError("缺少重置 token请通过邮件链接访问");
return;
}
setLoading(true);
try {
await authService.resetPassword(token, password);
setDone(true);
} catch (err: any) {
setError(err.message || "重置失败");
} finally {
setLoading(false);
}
};
if (done) {
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="w-full max-w-md bg-superdream-panel rounded-xl p-8 text-center">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">使</p>
<Link
to="/login"
className="px-5 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-medium hover:bg-purple-600 transition"
>
</Link>
</div>
</div>
);
}
return (
<div className="flex-1 flex items-center justify-center p-8">
<form
onSubmit={handleSubmit}
className="w-full max-w-md bg-superdream-panel rounded-xl p-8"
>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center"></h2>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
<div className="mb-4">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"></label>
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
placeholder="至少 6 位"
/>
</div>
<div className="mb-6">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"></label>
<input
type="password"
required
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
className="w-full px-4 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white focus:border-superdream-accent focus:outline-none"
placeholder="再次输入新密码"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white font-medium hover:bg-purple-600 transition disabled:opacity-50"
>
{loading ? "重置中..." : "重置密码"}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { useState, useEffect } from "react";
import { keyService, ApiKeyInfo } from "../../services/keyService";
export default function Keys() {
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
const [name, setName] = useState("");
const [newKey, setNewKey] = useState("");
const [copied, setCopied] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchKeys = async () => {
try {
const data = await keyService.list();
setKeys(data);
} catch (err: any) {
setError(err.message);
}
};
useEffect(() => {
fetchKeys();
}, []);
const handleCreate = async () => {
setError("");
setNewKey("");
setLoading(true);
try {
const res = await keyService.create(name);
setNewKey(res.key);
setName("");
await fetchKeys();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
await keyService.remove(deleteTarget);
setDeleteTarget(null);
await fetchKeys();
} catch (err: any) {
setError(err.message);
setDeleteTarget(null);
} finally {
setDeleting(false);
}
};
const handleCopy = async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">API Key </h2>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
{/* Create Key */}
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
<div className="flex gap-3 items-end">
<div className="flex-1">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Key </label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如:测试环境"
className="w-full px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none"
/>
</div>
<button
onClick={handleCreate}
disabled={loading}
className="px-4 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white text-sm font-medium hover:bg-purple-600 transition disabled:opacity-50"
>
{loading ? "创建中..." : "创建 Key"}
</button>
</div>
</div>
{/* Newly created key */}
{newKey && (
<div className="mb-4 p-4 rounded-xl bg-green-500/10 border border-green-500/30">
<p className="text-sm text-green-400 mb-2">Key Key </p>
<div className="flex items-center gap-2">
<code className="flex-1 text-sm text-gray-900 dark:text-white bg-superdream-bg px-3 py-2 rounded-lg break-all">
{newKey}
</code>
<button
onClick={() => handleCopy(newKey)}
className="px-3 py-2 rounded-lg bg-gray-300 dark:bg-gray-700 text-sm text-gray-900 dark:text-white hover:bg-gray-400 dark:hover:bg-gray-600 transition whitespace-nowrap"
>
{copied ? "已复制" : "复制"}
</button>
</div>
</div>
)}
{/* Key list */}
<div className="bg-superdream-panel rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium">Key</th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{keys.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
Key
</td>
</tr>
) : (
keys.map((k) => (
<tr key={k.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 text-gray-900 dark:text-white">{k.name || "-"}</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 font-mono">
sk-sd-{k.key_prefix}...{k.key_suffix}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(k.created_at).toLocaleString()}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setDeleteTarget(k.id)}
className="text-red-400 hover:text-red-300 transition"
>
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Delete confirm modal */}
{deleteTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60"
onClick={() => !deleting && setDeleteTarget(null)}
/>
<div className="relative bg-superdream-panel border border-gray-300 dark:border-gray-700 rounded-xl p-6 w-full max-w-sm shadow-xl">
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-2"></h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
Key
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteTarget(null)}
disabled={deleting}
className="px-4 py-2 rounded-lg border border-gray-400 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm hover:bg-gray-200 dark:hover:bg-gray-800 transition disabled:opacity-50"
>
</button>
<button
onClick={confirmDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg bg-red-600 text-gray-900 dark:text-white text-sm font-medium hover:bg-red-500 transition disabled:opacity-50"
>
{deleting ? "删除中..." : "确认删除"}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { Outlet, NavLink } from "react-router-dom";
const navItems = [
{ to: "/dashboard", label: "概览", end: true },
{ to: "/dashboard/keys", label: "API Key" },
{ to: "/dashboard/wallet", label: "钱包" },
{ to: "/dashboard/usage", label: "用量" },
{ to: "/dashboard/logs", label: "调用日志" },
];
export default function DashboardLayout() {
return (
<div className="flex flex-1 overflow-hidden">
<aside className="w-48 bg-superdream-panel border-r border-gray-200 dark:border-gray-800 p-4 flex flex-col gap-1">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) =>
`px-3 py-2 rounded-lg text-sm transition ${
isActive
? "bg-superdream-primary/20 text-superdream-accent"
: "text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-800"
}`
}
>
{item.label}
</NavLink>
))}
</aside>
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import { useState, useEffect } from "react";
import { usageService, UsageLogItem } from "../../services/usageService";
export default function Logs() {
const [logs, setLogs] = useState<UsageLogItem[]>([]);
const [page, setPage] = useState(1);
const [model, setModel] = useState("");
const [loading, setLoading] = useState(false);
const pageSize = 20;
const fetchLogs = async () => {
setLoading(true);
try {
const data = await usageService.logs({
page,
size: pageSize,
model: model || undefined,
});
setLogs(data);
} catch {
// silently fail
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchLogs();
}, [page]);
const handleFilter = () => {
setPage(1);
fetchLogs();
};
return (
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"></h2>
{/* Filters */}
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
<div className="flex gap-3 items-end">
<div>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"></label>
<input
type="text"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder="如 gpt-4o"
className="px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none w-48"
/>
</div>
<button
onClick={handleFilter}
className="px-4 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white text-sm font-medium hover:bg-purple-600 transition"
>
</button>
</div>
</div>
{/* Table */}
<div className="bg-superdream-panel rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium">Prompt</th>
<th className="text-right px-4 py-3 font-medium">Completion</th>
<th className="text-right px-4 py-3 font-medium">Total</th>
<th className="text-right px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
...
</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
{new Date(log.request_time).toLocaleString()}
</td>
<td className="px-4 py-3 text-gray-900 dark:text-white">{log.model}</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.prompt_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.completion_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-900 dark:text-white text-right font-medium">{log.total_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-superdream-accent text-right">¥{log.cost}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded text-xs ${
log.status === "success"
? "bg-green-500/10 text-green-400"
: "bg-red-500/10 text-red-400"
}`}>
{log.status}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex justify-between items-center mt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 text-sm hover:bg-gray-200 dark:hover:bg-gray-800 transition disabled:opacity-30"
>
</button>
<span className="text-sm text-gray-600 dark:text-gray-400"> {page} </span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={logs.length < pageSize}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 text-sm hover:bg-gray-200 dark:hover:bg-gray-800 transition disabled:opacity-30"
>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { useState, useEffect } from "react";
import { walletService } from "../../services/walletService";
import { keyService } from "../../services/keyService";
import { usageService, UsageSummary } from "../../services/usageService";
export default function Overview() {
const [balance, setBalance] = useState("0");
const [keyCount, setKeyCount] = useState(0);
const [summary, setSummary] = useState<UsageSummary | null>(null);
useEffect(() => {
walletService.getBalance().then((b) => setBalance(b.balance)).catch(() => {});
keyService.list().then((keys) => setKeyCount(keys.length)).catch(() => {});
usageService.summary().then(setSummary).catch(() => {});
}, []);
return (
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<div className="grid grid-cols-4 gap-4">
<div className="bg-superdream-panel rounded-xl p-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">¥{balance}</p>
</div>
<div className="bg-superdream-panel rounded-xl p-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"> Key</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{keyCount}</p>
</div>
<div className="bg-superdream-panel rounded-xl p-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
{summary ? `${summary.today_tokens.toLocaleString()} tokens` : "0"}
</p>
</div>
<div className="bg-superdream-panel rounded-xl p-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">¥{summary?.today_cost ?? "0"}</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import { useState, useEffect } from "react";
import {
ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
BarChart, Bar, Cell,
} from "recharts";
import {
usageService, UsageSummary, DailyUsage, ModelUsage,
} from "../../services/usageService";
const COLORS = ["#8B5CF6", "#a78bfa", "#6366f1", "#818cf8", "#c084fc", "#7c3aed"];
export default function Usage() {
const [summary, setSummary] = useState<UsageSummary | null>(null);
const [daily, setDaily] = useState<DailyUsage[]>([]);
const [byModel, setByModel] = useState<ModelUsage[]>([]);
useEffect(() => {
usageService.summary().then(setSummary).catch(() => {});
usageService.daily().then(setDaily).catch(() => {});
usageService.byModel().then(setByModel).catch(() => {});
}, []);
return (
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"></h2>
{/* Summary cards */}
<div className="grid grid-cols-4 gap-4 mb-6">
<Card label="今日 Tokens" value={summary?.today_tokens?.toLocaleString() ?? "0"} />
<Card label="今日消耗" value={`¥${summary?.today_cost ?? "0"}`} />
<Card label="本月 Tokens" value={summary?.month_tokens?.toLocaleString() ?? "0"} />
<Card label="本月消耗" value={`¥${summary?.month_cost ?? "0"}`} />
</div>
{/* Daily chart */}
<div className="bg-superdream-panel rounded-xl p-4 mb-6">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3"> 30 </h3>
{daily.length === 0 ? (
<p className="text-gray-500 text-sm py-8 text-center"></p>
) : (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={daily}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis
dataKey="date"
tick={{ fill: "#9ca3af", fontSize: 12 }}
tickFormatter={(v: string) => v.slice(5)}
/>
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
<Tooltip
contentStyle={{ background: "#1a1a2e", border: "1px solid #333", borderRadius: 8 }}
labelStyle={{ color: "#fff" }}
itemStyle={{ color: "#a78bfa" }}
/>
<Line
type="monotone"
dataKey="total_tokens"
name="Tokens"
stroke="#8B5CF6"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* By model chart */}
<div className="bg-superdream-panel rounded-xl p-4">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3"></h3>
{byModel.length === 0 ? (
<p className="text-gray-500 text-sm py-8 text-center"></p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={byModel}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis
dataKey="model"
tick={{ fill: "#9ca3af", fontSize: 12 }}
/>
<YAxis tick={{ fill: "#9ca3af", fontSize: 12 }} />
<Tooltip
contentStyle={{ background: "#1a1a2e", border: "1px solid #333", borderRadius: 8 }}
labelStyle={{ color: "#fff" }}
itemStyle={{ color: "#a78bfa" }}
/>
<Bar dataKey="total_tokens" name="Tokens" radius={[4, 4, 0, 0]}>
{byModel.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
function Card({ label, value }: { label: string; value: string }) {
return (
<div className="bg-superdream-panel rounded-xl p-4">
<p className="text-gray-600 dark:text-gray-400 text-sm">{label}</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useState, useEffect } from "react";
import { walletService, TransactionInfo } from "../../services/walletService";
const TYPE_LABELS: Record<string, string> = {
topup: "充值",
consume: "消费",
refund: "退款",
};
export default function Wallet() {
const [balance, setBalance] = useState("0");
const [code, setCode] = useState("");
const [transactions, setTransactions] = useState<TransactionInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const fetchData = async () => {
try {
const [b, txns] = await Promise.all([
walletService.getBalance(),
walletService.transactions(),
]);
setBalance(b.balance);
setTransactions(txns);
} catch (err: any) {
setError(err.message);
}
};
useEffect(() => {
fetchData();
}, []);
const handleRedeem = async () => {
setError("");
setSuccess("");
if (!code.trim()) return;
setLoading(true);
try {
const txn = await walletService.redeem(code.trim());
setSuccess(`充值成功!+¥${txn.amount}`);
setCode("");
await fetchData();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div>
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"></h2>
{/* Balance */}
<div className="bg-superdream-panel rounded-xl p-6 mb-4">
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">¥{balance}</p>
</div>
{/* Redeem */}
<div className="bg-superdream-panel rounded-xl p-4 mb-4">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-2"></label>
{error && (
<div className="mb-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm">
{error}
</div>
)}
{success && (
<div className="mb-3 p-3 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 text-sm">
{success}
</div>
)}
<div className="flex gap-3">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="输入兑换码"
className="flex-1 px-3 py-2 rounded-lg bg-superdream-bg border border-gray-300 dark:border-gray-700 text-gray-900 dark:text-white text-sm focus:border-superdream-accent focus:outline-none"
/>
<button
onClick={handleRedeem}
disabled={loading || !code.trim()}
className="px-4 py-2 rounded-lg bg-superdream-primary text-gray-900 dark:text-white text-sm font-medium hover:bg-purple-600 transition disabled:opacity-50"
>
{loading ? "兑换中..." : "兑换"}
</button>
</div>
</div>
{/* Transactions */}
<div className="bg-superdream-panel rounded-xl overflow-hidden">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 px-4 py-3 border-b border-gray-200 dark:border-gray-800">
</h3>
<table className="w-full text-sm">
<thead>
<tr className="text-gray-600 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{transactions.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
</td>
</tr>
) : (
transactions.map((txn) => (
<tr key={txn.id} className="border-b border-gray-200/50 dark:border-gray-800/50 hover:bg-gray-200/30 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 text-gray-900 dark:text-white">
{TYPE_LABELS[txn.type] || txn.type}
</td>
<td className={`px-4 py-3 font-medium ${txn.type === "consume" ? "text-red-400" : "text-green-400"}`}>
{txn.type === "consume" ? "-" : "+"}¥{txn.amount}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">¥{txn.balance_after}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{txn.reference_id || "-"}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(txn.created_at).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { httpClient } from "./httpClient";
export interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
}
export interface UserInfo {
id: string;
email: string;
balance: string;
status: string;
created_at: string;
}
export const authService = {
register: (email: string, password: string) =>
httpClient.post<UserInfo>("/auth/register", { email, password }),
login: (email: string, password: string) =>
httpClient.post<TokenResponse>("/auth/login", { email, password }),
refresh: (refresh_token: string) =>
httpClient.post<TokenResponse>("/auth/refresh", { refresh_token }),
me: () => httpClient.get<UserInfo>("/auth/me"),
forgotPassword: (email: string) =>
httpClient.post<{ message: string }>("/auth/forgot-password", { email }),
resetPassword: (token: string, new_password: string) =>
httpClient.post<{ message: string }>("/auth/reset-password", { token, new_password }),
};

View File

@@ -0,0 +1,9 @@
import { httpClient } from "./httpClient";
import { Example } from "../types";
export const exampleService = {
list: () => httpClient.get<Example[]>("/examples"),
get: (id: string) => httpClient.get<Example>(`/examples/${id}`),
create: (data: { name: string; description?: string }) =>
httpClient.post<Example>("/examples", data),
};

View File

@@ -0,0 +1,36 @@
const BASE_URL = "/api/v1";
function getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
const token = localStorage.getItem("sd_access_token");
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
return headers;
}
async function request<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const res = await fetch(`${BASE_URL}${endpoint}`, {
headers: getAuthHeaders(),
...options,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `HTTP ${res.status}: ${res.statusText}`);
}
return res.json();
}
export const httpClient = {
get: <T>(endpoint: string) => request<T>(endpoint),
post: <T>(endpoint: string, body: unknown) =>
request<T>(endpoint, {
method: "POST",
body: JSON.stringify(body),
}),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: "DELETE" }),
};

View File

@@ -0,0 +1,25 @@
import { httpClient } from "./httpClient";
export interface ApiKeyInfo {
id: string;
name: string;
key_prefix: string;
key_suffix: string;
status: string;
created_at: string;
}
export interface ApiKeyCreated {
id: string;
name: string;
key: string;
key_prefix: string;
key_suffix: string;
created_at: string;
}
export const keyService = {
list: () => httpClient.get<ApiKeyInfo[]>("/keys"),
create: (name: string) => httpClient.post<ApiKeyCreated>("/keys", { name }),
remove: (keyId: string) => httpClient.delete<{ message: string }>(`/keys/${keyId}`),
};

View File

@@ -0,0 +1,80 @@
import { httpClient } from "./httpClient";
export interface UsageSummary {
today_tokens: number;
today_cost: string;
month_tokens: number;
month_cost: string;
total_requests: number;
}
export interface DailyUsage {
date: string;
total_tokens: number;
cost: string;
requests: number;
}
export interface ModelUsage {
model: string;
total_tokens: number;
cost: string;
requests: number;
}
export interface KeyUsage {
key_id: string;
key_name: string;
key_prefix: string;
key_suffix: string;
total_tokens: number;
cost: string;
requests: number;
}
export interface UsageLogItem {
id: number;
key_id: string;
model: string;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
cost: string;
request_time: string;
status: string;
}
export const usageService = {
summary: () => httpClient.get<UsageSummary>("/usage/summary"),
daily: (start?: string, end?: string) => {
const params = new URLSearchParams();
if (start) params.set("start", start);
if (end) params.set("end", end);
const qs = params.toString();
return httpClient.get<DailyUsage[]>(`/usage/daily${qs ? `?${qs}` : ""}`);
},
byModel: () => httpClient.get<ModelUsage[]>("/usage/by-model"),
byKey: () => httpClient.get<KeyUsage[]>("/usage/by-key"),
logs: (params: {
page?: number;
size?: number;
model?: string;
key_id?: string;
start?: string;
end?: string;
} = {}) => {
const sp = new URLSearchParams();
if (params.page) sp.set("page", String(params.page));
if (params.size) sp.set("size", String(params.size));
if (params.model) sp.set("model", params.model);
if (params.key_id) sp.set("key_id", params.key_id);
if (params.start) sp.set("start", params.start);
if (params.end) sp.set("end", params.end);
const qs = sp.toString();
return httpClient.get<UsageLogItem[]>(`/usage/logs${qs ? `?${qs}` : ""}`);
},
};

View File

@@ -0,0 +1,21 @@
import { httpClient } from "./httpClient";
export interface BalanceInfo {
balance: string;
}
export interface TransactionInfo {
id: string;
type: string;
amount: string;
balance_after: string;
reference_id: string;
created_at: string;
}
export const walletService = {
getBalance: () => httpClient.get<BalanceInfo>("/wallet/balance"),
redeem: (code: string) => httpClient.post<TransactionInfo>("/wallet/redeem", { code }),
transactions: (page = 1, size = 20) =>
httpClient.get<TransactionInfo[]>(`/wallet/transactions?page=${page}&size=${size}`),
};

View File

@@ -0,0 +1,51 @@
import { authService, UserInfo } from "../services/authService";
const TOKEN_KEY = "sd_access_token";
const REFRESH_KEY = "sd_refresh_token";
let currentUser: UserInfo | null = null;
let listeners: Array<() => void> = [];
function notify() {
listeners.forEach((fn) => fn());
}
export const authStore = {
getToken: (): string | null => localStorage.getItem(TOKEN_KEY),
setTokens: (access: string, refresh?: string) => {
localStorage.setItem(TOKEN_KEY, access);
if (refresh) localStorage.setItem(REFRESH_KEY, refresh);
notify();
},
clearTokens: () => {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(REFRESH_KEY);
currentUser = null;
notify();
},
isLoggedIn: (): boolean => !!localStorage.getItem(TOKEN_KEY),
getUser: (): UserInfo | null => currentUser,
fetchUser: async (): Promise<UserInfo | null> => {
if (!authStore.isLoggedIn()) return null;
try {
currentUser = await authService.me();
notify();
return currentUser;
} catch {
authStore.clearTokens();
return null;
}
},
subscribe: (fn: () => void) => {
listeners.push(fn);
return () => {
listeners = listeners.filter((l) => l !== fn);
};
},
};

View File

@@ -0,0 +1,39 @@
const THEME_KEY = "sd_theme";
type Theme = "light" | "dark";
let listeners: Array<() => void> = [];
function notify() {
listeners.forEach((fn) => fn());
}
function applyTheme(theme: Theme) {
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
export const themeStore = {
get: (): Theme => {
return (localStorage.getItem(THEME_KEY) as Theme) || "dark";
},
toggle: () => {
const next: Theme = themeStore.get() === "dark" ? "light" : "dark";
localStorage.setItem(THEME_KEY, next);
applyTheme(next);
notify();
},
isDark: (): boolean => themeStore.get() === "dark",
subscribe: (fn: () => void) => {
listeners.push(fn);
return () => {
listeners = listeners.filter((l) => l !== fn);
};
},
};

11
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,11 @@
export interface Example {
id: string;
name: string;
description: string;
created_at: string;
}
export interface ApiResponse<T> {
data: T;
message?: string;
}

23
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/index.tsx","./src/types.ts","./src/components/authguard.tsx","./src/components/header.tsx","./src/components/statusbar.tsx","./src/hooks/usefetch.ts","./src/pages/docs.tsx","./src/pages/forgotpassword.tsx","./src/pages/home.tsx","./src/pages/login.tsx","./src/pages/pricing.tsx","./src/pages/register.tsx","./src/pages/resetpassword.tsx","./src/pages/dashboard/keys.tsx","./src/pages/dashboard/layout.tsx","./src/pages/dashboard/logs.tsx","./src/pages/dashboard/overview.tsx","./src/pages/dashboard/usage.tsx","./src/pages/dashboard/wallet.tsx","./src/services/authservice.ts","./src/services/exampleservice.ts","./src/services/httpclient.ts","./src/services/keyservice.ts","./src/services/usageservice.ts","./src/services/walletservice.ts","./src/stores/authstore.ts","./src/stores/themestore.ts"],"version":"5.8.3"}

23
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
port: 3000,
host: "0.0.0.0",
proxy: {
"/api": "http://127.0.0.1:18000",
"/data/files": "http://127.0.0.1:18000",
},
},
build: {
outDir: "dist",
},
});