281 lines
9.3 KiB
TypeScript
281 lines
9.3 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import styles from './Session.module.css';
|
|
|
|
interface Participant {
|
|
partyId: string;
|
|
name: string;
|
|
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
|
joinedAt: string;
|
|
}
|
|
|
|
interface SessionState {
|
|
sessionId: string;
|
|
walletName: string;
|
|
threshold: { t: number; n: number };
|
|
status: 'waiting' | 'ready' | 'processing' | 'completed' | 'failed';
|
|
participants: Participant[];
|
|
currentRound: number;
|
|
totalRounds: number;
|
|
publicKey?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export default function Session() {
|
|
const { sessionId } = useParams<{ sessionId: string }>();
|
|
const navigate = useNavigate();
|
|
|
|
const [session, setSession] = useState<SessionState | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchSessionStatus = useCallback(async () => {
|
|
if (!sessionId) return;
|
|
|
|
try {
|
|
const result = await window.electronAPI.grpc.getSessionStatus(sessionId);
|
|
if (result.success && result.session) {
|
|
setSession(result.session);
|
|
} else {
|
|
setError(result.error || '获取会话状态失败');
|
|
}
|
|
} catch (err) {
|
|
setError('获取会话状态失败');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [sessionId]);
|
|
|
|
useEffect(() => {
|
|
fetchSessionStatus();
|
|
|
|
// 订阅会话事件
|
|
const unsubscribe = window.electronAPI.grpc.subscribeSessionEvents(
|
|
sessionId!,
|
|
(event: any) => {
|
|
if (event.type === 'participant_joined') {
|
|
setSession(prev => prev ? {
|
|
...prev,
|
|
participants: [...prev.participants, event.participant]
|
|
} : null);
|
|
} else if (event.type === 'status_changed') {
|
|
setSession(prev => prev ? {
|
|
...prev,
|
|
status: event.status,
|
|
currentRound: event.currentRound ?? prev.currentRound,
|
|
} : null);
|
|
} else if (event.type === 'completed') {
|
|
setSession(prev => prev ? {
|
|
...prev,
|
|
status: 'completed',
|
|
publicKey: event.publicKey,
|
|
} : null);
|
|
// Auto-navigate to home after a short delay to show completion status
|
|
setTimeout(() => {
|
|
navigate('/');
|
|
}, 2000);
|
|
} else if (event.type === 'failed') {
|
|
setSession(prev => prev ? {
|
|
...prev,
|
|
status: 'failed',
|
|
error: event.error,
|
|
} : null);
|
|
}
|
|
}
|
|
);
|
|
|
|
return () => {
|
|
unsubscribe();
|
|
};
|
|
}, [sessionId, fetchSessionStatus]);
|
|
|
|
const getStatusText = (status: string) => {
|
|
switch (status) {
|
|
case 'waiting': return '等待参与方';
|
|
case 'ready': return '准备就绪';
|
|
case 'processing': return '密钥生成中';
|
|
case 'completed': return '已完成';
|
|
case 'failed': return '失败';
|
|
default: return status;
|
|
}
|
|
};
|
|
|
|
const getStatusClass = (status: string) => {
|
|
switch (status) {
|
|
case 'waiting': return styles.statusWaiting;
|
|
case 'ready': return styles.statusReady;
|
|
case 'processing': return styles.statusProcessing;
|
|
case 'completed': return styles.statusCompleted;
|
|
case 'failed': return styles.statusFailed;
|
|
default: return '';
|
|
}
|
|
};
|
|
|
|
const getParticipantStatusIcon = (status: string) => {
|
|
switch (status) {
|
|
case 'waiting': return '⏳';
|
|
case 'ready': return '✓';
|
|
case 'processing': return '⚡';
|
|
case 'completed': return '✓';
|
|
case 'failed': return '✗';
|
|
default: return '•';
|
|
}
|
|
};
|
|
|
|
const handleCopyPublicKey = async () => {
|
|
if (session?.publicKey) {
|
|
try {
|
|
await navigator.clipboard.writeText(session.publicKey);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className={styles.container}>
|
|
<div className={styles.loading}>
|
|
<div className={styles.spinner}></div>
|
|
<p>加载会话信息...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !session) {
|
|
return (
|
|
<div className={styles.container}>
|
|
<div className={styles.error}>
|
|
<div className={styles.errorIcon}>!</div>
|
|
<h3>加载失败</h3>
|
|
<p>{error || '无法获取会话信息'}</p>
|
|
<button className={styles.primaryButton} onClick={() => navigate('/')}>
|
|
返回首页
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={styles.container}>
|
|
<div className={styles.card}>
|
|
<div className={styles.header}>
|
|
<div>
|
|
<h1 className={styles.title}>{session.walletName}</h1>
|
|
<p className={styles.sessionId}>会话 ID: {session.sessionId}</p>
|
|
</div>
|
|
<span className={`${styles.status} ${getStatusClass(session.status)}`}>
|
|
{getStatusText(session.status)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className={styles.content}>
|
|
{/* 进度部分 */}
|
|
{session.status === 'processing' && (
|
|
<div className={styles.progress}>
|
|
<div className={styles.progressHeader}>
|
|
<span>密钥生成进度</span>
|
|
<span>{session.currentRound} / {session.totalRounds}</span>
|
|
</div>
|
|
<div className={styles.progressBar}>
|
|
<div
|
|
className={styles.progressFill}
|
|
style={{ width: `${(session.currentRound / session.totalRounds) * 100}%` }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 参与方列表 */}
|
|
<div className={styles.section}>
|
|
<h3 className={styles.sectionTitle}>
|
|
参与方 ({(session.participants || []).length} / {session.threshold?.n || 0})
|
|
</h3>
|
|
<div className={styles.participantList}>
|
|
{(session.participants || []).map((participant, index) => (
|
|
<div key={participant.partyId} className={styles.participant}>
|
|
<div className={styles.participantInfo}>
|
|
<span className={styles.participantIndex}>#{index + 1}</span>
|
|
<span className={styles.participantName}>{participant.name}</span>
|
|
</div>
|
|
<span className={`${styles.participantStatus} ${getStatusClass(participant.status)}`}>
|
|
{getParticipantStatusIcon(participant.status)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
{Array.from({ length: Math.max(0, (session.threshold?.n || 0) - (session.participants || []).length) }).map((_, index) => (
|
|
<div key={`empty-${index}`} className={`${styles.participant} ${styles.participantEmpty}`}>
|
|
<div className={styles.participantInfo}>
|
|
<span className={styles.participantIndex}>#{(session.participants || []).length + index + 1}</span>
|
|
<span className={styles.participantName}>等待加入...</span>
|
|
</div>
|
|
<span className={styles.participantStatus}>⏳</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 阈值信息 */}
|
|
{session.threshold && (
|
|
<div className={styles.section}>
|
|
<h3 className={styles.sectionTitle}>阈值设置</h3>
|
|
<div className={styles.thresholdInfo}>
|
|
<span className={styles.thresholdBadge}>
|
|
{session.threshold.t}-of-{session.threshold.n}
|
|
</span>
|
|
<span className={styles.thresholdText}>
|
|
需要 {session.threshold.t} 个参与方共同签名
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 完成状态 */}
|
|
{session.status === 'completed' && session.publicKey && (
|
|
<div className={styles.section}>
|
|
<h3 className={styles.sectionTitle}>钱包公钥</h3>
|
|
<div className={styles.publicKeyWrapper}>
|
|
<code className={styles.publicKey}>{session.publicKey}</code>
|
|
<button className={styles.copyButton} onClick={handleCopyPublicKey}>
|
|
复制
|
|
</button>
|
|
</div>
|
|
<p className={styles.successMessage}>
|
|
✓ 密钥份额已安全保存到本地
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* 失败状态 */}
|
|
{session.status === 'failed' && session.error && (
|
|
<div className={styles.section}>
|
|
<div className={styles.failureMessage}>
|
|
<span className={styles.failureIcon}>!</span>
|
|
<span>{session.error}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.footer}>
|
|
{session.status === 'completed' ? (
|
|
<button className={styles.primaryButton} onClick={() => navigate('/')}>
|
|
返回首页
|
|
</button>
|
|
) : session.status === 'failed' ? (
|
|
<button className={styles.primaryButton} onClick={() => navigate('/')}>
|
|
返回首页
|
|
</button>
|
|
) : (
|
|
<button className={styles.secondaryButton} onClick={() => navigate('/')}>
|
|
返回首页
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|