feat: 산책갈개 관리자 콘솔 (Vue3+TS+Quasar) — 사용자 앱과 분리된 독립 프로젝트
- 인증: 구글 로그인(GIS) + 개발자 로그인(local), 관리자(ADMIN) 가드 - 대시보드: 요약 카드·접속자 추이(체크인 기반)·티어별·시군구 지역 - 회원 관리: 목록·상태/구독/권한 변경·삭제, 회원 상세(강아지·체크인·매칭·구독 이력) - 구독 관리: 목록·요약·수동 부여/연장/취소 - 자체 토큰키(dn_admin_token)로 사용자 앱과 세션 분리, 백엔드 dognation_BT 연동 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
<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 { 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 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>
|
||||
Reference in New Issue
Block a user