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 { RechargeFormCard } from './components/recharge-form-card'
import { SubscriptionPlansCard } from './components/subscription-plans-card' import { SubscriptionPlansCard } from './components/subscription-plans-card'
import { WalletStatsCard } from './components/wallet-stats-card' import { WalletStatsCard } from './components/wallet-stats-card'
import { HeicodeUsageCard } from './components/heicode-usage-card'
import { DEFAULT_DISCOUNT_RATE } from './constants' import { DEFAULT_DISCOUNT_RATE } from './constants'
import { import {
useTopupInfo, useTopupInfo,
@@ -264,6 +265,8 @@ export function Wallet(props: WalletProps) {
<div className='mx-auto flex w-full max-w-7xl flex-col gap-4'> <div className='mx-auto flex w-full max-w-7xl flex-col gap-4'>
<WalletStatsCard user={user} loading={userLoading} /> <WalletStatsCard user={user} loading={userLoading} />
<HeicodeUsageCard />
<SubscriptionPlansCard topupInfo={topupInfo} /> <SubscriptionPlansCard topupInfo={topupInfo} />
<div className='grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(340px,0.4fr)] xl:items-start'> <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 Vendors": "Manage Vendors",
"Manage your API keys for accessing the service": "Manage your API keys for accessing the service", "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", "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", "Manage your security settings and account access": "Manage your security settings and account access",
"Manual Disabled": "Manual Disabled", "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).", "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 Vendors": "管理供应商",
"Manage your API keys for accessing the service": "管理您用于访问服务的 API 密钥", "Manage your API keys for accessing the service": "管理您用于访问服务的 API 密钥",
"Manage your balance and payment methods": "管理您的余额和付款方式", "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": "管理您的安全设置和账户访问", "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).": "将用户信息响应中的字段映射到本地用户属性。支持嵌套路径(例如 ocs.data.id)。", "Map fields from the user info response to local user attributes. Supports nested paths (e.g. ocs.data.id).": "将用户信息响应中的字段映射到本地用户属性。支持嵌套路径(例如 ocs.data.id)。",