diff --git a/apps/ScreenShareApi/src/main/kotlin/Repositories.kt b/apps/ScreenShareApi/src/main/kotlin/Repositories.kt index a361767..2ec7689 100644 --- a/apps/ScreenShareApi/src/main/kotlin/Repositories.kt +++ b/apps/ScreenShareApi/src/main/kotlin/Repositories.kt @@ -206,6 +206,24 @@ object RoomRepository { } } + fun findByTitle(title: String): RoomRecord? = Database.useConnection { connection -> + connection.prepareStatement( + "SELECT id, title, status, created_by FROM rooms WHERE LOWER(title) = LOWER(?)", + ).use { statement -> + statement.setString(1, title) + statement.executeQuery().use { result -> + if (result.next()) { + RoomRecord( + result.getObject("id", UUID::class.java), + result.getString("title"), + result.getString("status"), + result.getObject("created_by", UUID::class.java), + ) + } else null + } + } + } + fun member(roomId: UUID, userId: UUID): RoomMember? = Database.useConnection { connection -> connection.prepareStatement( "SELECT room_id, user_id, role FROM room_members WHERE room_id = ? AND user_id = ?", diff --git a/apps/ScreenShareApi/src/main/kotlin/Routing.kt b/apps/ScreenShareApi/src/main/kotlin/Routing.kt index 63e2097..7ff9288 100644 --- a/apps/ScreenShareApi/src/main/kotlin/Routing.kt +++ b/apps/ScreenShareApi/src/main/kotlin/Routing.kt @@ -105,6 +105,7 @@ fun Application.configureRouting() { val user = call.requireCurrentUser() val title = call.receive().title.trim() if (title.length !in 1..128) throw ApiException("房间标题必须为 1-128 个字符", 400) + if (RoomRepository.findByTitle(title) != null) throw ApiException("该房间标题已存在", 409) call.respond(HttpStatusCode.Created, RoomResponse(RoomRepository.create(user.id, title), RoomRole.host)) } @@ -121,6 +122,15 @@ fun Application.configureRouting() { call.respond(RoomMemberResponse(RoomRepository.joinAsViewer(call.roomId(), user.id))) } + post("/api/rooms/join-by-title") { + val user = call.requireCurrentUser() + val title = call.receive().title.trim() + if (title.isEmpty()) throw ApiException("请输入房间标题", 400) + val room = RoomRepository.findByTitle(title) ?: throw ApiException("未找到该房间", 404) + val membership = RoomRepository.joinAsViewer(room.id, user.id) + call.respond(RoomResponse(room, membership.role)) + } + post("/api/rooms/{roomId}/members/{userId}/role") { val requester = call.requireCurrentUser() val roomId = call.roomId() @@ -214,6 +224,7 @@ enum class RoomRole { host, publisher, viewer } @Serializable data class RegisterRequest(val inviteCode: String, val username: String, val password: String, val displayName: String) @Serializable data class CreateInvitationRequest(val type: String, val expiresInHours: Int = 168) @Serializable data class CreateRoomRequest(val title: String) +@Serializable data class FindRoomByTitleRequest(val title: String) @Serializable data class UpdateMemberRoleRequest(val role: String) @Serializable data class AuthResponse(val token: String, val user: UserResponse) @Serializable data class UserResponse(val id: String, val username: String, val displayName: String, val isAdmin: Boolean) { diff --git a/apps/ScreenShareApi/src/main/resources/db/migration/V3__make_room_titles_unique.sql b/apps/ScreenShareApi/src/main/resources/db/migration/V3__make_room_titles_unique.sql new file mode 100644 index 0000000..0f6f35e --- /dev/null +++ b/apps/ScreenShareApi/src/main/resources/db/migration/V3__make_room_titles_unique.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX uq_rooms_title_case_insensitive ON rooms (LOWER(title)); diff --git a/apps/ScreenShareWeb/src/App.css b/apps/ScreenShareWeb/src/App.css index 41a536b..94ca4ee 100644 --- a/apps/ScreenShareWeb/src/App.css +++ b/apps/ScreenShareWeb/src/App.css @@ -89,6 +89,36 @@ h2 { box-shadow: 0 8px 30px rgb(30 33 80 / 7%); } +.room-panel { + grid-template-columns: minmax(180px, 1.4fr) auto auto minmax(180px, 1fr) auto; +} + +.advanced-field { + font-size: 0.76rem; +} + +.id-join { + align-self: end; +} + +.room-history { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin: 14px 0 0; + color: #555871; + font-size: 0.86rem; +} + +.room-history button { + min-height: 32px; + padding: 0 10px; + background: #ecebff; + color: #4536c7; + font-size: 0.82rem; +} + label { display: grid; gap: 7px; @@ -234,7 +264,7 @@ button:disabled { padding: 32px 0; } - .join-panel, .screens { + .join-panel, .room-panel, .screens { grid-template-columns: 1fr; } diff --git a/apps/ScreenShareWeb/src/App.tsx b/apps/ScreenShareWeb/src/App.tsx index 61b7e4b..c77ddf9 100644 --- a/apps/ScreenShareWeb/src/App.tsx +++ b/apps/ScreenShareWeb/src/App.tsx @@ -23,6 +23,9 @@ type TokenResponse = { } type ScreenFeed = { track: Track; label: string } +type RoomHistoryItem = { id: string; title: string; visitedAt: string } + +const ROOM_HISTORY_KEY = 'screen-share-room-history' function ScreenTrack({ track, label }: ScreenFeed) { const mediaContainerRef = useRef(null) @@ -76,6 +79,14 @@ function App() { const [inviteCode, setInviteCode] = useState('') const [roomId, setRoomId] = useState('') const [roomTitle, setRoomTitle] = useState('') + const [roomHistory, setRoomHistory] = useState(() => { + try { + const stored = localStorage.getItem(ROOM_HISTORY_KEY) + return stored ? JSON.parse(stored) as RoomHistoryItem[] : [] + } catch { + return [] + } + }) const [connectionState, setConnectionState] = useState<'idle' | 'connecting' | 'connected'>('idle') const [roomRole, setRoomRole] = useState(null) const [isSharing, setIsSharing] = useState(false) @@ -107,6 +118,17 @@ function App() { setRemoteCount(room.remoteParticipants.size) } + function rememberRoom(room: Pick) { + setRoomHistory((history) => { + const next = [ + { ...room, visitedAt: new Date().toISOString() }, + ...history.filter((item) => item.id !== room.id), + ].slice(0, 12) + localStorage.setItem(ROOM_HISTORY_KEY, JSON.stringify(next)) + return next + }) + } + async function submitAuth() { setError('') try { @@ -146,17 +168,39 @@ function App() { } setError('') try { - const room = await apiRequest<{ id: string }>('/api/rooms', { + const room = await apiRequest<{ id: string; title: string }>('/api/rooms', { method: 'POST', body: JSON.stringify({ title: roomTitle.trim() }), }, session.token) setRoomId(room.id) + setRoomTitle(room.title) + rememberRoom(room) await connectRoom(room.id) } catch (cause) { setError(cause instanceof Error ? cause.message : '创建房间失败') } } + async function joinRoomByTitle() { + if (!session || !roomTitle.trim()) { + setError('请输入房间标题。') + return + } + setError('') + try { + const room = await apiRequest<{ id: string; title: string }>('/api/rooms/join-by-title', { + method: 'POST', + body: JSON.stringify({ title: roomTitle.trim() }), + }, session.token) + setRoomId(room.id) + setRoomTitle(room.title) + rememberRoom(room) + await connectRoom(room.id) + } catch (cause) { + setError(cause instanceof Error ? cause.message : '加入房间失败') + } + } + async function connectRoom(targetRoomId = roomId.trim()) { if (!session) return if (!targetRoomId) { @@ -298,15 +342,31 @@ function App() { )} -
- +
+ - {!connected - ? + ? : } + + {!connected && }
+ {roomHistory.length > 0 && ( +
+ 最近房间 + {roomHistory.map((item) => ( + + ))} +
+ )} + {error &&

{error}

}