Compare commits

...

10 Commits

Author SHA1 Message Date
sb 35d26e3563 fix(ios): 상단 이중 인셋 제거(contentInset never) + 광고 배너를 footer 안으로
CI / build (push) Failing after 13m33s
- contentInset: always→never — CSS safe-area(env inset)와 겹쳐 상하단 여백이
  이중으로 잡히던 문제 해결
- AdBanner 를 q-footer 안(탭바 위)으로 이동 — 콘텐츠가 광고에 가리지 않고
  레이아웃이 footer 높이를 정상 예약. 유료 티어는 배너 숨김으로 공간 회수.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:37:08 +09:00
sb 16d7cf4332 chore: 체크인 옆 스팟 채팅 버튼 제거 (매칭 1:1 채팅만 유지)
CI / build (push) Failing after 11m53s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:08:50 +09:00
sb cc3a0fa836 feat: 매칭 플로우 프론트 (진행중·알림·수락/거절·1:1 채팅)
CI / build (push) Failing after 14m12s
- 매칭 버튼: 신청 후 "진행중" 표시, 무료 소진(429) 안내
- 헤더 알림 벨: 받은 매칭 신청 목록 + 수락/거절
- 실시간 알림: STOMP /topic/users/{id} 구독(신청/수락/거절/만료 토스트)
- 수락 시 1:1 매칭 채팅(MatchChatDialog, /topic/matches/{id})

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 15:04:21 +09:00
sb 12dd26c47f feat: 지도 클릭 시 최근접 스팟 선택 + 현재위치 기능 제거
CI / build (push) Failing after 13m37s
마커가 겹쳐도 선택되도록 지도 클릭 지점에서 가장 가까운 스팟을 선택.
현재위치(Geolocation) 버튼은 스팟 선택 간섭 소지로 현 단계에서 비활성.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 14:04:56 +09:00
sb 0f77f71869 feat: 산책 메이트에서 본인 강아지 제외 (mates 에 userId 전달)
CI / build (push) Failing after 15m8s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:58:30 +09:00
sb 56255c0ae6 feat: 체크인 후 AI 궁합 추천 표시
CI / build (push) Failing after 13m34s
체크인 성공 시 /api/spots/{id}/ai-mates 를 호출해 사이좋게 지낼 만한
강아지를 궁합점수·이유와 함께 표시. 로딩 스피너·빈 상태 처리 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:44:46 +09:00
sb 0e69218710 feat: 지도 카카오로 전환 + 확대/축소·현재위치 (네이버 코드 유지)
CI / build (push) Failing after 11m24s
홈 지도를 Kakao Maps JS SDK 로 교체. ZoomControl(확대/축소)과
현재위치 버튼(@capacitor/geolocation → 파란 점 표시) 추가.
naverMap.ts / NaverMap.vue 는 미사용으로 보존.
시뮬레이터에서 렌더·현재위치 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 13:26:42 +09:00
sb b5f394a7bc fix: 로그인 화면 QLayout 래핑 (QPage 렌더 오류 → 빈 화면 수정)
CI / build (push) Failing after 11m27s
LoginPage 가 MainLayout(QLayout) 밖 독립 라우트라 QPage 조상이 없어
"QPage needs to be a deep child of QLayout" 로 빈 화면이 됐다.
자체 QLayout/QPageContainer 로 감싼다. (관리자 로그인은 이미 래핑됨)
시뮬레이터 실행으로 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:59:16 +09:00
ByungCheol 5f849c552e feat(admin): 관리자 대시보드 + 회원 상세 화면
CI / build (push) Failing after 11m12s
- 대시보드(/admin/dashboard, 기본 랜딩): 요약 카드(총회원·금일 신규/탈퇴·DAU/WAU/MAU),
  접속자 추이(일/주/월 토글), 티어별 회원수, 시군구 지역 분포 — 모두 체크인 기반
