first commit
This commit is contained in:
44
frontend/src/App.tsx
Normal file
44
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
frontend/src/components/AuthGuard.tsx
Normal file
9
frontend/src/components/AuthGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
89
frontend/src/components/Header.tsx
Normal file
89
frontend/src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/StatusBar.tsx
Normal file
34
frontend/src/components/StatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/hooks/useFetch.ts
Normal file
27
frontend/src/hooks/useFetch.ts
Normal 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
12
frontend/src/index.tsx
Normal 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
160
frontend/src/pages/Docs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
frontend/src/pages/ForgotPassword.tsx
Normal file
86
frontend/src/pages/ForgotPassword.tsx
Normal 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
108
frontend/src/pages/Home.tsx
Normal 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">
|
||||
更多模型持续接入中...
|
||||
<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 © {new Date().getFullYear()}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
frontend/src/pages/Login.tsx
Normal file
86
frontend/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
frontend/src/pages/Pricing.tsx
Normal file
98
frontend/src/pages/Pricing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
frontend/src/pages/Register.tsx
Normal file
111
frontend/src/pages/Register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
frontend/src/pages/ResetPassword.tsx
Normal file
111
frontend/src/pages/ResetPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
frontend/src/pages/dashboard/Keys.tsx
Normal file
191
frontend/src/pages/dashboard/Keys.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
frontend/src/pages/dashboard/Layout.tsx
Normal file
37
frontend/src/pages/dashboard/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
frontend/src/pages/dashboard/Logs.tsx
Normal file
136
frontend/src/pages/dashboard/Logs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
frontend/src/pages/dashboard/Overview.tsx
Normal file
42
frontend/src/pages/dashboard/Overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
frontend/src/pages/dashboard/Usage.tsx
Normal file
107
frontend/src/pages/dashboard/Usage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
frontend/src/pages/dashboard/Wallet.tsx
Normal file
139
frontend/src/pages/dashboard/Wallet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/services/authService.ts
Normal file
34
frontend/src/services/authService.ts
Normal 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 }),
|
||||
};
|
||||
9
frontend/src/services/exampleService.ts
Normal file
9
frontend/src/services/exampleService.ts
Normal 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),
|
||||
};
|
||||
36
frontend/src/services/httpClient.ts
Normal file
36
frontend/src/services/httpClient.ts
Normal 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" }),
|
||||
};
|
||||
25
frontend/src/services/keyService.ts
Normal file
25
frontend/src/services/keyService.ts
Normal 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}`),
|
||||
};
|
||||
80
frontend/src/services/usageService.ts
Normal file
80
frontend/src/services/usageService.ts
Normal 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}` : ""}`);
|
||||
},
|
||||
};
|
||||
21
frontend/src/services/walletService.ts
Normal file
21
frontend/src/services/walletService.ts
Normal 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}`),
|
||||
};
|
||||
51
frontend/src/stores/authStore.ts
Normal file
51
frontend/src/stores/authStore.ts
Normal 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);
|
||||
};
|
||||
},
|
||||
};
|
||||
39
frontend/src/stores/themeStore.ts
Normal file
39
frontend/src/stores/themeStore.ts
Normal 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
11
frontend/src/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user