Files
heicode-win/heicode/web/default/src/features/wallet/index.tsx
T
chenchenandClaude Opus 4.7 1796f04fa8 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>
2026-05-12 15:41:30 +08:00

356 lines
12 KiB
TypeScript
Vendored

import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { getSelf } from '@/lib/api'
import { useStatus } from '@/hooks/use-status'
import { useSystemConfig } from '@/hooks/use-system-config'
import { getHeicodeBalance } from '@/lib/heicode-mcp'
import { SectionPageLayout } from '@/components/layout'
import { AffiliateRewardsCard } from './components/affiliate-rewards-card'
import { BillingHistoryDialog } from './components/dialogs/billing-history-dialog'
import { CreemConfirmDialog } from './components/dialogs/creem-confirm-dialog'
import { PaymentConfirmDialog } from './components/dialogs/payment-confirm-dialog'
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,
usePayment,
useAffiliate,
useRedemption,
useCreemPayment,
useWaffoPayment,
useWaffoPancakePayment,
} from './hooks'
import {
getDefaultPaymentType,
getMinTopupAmount,
isWaffoPancakePayment,
} from './lib'
import type {
UserWalletData,
PaymentMethod,
PresetAmount,
CreemProduct,
} from './types'
interface WalletProps {
initialShowHistory?: boolean
}
export function Wallet(props: WalletProps) {
const { t } = useTranslation()
const [user, setUser] = useState<UserWalletData | null>(null)
const [userLoading, setUserLoading] = useState(true)
const [topupAmount, setTopupAmount] = useState(0)
const [selectedPreset, setSelectedPreset] = useState<number | null>(null)
const [selectedPaymentMethod, setSelectedPaymentMethod] =
useState<PaymentMethod>()
const [paymentLoading, setPaymentLoading] = useState<string | null>(null)
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false)
const [transferDialogOpen, setTransferDialogOpen] = useState(false)
const [billingDialogOpen, setBillingDialogOpen] = useState(false)
const [redemptionCode, setRedemptionCode] = useState('')
const [creemDialogOpen, setCreemDialogOpen] = useState(false)
const [selectedCreemProduct, setSelectedCreemProduct] =
useState<CreemProduct | null>(null)
const { status } = useStatus()
const { currency } = useSystemConfig()
const { topupInfo, presetAmounts, loading: topupLoading } = useTopupInfo()
// Calculate effective exchange rate - when display type is USD, use rate of 1
const effectiveUsdExchangeRate = useMemo(() => {
return currency?.quotaDisplayType === 'USD'
? 1
: currency?.usdExchangeRate || 1
}, [currency?.quotaDisplayType, currency?.usdExchangeRate])
const {
amount: paymentAmount,
calculating,
processing,
calculatePaymentAmount,
processPayment,
} = usePayment()
const {
affiliateLink,
loading: affiliateLoading,
transferQuota,
transferring,
} = useAffiliate()
const { redeeming, redeemCode } = useRedemption()
const { processing: creemProcessing, processCreemPayment } = useCreemPayment()
const { processWaffoPayment } = useWaffoPayment()
const { processing: pancakeProcessing, processWaffoPancakePayment } =
useWaffoPancakePayment()
// Fetch and refresh user data.
// Per docs/product-package/13 §6 "模型与余额" + Heicode-接口契约文档 §4,
// the canonical balance source is mcp-server /api/user/heicode/balance
// (server-side wraps NewAPI admin token, returns the freshest figures).
// Fall back to NewAPI /api/user/self when the user hasn't yet been
// mirrored into NewAPI via from-agnet (HEICODE_USER_NOT_FOUND) so a
// brand-new account still sees something instead of empty stats.
const fetchUser = useCallback(async () => {
try {
setUserLoading(true)
const heicodeBalance = await getHeicodeBalance().catch(() => null)
if (heicodeBalance) {
setUser({
id: heicodeBalance.heicodeUserId,
username: heicodeBalance.username,
email: heicodeBalance.email,
display_name: heicodeBalance.displayName,
group: heicodeBalance.group,
status: heicodeBalance.status,
quota: heicodeBalance.quota,
used_quota: heicodeBalance.usedQuota,
request_count: heicodeBalance.requestCount,
} as UserWalletData)
return
}
const response = await getSelf()
if (response.success && response.data) {
setUser(response.data as UserWalletData)
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Failed to fetch user data:', error)
} finally {
setUserLoading(false)
}
}, [])
useEffect(() => {
fetchUser()
}, [fetchUser])
useEffect(() => {
if (props.initialShowHistory) {
setBillingDialogOpen(true)
window.history.replaceState({}, '', window.location.pathname)
}
}, [props.initialShowHistory])
// Initialize topup amount when topup info is loaded
useEffect(() => {
if (topupInfo && topupAmount === 0) {
const minTopup = getMinTopupAmount(topupInfo)
setTopupAmount(minTopup)
// Calculate initial payment amount with default payment type
const defaultPaymentType = getDefaultPaymentType(topupInfo)
calculatePaymentAmount(minTopup, defaultPaymentType)
}
}, [topupInfo, topupAmount, calculatePaymentAmount])
// Get current payment type (selected or default)
const getCurrentPaymentType = useCallback(() => {
return selectedPaymentMethod?.type || getDefaultPaymentType(topupInfo)
}, [selectedPaymentMethod, topupInfo])
// Handle preset selection
const handleSelectPreset = (preset: PresetAmount) => {
setTopupAmount(preset.value)
setSelectedPreset(preset.value)
calculatePaymentAmount(preset.value, getCurrentPaymentType())
}
// Handle topup amount change
const handleTopupAmountChange = (amount: number) => {
setTopupAmount(amount)
setSelectedPreset(null)
calculatePaymentAmount(amount, getCurrentPaymentType())
}
// Handle payment method selection
const handlePaymentMethodSelect = async (method: PaymentMethod) => {
setSelectedPaymentMethod(method)
setPaymentLoading(method.type)
try {
// Validate minimum topup
const minTopup = getMinTopupAmount(topupInfo)
if (topupAmount < minTopup) {
return
}
// Calculate payment amount and show confirmation dialog
await calculatePaymentAmount(topupAmount, method.type)
setConfirmDialogOpen(true)
} finally {
setPaymentLoading(null)
}
}
// Handle payment confirmation
const handlePaymentConfirm = async () => {
if (!selectedPaymentMethod) return
const isPancake = isWaffoPancakePayment(selectedPaymentMethod.type)
const success = isPancake
? await processWaffoPancakePayment(topupAmount)
: await processPayment(topupAmount, selectedPaymentMethod.type)
if (success) {
setConfirmDialogOpen(false)
await fetchUser()
}
}
// Handle redemption
const handleRedeem = async () => {
if (!redemptionCode) return
const success = await redeemCode(redemptionCode)
if (success) {
setRedemptionCode('')
await fetchUser()
}
}
// Handle transfer
const handleTransfer = async (amount: number) => {
const success = await transferQuota(amount)
if (success) {
await fetchUser()
}
return success
}
// Handle Creem product selection
const handleCreemProductSelect = (product: CreemProduct) => {
setSelectedCreemProduct(product)
setCreemDialogOpen(true)
}
// Handle Creem payment confirmation
const handleCreemConfirm = async () => {
if (!selectedCreemProduct) return
const success = await processCreemPayment(selectedCreemProduct.productId)
if (success) {
setCreemDialogOpen(false)
setSelectedCreemProduct(null)
await fetchUser()
}
}
const handleWaffoMethodSelect = async (_method: unknown, index: number) => {
const loadingKey = `waffo-${index}`
setPaymentLoading(loadingKey)
try {
await processWaffoPayment(topupAmount, index)
} finally {
setPaymentLoading(null)
}
}
// Get discount rate for current topup amount
const getDiscountRate = useCallback(() => {
return topupInfo?.discount?.[topupAmount] || DEFAULT_DISCOUNT_RATE
}, [topupInfo, topupAmount])
return (
<>
<SectionPageLayout>
<SectionPageLayout.Title>{t('Wallet')}</SectionPageLayout.Title>
<SectionPageLayout.Description>
{t('Manage your balance and payment methods')}
</SectionPageLayout.Description>
<SectionPageLayout.Content>
<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'>
<div className='min-w-0'>
<RechargeFormCard
topupInfo={topupInfo}
presetAmounts={presetAmounts}
selectedPreset={selectedPreset}
onSelectPreset={handleSelectPreset}
topupAmount={topupAmount}
onTopupAmountChange={handleTopupAmountChange}
paymentAmount={paymentAmount}
calculating={calculating}
onPaymentMethodSelect={handlePaymentMethodSelect}
paymentLoading={paymentLoading}
redemptionCode={redemptionCode}
onRedemptionCodeChange={setRedemptionCode}
onRedeem={handleRedeem}
redeeming={redeeming}
topupLink={topupInfo?.topup_link}
loading={topupLoading}
priceRatio={(status?.price as number) || 1}
usdExchangeRate={effectiveUsdExchangeRate}
onOpenBilling={() => setBillingDialogOpen(true)}
creemProducts={topupInfo?.creem_products}
enableCreemTopup={topupInfo?.enable_creem_topup}
onCreemProductSelect={handleCreemProductSelect}
enableWaffoTopup={topupInfo?.enable_waffo_topup}
waffoPayMethods={topupInfo?.waffo_pay_methods}
waffoMinTopup={topupInfo?.waffo_min_topup}
onWaffoMethodSelect={handleWaffoMethodSelect}
enableWaffoPancakeTopup={
topupInfo?.enable_waffo_pancake_topup
}
/>
</div>
<div className='xl:sticky xl:top-6'>
<AffiliateRewardsCard
user={user}
affiliateLink={affiliateLink}
onTransfer={() => setTransferDialogOpen(true)}
loading={affiliateLoading}
/>
</div>
</div>
</div>
</SectionPageLayout.Content>
</SectionPageLayout>
<PaymentConfirmDialog
open={confirmDialogOpen}
onOpenChange={setConfirmDialogOpen}
onConfirm={handlePaymentConfirm}
topupAmount={topupAmount}
paymentAmount={paymentAmount}
paymentMethod={selectedPaymentMethod}
calculating={calculating}
processing={processing || pancakeProcessing}
discountRate={getDiscountRate()}
usdExchangeRate={effectiveUsdExchangeRate}
/>
<TransferDialog
open={transferDialogOpen}
onOpenChange={setTransferDialogOpen}
onConfirm={handleTransfer}
availableQuota={user?.aff_quota ?? 0}
transferring={transferring}
/>
<BillingHistoryDialog
open={billingDialogOpen}
onOpenChange={setBillingDialogOpen}
/>
<CreemConfirmDialog
open={creemDialogOpen}
onOpenChange={setCreemDialogOpen}
onConfirm={handleCreemConfirm}
product={selectedCreemProduct}
processing={creemProcessing}
/>
</>
)
}