feat: 화상회의 단계 4 — 방 관리(목록·생성·예약·참여 제한)

암묵적 방 생성을 명시적 방 관리로 확장한다. (기능 완성 + 임시 UI)

백엔드
- meeting_rooms 에 scheduled_at(예약), allowed_user_ids(참여 허용 allow-list) 추가 + 마이그레이션
- GET /meetings/rooms(목록: 접근 가능/진행자 여부 포함)
- POST /meetings/rooms(생성: name+예약+허용멤버, 생성자=진행자, 이름 중복 409)
- DELETE /meetings/rooms/:id(진행자만, 아니면 403)
- 토큰 발급 시 참여 제한 enforcement: 비허용자는 AUTH_003(403).
  방이 없으면 자동 생성(공개)으로 직접 링크 입장은 유지.

프론트
- useMeeting: listRooms/createRoom/removeRoom + types/meeting.ts
- MeetingPage 로비: 방 목록(입장/삭제) + 생성 폼(예약시각·참여 제한 멤버 피커, useUser 검색 재사용) + 빠른 입장
- 화상회의_화면명세.md 단계 4 반영(범위/로비/데이터 계약/엔드포인트)

권한 모델은 기존 결정대로 "누구나 생성=진행자" 유지(전역 admin 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 20:48:51 +09:00
parent 2416f728ba
commit 54fe3d36e7
9 changed files with 792 additions and 63 deletions
+25 -1
View File
@@ -1,4 +1,8 @@
import { useApi } from './useApi'
import type {
ApiMeetingRoom,
CreateMeetingRoomPayload,
} from '@/types/meeting'
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
export type MeetingRole = 'host' | 'participant'
@@ -22,5 +26,25 @@ export function useMeeting() {
})) as unknown as MeetingToken
}
return { getToken }
// 방 목록
async function listRooms(): Promise<ApiMeetingRoom[]> {
return (await api.get('/meetings/rooms')) as unknown as ApiMeetingRoom[]
}
// 방 생성(생성자=진행자)
async function createRoom(
payload: CreateMeetingRoomPayload,
): Promise<ApiMeetingRoom> {
return (await api.post(
'/meetings/rooms',
payload,
)) as unknown as ApiMeetingRoom
}
// 방 삭제(진행자만)
async function removeRoom(roomId: string): Promise<void> {
await api.delete(`/meetings/rooms/${roomId}`)
}
return { getToken, listRooms, createRoom, removeRoom }
}
+455 -18
View File
@@ -1,13 +1,17 @@
<script setup lang="ts">
// 화상회의 (단계 1: 인프라 토대) — 방 입장 → LiveKit 연결 → 참여자 영상 그리드
// 진행자/일반 권한 분기(단계 2), 화면 승격/페이지네이션(단계 3)은 후속 단계에서 구현한다.
import { computed, onBeforeUnmount, ref, watch } from 'vue'
// 화상회의 — 로비(방 목록·생성·빠른입장) + 회의실(stage/썸네일/승격/페이지네이션)
// ※ 레이아웃은 임시(추후 UI 개편). 데이터/로직 중심.
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { Participant } from 'livekit-client'
import AppShell from '@/layouts/AppShell.vue'
import ParticipantTile from '@/components/meeting/ParticipantTile.vue'
import { useMeeting, type MeetingRole } from '@/composables/useMeeting'
import { useLiveKitRoom } from '@/composables/useLiveKitRoom'
import { useUser } from '@/composables/useUser'
import { useDialog } from '@/composables/useDialog'
import type { ApiMeetingRoom } from '@/types/meeting'
import type { ApiMember } from '@/types/repo'
const route = useRoute()
const router = useRouter()
@@ -68,6 +72,111 @@ async function onLeave() {
await disconnect()
myRole.value = null
void router.replace('/meeting')
void loadRooms()
}
// --- 로비: 방 목록 / 생성 / 참여 제한 멤버 피커 ---
const userApi = useUser()
const dialog = useDialog()
const rooms = ref<ApiMeetingRoom[]>([])
async function loadRooms() {
try {
rooms.value = await meetingApi.listRooms()
} catch {
// 목록 조회 실패는 인터셉터에서 전역 처리
}
}
// 목록에서 방 선택 입장
async function joinRoom(name: string) {
roomName.value = name
await onJoin()
}
// 방 삭제(진행자만)
async function deleteRoom(r: ApiMeetingRoom) {
const ok = await dialog.confirm(`'${r.name}' 방을 삭제할까요?`, {
title: '방 삭제',
confirmText: '삭제',
variant: 'danger',
})
if (!ok) return
try {
await meetingApi.removeRoom(r.id)
await loadRooms()
} catch {
// 전역 처리
}
}
// 예약 시각 표시(로컬)
function fmtSchedule(iso: string): string {
return new Date(iso).toLocaleString('ko-KR', {
dateStyle: 'short',
timeStyle: 'short',
})
}
// 방 생성 폼
const showCreate = ref(false)
const newName = ref('')
const newScheduledAt = ref('')
const restrictOn = ref(false)
const allowedUsers = ref<ApiMember[]>([])
const userQuery = ref('')
const userResults = ref<ApiMember[]>([])
const creating = ref(false)
const createError = ref<string | null>(null)
// 참여 허용 멤버 검색(제한 켰을 때만)
watch(userQuery, async (q) => {
if (!restrictOn.value) return
const list = await userApi.search(q)
const picked = new Set(allowedUsers.value.map((u) => u.id))
userResults.value = list.filter((u) => !picked.has(u.id)).slice(0, 8)
})
function addUser(u: ApiMember) {
allowedUsers.value.push(u)
userResults.value = userResults.value.filter((x) => x.id !== u.id)
userQuery.value = ''
}
function removeUser(id: string) {
allowedUsers.value = allowedUsers.value.filter((u) => u.id !== id)
}
async function createRoom() {
const name = newName.value.trim()
if (!ROOM_PATTERN.test(name)) {
createError.value = '방 이름은 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다.'
return
}
creating.value = true
createError.value = null
try {
await meetingApi.createRoom({
name,
scheduledAt: newScheduledAt.value
? new Date(newScheduledAt.value).toISOString()
: undefined,
allowedUserIds:
restrictOn.value && allowedUsers.value.length
? allowedUsers.value.map((u) => u.id)
: undefined,
})
showCreate.value = false
newName.value = ''
newScheduledAt.value = ''
restrictOn.value = false
allowedUsers.value = []
userQuery.value = ''
userResults.value = []
await loadRooms()
} catch {
createError.value = '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
} finally {
creating.value = false
}
}
const participantCount = computed(() => participants.value.length)
@@ -113,6 +222,11 @@ watch(pageCount, (count) => {
if (page.value >= count) page.value = Math.max(0, count - 1)
})
// 로비 진입 시 방 목록 로드
onMounted(() => {
if (!connected.value) void loadRooms()
})
// 화면 이탈 시 연결 정리
onBeforeUnmount(() => {
void disconnect()
@@ -128,14 +242,22 @@ onBeforeUnmount(() => {
>
<div class="pagehead">
<h1>화상회의</h1>
<button
class="btn primary"
@click="showCreate = !showCreate"
>
{{ showCreate ? '닫기' : '방 만들기' }}
</button>
</div>
<p class="lede">
이름 입력해 입장하세요. 같은 이름으로 들어온 사람끼리 연결됩니다.
회의 만들거나 목록에서 선택해 입장하세요.
</p>
<!-- 생성 -->
<form
class="lobby card"
@submit.prevent="onJoin"
v-if="showCreate"
class="card create-form"
@submit.prevent="createRoom"
>
<div class="fblock">
<div class="flabel">
@@ -145,13 +267,173 @@ onBeforeUnmount(() => {
영문·숫자·하이픈(-)·밑줄(_) 사용할 있습니다.
</div>
<input
v-model="roomName"
v-model="newName"
type="text"
class="tinput"
placeholder="예: team-standup"
placeholder="예: weekly-sync"
maxlength="64"
:disabled="connecting"
>
</div>
<div class="fblock">
<div class="flabel">
예약 시각 (선택)
</div>
<input
v-model="newScheduledAt"
type="datetime-local"
class="tinput"
>
</div>
<div class="fblock">
<label class="check">
<input
v-model="restrictOn"
type="checkbox"
>
참여 제한 (지정한 사람만 입장)
</label>
<div
v-if="restrictOn"
class="picker"
>
<input
v-model="userQuery"
type="text"
class="tinput"
placeholder="이름 또는 이메일로 검색"
>
<ul
v-if="userResults.length"
class="picker-results"
>
<li
v-for="u in userResults"
:key="u.id"
@click="addUser(u)"
>
{{ u.name }} <span class="pr-email">{{ u.email }}</span>
</li>
</ul>
<div
v-if="allowedUsers.length"
class="chips"
>
<span
v-for="u in allowedUsers"
:key="u.id"
class="chip"
>
{{ u.name }}
<button
type="button"
@click="removeUser(u.id)"
>×</button>
</span>
</div>
</div>
</div>
<p
v-if="createError"
class="err"
>
{{ createError }}
</p>
<div class="form-footer">
<button
type="button"
class="btn ghost"
@click="showCreate = false"
>
취소
</button>
<button
type="submit"
class="btn primary"
:disabled="creating"
>
{{ creating ? '생성 중…' : '만들기' }}
</button>
</div>
</form>
<!-- 목록 -->
<div class="card rooms">
<div class="rooms-head">
회의 <span class="ct">{{ rooms.length }}</span>
</div>
<div
v-if="!rooms.length"
class="rooms-empty"
>
생성된 방이 없습니다. "방 만들기" 방을 만들어 보세요.
</div>
<div
v-for="r in rooms"
:key="r.id"
class="room-row"
>
<div class="rr-info">
<div class="rr-main">
<span class="rr-name">{{ r.name }}</span>
<span
v-if="r.restricted"
class="rr-badge"
>🔒 제한</span>
<span
v-if="r.isHost"
class="rr-badge host"
>진행자</span>
</div>
<div class="rr-sub">
<span v-if="r.host">{{ r.host.name }}</span>
<span v-if="r.scheduledAt"> · 예약 {{ fmtSchedule(r.scheduledAt) }}</span>
</div>
</div>
<div class="rr-actions">
<button
class="btn sm"
:disabled="!r.canJoin || connecting"
@click="joinRoom(r.name)"
>
{{ r.canJoin ? '입장' : '권한 없음' }}
</button>
<button
v-if="r.isHost"
class="btn sm ghost"
@click="deleteRoom(r)"
>
삭제
</button>
</div>
</div>
</div>
<!-- 빠른 입장( 이름 직접) -->
<form
class="card quick"
@submit.prevent="onJoin"
>
<div class="fblock">
<div class="flabel">
이름으로 바로 입장
</div>
<div class="quick-row">
<input
v-model="roomName"
type="text"
class="tinput"
placeholder="예: team-standup"
maxlength="64"
:disabled="connecting"
>
<button
class="btn primary"
type="submit"
:disabled="connecting"
>
{{ connecting ? '입장 중…' : '입장' }}
</button>
</div>
<p
v-if="joinError"
class="err"
@@ -159,15 +441,6 @@ onBeforeUnmount(() => {
{{ joinError }}
</p>
</div>
<div class="form-footer">
<button
class="btn primary"
type="submit"
:disabled="connecting"
>
{{ connecting ? '입장 중…' : '입장하기' }}
</button>
</div>
</form>
</div>
@@ -286,6 +559,10 @@ onBeforeUnmount(() => {
}
.pagehead {
padding: 1.125rem 0 0.375rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.pagehead h1 {
font-size: 1.375rem;
@@ -373,6 +650,166 @@ onBeforeUnmount(() => {
opacity: 0.6;
cursor: default;
}
.btn.ghost {
background: #fff;
border: 1px solid var(--border-strong);
color: var(--text-2);
}
.btn.sm {
height: 1.875rem;
padding: 0 0.75rem;
font-size: 0.781rem;
}
.btn.sm:disabled {
opacity: 0.5;
cursor: default;
}
/* 로비: 방 생성 폼 */
.create-form {
margin-bottom: 1rem;
}
.create-form .fblock {
border-top: 1px solid var(--border);
}
.create-form .fblock:first-child {
border-top: none;
}
.check {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
cursor: pointer;
}
.picker {
margin-top: 0.625rem;
}
.picker-results {
list-style: none;
margin: 0.375rem 0 0;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.picker-results li {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
cursor: pointer;
}
.picker-results li:hover {
background: var(--accent-weak);
}
.pr-email {
color: var(--text-3);
font-size: 0.719rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
margin-top: 0.5rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.1875rem 0.5rem;
background: var(--accent-weak);
color: var(--accent);
border: 1px solid var(--accent-border);
border-radius: 0.3125rem;
font-size: 0.75rem;
font-weight: 600;
}
.chip button {
border: none;
background: none;
color: inherit;
cursor: pointer;
font-size: 0.875rem;
line-height: 1;
}
/* 로비: 방 목록 */
.rooms {
margin-bottom: 1rem;
}
.rooms-head {
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--border);
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-2);
}
.rooms-head .ct {
color: var(--text-3);
margin-left: 0.25rem;
}
.rooms-empty {
padding: 1.5rem 1.25rem;
text-align: center;
font-size: 0.8125rem;
color: var(--text-3);
}
.room-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--border);
}
.room-row:last-child {
border-bottom: none;
}
.rr-info {
min-width: 0;
}
.rr-main {
display: flex;
align-items: center;
gap: 0.4375rem;
}
.rr-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
}
.rr-badge {
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
background: #f3f4f6;
color: var(--text-2);
font-size: 0.6875rem;
font-weight: 600;
}
.rr-badge.host {
background: var(--accent-weak);
color: var(--accent);
}
.rr-sub {
margin-top: 0.1875rem;
font-size: 0.75rem;
color: var(--text-3);
}
.rr-actions {
display: flex;
gap: 0.375rem;
flex-shrink: 0;
}
/* 로비: 빠른 입장 */
.quick-row {
display: flex;
gap: 0.5rem;
}
.quick-row .tinput {
flex: 1;
}
/* 회의실 */
.room {
+23
View File
@@ -0,0 +1,23 @@
// 화상회의 도메인 타입 — 백엔드 MeetingService 응답/DTO 와 1:1
// 회의 내 역할
export type MeetingRole = 'host' | 'participant'
// 회의 방(API 원시) — 백엔드 MeetingRoomResponse 와 1:1
export interface ApiMeetingRoom {
id: string
name: string
host: { id: string; name: string; avatarUrl: string | null } | null
scheduledAt: string | null // ISO, 예약 시각(없으면 null)
restricted: boolean // 참여 제한(allow-list) 여부
isHost: boolean // 현재 사용자가 진행자
canJoin: boolean // 현재 사용자가 입장 가능
createdAt: string
}
// 방 생성 요청
export interface CreateMeetingRoomPayload {
name: string
scheduledAt?: string // ISO
allowedUserIds?: string[] // 비우면 전체 공개
}