feat: 화상회의 방에 고유 입장 코드 도입(이름과 식별자 분리)
방 식별을 이름이 아닌 불변 코드로 전환 — 이름을 바꿔도 링크·세션이 유지되고 초대 링크의 안정적 기준이 된다. - 백엔드: meeting_rooms.code(고유·불변) 추가, name 고유 해제(표시용 라벨, 중복 허용). 마이그레이션 AddMeetingRoomCode(코드 백필+name 고유 인덱스 제거). 토큰 발급은 code 로 방 조회(자동 생성 제거 — 명시 생성된 방만 입장, 없으면 404). LiveKit 방=code. createRoom 이 고유 코드 발급. MeetingRoomResponse 에 code 추가. - 프론트: 입장/라우팅을 code 기준으로(/meeting/:code), 로비 입장은 방 객체 전달, '초대 링크 복사' 버튼(코드 기반 URL) 추가. 생성 폼 이름 중복 검사 제거. 검증: 백엔드 build, 프론트 type-check·lint 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,6 @@ import type { ApiMember } from '@/types/repo'
|
||||
import type { ApiMeetingRoom } from '@/types/meeting'
|
||||
|
||||
interface Props {
|
||||
// 이름 중복 즉시 검사용 현재 방 목록
|
||||
rooms: ApiMeetingRoom[]
|
||||
// 편집 모드 — 전달되면 해당 방의 현재 값으로 채우고 수정한다(없으면 생성 모드).
|
||||
editRoom?: ApiMeetingRoom | null
|
||||
}
|
||||
@@ -89,11 +87,7 @@ async function submit() {
|
||||
error.value = '방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다.'
|
||||
return
|
||||
}
|
||||
// 이름 중복 검사 — 편집 중인 방 자신은 제외한다.
|
||||
if (props.rooms.some((r) => r.name === n && r.id !== props.editRoom?.id)) {
|
||||
error.value = '이미 같은 이름의 방이 있습니다.'
|
||||
return
|
||||
}
|
||||
// 이름은 표시용 라벨이라 중복을 허용한다(식별은 코드로). 별도 중복 검사 없음.
|
||||
error.value = ''
|
||||
submitting.value = true
|
||||
const payload = {
|
||||
|
||||
@@ -24,6 +24,8 @@ export type MeetingIconName =
|
||||
| 'lock'
|
||||
| 'trash'
|
||||
| 'edit'
|
||||
| 'link'
|
||||
| 'check'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -177,5 +179,11 @@ defineProps<{ name: MeetingIconName }>()
|
||||
<template v-else-if="name === 'edit'">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'link'">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</template>
|
||||
<template v-else-if="name === 'check'">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -14,13 +14,15 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'join', name: string): void
|
||||
(e: 'join', room: ApiMeetingRoom): void
|
||||
(e: 'created'): void
|
||||
(e: 'delete', room: ApiMeetingRoom): void
|
||||
(e: 'clear-error'): void
|
||||
}>()
|
||||
|
||||
const showForm = ref(false)
|
||||
// 방금 초대 링크를 복사한 방 코드(잠깐 "복사됨" 표시)
|
||||
const copiedCode = ref<string | null>(null)
|
||||
// 편집 대상 방 — 설정되면 생성 폼이 편집 모드로 열린다(null 이면 생성 모드).
|
||||
const editRoom = ref<ApiMeetingRoom | null>(null)
|
||||
const keyword = ref('')
|
||||
@@ -66,6 +68,20 @@ function fmtSchedule(iso: string): string {
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
// 초대 링크 복사 — 입장 코드 기반 URL(/meeting/<code>). 이름이 바뀌어도 링크는 유지된다.
|
||||
async function copyInvite(room: ApiMeetingRoom) {
|
||||
const url = `${window.location.origin}/meeting/${room.code}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
copiedCode.value = room.code
|
||||
window.setTimeout(() => {
|
||||
if (copiedCode.value === room.code) copiedCode.value = null
|
||||
}, 1500)
|
||||
} catch {
|
||||
// 클립보드 권한이 없으면 무시한다(사용자가 직접 복사)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -110,7 +126,6 @@ function fmtSchedule(iso: string): string {
|
||||
|
||||
<MeetingCreateForm
|
||||
v-if="showForm || editRoom"
|
||||
:rooms="rooms"
|
||||
:edit-room="editRoom"
|
||||
@created="onFormDone"
|
||||
@cancel="closeForm"
|
||||
@@ -153,11 +168,20 @@ function fmtSchedule(iso: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn rr-invite"
|
||||
:class="{ copied: copiedCode === r.code }"
|
||||
:title="copiedCode === r.code ? '복사됨!' : '초대 링크 복사'"
|
||||
@click="copyInvite(r)"
|
||||
>
|
||||
<MeetingIcon :name="copiedCode === r.code ? 'check' : 'link'" />
|
||||
</button>
|
||||
<button
|
||||
v-if="r.canJoin"
|
||||
type="button"
|
||||
class="btn primary rr-join"
|
||||
@click="emit('join', r.name)"
|
||||
@click="emit('join', r)"
|
||||
>
|
||||
입장
|
||||
</button>
|
||||
@@ -359,21 +383,29 @@ function fmtSchedule(iso: string): string {
|
||||
.rr-noaccess {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.rr-invite,
|
||||
.rr-edit,
|
||||
.rr-del {
|
||||
width: 2.25rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.rr-invite svg,
|
||||
.rr-edit svg,
|
||||
.rr-del svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.rr-invite:hover,
|
||||
.rr-edit:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
/* 초대 링크 복사 완료 — 잠깐 초록 강조 */
|
||||
.rr-invite.copied {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
/* 버튼 기본/primary 는 전역 .btn 사용. 여기선 danger·비활성·사이즈 변형만 보강. */
|
||||
.btn.danger {
|
||||
|
||||
@@ -20,10 +20,10 @@ export interface MeetingToken {
|
||||
export function useMeeting() {
|
||||
const api = useApi()
|
||||
|
||||
// LiveKit 입장 토큰 발급
|
||||
async function getToken(roomName: string): Promise<MeetingToken> {
|
||||
// LiveKit 입장 토큰 발급 — 방 입장 코드로 요청한다.
|
||||
async function getToken(code: string): Promise<MeetingToken> {
|
||||
return (await api.post('/meetings/token', {
|
||||
roomName,
|
||||
code,
|
||||
})) as unknown as MeetingToken
|
||||
}
|
||||
|
||||
|
||||
@@ -35,10 +35,9 @@ const {
|
||||
sendChat,
|
||||
} = useLiveKitRoom()
|
||||
|
||||
const ROOM_PATTERN = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]{1,64}$/
|
||||
|
||||
// 프리조인 대상 방 정보(표시용)
|
||||
// 프리조인 대상 방 정보(표시용 + 입장 코드)
|
||||
interface PrejoinTarget {
|
||||
code: string
|
||||
name: string
|
||||
hostName: string | null
|
||||
restricted: boolean
|
||||
@@ -74,51 +73,34 @@ async function loadRooms() {
|
||||
}
|
||||
}
|
||||
|
||||
// 로비에서 방 선택/입력 → 프리조인으로
|
||||
function onJoin(name: string) {
|
||||
const n = (name || '').trim()
|
||||
if (!n) {
|
||||
joinError.value = '방 이름을 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
if (!ROOM_PATTERN.test(n)) {
|
||||
joinError.value =
|
||||
'방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다. (공백 불가)'
|
||||
return
|
||||
}
|
||||
const room = rooms.value.find((r) => r.name === n)
|
||||
if (room && room.restricted && !room.canJoin) {
|
||||
joinError.value = `'${n}' 방은 참여가 제한되어 있습니다. 진행자에게 허용을 요청하세요.`
|
||||
// 로비에서 방 선택 → 프리조인으로 (방은 명시적으로 생성된 것만 입장 가능)
|
||||
function onJoin(room: ApiMeetingRoom) {
|
||||
if (room.restricted && !room.canJoin) {
|
||||
joinError.value = `'${room.name}' 방은 참여가 제한되어 있습니다. 진행자에게 허용을 요청하세요.`
|
||||
return
|
||||
}
|
||||
joinError.value = null
|
||||
target.value = room
|
||||
? {
|
||||
name: room.name,
|
||||
hostName: room.host?.name ?? null,
|
||||
restricted: room.restricted,
|
||||
isHost: room.isHost,
|
||||
}
|
||||
: {
|
||||
name: n,
|
||||
hostName: myName.value, // 새 방은 입장자가 진행자가 된다
|
||||
restricted: false,
|
||||
isHost: true,
|
||||
}
|
||||
target.value = {
|
||||
code: room.code,
|
||||
name: room.name,
|
||||
hostName: room.host?.name ?? null,
|
||||
restricted: room.restricted,
|
||||
isHost: room.isHost,
|
||||
}
|
||||
phase.value = 'prejoin'
|
||||
}
|
||||
|
||||
// 프리조인 → 입장(토큰 발급 + 연결)
|
||||
async function onEnter(opts: { mic: boolean; cam: boolean }) {
|
||||
const name = target.value?.name
|
||||
if (!name) return
|
||||
const t = target.value
|
||||
if (!t) return
|
||||
entering.value = true
|
||||
try {
|
||||
const { token, role } = await meetingApi.getToken(name)
|
||||
const { token, role } = await meetingApi.getToken(t.code)
|
||||
myRole.value = role
|
||||
await connect(token, opts)
|
||||
if (route.params.room !== name) {
|
||||
void router.replace(`/meeting/${name}`)
|
||||
if (route.params.code !== t.code) {
|
||||
void router.replace(`/meeting/${t.code}`)
|
||||
}
|
||||
} catch {
|
||||
joinError.value = '입장에 실패했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
@@ -168,8 +150,13 @@ function onCreated() {
|
||||
// 진입 시 방 목록 로드. 공유 링크(/meeting/:room)면 그 방 프리조인으로 바로 이동.
|
||||
onMounted(async () => {
|
||||
await loadRooms()
|
||||
const param = route.params.room
|
||||
if (typeof param === 'string' && param) onJoin(param)
|
||||
// 공유/초대 링크(/meeting/:code) — 코드로 방을 찾아 프리조인. 없으면 안내한다.
|
||||
const param = route.params.code
|
||||
if (typeof param === 'string' && param) {
|
||||
const room = rooms.value.find((r) => r.code === param)
|
||||
if (room) onJoin(room)
|
||||
else joinError.value = '회의 방을 찾을 수 없습니다. 링크를 확인해 주세요.'
|
||||
}
|
||||
})
|
||||
|
||||
// 화면 이탈 시 연결 정리
|
||||
|
||||
@@ -109,8 +109,8 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 화상회의 — LiveKit 기반(단계 1: 방 입장/연결). :room 으로 공유 링크 가능
|
||||
path: '/meeting/:room?',
|
||||
// 화상회의 — LiveKit 기반. :code 로 공유/초대 링크 가능(/meeting/<입장코드>)
|
||||
path: '/meeting/:code?',
|
||||
name: 'meeting',
|
||||
component: () => import('@/pages/relay/MeetingPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
|
||||
@@ -7,6 +7,7 @@ export type MeetingRole = 'host' | 'participant'
|
||||
// 회의 방(API 원시) — 백엔드 MeetingRoomResponse 와 1:1
|
||||
export interface ApiMeetingRoom {
|
||||
id: string
|
||||
code: string // 입장 코드(불변) — 입장 URL·초대 링크의 기준
|
||||
name: string
|
||||
host: { id: string; name: string; avatarUrl: string | null } | null
|
||||
scheduledAt: string | null // ISO, 예약 시각(없으면 null)
|
||||
|
||||
Reference in New Issue
Block a user