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:
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
@@ -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" }),
|
||||
};
|
||||
};
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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}`),
|
||||
};
|
||||
Reference in New Issue
Block a user