tag(功能):完善登录功能

This commit is contained in:
OrangeRoll
2026-08-07 14:07:42 +08:00
parent 7f76df0002
commit 9c0a4f59f6
15 changed files with 966 additions and 244 deletions
+184 -117
View File
@@ -4,6 +4,15 @@ import './App.css'
type Role = 'host' | 'publisher' | 'viewer'
type User = {
id: string
username: string
displayName: string
isAdmin: boolean
}
type Session = { token: string; user: User }
type TokenResponse = {
roomName: string
participantIdentity: string
@@ -13,10 +22,7 @@ type TokenResponse = {
role: Role
}
type ScreenFeed = {
track: Track
label: string
}
type ScreenFeed = { track: Track; label: string }
function ScreenTrack({ track, label }: ScreenFeed) {
const mediaContainerRef = useRef<HTMLDivElement>(null)
@@ -25,18 +31,12 @@ function ScreenTrack({ track, label }: ScreenFeed) {
const mediaElement = track.attach()
mediaElement.autoplay = true
if (mediaElement instanceof HTMLVideoElement) {
// Local preview must be muted for browsers to allow autoplay.
mediaElement.muted = true
mediaElement.playsInline = true
void mediaElement.play().catch(() => {
// The user can still start playback manually if their browser blocks it.
})
void mediaElement.play().catch(() => undefined)
}
mediaContainerRef.current?.append(mediaElement)
return () => {
mediaElement.remove()
}
return () => mediaElement.remove()
}, [track])
return (
@@ -47,60 +47,138 @@ function ScreenTrack({ track, label }: ScreenFeed) {
)
}
async function apiRequest<T>(path: string, options: RequestInit = {}, token?: string): Promise<T> {
const response = await fetch(path, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: 'Bearer ' + token } : {}),
...options.headers,
},
})
const payload = (await response.json()) as T | { message: string }
if (!response.ok) {
const message = typeof payload === 'object' && payload !== null && 'message' in payload
? String(payload.message)
: '请求失败'
throw new Error(message)
}
return payload as T
}
function App() {
const roomRef = useRef<Room | null>(null)
const [roomName, setRoomName] = useState('demo-room')
const [participantName, setParticipantName] = useState('')
const [role, setRole] = useState<Role>('publisher')
const [session, setSession] = useState<Session | null>(null)
const [authMode, setAuthMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [displayName, setDisplayName] = useState('')
const [inviteCode, setInviteCode] = useState('')
const [roomId, setRoomId] = useState('')
const [roomTitle, setRoomTitle] = useState('')
const [connectionState, setConnectionState] = useState<'idle' | 'connecting' | 'connected'>('idle')
const [roomRole, setRoomRole] = useState<Role | null>(null)
const [isSharing, setIsSharing] = useState(false)
const [remoteCount, setRemoteCount] = useState(0)
const [localScreen, setLocalScreen] = useState<ScreenFeed | null>(null)
const [remoteScreens, setRemoteScreens] = useState<ScreenFeed[]>([])
const [createdInvite, setCreatedInvite] = useState('')
const [error, setError] = useState('')
useEffect(() => {
return () => {
roomRef.current?.disconnect()
}
const stored = localStorage.getItem('screen-share-session')
if (!stored) return
const saved = JSON.parse(stored) as Session
void apiRequest<User>('/api/auth/me', {}, saved.token)
.then((user) => setSession({ token: saved.token, user }))
.catch(() => localStorage.removeItem('screen-share-session'))
}, [])
useEffect(() => () => {
void roomRef.current?.disconnect()
}, [])
function saveSession(next: Session) {
localStorage.setItem('screen-share-session', JSON.stringify(next))
setSession(next)
}
function refreshRemoteCount(room: Room) {
setRemoteCount(room.remoteParticipants.size)
}
async function joinRoom() {
const normalizedRoom = roomName.trim()
const normalizedName = participantName.trim()
async function submitAuth() {
setError('')
try {
const body = authMode === 'login'
? { username, password }
: { username, password, displayName, inviteCode }
const result = await apiRequest<Session>('/api/auth/' + authMode, {
method: 'POST',
body: JSON.stringify(body),
})
saveSession(result)
setPassword('')
setInviteCode('')
} catch (cause) {
setError(cause instanceof Error ? cause.message : '认证失败')
}
}
if (!/^[A-Za-z0-9_-]{3,64}$/.test(normalizedRoom)) {
setError('房间号只能包含 3–64 个字母、数字、下划线或连字符。')
async function createInvite(type: 'user' | 'admin') {
if (!session) return
setError('')
try {
const result = await apiRequest<{ code: string }>('/api/admin/invitations', {
method: 'POST',
body: JSON.stringify({ type, expiresInHours: 168 }),
}, session.token)
setCreatedInvite(result.code)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '创建邀请码失败')
}
}
async function createRoom() {
if (!session || !roomTitle.trim()) {
setError('请输入房间标题。')
return
}
if (!normalizedName) {
setError('请输入显示名称。')
setError('')
try {
const room = await apiRequest<{ id: string }>('/api/rooms', {
method: 'POST',
body: JSON.stringify({ title: roomTitle.trim() }),
}, session.token)
setRoomId(room.id)
await connectRoom(room.id)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '创建房间失败')
}
}
async function connectRoom(targetRoomId = roomId.trim()) {
if (!session) return
if (!targetRoomId) {
setError('请输入房间 ID。')
return
}
setError('')
setConnectionState('connecting')
try {
const response = await fetch(`/api/rooms/${encodeURIComponent(normalizedRoom)}/token`, {
await apiRequest('/api/rooms/' + encodeURIComponent(targetRoomId) + '/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ participantName: normalizedName, role }),
})
const payload = (await response.json()) as TokenResponse | { message: string }
if (!response.ok || !('token' in payload)) {
throw new Error('message' in payload ? payload.message : '无法获取房间凭证。')
}
body: '{}',
}, session.token)
const payload = await apiRequest<TokenResponse>('/api/rooms/' + encodeURIComponent(targetRoomId) + '/token', {
method: 'POST',
body: '{}',
}, session.token)
roomRef.current?.disconnect()
setLocalScreen(null)
setRemoteScreens([])
const room = new Room()
room.on(RoomEvent.ParticipantConnected, () => refreshRemoteCount(room))
room.on(RoomEvent.ParticipantDisconnected, () => refreshRemoteCount(room))
@@ -108,7 +186,7 @@ function App() {
if (track.kind === Track.Kind.Video && publication.source === Track.Source.ScreenShare) {
setRemoteScreens((screens) => [
...screens.filter((screen) => screen.track.sid !== track.sid),
{ track, label: `${participant.name || participant.identity} 的屏幕` },
{ track, label: (participant.name || participant.identity) + ' 的屏幕' },
])
}
})
@@ -129,6 +207,7 @@ function App() {
})
room.on(RoomEvent.Disconnected, () => {
setConnectionState('idle')
setRoomRole(null)
setIsSharing(false)
setRemoteCount(0)
setLocalScreen(null)
@@ -137,122 +216,110 @@ function App() {
await room.connect(payload.livekitUrl, payload.token)
roomRef.current = room
setRoomId(targetRoomId)
setRoomRole(payload.role)
refreshRemoteCount(room)
setConnectionState('connected')
} catch (cause) {
setConnectionState('idle')
setError(cause instanceof Error ? cause.message : '连接房间时发生未知错误。')
setError(cause instanceof Error ? cause.message : '连接房间失败')
}
}
async function toggleScreenShare() {
const room = roomRef.current
if (!room) {
return
}
setError('')
if (!roomRef.current) return
try {
await room.localParticipant.setScreenShareEnabled(!isSharing)
await roomRef.current.localParticipant.setScreenShareEnabled(!isSharing)
} catch (cause) {
setError(cause instanceof Error ? cause.message : '无法切换屏幕共享')
setError(cause instanceof Error ? cause.message : '无法切换屏幕共享')
}
}
function leaveRoom() {
roomRef.current?.disconnect()
roomRef.current = null
setConnectionState('idle')
setIsSharing(false)
setRemoteCount(0)
setLocalScreen(null)
setRemoteScreens([])
}
function logout() {
leaveRoom()
localStorage.removeItem('screen-share-session')
setSession(null)
setCreatedInvite('')
}
const connected = connectionState === 'connected'
const canShare = role === 'host' || role === 'publisher'
const canShare = roomRole === 'host' || roomRole === 'publisher'
if (!session) {
return (
<main className="app-shell narrow">
<header>
<p className="eyebrow">SCREEN SHARE</p>
<h1>{authMode === 'login' ? '登录' : '使用邀请码注册'}</h1>
</header>
<section className="auth-card">
<label><input value={username} onChange={(event) => setUsername(event.target.value)} /></label>
<label><input type="password" value={password} onChange={(event) => setPassword(event.target.value)} /></label>
{authMode === 'register' && (
<>
<label><input value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></label>
<label><input value={inviteCode} onChange={(event) => setInviteCode(event.target.value)} /></label>
</>
)}
<button type="button" className="primary" onClick={() => void submitAuth()}>
{authMode === 'login' ? '登录' : '注册并登录'}
</button>
<button type="button" className="link-button" onClick={() => setAuthMode(authMode === 'login' ? 'register' : 'login')}>
{authMode === 'login' ? '没有账号?使用邀请码注册' : '已有账号?去登录'}
</button>
{error && <p className="error" role="alert">{error}</p>}
</section>
</main>
)
}
return (
<main className="app-shell">
<header>
<p className="eyebrow">LIVEKIT · </p>
<h1></h1>
<p className="subtitle"> LiveKitKtor 访</p>
<header className="app-header">
<div>
<p className="eyebrow">LIVEKIT · </p>
<h1></h1>
<p className="subtitle">{session.user.displayName}{session.user.isAdmin ? '管理员' : '普通用户'}</p>
</div>
<button type="button" className="secondary" onClick={logout}>退</button>
</header>
<section className="join-panel" aria-label="加入房间">
<label>
<input
value={roomName}
onChange={(event) => setRoomName(event.target.value)}
disabled={connected}
maxLength={64}
/>
</label>
<label>
<input
value={participantName}
onChange={(event) => setParticipantName(event.target.value)}
disabled={connected}
maxLength={64}
placeholder="例如:张三"
/>
</label>
<label>
<select value={role} onChange={(event) => setRole(event.target.value as Role)} disabled={connected}>
<option value="host">Host</option>
<option value="publisher">Publisher</option>
<option value="viewer">Viewer</option>
</select>
</label>
{session.user.isAdmin && (
<section className="admin-panel">
<strong></strong>
<button type="button" onClick={() => void createInvite('user')}></button>
<button type="button" onClick={() => void createInvite('admin')}></button>
{createdInvite && <code className="invite-code">{createdInvite}</code>}
</section>
)}
{!connected ? (
<button type="button" className="primary" onClick={() => void joinRoom()} disabled={connectionState === 'connecting'}>
{connectionState === 'connecting' ? '正在连接…' : '加入房间'}
</button>
) : (
<button type="button" className="secondary" onClick={leaveRoom}></button>
)}
<section className="join-panel">
<label><input value={roomTitle} onChange={(event) => setRoomTitle(event.target.value)} disabled={connected} /></label>
<button type="button" className="primary" onClick={() => void createRoom()} disabled={connected}></button>
<label> ID<input value={roomId} onChange={(event) => setRoomId(event.target.value)} disabled={connected} placeholder="创建后或从他人处获得" /></label>
{!connected
? <button type="button" className="secondary" onClick={() => void connectRoom()} disabled={connectionState === 'connecting'}>{connectionState === 'connecting' ? '正在连接…' : '加入房间'}</button>
: <button type="button" className="secondary" onClick={leaveRoom}></button>}
</section>
{error && <p className="error" role="alert">{error}</p>}
<section className="status-bar" aria-live="polite">
<span className={connected ? 'status online' : 'status'}>{connected ? '已连接' : '未连接'}</span>
<span>{roomRole ? '房间角色:' + roomRole : '尚未加入房间'}</span>
<span>{remoteCount}</span>
{connected && (
<button type="button" className="share-button" onClick={() => void toggleScreenShare()} disabled={!canShare}>
{isSharing ? '停止共享屏幕' : '共享屏幕'}
</button>
)}
{connected && !canShare && <span className="hint">Viewer </span>}
{connected && <button type="button" className="share-button" onClick={() => void toggleScreenShare()} disabled={!canShare}>{isSharing ? '停止共享屏幕' : '共享屏幕'}</button>}
</section>
<section className="screens">
<div className="screen-section">
<h2></h2>
<div className="video-stack">
{localScreen ? <ScreenTrack {...localScreen} /> : <p className="empty"></p>}
</div>
</div>
<div className="screen-section">
<h2></h2>
<div className="video-stack">
{remoteScreens.length === 0
? <p className="empty"></p>
: remoteScreens.map((screen) => (
<ScreenTrack key={screen.track.sid} {...screen} />
))}
</div>
</div>
<div className="screen-section"><h2></h2><div className="video-stack">{localScreen ? <ScreenTrack {...localScreen} /> : <p className="empty"></p>}</div></div>
<div className="screen-section"><h2></h2><div className="video-stack">{remoteScreens.length === 0 ? <p className="empty"></p> : remoteScreens.map((screen) => <ScreenTrack key={screen.track.sid} {...screen} />)}</div></div>
</section>
<p className="development-note">
JWT
</p>
</main>
)
}