tag(功能):完善登录功能
This commit is contained in:
+184
-117
@@ -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">浏览器直接连接 LiveKit,Ktor 仅签发短期访问凭证。</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user