feat(web): wallet shows mcp-server §4 usage + recent requests
Add HeicodeUsageCard between balance and subscription plans: - 14-day usage sparkline from /api/user/heicode/usage - Last 6 requests from /api/user/heicode/logs Both endpoints come from the product-package §4 contract (mcp-server), so the figures match what the desktop sidecar sees. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Activity, ListChecks } from 'lucide-react'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { formatCompactNumber, formatQuota } from '@/lib/format'
|
||||
import { getHeicodeUsage, getHeicodeLogs } from '@/lib/heicode-mcp'
|
||||
|
||||
type UsageRow = {
|
||||
date?: string
|
||||
total_quota?: number
|
||||
total_tokens?: number
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
count?: number
|
||||
}
|
||||
|
||||
type LogRow = {
|
||||
created_at?: number | string
|
||||
model_name?: string
|
||||
token_name?: string
|
||||
prompt_tokens?: number
|
||||
completion_tokens?: number
|
||||
quota?: number
|
||||
type?: number | string
|
||||
}
|
||||
|
||||
function pickNumber(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string') {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function formatLogTime(value: number | string | undefined): string {
|
||||
if (value == null) return '-'
|
||||
const n = typeof value === 'number' ? value : Number(value)
|
||||
const ms = Number.isFinite(n) ? (n > 1e12 ? n : n * 1000) : Date.parse(String(value))
|
||||
if (!Number.isFinite(ms)) return '-'
|
||||
const d = new Date(ms)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
export function HeicodeUsageCard() {
|
||||
const { t } = useTranslation()
|
||||
const [usage, setUsage] = useState<UsageRow[] | null>(null)
|
||||
const [logs, setLogs] = useState<LogRow[] | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
Promise.allSettled([getHeicodeUsage(14), getHeicodeLogs(10, 1)])
|
||||
.then(([u, l]) => {
|
||||
if (cancelled) return
|
||||
setUsage(u.status === 'fulfilled' ? (u.value as UsageRow[]) : [])
|
||||
setLogs(l.status === 'fulfilled' ? (l.value as LogRow[]) : [])
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const maxQuota = useMemo(() => {
|
||||
if (!usage || usage.length === 0) return 0
|
||||
return usage.reduce((m, row) => Math.max(m, pickNumber(row.total_quota)), 0)
|
||||
}, [usage])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='mt-3 h-24 w-full' />
|
||||
</div>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<Skeleton className='h-4 w-32' />
|
||||
<Skeleton className='mt-3 h-24 w-full' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const usageEmpty = !usage || usage.length === 0
|
||||
const logsEmpty = !logs || logs.length === 0
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Activity className='size-4 text-muted-foreground' />
|
||||
<h3 className='text-sm font-medium'>{t('Recent Usage (14d)')}</h3>
|
||||
</div>
|
||||
{usageEmpty ? (
|
||||
<p className='text-muted-foreground mt-3 text-xs'>{t('No usage yet')}</p>
|
||||
) : (
|
||||
<div className='mt-3 flex items-end gap-1' style={{ height: 64 }}>
|
||||
{usage!.map((row, i) => {
|
||||
const q = pickNumber(row.total_quota)
|
||||
const h = maxQuota > 0 ? Math.max(2, (q / maxQuota) * 60) : 2
|
||||
return (
|
||||
<div
|
||||
key={`${row.date ?? i}`}
|
||||
title={`${row.date ?? ''} · ${formatQuota(q)}`}
|
||||
className='bg-primary/70 hover:bg-primary flex-1 rounded-sm transition-colors'
|
||||
style={{ height: `${h}px` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border p-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<ListChecks className='size-4 text-muted-foreground' />
|
||||
<h3 className='text-sm font-medium'>{t('Recent Requests')}</h3>
|
||||
</div>
|
||||
{logsEmpty ? (
|
||||
<p className='text-muted-foreground mt-3 text-xs'>{t('No requests yet')}</p>
|
||||
) : (
|
||||
<ul className='divide-border/60 mt-2 divide-y text-xs'>
|
||||
{logs!.slice(0, 6).map((row, i) => (
|
||||
<li key={i} className='flex items-center justify-between gap-2 py-1.5'>
|
||||
<span className='truncate font-mono'>{row.model_name ?? '-'}</span>
|
||||
<span className='text-muted-foreground shrink-0 tabular-nums'>
|
||||
{formatCompactNumber(pickNumber(row.prompt_tokens) + pickNumber(row.completion_tokens))}t · {formatQuota(pickNumber(row.quota))}
|
||||
</span>
|
||||
<span className='text-muted-foreground shrink-0 tabular-nums'>
|
||||
{formatLogTime(row.created_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { TransferDialog } from './components/dialogs/transfer-dialog'
|
||||
import { RechargeFormCard } from './components/recharge-form-card'
|
||||
import { SubscriptionPlansCard } from './components/subscription-plans-card'
|
||||
import { WalletStatsCard } from './components/wallet-stats-card'
|
||||
import { HeicodeUsageCard } from './components/heicode-usage-card'
|
||||
import { DEFAULT_DISCOUNT_RATE } from './constants'
|
||||
import {
|
||||
useTopupInfo,
|
||||
@@ -264,6 +265,8 @@ export function Wallet(props: WalletProps) {
|
||||
<div className='mx-auto flex w-full max-w-7xl flex-col gap-4'>
|
||||
<WalletStatsCard user={user} loading={userLoading} />
|
||||
|
||||
<HeicodeUsageCard />
|
||||
|
||||
<SubscriptionPlansCard topupInfo={topupInfo} />
|
||||
|
||||
<div className='grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(340px,0.4fr)] xl:items-start'>
|
||||
|
||||
@@ -2050,6 +2050,10 @@
|
||||
"Manage Vendors": "Manage Vendors",
|
||||
"Manage your API keys for accessing the service": "Manage your API keys for accessing the service",
|
||||
"Manage your balance and payment methods": "Manage your balance and payment methods",
|
||||
"Recent Usage (14d)": "Recent Usage (14d)",
|
||||
"No usage yet": "No usage yet",
|
||||
"Recent Requests": "Recent Requests",
|
||||
"No requests yet": "No requests yet",
|
||||
"Manage your security settings and account access": "Manage your security settings and account access",
|
||||
"Manual Disabled": "Manual Disabled",
|
||||
"Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).": "Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).",
|
||||
|
||||
@@ -2050,6 +2050,10 @@
|
||||
"Manage Vendors": "管理供应商",
|
||||
"Manage your API keys for accessing the service": "管理您用于访问服务的 API 密钥",
|
||||
"Manage your balance and payment methods": "管理您的余额和付款方式",
|
||||
"Recent Usage (14d)": "近 14 天用量",
|
||||
"No usage yet": "暂无用量",
|
||||
"Recent Requests": "最近请求",
|
||||
"No requests yet": "暂无请求",
|
||||
"Manage your security settings and account access": "管理您的安全设置和账户访问",
|
||||
"Manual Disabled": "手动禁用",
|
||||
"Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).": "将用户信息响应中的字段映射到本地用户属性。支持嵌套路径(例如 ocs.data.id)。",
|
||||
|
||||
Reference in New Issue
Block a user