feat: 添加交易历史、错误提示和打包优化
1. 交易历史记录功能 - 新增 useTransactionHistory hook 管理交易记录 - 新增 TransactionHistory 组件显示历史 - 交易记录保存到 localStorage 2. 错误处理和用户提示 - 新增 Toast 通知组件 - 交易提交/成功/失败时显示提示 - 解析并显示友好的错误信息 3. 打包优化 - 配置代码分割 (manualChunks) - 分离 react/web3/walletconnect/i18n - 提高 chunk 大小警告阈值 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -647,3 +647,180 @@ body {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
animation: slideIn 0.3s ease;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
background: #e8f5e9;
|
||||
color: #2e7d32;
|
||||
border-left: 4px solid #4caf50;
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
border-left: 4px solid #f44336;
|
||||
}
|
||||
|
||||
.toast-warning {
|
||||
background: #fff3e0;
|
||||
color: #e65100;
|
||||
border-left: 4px solid #ff9800;
|
||||
}
|
||||
|
||||
.toast-info {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
border-left: 4px solid #2196f3;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.toast-message {
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Transaction History */
|
||||
.tx-history {
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.tx-history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.tx-history-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.tx-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tx-item {
|
||||
background: #f9f9f9;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #ddd;
|
||||
}
|
||||
|
||||
.tx-item.tx-success {
|
||||
border-left-color: #4caf50;
|
||||
}
|
||||
|
||||
.tx-item.tx-failed {
|
||||
border-left-color: #f44336;
|
||||
}
|
||||
|
||||
.tx-item.tx-pending {
|
||||
border-left-color: #ff9800;
|
||||
}
|
||||
|
||||
.tx-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tx-type {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tx-amount {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.tx-time {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tx-hash {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tx-hash a {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tx-hash a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.tx-status {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.tx-status.success {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.tx-status.failed {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.tx-status.pending {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.tx-error {
|
||||
font-size: 12px;
|
||||
color: #c62828;
|
||||
margin-top: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import { LanguageSwitch } from './components/LanguageSwitch'
|
||||
import { WUSDPanel } from './components/WUSDPanel'
|
||||
import { VaultPanel } from './components/VaultPanel'
|
||||
import { FactoryPanel } from './components/FactoryPanel'
|
||||
import { ToastProvider } from './components/Toast'
|
||||
import { TransactionProvider } from './context/TransactionContext'
|
||||
import './App.css'
|
||||
|
||||
type Tab = 'wusd' | 'vault' | 'factory'
|
||||
@@ -65,7 +67,11 @@ function App() {
|
||||
return (
|
||||
<WagmiProvider config={config}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContent />
|
||||
<ToastProvider>
|
||||
<TransactionProvider>
|
||||
<AppContent />
|
||||
</TransactionProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
|
||||
import { parseUnits, formatUnits } from 'viem'
|
||||
import { CONTRACTS, FACTORY_ABI, VAULT_ABI } from '../config/contracts'
|
||||
import { CONTRACTS, FACTORY_ABI } from '../config/contracts'
|
||||
|
||||
export function FactoryPanel() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
70
frontend/src/components/Toast.tsx
Normal file
70
frontend/src/components/Toast.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect, createContext, useContext } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
type: ToastType
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (type: ToastType, message: string, duration?: number) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | null>(null)
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const showToast = (type: ToastType, message: string, duration = 4000) => {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
setToasts(prev => [...prev, { id, type, message, duration }])
|
||||
}
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
<div className="toast-container">
|
||||
{toasts.map(toast => (
|
||||
<ToastItem key={toast.id} toast={toast} onRemove={removeToast} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
onRemove(toast.id)
|
||||
}, toast.duration || 4000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toast.id, toast.duration, onRemove])
|
||||
|
||||
return (
|
||||
<div className={`toast toast-${toast.type}`} onClick={() => onRemove(toast.id)}>
|
||||
<span className="toast-icon">
|
||||
{toast.type === 'success' && '✓'}
|
||||
{toast.type === 'error' && '✗'}
|
||||
{toast.type === 'warning' && '⚠'}
|
||||
{toast.type === 'info' && 'ℹ'}
|
||||
</span>
|
||||
<span className="toast-message">{toast.message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
88
frontend/src/components/TransactionHistory.tsx
Normal file
88
frontend/src/components/TransactionHistory.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TransactionRecord } from '../hooks/useTransactionHistory'
|
||||
|
||||
interface Props {
|
||||
transactions: TransactionRecord[]
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export function TransactionHistory({ transactions, onClear }: Props) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
mint: 'Mint WUSD',
|
||||
burn: 'Burn WUSD',
|
||||
buy: 'Buy YT',
|
||||
sell: 'Sell YT',
|
||||
approve: 'Approve',
|
||||
create_vault: 'Create Vault',
|
||||
update_price: 'Update Price',
|
||||
test: 'Test',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
const getStatusClass = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success': return 'tx-success'
|
||||
case 'failed': return 'tx-failed'
|
||||
default: return 'tx-pending'
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const shortenHash = (hash: string) => {
|
||||
return `${hash.slice(0, 8)}...${hash.slice(-6)}`
|
||||
}
|
||||
|
||||
if (transactions.length === 0) {
|
||||
return (
|
||||
<div className="tx-history">
|
||||
<div className="tx-history-header">
|
||||
<h3>{t('history.title')}</h3>
|
||||
</div>
|
||||
<p className="text-muted">{t('history.empty')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tx-history">
|
||||
<div className="tx-history-header">
|
||||
<h3>{t('history.title')}</h3>
|
||||
<button onClick={onClear} className="btn btn-sm btn-secondary">
|
||||
{t('history.clear')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tx-list">
|
||||
{transactions.slice(0, 10).map((tx) => (
|
||||
<div key={tx.id} className={`tx-item ${getStatusClass(tx.status)}`}>
|
||||
<div className="tx-info">
|
||||
<span className="tx-type">{getTypeLabel(tx.type)}</span>
|
||||
{tx.amount && <span className="tx-amount">{tx.amount} {tx.token}</span>}
|
||||
<span className="tx-time">{formatTime(tx.timestamp)}</span>
|
||||
</div>
|
||||
<div className="tx-hash">
|
||||
<a
|
||||
href={`https://sepolia.arbiscan.io/tx/${tx.hash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{shortenHash(tx.hash)}
|
||||
</a>
|
||||
<span className={`tx-status ${tx.status}`}>
|
||||
{tx.status === 'success' ? '✓' : tx.status === 'failed' ? '✗' : '...'}
|
||||
</span>
|
||||
</div>
|
||||
{tx.error && <div className="tx-error">{tx.error}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
|
||||
import { parseUnits, formatUnits, maxUint256 } from 'viem'
|
||||
import { CONTRACTS, VAULT_ABI, WUSD_ABI, FACTORY_ABI } from '../config/contracts'
|
||||
import { useTransactions } from '../context/TransactionContext'
|
||||
import type { TransactionType } from '../context/TransactionContext'
|
||||
import { useToast } from './Toast'
|
||||
import { TransactionHistory } from './TransactionHistory'
|
||||
|
||||
const VAULTS = [
|
||||
{ name: 'YT-A', address: CONTRACTS.VAULTS.YT_A },
|
||||
@@ -13,12 +17,15 @@ const VAULTS = [
|
||||
export function VaultPanel() {
|
||||
const { t } = useTranslation()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { transactions, addTransaction, updateTransaction, clearHistory } = useTransactions()
|
||||
const { showToast } = useToast()
|
||||
const [selectedVault, setSelectedVault] = useState(VAULTS[0])
|
||||
const [buyAmount, setBuyAmount] = useState('')
|
||||
const [sellAmount, setSellAmount] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'buy' | 'sell'>('buy')
|
||||
const [showBoundaryTest, setShowBoundaryTest] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ type: 'success' | 'error' | 'pending', msg: string } | null>(null)
|
||||
const pendingTxRef = useRef<{ id: string; type: TransactionType; amount?: string } | null>(null)
|
||||
|
||||
const { data: vaultInfo, refetch: refetchVaultInfo } = useReadContract({
|
||||
address: selectedVault.address as `0x${string}`,
|
||||
@@ -98,14 +105,28 @@ export function VaultPanel() {
|
||||
functionName: 'owner',
|
||||
})
|
||||
|
||||
const { writeContract, data: hash, isPending, reset } = useWriteContract()
|
||||
const { writeContract, data: hash, isPending, reset, error: writeError } = useWriteContract()
|
||||
|
||||
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
|
||||
const { isLoading: isConfirming, isSuccess, isError, error: txError } = useWaitForTransactionReceipt({
|
||||
hash,
|
||||
})
|
||||
|
||||
// 处理交易提交
|
||||
useEffect(() => {
|
||||
if (hash && pendingTxRef.current) {
|
||||
updateTransaction(pendingTxRef.current.id, { hash, status: 'pending' })
|
||||
showToast('info', t('toast.txSubmitted'))
|
||||
}
|
||||
}, [hash])
|
||||
|
||||
// 处理交易成功
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
if (pendingTxRef.current) {
|
||||
updateTransaction(pendingTxRef.current.id, { status: 'success' })
|
||||
showToast('success', t('toast.txSuccess'))
|
||||
pendingTxRef.current = null
|
||||
}
|
||||
refetchVaultInfo()
|
||||
refetchYtBalance()
|
||||
refetchWusdBalance()
|
||||
@@ -116,8 +137,61 @@ export function VaultPanel() {
|
||||
}
|
||||
}, [isSuccess])
|
||||
|
||||
// 处理交易失败
|
||||
useEffect(() => {
|
||||
if (isError && pendingTxRef.current) {
|
||||
updateTransaction(pendingTxRef.current.id, {
|
||||
status: 'failed',
|
||||
error: txError?.message || 'Transaction failed'
|
||||
})
|
||||
showToast('error', t('toast.txFailed'))
|
||||
pendingTxRef.current = null
|
||||
}
|
||||
}, [isError])
|
||||
|
||||
// 处理写入错误
|
||||
useEffect(() => {
|
||||
if (writeError) {
|
||||
const errorMsg = parseError(writeError)
|
||||
showToast('error', errorMsg)
|
||||
if (pendingTxRef.current) {
|
||||
updateTransaction(pendingTxRef.current.id, { status: 'failed', error: errorMsg })
|
||||
pendingTxRef.current = null
|
||||
}
|
||||
}
|
||||
}, [writeError])
|
||||
|
||||
// 解析错误信息
|
||||
const parseError = (error: any): string => {
|
||||
const msg = error?.message || error?.toString() || 'Unknown error'
|
||||
if (msg.includes('User rejected') || msg.includes('user rejected')) {
|
||||
return t('toast.userRejected')
|
||||
}
|
||||
if (msg.includes('insufficient funds') || msg.includes('InsufficientBalance')) {
|
||||
return t('toast.insufficientBalance')
|
||||
}
|
||||
// 提取合约错误
|
||||
const match = msg.match(/error[:\s]+(\w+)/i)
|
||||
if (match) return match[1]
|
||||
return msg.slice(0, 100)
|
||||
}
|
||||
|
||||
// 记录交易
|
||||
const recordTx = (type: TransactionType, amount?: string, token?: string) => {
|
||||
const id = addTransaction({
|
||||
type,
|
||||
hash: '',
|
||||
status: 'pending',
|
||||
amount,
|
||||
token,
|
||||
vault: selectedVault.name,
|
||||
})
|
||||
pendingTxRef.current = { id, type, amount }
|
||||
}
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!buyAmount) return
|
||||
recordTx('approve', buyAmount, 'WUSD')
|
||||
writeContract({
|
||||
address: CONTRACTS.WUSD,
|
||||
abi: WUSD_ABI,
|
||||
@@ -128,6 +202,7 @@ export function VaultPanel() {
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (!buyAmount) return
|
||||
recordTx('buy', buyAmount, 'WUSD')
|
||||
writeContract({
|
||||
address: selectedVault.address as `0x${string}`,
|
||||
abi: VAULT_ABI,
|
||||
@@ -138,6 +213,7 @@ export function VaultPanel() {
|
||||
|
||||
const handleSell = async () => {
|
||||
if (!sellAmount) return
|
||||
recordTx('sell', sellAmount, 'YT')
|
||||
writeContract({
|
||||
address: selectedVault.address as `0x${string}`,
|
||||
abi: VAULT_ABI,
|
||||
@@ -499,6 +575,9 @@ export function VaultPanel() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 交易历史 */}
|
||||
<TransactionHistory transactions={transactions} onClear={clearHistory} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAccount, useReadContract, useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
|
||||
import { parseUnits, formatUnits } from 'viem'
|
||||
import { CONTRACTS, WUSD_ABI } from '../config/contracts'
|
||||
import { useTransactions } from '../context/TransactionContext'
|
||||
import type { TransactionType } from '../context/TransactionContext'
|
||||
import { useToast } from './Toast'
|
||||
import { TransactionHistory } from './TransactionHistory'
|
||||
|
||||
export function WUSDPanel() {
|
||||
const { t } = useTranslation()
|
||||
const { address, isConnected } = useAccount()
|
||||
const { transactions, addTransaction, updateTransaction, clearHistory } = useTransactions()
|
||||
const { showToast } = useToast()
|
||||
const [mintAmount, setMintAmount] = useState('')
|
||||
const [showBoundaryTest, setShowBoundaryTest] = useState(false)
|
||||
const pendingTxRef = useRef<{ id: string; type: TransactionType; amount?: string } | null>(null)
|
||||
|
||||
const { data: balance, refetch: refetchBalance } = useReadContract({
|
||||
address: CONTRACTS.WUSD,
|
||||
@@ -29,67 +36,126 @@ export function WUSDPanel() {
|
||||
functionName: 'decimals',
|
||||
})
|
||||
|
||||
const { writeContract, data: hash, isPending } = useWriteContract()
|
||||
const { writeContract, data: hash, isPending, error: writeError } = useWriteContract()
|
||||
|
||||
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
|
||||
const { isLoading: isConfirming, isSuccess, isError, error: txError } = useWaitForTransactionReceipt({
|
||||
hash,
|
||||
})
|
||||
|
||||
// 处理交易提交
|
||||
useEffect(() => {
|
||||
if (hash && pendingTxRef.current) {
|
||||
updateTransaction(pendingTxRef.current.id, { hash, status: 'pending' })
|
||||
showToast('info', t('toast.txSubmitted'))
|
||||
}
|
||||
}, [hash])
|
||||
|
||||
// 处理交易成功
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
if (pendingTxRef.current) {
|
||||
updateTransaction(pendingTxRef.current.id, { status: 'success' })
|
||||
showToast('success', t('toast.txSuccess'))
|
||||
pendingTxRef.current = null
|
||||
}
|
||||
refetchBalance()
|
||||
setMintAmount('')
|
||||
}
|
||||
}, [isSuccess])
|
||||
|
||||
// 处理交易失败
|
||||
useEffect(() => {
|
||||
if (isError && pendingTxRef.current) {
|
||||
updateTransaction(pendingTxRef.current.id, {
|
||||
status: 'failed',
|
||||
error: txError?.message || 'Transaction failed'
|
||||
})
|
||||
showToast('error', t('toast.txFailed'))
|
||||
pendingTxRef.current = null
|
||||
}
|
||||
}, [isError])
|
||||
|
||||
// 处理写入错误
|
||||
useEffect(() => {
|
||||
if (writeError) {
|
||||
const errorMsg = parseError(writeError)
|
||||
showToast('error', errorMsg)
|
||||
if (pendingTxRef.current) {
|
||||
updateTransaction(pendingTxRef.current.id, { status: 'failed', error: errorMsg })
|
||||
pendingTxRef.current = null
|
||||
}
|
||||
}
|
||||
}, [writeError])
|
||||
|
||||
const parseError = (error: any): string => {
|
||||
const msg = error?.message || error?.toString() || 'Unknown error'
|
||||
if (msg.includes('User rejected') || msg.includes('user rejected')) {
|
||||
return t('toast.userRejected')
|
||||
}
|
||||
if (msg.includes('insufficient funds') || msg.includes('InsufficientBalance')) {
|
||||
return t('toast.insufficientBalance')
|
||||
}
|
||||
const match = msg.match(/error[:\s]+(\w+)/i)
|
||||
if (match) return match[1]
|
||||
return msg.slice(0, 100)
|
||||
}
|
||||
|
||||
const recordTx = (type: TransactionType, amount?: string, token?: string) => {
|
||||
const id = addTransaction({
|
||||
type,
|
||||
hash: '',
|
||||
status: 'pending',
|
||||
amount,
|
||||
token,
|
||||
})
|
||||
pendingTxRef.current = { id, type, amount }
|
||||
}
|
||||
|
||||
const handleMint = async () => {
|
||||
if (!address || !mintAmount || !decimals) return
|
||||
|
||||
try {
|
||||
writeContract({
|
||||
address: CONTRACTS.WUSD,
|
||||
abi: WUSD_ABI,
|
||||
functionName: 'mint',
|
||||
args: [address, parseUnits(mintAmount, decimals)],
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Mint error:', error)
|
||||
}
|
||||
recordTx('mint', mintAmount, 'WUSD')
|
||||
writeContract({
|
||||
address: CONTRACTS.WUSD,
|
||||
abi: WUSD_ABI,
|
||||
functionName: 'mint',
|
||||
args: [address, parseUnits(mintAmount, decimals)],
|
||||
})
|
||||
}
|
||||
|
||||
// 边界测试函数
|
||||
const runBoundaryTest = (testType: string) => {
|
||||
if (!address || !decimals) return
|
||||
try {
|
||||
switch (testType) {
|
||||
case 'mint_zero':
|
||||
writeContract({
|
||||
address: CONTRACTS.WUSD,
|
||||
abi: WUSD_ABI,
|
||||
functionName: 'mint',
|
||||
args: [address, BigInt(0)],
|
||||
})
|
||||
break
|
||||
case 'burn_exceed':
|
||||
const exceedAmount = balance ? balance + parseUnits('999999', decimals) : parseUnits('999999999', decimals)
|
||||
writeContract({
|
||||
address: CONTRACTS.WUSD,
|
||||
abi: WUSD_ABI,
|
||||
functionName: 'burn',
|
||||
args: [address, exceedAmount],
|
||||
})
|
||||
break
|
||||
case 'mint_10000':
|
||||
writeContract({
|
||||
address: CONTRACTS.WUSD,
|
||||
abi: WUSD_ABI,
|
||||
functionName: 'mint',
|
||||
args: [address, parseUnits('10000', decimals)],
|
||||
})
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Test error:', err)
|
||||
recordTx('test', undefined, 'WUSD')
|
||||
switch (testType) {
|
||||
case 'mint_zero':
|
||||
writeContract({
|
||||
address: CONTRACTS.WUSD,
|
||||
abi: WUSD_ABI,
|
||||
functionName: 'mint',
|
||||
args: [address, BigInt(0)],
|
||||
})
|
||||
break
|
||||
case 'burn_exceed':
|
||||
const exceedAmount = balance ? balance + parseUnits('999999', decimals) : parseUnits('999999999', decimals)
|
||||
writeContract({
|
||||
address: CONTRACTS.WUSD,
|
||||
abi: WUSD_ABI,
|
||||
functionName: 'burn',
|
||||
args: [address, exceedAmount],
|
||||
})
|
||||
break
|
||||
case 'mint_10000':
|
||||
pendingTxRef.current!.amount = '10000'
|
||||
writeContract({
|
||||
address: CONTRACTS.WUSD,
|
||||
abi: WUSD_ABI,
|
||||
functionName: 'mint',
|
||||
args: [address, parseUnits('10000', decimals)],
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (isSuccess) {
|
||||
refetchBalance()
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="panel">
|
||||
@@ -134,10 +200,6 @@ export function WUSDPanel() {
|
||||
{isPending ? t('wusd.confirming') : isConfirming ? t('wusd.minting') : t('wusd.mint')}
|
||||
</button>
|
||||
|
||||
{isSuccess && (
|
||||
<p className="success-text">{t('wusd.mintSuccess')}</p>
|
||||
)}
|
||||
|
||||
{/* 边界测试区域 */}
|
||||
<div className="section">
|
||||
<div className="section-header" onClick={() => setShowBoundaryTest(!showBoundaryTest)} style={{ cursor: 'pointer' }}>
|
||||
@@ -180,6 +242,9 @@ export function WUSDPanel() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 交易历史 */}
|
||||
<TransactionHistory transactions={transactions} onClear={clearHistory} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
33
frontend/src/context/TransactionContext.tsx
Normal file
33
frontend/src/context/TransactionContext.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useTransactionHistory } from '../hooks/useTransactionHistory'
|
||||
import type { TransactionRecord, TransactionType } from '../hooks/useTransactionHistory'
|
||||
|
||||
interface TransactionContextType {
|
||||
transactions: TransactionRecord[]
|
||||
addTransaction: (tx: Omit<TransactionRecord, 'id' | 'timestamp'>) => string
|
||||
updateTransaction: (id: string, updates: Partial<TransactionRecord>) => void
|
||||
clearHistory: () => void
|
||||
}
|
||||
|
||||
const TransactionContext = createContext<TransactionContextType | null>(null)
|
||||
|
||||
export function TransactionProvider({ children }: { children: ReactNode }) {
|
||||
const history = useTransactionHistory()
|
||||
|
||||
return (
|
||||
<TransactionContext.Provider value={history}>
|
||||
{children}
|
||||
</TransactionContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTransactions() {
|
||||
const context = useContext(TransactionContext)
|
||||
if (!context) {
|
||||
throw new Error('useTransactions must be used within TransactionProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export type { TransactionType, TransactionRecord }
|
||||
82
frontend/src/hooks/useTransactionHistory.ts
Normal file
82
frontend/src/hooks/useTransactionHistory.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export type TransactionType = 'mint' | 'burn' | 'buy' | 'sell' | 'approve' | 'create_vault' | 'update_price' | 'test'
|
||||
|
||||
export interface TransactionRecord {
|
||||
id: string
|
||||
type: TransactionType
|
||||
hash: string
|
||||
timestamp: number
|
||||
status: 'pending' | 'success' | 'failed'
|
||||
amount?: string
|
||||
token?: string
|
||||
vault?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'yt_asset_tx_history'
|
||||
const MAX_RECORDS = 50
|
||||
|
||||
export function useTransactionHistory() {
|
||||
const [transactions, setTransactions] = useState<TransactionRecord[]>([])
|
||||
|
||||
// 从 localStorage 加载历史记录
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
setTransactions(JSON.parse(stored))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load transaction history:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 保存到 localStorage
|
||||
const saveToStorage = (records: TransactionRecord[]) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(records.slice(0, MAX_RECORDS)))
|
||||
} catch (e) {
|
||||
console.error('Failed to save transaction history:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加新交易
|
||||
const addTransaction = (tx: Omit<TransactionRecord, 'id' | 'timestamp'>) => {
|
||||
const newTx: TransactionRecord = {
|
||||
...tx,
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
setTransactions(prev => {
|
||||
const updated = [newTx, ...prev].slice(0, MAX_RECORDS)
|
||||
saveToStorage(updated)
|
||||
return updated
|
||||
})
|
||||
return newTx.id
|
||||
}
|
||||
|
||||
// 更新交易状态
|
||||
const updateTransaction = (id: string, updates: Partial<TransactionRecord>) => {
|
||||
setTransactions(prev => {
|
||||
const updated = prev.map(tx =>
|
||||
tx.id === id ? { ...tx, ...updates } : tx
|
||||
)
|
||||
saveToStorage(updated)
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
// 清空历史记录
|
||||
const clearHistory = () => {
|
||||
setTransactions([])
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
transactions,
|
||||
addTransaction,
|
||||
updateTransaction,
|
||||
clearHistory,
|
||||
}
|
||||
}
|
||||
@@ -88,6 +88,22 @@
|
||||
"en": "English",
|
||||
"zh": "Chinese"
|
||||
},
|
||||
"history": {
|
||||
"title": "Transaction History",
|
||||
"empty": "No transactions yet",
|
||||
"clear": "Clear",
|
||||
"viewMore": "View More"
|
||||
},
|
||||
"toast": {
|
||||
"txSubmitted": "Transaction submitted",
|
||||
"txSuccess": "Transaction successful",
|
||||
"txFailed": "Transaction failed",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"walletError": "Wallet error",
|
||||
"networkError": "Network error",
|
||||
"insufficientBalance": "Insufficient balance",
|
||||
"userRejected": "User rejected the transaction"
|
||||
},
|
||||
"test": {
|
||||
"title": "Boundary Test",
|
||||
"currentStatus": "Current Status",
|
||||
|
||||
@@ -88,6 +88,22 @@
|
||||
"en": "英文",
|
||||
"zh": "中文"
|
||||
},
|
||||
"history": {
|
||||
"title": "交易记录",
|
||||
"empty": "暂无交易记录",
|
||||
"clear": "清空",
|
||||
"viewMore": "查看更多"
|
||||
},
|
||||
"toast": {
|
||||
"txSubmitted": "交易已提交",
|
||||
"txSuccess": "交易成功",
|
||||
"txFailed": "交易失败",
|
||||
"copySuccess": "已复制到剪贴板",
|
||||
"walletError": "钱包错误",
|
||||
"networkError": "网络错误",
|
||||
"insufficientBalance": "余额不足",
|
||||
"userRejected": "用户取消了交易"
|
||||
},
|
||||
"test": {
|
||||
"title": "边界测试",
|
||||
"currentStatus": "当前状态",
|
||||
|
||||
@@ -9,5 +9,26 @@ export default defineConfig({
|
||||
host: '0.0.0.0',
|
||||
strictPort: true,
|
||||
allowedHosts: ['maxfight.vip', 'localhost', '127.0.0.1'],
|
||||
}
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
// React 相关
|
||||
'react-vendor': ['react', 'react-dom'],
|
||||
// Web3 相关
|
||||
'web3-vendor': ['wagmi', 'viem', '@tanstack/react-query'],
|
||||
// WalletConnect 相关
|
||||
'walletconnect': [
|
||||
'@web3modal/wagmi',
|
||||
'@walletconnect/ethereum-provider',
|
||||
],
|
||||
// i18n
|
||||
'i18n': ['react-i18next', 'i18next', 'i18next-browser-languagedetector'],
|
||||
},
|
||||
},
|
||||
},
|
||||
// 提高 chunk 大小警告阈值
|
||||
chunkSizeWarningLimit: 600,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user