rwadurian/backend/mpc-system/services/service-party-app/src/pages/Home.tsx

384 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}