feat(admin): 지역별 매칭건수 통계 화면 + 메뉴 정리
- 통계 화면(/statistics): 시군구 지역별 매칭건수 전체 — 요약카드·막대분포·상세표(비율) - 대시보드 지역 카드는 상위 5개만 노출 + '전체 통계' 링크 - 메뉴: '피신고자 관리' → '신고 관리', '통계' 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -59,9 +59,10 @@ const drawer = ref(true)
|
||||
|
||||
const menu = [
|
||||
{ name: 'dashboard', label: '대시보드', icon: 'dashboard' },
|
||||
{ name: 'statistics', label: '통계', icon: 'insights' },
|
||||
{ name: 'members', label: '회원 관리', icon: 'group' },
|
||||
{ name: 'subscriptions', label: '구독 관리', icon: 'card_membership' },
|
||||
{ name: 'reports', label: '피신고자 관리', icon: 'report' },
|
||||
{ name: 'reports', label: '신고 관리', icon: 'report' },
|
||||
{ name: 'blacklist', label: '블랙리스트', icon: 'block' },
|
||||
]
|
||||
|
||||
|
||||
@@ -77,12 +77,16 @@
|
||||
|
||||
<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 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 regions" :key="r.region" class="q-mb-sm">
|
||||
<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 />
|
||||
@@ -127,6 +131,7 @@ 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)))
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<q-page class="q-pa-md">
|
||||
<div class="row items-center q-mb-md">
|
||||
<div class="text-h6 text-weight-bold">지역별 매칭건수</div>
|
||||
<div class="text-caption text-grey-6 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="wifi_off" /></template>
|
||||
통계를 불러오지 못했습니다.
|
||||
</q-banner>
|
||||
|
||||
<div class="row q-col-gutter-md q-mb-md">
|
||||
<div class="col-6 col-md-3">
|
||||
<q-card flat bordered><q-card-section>
|
||||
<div class="text-caption text-grey-7">지역 수</div>
|
||||
<div class="text-h5 text-weight-bold">{{ regions.length }}</div>
|
||||
</q-card-section></q-card>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<q-card flat bordered><q-card-section>
|
||||
<div class="text-caption text-grey-7">총 매칭건수</div>
|
||||
<div class="text-h5 text-weight-bold">{{ totalCheckIns }}</div>
|
||||
</q-card-section></q-card>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<q-card flat bordered><q-card-section>
|
||||
<div class="text-caption text-grey-7">최다 지역</div>
|
||||
<div class="text-h6 text-weight-bold">{{ regions[0]?.region ?? '–' }}</div>
|
||||
</q-card-section></q-card>
|
||||
</div>
|
||||
<div class="col-6 col-md-3">
|
||||
<q-card flat bordered><q-card-section>
|
||||
<div class="text-caption text-grey-7">총 방문자</div>
|
||||
<div class="text-h5 text-weight-bold">{{ totalUsers }}</div>
|
||||
</q-card-section></q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 지역별 막대 -->
|
||||
<q-card flat bordered class="q-mb-md">
|
||||
<q-card-section class="q-pb-none">
|
||||
<div class="text-subtitle1 text-weight-medium">지역별 분포</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<div v-if="!regions.length" class="text-caption text-grey-5">데이터 없음</div>
|
||||
<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 text-weight-medium" style="width: 92px">{{ r.region }}</div>
|
||||
<div class="col track"><div class="fill" :style="{ width: pct(r.checkIns), background: '#6DB3E8' }" /></div>
|
||||
<div class="text-body2 text-weight-medium q-ml-sm" style="width: 64px; text-align: right">{{ r.checkIns }}건</div>
|
||||
</div>
|
||||
</div>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
|
||||
<!-- 상세 표 -->
|
||||
<q-table
|
||||
:rows="regions"
|
||||
:columns="columns"
|
||||
row-key="region"
|
||||
:loading="loading"
|
||||
:pagination="{ rowsPerPage: 0 }"
|
||||
hide-pagination
|
||||
flat
|
||||
bordered
|
||||
>
|
||||
<template #body-cell-share="props">
|
||||
<q-td :props="props" class="text-right">{{ share(props.row.checkIns) }}%</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { QTableColumn } from 'quasar'
|
||||
import { statsApi, type RegionStat } from '@/api/stats'
|
||||
|
||||
const regions = ref<RegionStat[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
|
||||
const totalCheckIns = computed(() => regions.value.reduce((s, r) => s + r.checkIns, 0))
|
||||
const totalUsers = computed(() => regions.value.reduce((s, r) => s + r.users, 0))
|
||||
const regionMax = computed(() => Math.max(1, ...regions.value.map((r) => r.checkIns)))
|
||||
|
||||
const columns: QTableColumn<RegionStat>[] = [
|
||||
{ name: 'region', label: '시군구', field: 'region', align: 'left', sortable: true },
|
||||
{ name: 'checkIns', label: '매칭건수', field: 'checkIns', align: 'right', sortable: true },
|
||||
{ name: 'users', label: '방문자', field: 'users', align: 'right', sortable: true },
|
||||
{ name: 'share', label: '비율', field: 'checkIns', align: 'right', sortable: true },
|
||||
]
|
||||
|
||||
function pct(v: number): string {
|
||||
return `${Math.round((v / regionMax.value) * 100)}%`
|
||||
}
|
||||
function share(v: number): string {
|
||||
return totalCheckIns.value > 0 ? ((v / totalCheckIns.value) * 100).toFixed(1) : '0.0'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
regions.value = await statsApi.regions()
|
||||
} catch (e) {
|
||||
error.value = true
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.track { height: 12px; background: #eceff1; border-radius: 6px; overflow: hidden; }
|
||||
.fill { height: 100%; border-radius: 6px; transition: width 0.3s; }
|
||||
</style>
|
||||
@@ -14,6 +14,7 @@ const routes: RouteRecordRaw[] = [
|
||||
children: [
|
||||
{ path: '', redirect: { name: 'dashboard' } },
|
||||
{ path: 'dashboard', name: 'dashboard', component: () => import('@/pages/DashboardPage.vue') },
|
||||
{ path: 'statistics', name: 'statistics', component: () => import('@/pages/StatisticsPage.vue') },
|
||||
{ path: 'members', name: 'members', component: () => import('@/pages/MembersPage.vue') },
|
||||
{ path: 'members/:id', name: 'member-detail', component: () => import('@/pages/MemberDetailPage.vue') },
|
||||
{ path: 'subscriptions', name: 'subscriptions', component: () => import('@/pages/SubscriptionsPage.vue') },
|
||||
|
||||
Reference in New Issue
Block a user