feat(admin): 数据源页转 RAG 运维台 + 支付/模型菜单重组 + 概览升级为仪表盘
后端: - 新增 POST /admin/kb/search:管理端跨租户检索,支持 mode 指定单路 (vector/fulltext/graph/hybrid),不走 scopedKB(否则会被强制锁到调用者 自己的 space,跨租户排障就没法做了) - KB 清单补 space_id(检索键是 <space_id>/<name>,缺它前端拼不出 key) 前端: - 数据源&RAG 页补「检索试验台」:同一 query 并排跑生产链路 + 四路诊断, 召回不准时能直接定位是向量/分词/图谱哪一环挂了 - 支付拆成「配置 / 订单与对账」两个子页,挂到运维 > 支付 下; 导航支持二级菜单(NavParent 命中子路由自动展开) - SettingsPage → ModelConfigPage「模型配置」,模型参数与计费规则合一 - 概览 → 仪表盘:并入计费与用量(UsagePage → UsageSection), 去掉系统健康拓扑(与服务状态页重复,同一份 /admin/status 数据) - 全局隐藏滚动条(保留滚动) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { UsageSection } from "../components/UsageSection";
|
||||
import { adminOverview, getStatus, type AdminOverview, type SystemStatus } from "../api";
|
||||
|
||||
// 管理端「概览」= 系统控制塔:统筹全系统的吞吐 / 配置态 / 健康,而非某个账号的个人工作台。
|
||||
@@ -150,6 +151,75 @@ export function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* C. 全局任务吞吐 */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">全平台任务吞吐</h4>
|
||||
<p className="text-[11px] text-gray-400">近 7 天调度中心处理的任务总数(所有租户/用户)</p>
|
||||
</div>
|
||||
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">真实数据</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="h-48 w-full overflow-visible">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#f1f5f9" />
|
||||
{areaPath && <path d={areaPath} fill="url(#g)" />}
|
||||
{dPath && <path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
{pts.map((p, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={p.x} cy={p.y} r="4" fill="#fff" stroke="#7c3aed" strokeWidth="2" />
|
||||
<title>{`${trend[i].key}: ${trend[i].count} 任务`}</title>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<div className="mt-2 flex justify-between px-2 text-[10px] text-gray-400">
|
||||
{trend.map((d) => (
|
||||
<span key={d.key}>{d.key}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700">任务终态分布</h4>
|
||||
<p className="text-[11px] text-gray-400">近 7 天全局终态占比</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{ov.status_count.length === 0 && <div className="text-xs text-gray-400">暂无任务</div>}
|
||||
{ov.status_count
|
||||
.slice()
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((d) => {
|
||||
const st = statusStyle(d.key);
|
||||
const pct = (d.count / totalStatus) * 100;
|
||||
return (
|
||||
<div key={d.key}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5 text-gray-600">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: st.color }} />
|
||||
{st.label}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-400">{d.count} · {pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: st.color }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 计费与用量(原独立页并入:积分消耗/Token/成本 + 趋势 + 各租户排行 + 充值) */}
|
||||
<UsageSection />
|
||||
|
||||
{/* B. 控制面配置态(管理端独有) */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
@@ -224,90 +294,6 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* C. 全局任务吞吐 */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm lg:col-span-2">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">全平台任务吞吐</h4>
|
||||
<p className="text-[11px] text-gray-400">近 7 天调度中心处理的任务总数(所有租户/用户)</p>
|
||||
</div>
|
||||
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">真实数据</span>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${W} ${H}`} className="h-48 w-full overflow-visible">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#7c3aed" stopOpacity="0.25" />
|
||||
<stop offset="100%" stopColor="#7c3aed" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#f1f5f9" />
|
||||
{areaPath && <path d={areaPath} fill="url(#g)" />}
|
||||
{dPath && <path d={dPath} fill="none" stroke="#7c3aed" strokeWidth="2.5" strokeLinecap="round" />}
|
||||
{pts.map((p, i) => (
|
||||
<g key={i}>
|
||||
<circle cx={p.x} cy={p.y} r="4" fill="#fff" stroke="#7c3aed" strokeWidth="2" />
|
||||
<title>{`${trend[i].key}: ${trend[i].count} 任务`}</title>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
<div className="mt-2 flex justify-between px-2 text-[10px] text-gray-400">
|
||||
{trend.map((d) => (
|
||||
<span key={d.key}>{d.key}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-semibold text-gray-700">任务终态分布</h4>
|
||||
<p className="text-[11px] text-gray-400">近 7 天全局终态占比</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{ov.status_count.length === 0 && <div className="text-xs text-gray-400">暂无任务</div>}
|
||||
{ov.status_count
|
||||
.slice()
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((d) => {
|
||||
const st = statusStyle(d.key);
|
||||
const pct = (d.count / totalStatus) * 100;
|
||||
return (
|
||||
<div key={d.key}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5 text-gray-600">
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: st.color }} />
|
||||
{st.label}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-400">{d.count} · {pct.toFixed(0)}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: st.color }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* D. 系统健康拓扑 */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">系统健康拓扑</h4>
|
||||
<p className="text-[11px] text-gray-400">基建 + 应用服务 + MCP 工具注册(NATS 实时探活)</p>
|
||||
</div>
|
||||
<span className="rounded bg-emerald-50 px-2 py-0.5 text-[10px] font-medium text-emerald-700">实时</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-x-8 gap-y-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<HealthGroup title="基建" items={status.infra.map((i) => ({ name: i.name, up: i.up, detail: i.detail }))} />
|
||||
<HealthGroup title="应用服务" items={status.services.map((s) => ({ name: s.name, up: s.up, detail: s.detail }))} />
|
||||
<HealthGroup
|
||||
title="MCP 工具组"
|
||||
items={status.tools.map((t) => ({ name: t.server, up: t.up, detail: `${t.tools?.length ?? 0} 个工具` }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -321,23 +307,6 @@ function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function HealthGroup({ title, items }: { title: string; items: { name: string; up: boolean; detail?: string }[] }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 mt-2 text-[10px] font-semibold uppercase tracking-wider text-gray-300">{title}</div>
|
||||
{items.map((it) => (
|
||||
<div key={it.name} className="flex items-center justify-between border-b border-gray-50 py-1.5 text-xs">
|
||||
<span className="flex items-center gap-1.5 text-gray-600">
|
||||
<span className={`h-2 w-2 rounded-full ${it.up ? "bg-emerald-500" : "bg-rose-500"}`} />
|
||||
{it.name}
|
||||
</span>
|
||||
<span className={`text-[10px] ${it.up ? "text-gray-400" : "text-rose-500"}`}>{it.up ? it.detail || "在线" : "离线"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Tone = "violet" | "emerald" | "cyan" | "amber" | "rose";
|
||||
const TONE: Record<Tone, string> = {
|
||||
violet: "bg-violet-50 text-violet-600",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { RetrievalBench } from "../components/RetrievalBench";
|
||||
import { adminDatasources, listModels, migrateKbStorage, type DatasourceKB, type MigrateKbResult } from "../api";
|
||||
|
||||
// 数据源 & RAG:真数据(全平台知识库清单,来自 sundynix_kb/sundynix_doc)。
|
||||
@@ -73,10 +74,10 @@ export function DatasourcesPage() {
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/admin/settings"
|
||||
to="/admin/models"
|
||||
className="flex items-center gap-1 text-xs font-medium text-violet-600 hover:text-violet-700 hover:underline"
|
||||
>
|
||||
前往「系统配置 → 模型」修改 →
|
||||
前往「模型配置」修改 →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -240,6 +241,9 @@ export function DatasourcesPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 检索试验台:紧跟管线说明 —— 讲完原理就能当场验证,不用只看文字 */}
|
||||
<RetrievalBench kbs={rows} />
|
||||
|
||||
{/* 平台数据源计数 */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Stat label="知识库" value={String(counts.kbs)} tone="violet" />
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ModelsPage } from "./ModelsPage";
|
||||
import { BillingRules } from "../components/BillingRules";
|
||||
|
||||
// 模型配置:模型登记/激活/连通性测试 + 它的计费规则(token→积分汇率、硬拦截、按模型定价与权重)。
|
||||
// 配了模型就顺手定它的价,两块放一起。
|
||||
// 支付相关(渠道配置 / 订单对账)已拆到「运维 → 支付」下的两个子页,不在这里。
|
||||
// 提示词有版本历史 + diff + 激活的完整工作流,保留独立页。
|
||||
export function ModelConfigPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">模型配置</h3>
|
||||
<p className="text-xs text-gray-400">对话 / 向量模型登记与激活 · 计费规则与按模型定价</p>
|
||||
</div>
|
||||
|
||||
<ModelsPage />
|
||||
<BillingRules />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { TopupChannels } from "../components/TopupChannels";
|
||||
|
||||
// 支付 · 配置:充值渠道(积分包定价、兑换码生成/台账)+ 微信支付参数。
|
||||
// 与「支付 · 订单与对账」拆开:这里只管「怎么收钱」,那边看「收到了什么」。
|
||||
export function PaymentConfigPage() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">支付 · 配置</h3>
|
||||
<p className="text-xs text-gray-400">积分包定价与上下架 · 兑换码生成与台账 · 微信支付商户参数</p>
|
||||
</div>
|
||||
<TopupChannels />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { OrderStream } from "../components/OrderStream";
|
||||
|
||||
// 支付 · 订单与对账:全平台充值订单流 + 状态计数 + 一键对账 + 人工退款。
|
||||
// 配置面在「支付 · 配置」,这里只做流水观测与处置。
|
||||
export function PaymentOrdersPage() {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="border-b border-gray-200 pb-4">
|
||||
<h3 className="text-base font-semibold text-gray-800">支付 · 订单与对账</h3>
|
||||
<p className="text-xs text-gray-400">充值订单流水 · 账本一致性对账 · 人工退款</p>
|
||||
</div>
|
||||
<OrderStream />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { ModelsPage } from "./ModelsPage";
|
||||
import { BillingRules } from "../components/BillingRules";
|
||||
import { TopupChannels } from "../components/TopupChannels";
|
||||
import { OrderStream } from "../components/OrderStream";
|
||||
|
||||
// 系统配置:按「域」聚合 —— 配了什么,就在同一处看它相关的规则与数据。
|
||||
// 模型 = 模型登记/激活/连通性测试 + 计费规则(token→积分汇率、硬拦截、按模型定价与权重)
|
||||
// 支付 = 支付渠道配置(兑换码 / 积分包 / 微信参数)+ 订单流水(订单流、对账、人工退款)
|
||||
// 「计费 & 用量」页只留纯用量观测(消耗趋势/租户排行/发放积分),不再混配置。
|
||||
// 提示词有版本历史 + diff + 激活的完整工作流,保留独立页,不塞进 Tab。
|
||||
|
||||
type Tab = "models" | "payment";
|
||||
|
||||
const TABS: Array<{ key: Tab; label: string; desc: string }> = [
|
||||
{ key: "models", label: "模型", desc: "模型登记与激活 · 计费规则与按模型定价" },
|
||||
{ key: "payment", label: "支付", desc: "支付渠道配置 · 订单流水与对账退款" },
|
||||
];
|
||||
|
||||
export function SettingsPage() {
|
||||
const [tab, setTab] = useState<Tab>("models");
|
||||
const current = TABS.find((t) => t.key === tab);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 border-b border-gray-200 pb-4">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-800">系统配置</h3>
|
||||
<p className="text-xs text-gray-400">{current?.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex rounded-lg border border-gray-200 bg-gray-50/50 p-1">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-all ${
|
||||
tab === t.key ? "bg-white text-violet-700 shadow-sm" : "text-gray-500 hover:text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 模型域:先登记模型,紧接着配它的计价规则 */}
|
||||
{tab === "models" && (
|
||||
<div className="space-y-6">
|
||||
<ModelsPage />
|
||||
<BillingRules />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 支付域:先配渠道,紧接着看这些渠道产生的订单流水与对账 */}
|
||||
{tab === "payment" && (
|
||||
<div className="space-y-6">
|
||||
<TopupChannels />
|
||||
<OrderStream />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { adminUsage, listTenants, type TenantRow, type UsageReport, type UsageTenantSum, type UsageDay } from "../api";
|
||||
import { GrantCreditsModal } from "../components/GrantCreditsModal";
|
||||
|
||||
|
||||
// 管理端「用量 & 计费」= 计费闭环一页:顶部配「规则」(单价/积分权重/汇率),下方看「结果」(用量观测)。
|
||||
// 规则→扣费→观测:改规则即对后续任务生效,用量观测(读 /admin/usage,系统级跨租户)即其结果。
|
||||
// 金额/积分均为微单位(×10⁻⁶),展示时 ÷1e6。
|
||||
|
||||
const MICRO = 1_000_000;
|
||||
const credits = (micro: number) => (micro / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
const money = (micro: number) => (micro / MICRO).toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
const int = (n: number) => n.toLocaleString("zh-CN");
|
||||
|
||||
// YYYYMMDD(本地)。
|
||||
function ymd(d: Date): string {
|
||||
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
function rangeFor(days: number): { from: string; to: string } {
|
||||
const now = new Date();
|
||||
const from = new Date(now);
|
||||
from.setDate(now.getDate() - (days - 1));
|
||||
return { from: ymd(from), to: ymd(now) };
|
||||
}
|
||||
// 把稀疏的 trend 补齐成连续日序列(缺的天补 0),便于成条形图。
|
||||
function fillDays(trend: UsageDay[], days: number): UsageDay[] {
|
||||
const byDay = new Map(trend.map((d) => [d.day, d]));
|
||||
const out: UsageDay[] = [];
|
||||
const now = new Date();
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(now.getDate() - i);
|
||||
const key = ymd(d);
|
||||
out.push(byDay.get(key) ?? { day: key, total_tok: 0, credits_micro: 0, cost_micros: 0, task_count: 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const mmdd = (ymdStr: string) => `${ymdStr.slice(4, 6)}-${ymdStr.slice(6, 8)}`;
|
||||
|
||||
export function UsagePage() {
|
||||
const [days, setDays] = useState(30);
|
||||
const [tenant, setTenant] = useState(""); // "" = 全平台
|
||||
const [report, setReport] = useState<UsageReport | null>(null);
|
||||
const [tenantOpts, setTenantOpts] = useState<UsageTenantSum[]>([]); // 下拉选项(来自全平台口径)
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
// 发放积分 Modal
|
||||
const [grantTenant, setGrantTenant] = useState<TenantRow | null>(null);
|
||||
const [allTenants, setAllTenants] = useState<TenantRow[]>([]);
|
||||
|
||||
|
||||
const load = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const { from, to } = rangeFor(days);
|
||||
const r = await adminUsage({ tenant: tenant || undefined, from, to });
|
||||
setReport(r);
|
||||
if (!tenant && r.tenants) setTenantOpts(r.tenants); // 全平台口径顺带刷新下拉
|
||||
setErr("");
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 预载租户列表用于发放积分 Modal
|
||||
listTenants().then(setAllTenants).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [days, tenant]);
|
||||
|
||||
|
||||
const series = useMemo(() => (report ? fillDays(report.trend, days) : []), [report, days]);
|
||||
|
||||
const openGrant = (r: UsageTenantSum) => {
|
||||
const found = allTenants.find((t) => t.id === r.tenant_id);
|
||||
if (found) {
|
||||
setGrantTenant(found);
|
||||
} else {
|
||||
// 如果全载列表还没到,直接用 UsageTenantSum 构造一个临时对象
|
||||
setGrantTenant({
|
||||
id: r.tenant_id, name: r.name, slug: "", plan: "free",
|
||||
status: "active", credit_balance_micro: r.balance_micro,
|
||||
shared_billing: false, members: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (loading) return <div className="text-sm text-gray-400">加载用量数据中…</div>;
|
||||
if (err) return <div className="text-sm text-rose-500">用量加载失败:{err}</div>;
|
||||
if (!report) return null;
|
||||
|
||||
const t = report.totals;
|
||||
const selectedName = tenant ? tenantOpts.find((x) => x.tenant_id === tenant)?.name ?? tenant : "";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 发放积分 Modal */}
|
||||
<GrantCreditsModal tenant={grantTenant} onClose={() => setGrantTenant(null)} onDone={() => void load()} />
|
||||
|
||||
{/* 本页只做「用量」观测。模型/计价、支付渠道/订单流水均按域收口到「系统配置」页。 */}
|
||||
|
||||
{/* 观测端:用量结果 */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<h3 className="text-sm font-semibold text-gray-700">用量观测</h3>
|
||||
<span className="text-[11px] text-gray-400">按规则折算后的实际消耗</span>
|
||||
</div>
|
||||
|
||||
{/* 顶栏:租户筛选 + 区间 + 刷新 */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={tenant}
|
||||
onChange={(e) => setTenant(e.target.value)}
|
||||
className="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-sm text-gray-700 shadow-sm focus:border-violet-400 focus:outline-none"
|
||||
>
|
||||
<option value="">全平台(所有租户)</option>
|
||||
{tenantOpts.map((o) => (
|
||||
<option key={o.tenant_id} value={o.tenant_id}>
|
||||
{o.name || o.tenant_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex overflow-hidden rounded-lg border border-gray-200 text-xs">
|
||||
{[7, 30].map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDays(d)}
|
||||
className={`px-3 py-1.5 ${days === d ? "bg-violet-600 text-white" : "bg-white text-gray-500 hover:bg-gray-50"}`}
|
||||
>
|
||||
近 {d} 天
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span>
|
||||
{mmdd(report.from)} ~ {mmdd(report.to)} · {tenant ? "单租户口径" : "全平台口径"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => void load()}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 rounded border border-gray-200 px-2.5 py-1 text-gray-500 hover:bg-gray-50 disabled:opacity-40"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M23 4v6h-6 M1 20v-6h6 M3.51 9a9 9 0 0 1 14.85-3.36L23 10 M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
{refreshing ? "刷新中" : "刷新"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI 卡片 */}
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<Metric label="积分消耗" tone="violet" value={credits(t.credits_micro)} sub={`区间合计 · ${int(t.task_count)} 次运行`} />
|
||||
<Metric label="Token 用量" tone="cyan" value={int(t.total_tok)} sub="prompt + completion(估算)" />
|
||||
<Metric label="估算成本" tone="amber" value={money(t.cost_micros)} sub="按定价折算(配置币种)" />
|
||||
{tenant ? (
|
||||
<Metric label="当前积分余额" tone={(report.balance_micro ?? 0) >= 0 ? "emerald" : "rose"} value={credits(report.balance_micro ?? 0)} sub={selectedName || "该租户"} />
|
||||
) : (
|
||||
<Metric label="活跃租户" tone="emerald" value={int(tenantOpts.length)} sub="区间内有用量的租户数" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 积分消耗趋势(按天) */}
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-700">积分消耗趋势</h4>
|
||||
<span className="text-[11px] text-gray-400">每日 credits · 悬停看明细</span>
|
||||
</div>
|
||||
<TrendBars series={series} />
|
||||
</div>
|
||||
|
||||
{/* 全平台:各租户用量排行 */}
|
||||
{!tenant && (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold text-gray-700">各租户用量排行</h4>
|
||||
<span className="rounded bg-violet-50 px-2 py-0.5 text-[10px] font-medium text-violet-700">按积分消耗</span>
|
||||
</div>
|
||||
{tenantOpts.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-gray-400">区间内暂无用量</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||
<th className="py-2 pr-3 font-medium">租户</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">积分消耗</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">Token</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">成本</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">运行数</th>
|
||||
<th className="py-2 pr-3 text-right font-medium">当前余额</th>
|
||||
<th className="py-2 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenantOpts.map((r) => (
|
||||
<tr key={r.tenant_id} className="border-b border-gray-50 last:border-0">
|
||||
<td className="py-2 pr-3">
|
||||
<button onClick={() => setTenant(r.tenant_id)} className="font-medium text-violet-600 hover:underline">
|
||||
{r.name || r.tenant_id}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">{credits(r.credits_micro)}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{int(r.total_tok)}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{money(r.cost_micros)}</td>
|
||||
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{int(r.task_count)}</td>
|
||||
<td className={`py-2 pr-3 text-right tabular-nums ${r.balance_micro >= 0 ? "text-gray-800" : "text-rose-500"}`}>{credits(r.balance_micro)}</td>
|
||||
<td className="py-2 text-right">
|
||||
<button
|
||||
onClick={() => openGrant(r)}
|
||||
className="rounded border border-violet-200 px-2.5 py-1 text-xs text-violet-600 hover:bg-violet-50"
|
||||
>
|
||||
充值
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TONE: Record<string, string> = {
|
||||
violet: "text-violet-600",
|
||||
cyan: "text-cyan-600",
|
||||
amber: "text-amber-600",
|
||||
emerald: "text-emerald-600",
|
||||
rose: "text-rose-500",
|
||||
};
|
||||
|
||||
function Metric({ label, value, sub, tone }: { label: string; value: ReactNode; sub?: ReactNode; tone: string }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-100 bg-white p-4 shadow-sm">
|
||||
<div className="text-xs text-gray-400">{label}</div>
|
||||
<div className={`mt-1 text-2xl font-semibold tabular-nums ${TONE[tone] ?? "text-gray-800"}`}>{value}</div>
|
||||
{sub && <div className="mt-1 text-[11px] text-gray-400">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrendBars({ series }: { series: UsageDay[] }) {
|
||||
const max = Math.max(1, ...series.map((d) => d.credits_micro));
|
||||
if (series.every((d) => d.credits_micro === 0)) {
|
||||
return <div className="py-8 text-center text-xs text-gray-400">区间内暂无用量</div>;
|
||||
}
|
||||
return (
|
||||
<div className="flex h-40 items-end gap-1">
|
||||
{series.map((d) => {
|
||||
const h = (d.credits_micro / max) * 100;
|
||||
return (
|
||||
<div key={d.day} className="group relative flex h-full flex-1 flex-col items-center justify-end">
|
||||
<div
|
||||
className="w-full rounded-t bg-violet-400 transition-colors group-hover:bg-violet-600"
|
||||
style={{ height: `${Math.max(d.credits_micro > 0 ? 4 : 0, h)}%` }}
|
||||
/>
|
||||
{/* tooltip */}
|
||||
<div className="pointer-events-none absolute bottom-full mb-1 hidden whitespace-nowrap rounded bg-gray-800 px-2 py-1 text-[10px] text-white group-hover:block">
|
||||
{mmdd(d.day)} · {credits(d.credits_micro)} 积分 · {int(d.task_count)} 次
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user