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:
2026-05-12 15:41:30 +08:00
co-authored by Claude Opus 4.7
parent ddc32466c2
commit 1796f04fa8
4 changed files with 155 additions and 0 deletions
@@ -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>
)
}
+3
View File
@@ -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'>
+4
View File
@@ -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).",
+4
View File
@@ -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)。",