Files
dognation_AT/src/pages/DashboardPage.vue
T
ByungCheol bd5ccb6201 feat(admin): 지역별 매칭건수 통계 화면 + 메뉴 정리
- 통계 화면(/statistics): 시군구 지역별 매칭건수 전체 — 요약카드·막대분포·상세표(비율)
- 대시보드 지역 카드는 상위 5개만 노출 + '전체 통계' 링크
- 메뉴: '피신고자 관리' → '신고 관리', '통계' 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 23:19:07 +09:00

190 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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="row items-center q-pb-none">
<div>
<div class="text-subtitle1 text-weight-medium">지역별 매칭건수 (시군구)</div>
<div class="text-caption text-grey-6">체크인 지역 기준 · 상위 5</div>
</div>
<q-space />
<q-btn flat dense size="sm" color="primary" label="전체 통계" icon-right="chevron_right" :to="{ name: 'statistics' }" />
</q-card-section>
<q-card-section>
<div v-for="r in topRegions" :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 { statsApi, type RegionStat, type StatsSummary, type VisitorPoint, type VisitorUnit } from '@/api/stats'
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']
return [...(summary.value?.members.byTier ?? [])].sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
})
const topRegions = computed(() => regions.value.slice(0, 5))
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 {
return i % (visitors.value.length > 15 ? 5 : 1) === 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([statsApi.summary(), statsApi.visitors(unit.value), statsApi.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 statsApi.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>