- 회원 상세(/admin/members/:id): 프로필 + 강아지/체크인/매칭/구독 이력, 목록에서 상세 버튼
- adminStats API 모듈, 좌측 메뉴에 대시보드 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:38:56 +09:00
sb c1fb8625e7 ci: Gitea Actions 로 전환 (dev 전용, .gitlab-ci.yml 제거)
CI / build (push) Failing after 13m53s
이 인스턴스는 Gitea → .gitlab-ci.yml 미사용. sb-front 컨벤션 따라 .gitea/workflows/ci.yaml.
npm run build(vue-tsc 타입체크 + vite build).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 11:29:42 +09:00
27 changed files with 1440 additions and 85 deletions
+6 -1
View File
@@ -1,7 +1,12 @@
# 백엔드 API 주소 (dognation_BT)
VITE_API_BASE_URL=http://localhost:8080
# 네이버 지도 (네이버 클라우드 플랫폼 > Maps 에서 발급)
# 지도: 현재 카카오 지도 사용 (홈 화면). 네이버 키는 참고용으로 남겨둠.
# 카카오 지도 (Kakao Developers > 내 애플리케이션 > 앱 키 > JavaScript 키)
# 콘솔 > 플랫폼 > Web 에 서비스 도메인 등록 필요 (개발: http://localhost:9000)
VITE_KAKAO_MAP_KEY=your_kakao_javascript_key
# 네이버 지도 (네이버 클라우드 플랫폼 > Maps) — 현재 미사용, 코드는 유지
VITE_NAVER_MAP_CLIENT_ID=your_ncp_key_id
# 신규 키는 ncpKeyId(기본), 구형 키면 ncpClientId 로 변경
# VITE_NAVER_MAP_AUTH_PARAM=ncpKeyId
+33
View File
@@ -0,0 +1,33 @@
name: CI
# dev 브랜치 전용 CI (요청: dev 에만).
on:
push:
branches: [dev]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
# cache 제거: Gitea 캐시 서버 미연결 시 'Save cache' 후처리가 수 분 hang → '가짜 실패' 유발(sb-front 와 동일)
- name: Install dependencies
run: npm ci
# build = vue-tsc 타입체크 + vite 빌드 (test/lint 스크립트는 아직 없음)
- name: Build
run: npm run build
- name: Upload build output
uses: actions/upload-artifact@v3
with:
name: dist
path: dist
retention-days: 7
-26
View File
@@ -1,26 +0,0 @@
# GitLab CI — dognation 프론트 (Vue3 + TS + Vite / Capacitor)
# dev 브랜치 푸시에서만 실행. build 스크립트가 vue-tsc 타입체크 + vite 빌드를 함께 수행.
image: node:22
variables:
npm_config_cache: "$CI_PROJECT_DIR/.npm"
cache:
key: "$CI_COMMIT_REF_SLUG"
paths:
- .npm/
stages:
- build
build:
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "dev"'
script:
- npm ci
- npm run build # vue-tsc --noEmit && vite build
artifacts:
paths:
- dist/
expire_in: 1 week
+2 -1
View File
@@ -5,7 +5,8 @@ const config: CapacitorConfig = {
appName: '산책갈개',
webDir: 'dist',
ios: {
contentInset: 'always',
// never + CSS safe-area(env inset)로 노치/홈바 여백 처리 (이중 인셋 방지)
contentInset: 'never',
},
server: {
androidScheme: 'https',
+19
View File
@@ -11,6 +11,7 @@
"@capacitor-community/apple-sign-in": "^7.1.0",
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.3.4",
"@capacitor/geolocation": "^8.2.0",
"@quasar/extras": "^1.16.12",
"@stomp/stompjs": "^7.3.0",
"pinia": "^2.3.0",
@@ -158,6 +159,18 @@
"tslib": "^2.1.0"
}
},
"node_modules/@capacitor/geolocation": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/@capacitor/geolocation/-/geolocation-8.2.0.tgz",
"integrity": "sha512-N29QcoIPmme0xSxRkm7+3hjoHp6mBAOarxecvtCCZKyOBeKiJsFUq981cezg2XWBa6fhCXJMCCjQPngKK/dIag==",
"license": "MIT",
"dependencies": {
"@capacitor/synapse": "^1.0.4"
},
"peerDependencies": {
"@capacitor/core": ">=8.0.0"
}
},
"node_modules/@capacitor/ios": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/@capacitor/ios/-/ios-8.4.1.tgz",
@@ -168,6 +181,12 @@
"@capacitor/core": "^8.4.0"
}
},
"node_modules/@capacitor/synapse": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.4.tgz",
"integrity": "sha512-/C1FUo8/OkKuAT4nCIu/34ny9siNHr9qtFezu4kxm6GY1wNFxrCFWjfYx5C1tUhVGz3fxBABegupkpjXvjCHrw==",
"license": "ISC"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@capacitor-community/apple-sign-in": "^7.1.0",
"@capacitor/app": "^8.0.0",
"@capacitor/core": "^8.3.4",
"@capacitor/geolocation": "^8.2.0",
"@quasar/extras": "^1.16.12",
"@stomp/stompjs": "^7.3.0",
"pinia": "^2.3.0",
+39
View File
@@ -5,10 +5,49 @@ export type MemberStatus = 'ACTIVE' | 'DORMANT' | 'BANNED'
export type MemberTier = 'FREE' | 'AD_FREE' | 'PREMIUM'
export type MemberRole = 'USER' | 'ADMIN'
export interface DogDetail {
id: number
name: string
breed: string | null
size: string | null
gender: string | null
neutered: boolean
birthDate: string | null
imageUrl: string | null
}
export interface CheckInDetail {
spotName: string
region: string | null
checkedInAt: string
}
export interface MatchDetail {
id: number
direction: string
counterpartDog: string
status: string
createdAt: string
}
export interface SubscriptionDetail {
tier: string
platform: string
startedAt: string
expiresAt: string
active: boolean
}
export interface MemberDetail {
member: Member
dogs: DogDetail[]
checkIns: CheckInDetail[]
matches: MatchDetail[]
subscriptions: SubscriptionDetail[]
}
/** 관리자 회원관리 API — /api/admin/members (ADMIN 전용) */
export const adminMembersApi = {
list: () => http.get<Member[]>('/api/admin/members'),
detail: (id: number) => http.get<MemberDetail>(`/api/admin/members/${id}`),
updateStatus: (id: number, status: MemberStatus) =>
http.put<Member>(`/api/admin/members/${id}/status`, { status }),
+32
View File
@@ -0,0 +1,32 @@
import { http } from './http'
export interface Count {
key: string
count: number
}
export interface StatsSummary {
members: { total: number; byTier: Count[]; byStatus: Count[] }
today: { signups: number; withdrawals: number }
visitors: { dau: number; wau: number; mau: number }
}
export interface VisitorPoint {
label: string
count: number
}
export interface RegionStat {
region: string
checkIns: number
users: number
}
export type VisitorUnit = 'day' | 'week' | 'month'
/** 관리자 대시보드 통계 API (체크인 기반) */
export const adminStatsApi = {
summary: () => http.get<StatsSummary>('/api/admin/stats/summary'),
visitors: (unit: VisitorUnit) => http.get<VisitorPoint[]>(`/api/admin/stats/visitors?unit=${unit}`),
regions: () => http.get<RegionStat[]>('/api/admin/stats/regions'),
}
+4 -1
View File
@@ -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
View File
@@ -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}`),
}
+12 -1
View File
@@ -14,6 +14,14 @@ export interface Mate {
breed: string | null
}
export interface AiMate {
dogId: number
name: string
breed: string | null
score: number
reason: string
}
export interface Review {
id: number
tag: string | null
@@ -30,7 +38,10 @@ export interface CheckInResult {
export const spotsApi = {
list: () => http.get<Spot[]>('/api/spots'),
mates: (spotId: number) => http.get<Mate[]>(`/api/spots/${spotId}/mates`),
mates: (spotId: number, userId?: number) =>
http.get<Mate[]>(`/api/spots/${spotId}/mates${userId ? `?userId=${userId}` : ''}`),
aiMates: (spotId: number, dogId: number) =>
http.get<AiMate[]>(`/api/spots/${spotId}/ai-mates?dogId=${dogId}`),
reviews: (spotId: number) => http.get<Review[]>(`/api/spots/${spotId}/reviews`),
checkIn: (spotId: number, userId: number, dogId: number) =>
http.post<CheckInResult>(`/api/spots/${spotId}/checkins`, { userId, dogId }),
+153
View File
@@ -0,0 +1,153 @@
<template>
<div class="map-wrap">
<div ref="mapEl" class="map-canvas"></div>
<!-- SDK 미로드/ 미설정 폴백 -->
<div v-if="errorMsg" class="map-fallback flex flex-center column text-grey-6">
<q-icon name="map" size="40px" color="secondary" />
<div class="q-mt-sm text-caption text-center">{{ errorMsg }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { loadKakaoMaps } from '@/lib/kakaoMap'
import type { Spot } from '@/api/spots'
const props = defineProps<{
spots: Spot[]
selectedId: number | null
}>()
const emit = defineEmits<{ (e: 'select', id: number): void }>()
// 기본 중심: 서울시청
const DEFAULT_CENTER = { lat: 37.5666, lng: 126.9784 }
const mapEl = ref<HTMLElement | null>(null)
const errorMsg = ref('')
let map: kakao.maps.Map | null = null
let infoWindow: kakao.maps.InfoWindow | null = null
const markers = new Map<number, kakao.maps.Marker>()
onMounted(async () => {
try {
const kk = await loadKakaoMaps()
if (!mapEl.value) return
const center = firstCenter()
map = new kk.maps.Map(mapEl.value, {
center: new kk.maps.LatLng(center.lat, center.lng),
level: 5, // 네이버 zoom 14 ≈ 카카오 level 5
})
// 확대/축소 +/- 컨트롤 (핀치 줌도 함께 동작)
map.addControl(new kk.maps.ZoomControl(), kk.maps.ControlPosition.RIGHT)
infoWindow = new kk.maps.InfoWindow({ removable: false })
// 지도 아무 곳이나 탭하면 가장 가까운 스팟 선택 (마커가 겹쳐도 선택 가능)
kk.maps.event.addListener(map, 'click', (e) => {
if (!e) return
const spot = nearestSpot(e.latLng.getLat(), e.latLng.getLng())
if (spot) emit('select', spot.id)
})
renderMarkers(kk)
} catch (e) {
errorMsg.value = `${(e as Error).message} 지도 키(VITE_KAKAO_MAP_KEY)를 설정하세요.`
}
})
onBeforeUnmount(() => {
markers.forEach((m) => m.setMap(null))
markers.clear()
})
// 스팟 목록이 갱신되면 마커 다시 그림
watch(
() => props.spots,
() => {
if (map && window.kakao?.maps) renderMarkers(window.kakao)
},
)
// 외부에서 선택된 스팟이 바뀌면 지도 중심 이동 + 정보창
watch(
() => props.selectedId,
(id) => {
if (!map || id == null) return
const spot = props.spots.find((s) => s.id === id)
const marker = markers.get(id)
if (spot?.latitude != null && spot?.longitude != null) {
map.panTo(new window.kakao.maps.LatLng(spot.latitude, spot.longitude))
}
if (spot && marker) openInfo(spot, marker)
},
)
function firstCenter() {
const withCoord = props.spots.find((s) => s.latitude != null && s.longitude != null)
return withCoord ? { lat: withCoord.latitude!, lng: withCoord.longitude! } : DEFAULT_CENTER
}
/** 클릭 좌표에서 가장 가까운 스팟(위경도 제곱거리 최소). */
function nearestSpot(lat: number, lng: number): Spot | null {
let best: Spot | null = null
let bestDist = Infinity
for (const s of props.spots) {
if (s.latitude == null || s.longitude == null) continue
const dLat = s.latitude - lat
const dLng = s.longitude - lng
const dist = dLat * dLat + dLng * dLng
if (dist < bestDist) {
bestDist = dist
best = s
}
}
return best
}
function renderMarkers(kk: typeof kakao) {
markers.forEach((m) => m.setMap(null))
markers.clear()
for (const spot of props.spots) {
if (spot.latitude == null || spot.longitude == null) continue
const marker = new kk.maps.Marker({
position: new kk.maps.LatLng(spot.latitude, spot.longitude),
map: map!,
title: spot.name,
})
kk.maps.event.addListener(marker, 'click', () => {
openInfo(spot, marker)
emit('select', spot.id)
})
markers.set(spot.id, marker)
}
}
function openInfo(spot: Spot, marker: kakao.maps.Marker) {
if (!infoWindow || !map) return
infoWindow.setContent(
`<div style="padding:6px 12px;font-size:13px;font-weight:600;color:#3E92CC;white-space:nowrap;">📍 ${spot.name}</div>`,
)
infoWindow.open(map, marker)
}
</script>
<style scoped>
.map-wrap {
position: relative;
height: 240px;
border-radius: 8px;
overflow: hidden;
}
.map-canvas {
width: 100%;
height: 100%;
}
.map-fallback {
position: absolute;
inset: 0;
background: linear-gradient(160deg, #eaf5fe 0%, #d7ecfb 100%);
padding: 16px;
}
</style>
+144
View File
@@ -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>
+2
View File
@@ -11,6 +11,7 @@ interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_NAVER_MAP_CLIENT_ID: string
readonly VITE_NAVER_MAP_AUTH_PARAM?: string
readonly VITE_KAKAO_MAP_KEY: string
}
interface ImportMeta {
@@ -19,4 +20,5 @@ interface ImportMeta {
interface Window {
naver: typeof naver
kakao: typeof kakao
}
+4 -1
View File
@@ -58,7 +58,10 @@ const admin = useAdminAuthStore()
const drawer = ref(true)
const menu = [{ name: 'admin-members', label: '회원 관리', icon: 'group' }]
const menu = [
{ name: 'admin-dashboard', label: '대시보드', icon: 'dashboard' },
{ name: 'admin-members', label: '회원 관리', icon: 'group' },
]
async function onLogout() {
await admin.logout()
+86 -4
View File
@@ -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">
@@ -32,10 +63,9 @@
<router-view />
</q-page-container>
<!-- FREE 티어 하단 배너 광고 -->
<AdBanner />
<q-footer class="bg-white text-grey-8 safe-bottom">
<!-- FREE 티어 하단 배너 광고 (탭바 ). 유료 티어는 숨김 콘텐츠 공간 회수 -->
<AdBanner />
<q-tabs
v-model="tab"
active-color="primary"
@@ -46,22 +76,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 +156,7 @@ const tierLabel = computed(() => {
})
async function onLogout() {
matching.disconnect()
await auth.logout()
dogs.reset()
router.replace({ name: 'login' })
+57
View File
@@ -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(),
}
}
+41
View File
@@ -0,0 +1,41 @@
// 카카오 지도 JS SDK 동적 로더 (싱글턴)
// JavaScript 키는 Kakao Developers > 내 애플리케이션 > 앱 키 > JavaScript 키.
// .env 의 VITE_KAKAO_MAP_KEY 로 주입. 콘솔 > 플랫폼 > Web 에 서비스 도메인 등록 필요
// (개발: http://localhost:9000 / 라이브리로드 IP, 배포: 실제 도메인·capacitor 스킴).
let loadPromise: Promise<typeof kakao> | null = null
export function loadKakaoMaps(): Promise<typeof kakao> {
if (typeof window !== 'undefined' && window.kakao?.maps) {
return Promise.resolve(window.kakao)
}
if (loadPromise) {
return loadPromise
}
const appKey = import.meta.env.VITE_KAKAO_MAP_KEY
loadPromise = new Promise<typeof kakao>((resolve, reject) => {
if (!appKey) {
reject(new Error('VITE_KAKAO_MAP_KEY 가 설정되지 않았습니다.'))
return
}
const script = document.createElement('script')
// autoload=false → 스크립트 로드 후 kakao.maps.load 로 명시적으로 초기화한다.
script.src = `https://dapi.kakao.com/v2/maps/sdk.js?appkey=${appKey}&autoload=false`
script.async = true
script.onload = () => {
// 키/도메인이 유효하지 않으면 load 콜백이 호출되지 않을 수 있어 타임아웃으로 방어.
const timer = setTimeout(
() => reject(new Error('카카오 지도 초기화 실패 · 키/도메인 등록을 확인하세요.')),
8000,
)
window.kakao.maps.load(() => {
clearTimeout(timer)
resolve(window.kakao)
})
}
script.onerror = () => reject(new Error('카카오 지도 SDK 를 불러오지 못했습니다.'))
document.head.appendChild(script)
})
return loadPromise
}
+97 -43
View File
@@ -1,8 +1,8 @@
<template>
<q-page class="q-pa-md">
<!-- 네이버 지도: 스팟 마커 표시, 마커 클릭 해당 스팟 선택 -->
<!-- 카카오 지도: 스팟 마커 표시, 마커 클릭 해당 스팟 선택 -->
<q-card flat bordered class="q-mb-md overflow-hidden">
<NaverMap :spots="spots" :selected-id="currentSpot?.id ?? null" @select="onSelectSpot" />
<KakaoMap :spots="spots" :selected-id="currentSpot?.id ?? null" @select="onSelectSpot" />
</q-card>
<!-- 로딩 / 에러 -->
@@ -11,41 +11,48 @@
백엔드에 연결하지 못했습니다. (서버 실행 여부를 확인하세요)
</q-banner>
<!-- 체크인 + 채팅 -->
<div class="row q-col-gutter-sm">
<div class="col">
<q-btn
color="primary"
class="full-width q-py-sm"
unelevated
:loading="checkingIn"
:disable="!currentSpot"
icon="place"
:label="checkedIn ? '체크인 완료' : '나 지금 여기 도착!'"
@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"
<!-- 체크인 -->
<q-btn
color="primary"
class="full-width q-py-sm"
unelevated
:loading="checkingIn"
:disable="!currentSpot"
icon="place"
:label="checkedIn ? '체크인 완료' : '나 지금 여기 도착!'"
@click="onCheckIn"
/>
<!-- AI 궁합 추천 (체크인 , 스팟의 강아지 사이좋게 지낼 만한 목록) -->
<div v-if="aiLoading || aiTried" class="q-mt-lg">
<div class="text-subtitle1 text-weight-bold q-mb-sm">
<q-icon name="auto_awesome" color="primary" class="q-mr-xs" />AI 궁합 추천
</div>
<div v-if="aiLoading" class="row items-center q-gutter-sm text-grey-6">
<q-spinner-dots size="24px" color="primary" />
<span class="text-caption">사이좋게 지낼 강아지를 찾는 </span>
</div>
<template v-else>
<q-card v-for="m in aiMates" :key="m.dogId" flat bordered class="q-mb-sm">
<q-item>
<q-item-section avatar>
<q-avatar color="primary" text-color="white" icon="favorite" />
</q-item-section>
<q-item-section>
<q-item-label class="text-weight-medium">
{{ m.name }}
<q-badge color="primary" class="q-ml-xs">궁합 {{ m.score }}</q-badge>
</q-item-label>
<q-item-label caption>{{ m.breed || '견종 미상' }} · {{ m.reason }}</q-item-label>
</q-item-section>
</q-item>
</q-card>
<div v-if="!aiMates.length" class="text-caption text-grey-5">
지금 스팟엔 맞는 강아지가 없어요.
</div>
</template>
</div>
<!-- 스팟 한줄평 (네이버 지도식 태그형) -->
<div class="text-subtitle1 text-weight-bold q-mt-lg q-mb-sm">
{{ currentSpot ? currentSpot.name : '스팟' }} · 오늘의 한줄평
@@ -77,6 +84,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"
@@ -94,19 +111,18 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useQuasar } from 'quasar'
import { spotsApi, type Mate, type Review, type Spot } from '@/api/spots'
import { spotsApi, type AiMate, type Mate, type Review, type Spot } from '@/api/spots'
import { matchesApi } from '@/api/matches'
import { ApiError } from '@/api/http'
import { useAuthStore } from '@/stores/auth'
import { useDogsStore } from '@/stores/dogs'
import NaverMap from '@/components/NaverMap.vue'
import ChatDialog from '@/components/ChatDialog.vue'
import { useMatchingStore } from '@/stores/matching'
import KakaoMap from '@/components/KakaoMap.vue'
const $q = useQuasar()
const auth = useAuthStore()
const dogs = useDogsStore()
const chatOpen = ref(false)
const matching = useMatchingStore()
// 체크인/매칭 주체: 로그인 회원 + 대표 강아지
const currentUserId = () => auth.member?.id ?? 0
@@ -120,6 +136,11 @@ const checkingIn = ref(false)
const matchingDogId = ref<number | null>(null)
const loadError = ref(false)
// AI 궁합 추천 (체크인 후 로드)
const aiMates = ref<AiMate[]>([])
const aiLoading = ref(false)
const aiTried = ref(false)
onMounted(load)
async function load() {
@@ -142,11 +163,16 @@ async function onSelectSpot(spotId: number) {
if (!spot) return
currentSpot.value = spot
checkedIn.value = false
aiMates.value = []
aiTried.value = false
await refreshSpot(spotId)
}
async function refreshSpot(spotId: number) {
const [r, m] = await Promise.all([spotsApi.reviews(spotId), spotsApi.mates(spotId)])
const [r, m] = await Promise.all([
spotsApi.reviews(spotId),
spotsApi.mates(spotId, currentUserId()),
])
reviews.value = r
mates.value = m
}
@@ -163,6 +189,7 @@ async function onCheckIn() {
checkedIn.value = true
await refreshSpot(currentSpot.value.id)
$q.notify({ color: 'primary', message: '체크인 완료! 스팟 채팅이 열렸어요.', icon: 'place', position: 'top' })
loadAiMates() // 체크인 후 AI 궁합 추천 (비동기)
} catch (e) {
notifyError(e)
} finally {
@@ -170,6 +197,20 @@ async function onCheckIn() {
}
}
/** 체크인 후 AI 궁합 추천 로드 — 이 스팟의 강아지 중 내 개와 사이좋게 지낼 만한 목록. */
async function loadAiMates() {
if (!currentSpot.value || dogs.currentDogId == null) return
aiLoading.value = true
aiTried.value = true
try {
aiMates.value = await spotsApi.aiMates(currentSpot.value.id, dogs.currentDogId)
} catch {
aiMates.value = []
} finally {
aiLoading.value = false
}
}
async function onMatch(mate: Mate) {
if (dogs.currentDogId == null) {
$q.notify({ color: 'warning', message: '먼저 우리 개를 등록해 주세요.', icon: 'pets', position: 'top' })
@@ -178,9 +219,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
}
+7 -2
View File
@@ -1,5 +1,8 @@
<template>
<q-page class="flex flex-center column q-pa-lg bg-brand">
<!-- 로그인은 MainLayout(QLayout) 밖의 독립 화면 QPage 자체 QLayout 으로 감싼다. -->
<q-layout view="hHh lpR fFf">
<q-page-container>
<q-page class="flex flex-center column q-pa-lg bg-brand">
<div class="column items-center q-mb-xl">
<q-avatar size="88px" color="white" text-color="primary" icon="pets" />
<div class="text-h4 text-weight-bold text-white q-mt-md">산책갈개</div>
@@ -47,7 +50,9 @@
<div class="text-caption text-white q-mt-lg" style="opacity: 0.8">
계속 진행하면 이용약관 개인정보 처리방침에 동의하게 됩니다.
</div>
</q-page>
</q-page>
</q-page-container>
</q-layout>
</template>
<script setup lang="ts">
+253
View File
@@ -0,0 +1,253 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center q-mb-md">
<div class="text-h6 text-weight-bold">대시보드</div>
<q-space />
<q-btn flat round icon="refresh" :loading="loading" @click="loadAll" />
</div>
<q-banner v-if="error" dense class="bg-red-1 text-red-8 q-mb-md rounded-borders">
<template #avatar><q-icon name="wifi_off" /></template>
통계를 불러오지 못했습니다.
</q-banner>
<!-- 요약 카드 -->
<div class="row q-col-gutter-md q-mb-md">
<div v-for="c in cards" :key="c.label" class="col-6 col-sm-4 col-md-2">
<q-card flat bordered>
<q-card-section class="q-pb-xs">
<div class="row items-center no-wrap">
<q-icon :name="c.icon" :color="c.color" size="20px" class="q-mr-xs" />
<div class="text-caption text-grey-7">{{ c.label }}</div>
</div>
<div class="text-h5 text-weight-bold q-mt-xs">{{ c.value }}</div>
<div v-if="c.sub" class="text-caption text-grey-6">{{ c.sub }}</div>
</q-card-section>
</q-card>
</div>
</div>
<!-- 접속자 추이 (체크인 기반) -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-medium">접속자 추이</div>
<div class="text-caption text-grey-6 q-ml-sm">체크인 순사용자</div>
<q-space />
<q-btn-toggle
v-model="unit"
dense
unelevated
toggle-color="primary"
size="sm"
:options="[
{ label: '일간', value: 'day' },
{ label: '주간', value: 'week' },
{ label: '월간', value: 'month' },
]"
@update:model-value="loadVisitors"
/>
</q-card-section>
<q-card-section>
<div v-if="visitors.length" class="chart">
<div
v-for="(p, i) in visitors"
:key="i"
class="chart-col"
>
<div class="chart-bar-wrap">
<div
class="chart-bar"
:style="{ height: barHeight(p.count) }"
:title="`${p.label}: ${p.count}명`"
/>
</div>
<div class="chart-label">{{ labelEvery(i) ? p.label : '' }}</div>
</div>
</div>
<div v-else class="text-caption text-grey-5 q-py-md text-center">데이터 없음</div>
</q-card-section>
</q-card>
<div class="row q-col-gutter-md">
<!-- 티어별 회원수 -->
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section class="q-pb-none">
<div class="text-subtitle1 text-weight-medium">티어별 회원수</div>
</q-card-section>
<q-card-section>
<div v-for="t in tierRows" :key="t.key" class="q-mb-sm">
<div class="row items-center q-mb-xs">
<div class="text-body2">{{ tierLabel(t.key) }}</div>
<q-space />
<div class="text-body2 text-weight-medium">{{ t.count }}</div>
</div>
<div class="track">
<div class="fill" :style="{ width: pct(t.count, memberTotal), background: tierColor(t.key) }" />
</div>
</div>
<div v-if="!tierRows.length" class="text-caption text-grey-5">데이터 없음</div>
</q-card-section>
</q-card>
</div>
<!-- 시군구 매칭(체크인) 지역 -->
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section class="q-pb-none">
<div class="text-subtitle1 text-weight-medium">지역별 매칭 (시군구)</div>
<div class="text-caption text-grey-6">체크인 지역 기준</div>
</q-card-section>
<q-card-section>
<div v-for="r in regions" :key="r.region" class="q-mb-sm">
<div class="row items-center q-mb-xs">
<div class="text-body2">{{ r.region }}</div>
<q-space />
<div class="text-caption text-grey-6 q-mr-sm">{{ r.users }}</div>
<div class="text-body2 text-weight-medium">{{ r.checkIns }}</div>
</div>
<div class="track">
<div class="fill" :style="{ width: pct(r.checkIns, regionMax), background: '#6DB3E8' }" />
</div>
</div>
<div v-if="!regions.length" class="text-caption text-grey-5">데이터 없음</div>
</q-card-section>
</q-card>
</div>
</div>
</q-page>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
adminStatsApi,
type RegionStat,
type StatsSummary,
type VisitorPoint,
type VisitorUnit,
} from '@/api/adminStats'
const loading = ref(false)
const error = ref(false)
const summary = ref<StatsSummary | null>(null)
const visitors = ref<VisitorPoint[]>([])
const regions = ref<RegionStat[]>([])
const unit = ref<VisitorUnit>('day')
const cards = computed(() => {
const s = summary.value
return [
{ label: '총 회원', value: s?.members.total ?? '', icon: 'group', color: 'primary', sub: '' },
{ label: '금일 신규', value: s?.today.signups ?? '', icon: 'person_add', color: 'positive', sub: '' },
{ label: '금일 탈퇴', value: s?.today.withdrawals ?? '', icon: 'person_remove', color: 'negative', sub: '' },
{ label: 'DAU', value: s?.visitors.dau ?? '', icon: 'today', color: 'primary', sub: '오늘 접속' },
{ label: 'WAU', value: s?.visitors.wau ?? '', icon: 'date_range', color: 'primary', sub: '최근 7일' },
{ label: 'MAU', value: s?.visitors.mau ?? '', icon: 'calendar_month', color: 'primary', sub: '최근 30일' },
]
})
const memberTotal = computed(() => summary.value?.members.total ?? 0)
const tierRows = computed(() => {
const order = ['FREE', 'AD_FREE', 'PREMIUM']
const rows = summary.value?.members.byTier ?? []
return [...rows].sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
})
const regionMax = computed(() => Math.max(1, ...regions.value.map((r) => r.checkIns)))
const visitorMax = computed(() => Math.max(1, ...visitors.value.map((v) => v.count)))
function barHeight(count: number): string {
return `${Math.round((count / visitorMax.value) * 100)}%`
}
function pct(v: number, total: number): string {
return `${total > 0 ? Math.round((v / total) * 100) : 0}%`
}
function labelEvery(i: number): boolean {
const step = visitors.value.length > 15 ? 5 : 1
return i % step === 0
}
function tierLabel(key: string): string {
return key === 'AD_FREE' ? '광고제거' : key
}
function tierColor(key: string): string {
return key === 'PREMIUM' ? '#FFB300' : key === 'AD_FREE' ? '#4DB6AC' : '#90A4AE'
}
onMounted(loadAll)
async function loadAll() {
loading.value = true
error.value = false
try {
const [s, v, r] = await Promise.all([
adminStatsApi.summary(),
adminStatsApi.visitors(unit.value),
adminStatsApi.regions(),
])
summary.value = s
visitors.value = v
regions.value = r
} catch (e) {
error.value = true
console.error(e)
} finally {
loading.value = false
}
}
async function loadVisitors() {
try {
visitors.value = await adminStatsApi.visitors(unit.value)
} catch (e) {
console.error(e)
}
}
</script>
<style scoped>
.chart {
display: flex;
align-items: flex-end;
gap: 3px;
height: 180px;
}
.chart-col {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.chart-bar-wrap {
width: 100%;
height: 160px;
display: flex;
align-items: flex-end;
justify-content: center;
}
.chart-bar {
width: 70%;
min-height: 2px;
background: #6db3e8;
border-radius: 3px 3px 0 0;
transition: height 0.2s;
}
.chart-label {
font-size: 10px;
color: #90a4ae;
margin-top: 4px;
white-space: nowrap;
height: 14px;
}
.track {
height: 8px;
background: #eceff1;
border-radius: 4px;
overflow: hidden;
}
.fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s;
}
</style>
+1 -1
View File
@@ -126,7 +126,7 @@ async function finishLogin(login: () => Promise<{ role: string }>) {
error.value = '관리자 권한이 없는 계정입니다.'
return
}
await router.replace({ name: 'admin-members' })
await router.replace({ name: 'admin-dashboard' })
} catch (e) {
error.value = e instanceof ApiError ? e.message : '로그인 중 오류가 발생했습니다.'
} finally {
+203
View File
@@ -0,0 +1,203 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center q-mb-md">
<q-btn flat dense round icon="arrow_back" @click="goBack" />
<div class="text-h6 text-weight-bold q-ml-sm">회원 상세</div>
<q-space />
<q-btn flat round icon="refresh" :loading="loading" @click="load" />
</div>
<q-banner v-if="error" dense class="bg-red-1 text-red-8 q-mb-md rounded-borders">
<template #avatar><q-icon name="error" /></template>
회원 정보를 불러오지 못했습니다.
</q-banner>
<template v-if="detail">
<!-- 프로필 헤더 -->
<q-card flat bordered class="q-mb-md">
<q-card-section class="row items-center no-wrap">
<q-avatar size="56px" color="blue-1" text-color="primary">
<img v-if="m.profileImage" :src="m.profileImage" alt="" />
<q-icon v-else name="person" size="32px" />
</q-avatar>
<div class="q-ml-md">
<div class="text-h6 text-weight-bold">{{ m.nickname }}</div>
<div class="text-caption text-grey-6">{{ m.email }}</div>
<div class="q-mt-xs">
<q-badge :color="tierColor(m.subscriptionTier)" class="q-mr-xs">{{ m.subscriptionTier }}</q-badge>
<q-badge :color="statusColor(m.status)" class="q-mr-xs">{{ statusLabel(m.status) }}</q-badge>
<q-badge v-if="m.role === 'ADMIN'" color="deep-purple" class="q-mr-xs">관리자</q-badge>
<q-badge outline color="grey-7">{{ m.provider }}</q-badge>
</div>
</div>
<q-space />
<div class="text-caption text-grey-6 self-start">가입 {{ date(m.createdAt) }}</div>
</q-card-section>
</q-card>
<div class="row q-col-gutter-md">
<!-- 강아지 -->
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section class="q-pb-none">
<div class="text-subtitle1 text-weight-medium">강아지 ({{ detail.dogs.length }})</div>
</q-card-section>
<q-card-section>
<div v-if="!detail.dogs.length" class="text-caption text-grey-5">등록된 강아지 없음</div>
<q-item v-for="d in detail.dogs" :key="d.id" class="q-px-none">
<q-item-section avatar>
<q-avatar color="secondary" text-color="white" icon="pets" />
</q-item-section>
<q-item-section>
<q-item-label class="text-weight-medium">{{ d.name }}</q-item-label>
<q-item-label caption>
{{ [d.breed, sizeLabel(d.size), genderLabel(d.gender), d.neutered ? '중성화' : null]
.filter(Boolean).join(' · ') || '정보 없음' }}
</q-item-label>
</q-item-section>
</q-item>
</q-card-section>
</q-card>
</div>
<!-- 구독 이력 -->
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section class="q-pb-none">
<div class="text-subtitle1 text-weight-medium">구독 이력 ({{ detail.subscriptions.length }})</div>
</q-card-section>
<q-card-section>
<div v-if="!detail.subscriptions.length" class="text-caption text-grey-5">구독 내역 없음</div>
<q-markup-table v-else flat dense wrap-cells>
<thead>
<tr><th class="text-left">티어</th><th class="text-left">플랫폼</th><th class="text-left">기간</th><th>상태</th></tr>
</thead>
<tbody>
<tr v-for="(s, i) in detail.subscriptions" :key="i">
<td>{{ s.tier }}</td>
<td>{{ s.platform }}</td>
<td class="text-caption">{{ date(s.startedAt) }} ~ {{ date(s.expiresAt) }}</td>
<td class="text-center">
<q-badge :color="s.active ? 'positive' : 'grey'">{{ s.active ? '활성' : '만료' }}</q-badge>
</td>
</tr>
</tbody>
</q-markup-table>
</q-card-section>
</q-card>
</div>
<!-- 체크인 이력 -->
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section class="q-pb-none">
<div class="text-subtitle1 text-weight-medium">체크인 이력 (최근 {{ detail.checkIns.length }})</div>
</q-card-section>
<q-card-section>
<div v-if="!detail.checkIns.length" class="text-caption text-grey-5">체크인 없음</div>
<q-list v-else separator>
<q-item v-for="(c, i) in detail.checkIns" :key="i" class="q-px-none">
<q-item-section avatar>
<q-icon name="place" color="primary" />
</q-item-section>
<q-item-section>
<q-item-label>{{ c.spotName }}</q-item-label>
<q-item-label caption>{{ c.region || '미지정' }}</q-item-label>
</q-item-section>
<q-item-section side class="text-caption text-grey-6">{{ dateTime(c.checkedInAt) }}</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
<!-- 매칭 이력 -->
<div class="col-12 col-md-6">
<q-card flat bordered>
<q-card-section class="q-pb-none">
<div class="text-subtitle1 text-weight-medium">매칭 이력 (최근 {{ detail.matches.length }})</div>
</q-card-section>
<q-card-section>
<div v-if="!detail.matches.length" class="text-caption text-grey-5">매칭 없음</div>
<q-markup-table v-else flat dense wrap-cells>
<thead>
<tr><th>구분</th><th class="text-left">상대</th><th>상태</th><th class="text-left">일시</th></tr>
</thead>
<tbody>
<tr v-for="mt in detail.matches" :key="mt.id">
<td class="text-center">
<q-badge :color="mt.direction === '보냄' ? 'primary' : 'teal'">{{ mt.direction }}</q-badge>
</td>
<td>{{ mt.counterpartDog }}</td>
<td class="text-center"><q-badge :color="matchColor(mt.status)">{{ mt.status }}</q-badge></td>
<td class="text-caption">{{ dateTime(mt.createdAt) }}</td>
</tr>
</tbody>
</q-markup-table>
</q-card-section>
</q-card>
</div>
</div>
</template>
</q-page>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { adminMembersApi, type MemberDetail } from '@/api/adminMembers'
const route = useRoute()
const router = useRouter()
const detail = ref<MemberDetail | null>(null)
const loading = ref(false)
const error = ref(false)
const m = computed(() => detail.value!.member)
const memberId = computed(() => Number(route.params.id))
onMounted(load)
async function load() {
loading.value = true
error.value = false
try {
detail.value = await adminMembersApi.detail(memberId.value)
} catch (e) {
error.value = true
console.error(e)
} finally {
loading.value = false
}
}
function goBack() {
router.push({ name: 'admin-members' })
}
function date(s: string | null): string {
return s ? new Date(s).toLocaleDateString('ko-KR') : '-'
}
function dateTime(s: string): string {
return new Date(s).toLocaleString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function tierColor(t: string): string {
return t === 'PREMIUM' ? 'amber-8' : t === 'AD_FREE' ? 'teal' : 'blue-grey'
}
function statusColor(s: string): string {
return s === 'ACTIVE' ? 'positive' : s === 'BANNED' ? 'negative' : 'grey'
}
function statusLabel(s: string): string {
return s === 'ACTIVE' ? '활성' : s === 'BANNED' ? '정지' : '휴면'
}
function matchColor(s: string): string {
return s === 'ACCEPTED' ? 'positive' : s === 'REJECTED' || s === 'CANCELED' ? 'negative' : 'orange'
}
function sizeLabel(s: string | null): string | null {
return s === 'SMALL' ? '소형' : s === 'MEDIUM' ? '중형' : s === 'LARGE' ? '대형' : null
}
function genderLabel(g: string | null): string | null {
return g === 'MALE' ? '수컷' : g === 'FEMALE' ? '암컷' : null
}
</script>
+13 -1
View File
@@ -107,9 +107,15 @@
</q-td>
</template>
<!-- 삭제 -->
<!-- 상세 / 삭제 -->
<template #body-cell-actions="props">
<q-td :props="props">
<q-btn
flat dense round icon="visibility" color="primary"
@click="goDetail(props.row)"
>
<q-tooltip>상세</q-tooltip>
</q-btn>
<q-btn
flat dense round icon="delete" color="negative"
:disable="isSelf(props.row) || busyId === props.row.id"
@@ -123,6 +129,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar, type QTableColumn } from 'quasar'
import {
adminMembersApi,
@@ -135,6 +142,7 @@ import { ApiError } from '@/api/http'
import { useAdminAuthStore } from '@/stores/adminAuth'
const $q = useQuasar()
const router = useRouter()
const admin = useAdminAuthStore()
const members = ref<Member[]>([])
@@ -181,6 +189,10 @@ function isSelf(m: Member): boolean {
return m.id === admin.member?.id
}
function goDetail(m: Member) {
router.push({ name: 'admin-member-detail', params: { id: m.id } })
}
async function load() {
loading.value = true
loadError.value = false
+12 -2
View File
@@ -29,12 +29,22 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/layouts/AdminLayout.vue'),
meta: { requiresAdmin: true },
children: [
{ path: '', redirect: { name: 'admin-members' } },
{ path: '', redirect: { name: 'admin-dashboard' } },
{
path: 'dashboard',
name: 'admin-dashboard',
component: () => import('@/pages/admin/AdminDashboardPage.vue'),
},
{
path: 'members',
name: 'admin-members',
component: () => import('@/pages/admin/AdminMembersPage.vue'),
},
{
path: 'members/:id',
name: 'admin-member-detail',
component: () => import('@/pages/admin/AdminMemberDetailPage.vue'),
},
],
},
]
@@ -72,7 +82,7 @@ router.beforeEach(async (to) => {
return { name: 'admin-login' }
}
if (to.name === 'admin-login' && admin.isAdmin) {
return { name: 'admin-members' }
return { name: 'admin-dashboard' }
}
return true
})
+122
View File
@@ -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'
}
}
+76
View File
@@ -0,0 +1,76 @@
// 카카오 지도 JS SDK 최소 타입 선언 (프로젝트에서 사용하는 API 서브셋).
// 공식 @types 가 없어 필요한 부분만 선언한다.
declare namespace kakao.maps {
class LatLng {
constructor(lat: number, lng: number)
getLat(): number
getLng(): number
}
class Map {
constructor(container: HTMLElement, options: { center: LatLng; level?: number })
panTo(latlng: LatLng): void
setCenter(latlng: LatLng): void
setLevel(level: number): void
getLevel(): number
addControl(control: ZoomControl, position: ControlPosition): void
}
// 지도 확대/축소 +/- 위젯
class ZoomControl {}
enum ControlPosition {
TOP,
TOPLEFT,
TOPRIGHT,
LEFT,
RIGHT,
BOTTOMLEFT,
BOTTOM,
BOTTOMRIGHT,
}
// 현재위치 표시용 원(파란 점)
class Circle {
constructor(options: {
center: LatLng
radius: number
strokeWeight?: number
strokeColor?: string
strokeOpacity?: number
fillColor?: string
fillOpacity?: number
})
setMap(map: Map | null): void
setPosition(latlng: LatLng): void
}
class Marker {
constructor(options: { position: LatLng; map?: Map; title?: string })
setMap(map: Map | null): void
getPosition(): LatLng
}
class InfoWindow {
constructor(options: { content?: string; removable?: boolean })
setContent(content: string): void
open(map: Map, marker?: Marker): void
close(): void
}
// 지도 클릭 등 좌표를 포함하는 이벤트
interface MouseEvent {
latLng: LatLng
}
namespace event {
function addListener(
target: unknown,
type: string,
handler: (event?: MouseEvent) => void,
): void
}
// autoload=false 로 로드한 뒤 SDK 초기화 콜백.
function load(callback: () => void): void
}