@@ -0,0 +1,232 @@
import { useEffect , useMemo , useState , type ReactNode } from "react" ;
import { adminUsage , type UsageReport , type UsageTenantSum , type UsageDay } from "../api" ;
// 管理端「用量 & 计费」= SaaS P2 计量的观测面:全平台 / 单租户的 token · 积分 · 成本口径。
// 数据来自 /api/v1/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 ( "" ) ;
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 ( ( ) = > {
void load ( ) ;
// eslint-disable-next-line react-hooks/exhaustive-deps
} , [ days , tenant ] ) ;
const series = useMemo ( ( ) = > ( report ? fillDays ( report . trend , days ) : [ ] ) , [ report , days ] ) ;
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" >
{ /* 顶栏:租户筛选 + 区间 + 刷新 */ }
< 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 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 text-right tabular-nums ${ r . balance_micro >= 0 ? "text-gray-800" : "text-rose-500" } ` } > { credits ( r . balance_micro ) } < / 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 >
) ;
}