diff --git a/src/pages/compute/index.tsx b/src/pages/compute/index.tsx index 3bb2d64..fc9a138 100644 --- a/src/pages/compute/index.tsx +++ b/src/pages/compute/index.tsx @@ -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(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() { ))} - - 开通算力(建号 + 发 PAT) - -
- - setUserId(e.target.value)} placeholder="u_xxx" /> -
- - {provisionMsg &&

{provisionMsg}

} -
-
); } diff --git a/src/pages/compute/keys.tsx b/src/pages/compute/keys.tsx index ecd0a85..edb81c6 100644 --- a/src/pages/compute/keys.tsx +++ b/src/pages/compute/keys.tsx @@ -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) => {r.id} }, { key: "name", header: "名称", cell: (r) => {r.name || "-"} }, { key: "status", header: "状态", cell: (r) => {r.status === 1 ? "启用" : "停用"} }, - { 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(blank); const [busy, setBusy] = React.useState(false); const [err, setErr] = React.useState(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 set("name", e.target.value)} /> set("group", e.target.value)} /> - set("remain_quota", e.target.value)} /> + set("remain_quota", e.target.value)} /> set("expired_time", e.target.value)} /> setF({ ...f, name: e.target.value })} /> -
setF({ ...f, quota: e.target.value })} />
+
setF({ ...f, quota: e.target.value })} />
setF({ ...f, count: e.target.value })} />
setF({ ...f, expired_time: e.target.value })} />
@@ -86,7 +86,7 @@ export default function RedemptionPage() { {r.id} {r.name} {r.key} - {fmtInt(r.quota)} + {fmtYuan(quotaToYuan(r.quota))} {r.status === 1 ? "未用" : "已用"} {r.used_user_id ? `#${r.used_user_id}` : "-"} {fmtTime(r.created_time)} diff --git a/src/pages/compute/useCompute.ts b/src/pages/compute/useCompute.ts index b93cd39..552c6c4 100644 --- a/src/pages/compute/useCompute.ts +++ b/src/pages/compute/useCompute.ts @@ -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 })}`; +} diff --git a/src/pages/compute/users.tsx b/src/pages/compute/users.tsx index 3be8258..388fb4d 100644 --- a/src/pages/compute/users.tsx +++ b/src/pages/compute/users.tsx @@ -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(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 ( { if (!o) onDone(); }}> - 调整引擎用户额度 + 调整算力余额 - 账号:{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))}
@@ -52,19 +67,63 @@ function BalanceDialog({ row, onDone }: { row: Row | null; onDone: () => void })
- - setAmount(e.target.value)} placeholder={`如 50000 = ¥${(50000 / perUnit).toFixed(2)}`} type="number" /> -

约 ¥{Number(amount ? amount : 0) / perUnit}(按 {perUnit.toLocaleString("zh-CN")} 配额 = 1 元)

+ + setAmount(e.target.value)} placeholder="如 10.00" type="number" step="0.01" min="0" /> +

按真实金额(元){mode === "subtract" ? "扣减" : mode === "override" ? "设置" : "充值"}余额,实时生效于 AI 调用扣费

{msg &&

{msg}

} - + +
+ + + ); +} + +/** 折扣调整对话框: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(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 ( + { if (!o) onDone(); }}> + + 设置计费折扣 + + 账号:{row?.nickname || row?.username}({row?.username} · 引擎ID {row?.engine_id ?? "-"})· + 当前折扣 {(row?.discount ?? 100) === 100 ? "原价(100%)" : `${row?.discount}%`} + + +
+
+ + setDiscount(e.target.value)} type="number" min="1" max="500" /> +

100=原价扣费;120=按原价 120% 扣;60=按 60% 扣

+
+ {msg &&

{msg}

} +
@@ -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([]); + const [engineTotal, setEngineTotal] = React.useState(0); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [keyword, setKeyword] = React.useState(""); + const [onlyProvisioned, setOnlyProvisioned] = React.useState("1"); const [syncing, setSyncing] = React.useState(false); const [syncMsg, setSyncMsg] = React.useState(null); const [balanceRow, setBalanceRow] = React.useState(null); - const res = useComputeList("GET", "user", { status, type: "-1" }); + const [discountRow, setDiscountRow] = React.useState(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("/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[] = [ - { key: "id", header: "ID", cell: (r) => {r.id} }, - { key: "username", header: "用户名", cell: (r) => {r.username} }, - { 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) => {r.username} }, + { key: "nickname", header: "昵称", cell: (r) => r.nickname || "-" }, + { key: "role", header: "角色", cell: (r) => r.role || "-" }, + { + key: "engine_id", header: "引擎账户", cell: (r) => r.provisioned + ? 已接入 · ID {r.engine_id} + : 未接入引擎, + }, + { key: "quota", header: "真实余额", cell: (r) => {fmtYuan(quotaToYuan(r.quota))} }, + { 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) => ( - +
+ + +
), }, ]; - const totalPages = Math.max(1, Math.ceil(res.total / res.pageSize)); return (
- {syncing ? "同步中…" : "同步用户"}} /> {syncMsg &&

{syncMsg}

}
- setKeyword(e.target.value)} className="w-64" /> + -
共 {res.total} 条 · 每页 -
+
+ 平台 {rows.length} 用户 · 引擎 {engineTotal} 账户 +
- {res.error ?

{res.error}

: null} - + {error ?

{error}

: null} +
-
- - {res.page} / {totalPages} - -
- { setBalanceRow(null); res.reload(); }} /> + { setBalanceRow(null); load(); }} /> + { setDiscountRow(null); load(); }} />
); } diff --git a/src/pages/compute/wallet.tsx b/src/pages/compute/wallet.tsx index 4548a7a..39a8b34 100644 --- a/src/pages/compute/wallet.tsx +++ b/src/pages/compute/wallet.tsx @@ -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) => {r.id} }, { 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 || "-" },