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 = [
|
const menu = [
|
||||||
{ name: 'dashboard', label: '대시보드', icon: 'dashboard' },
|
{ name: 'dashboard', label: '대시보드', icon: 'dashboard' },
|
||||||
|
{ name: 'statistics', label: '통계', icon: 'insights' },
|
||||||
{ name: 'members', label: '회원 관리', icon: 'group' },
|
{ name: 'members', label: '회원 관리', icon: 'group' },
|
||||||
{ name: 'subscriptions', label: '구독 관리', icon: 'card_membership' },
|
{ name: 'subscriptions', label: '구독 관리', icon: 'card_membership' },
|
||||||
{ name: 'reports', label: '피신고자 관리', icon: 'report' },
|
{ name: 'reports', label: '신고 관리', icon: 'report' },
|
||||||
{ name: 'blacklist', label: '블랙리스트', icon: 'block' },
|
{ name: 'blacklist', label: '블랙리스트', icon: 'block' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -77,12 +77,16 @@
|
|||||||
|
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
<q-card flat bordered>
|
<q-card flat bordered>
|
||||||
<q-card-section class="q-pb-none">
|
<q-card-section class="row items-center q-pb-none">
|
||||||
<div class="text-subtitle1 text-weight-medium">지역별 매칭 (시군구)</div>
|
<div>
|
||||||
<div class="text-caption text-grey-6">체크인 지역 기준</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>
|
||||||
<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="row items-center q-mb-xs">
|
||||||
<div class="text-body2">{{ r.region }}</div>
|
<div class="text-body2">{{ r.region }}</div>
|
||||||
<q-space />
|
<q-space />
|
||||||
@@ -127,6 +131,7 @@ const tierRows = computed(() => {
|
|||||||
const order = ['FREE', 'AD_FREE', 'PREMIUM']
|
const order = ['FREE', 'AD_FREE', 'PREMIUM']
|
||||||
return [...(summary.value?.members.byTier ?? [])].sort((a, b) => order.indexOf(a.key) - order.indexOf(b.key))
|
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 regionMax = computed(() => Math.max(1, ...regions.value.map((r) => r.checkIns)))
|
||||||
const visitorMax = computed(() => Math.max(1, ...visitors.value.map((v) => v.count)))
|
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: [
|
children: [
|
||||||
{ path: '', redirect: { name: 'dashboard' } },
|
{ path: '', redirect: { name: 'dashboard' } },
|
||||||
{ path: 'dashboard', name: 'dashboard', component: () => import('@/pages/DashboardPage.vue') },
|
{ 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', name: 'members', component: () => import('@/pages/MembersPage.vue') },
|
||||||
{ path: 'members/:id', name: 'member-detail', component: () => import('@/pages/MemberDetailPage.vue') },
|
{ path: 'members/:id', name: 'member-detail', component: () => import('@/pages/MemberDetailPage.vue') },
|
||||||
{ path: 'subscriptions', name: 'subscriptions', component: () => import('@/pages/SubscriptionsPage.vue') },
|
{ path: 'subscriptions', name: 'subscriptions', component: () => import('@/pages/SubscriptionsPage.vue') },
|
||||||
|
|||||||
Reference in New Issue
Block a user