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>
This commit is contained in:
ByungCheol
2026-07-11 11:38:54 +09:00
parent c1fb8625e7
commit 5f849c552e
8 changed files with 557 additions and 5 deletions
+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
@@ -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()
+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
})