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,2 @@
|
||||
# 백엔드 API 주소 (dognation_BT)
|
||||
VITE_API_BASE_URL=http://localhost:8080
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# build/tooling
|
||||
*.local
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#6DB3E8" />
|
||||
<title>산책갈개 관리자</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2488
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "dognation_at",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "산책갈개 관리자 콘솔 (웹 전용)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^1.16.12",
|
||||
"pinia": "^2.3.0",
|
||||
"quasar": "^2.17.4",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@quasar/vite-plugin": "^1.9.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"sass-embedded": "^1.83.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 루트 컴포넌트
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { http } from './http'
|
||||
|
||||
export interface Member {
|
||||
id: number
|
||||
email: string
|
||||
nickname: string
|
||||
role: 'USER' | 'ADMIN'
|
||||
provider: 'GOOGLE' | 'APPLE'
|
||||
subscriptionTier: 'FREE' | 'AD_FREE' | 'PREMIUM'
|
||||
status: 'ACTIVE' | 'DORMANT' | 'BANNED'
|
||||
profileImage: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
expiresInSeconds: number
|
||||
member: Member
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
googleClientId: () => http.get<{ clientId: string }>('/api/auth/google-client-id'),
|
||||
|
||||
googleLogin: (idToken: string, rememberMe = true) =>
|
||||
http.post<LoginResponse>('/api/auth/google', { idToken, rememberMe }),
|
||||
|
||||
me: () => http.get<Member>('/api/auth/me'),
|
||||
|
||||
logout: () => http.post<void>('/api/auth/logout'),
|
||||
|
||||
/** 개발자 로그인 — local 백엔드 전용(구글 없이 이메일로 세션 발급). */
|
||||
devLogin: (email: string) => http.post<LoginResponse>('/api/auth/dev-login', { email }),
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// 공통 HTTP 클라이언트 — VITE_API_BASE_URL 기준, Bearer 토큰 자동 첨부
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080'
|
||||
|
||||
// 관리자 콘솔 전용 토큰 키 (사용자 앱과 분리)
|
||||
const TOKEN_KEY = 'dn_admin_token'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(token: string | null): void {
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token)
|
||||
else localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||||
const token = getToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
const text = await res.text()
|
||||
const data = text ? JSON.parse(text) : null
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) setToken(null)
|
||||
const code = (data && data.code) || 'ERROR'
|
||||
const message = (data && data.message) || `요청 실패 (${res.status})`
|
||||
throw new ApiError(res.status, code, message)
|
||||
}
|
||||
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const http = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
del: <T>(path: string) => request<T>('DELETE', path),
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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[]
|
||||
}
|
||||
|
||||
export const membersApi = {
|
||||
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}`),
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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'
|
||||
|
||||
export const statsApi = {
|
||||
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'),
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { http } from './http'
|
||||
|
||||
export interface Subscription {
|
||||
id: number
|
||||
userId: number
|
||||
email: string
|
||||
nickname: string
|
||||
tier: string
|
||||
platform: string
|
||||
startedAt: string
|
||||
expiresAt: string
|
||||
active: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface SubscriptionSummary {
|
||||
active: number
|
||||
expiringSoon: number
|
||||
byTier: { key: string; count: number }[]
|
||||
byPlatform: { key: string; count: number }[]
|
||||
}
|
||||
|
||||
export type SubscriptionFilter = 'all' | 'active' | 'inactive'
|
||||
|
||||
export const subscriptionsApi = {
|
||||
list: (filter: SubscriptionFilter = 'all') =>
|
||||
http.get<Subscription[]>(`/api/admin/subscriptions?filter=${filter}`),
|
||||
|
||||
summary: () => http.get<SubscriptionSummary>('/api/admin/subscriptions/summary'),
|
||||
|
||||
grant: (userId: number, tier: 'AD_FREE' | 'PREMIUM', months: number) =>
|
||||
http.post<Subscription>('/api/admin/subscriptions/grant', { userId, tier, months }),
|
||||
|
||||
extend: (id: number, months: number) =>
|
||||
http.put<Subscription>(`/api/admin/subscriptions/${id}/extend`, { months }),
|
||||
|
||||
cancel: (id: number) => http.put<Subscription>(`/api/admin/subscriptions/${id}/cancel`),
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// 관리자 콘솔 전역 스타일
|
||||
body {
|
||||
background: #f4faff;
|
||||
}
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<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">
|
||||
{{ auth.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 { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const drawer = ref(true)
|
||||
|
||||
const menu = [
|
||||
{ name: 'dashboard', label: '대시보드', icon: 'dashboard' },
|
||||
{ name: 'members', label: '회원 관리', icon: 'group' },
|
||||
{ name: 'subscriptions', label: '구독 관리', icon: 'card_membership' },
|
||||
]
|
||||
|
||||
async function onLogout() {
|
||||
await auth.logout()
|
||||
await router.replace({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { createApp } from 'vue'
|
||||
import { Quasar, Notify, Dialog } from 'quasar'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import '@quasar/extras/material-icons/material-icons.css'
|
||||
import 'quasar/src/css/index.sass'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './css/app.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(Quasar, {
|
||||
plugins: { Notify, Dialog },
|
||||
config: {
|
||||
brand: { primary: '#6DB3E8' },
|
||||
},
|
||||
})
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -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>
|
||||
@@ -0,0 +1,137 @@
|
||||
<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>
|
||||
|
||||
<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 { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
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(() => auth.loginWithGoogle(response.credential))
|
||||
}
|
||||
|
||||
function onDevLogin() {
|
||||
finishLogin(() => auth.loginDev('admin@dog.com'))
|
||||
}
|
||||
|
||||
async function finishLogin(login: () => Promise<{ role: string }>) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const member = await login()
|
||||
if (member.role !== 'ADMIN') {
|
||||
await auth.logout()
|
||||
error.value = '관리자 권한이 없는 계정입니다.'
|
||||
return
|
||||
}
|
||||
await router.replace({ name: '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>
|
||||
@@ -0,0 +1,176 @@
|
||||
<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 { membersApi, type MemberDetail } from '@/api/members'
|
||||
|
||||
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 membersApi.detail(memberId.value)
|
||||
} catch (e) {
|
||||
error.value = true
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: '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>
|
||||
@@ -0,0 +1,193 @@
|
||||
<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 { membersApi, type MemberRole, type MemberStatus, type MemberTier } from '@/api/members'
|
||||
import type { Member } from '@/api/auth'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const $q = useQuasar()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
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 === auth.member?.id
|
||||
}
|
||||
function goDetail(m: Member) {
|
||||
router.push({ name: 'member-detail', params: { id: m.id } })
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
try {
|
||||
members.value = await membersApi.list()
|
||||
} catch (e) {
|
||||
loadError.value = true
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
$q.notify({ color: 'negative', message: e instanceof ApiError ? e.message : '변경에 실패했습니다.', icon: 'error', position: 'top' })
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onStatus(m: Member, status: MemberStatus) {
|
||||
if (status === m.status) return
|
||||
run(m.id, () => membersApi.updateStatus(m.id, status), '상태를 변경했습니다.')
|
||||
}
|
||||
function onTier(m: Member, tier: MemberTier) {
|
||||
if (tier === m.subscriptionTier) return
|
||||
run(m.id, () => membersApi.updateTier(m.id, tier), '구독 티어를 변경했습니다.')
|
||||
}
|
||||
function onRole(m: Member, role: MemberRole) {
|
||||
if (role === m.role) return
|
||||
run(m.id, () => membersApi.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 membersApi.remove(m.id)
|
||||
members.value = members.value.filter((x) => x.id !== m.id)
|
||||
$q.notify({ color: 'positive', message: '회원을 삭제했습니다.', icon: 'check', position: 'top' })
|
||||
} catch (e) {
|
||||
$q.notify({ color: 'negative', message: e instanceof ApiError ? e.message : '삭제에 실패했습니다.', icon: 'error', position: 'top' })
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,250 @@
|
||||
<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 unelevated color="primary" icon="add" label="구독 부여" class="q-mr-sm" @click="openGrant" />
|
||||
<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 class="col-6 col-md-3">
|
||||
<q-card flat bordered><q-card-section>
|
||||
<div class="text-caption text-grey-7"><q-icon name="card_membership" size="18px" color="primary" /> 활성 구독</div>
|
||||
<div class="text-h5 text-weight-bold">{{ summary?.active ?? '–' }}</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"><q-icon name="hourglass_bottom" size="18px" color="orange" /> 만료 임박(7일)</div>
|
||||
<div class="text-h5 text-weight-bold">{{ summary?.expiringSoon ?? '–' }}</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 q-mb-xs">티어별(활성)</div>
|
||||
<div v-for="c in summary?.byTier ?? []" :key="c.key" class="text-body2">{{ c.key }} <b>{{ c.count }}</b></div>
|
||||
<div v-if="!summary?.byTier.length" class="text-caption text-grey-5">없음</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 q-mb-xs">플랫폼별(활성)</div>
|
||||
<div v-for="c in summary?.byPlatform ?? []" :key="c.key" class="text-body2">{{ c.key }} <b>{{ c.count }}</b></div>
|
||||
<div v-if="!summary?.byPlatform.length" class="text-caption text-grey-5">없음</div>
|
||||
</q-card-section></q-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 필터 -->
|
||||
<div class="q-mb-sm">
|
||||
<q-btn-toggle
|
||||
v-model="filter" dense unelevated toggle-color="primary" size="sm"
|
||||
:options="[{ label: '전체', value: 'all' }, { label: '활성', value: 'active' }, { label: '비활성', value: 'inactive' }]"
|
||||
@update:model-value="loadList"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<q-table :rows="subs" :columns="columns" row-key="id" :loading="loading" :pagination="{ rowsPerPage: 20 }" flat bordered>
|
||||
<template #body-cell-member="props">
|
||||
<q-td :props="props">
|
||||
<div class="text-weight-medium">{{ props.row.nickname }}</div>
|
||||
<div class="text-caption text-grey-6">{{ props.row.email }}</div>
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-tier="props">
|
||||
<q-td :props="props"><q-badge :color="props.row.tier === 'PREMIUM' ? 'amber-8' : 'teal'">{{ props.row.tier }}</q-badge></q-td>
|
||||
</template>
|
||||
<template #body-cell-platform="props">
|
||||
<q-td :props="props"><q-chip dense square size="sm" color="grey-3" text-color="grey-8">{{ props.row.platform }}</q-chip></q-td>
|
||||
</template>
|
||||
<template #body-cell-period="props">
|
||||
<q-td :props="props" class="text-caption">{{ date(props.row.startedAt) }} ~ {{ date(props.row.expiresAt) }}</q-td>
|
||||
</template>
|
||||
<template #body-cell-active="props">
|
||||
<q-td :props="props" class="text-center">
|
||||
<q-badge :color="props.row.active ? 'positive' : 'grey'">{{ props.row.active ? '활성' : '해지' }}</q-badge>
|
||||
</q-td>
|
||||
</template>
|
||||
<template #body-cell-actions="props">
|
||||
<q-td :props="props" class="text-center">
|
||||
<q-btn flat dense round icon="more_time" color="primary" :disable="busyId === props.row.id" @click="onExtend(props.row)">
|
||||
<q-tooltip>연장</q-tooltip>
|
||||
</q-btn>
|
||||
<q-btn flat dense round icon="cancel" color="negative" :disable="!props.row.active || busyId === props.row.id" @click="onCancel(props.row)">
|
||||
<q-tooltip>취소</q-tooltip>
|
||||
</q-btn>
|
||||
</q-td>
|
||||
</template>
|
||||
</q-table>
|
||||
|
||||
<!-- 구독 부여 다이얼로그 -->
|
||||
<q-dialog v-model="grantOpen">
|
||||
<q-card style="min-width: 340px">
|
||||
<q-card-section class="text-subtitle1 text-weight-bold">구독 수동 부여</q-card-section>
|
||||
<q-card-section class="q-gutter-md">
|
||||
<q-select
|
||||
v-model="grantUserId" outlined dense use-input input-debounce="200"
|
||||
label="회원" :options="memberOptions" emit-value map-options option-value="id" option-label="label"
|
||||
@filter="filterMembers"
|
||||
/>
|
||||
<q-select v-model="grantTier" outlined dense label="티어" :options="['AD_FREE', 'PREMIUM']" />
|
||||
<q-input v-model.number="grantMonths" outlined dense type="number" label="개월 수" :min="1" />
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="취소" v-close-popup />
|
||||
<q-btn unelevated color="primary" label="부여" :loading="granting" :disable="!grantUserId" @click="submitGrant" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useQuasar, type QTableColumn } from 'quasar'
|
||||
import { subscriptionsApi, type Subscription, type SubscriptionFilter, type SubscriptionSummary } from '@/api/subscriptions'
|
||||
import { membersApi } from '@/api/members'
|
||||
import type { Member } from '@/api/auth'
|
||||
import { ApiError } from '@/api/http'
|
||||
|
||||
const $q = useQuasar()
|
||||
|
||||
const subs = ref<Subscription[]>([])
|
||||
const summary = ref<SubscriptionSummary | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(false)
|
||||
const filter = ref<SubscriptionFilter>('all')
|
||||
const busyId = ref<number | null>(null)
|
||||
|
||||
const columns: QTableColumn<Subscription>[] = [
|
||||
{ name: 'member', label: '회원', field: 'email', align: 'left' },
|
||||
{ name: 'tier', label: '티어', field: 'tier', align: 'center' },
|
||||
{ name: 'platform', label: '플랫폼', field: 'platform', align: 'center' },
|
||||
{ name: 'period', label: '기간', field: 'expiresAt', align: 'left', sortable: true },
|
||||
{ name: 'active', label: '상태', field: 'active', align: 'center' },
|
||||
{ name: 'actions', label: '', field: 'id', align: 'center' },
|
||||
]
|
||||
|
||||
// 부여 다이얼로그
|
||||
const grantOpen = ref(false)
|
||||
const granting = ref(false)
|
||||
const grantUserId = ref<number | null>(null)
|
||||
const grantTier = ref<'AD_FREE' | 'PREMIUM'>('PREMIUM')
|
||||
const grantMonths = ref(1)
|
||||
const allMembers = ref<Member[]>([])
|
||||
const memberOptions = ref<{ id: number; label: string }[]>([])
|
||||
|
||||
onMounted(loadAll)
|
||||
|
||||
function date(s: string): string {
|
||||
return new Date(s).toLocaleDateString('ko-KR')
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const [list, sum] = await Promise.all([subscriptionsApi.list(filter.value), subscriptionsApi.summary()])
|
||||
subs.value = list
|
||||
summary.value = sum
|
||||
} catch (e) {
|
||||
error.value = true
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
loading.value = true
|
||||
try {
|
||||
subs.value = await subscriptionsApi.list(filter.value)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openGrant() {
|
||||
grantOpen.value = true
|
||||
if (!allMembers.value.length) {
|
||||
try {
|
||||
allMembers.value = await membersApi.list()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
setOptions('')
|
||||
}
|
||||
|
||||
function setOptions(needle: string) {
|
||||
const n = needle.toLowerCase()
|
||||
memberOptions.value = allMembers.value
|
||||
.filter((m) => !n || m.email.toLowerCase().includes(n) || m.nickname.toLowerCase().includes(n))
|
||||
.slice(0, 50)
|
||||
.map((m) => ({ id: m.id, label: `${m.nickname} (${m.email})` }))
|
||||
}
|
||||
function filterMembers(val: string, update: (fn: () => void) => void) {
|
||||
update(() => setOptions(val))
|
||||
}
|
||||
|
||||
async function submitGrant() {
|
||||
if (!grantUserId.value || grantMonths.value < 1) return
|
||||
granting.value = true
|
||||
try {
|
||||
await subscriptionsApi.grant(grantUserId.value, grantTier.value, grantMonths.value)
|
||||
grantOpen.value = false
|
||||
grantUserId.value = null
|
||||
grantMonths.value = 1
|
||||
$q.notify({ color: 'positive', message: '구독을 부여했습니다.', icon: 'check', position: 'top' })
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
$q.notify({ color: 'negative', message: e instanceof ApiError ? e.message : '부여에 실패했습니다.', icon: 'error', position: 'top' })
|
||||
} finally {
|
||||
granting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onExtend(s: Subscription) {
|
||||
$q.dialog({
|
||||
title: '구독 연장',
|
||||
message: `${s.nickname} 님의 구독을 몇 개월 연장할까요?`,
|
||||
prompt: { model: '1', type: 'number', isValid: (v: string) => Number(v) >= 1 },
|
||||
cancel: true,
|
||||
}).onOk(async (val: string) => {
|
||||
await mutate(s.id, () => subscriptionsApi.extend(s.id, Number(val)), '연장했습니다.')
|
||||
})
|
||||
}
|
||||
|
||||
function onCancel(s: Subscription) {
|
||||
$q.dialog({
|
||||
title: '구독 취소',
|
||||
message: `${s.nickname}(${s.email}) 님의 구독을 취소하고 티어를 FREE로 되돌릴까요?`,
|
||||
cancel: true,
|
||||
ok: { label: '취소 처리', color: 'negative' },
|
||||
}).onOk(async () => {
|
||||
await mutate(s.id, () => subscriptionsApi.cancel(s.id), '구독을 취소했습니다.')
|
||||
})
|
||||
}
|
||||
|
||||
async function mutate(id: number, fn: () => Promise<Subscription>, done: string) {
|
||||
busyId.value = id
|
||||
try {
|
||||
await fn()
|
||||
$q.notify({ color: 'positive', message: done, icon: 'check', position: 'top' })
|
||||
await loadAll()
|
||||
} catch (e) {
|
||||
$q.notify({ color: 'negative', message: e instanceof ApiError ? e.message : '처리에 실패했습니다.', icon: 'error', position: 'top' })
|
||||
} finally {
|
||||
busyId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,14 @@
|
||||
// ============================================================
|
||||
// 산책갈개 브랜드 컬러 — 하늘색 파스텔톤 (관리자 콘솔)
|
||||
// ============================================================
|
||||
$primary : #6DB3E8;
|
||||
$secondary : #BFE3FB;
|
||||
$accent : #3E92CC;
|
||||
|
||||
$dark : #1D1D1D;
|
||||
$dark-page : #121212;
|
||||
|
||||
$positive : #7FD1AE;
|
||||
$negative : #F2A6A6;
|
||||
$info : #A9D6F5;
|
||||
$warning : #FCE5A6;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/pages/LoginPage.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
meta: { requiresAdmin: true },
|
||||
children: [
|
||||
{ path: '', redirect: { name: 'dashboard' } },
|
||||
{ path: 'dashboard', name: 'dashboard', component: () => import('@/pages/DashboardPage.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') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 인증 가드 — 토큰/회원 복원 후 ADMIN 여부 확인
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
const needsAdmin = to.matched.some((r) => r.meta.requiresAdmin)
|
||||
|
||||
if (auth.token && !auth.member) {
|
||||
await auth.fetchMe()
|
||||
}
|
||||
|
||||
if (needsAdmin && !auth.isAdmin) {
|
||||
return { name: 'login' }
|
||||
}
|
||||
if (to.name === 'login' && auth.isAdmin) {
|
||||
return { name: 'dashboard' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,58 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { authApi, type Member } from '@/api/auth'
|
||||
import { getToken, setToken } from '@/api/http'
|
||||
|
||||
/**
|
||||
* 관리자 인증 스토어. 토큰은 localStorage(http.ts)에 저장, 회원은 /api/auth/me 로 복원.
|
||||
*/
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(getToken())
|
||||
const member = ref<Member | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const isAdmin = computed(() => member.value?.role === 'ADMIN')
|
||||
|
||||
function apply(res: { token: string; member: Member }): Member {
|
||||
setToken(res.token)
|
||||
token.value = res.token
|
||||
member.value = res.member
|
||||
return res.member
|
||||
}
|
||||
|
||||
async function loginWithGoogle(idToken: string): Promise<Member> {
|
||||
return apply(await authApi.googleLogin(idToken))
|
||||
}
|
||||
|
||||
async function loginDev(email: string): Promise<Member> {
|
||||
return apply(await authApi.devLogin(email))
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<Member | null> {
|
||||
if (!token.value) 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 {
|
||||
setToken(null)
|
||||
token.value = null
|
||||
member.value = null
|
||||
}
|
||||
|
||||
return { token, member, isAuthenticated, isAdmin, loginWithGoogle, loginDev, fetchMe, logout, clear }
|
||||
})
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
// Google Identity Services (GIS) 최소 타입 선언 — 웹 구글 로그인 버튼용
|
||||
interface GsiCredentialResponse {
|
||||
credential: string
|
||||
}
|
||||
|
||||
interface GsiButtonConfig {
|
||||
type?: 'standard' | 'icon'
|
||||
theme?: 'outline' | 'filled_blue' | 'filled_black'
|
||||
size?: 'large' | 'medium' | 'small'
|
||||
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin'
|
||||
shape?: 'rectangular' | 'pill' | 'circle' | 'square'
|
||||
width?: number
|
||||
locale?: string
|
||||
}
|
||||
|
||||
interface GoogleAccountsId {
|
||||
initialize(config: {
|
||||
client_id: string
|
||||
callback: (response: GsiCredentialResponse) => void
|
||||
auto_select?: boolean
|
||||
}): void
|
||||
renderButton(parent: HTMLElement, options: GsiButtonConfig): void
|
||||
prompt(): void
|
||||
}
|
||||
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: GoogleAccountsId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "quasar", "node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { quasar, transformAssetUrls } from '@quasar/vite-plugin'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue({ template: { transformAssetUrls } }),
|
||||
quasar({ sassVariables: fileURLToPath(new URL('./src/quasar-variables.scss', import.meta.url)) }),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 9100,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user