integrate sub2api as upstream for auth/keys/usage via FastAPI BFF

Preserve local user table for superDream-specific features while syncing
user lifecycle, API key CRUD and usage queries through sub2api. Admin token
handles reads and user lifecycle; per-user tokens (Fernet-encrypted in DB)
handle key writes that admin endpoints do not expose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xuyong
2026-04-17 21:23:08 +08:00
parent 20e842a60a
commit 35c0b7de16
30 changed files with 1707 additions and 803 deletions

View File

@@ -1,22 +1,34 @@
import { useState, useEffect } from "react";
import { keyService, ApiKeyInfo } from "../../services/keyService";
import { keyService, ApiKey } from "../../services/keyService";
function maskKey(key: string): string {
if (!key) return "";
if (key.length <= 12) return key;
return `${key.slice(0, 8)}...${key.slice(-4)}`;
}
function formatQuota(used: number, total: number): string {
if (total === 0) return `$${used.toFixed(4)} / 无限`;
return `$${used.toFixed(4)} / $${total.toFixed(2)}`;
}
export default function Keys() {
const [keys, setKeys] = useState<ApiKeyInfo[]>([]);
const [keys, setKeys] = useState<ApiKey[]>([]);
const [name, setName] = useState("");
const [quota, setQuota] = useState<string>("0");
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 [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const [deleting, setDeleting] = useState(false);
const fetchKeys = async () => {
try {
const data = await keyService.list();
setKeys(data);
} catch (err: any) {
setError(err.message);
const page = await keyService.list({ page_size: 100 });
setKeys(page.items);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
@@ -29,26 +41,30 @@ export default function Keys() {
setNewKey("");
setLoading(true);
try {
const res = await keyService.create(name);
const quotaNum = Number(quota);
const res = await keyService.create({
name: name || "未命名",
quota: Number.isFinite(quotaNum) ? quotaNum : 0,
});
setNewKey(res.key);
setName("");
await fetchKeys();
} catch (err: any) {
setError(err.message);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
const confirmDelete = async () => {
if (!deleteTarget) return;
if (deleteTarget === null) return;
setDeleting(true);
try {
await keyService.remove(deleteTarget);
setDeleteTarget(null);
await fetchKeys();
} catch (err: any) {
setError(err.message);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setDeleteTarget(null);
} finally {
setDeleting(false);
@@ -71,11 +87,10 @@ export default function Keys() {
</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>
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1">Key </label>
<input
type="text"
value={name}
@@ -84,6 +99,17 @@ export default function Keys() {
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>
<div className="w-40">
<label className="block text-sm text-gray-600 dark:text-gray-400 mb-1"> USD (0=)</label>
<input
type="number"
value={quota}
onChange={(e) => setQuota(e.target.value)}
min="0"
step="0.01"
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}
@@ -94,10 +120,9 @@ export default function Keys() {
</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>
<p className="text-sm text-green-400 mb-2">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}
@@ -112,13 +137,15 @@ export default function Keys() {
</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-left px-4 py-3 font-medium"> (USD)</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-right px-4 py-3 font-medium"></th>
</tr>
@@ -126,7 +153,7 @@ export default function Keys() {
<tbody>
{keys.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
Key
</td>
</tr>
@@ -134,8 +161,21 @@ export default function Keys() {
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 className="px-4 py-3 text-gray-700 dark:text-gray-300 font-mono">{maskKey(k.key)}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${
k.status === "active"
? "bg-green-500/10 text-green-400"
: "bg-gray-500/10 text-gray-400"
}`}>
{k.status}
</span>
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">
{formatQuota(k.quota_used, k.quota)}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{k.last_used_at ? new Date(k.last_used_at).toLocaleString() : "-"}
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">
{new Date(k.created_at).toLocaleString()}
@@ -155,8 +195,7 @@ export default function Keys() {
</table>
</div>
{/* Delete confirm modal */}
{deleteTarget && (
{deleteTarget !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/60"
@@ -188,4 +227,4 @@ export default function Keys() {
)}
</div>
);
}
}

View File

@@ -1,8 +1,9 @@
import { useState, useEffect } from "react";
import { usageService, UsageLogItem } from "../../services/usageService";
import { usageService, UsageLog } from "../../services/usageService";
export default function Logs() {
const [logs, setLogs] = useState<UsageLogItem[]>([]);
const [logs, setLogs] = useState<UsageLog[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [model, setModel] = useState("");
const [loading, setLoading] = useState(false);
@@ -13,12 +14,13 @@ export default function Logs() {
try {
const data = await usageService.logs({
page,
size: pageSize,
page_size: pageSize,
model: model || undefined,
});
setLogs(data);
setLogs(data.items);
setTotal(data.total);
} catch {
// silently fail
setLogs([]);
} finally {
setLoading(false);
}
@@ -29,15 +31,16 @@ export default function Logs() {
}, [page]);
const handleFilter = () => {
setPage(1);
fetchLogs();
if (page !== 1) setPage(1);
else fetchLogs();
};
const pages = Math.max(1, Math.ceil(total / pageSize));
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>
@@ -59,53 +62,44 @@ export default function Logs() {
</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-left px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium">Input</th>
<th className="text-right px-4 py-3 font-medium">Output</th>
<th className="text-right px-4 py-3 font-medium">Cache </th>
<th className="text-right px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium"> ms</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-gray-500">
...
</td>
<td colSpan={8} 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>
<td colSpan={8} 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()}
{new Date(log.created_at).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 className="px-4 py-3 text-gray-600 dark:text-gray-400">
{log.request_type}{log.stream ? " · stream" : ""}
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.input_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 text-right">{log.output_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 text-right">{log.cache_read_tokens.toLocaleString()}</td>
<td className="px-4 py-3 text-superdream-accent text-right">${log.total_cost.toFixed(6)}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 text-right">{log.duration_ms ?? "-"}</td>
</tr>
))
)}
@@ -113,7 +107,6 @@ export default function Logs() {
</table>
</div>
{/* Pagination */}
<div className="flex justify-between items-center mt-4">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
@@ -122,10 +115,12 @@ export default function Logs() {
>
</button>
<span className="text-sm text-gray-600 dark:text-gray-400"> {page} </span>
<span className="text-sm text-gray-600 dark:text-gray-400">
{page} / {pages} {total}
</span>
<button
onClick={() => setPage((p) => p + 1)}
disabled={logs.length < pageSize}
disabled={page >= pages}
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"
>
@@ -133,4 +128,4 @@ export default function Logs() {
</div>
</div>
);
}
}

View File

@@ -1,42 +1,76 @@
import { useState, useEffect } from "react";
import { walletService } from "../../services/walletService";
import { authService, UserInfo } from "../../services/authService";
import { keyService } from "../../services/keyService";
import { usageService, UsageSummary } from "../../services/usageService";
import { usageService, UsageStats } from "../../services/usageService";
export default function Overview() {
const [balance, setBalance] = useState("0");
const [user, setUser] = useState<UserInfo | null>(null);
const [keyCount, setKeyCount] = useState(0);
const [summary, setSummary] = useState<UsageSummary | null>(null);
const [today, setToday] = useState<UsageStats | null>(null);
const [month, setMonth] = useState<UsageStats | null>(null);
useEffect(() => {
walletService.getBalance().then((b) => setBalance(b.balance)).catch(() => {});
keyService.list().then((keys) => setKeyCount(keys.length)).catch(() => {});
usageService.summary().then(setSummary).catch(() => {});
authService.me().then(setUser).catch(() => {});
keyService.list({ page_size: 1 }).then((p) => setKeyCount(p.total)).catch(() => {});
usageService.stats({ period: "today" }).then(setToday).catch(() => {});
usageService.stats({ period: "month" }).then(setMonth).catch(() => {});
}, []);
const todayTokens = today ? (today.total_input_tokens + today.total_output_tokens) : 0;
const todayCost = today?.total_cost ?? 0;
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>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
${(user?.balance ?? 0).toFixed(4)}
</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-gray-600 dark:text-gray-400 text-sm">API 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-gray-600 dark:text-gray-400 text-sm"> Tokens</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
{summary ? `${summary.today_tokens.toLocaleString()} tokens` : "0"}
{todayTokens.toLocaleString()}
</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>
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">
${todayCost.toFixed(4)}
</p>
</div>
</div>
{month && (
<div className="mt-6 bg-superdream-panel rounded-xl p-4">
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3"></h3>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-gray-500"></p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{month.total_requests?.toLocaleString() ?? 0}
</p>
</div>
<div>
<p className="text-gray-500">Tokens</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{(month.total_input_tokens + month.total_output_tokens).toLocaleString()}
</p>
</div>
<div>
<p className="text-gray-500"></p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
${month.total_cost?.toFixed(4) ?? "0"}
</p>
</div>
</div>
</div>
)}
</div>
);
}
}

View File

@@ -4,42 +4,45 @@ import {
BarChart, Bar, Cell,
} from "recharts";
import {
usageService, UsageSummary, DailyUsage, ModelUsage,
usageService, UsageStats, DashboardTrendPoint, DashboardModelStat,
} 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[]>([]);
const [today, setToday] = useState<UsageStats | null>(null);
const [month, setMonth] = useState<UsageStats | null>(null);
const [trend, setTrend] = useState<DashboardTrendPoint[]>([]);
const [models, setModels] = useState<DashboardModelStat[]>([]);
useEffect(() => {
usageService.summary().then(setSummary).catch(() => {});
usageService.daily().then(setDaily).catch(() => {});
usageService.byModel().then(setByModel).catch(() => {});
usageService.stats({ period: "today" }).then(setToday).catch(() => {});
usageService.stats({ period: "month" }).then(setMonth).catch(() => {});
usageService.dashboardTrend({ granularity: "day" }).then((r) => setTrend(r.trend)).catch(() => {});
usageService.dashboardModels().then((r) => setModels(r.models)).catch(() => {});
}, []);
const todayTokens = today ? today.total_input_tokens + today.total_output_tokens : 0;
const monthTokens = month ? month.total_input_tokens + month.total_output_tokens : 0;
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"}`} />
<Card label="今日 Tokens" value={todayTokens.toLocaleString()} />
<Card label="今日消耗" value={`$${(today?.total_cost ?? 0).toFixed(4)}`} />
<Card label="本月 Tokens" value={monthTokens.toLocaleString()} />
<Card label="本月消耗" value={`$${(month?.total_cost ?? 0).toFixed(4)}`} />
</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 ? (
<h3 className="text-sm font-medium text-gray-600 dark:text-gray-400 mb-3"></h3>
{trend.length === 0 ? (
<p className="text-gray-500 text-sm py-8 text-center"></p>
) : (
<ResponsiveContainer width="100%" height={260}>
<LineChart data={daily}>
<LineChart data={trend}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis
dataKey="date"
@@ -52,40 +55,29 @@ export default function Usage() {
labelStyle={{ color: "#fff" }}
itemStyle={{ color: "#a78bfa" }}
/>
<Line
type="monotone"
dataKey="total_tokens"
name="Tokens"
stroke="#8B5CF6"
strokeWidth={2}
dot={false}
/>
<Line type="monotone" dataKey="requests" name="请求数" 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 ? (
{models.length === 0 ? (
<p className="text-gray-500 text-sm py-8 text-center"></p>
) : (
<ResponsiveContainer width="100%" height={260}>
<BarChart data={byModel}>
<BarChart data={models}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis
dataKey="model"
tick={{ fill: "#9ca3af", fontSize: 12 }}
/>
<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) => (
<Bar dataKey="requests" name="请求数" radius={[4, 4, 0, 0]}>
{models.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Bar>
@@ -104,4 +96,4 @@ function Card({ label, value }: { label: string; value: string }) {
<p className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{value}</p>
</div>
);
}
}

View File

@@ -1,139 +1,27 @@
import { useState, useEffect } from "react";
import { walletService, TransactionInfo } from "../../services/walletService";
const TYPE_LABELS: Record<string, string> = {
topup: "充值",
consume: "消费",
refund: "退款",
};
import { authService, UserInfo } from "../../services/authService";
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);
}
};
const [user, setUser] = useState<UserInfo | null>(null);
useEffect(() => {
fetchData();
authService.me().then(setUser).catch(() => {});
}, []);
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>
<p className="text-gray-600 dark:text-gray-400 text-sm"> (USD)</p>
<p className="text-3xl font-bold text-gray-900 dark:text-white mt-1">
${(user?.balance ?? 0).toFixed(4)}
</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 className="bg-superdream-panel rounded-xl p-4 text-sm text-gray-600 dark:text-gray-400">
sub2api <code>/api/v1/redeem</code>
</div>
</div>
);
}
}

View File

@@ -9,9 +9,10 @@ export interface TokenResponse {
export interface UserInfo {
id: string;
email: string;
balance: string;
status: string;
created_at: string;
sub2api_user_id: number | null;
balance: number;
}
export const authService = {
@@ -31,4 +32,4 @@ export const authService = {
resetPassword: (token: string, new_password: string) =>
httpClient.post<{ message: string }>("/auth/reset-password", { token, new_password }),
};
};

View File

@@ -19,7 +19,7 @@ async function request<T>(
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `HTTP ${res.status}: ${res.statusText}`);
throw new Error(body.detail || body.message || `HTTP ${res.status}: ${res.statusText}`);
}
return res.json();
}
@@ -31,6 +31,11 @@ export const httpClient = {
method: "POST",
body: JSON.stringify(body),
}),
put: <T>(endpoint: string, body: unknown) =>
request<T>(endpoint, {
method: "PUT",
body: JSON.stringify(body),
}),
delete: <T>(endpoint: string) =>
request<T>(endpoint, { method: "DELETE" }),
};
};

View File

@@ -1,25 +1,100 @@
import { httpClient } from "./httpClient";
export interface ApiKeyInfo {
id: string;
// Matches sub2api dto.APIKey 1:1 (see docs/sub2api_api.md §3.1)
export interface ApiKey {
id: number;
user_id: number;
key: string;
name: string;
key_prefix: string;
key_suffix: string;
status: string;
group_id: number | null;
status: "active" | "inactive";
ip_whitelist: string[];
ip_blacklist: string[];
last_used_at: string | null;
quota: number;
quota_used: number;
expires_at: string | null;
rate_limit_5h: number;
rate_limit_1d: number;
rate_limit_7d: number;
usage_5h: number;
usage_1d: number;
usage_7d: number;
window_5h_start: string | null;
window_1d_start: string | null;
window_7d_start: string | null;
reset_5h_at?: string | null;
reset_1d_at?: string | null;
reset_7d_at?: string | null;
created_at: string;
updated_at: string;
group?: Group;
}
export interface ApiKeyCreated {
id: string;
export interface Group {
id: number;
name: string;
key: string;
key_prefix: string;
key_suffix: string;
created_at: string;
description: string;
platform: string;
rate_multiplier: number;
status: string;
}
export interface Paginated<T> {
items: T[];
total: number;
page: number;
page_size: number;
pages: number;
}
export interface CreateKeyPayload {
name: string;
group_id?: number | null;
custom_key?: string | null;
ip_whitelist?: string[];
ip_blacklist?: string[];
quota?: number;
expires_in_days?: number | null;
rate_limit_5h?: number;
rate_limit_1d?: number;
rate_limit_7d?: number;
}
export interface UpdateKeyPayload {
name?: string;
group_id?: number | null;
status?: "active" | "inactive";
ip_whitelist?: string[];
ip_blacklist?: string[];
quota?: number;
expires_at?: string | null;
reset_quota?: boolean;
rate_limit_5h?: number;
rate_limit_1d?: number;
rate_limit_7d?: number;
reset_rate_limit_usage?: boolean;
}
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}`),
};
list: (params: { page?: number; page_size?: number; sort_by?: string; sort_order?: string } = {}) => {
const sp = new URLSearchParams();
if (params.page) sp.set("page", String(params.page));
if (params.page_size) sp.set("page_size", String(params.page_size));
if (params.sort_by) sp.set("sort_by", params.sort_by);
if (params.sort_order) sp.set("sort_order", params.sort_order);
const qs = sp.toString();
return httpClient.get<Paginated<ApiKey>>(`/keys${qs ? `?${qs}` : ""}`);
},
get: (keyId: number) => httpClient.get<ApiKey>(`/keys/${keyId}`),
create: (payload: CreateKeyPayload) => httpClient.post<ApiKey>("/keys", payload),
update: (keyId: number, payload: UpdateKeyPayload) =>
httpClient.put<ApiKey>(`/keys/${keyId}`, payload),
remove: (keyId: number) => httpClient.delete<{ message: string }>(`/keys/${keyId}`),
availableGroups: () => httpClient.get<Group[]>("/keys/meta/available-groups"),
};

View File

@@ -1,80 +1,143 @@
import { httpClient } from "./httpClient";
import type { Paginated } from "./keyService";
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 {
// Matches sub2api dto.UsageLog 1:1 (see docs/sub2api_api.md §4.1)
export interface UsageLog {
id: number;
key_id: string;
user_id: number;
api_key_id: number;
account_id: number;
request_id: string;
model: string;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
cost: string;
request_time: string;
status: string;
service_tier?: string | null;
reasoning_effort?: string | null;
inbound_endpoint?: string | null;
upstream_endpoint?: string | null;
group_id: number | null;
subscription_id: number | null;
input_tokens: number;
output_tokens: number;
cache_creation_tokens: number;
cache_read_tokens: number;
input_cost: number;
output_cost: number;
cache_creation_cost: number;
cache_read_cost: number;
total_cost: number;
actual_cost: number;
rate_multiplier: number;
billing_type: number;
request_type: string;
stream: boolean;
duration_ms: number | null;
first_token_ms: number | null;
image_count: number;
image_size: string | null;
created_at: string;
}
// sub2api service.UsageStats shape (summed totals for a period)
export interface UsageStats {
total_requests: number;
total_input_tokens: number;
total_output_tokens: number;
total_cache_creation_tokens: number;
total_cache_read_tokens: number;
total_cost: number;
total_actual_cost: number;
[key: string]: unknown;
}
export interface DashboardStats {
balance?: number;
today?: UsageStats;
month?: UsageStats;
total_requests?: number;
active_keys?: number;
[key: string]: unknown;
}
export interface DashboardTrendPoint {
date: string;
requests: number;
input_tokens: number;
output_tokens: number;
total_cost: number;
[key: string]: unknown;
}
export interface DashboardModelStat {
model: string;
requests: number;
input_tokens: number;
output_tokens: number;
total_cost: number;
[key: string]: unknown;
}
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;
} = {}) => {
logs: (
params: {
page?: number;
page_size?: number;
api_key_id?: number;
model?: string;
request_type?: string;
stream?: boolean;
start_date?: string;
end_date?: string;
timezone?: 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);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
});
const qs = sp.toString();
return httpClient.get<UsageLogItem[]>(`/usage/logs${qs ? `?${qs}` : ""}`);
return httpClient.get<Paginated<UsageLog>>(`/usage${qs ? `?${qs}` : ""}`);
},
};
stats: (
params: {
period?: "today" | "week" | "month";
start_date?: string;
end_date?: string;
api_key_id?: number;
timezone?: string;
} = {},
) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
});
const qs = sp.toString();
return httpClient.get<UsageStats>(`/usage/stats${qs ? `?${qs}` : ""}`);
},
dashboardStats: () => httpClient.get<DashboardStats>("/usage/dashboard/stats"),
dashboardTrend: (params: { granularity?: string; start_date?: string; end_date?: string } = {}) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
});
const qs = sp.toString();
return httpClient.get<{ trend: DashboardTrendPoint[]; start_date: string; end_date: string; granularity: string }>(
`/usage/dashboard/trend${qs ? `?${qs}` : ""}`,
);
},
dashboardModels: (params: { start_date?: string; end_date?: string } = {}) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
});
const qs = sp.toString();
return httpClient.get<{ models: DashboardModelStat[]; start_date: string; end_date: string }>(
`/usage/dashboard/models${qs ? `?${qs}` : ""}`,
);
},
dashboardAPIKeysUsage: (api_key_ids: number[]) =>
httpClient.post<{ stats: Record<string, UsageStats> }>("/usage/dashboard/api-keys-usage", { api_key_ids }),
};

View File

@@ -1,21 +0,0 @@
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

@@ -1 +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"}
{"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/stores/authstore.ts","./src/stores/themestore.ts"],"version":"5.8.3"}