feat(compute): 算力页改造为平台用户视图(引擎真实余额/折扣/用量)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))}>
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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>过期时间(ms,0 不过期)</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>
|
||||
|
||||
@@ -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
@@ -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,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 || "-" },
|
||||
|
||||
Reference in New Issue
Block a user