- 관리자 페이지/스토어/API 삭제(adminAuth·adminMembers·adminStats·AdminLayout·pages/admin) - 라우터에서 /admin 경로·관리자 가드 제거 (사용자 앱 라우팅만 유지) - 공용 저수준(http put/del 등)은 dogs API 등이 사용하므로 유지 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
-65
@@ -925,9 +925,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -949,9 +946,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -973,9 +967,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -997,9 +988,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1021,9 +1009,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1045,9 +1030,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1246,9 +1228,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1263,9 +1242,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1280,9 +1256,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1297,9 +1270,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1314,9 +1284,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1331,9 +1298,6 @@
|
|||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1348,9 +1312,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1365,9 +1326,6 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1382,9 +1340,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1399,9 +1354,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1416,9 +1368,6 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1433,9 +1382,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"glibc"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1450,9 +1396,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": [
|
|
||||||
"musl"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3036,7 +2979,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": "glibc",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3054,7 +2996,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": "glibc",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3072,7 +3013,6 @@
|
|||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": "musl",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3090,7 +3030,6 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": "musl",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3108,7 +3047,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": "musl",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3126,7 +3064,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": "musl",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3144,7 +3081,6 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": "glibc",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3162,7 +3098,6 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"libc": "glibc",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
import { http } from './http'
|
|
||||||
import type { LoginResponse } from './auth'
|
|
||||||
|
|
||||||
/** 관리자 콘솔 전용 인증 API (사용자 authApi 와 분리). */
|
|
||||||
export const adminAuthApi = {
|
|
||||||
/** 개발자 로그인 — local 백엔드 전용(구글 없이 이메일로 세션 발급). */
|
|
||||||
devLogin: (email: string) => http.post<LoginResponse>('/api/auth/dev-login', { email }),
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { http } from './http'
|
|
||||||
import type { Member } from './auth'
|
|
||||||
|
|
||||||
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 }),
|
|
||||||
|
|
||||||
updateTier: (id: number, tier: MemberTier) =>
|
|
||||||
http.put<Member>(`/api/admin/members/${id}/tier`, { tier }),
|
|
||||||
|
|
||||||
updateRole: (id: number, role: MemberRole) =>
|
|
||||||
http.put<Member>(`/api/admin/members/${id}/role`, { role }),
|
|
||||||
|
|
||||||
remove: (id: number) => http.del<void>(`/api/admin/members/${id}`),
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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'),
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<template>
|
|
||||||
<q-layout view="hHh Lpr lFf">
|
|
||||||
<!-- 헤더 -->
|
|
||||||
<q-header elevated class="bg-primary text-white">
|
|
||||||
<q-toolbar>
|
|
||||||
<q-btn flat dense round icon="menu" aria-label="메뉴" @click="drawer = !drawer" />
|
|
||||||
<q-toolbar-title class="text-weight-bold">
|
|
||||||
<q-icon name="pets" class="q-mr-sm" />산책갈개 관리자
|
|
||||||
</q-toolbar-title>
|
|
||||||
|
|
||||||
<q-chip dense color="white" text-color="primary" icon="admin_panel_settings">
|
|
||||||
{{ admin.member?.nickname ?? '관리자' }}
|
|
||||||
</q-chip>
|
|
||||||
<q-btn flat dense round icon="logout" aria-label="로그아웃" @click="onLogout" />
|
|
||||||
</q-toolbar>
|
|
||||||
</q-header>
|
|
||||||
|
|
||||||
<!-- 좌측 사이드 메뉴 -->
|
|
||||||
<q-drawer v-model="drawer" show-if-above bordered :width="240" class="bg-grey-1">
|
|
||||||
<q-list padding>
|
|
||||||
<q-item-label header class="text-grey-7">관리 메뉴</q-item-label>
|
|
||||||
|
|
||||||
<q-item
|
|
||||||
v-for="item in menu"
|
|
||||||
:key="item.name"
|
|
||||||
clickable
|
|
||||||
:to="{ name: item.name }"
|
|
||||||
active-class="text-primary bg-blue-1"
|
|
||||||
exact
|
|
||||||
>
|
|
||||||
<q-item-section avatar>
|
|
||||||
<q-icon :name="item.icon" />
|
|
||||||
</q-item-section>
|
|
||||||
<q-item-section>{{ item.label }}</q-item-section>
|
|
||||||
</q-item>
|
|
||||||
</q-list>
|
|
||||||
</q-drawer>
|
|
||||||
|
|
||||||
<!-- 바디 -->
|
|
||||||
<q-page-container>
|
|
||||||
<router-view />
|
|
||||||
</q-page-container>
|
|
||||||
|
|
||||||
<!-- 풋터 -->
|
|
||||||
<q-footer class="bg-white text-grey-6">
|
|
||||||
<div class="text-caption text-center q-py-sm">© 2026 산책갈개 · 관리자 콘솔</div>
|
|
||||||
</q-footer>
|
|
||||||
</q-layout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useAdminAuthStore } from '@/stores/adminAuth'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const admin = useAdminAuthStore()
|
|
||||||
|
|
||||||
const drawer = ref(true)
|
|
||||||
|
|
||||||
const menu = [
|
|
||||||
{ name: 'admin-dashboard', label: '대시보드', icon: 'dashboard' },
|
|
||||||
{ name: 'admin-members', label: '회원 관리', icon: 'group' },
|
|
||||||
]
|
|
||||||
|
|
||||||
async function onLogout() {
|
|
||||||
await admin.logout()
|
|
||||||
await router.replace({ name: 'admin-login' })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
<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,143 +0,0 @@
|
|||||||
<template>
|
|
||||||
<q-layout view="lHh Lpr lFf">
|
|
||||||
<q-page-container>
|
|
||||||
<q-page class="flex flex-center bg-grey-2">
|
|
||||||
<q-card flat bordered class="login-card q-pa-lg">
|
|
||||||
<div class="text-center q-mb-lg">
|
|
||||||
<q-icon name="pets" size="42px" color="primary" />
|
|
||||||
<div class="text-h6 text-weight-bold q-mt-sm">산책갈개 관리자</div>
|
|
||||||
<div class="text-caption text-grey-6">관리자 계정으로 로그인하세요</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 구글 로그인 버튼 렌더 위치 -->
|
|
||||||
<div class="flex flex-center">
|
|
||||||
<div ref="googleBtn" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 개발자 로그인 (local 백엔드 전용, dev 빌드에서만 노출) -->
|
|
||||||
<div v-if="isDev" class="q-mt-lg">
|
|
||||||
<q-separator class="q-mb-md" />
|
|
||||||
<div class="text-caption text-grey-6 text-center q-mb-sm">개발용 (local 백엔드)</div>
|
|
||||||
<q-btn
|
|
||||||
outline
|
|
||||||
color="primary"
|
|
||||||
class="full-width"
|
|
||||||
icon="developer_mode"
|
|
||||||
label="개발자 로그인 (admin@dog.com)"
|
|
||||||
:loading="loading"
|
|
||||||
@click="onDevLogin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-inner-loading :showing="loading" />
|
|
||||||
|
|
||||||
<q-banner v-if="error" dense class="bg-red-1 text-red-8 q-mt-md rounded-borders">
|
|
||||||
<template #avatar><q-icon name="error" /></template>
|
|
||||||
{{ error }}
|
|
||||||
</q-banner>
|
|
||||||
|
|
||||||
<div v-if="!clientId && ready" class="text-caption text-grey-6 text-center q-mt-md">
|
|
||||||
구글 로그인이 설정되지 않았습니다. (서버 GOOGLE_CLIENT_ID 확인)
|
|
||||||
</div>
|
|
||||||
</q-card>
|
|
||||||
</q-page>
|
|
||||||
</q-page-container>
|
|
||||||
</q-layout>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { authApi } from '@/api/auth'
|
|
||||||
import { ApiError } from '@/api/http'
|
|
||||||
import { useAdminAuthStore } from '@/stores/adminAuth'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const admin = useAdminAuthStore()
|
|
||||||
|
|
||||||
const googleBtn = ref<HTMLElement | null>(null)
|
|
||||||
const clientId = ref('')
|
|
||||||
const ready = ref(false)
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
const isDev = import.meta.env.DEV
|
|
||||||
const GSI_SRC = 'https://accounts.google.com/gsi/client'
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
clientId.value = (await authApi.googleClientId()).clientId
|
|
||||||
} catch {
|
|
||||||
error.value = '서버에 연결하지 못했습니다.'
|
|
||||||
ready.value = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ready.value = true
|
|
||||||
if (!clientId.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadGsi()
|
|
||||||
window.google!.accounts.id.initialize({
|
|
||||||
client_id: clientId.value,
|
|
||||||
callback: onCredential,
|
|
||||||
})
|
|
||||||
window.google!.accounts.id.renderButton(googleBtn.value as HTMLElement, {
|
|
||||||
type: 'standard',
|
|
||||||
theme: 'outline',
|
|
||||||
size: 'large',
|
|
||||||
text: 'signin_with',
|
|
||||||
shape: 'pill',
|
|
||||||
width: 260,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
error.value = '구글 로그인 초기화에 실패했습니다.'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
function loadGsi(): Promise<void> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (window.google?.accounts?.id) return resolve()
|
|
||||||
const el = document.createElement('script')
|
|
||||||
el.src = GSI_SRC
|
|
||||||
el.async = true
|
|
||||||
el.defer = true
|
|
||||||
el.onload = () => resolve()
|
|
||||||
el.onerror = () => reject(new Error('GSI load failed'))
|
|
||||||
document.head.appendChild(el)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCredential(response: GsiCredentialResponse) {
|
|
||||||
finishLogin(() => admin.loginWithGoogle(response.credential))
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDevLogin() {
|
|
||||||
finishLogin(() => admin.loginDev('admin@dog.com'))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 로그인 실행 → ADMIN 확인 → 회원관리로 이동. 공용. */
|
|
||||||
async function finishLogin(login: () => Promise<{ role: string }>) {
|
|
||||||
loading.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
const member = await login()
|
|
||||||
if (member.role !== 'ADMIN') {
|
|
||||||
await admin.logout()
|
|
||||||
error.value = '관리자 권한이 없는 계정입니다.'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await router.replace({ name: 'admin-dashboard' })
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof ApiError ? e.message : '로그인 중 오류가 발생했습니다.'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.login-card {
|
|
||||||
width: 360px;
|
|
||||||
max-width: 90vw;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
<template>
|
|
||||||
<q-page class="q-pa-md">
|
|
||||||
<!-- 상단 툴바 -->
|
|
||||||
<div class="row items-center q-mb-md">
|
|
||||||
<div class="text-h6 text-weight-bold">회원 관리</div>
|
|
||||||
<q-chip dense color="blue-1" text-color="primary" class="q-ml-sm">
|
|
||||||
총 {{ members.length }}명
|
|
||||||
</q-chip>
|
|
||||||
<q-space />
|
|
||||||
<q-input
|
|
||||||
v-model="filter"
|
|
||||||
dense
|
|
||||||
outlined
|
|
||||||
debounce="200"
|
|
||||||
placeholder="이메일·닉네임 검색"
|
|
||||||
class="q-mr-sm"
|
|
||||||
style="min-width: 220px"
|
|
||||||
>
|
|
||||||
<template #prepend><q-icon name="search" /></template>
|
|
||||||
</q-input>
|
|
||||||
<q-btn flat round icon="refresh" :loading="loading" @click="load" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<q-banner v-if="loadError" dense class="bg-red-1 text-red-8 q-mb-md rounded-borders">
|
|
||||||
<template #avatar><q-icon name="wifi_off" /></template>
|
|
||||||
회원 목록을 불러오지 못했습니다.
|
|
||||||
</q-banner>
|
|
||||||
|
|
||||||
<q-table
|
|
||||||
:rows="members"
|
|
||||||
:columns="columns"
|
|
||||||
row-key="id"
|
|
||||||
:loading="loading"
|
|
||||||
:filter="filter"
|
|
||||||
:pagination="{ rowsPerPage: 20 }"
|
|
||||||
flat
|
|
||||||
bordered
|
|
||||||
>
|
|
||||||
<!-- 회원(이메일+닉네임+아바타) -->
|
|
||||||
<template #body-cell-member="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<div class="row items-center no-wrap">
|
|
||||||
<q-avatar size="32px" color="blue-1" text-color="primary">
|
|
||||||
<img v-if="props.row.profileImage" :src="props.row.profileImage" alt="" />
|
|
||||||
<q-icon v-else name="person" />
|
|
||||||
</q-avatar>
|
|
||||||
<div class="q-ml-sm">
|
|
||||||
<div class="text-weight-medium">
|
|
||||||
{{ props.row.nickname }}
|
|
||||||
<q-chip v-if="isSelf(props.row)" dense size="sm" color="grey-3" text-color="grey-8">본인</q-chip>
|
|
||||||
</div>
|
|
||||||
<div class="text-caption text-grey-6">{{ props.row.email }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 제공자 -->
|
|
||||||
<template #body-cell-provider="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-chip dense square size="sm" :color="props.row.provider === 'APPLE' ? 'grey-9' : 'red-2'"
|
|
||||||
:text-color="props.row.provider === 'APPLE' ? 'white' : 'red-9'">
|
|
||||||
{{ props.row.provider }}
|
|
||||||
</q-chip>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 상태 -->
|
|
||||||
<template #body-cell-status="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-select
|
|
||||||
dense options-dense outlined emit-value map-options
|
|
||||||
:model-value="props.row.status"
|
|
||||||
:options="statusOptions"
|
|
||||||
:disable="isSelf(props.row) || busyId === props.row.id"
|
|
||||||
style="min-width: 110px"
|
|
||||||
@update:model-value="(v: MemberStatus) => onStatus(props.row, v)"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 구독 티어 -->
|
|
||||||
<template #body-cell-tier="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-select
|
|
||||||
dense options-dense outlined emit-value map-options
|
|
||||||
:model-value="props.row.subscriptionTier"
|
|
||||||
:options="tierOptions"
|
|
||||||
:disable="busyId === props.row.id"
|
|
||||||
style="min-width: 120px"
|
|
||||||
@update:model-value="(v: MemberTier) => onTier(props.row, v)"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 권한 -->
|
|
||||||
<template #body-cell-role="props">
|
|
||||||
<q-td :props="props">
|
|
||||||
<q-select
|
|
||||||
dense options-dense outlined emit-value map-options
|
|
||||||
:model-value="props.row.role"
|
|
||||||
:options="roleOptions"
|
|
||||||
:disable="isSelf(props.row) || busyId === props.row.id"
|
|
||||||
style="min-width: 110px"
|
|
||||||
@update:model-value="(v: MemberRole) => onRole(props.row, v)"
|
|
||||||
/>
|
|
||||||
</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"
|
|
||||||
@click="onDelete(props.row)"
|
|
||||||
/>
|
|
||||||
</q-td>
|
|
||||||
</template>
|
|
||||||
</q-table>
|
|
||||||
</q-page>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useQuasar, type QTableColumn } from 'quasar'
|
|
||||||
import {
|
|
||||||
adminMembersApi,
|
|
||||||
type MemberRole,
|
|
||||||
type MemberStatus,
|
|
||||||
type MemberTier,
|
|
||||||
} from '@/api/adminMembers'
|
|
||||||
import type { Member } from '@/api/auth'
|
|
||||||
import { ApiError } from '@/api/http'
|
|
||||||
import { useAdminAuthStore } from '@/stores/adminAuth'
|
|
||||||
|
|
||||||
const $q = useQuasar()
|
|
||||||
const router = useRouter()
|
|
||||||
const admin = useAdminAuthStore()
|
|
||||||
|
|
||||||
const members = ref<Member[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const loadError = ref(false)
|
|
||||||
const filter = ref('')
|
|
||||||
const busyId = ref<number | null>(null)
|
|
||||||
|
|
||||||
const statusOptions = [
|
|
||||||
{ label: '활성', value: 'ACTIVE' },
|
|
||||||
{ label: '휴면', value: 'DORMANT' },
|
|
||||||
{ label: '정지', value: 'BANNED' },
|
|
||||||
]
|
|
||||||
const tierOptions = [
|
|
||||||
{ label: 'FREE', value: 'FREE' },
|
|
||||||
{ label: '광고제거', value: 'AD_FREE' },
|
|
||||||
{ label: 'PREMIUM', value: 'PREMIUM' },
|
|
||||||
]
|
|
||||||
const roleOptions = [
|
|
||||||
{ label: '일반', value: 'USER' },
|
|
||||||
{ label: '관리자', value: 'ADMIN' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const columns: QTableColumn<Member>[] = [
|
|
||||||
{ name: 'member', label: '회원', field: 'email', align: 'left', sortable: true },
|
|
||||||
{ name: 'provider', label: '가입', field: 'provider', align: 'center' },
|
|
||||||
{ name: 'status', label: '상태', field: 'status', align: 'center' },
|
|
||||||
{ name: 'tier', label: '구독', field: 'subscriptionTier', align: 'center' },
|
|
||||||
{ name: 'role', label: '권한', field: 'role', align: 'center' },
|
|
||||||
{
|
|
||||||
name: 'createdAt',
|
|
||||||
label: '가입일',
|
|
||||||
field: 'createdAt',
|
|
||||||
align: 'center',
|
|
||||||
sortable: true,
|
|
||||||
format: (v: string) => new Date(v).toLocaleDateString('ko-KR'),
|
|
||||||
},
|
|
||||||
{ name: 'actions', label: '', field: 'id', align: 'center' },
|
|
||||||
]
|
|
||||||
|
|
||||||
onMounted(load)
|
|
||||||
|
|
||||||
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
|
|
||||||
try {
|
|
||||||
members.value = await adminMembersApi.list()
|
|
||||||
} catch (e) {
|
|
||||||
loadError.value = true
|
|
||||||
console.error(e)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 변경 API 공통 실행 — 성공 시 해당 행 갱신, 실패 시 알림. */
|
|
||||||
async function run(id: number, fn: () => Promise<Member>, done: string) {
|
|
||||||
busyId.value = id
|
|
||||||
try {
|
|
||||||
const updated = await fn()
|
|
||||||
const i = members.value.findIndex((m) => m.id === id)
|
|
||||||
if (i >= 0) members.value[i] = updated
|
|
||||||
$q.notify({ color: 'positive', message: done, icon: 'check', position: 'top' })
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof ApiError ? e.message : '변경에 실패했습니다.'
|
|
||||||
$q.notify({ color: 'negative', message: msg, icon: 'error', position: 'top' })
|
|
||||||
} finally {
|
|
||||||
busyId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onStatus(m: Member, status: MemberStatus) {
|
|
||||||
if (status === m.status) return
|
|
||||||
run(m.id, () => adminMembersApi.updateStatus(m.id, status), '상태를 변경했습니다.')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onTier(m: Member, tier: MemberTier) {
|
|
||||||
if (tier === m.subscriptionTier) return
|
|
||||||
run(m.id, () => adminMembersApi.updateTier(m.id, tier), '구독 티어를 변경했습니다.')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRole(m: Member, role: MemberRole) {
|
|
||||||
if (role === m.role) return
|
|
||||||
run(m.id, () => adminMembersApi.updateRole(m.id, role), '권한을 변경했습니다.')
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDelete(m: Member) {
|
|
||||||
$q.dialog({
|
|
||||||
title: '회원 삭제',
|
|
||||||
message: `${m.nickname}(${m.email}) 회원을 삭제할까요? 되돌릴 수 없습니다.`,
|
|
||||||
cancel: true,
|
|
||||||
ok: { label: '삭제', color: 'negative' },
|
|
||||||
}).onOk(async () => {
|
|
||||||
busyId.value = m.id
|
|
||||||
try {
|
|
||||||
await adminMembersApi.remove(m.id)
|
|
||||||
members.value = members.value.filter((x) => x.id !== m.id)
|
|
||||||
$q.notify({ color: 'positive', message: '회원을 삭제했습니다.', icon: 'check', position: 'top' })
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof ApiError ? e.message : '삭제에 실패했습니다.'
|
|
||||||
$q.notify({ color: 'negative', message: msg, icon: 'error', position: 'top' })
|
|
||||||
} finally {
|
|
||||||
busyId.value = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||||
import MainLayout from '@/layouts/MainLayout.vue'
|
import MainLayout from '@/layouts/MainLayout.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useAdminAuthStore } from '@/stores/adminAuth'
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -18,35 +17,6 @@ const routes: RouteRecordRaw[] = [
|
|||||||
{ path: 'profile', name: 'profile', component: () => import('@/pages/ProfilePage.vue') },
|
{ path: 'profile', name: 'profile', component: () => import('@/pages/ProfilePage.vue') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// 관리자 콘솔 (웹 전용) — 사용자 앱과 분리
|
|
||||||
{
|
|
||||||
path: '/admin/login',
|
|
||||||
name: 'admin-login',
|
|
||||||
component: () => import('@/pages/admin/AdminLoginPage.vue'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin',
|
|
||||||
component: () => import('@/layouts/AdminLayout.vue'),
|
|
||||||
meta: { requiresAdmin: true },
|
|
||||||
children: [
|
|
||||||
{ 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'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@@ -56,10 +26,6 @@ const router = createRouter({
|
|||||||
|
|
||||||
// 소셜 로그인 전용 앱: 미로그인 시 보호 경로 접근 → 로그인으로
|
// 소셜 로그인 전용 앱: 미로그인 시 보호 경로 접근 → 로그인으로
|
||||||
router.beforeEach((to) => {
|
router.beforeEach((to) => {
|
||||||
// 관리자 경로는 아래 관리자 가드가 처리 (사용자 가드는 관여하지 않음)
|
|
||||||
if (to.matched.some((r) => r.meta.requiresAdmin) || to.name === 'admin-login') {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||||
return { name: 'login', query: { redirect: to.fullPath } }
|
return { name: 'login', query: { redirect: to.fullPath } }
|
||||||
@@ -69,22 +35,4 @@ router.beforeEach((to) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 관리자 콘솔 가드 — 토큰/회원 복원 후 ADMIN 여부 확인 (사용자 인증과 분리)
|
|
||||||
router.beforeEach(async (to) => {
|
|
||||||
const needsAdmin = to.matched.some((r) => r.meta.requiresAdmin)
|
|
||||||
if (!needsAdmin && to.name !== 'admin-login') return true
|
|
||||||
|
|
||||||
const admin = useAdminAuthStore()
|
|
||||||
if (admin.hasToken() && !admin.member) {
|
|
||||||
await admin.fetchMe()
|
|
||||||
}
|
|
||||||
if (needsAdmin && !admin.isAdmin) {
|
|
||||||
return { name: 'admin-login' }
|
|
||||||
}
|
|
||||||
if (to.name === 'admin-login' && admin.isAdmin) {
|
|
||||||
return { name: 'admin-dashboard' }
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { defineStore } from 'pinia'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
import { authApi, type Member } from '@/api/auth'
|
|
||||||
import { adminAuthApi } from '@/api/adminAuth'
|
|
||||||
import { tokenStore } from '@/lib/token'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 관리자 콘솔 전용 인증 스토어 (사용자 auth 스토어와 분리).
|
|
||||||
* 저수준 토큰 저장소(tokenStore)와 소셜 로그인/조회(authApi)는 공용을 재사용한다.
|
|
||||||
*/
|
|
||||||
export const useAdminAuthStore = defineStore('adminAuth', () => {
|
|
||||||
const member = ref<Member | null>(null)
|
|
||||||
const isAdmin = computed(() => member.value?.role === 'ADMIN')
|
|
||||||
|
|
||||||
function hasToken(): boolean {
|
|
||||||
return !!tokenStore.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 구글 로그인 → 토큰 저장 + 회원 세팅. */
|
|
||||||
async function loginWithGoogle(idToken: string): Promise<Member> {
|
|
||||||
const res = await authApi.google(idToken)
|
|
||||||
tokenStore.set(res.token)
|
|
||||||
member.value = res.member
|
|
||||||
return res.member
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 개발자 로그인 (local 백엔드 전용). */
|
|
||||||
async function loginDev(email: string): Promise<Member> {
|
|
||||||
const res = await adminAuthApi.devLogin(email)
|
|
||||||
tokenStore.set(res.token)
|
|
||||||
member.value = res.member
|
|
||||||
return res.member
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 토큰으로 회원 복원(새로고침/가드). 실패 시 세션 정리. */
|
|
||||||
async function fetchMe(): Promise<Member | null> {
|
|
||||||
if (!tokenStore.get()) return null
|
|
||||||
try {
|
|
||||||
member.value = await authApi.me()
|
|
||||||
return member.value
|
|
||||||
} catch {
|
|
||||||
clear()
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await authApi.logout()
|
|
||||||
} catch {
|
|
||||||
/* 서버 실패해도 로컬 세션은 정리 */
|
|
||||||
}
|
|
||||||
clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clear(): void {
|
|
||||||
tokenStore.clear()
|
|
||||||
member.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
return { member, isAdmin, hasToken, loginWithGoogle, loginDev, fetchMe, logout, clear }
|
|
||||||
})
|
|
||||||
Reference in New Issue
Block a user