437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useNavigate, useParams } from 'react-router-dom';
|
||
import styles from './Sign.module.css';
|
||
|
||
interface ShareItem {
|
||
id: string;
|
||
walletName: string;
|
||
publicKey: string;
|
||
threshold: { t: number; n: number };
|
||
createdAt: string;
|
||
metadata: {
|
||
participants: Array<{ partyId: string; name: string }>;
|
||
};
|
||
}
|
||
|
||
interface SigningSession {
|
||
sessionId: string;
|
||
walletName: string;
|
||
messageHash: string;
|
||
threshold: { t: number; n: number };
|
||
currentParticipants: number;
|
||
initiator: string;
|
||
}
|
||
|
||
export default function Sign() {
|
||
const { sessionId: urlSessionId } = useParams<{ sessionId?: string }>();
|
||
const navigate = useNavigate();
|
||
|
||
const [step, setStep] = useState<'select' | 'join' | 'signing' | 'completed' | 'failed'>('select');
|
||
const [shares, setShares] = useState<ShareItem[]>([]);
|
||
const [selectedShare, setSelectedShare] = useState<ShareItem | null>(null);
|
||
const [sessionCode, setSessionCode] = useState(urlSessionId || '');
|
||
const [password, setPassword] = useState('');
|
||
const [signingSession, setSigningSession] = useState<SigningSession | null>(null);
|
||
const [signature, setSignature] = useState<string | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
||
|
||
// 加载本地保存的 shares
|
||
useEffect(() => {
|
||
loadShares();
|
||
}, []);
|
||
|
||
// 如果 URL 中有 sessionId,自动进入加入流程
|
||
useEffect(() => {
|
||
if (urlSessionId && shares.length > 0) {
|
||
setStep('join');
|
||
}
|
||
}, [urlSessionId, shares]);
|
||
|
||
const loadShares = async () => {
|
||
try {
|
||
if (window.electronAPI) {
|
||
const result = await window.electronAPI.storage.listShares();
|
||
if (result.success && result.data) {
|
||
setShares(result.data as ShareItem[]);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load shares:', error);
|
||
}
|
||
};
|
||
|
||
const handleImportShare = async () => {
|
||
try {
|
||
if (window.electronAPI) {
|
||
const filePath = await window.electronAPI.dialog.selectFile([
|
||
{ name: 'Share Backup', extensions: ['dat', 'json'] }
|
||
]);
|
||
if (filePath) {
|
||
const importPassword = window.prompt('请输入备份文件的密码:');
|
||
if (!importPassword) return;
|
||
|
||
const result = await window.electronAPI.storage.importShare(filePath, importPassword);
|
||
if (result.success && result.share) {
|
||
await loadShares();
|
||
setSelectedShare(result.share as ShareItem);
|
||
} else {
|
||
setError(result.error || '导入失败');
|
||
}
|
||
}
|
||
}
|
||
} catch (err) {
|
||
setError('导入失败: ' + (err as Error).message);
|
||
}
|
||
};
|
||
|
||
const handleValidateSession = async () => {
|
||
if (!sessionCode.trim()) {
|
||
setError('请输入签名会话码');
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const result = await window.electronAPI.grpc.validateSigningSession(sessionCode);
|
||
if (result.success && result.session) {
|
||
setSigningSession(result.session);
|
||
setStep('join');
|
||
} else {
|
||
setError(result.error || '无效的签名会话码');
|
||
}
|
||
} catch (err) {
|
||
setError('验证失败: ' + (err as Error).message);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleJoinSigning = async () => {
|
||
if (!selectedShare) {
|
||
setError('请选择要使用的钱包份额');
|
||
return;
|
||
}
|
||
// 密码是可选的,如果创建时没有设置密码,这里也不需要输入
|
||
|
||
setIsLoading(true);
|
||
setError(null);
|
||
setStep('signing');
|
||
|
||
try {
|
||
// 获取解密后的 share
|
||
const shareData = await window.electronAPI.storage.getShare(selectedShare.id, password);
|
||
if (!shareData) {
|
||
setError('密码错误或份额数据损坏');
|
||
setStep('join');
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
// 加入签名会话
|
||
const result = await window.electronAPI.grpc.joinSigningSession({
|
||
sessionId: signingSession!.sessionId,
|
||
shareId: selectedShare.id,
|
||
password: password,
|
||
});
|
||
|
||
if (result.success) {
|
||
// 订阅签名进度
|
||
window.electronAPI.grpc.subscribeSigningProgress(
|
||
signingSession!.sessionId,
|
||
(event) => {
|
||
if (event.type === 'progress') {
|
||
setProgress({ current: event.current || 0, total: event.total || 0 });
|
||
} else if (event.type === 'completed') {
|
||
setSignature(event.signature || '');
|
||
setStep('completed');
|
||
setIsLoading(false);
|
||
} else if (event.type === 'failed') {
|
||
setError(event.error || '签名失败');
|
||
setStep('failed');
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
);
|
||
} else {
|
||
setError(result.error || '加入签名会话失败');
|
||
setStep('join');
|
||
setIsLoading(false);
|
||
}
|
||
} catch (err) {
|
||
setError('签名过程出错: ' + (err as Error).message);
|
||
setStep('failed');
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCopySignature = async () => {
|
||
if (signature) {
|
||
try {
|
||
await navigator.clipboard.writeText(signature);
|
||
} catch (err) {
|
||
console.error('Failed to copy:', err);
|
||
}
|
||
}
|
||
};
|
||
|
||
const handlePaste = async () => {
|
||
try {
|
||
const text = await navigator.clipboard.readText();
|
||
setSessionCode(text.trim());
|
||
} catch (err) {
|
||
console.error('Failed to read clipboard:', err);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className={styles.container}>
|
||
<div className={styles.card}>
|
||
<h1 className={styles.title}>参与签名</h1>
|
||
<p className={styles.subtitle}>使用您的钱包份额参与多方签名</p>
|
||
|
||
{step === 'select' && (
|
||
<div className={styles.form}>
|
||
{/* 选择本地 Share */}
|
||
<div className={styles.section}>
|
||
<h3 className={styles.sectionTitle}>选择钱包份额</h3>
|
||
{shares.length === 0 ? (
|
||
<div className={styles.emptyShares}>
|
||
<p>暂无本地保存的钱包份额</p>
|
||
<button className={styles.linkButton} onClick={handleImportShare}>
|
||
导入备份文件
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className={styles.shareList}>
|
||
{shares.map((share) => (
|
||
<div
|
||
key={share.id}
|
||
className={`${styles.shareItem} ${selectedShare?.id === share.id ? styles.shareItemSelected : ''}`}
|
||
onClick={() => setSelectedShare(share)}
|
||
>
|
||
<div className={styles.shareInfo}>
|
||
<span className={styles.shareName}>{share.walletName}</span>
|
||
<span className={styles.shareThreshold}>
|
||
{share.threshold.t}-of-{share.threshold.n}
|
||
</span>
|
||
</div>
|
||
<code className={styles.shareKey}>
|
||
{share.publicKey.slice(0, 8)}...{share.publicKey.slice(-8)}
|
||
</code>
|
||
</div>
|
||
))}
|
||
<button className={styles.linkButton} onClick={handleImportShare}>
|
||
+ 导入其他备份文件
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 输入签名会话码 */}
|
||
<div className={styles.inputGroup}>
|
||
<label className={styles.label}>签名会话码</label>
|
||
<div className={styles.inputWrapper}>
|
||
<input
|
||
type="text"
|
||
value={sessionCode}
|
||
onChange={(e) => setSessionCode(e.target.value)}
|
||
placeholder="粘贴签名会话码或扫描二维码"
|
||
className={styles.input}
|
||
disabled={isLoading}
|
||
/>
|
||
<button
|
||
className={styles.pasteButton}
|
||
onClick={handlePaste}
|
||
disabled={isLoading}
|
||
>
|
||
粘贴
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <div className={styles.error}>{error}</div>}
|
||
|
||
<div className={styles.actions}>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={() => navigate('/')}
|
||
disabled={isLoading}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={handleValidateSession}
|
||
disabled={isLoading || !selectedShare || !sessionCode.trim()}
|
||
>
|
||
{isLoading ? '验证中...' : '下一步'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'join' && signingSession && (
|
||
<div className={styles.form}>
|
||
{/* 显示签名会话信息 */}
|
||
<div className={styles.sessionInfo}>
|
||
<h3 className={styles.sectionTitle}>签名会话信息</h3>
|
||
<div className={styles.infoGrid}>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>钱包名称</span>
|
||
<span className={styles.infoValue}>{signingSession.walletName}</span>
|
||
</div>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>发起者</span>
|
||
<span className={styles.infoValue}>{signingSession.initiator}</span>
|
||
</div>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>阈值</span>
|
||
<span className={styles.infoValue}>
|
||
{signingSession.threshold.t}-of-{signingSession.threshold.n}
|
||
</span>
|
||
</div>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>当前参与者</span>
|
||
<span className={styles.infoValue}>
|
||
{signingSession.currentParticipants} / {signingSession.threshold.t}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.messageHashSection}>
|
||
<span className={styles.infoLabel}>待签名消息哈希</span>
|
||
<code className={styles.messageHash}>{signingSession.messageHash}</code>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 已选择的 Share */}
|
||
<div className={styles.selectedShare}>
|
||
<span className={styles.infoLabel}>使用的钱包份额</span>
|
||
<div className={styles.shareItem}>
|
||
<div className={styles.shareInfo}>
|
||
<span className={styles.shareName}>{selectedShare?.walletName}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 输入密码 */}
|
||
<div className={styles.inputGroup}>
|
||
<label className={styles.label}>解锁密码</label>
|
||
<input
|
||
type="password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
placeholder="输入您的份额解锁密码"
|
||
className={styles.input}
|
||
disabled={isLoading}
|
||
/>
|
||
</div>
|
||
|
||
{error && <div className={styles.error}>{error}</div>}
|
||
|
||
<div className={styles.actions}>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={() => {
|
||
setStep('select');
|
||
setSigningSession(null);
|
||
setError(null);
|
||
}}
|
||
disabled={isLoading}
|
||
>
|
||
返回
|
||
</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={handleJoinSigning}
|
||
disabled={isLoading}
|
||
>
|
||
{isLoading ? '加入中...' : '参与签名'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'signing' && (
|
||
<div className={styles.signing}>
|
||
<div className={styles.spinner}></div>
|
||
<h3 className={styles.signingTitle}>签名进行中</h3>
|
||
<p className={styles.signingText}>
|
||
正在与其他参与方协同完成签名...
|
||
</p>
|
||
{progress.total > 0 && (
|
||
<div className={styles.progressSection}>
|
||
<div className={styles.progressBar}>
|
||
<div
|
||
className={styles.progressFill}
|
||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||
/>
|
||
</div>
|
||
<span className={styles.progressText}>
|
||
轮次 {progress.current} / {progress.total}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{step === 'completed' && signature && (
|
||
<div className={styles.form}>
|
||
<div className={styles.successIcon}>✓</div>
|
||
<h3 className={styles.successTitle}>签名完成</h3>
|
||
|
||
<div className={styles.signatureSection}>
|
||
<label className={styles.label}>签名结果</label>
|
||
<div className={styles.signatureWrapper}>
|
||
<code className={styles.signature}>{signature}</code>
|
||
<button className={styles.copyButton} onClick={handleCopySignature}>
|
||
复制
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles.actions}>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => navigate('/')}
|
||
>
|
||
返回首页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'failed' && (
|
||
<div className={styles.form}>
|
||
<div className={styles.failureIcon}>!</div>
|
||
<h3 className={styles.failureTitle}>签名失败</h3>
|
||
<p className={styles.failureText}>{error}</p>
|
||
|
||
<div className={styles.actions}>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={() => {
|
||
setStep('select');
|
||
setError(null);
|
||
}}
|
||
>
|
||
重试
|
||
</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => navigate('/')}
|
||
>
|
||
返回首页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|