279 lines
8.5 KiB
TypeScript
279 lines
8.5 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { useParams, useNavigate } from 'react-router-dom';
|
||
import styles from './Join.module.css';
|
||
|
||
interface SessionInfo {
|
||
sessionId: string;
|
||
walletName: string;
|
||
threshold: { t: number; n: number };
|
||
initiator: string;
|
||
createdAt: string;
|
||
currentParticipants: number;
|
||
totalParticipants?: number;
|
||
}
|
||
|
||
// 生成默认参与者名称
|
||
function generateParticipantName(): string {
|
||
const timestamp = Date.now().toString(36).slice(-4);
|
||
return `参与者-${timestamp}`;
|
||
}
|
||
|
||
interface ValidateResult {
|
||
success: boolean;
|
||
error?: string;
|
||
sessionInfo?: SessionInfo;
|
||
joinToken?: string;
|
||
}
|
||
|
||
export default function Join() {
|
||
const { inviteCode } = useParams<{ inviteCode?: string }>();
|
||
const navigate = useNavigate();
|
||
|
||
const [code, setCode] = useState(inviteCode || '');
|
||
const [participantName] = useState(generateParticipantName());
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null);
|
||
const [joinToken, setJoinToken] = useState<string | null>(null);
|
||
const [partyId, setPartyId] = useState<string | null>(null);
|
||
const [step, setStep] = useState<'input' | 'confirm' | 'joining'>('input');
|
||
const [autoJoinAttempted, setAutoJoinAttempted] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (inviteCode) {
|
||
handleValidateCode(inviteCode);
|
||
}
|
||
}, [inviteCode]);
|
||
|
||
// 自动加入:验证成功后自动加入会话
|
||
useEffect(() => {
|
||
if (
|
||
step === 'confirm' &&
|
||
sessionInfo &&
|
||
joinToken &&
|
||
partyId &&
|
||
participantName &&
|
||
!autoJoinAttempted &&
|
||
!isLoading
|
||
) {
|
||
setAutoJoinAttempted(true);
|
||
handleJoinSession();
|
||
}
|
||
}, [step, sessionInfo, joinToken, partyId, participantName, autoJoinAttempted, isLoading]);
|
||
|
||
const handleValidateCode = async (codeToValidate: string) => {
|
||
if (!codeToValidate.trim()) {
|
||
setError('请输入邀请码');
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
// 获取当前 partyId
|
||
const partyResult = await window.electronAPI.grpc.getPartyId();
|
||
if (!partyResult.success || !partyResult.partyId) {
|
||
setError('请先连接到消息路由器');
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
setPartyId(partyResult.partyId);
|
||
|
||
// 解析邀请码获取会话信息
|
||
const result: ValidateResult = await window.electronAPI.grpc.validateInviteCode(codeToValidate);
|
||
if (result.success && result.sessionInfo) {
|
||
setSessionInfo(result.sessionInfo);
|
||
if (result.joinToken) {
|
||
setJoinToken(result.joinToken);
|
||
}
|
||
setStep('confirm');
|
||
} else {
|
||
setError(result.error || '无效的邀请码');
|
||
}
|
||
} catch (err) {
|
||
setError('验证邀请码失败,请检查网络连接');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleJoinSession = async () => {
|
||
if (!sessionInfo) {
|
||
setError('会话信息不完整');
|
||
return;
|
||
}
|
||
|
||
if (!partyId) {
|
||
setError('未获取到 Party ID,请重试');
|
||
return;
|
||
}
|
||
|
||
if (!joinToken) {
|
||
setError('未获取到加入令牌,请重新验证邀请码');
|
||
return;
|
||
}
|
||
|
||
setStep('joining');
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const result = await window.electronAPI.grpc.joinSession({
|
||
sessionId: sessionInfo.sessionId,
|
||
partyId: partyId,
|
||
joinToken: joinToken,
|
||
walletName: sessionInfo.walletName,
|
||
});
|
||
|
||
if (result.success) {
|
||
navigate(`/session/${sessionInfo.sessionId}`);
|
||
} else {
|
||
setError(result.error || '加入会话失败');
|
||
setStep('confirm');
|
||
}
|
||
} catch (err) {
|
||
setError('加入会话失败,请重试');
|
||
setStep('confirm');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const handlePaste = async () => {
|
||
try {
|
||
const text = await navigator.clipboard.readText();
|
||
setCode(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 === 'input' && (
|
||
<div className={styles.form}>
|
||
<div className={styles.inputGroup}>
|
||
<label className={styles.label}>邀请码</label>
|
||
<div className={styles.inputWrapper}>
|
||
<input
|
||
type="text"
|
||
value={code}
|
||
onChange={(e) => setCode(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={() => handleValidateCode(code)}
|
||
disabled={isLoading || !code.trim()}
|
||
>
|
||
{isLoading ? '验证中...' : '下一步'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{step === 'confirm' && sessionInfo && (
|
||
<div className={styles.form}>
|
||
<div className={styles.sessionInfo}>
|
||
<h3 className={styles.sessionTitle}>会话信息</h3>
|
||
<div className={styles.infoGrid}>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>钱包名称</span>
|
||
<span className={styles.infoValue}>{sessionInfo.walletName}</span>
|
||
</div>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>阈值设置</span>
|
||
<span className={styles.infoValue}>
|
||
{sessionInfo.threshold.t}-of-{sessionInfo.threshold.n}
|
||
</span>
|
||
</div>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>发起者</span>
|
||
<span className={styles.infoValue}>{sessionInfo.initiator}</span>
|
||
</div>
|
||
<div className={styles.infoItem}>
|
||
<span className={styles.infoLabel}>当前参与者</span>
|
||
<span className={styles.infoValue}>
|
||
{sessionInfo.currentParticipants} / {sessionInfo.threshold.n}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{error ? (
|
||
<>
|
||
<div className={styles.error}>{error}</div>
|
||
<div className={styles.actions}>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={() => {
|
||
setStep('input');
|
||
setSessionInfo(null);
|
||
setJoinToken(null);
|
||
setPartyId(null);
|
||
setError(null);
|
||
setAutoJoinAttempted(false);
|
||
}}
|
||
disabled={isLoading}
|
||
>
|
||
返回
|
||
</button>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={() => {
|
||
setAutoJoinAttempted(false);
|
||
setError(null);
|
||
}}
|
||
disabled={isLoading}
|
||
>
|
||
重试
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<div className={styles.joining}>
|
||
<div className={styles.spinner}></div>
|
||
<p>正在自动加入会话...</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{step === 'joining' && (
|
||
<div className={styles.joining}>
|
||
<div className={styles.spinner}></div>
|
||
<p>正在加入会话...</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|