384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { QRCodeSVG } from 'qrcode.react';
|
||
import styles from './Home.module.css';
|
||
import { deriveEvmAddress, formatAddress, getKavaExplorerUrl } from '../utils/address';
|
||
|
||
interface ShareItem {
|
||
id: string;
|
||
walletName: string;
|
||
publicKey: string;
|
||
threshold: { t: number; n: number };
|
||
createdAt: string;
|
||
lastUsedAt?: string;
|
||
participants?: Array<{ partyId: string; name: string }>;
|
||
}
|
||
|
||
interface ShareWithAddress extends ShareItem {
|
||
evmAddress?: string;
|
||
kavaBalance?: string;
|
||
balanceLoading?: boolean;
|
||
}
|
||
|
||
// Kava Testnet EVM RPC endpoint
|
||
const KAVA_TESTNET_RPC = 'https://evm.testnet.kava.io';
|
||
|
||
/**
|
||
* 获取 KAVA 代币余额
|
||
* @param address EVM 地址
|
||
* @returns 余额字符串 (格式化后的 KAVA 数量)
|
||
*/
|
||
async function fetchKavaBalance(address: string): Promise<string> {
|
||
try {
|
||
const response = await fetch(KAVA_TESTNET_RPC, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
jsonrpc: '2.0',
|
||
method: 'eth_getBalance',
|
||
params: [address, 'latest'],
|
||
id: 1,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
if (data.result) {
|
||
// 将 wei 转换为 KAVA (18 位小数)
|
||
const balanceWei = BigInt(data.result);
|
||
const balanceKava = Number(balanceWei) / 1e18;
|
||
// 格式化显示,最多 6 位小数
|
||
return balanceKava.toFixed(balanceKava < 0.000001 && balanceKava > 0 ? 8 : 6).replace(/\.?0+$/, '') || '0';
|
||
}
|
||
return '0';
|
||
} catch (error) {
|
||
console.error('Failed to fetch KAVA balance:', error);
|
||
return '0';
|
||
}
|
||
}
|
||
|
||
export default function Home() {
|
||
const navigate = useNavigate();
|
||
const [shares, setShares] = useState<ShareWithAddress[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [selectedShare, setSelectedShare] = useState<ShareWithAddress | null>(null);
|
||
const [showQrModal, setShowQrModal] = useState(false);
|
||
|
||
const deriveAddresses = useCallback(async (shareList: ShareItem[]): Promise<ShareWithAddress[]> => {
|
||
const sharesWithAddresses: ShareWithAddress[] = [];
|
||
for (const share of shareList) {
|
||
try {
|
||
const evmAddress = await deriveEvmAddress(share.publicKey);
|
||
sharesWithAddresses.push({ ...share, evmAddress, balanceLoading: true });
|
||
} catch (error) {
|
||
console.warn(`Failed to derive address for share ${share.id}:`, error);
|
||
sharesWithAddresses.push({ ...share });
|
||
}
|
||
}
|
||
return sharesWithAddresses;
|
||
}, []);
|
||
|
||
// 单独获取所有钱包的余额
|
||
const fetchAllBalances = useCallback(async (sharesWithAddrs: ShareWithAddress[]) => {
|
||
const updatedShares = await Promise.all(
|
||
sharesWithAddrs.map(async (share) => {
|
||
if (share.evmAddress) {
|
||
const kavaBalance = await fetchKavaBalance(share.evmAddress);
|
||
return { ...share, kavaBalance, balanceLoading: false };
|
||
}
|
||
return { ...share, balanceLoading: false };
|
||
})
|
||
);
|
||
setShares(updatedShares);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadShares();
|
||
}, []);
|
||
|
||
const loadShares = async () => {
|
||
try {
|
||
let shareList: ShareItem[] = [];
|
||
|
||
// 检测是否在 Electron 环境中
|
||
if (window.electronAPI) {
|
||
const result = await window.electronAPI.storage.listShares();
|
||
if (result.success && result.data) {
|
||
shareList = result.data as ShareItem[];
|
||
}
|
||
} else {
|
||
// 浏览器环境,使用 localStorage 或 API
|
||
const stored = localStorage.getItem('shares');
|
||
if (stored) {
|
||
shareList = JSON.parse(stored);
|
||
}
|
||
}
|
||
|
||
// 为每个 share 派生 EVM 地址
|
||
const sharesWithAddresses = await deriveAddresses(shareList);
|
||
setShares(sharesWithAddresses);
|
||
|
||
// 异步获取所有余额 (不阻塞 UI)
|
||
fetchAllBalances(sharesWithAddresses);
|
||
} catch (error) {
|
||
console.error('Failed to load shares:', error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleExport = async (id: string) => {
|
||
const password = window.prompt('请输入密码以导出备份文件:');
|
||
if (!password) return;
|
||
|
||
try {
|
||
if (window.electronAPI) {
|
||
// 先让用户选择保存位置
|
||
const savePath = await window.electronAPI.dialog.saveFile(
|
||
`share-backup-${id.slice(0, 8)}.dat`,
|
||
[{ name: 'Share Backup', extensions: ['dat'] }]
|
||
);
|
||
|
||
if (!savePath) return;
|
||
|
||
const result = await window.electronAPI.storage.exportShare(id, password);
|
||
if (result.success && result.data) {
|
||
// 使用 file:write IPC 写入文件到用户选择的路径
|
||
const dataArray = result.data instanceof ArrayBuffer ? new Uint8Array(result.data) : result.data;
|
||
const writeResult = await window.electronAPI.file.write(savePath, dataArray);
|
||
if (writeResult.success) {
|
||
alert('备份文件导出成功!');
|
||
} else {
|
||
alert('写入文件失败: ' + (writeResult.error || '未知错误'));
|
||
}
|
||
} else {
|
||
alert('导出失败: ' + (result.error || '未知错误'));
|
||
}
|
||
}
|
||
} catch (error) {
|
||
alert('导出失败: ' + (error as Error).message);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: string) => {
|
||
const confirmed = window.confirm('确定要删除这个钱包吗?删除后无法恢复,请确保已备份。');
|
||
if (!confirmed) return;
|
||
|
||
try {
|
||
if (window.electronAPI) {
|
||
const result = await window.electronAPI.storage.deleteShare(id);
|
||
if (result.success) {
|
||
setShares(shares.filter((s) => s.id !== id));
|
||
} else {
|
||
alert('删除失败: ' + (result.error || '未知错误'));
|
||
}
|
||
}
|
||
} catch (error) {
|
||
alert('删除失败: ' + (error as Error).message);
|
||
}
|
||
};
|
||
|
||
const handleShowQr = (share: ShareWithAddress) => {
|
||
setSelectedShare(share);
|
||
setShowQrModal(true);
|
||
};
|
||
|
||
const handleCopyAddress = (address: string) => {
|
||
navigator.clipboard.writeText(address);
|
||
alert('地址已复制到剪贴板');
|
||
};
|
||
|
||
const formatDate = (dateStr: string) => {
|
||
return new Date(dateStr).toLocaleDateString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
});
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className={styles.loading}>
|
||
<div className={styles.spinner} />
|
||
<p>加载中...</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
<header className={styles.header}>
|
||
<h1 className={styles.title}>我的共管钱包</h1>
|
||
<p className={styles.subtitle}>管理您参与的多方共管钱包 (3-of-5 混合托管)</p>
|
||
</header>
|
||
|
||
{shares.length === 0 ? (
|
||
<div className={styles.empty}>
|
||
<div className={styles.emptyIcon}>🔐</div>
|
||
<h2 className={styles.emptyTitle}>暂无共管钱包</h2>
|
||
<p className={styles.emptyText}>
|
||
您可以创建新的共管钱包,或加入他人发起的钱包创建
|
||
</p>
|
||
<div className={styles.emptyActions}>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => navigate('/create')}
|
||
>
|
||
创建钱包
|
||
</button>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={() => navigate('/join')}
|
||
>
|
||
加入创建
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className={styles.grid}>
|
||
{shares.map((share) => (
|
||
<div key={share.id} className={styles.card}>
|
||
<div className={styles.cardHeader}>
|
||
<h3 className={styles.cardTitle}>{share.walletName}</h3>
|
||
<span className={styles.threshold}>
|
||
{share.threshold.t}-of-{share.threshold.n}
|
||
</span>
|
||
</div>
|
||
<div className={styles.cardBody}>
|
||
{/* 地址区域 - 可点击显示二维码 */}
|
||
{share.evmAddress && (
|
||
<div
|
||
className={styles.addressSection}
|
||
onClick={() => handleShowQr(share)}
|
||
>
|
||
<div className={styles.qrPreview}>
|
||
<QRCodeSVG
|
||
value={share.evmAddress}
|
||
size={80}
|
||
level="M"
|
||
includeMargin={false}
|
||
/>
|
||
</div>
|
||
<div className={styles.addressInfo}>
|
||
<span className={styles.addressLabel}>Kava EVM 地址</span>
|
||
<code className={styles.addressValue}>
|
||
{formatAddress(share.evmAddress)}
|
||
</code>
|
||
<span className={styles.addressHint}>点击查看完整二维码</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* KAVA 余额显示 */}
|
||
{share.evmAddress && (
|
||
<div className={styles.balanceSection}>
|
||
<span className={styles.balanceLabel}>KAVA 余额</span>
|
||
<span className={styles.balanceValue}>
|
||
{share.balanceLoading ? (
|
||
<span className={styles.balanceLoading}>加载中...</span>
|
||
) : (
|
||
<>{share.kavaBalance || '0'} KAVA</>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className={styles.infoRow}>
|
||
<span className={styles.infoLabel}>参与方</span>
|
||
<span className={styles.infoValue}>
|
||
{(share.participants || []).length || share.threshold.n} 人
|
||
</span>
|
||
</div>
|
||
<div className={styles.infoRow}>
|
||
<span className={styles.infoLabel}>创建时间</span>
|
||
<span className={styles.infoValue}>
|
||
{formatDate(share.createdAt)}
|
||
</span>
|
||
</div>
|
||
{share.lastUsedAt && (
|
||
<div className={styles.infoRow}>
|
||
<span className={styles.infoLabel}>上次使用</span>
|
||
<span className={styles.infoValue}>
|
||
{formatDate(share.lastUsedAt)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className={styles.cardFooter}>
|
||
<button
|
||
className={`${styles.actionButton} ${styles.transferButton}`}
|
||
onClick={() => navigate(`/transfer?shareId=${share.id}`)}
|
||
>
|
||
转账
|
||
</button>
|
||
<button
|
||
className={styles.actionButton}
|
||
onClick={() => handleExport(share.id)}
|
||
>
|
||
导出备份
|
||
</button>
|
||
<button
|
||
className={`${styles.actionButton} ${styles.dangerButton}`}
|
||
onClick={() => handleDelete(share.id)}
|
||
>
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* 二维码弹窗 */}
|
||
{showQrModal && selectedShare && (
|
||
<div className={styles.modalOverlay} onClick={() => setShowQrModal(false)}>
|
||
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>{selectedShare.walletName}</h2>
|
||
<button
|
||
className={styles.modalClose}
|
||
onClick={() => setShowQrModal(false)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
<div className={styles.modalBody}>
|
||
<div className={styles.qrContainer}>
|
||
<QRCodeSVG
|
||
value={selectedShare.evmAddress || ''}
|
||
size={240}
|
||
level="H"
|
||
includeMargin={true}
|
||
/>
|
||
</div>
|
||
<div className={styles.fullAddress}>
|
||
<span className={styles.addressLabelLarge}>Kava EVM 地址</span>
|
||
<code className={styles.addressValueLarge}>
|
||
{selectedShare.evmAddress}
|
||
</code>
|
||
</div>
|
||
<div className={styles.modalActions}>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => handleCopyAddress(selectedShare.evmAddress || '')}
|
||
>
|
||
复制地址
|
||
</button>
|
||
<a
|
||
href={getKavaExplorerUrl(selectedShare.evmAddress || '', true)}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className={styles.secondaryButton}
|
||
>
|
||
在区块浏览器查看
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|