feat: 실시간 채팅 UI (STOMP 연동)
- ChatDialog: 히스토리 로드 + 실시간 송수신, 내 메시지 우측 정렬, 연결상태 표시 - lib/chatSocket: STOMP 클라이언트(브로커 ws, Bearer 인증, 자동 재연결) - api/chat 히스토리, HomePage 스팟 채팅 버튼 - @stomp/stompjs 의존성 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+7
@@ -11,6 +11,7 @@
|
|||||||
"@capacitor/app": "^8.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
"@capacitor/core": "^8.3.4",
|
"@capacitor/core": "^8.3.4",
|
||||||
"@quasar/extras": "^1.16.12",
|
"@quasar/extras": "^1.16.12",
|
||||||
|
"@stomp/stompjs": "^7.3.0",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
"quasar": "^2.17.4",
|
"quasar": "^2.17.4",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
@@ -1509,6 +1510,12 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@stomp/stompjs": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||||
|
|||||||
+2
-1
@@ -15,6 +15,7 @@
|
|||||||
"@capacitor/app": "^8.0.0",
|
"@capacitor/app": "^8.0.0",
|
||||||
"@capacitor/core": "^8.3.4",
|
"@capacitor/core": "^8.3.4",
|
||||||
"@quasar/extras": "^1.16.12",
|
"@quasar/extras": "^1.16.12",
|
||||||
|
"@stomp/stompjs": "^7.3.0",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
"quasar": "^2.17.4",
|
"quasar": "^2.17.4",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
@@ -24,8 +25,8 @@
|
|||||||
"@capacitor/android": "^8.3.4",
|
"@capacitor/android": "^8.3.4",
|
||||||
"@capacitor/cli": "^8.3.4",
|
"@capacitor/cli": "^8.3.4",
|
||||||
"@capacitor/ios": "^8.3.4",
|
"@capacitor/ios": "^8.3.4",
|
||||||
"@types/navermaps": "^3.7.5",
|
|
||||||
"@quasar/vite-plugin": "^1.9.0",
|
"@quasar/vite-plugin": "^1.9.0",
|
||||||
|
"@types/navermaps": "^3.7.5",
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
"sass-embedded": "^1.83.0",
|
"sass-embedded": "^1.83.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { http } from './http'
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
id: number
|
||||||
|
spotId: number
|
||||||
|
senderId: number
|
||||||
|
senderNickname: string
|
||||||
|
content: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chatApi = {
|
||||||
|
history: (spotId: number) => http.get<ChatMessage[]>(`/api/chat/spots/${spotId}/messages`),
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<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="chat" class="q-mr-sm" />
|
||||||
|
<q-toolbar-title>{{ spotName }} 채팅</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, useQuasar } from 'quasar'
|
||||||
|
import { chatApi, type ChatMessage } from '@/api/chat'
|
||||||
|
import { createSpotChat } from '@/lib/chatSocket'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean; spotId: number; spotName: string }>()
|
||||||
|
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
|
||||||
|
|
||||||
|
const $q = useQuasar()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
const messages = ref<ChatMessage[]>([])
|
||||||
|
const draft = ref('')
|
||||||
|
const connected = ref(false)
|
||||||
|
const scrollArea = ref<QScrollArea | null>(null)
|
||||||
|
let chat: ReturnType<typeof createSpotChat> | 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.history(props.spotId)
|
||||||
|
scrollToBottom()
|
||||||
|
} catch {
|
||||||
|
// 히스토리 실패해도 실시간 연결은 시도
|
||||||
|
}
|
||||||
|
chat = createSpotChat(
|
||||||
|
props.spotId,
|
||||||
|
(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>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Client, type IMessage } from '@stomp/stompjs'
|
||||||
|
import { tokenStore } from '@/lib/token'
|
||||||
|
import type { ChatMessage } from '@/api/chat'
|
||||||
|
|
||||||
|
function wsUrl(): string {
|
||||||
|
const base = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
|
||||||
|
// http(s) -> ws(s)
|
||||||
|
return base.replace(/^http/, 'ws') + '/ws'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스팟 채팅용 STOMP 클라이언트.
|
||||||
|
* connect 후 스팟 토픽을 구독하고, send 로 메시지를 전송한다.
|
||||||
|
*/
|
||||||
|
export function createSpotChat(
|
||||||
|
spotId: number,
|
||||||
|
onMessage: (m: ChatMessage) => void,
|
||||||
|
onStatus?: (connected: boolean) => void,
|
||||||
|
) {
|
||||||
|
const client = new Client({
|
||||||
|
brokerURL: wsUrl(),
|
||||||
|
connectHeaders: { Authorization: `Bearer ${tokenStore.get() ?? ''}` },
|
||||||
|
reconnectDelay: 3000,
|
||||||
|
onConnect: () => {
|
||||||
|
onStatus?.(true)
|
||||||
|
client.subscribe(`/topic/spots/${spotId}`, (msg: IMessage) => {
|
||||||
|
onMessage(JSON.parse(msg.body) as ChatMessage)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onDisconnect: () => onStatus?.(false),
|
||||||
|
onWebSocketClose: () => onStatus?.(false),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
activate: () => client.activate(),
|
||||||
|
deactivate: () => client.deactivate(),
|
||||||
|
send: (content: string) => {
|
||||||
|
client.publish({
|
||||||
|
destination: `/app/spots/${spotId}/chat`,
|
||||||
|
body: JSON.stringify({ content }),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
isConnected: () => client.connected,
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-2
@@ -11,7 +11,9 @@
|
|||||||
백엔드에 연결하지 못했습니다. (서버 실행 여부를 확인하세요)
|
백엔드에 연결하지 못했습니다. (서버 실행 여부를 확인하세요)
|
||||||
</q-banner>
|
</q-banner>
|
||||||
|
|
||||||
<!-- 체크인 -->
|
<!-- 체크인 + 채팅 -->
|
||||||
|
<div class="row q-col-gutter-sm">
|
||||||
|
<div class="col">
|
||||||
<q-btn
|
<q-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
class="full-width q-py-sm"
|
class="full-width q-py-sm"
|
||||||
@@ -19,9 +21,30 @@
|
|||||||
:loading="checkingIn"
|
:loading="checkingIn"
|
||||||
:disable="!currentSpot"
|
:disable="!currentSpot"
|
||||||
icon="place"
|
icon="place"
|
||||||
:label="checkedIn ? '체크인 완료 · 여기 있어요!' : '나 지금 여기 도착!'"
|
:label="checkedIn ? '체크인 완료' : '나 지금 여기 도착!'"
|
||||||
@click="onCheckIn"
|
@click="onCheckIn"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<q-btn
|
||||||
|
color="accent"
|
||||||
|
class="full-height q-px-md"
|
||||||
|
outline
|
||||||
|
:disable="!currentSpot"
|
||||||
|
icon="chat"
|
||||||
|
label="채팅"
|
||||||
|
@click="chatOpen = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 스팟 채팅 -->
|
||||||
|
<ChatDialog
|
||||||
|
v-if="currentSpot"
|
||||||
|
v-model="chatOpen"
|
||||||
|
:spot-id="currentSpot.id"
|
||||||
|
:spot-name="currentSpot.name"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 스팟 한줄평 (네이버 지도식 태그형) -->
|
<!-- 스팟 한줄평 (네이버 지도식 태그형) -->
|
||||||
<div class="text-subtitle1 text-weight-bold q-mt-lg q-mb-sm">
|
<div class="text-subtitle1 text-weight-bold q-mt-lg q-mb-sm">
|
||||||
@@ -77,11 +100,14 @@ import { ApiError } from '@/api/http'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useDogsStore } from '@/stores/dogs'
|
import { useDogsStore } from '@/stores/dogs'
|
||||||
import NaverMap from '@/components/NaverMap.vue'
|
import NaverMap from '@/components/NaverMap.vue'
|
||||||
|
import ChatDialog from '@/components/ChatDialog.vue'
|
||||||
|
|
||||||
const $q = useQuasar()
|
const $q = useQuasar()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const dogs = useDogsStore()
|
const dogs = useDogsStore()
|
||||||
|
|
||||||
|
const chatOpen = ref(false)
|
||||||
|
|
||||||
// 체크인/매칭 주체: 로그인 회원 + 대표 강아지
|
// 체크인/매칭 주체: 로그인 회원 + 대표 강아지
|
||||||
const currentUserId = () => auth.member?.id ?? 0
|
const currentUserId = () => auth.member?.id ?? 0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user