- 매칭 버튼: 신청 후 "진행중" 표시, 무료 소진(429) 안내
- 헤더 알림 벨: 받은 매칭 신청 목록 + 수락/거절
- 실시간 알림: STOMP /topic/users/{id} 구독(신청/수락/거절/만료 토스트)
- 수락 시 1:1 매칭 채팅(MatchChatDialog, /topic/matches/{id})
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -2,7 +2,8 @@ import { http } from './http'
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number
|
||||
spotId: number
|
||||
spotId: number | null
|
||||
matchId: number | null
|
||||
senderId: number
|
||||
senderNickname: string
|
||||
content: string
|
||||
@@ -11,4 +12,6 @@ export interface ChatMessage {
|
||||
|
||||
export const chatApi = {
|
||||
history: (spotId: number) => http.get<ChatMessage[]>(`/api/chat/spots/${spotId}/messages`),
|
||||
matchHistory: (matchId: number) =>
|
||||
http.get<ChatMessage[]>(`/api/chat/matches/${matchId}/messages`),
|
||||
}
|
||||
|
||||
+21
-1
@@ -6,13 +6,33 @@ export interface Match {
|
||||
targetDogId: number
|
||||
status: string
|
||||
createdAt: string
|
||||
expiresAt: string | null
|
||||
respondedAt: string | null
|
||||
}
|
||||
|
||||
/** 받은 매칭 신청 (신청 견 정보 포함) — 수락/거절 UI용. */
|
||||
export interface ReceivedMatch {
|
||||
id: number
|
||||
requesterDogId: number
|
||||
requesterDogName: string
|
||||
requesterDogBreed: string | null
|
||||
createdAt: string
|
||||
expiresAt: string | null
|
||||
}
|
||||
|
||||
/** STOMP /topic/users/{userId} 로 오는 매칭 알림. */
|
||||
export interface MatchNotification {
|
||||
type: 'NEW_REQUEST' | 'ACCEPTED' | 'REJECTED' | 'EXPIRED'
|
||||
matchId: number
|
||||
title: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export const matchesApi = {
|
||||
create: (requesterDogId: number, targetDogId: number) =>
|
||||
http.post<Match>('/api/matches', { requesterDogId, targetDogId }),
|
||||
accept: (id: number) => http.post<Match>(`/api/matches/${id}/accept`),
|
||||
reject: (id: number) => http.post<Match>(`/api/matches/${id}/reject`),
|
||||
received: (dogId: number) => http.get<Match[]>(`/api/matches/received/${dogId}`),
|
||||
received: (dogId: number) => http.get<ReceivedMatch[]>(`/api/matches/received/${dogId}`),
|
||||
sent: (dogId: number) => http.get<Match[]>(`/api/matches/sent/${dogId}`),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<q-dialog :model-value="modelValue" @update:model-value="$emit('update:modelValue', $event)" maximized>
|
||||
<q-card class="column no-wrap">
|
||||
<q-toolbar class="bg-primary text-white">
|
||||
<q-icon name="favorite" class="q-mr-sm" />
|
||||
<q-toolbar-title>매칭 채팅</q-toolbar-title>
|
||||
<q-chip dense :color="connected ? 'positive' : 'grey-5'" text-color="white">
|
||||
{{ connected ? '실시간' : '연결 중…' }}
|
||||
</q-chip>
|
||||
<q-btn flat round dense icon="close" @click="close" />
|
||||
</q-toolbar>
|
||||
|
||||
<q-scroll-area ref="scrollArea" class="col q-pa-md" style="background: #f4faff">
|
||||
<div v-if="!messages.length" class="text-center text-grey-5 q-mt-lg">
|
||||
매칭된 메이트와 첫 인사를 나눠보세요.
|
||||
</div>
|
||||
<div
|
||||
v-for="m in messages"
|
||||
:key="m.id"
|
||||
class="q-mb-sm row"
|
||||
:class="isMine(m) ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<div :class="isMine(m) ? 'msg mine' : 'msg'">
|
||||
<div v-if="!isMine(m)" class="text-caption text-weight-medium text-accent">
|
||||
{{ m.senderNickname }}
|
||||
</div>
|
||||
<div>{{ m.content }}</div>
|
||||
<div class="text-caption text-grey-6 text-right">{{ time(m.createdAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-scroll-area>
|
||||
|
||||
<div class="row q-pa-sm q-gutter-sm bg-white items-center">
|
||||
<q-input
|
||||
v-model="draft"
|
||||
class="col"
|
||||
dense
|
||||
outlined
|
||||
placeholder="메시지 입력…"
|
||||
maxlength="1000"
|
||||
:disable="!connected"
|
||||
@keyup.enter="send"
|
||||
/>
|
||||
<q-btn round color="primary" icon="send" :disable="!connected || !draft.trim()" @click="send" />
|
||||
</div>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { QScrollArea } from 'quasar'
|
||||
import { chatApi, type ChatMessage } from '@/api/chat'
|
||||
import { createMatchChat } from '@/lib/chatSocket'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps<{ modelValue: boolean; matchId: number }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const draft = ref('')
|
||||
const connected = ref(false)
|
||||
const scrollArea = ref<QScrollArea | null>(null)
|
||||
let chat: ReturnType<typeof createMatchChat> | null = null
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (open) => {
|
||||
if (open) await start()
|
||||
else stop()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(stop)
|
||||
|
||||
async function start() {
|
||||
messages.value = []
|
||||
try {
|
||||
messages.value = await chatApi.matchHistory(props.matchId)
|
||||
scrollToBottom()
|
||||
} catch {
|
||||
// 히스토리 실패해도 실시간 연결은 시도
|
||||
}
|
||||
chat = createMatchChat(
|
||||
props.matchId,
|
||||
(m) => {
|
||||
messages.value.push(m)
|
||||
scrollToBottom()
|
||||
},
|
||||
(c) => (connected.value = c),
|
||||
)
|
||||
chat.activate()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
chat?.deactivate()
|
||||
chat = null
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
function send() {
|
||||
const text = draft.value.trim()
|
||||
if (!text || !chat) return
|
||||
chat.send(text)
|
||||
draft.value = ''
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function isMine(m: ChatMessage) {
|
||||
return m.senderId === auth.member?.id
|
||||
}
|
||||
|
||||
function time(iso: string) {
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
const el = scrollArea.value
|
||||
if (el) el.setScrollPercentage('vertical', 1, 150)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.msg {
|
||||
max-width: 76%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e3eef7;
|
||||
word-break: break-word;
|
||||
}
|
||||
.msg.mine {
|
||||
background: #d7ecfb;
|
||||
border-color: #cfe8fb;
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,37 @@
|
||||
<q-chip dense color="white" text-color="primary" icon="bolt">
|
||||
{{ tierLabel }}
|
||||
</q-chip>
|
||||
|
||||
<!-- 받은 매칭 알림 -->
|
||||
<q-btn flat round dense icon="notifications" class="q-mr-xs">
|
||||
<q-badge v-if="matching.received.length" color="red" floating>
|
||||
{{ matching.received.length }}
|
||||
</q-badge>
|
||||
<q-menu anchor="bottom right" self="top right">
|
||||
<q-list style="min-width: 260px">
|
||||
<q-item-label header>받은 매칭 신청</q-item-label>
|
||||
<q-item v-if="!matching.received.length">
|
||||
<q-item-section class="text-grey-6">받은 신청이 없어요.</q-item-section>
|
||||
</q-item>
|
||||
<q-item v-for="r in matching.received" :key="r.id">
|
||||
<q-item-section avatar>
|
||||
<q-avatar color="secondary" text-color="accent" icon="pets" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label class="text-weight-medium">{{ r.requesterDogName }}</q-item-label>
|
||||
<q-item-label caption>{{ r.requesterDogBreed || '견종 미상' }} · 매칭 신청</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<div class="row q-gutter-xs">
|
||||
<q-btn dense unelevated color="primary" label="수락" @click="onAccept(r.id)" />
|
||||
<q-btn dense flat color="grey-7" label="거절" @click="onReject(r.id)" />
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-menu>
|
||||
</q-btn>
|
||||
|
||||
<q-btn flat round dense icon="account_circle">
|
||||
<q-menu>
|
||||
<q-list style="min-width: 160px">
|
||||
@@ -46,22 +77,73 @@
|
||||
<q-route-tab name="profile" :to="{ name: 'profile' }" icon="pets" label="우리 개" />
|
||||
</q-tabs>
|
||||
</q-footer>
|
||||
|
||||
<!-- 매칭 수락 시 1:1 채팅 -->
|
||||
<MatchChatDialog v-if="matchChatId != null" v-model="matchChatOpen" :match-id="matchChatId" />
|
||||
</q-layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useQuasar } from 'quasar'
|
||||
import AdBanner from '@/components/AdBanner.vue'
|
||||
import MatchChatDialog from '@/components/MatchChatDialog.vue'
|
||||
import { useSubscriptionStore } from '@/stores/subscription'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDogsStore } from '@/stores/dogs'
|
||||
import { useMatchingStore } from '@/stores/matching'
|
||||
import { ApiError } from '@/api/http'
|
||||
|
||||
const tab = ref('home')
|
||||
const subscription = useSubscriptionStore()
|
||||
const auth = useAuthStore()
|
||||
const dogs = useDogsStore()
|
||||
const matching = useMatchingStore()
|
||||
const router = useRouter()
|
||||
const $q = useQuasar()
|
||||
|
||||
const matchChatOpen = ref(false)
|
||||
const matchChatId = ref<number | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await dogs.loadMyDogs()
|
||||
if (auth.member && dogs.currentDogId != null) {
|
||||
matching.connect(auth.member.id, dogs.currentDogId)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => matching.disconnect())
|
||||
|
||||
// 수락(내가/상대가) → 1:1 채팅 열기
|
||||
watch(
|
||||
() => matching.openChatMatchId,
|
||||
(id) => {
|
||||
if (id != null) {
|
||||
matchChatId.value = id
|
||||
matchChatOpen.value = true
|
||||
matching.openChatMatchId = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function onAccept(id: number) {
|
||||
try {
|
||||
await matching.accept(id)
|
||||
} catch (e) {
|
||||
notifyError(e)
|
||||
}
|
||||
}
|
||||
async function onReject(id: number) {
|
||||
try {
|
||||
await matching.reject(id)
|
||||
} catch (e) {
|
||||
notifyError(e)
|
||||
}
|
||||
}
|
||||
function notifyError(e: unknown) {
|
||||
const message = e instanceof ApiError ? e.message : '처리 중 오류가 발생했습니다.'
|
||||
$q.notify({ color: 'negative', message, icon: 'error', position: 'top' })
|
||||
}
|
||||
|
||||
const tierLabel = computed(() => {
|
||||
switch (subscription.tier) {
|
||||
@@ -75,6 +157,7 @@ const tierLabel = computed(() => {
|
||||
})
|
||||
|
||||
async function onLogout() {
|
||||
matching.disconnect()
|
||||
await auth.logout()
|
||||
dogs.reset()
|
||||
router.replace({ name: 'login' })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Client, type IMessage } from '@stomp/stompjs'
|
||||
import { tokenStore } from '@/lib/token'
|
||||
import type { ChatMessage } from '@/api/chat'
|
||||
import type { MatchNotification } from '@/api/matches'
|
||||
|
||||
function wsUrl(): string {
|
||||
const base = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
|
||||
@@ -8,6 +9,21 @@ function wsUrl(): string {
|
||||
return base.replace(/^http/, 'ws') + '/ws'
|
||||
}
|
||||
|
||||
function stompClient(onConnect: (c: Client) => void, onStatus?: (connected: boolean) => void) {
|
||||
const client = new Client({
|
||||
brokerURL: wsUrl(),
|
||||
connectHeaders: { Authorization: `Bearer ${tokenStore.get() ?? ''}` },
|
||||
reconnectDelay: 3000,
|
||||
onConnect: () => {
|
||||
onStatus?.(true)
|
||||
onConnect(client)
|
||||
},
|
||||
onDisconnect: () => onStatus?.(false),
|
||||
onWebSocketClose: () => onStatus?.(false),
|
||||
})
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* 스팟 채팅용 STOMP 클라이언트.
|
||||
* connect 후 스팟 토픽을 구독하고, send 로 메시지를 전송한다.
|
||||
@@ -43,3 +59,44 @@ export function createSpotChat(
|
||||
isConnected: () => client.connected,
|
||||
}
|
||||
}
|
||||
|
||||
/** 매칭 수락으로 열린 1:1 채팅용 STOMP 클라이언트. */
|
||||
export function createMatchChat(
|
||||
matchId: number,
|
||||
onMessage: (m: ChatMessage) => void,
|
||||
onStatus?: (connected: boolean) => void,
|
||||
) {
|
||||
const client = stompClient((c) => {
|
||||
c.subscribe(`/topic/matches/${matchId}`, (msg: IMessage) => {
|
||||
onMessage(JSON.parse(msg.body) as ChatMessage)
|
||||
})
|
||||
}, onStatus)
|
||||
|
||||
return {
|
||||
activate: () => client.activate(),
|
||||
deactivate: () => client.deactivate(),
|
||||
send: (content: string) => {
|
||||
client.publish({
|
||||
destination: `/app/matches/${matchId}/chat`,
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
},
|
||||
isConnected: () => client.connected,
|
||||
}
|
||||
}
|
||||
|
||||
/** 로그인 사용자용 매칭 실시간 알림 구독 (/topic/users/{userId}). */
|
||||
export function createUserNotifications(
|
||||
userId: number,
|
||||
onNotification: (n: MatchNotification) => void,
|
||||
) {
|
||||
const client = stompClient((c) => {
|
||||
c.subscribe(`/topic/users/${userId}`, (msg: IMessage) => {
|
||||
onNotification(JSON.parse(msg.body) as MatchNotification)
|
||||
})
|
||||
})
|
||||
return {
|
||||
activate: () => client.activate(),
|
||||
deactivate: () => client.deactivate(),
|
||||
}
|
||||
}
|
||||
|
||||
+27
-2
@@ -107,6 +107,16 @@
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-btn
|
||||
v-if="matching.isPending(mate.dogId)"
|
||||
dense
|
||||
outline
|
||||
color="grey-7"
|
||||
icon="hourglass_top"
|
||||
label="진행중"
|
||||
disable
|
||||
/>
|
||||
<q-btn
|
||||
v-else
|
||||
dense
|
||||
unelevated
|
||||
color="primary"
|
||||
@@ -129,12 +139,14 @@ import { matchesApi } from '@/api/matches'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useDogsStore } from '@/stores/dogs'
|
||||
import { useMatchingStore } from '@/stores/matching'
|
||||
import KakaoMap from '@/components/KakaoMap.vue'
|
||||
import ChatDialog from '@/components/ChatDialog.vue'
|
||||
|
||||
const $q = useQuasar()
|
||||
const auth = useAuthStore()
|
||||
const dogs = useDogsStore()
|
||||
const matching = useMatchingStore()
|
||||
|
||||
const chatOpen = ref(false)
|
||||
|
||||
@@ -233,9 +245,22 @@ async function onMatch(mate: Mate) {
|
||||
matchingDogId.value = mate.dogId
|
||||
try {
|
||||
await matchesApi.create(dogs.currentDogId, mate.dogId)
|
||||
$q.notify({ color: 'positive', message: `${mate.name}에게 매칭을 신청했어요!`, icon: 'favorite', position: 'top' })
|
||||
matching.markSent(mate.dogId) // 버튼 "진행중" 표시
|
||||
$q.notify({
|
||||
color: 'positive',
|
||||
message: `${mate.name}에게 매칭을 신청했어요! (상대 수락 시 채팅)`,
|
||||
icon: 'favorite',
|
||||
position: 'top',
|
||||
})
|
||||
} catch (e) {
|
||||
notifyError(e)
|
||||
// 429: 무료 매칭 횟수 소진
|
||||
const msg = e instanceof ApiError ? e.message : '매칭 신청에 실패했습니다.'
|
||||
$q.notify({
|
||||
color: e instanceof ApiError && e.status === 429 ? 'warning' : 'negative',
|
||||
message: msg,
|
||||
icon: e instanceof ApiError && e.status === 429 ? 'block' : 'error',
|
||||
position: 'top',
|
||||
})
|
||||
} finally {
|
||||
matchingDogId.value = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { Notify } from 'quasar'
|
||||
import { matchesApi, type MatchNotification, type ReceivedMatch } from '@/api/matches'
|
||||
import { createUserNotifications } from '@/lib/chatSocket'
|
||||
|
||||
/**
|
||||
* 매칭 상태/알림 스토어.
|
||||
* - 로그인 시 connect(userId, myDogId): 실시간 알림 구독 + 받은/보낸 매칭 로드
|
||||
* - received: 내 개가 받은 대기중 신청 (수락/거절 대상)
|
||||
* - sentPendingTargetIds: 내가 신청한 진행중 대상 dogId (버튼 "진행중" 표시)
|
||||
* - openChatMatchId: 수락된 매칭 → 1:1 채팅 열기 신호
|
||||
*/
|
||||
export const useMatchingStore = defineStore('matching', () => {
|
||||
const received = ref<ReceivedMatch[]>([])
|
||||
const sentPendingTargetIds = ref<Set<number>>(new Set())
|
||||
const openChatMatchId = ref<number | null>(null)
|
||||
|
||||
let socket: ReturnType<typeof createUserNotifications> | null = null
|
||||
let myDogId: number | null = null
|
||||
|
||||
async function refresh(dogId: number): Promise<void> {
|
||||
myDogId = dogId
|
||||
try {
|
||||
const [rec, snt] = await Promise.all([matchesApi.received(dogId), matchesApi.sent(dogId)])
|
||||
received.value = rec
|
||||
sentPendingTargetIds.value = new Set(snt.map((m) => m.targetDogId))
|
||||
} catch {
|
||||
// 무시 (미로그인/네트워크)
|
||||
}
|
||||
}
|
||||
|
||||
function connect(userId: number, dogId: number): void {
|
||||
disconnect()
|
||||
myDogId = dogId
|
||||
void refresh(dogId)
|
||||
socket = createUserNotifications(userId, onNotification)
|
||||
socket.activate()
|
||||
}
|
||||
|
||||
function disconnect(): void {
|
||||
socket?.deactivate()
|
||||
socket = null
|
||||
received.value = []
|
||||
sentPendingTargetIds.value = new Set()
|
||||
}
|
||||
|
||||
function onNotification(n: MatchNotification): void {
|
||||
Notify.create({
|
||||
message: n.message,
|
||||
caption: n.title,
|
||||
color: colorFor(n.type),
|
||||
icon: iconFor(n.type),
|
||||
position: 'top',
|
||||
timeout: 4000,
|
||||
})
|
||||
if (n.type === 'ACCEPTED') {
|
||||
openChatMatchId.value = n.matchId // 신청자: 수락됨 → 채팅 열기
|
||||
}
|
||||
if (myDogId != null) void refresh(myDogId) // 목록/진행중 동기화
|
||||
}
|
||||
|
||||
async function accept(id: number): Promise<void> {
|
||||
await matchesApi.accept(id)
|
||||
received.value = received.value.filter((m) => m.id !== id)
|
||||
openChatMatchId.value = id // 수락자도 바로 채팅 열기
|
||||
}
|
||||
|
||||
async function reject(id: number): Promise<void> {
|
||||
await matchesApi.reject(id)
|
||||
received.value = received.value.filter((m) => m.id !== id)
|
||||
}
|
||||
|
||||
/** 매칭 신청 직후 낙관적으로 진행중 표시. */
|
||||
function markSent(targetDogId: number): void {
|
||||
const next = new Set(sentPendingTargetIds.value)
|
||||
next.add(targetDogId)
|
||||
sentPendingTargetIds.value = next
|
||||
}
|
||||
|
||||
function isPending(targetDogId: number): boolean {
|
||||
return sentPendingTargetIds.value.has(targetDogId)
|
||||
}
|
||||
|
||||
return {
|
||||
received,
|
||||
sentPendingTargetIds,
|
||||
openChatMatchId,
|
||||
connect,
|
||||
disconnect,
|
||||
refresh,
|
||||
accept,
|
||||
reject,
|
||||
markSent,
|
||||
isPending,
|
||||
}
|
||||
})
|
||||
|
||||
function colorFor(type: MatchNotification['type']): string {
|
||||
switch (type) {
|
||||
case 'ACCEPTED':
|
||||
return 'positive'
|
||||
case 'REJECTED':
|
||||
case 'EXPIRED':
|
||||
return 'grey-8'
|
||||
default:
|
||||
return 'primary'
|
||||
}
|
||||
}
|
||||
|
||||
function iconFor(type: MatchNotification['type']): string {
|
||||
switch (type) {
|
||||
case 'NEW_REQUEST':
|
||||
return 'favorite'
|
||||
case 'ACCEPTED':
|
||||
return 'celebration'
|
||||
case 'EXPIRED':
|
||||
return 'schedule'
|
||||
default:
|
||||
return 'heart_broken'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user