feat(compute): 算力页改造为平台用户视图(引擎真实余额/折扣/用量)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-30 22:32:49 +08:00
parent 6568108777
commit 0369cbb146
7 changed files with 190 additions and 94 deletions
+6 -31
View File
@@ -3,8 +3,6 @@ import { PageHeader } from "@/components/generic/PageHeader";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Loader2 } from "lucide-react";
import { computeApi } from "@/api/resources";
import { useUrlTab } from "@/lib/useUrlTab";
@@ -66,9 +64,6 @@ export default function ComputePage() {
function OverviewTab() {
const [ping, setPing] = React.useState<{ ok?: boolean; detail?: string; version?: string; quota_per_unit?: number } | null>(null);
const [pinging, setPinging] = React.useState(false);
const [userId, setUserId] = React.useState("");
const [provisioning, setProvisioning] = React.useState(false);
const [provisionMsg, setProvisionMsg] = React.useState<string | null>(null);
async function doPing() {
setPinging(true);
@@ -76,21 +71,14 @@ function OverviewTab() {
catch (e) { setPing({ ok: false, detail: e instanceof Error ? e.message : "ping 失败" }); }
finally { setPinging(false); }
}
async function doProvision() {
if (!userId) return;
setProvisioning(true); setProvisionMsg(null);
try {
const r = await computeApi.provision(userId);
setProvisionMsg(r.created ? `建号成功:${r.engine_user_id ?? ""}PAT 已签发` : "已存在,PAT 已重新签发");
} catch (e) { setProvisionMsg(e instanceof Error ? e.message : "开通失败"); }
finally { setProvisioning(false); }
}
const perUnit = ping?.quota_per_unit || 500000;
// 进入总览即自动检查连通性(引擎版本/连通状态免手动刷新)
React.useEffect(() => { doPing(); /* eslint-disable-next-line */ }, []);
const cards = [
{ label: "引擎连通", value: ping ? (ping.ok ? "正常" : ping.detail || "连接失败") : "检查" },
{ label: "引擎版本", value: ping?.version || "-" },
{ label: "单位(1元 = 配额)", value: Number.isFinite(perUnit) ? perUnit.toLocaleString("zh-CN") : "-" },
{ label: "引擎连通", value: ping ? (ping.ok ? "正常" : ping.detail || "连接失败") : "检查中…" },
{ label: "引擎版本", value: ping?.version || (ping ? "-" : "检查中…") },
{ label: "计费单位", value: "按真实金额(元)计费与扣除" },
{ label: "入口", value: "经 server-core /admin/compute/proxy 代理;模型须带用户算力令牌(余额不足返还 403)" },
];
@@ -110,19 +98,6 @@ function OverviewTab() {
</Card>
))}
</div>
<Card>
<CardHeader><CardTitle className="text-base"> + PAT</CardTitle></CardHeader>
<CardContent className="max-w-md space-y-3">
<div className="space-y-1">
<Label htmlFor="pid"> id</Label>
<Input id="pid" value={userId} onChange={(e) => setUserId(e.target.value)} placeholder="u_xxx" />
</div>
<Button onClick={doProvision} disabled={provisioning || !userId}>
{provisioning && <Loader2 className="animate-spin" />}
</Button>
{provisionMsg && <p className="text-sm text-muted-foreground">{provisionMsg}</p>}
</CardContent>
</Card>
</div>
);
}
+13 -6
View File
@@ -10,7 +10,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { computeAction, useComputeList, fmtInt, fmtTime } from "./useCompute";
import { computeAction, useComputeList, fmtTime, quotaToYuan, yuanToQuota, fmtYuan } from "./useCompute";
interface Row { [k: string]: any }
const blank: Row = { name: "", status: 1, unlimited_quota: false, remain_quota: 0, expired_time: -1, group: "", allow_ips: "", model_limits: "" };
@@ -46,8 +46,8 @@ export default function KeysPage() {
{ key: "id", header: "ID", cell: (r) => <span className="text-muted-foreground">{r.id}</span> },
{ key: "name", header: "名称", cell: (r) => <span className="font-medium">{r.name || "-"}</span> },
{ key: "status", header: "状态", cell: (r) => <Badge variant={r.status === 1 ? "default" : "secondary"}>{r.status === 1 ? "启用" : "停用"}</Badge> },
{ key: "remain_quota", header: "剩余额度", cell: (r) => r.unlimited_quota ? "∞" : fmtInt(r.remain_quota) },
{ key: "used_quota", header: "已用额度", cell: (r) => fmtInt(r.used_quota) },
{ key: "remain_quota", header: "剩余额度", cell: (r) => r.unlimited_quota ? "∞" : fmtYuan(quotaToYuan(r.remain_quota)) },
{ key: "used_quota", header: "已用额度", cell: (r) => fmtYuan(quotaToYuan(r.used_quota)) },
{ key: "group", header: "分组", cell: (r) => r.group || "-" },
{ key: "expired", header: "过期时间", cell: (r) => Number(r.expired_time) < 0 ? "永不过期" : fmtTime(r.expired_time) },
{ key: "accessed", header: "最近访问", cell: (r) => fmtTime(r.accessed_time) },
@@ -103,12 +103,19 @@ function TokenFormDialog({ open, row, onClose, onDone }: { open: boolean; row: R
const [f, setF] = React.useState<Row>(blank);
const [busy, setBusy] = React.useState(false);
const [err, setErr] = React.useState<string | null>(null);
React.useEffect(() => { if (open) { setF(row?.id ? { ...blank, ...row } : blank); setErr(null); } }, [open, row]);
// 表单内剩余额度按真实金额(元)编辑;打开时由引擎配额换算,保存时换回配额
React.useEffect(() => {
if (open) {
if (row?.id) { const { remain_quota, ...rest } = row; setF({ ...blank, ...rest, remain_quota: quotaToYuan(remain_quota) }); }
else setF(blank);
setErr(null);
}
}, [open, row]);
const set = (k: string, v: any) => setF((p) => ({ ...p, [k]: v }));
async function save() {
setBusy(true); setErr(null);
const body: any = { ...f, id: row?.id, remain_quota: Number(f.remain_quota) || 0, expired_time: Number(f.expired_time) ?? -1, unlimited_quota: !!f.unlimited_quota };
const body: any = { ...f, id: row?.id, remain_quota: yuanToQuota(f.remain_quota), expired_time: Number(f.expired_time) ?? -1, unlimited_quota: !!f.unlimited_quota };
delete body.key; delete body.used_quota; delete body.accessed_time; delete body.created_time; delete body.deleted_at;
const out = row?.id ? await computeAction("PUT", "token", body) : await computeAction("POST", "token", body);
setBusy(false);
@@ -123,7 +130,7 @@ function TokenFormDialog({ open, row, onClose, onDone }: { open: boolean; row: R
<Label className="sm:col-span-2"></Label>
<Input className="sm:col-span-2" value={f.name} onChange={(e) => set("name", e.target.value)} />
<Label></Label><Input value={f.group || ""} onChange={(e) => set("group", e.target.value)} />
<Label></Label><Input type="number" value={f.remain_quota} onChange={(e) => set("remain_quota", e.target.value)} />
<Label></Label><Input type="number" step="0.01" min="0" value={f.remain_quota} onChange={(e) => set("remain_quota", e.target.value)} />
<Label>-1 </Label><Input type="number" value={f.expired_time ?? -1} onChange={(e) => set("expired_time", e.target.value)} />
<Label></Label>
<Select value={String(f.status ?? 1)} onValueChange={(v) => set("status", Number(v))}>
+2 -2
View File
@@ -4,7 +4,7 @@ import { DataTable, type Column } from "@/components/generic/DataTable";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Card, CardContent } from "@/components/ui/card";
import { useComputeList, fmtInt, fmtTime } from "./useCompute";
import { useComputeList, fmtInt, fmtTime, quotaToYuan, fmtYuan } from "./useCompute";
interface Row { [k: string]: any }
const TYPES: [number, string][] = [
@@ -22,7 +22,7 @@ export default function LogsPage() {
{ key: "username", header: "用户", cell: (r) => r.username || "-" },
{ key: "model_name", header: "模型", cell: (r) => r.model_name || "-" },
{ key: "token_name", header: "令牌", cell: (r) => r.token_name || "-" },
{ key: "quota", header: "额度", cell: (r) => fmtInt(r.quota) },
{ key: "quota", header: "消费", cell: (r) => fmtYuan(quotaToYuan(r.quota)) },
{ key: "prompt_tokens", header: "Prompt", cell: (r) => fmtInt(r.prompt_tokens) },
{ key: "completion_tokens", header: "Completion", cell: (r) => fmtInt(r.completion_tokens) },
{ key: "use_time", header: "耗时(ms)", cell: (r) => fmtInt(r.use_time) },
+5 -5
View File
@@ -6,7 +6,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { computeAction, fmtInt, fmtTime } from "./useCompute";
import { computeAction, fmtTime, quotaToYuan, yuanToQuota, fmtYuan } from "./useCompute";
interface Row { [k: string]: any }
@@ -20,7 +20,7 @@ export default function RedemptionPage() {
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState<string | null>(null);
const [showCreate, setShowCreate] = React.useState(false);
const [f, setF] = React.useState<Row>({ name: "", quota: 100, count: 1, expired_time: 0 });
const [f, setF] = React.useState<Row>({ name: "", quota: 1, count: 1, expired_time: 0 }); // quota 单位=元,提交时换算配额
async function load(p = page, ps = pageSize) {
setLoading(true); setErr(null);
@@ -41,7 +41,7 @@ export default function RedemptionPage() {
}
async function create() {
setBusy(true); setMsg(null);
const out = await computeAction("POST", "redemption", { ...f, quota: Number(f.quota) || 0, count: Number(f.count) || 1, expired_time: Number(f.expired_time) || 0 });
const out = await computeAction("POST", "redemption", { ...f, quota: yuanToQuota(f.quota), count: Number(f.count) || 1, expired_time: Number(f.expired_time) || 0 });
setBusy(false);
if (out.ok) { setMsg(`已生成 ${f.count || 1} 个兑换码`); setShowCreate(false); load(); }
else setMsg(`生成失败:${out.message}`);
@@ -62,7 +62,7 @@ export default function RedemptionPage() {
{showCreate && (
<Card><CardContent className="grid gap-3 py-4 sm:grid-cols-2">
<div className="space-y-1"><Label></Label><Input value={f.name} onChange={(e) => setF({ ...f, name: e.target.value })} /></div>
<div className="space-y-1"><Label></Label><Input type="number" value={f.quota} onChange={(e) => setF({ ...f, quota: e.target.value })} /></div>
<div className="space-y-1"><Label></Label><Input type="number" step="0.01" min="0" value={f.quota} onChange={(e) => setF({ ...f, quota: e.target.value })} /></div>
<div className="space-y-1"><Label></Label><Input type="number" value={f.count} onChange={(e) => setF({ ...f, count: e.target.value })} /></div>
<div className="space-y-1"><Label>(ms0 )</Label><Input type="number" value={f.expired_time} onChange={(e) => setF({ ...f, expired_time: e.target.value })} /></div>
<div className="flex gap-2 sm:col-span-2">
@@ -86,7 +86,7 @@ export default function RedemptionPage() {
<td className="py-2 pr-4 text-muted-foreground">{r.id}</td>
<td className="py-2 pr-4 font-medium">{r.name}</td>
<td className="py-2 pr-4 text-xs">{r.key}</td>
<td className="py-2 pr-4">{fmtInt(r.quota)}</td>
<td className="py-2 pr-4">{fmtYuan(quotaToYuan(r.quota))}</td>
<td className="py-2 pr-4"><Badge variant={r.status === 1 ? "default" : "secondary"}>{r.status === 1 ? "未用" : "已用"}</Badge></td>
<td className="py-2 pr-4">{r.used_user_id ? `#${r.used_user_id}` : "-"}</td>
<td className="py-2 pr-4">{fmtTime(r.created_time)}</td>
+25
View File
@@ -114,3 +114,28 @@ export function fmtMoney(v: unknown): string {
if (!Number.isFinite(n)) return "-";
return n.toLocaleString("zh-CN", { maximumFractionDigits: 4 });
}
/** 引擎计费换算:引擎内部按「配额」计量,对外统一按真实金额(元)呈现与输入。
* 1 元 = QUOTA_PER_YUAN 配额(compute-service 口径:QUOTA_PER_YUAN = 1e6,见 app/services/meter.py)。 */
export const QUOTA_PER_YUAN = 1_000_000;
/** 引擎配额 → 真实金额(元) */
export function quotaToYuan(quota: unknown): number {
const n = Number(quota);
if (!Number.isFinite(n)) return 0;
return n / QUOTA_PER_YUAN;
}
/** 真实金额(元) → 引擎配额(四舍五入取整) */
export function yuanToQuota(yuan: unknown): number {
const n = Number(yuan);
if (!Number.isFinite(n)) return 0;
return Math.round(n * QUOTA_PER_YUAN);
}
/** 金额格式化:¥1,234.56 */
export function fmtYuan(yuan: unknown): string {
const n = Number(yuan);
if (!Number.isFinite(n)) return "-";
return `¥${n.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
+137 -48
View File
@@ -6,32 +6,46 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { api } from "@/api/client";
import { computeApi } from "@/api/resources";
import { useComputeList, fmtInt, fmtTime } from "./useCompute";
import { computeApi, computeProxy } from "@/api/resources";
import { fmtInt, fmtTime, quotaToYuan, yuanToQuota, fmtYuan } from "./useCompute";
interface Row { [k: string]: any }
interface Row {
id: string;
username: string;
nickname: string;
role: string;
provisioned: boolean;
engine_id: number | null;
quota: number; // 真实余额(引擎 users.quota,扣费真源)
used_quota: number;
discount: number;
group: string;
request_count: number;
last_login_at: number | null;
mirror_quota: number; // 平台镜像(仅参考)
}
/** 充值/调整额度对话框 */
/** 充值/调整额度对话框(按真实金额·元 输入,提交时换算为引擎配额) */
function BalanceDialog({ row, onDone }: { row: Row | null; onDone: () => void }) {
const [amount, setAmount] = React.useState("");
const [mode, setMode] = React.useState<"add" | "subtract" | "override">("add");
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState<string | null>(null);
const perUnit = 500000;
React.useEffect(() => { if (row) { setAmount(""); setMode("add"); setMsg(null); } }, [row]);
const doBalance = React.useCallback(async () => {
if (!row) return;
if (!row?.engine_id) return;
const v = Number(amount);
if (!Number.isFinite(v) || v <= 0) { setMsg("请输入大于 0 的配额数"); return; }
if (!Number.isFinite(v) || v <= 0) { setMsg("请输入大于 0 的金额(元)"); return; }
setBusy(true); setMsg(null);
try {
await computeApi.balance(Number(row.id), Math.round(v), mode);
await computeApi.balance(Number(row.engine_id), yuanToQuota(v), mode);
setMsg("已提交"); onDone();
} catch (e) {
setMsg(e instanceof Error ? e.message : "操作失败");
@@ -41,9 +55,10 @@ function BalanceDialog({ row, onDone }: { row: Row | null; onDone: () => void })
return (
<Dialog open={!!row} onOpenChange={(o) => { if (!o) onDone(); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader><DialogTitle></DialogTitle>
<DialogHeader><DialogTitle></DialogTitle>
<DialogDescription>
{row?.username}ID {row?.id}· {fmtInt(row?.quota)} · 1 = {perUnit.toLocaleString("zh-CN")}
{row?.nickname || row?.username}{row?.username} · ID {row?.engine_id ?? "-"}·
{fmtYuan(quotaToYuan(row?.quota))}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
@@ -52,19 +67,63 @@ function BalanceDialog({ row, onDone }: { row: Row | null; onDone: () => void })
<Select value={mode} onValueChange={(v) => setMode(v as typeof mode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="add"></SelectItem>
<SelectItem value="subtract"></SelectItem>
<SelectItem value="add"></SelectItem>
<SelectItem value="subtract"></SelectItem>
<SelectItem value="override"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label></Label>
<Input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder={`如 50000 = ¥${(50000 / perUnit).toFixed(2)}`} type="number" />
<p className="text-xs text-muted-foreground"> ¥{Number(amount ? amount : 0) / perUnit} {perUnit.toLocaleString("zh-CN")} = 1 </p>
<Label></Label>
<Input value={amount} onChange={(e) => setAmount(e.target.value)} placeholder="如 10.00" type="number" step="0.01" min="0" />
<p className="text-xs text-muted-foreground">{mode === "subtract" ? "扣减" : mode === "override" ? "设置" : "充值"} AI </p>
</div>
{msg && <p className="text-sm text-destructive">{msg}</p>}
<Button className="w-full" onClick={doBalance} disabled={busy || !amount}>{busy ? "提交中…" : "提交"}</Button>
<Button className="w-full" onClick={doBalance} disabled={busy || !amount || !row?.engine_id}>{busy ? "提交中…" : "提交"}</Button>
</div>
</DialogContent>
</Dialog>
);
}
/** 折扣调整对话框:100=原价、120=扣 120%、60=扣 60%(计费=原价×折扣%) */
function DiscountDialog({ row, onDone }: { row: Row | null; onDone: () => void }) {
const [discount, setDiscount] = React.useState("100");
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState<string | null>(null);
React.useEffect(() => { if (row) { setDiscount(String(row.discount ?? 100)); setMsg(null); } }, [row]);
const doSet = React.useCallback(async () => {
if (!row?.engine_id) return;
const v = Number(discount);
if (!Number.isFinite(v) || v < 1 || v > 500) { setMsg("折扣需在 1%-500% 之间"); return; }
setBusy(true); setMsg(null);
try {
await computeProxy("POST", "user/manage", { id: Number(row.engine_id), action: "set_discount", value: Math.round(v) });
onDone();
} catch (e) {
setMsg(e instanceof Error ? e.message : "操作失败");
} finally { setBusy(false); }
}, [discount, row, onDone]);
return (
<Dialog open={!!row} onOpenChange={(o) => { if (!o) onDone(); }}>
<DialogContent className="sm:max-w-sm">
<DialogHeader><DialogTitle></DialogTitle>
<DialogDescription>
{row?.nickname || row?.username}{row?.username} · ID {row?.engine_id ?? "-"}·
{(row?.discount ?? 100) === 100 ? "原价(100%)" : `${row?.discount}%`}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<Label>%</Label>
<Input value={discount} onChange={(e) => setDiscount(e.target.value)} type="number" min="1" max="500" />
<p className="text-xs text-muted-foreground">100=120= 120% 60= 60% </p>
</div>
{msg && <p className="text-sm text-destructive">{msg}</p>}
<Button className="w-full" onClick={doSet} disabled={busy || !row?.engine_id}>{busy ? "提交中…" : "提交"}</Button>
</div>
</DialogContent>
</Dialog>
@@ -72,11 +131,29 @@ function BalanceDialog({ row, onDone }: { row: Row | null; onDone: () => void })
}
export default function UsersPage() {
const [status, setStatus] = React.useState("1");
const [rows, setRows] = React.useState<Row[]>([]);
const [engineTotal, setEngineTotal] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [keyword, setKeyword] = React.useState("");
const [onlyProvisioned, setOnlyProvisioned] = React.useState("1");
const [syncing, setSyncing] = React.useState(false);
const [syncMsg, setSyncMsg] = React.useState<string | null>(null);
const [balanceRow, setBalanceRow] = React.useState<Row | null>(null);
const res = useComputeList<Row>("GET", "user", { status, type: "-1" });
const [discountRow, setDiscountRow] = React.useState<Row | null>(null);
const load = React.useCallback(() => {
setLoading(true); setError(null);
api.get<{ items: Row[]; total: number; engine_total: number }>("/admin/compute/platform-users")
.then((r: any) => {
const d = r.data ?? r;
setRows(d.items ?? []);
setEngineTotal(d.engine_total ?? 0);
})
.catch((e) => setError(e instanceof Error ? e.message : "加载失败"))
.finally(() => setLoading(false));
}, []);
React.useEffect(() => { load(); }, [load]);
const sync = React.useCallback(async () => {
setSyncing(true); setSyncMsg(null);
@@ -84,57 +161,69 @@ export default function UsersPage() {
const r = await api.post<any>("/admin/compute/sync-users");
const d = r.data?.data ?? r.data ?? {};
setSyncMsg(`已同步:平台 ${d.total ?? "?"} 用户,建号 ${d.synced ?? "?"},签发PAT ${d.pat_issued ?? "?"}`);
res.reload();
load();
} catch (e) {
setSyncMsg(e instanceof Error ? e.message : "同步失败");
} finally { setSyncing(false); }
}, [res]);
React.useEffect(() => { sync(); /* eslint-disable-next-line */ }, []);
}, [load]);
const filtered = React.useMemo(() => rows.filter((r) => {
if (onlyProvisioned === "1" && !r.provisioned) return false;
const kw = keyword.trim().toLowerCase();
if (!kw) return true;
return (r.username || "").toLowerCase().includes(kw)
|| (r.nickname || "").toLowerCase().includes(kw)
|| String(r.engine_id ?? "").includes(kw);
}), [rows, keyword, onlyProvisioned]);
const columns: Column<Row>[] = [
{ key: "id", header: "ID", cell: (r) => <span className="text-muted-foreground">{r.id}</span> },
{ key: "username", header: "用户名", cell: (r) => <span className="font-medium">{r.username}</span> },
{ key: "display_name", header: "显示名", cell: (r) => r.display_name || "-" },
{ key: "role", header: "角色", cell: (r) => (r.role === 100 ? "管理员" : r.role === 10 ? "普通" : fmtInt(r.role)) },
{ key: "group", header: "分组", cell: (r) => r.group || "default" },
{ key: "quota", header: "额度", cell: (r) => fmtInt(r.quota) },
{ key: "used_quota", header: "已用", cell: (r) => fmtInt(r.used_quota) },
{ key: "username", header: "平台用户名", cell: (r) => <span className="font-medium">{r.username}</span> },
{ key: "nickname", header: "昵称", cell: (r) => r.nickname || "-" },
{ key: "role", header: "角色", cell: (r) => r.role || "-" },
{
key: "engine_id", header: "引擎账户", cell: (r) => r.provisioned
? <Badge variant="secondary"> · ID {r.engine_id}</Badge>
: <Badge variant="outline"></Badge>,
},
{ key: "quota", header: "真实余额", cell: (r) => <span className="font-medium">{fmtYuan(quotaToYuan(r.quota))}</span> },
{ key: "used_quota", header: "已用", cell: (r) => fmtYuan(quotaToYuan(r.used_quota)) },
{ key: "discount", header: "折扣", cell: (r) => (r.discount ?? 100) === 100 ? "原价" : `${r.discount}%` },
{ key: "request_count", header: "请求", cell: (r) => fmtInt(r.request_count) },
{ key: "last_login", header: "最近登录", cell: (r) => fmtTime(r.last_login_at) },
{ key: "last_login_at", header: "最近登录", cell: (r) => fmtTime(r.last_login_at) },
{
key: "_actions", header: "操作", cell: (r) => (
<Button size="sm" variant="outline" onClick={() => setBalanceRow(r)}></Button>
<div className="flex gap-2">
<Button size="sm" variant="outline" disabled={!r.engine_id} onClick={() => setBalanceRow(r)}>/</Button>
<Button size="sm" variant="outline" disabled={!r.engine_id} onClick={() => setDiscountRow(r)}></Button>
</div>
),
},
];
const totalPages = Math.max(1, Math.ceil(res.total / res.pageSize));
return (
<div className="space-y-4">
<PageHeader title="引擎用户" description="平台用户算力账户"
<PageHeader title="算力账户" description="平台用户管理算力账户;余额为引擎真实扣费余额(非镜像)"
actions={<Button size="sm" variant="outline" onClick={sync} disabled={syncing}>{syncing ? "同步中…" : "同步用户"}</Button>} />
{syncMsg && <p className="text-sm text-muted-foreground">{syncMsg}</p>}
<div className="flex flex-wrap items-center gap-2">
<Select value={status} onValueChange={(v) => { setStatus(v); res.setParams({ status: v }); }}>
<SelectTrigger className="w-40"><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="1"></SelectItem><SelectItem value="0"></SelectItem><SelectItem value="-1"></SelectItem></SelectContent>
<Input placeholder="搜索 用户名 / 昵称 / 引擎ID" value={keyword} onChange={(e) => setKeyword(e.target.value)} className="w-64" />
<Select value={onlyProvisioned} onValueChange={setOnlyProvisioned}>
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="1"></SelectItem>
<SelectItem value="-1"></SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground"> {res.total} ·
<Select value={String(res.pageSize)} onValueChange={(v) => res.setPageSize(Number(v))}>
<SelectTrigger className="w-20"><SelectValue /></SelectTrigger>
<SelectContent>{[10, 20, 50].map((s) => <SelectItem key={s} value={String(s)}>{s}</SelectItem>)}</SelectContent>
</Select></div>
<div className="ml-auto flex items-center gap-2 text-sm text-muted-foreground">
{rows.length} · {engineTotal}
</div>
</div>
<Card><CardContent className="pt-2">
{res.error ? <p className="py-4 text-sm text-destructive">{res.error}</p> : null}
<DataTable columns={columns} data={res.items} loading={res.loading} empty="暂无引擎用户" />
{error ? <p className="py-4 text-sm text-destructive">{error}</p> : null}
<DataTable columns={columns} data={filtered} loading={loading} empty="暂无算力账户" />
</CardContent></Card>
<div className="flex items-center justify-end gap-2">
<Button variant="outline" size="sm" disabled={res.page <= 1} onClick={() => res.setPage(res.page - 1)}></Button>
<span className="text-sm text-muted-foreground">{res.page} / {totalPages}</span>
<Button variant="outline" size="sm" disabled={res.page >= totalPages} onClick={() => res.setPage(res.page + 1)}></Button>
</div>
<BalanceDialog row={balanceRow} onDone={() => { setBalanceRow(null); res.reload(); }} />
<BalanceDialog row={balanceRow} onDone={() => { setBalanceRow(null); load(); }} />
<DiscountDialog row={discountRow} onDone={() => { setDiscountRow(null); load(); }} />
</div>
);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { PageHeader } from "@/components/generic/PageHeader";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useComputeList, fmtInt, fmtMoney, fmtTime } from "./useCompute";
import { useComputeList, fmtMoney, fmtTime, quotaToYuan, fmtYuan } from "./useCompute";
interface Row { [k: string]: any }
@@ -12,7 +12,7 @@ export default function WalletPage() {
const columns = [
{ key: "id", header: "ID", cell: (r: Row) => <span className="text-muted-foreground">{r.id}</span> },
{ key: "user_id", header: "用户", cell: (r: Row) => `#${r.user_id}` },
{ key: "amount", header: "额度", cell: (r: Row) => fmtInt(r.amount) },
{ key: "amount", header: "额度", cell: (r: Row) => fmtYuan(quotaToYuan(r.amount)) },
{ key: "money", header: "金额", cell: (r: Row) => fmtMoney(r.money) },
{ key: "payment_method", header: "方式", cell: (r: Row) => r.payment_method || "-" },
{ key: "payment_provider", header: "支付渠道", cell: (r: Row) => r.payment_provider || "-" },