feat(admin): 피신고자 관리 + 블랙리스트 화면

- 피신고자 관리(/reports): 피신고자 집계 표(총/미처리/사유), 신고 내역 다이얼로그(처리/기각), 블랙리스트 차단
- 블랙리스트(/blacklist): 목록·이메일 차단 추가·해제
- api/reports, api/blacklist, 라우트·좌측 메뉴 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-11 21:48:47 +09:00
parent 3b8d1dbce5
commit 3e0219388a
6 changed files with 374 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
import { http } from './http'
export interface BlacklistItem {
id: number
userId: number | null
email: string | null
nickname: string | null
reason: string | null
createdAt: string
}
export interface BlacklistAdd {
userId?: number
email?: string
reason?: string
}
export const blacklistApi = {
list: () => http.get<BlacklistItem[]>('/api/admin/blacklist'),
add: (body: BlacklistAdd) => http.post<void>('/api/admin/blacklist', body),
remove: (id: number) => http.del<void>(`/api/admin/blacklist/${id}`),
}
+32
View File
@@ -0,0 +1,32 @@
import { http } from './http'
export interface ReportedUser {
userId: number
email: string
nickname: string
status: string
totalReports: number
pendingReports: number
lastReportedAt: string | null
reasons: string[]
}
export interface ReportItem {
id: number
reporter: string | null
targetType: string
reason: string
detail: string | null
status: string
createdAt: string
}
export type ReportStatus = 'PENDING' | 'RESOLVED' | 'DISMISSED'
export const reportsApi = {
reportedUsers: () => http.get<ReportedUser[]>('/api/admin/reports/reported-users'),
byUser: (reportedUserId: number) =>
http.get<ReportItem[]>(`/api/admin/reports?reportedUserId=${reportedUserId}`),
updateStatus: (id: number, status: ReportStatus) =>
http.put<void>(`/api/admin/reports/${id}/status`, { status }),
}
+2
View File
@@ -61,6 +61,8 @@ const menu = [
{ name: 'dashboard', label: '대시보드', icon: 'dashboard' }, { name: 'dashboard', label: '대시보드', icon: 'dashboard' },
{ name: 'members', label: '회원 관리', icon: 'group' }, { name: 'members', label: '회원 관리', icon: 'group' },
{ name: 'subscriptions', label: '구독 관리', icon: 'card_membership' }, { name: 'subscriptions', label: '구독 관리', icon: 'card_membership' },
{ name: 'reports', label: '피신고자 관리', icon: 'report' },
{ name: 'blacklist', label: '블랙리스트', icon: 'block' },
] ]
async function onLogout() { async function onLogout() {
+123
View File
@@ -0,0 +1,123 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center q-mb-md">
<div class="text-h6 text-weight-bold">블랙리스트</div>
<q-chip dense color="grey-3" text-color="grey-8" class="q-ml-sm">{{ rows.length }}</q-chip>
<q-space />
<q-btn unelevated color="negative" icon="add" label="이메일 차단" class="q-mr-sm" @click="addOpen = true" />
<q-btn flat round icon="refresh" :loading="loading" @click="load" />
</div>
<q-banner v-if="error" dense class="bg-red-1 text-red-8 q-mb-md rounded-borders">
<template #avatar><q-icon name="wifi_off" /></template>
블랙리스트를 불러오지 못했습니다.
</q-banner>
<q-table :rows="rows" :columns="columns" row-key="id" :loading="loading" :pagination="{ rowsPerPage: 20 }" flat bordered>
<template #body-cell-target="props">
<q-td :props="props">
<div class="text-weight-medium">{{ props.row.email || '(이메일 없음)' }}</div>
<div class="text-caption text-grey-6">
{{ props.row.userId ? `회원 #${props.row.userId}${props.row.nickname ? ' · ' + props.row.nickname : ''}` : '이메일 차단' }}
</div>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" class="text-center">
<q-btn flat dense round icon="delete" color="primary" @click="onRemove(props.row)">
<q-tooltip>해제</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<!-- 이메일 차단 추가 -->
<q-dialog v-model="addOpen">
<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-input v-model="addEmail" outlined dense type="email" label="이메일" />
<q-input v-model="addReason" outlined dense label="사유(선택)" />
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="취소" v-close-popup />
<q-btn unelevated color="negative" label="차단" :loading="adding" :disable="!addEmail" @click="submitAdd" />
</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 { blacklistApi, type BlacklistItem } from '@/api/blacklist'
import { ApiError } from '@/api/http'
const $q = useQuasar()
const rows = ref<BlacklistItem[]>([])
const loading = ref(false)
const error = ref(false)
const addOpen = ref(false)
const adding = ref(false)
const addEmail = ref('')
const addReason = ref('')
const columns: QTableColumn<BlacklistItem>[] = [
{ name: 'target', label: '대상', field: 'email', align: 'left' },
{ name: 'reason', label: '사유', field: 'reason', align: 'left' },
{ 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)
async function load() {
loading.value = true
error.value = false
try {
rows.value = await blacklistApi.list()
} catch (e) {
error.value = true
console.error(e)
} finally {
loading.value = false
}
}
async function submitAdd() {
if (!addEmail.value) return
adding.value = true
try {
await blacklistApi.add({ email: addEmail.value.trim(), reason: addReason.value || undefined })
addOpen.value = false
addEmail.value = ''
addReason.value = ''
$q.notify({ color: 'positive', message: '이메일을 블랙리스트에 추가했습니다.', icon: 'block', position: 'top' })
await load()
} catch (e) {
$q.notify({ color: 'negative', message: e instanceof ApiError ? e.message : '추가에 실패했습니다.', icon: 'error', position: 'top' })
} finally {
adding.value = false
}
}
function onRemove(b: BlacklistItem) {
$q.dialog({
title: '블랙리스트 해제',
message: `${b.email || '해당 항목'}을(를) 블랙리스트에서 해제할까요?`,
cancel: true,
ok: { label: '해제', color: 'primary' },
}).onOk(async () => {
try {
await blacklistApi.remove(b.id)
rows.value = rows.value.filter((x) => x.id !== b.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' })
}
})
}
</script>
+193
View File
@@ -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="red-1" text-color="negative" class="q-ml-sm">{{ rows.length }}</q-chip>
<q-space />
<q-btn flat round icon="refresh" :loading="loading" @click="load" />
</div>
<q-banner v-if="error" dense class="bg-red-1 text-red-8 q-mb-md rounded-borders">
<template #avatar><q-icon name="wifi_off" /></template>
신고 목록을 불러오지 못했습니다.
</q-banner>
<q-table :rows="rows" :columns="columns" row-key="userId" :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-status="props">
<q-td :props="props" class="text-center">
<q-badge :color="props.row.status === 'BANNED' ? 'negative' : props.row.status === 'ACTIVE' ? 'positive' : 'grey'">
{{ statusLabel(props.row.status) }}
</q-badge>
</q-td>
</template>
<template #body-cell-pending="props">
<q-td :props="props" class="text-center">
<q-badge :color="props.row.pendingReports > 0 ? 'orange' : 'grey-4'" :text-color="props.row.pendingReports > 0 ? 'white' : 'grey-7'">
{{ props.row.pendingReports }}
</q-badge>
</q-td>
</template>
<template #body-cell-reasons="props">
<q-td :props="props">
<q-chip v-for="r in props.row.reasons" :key="r" dense size="sm" color="red-1" text-color="red-9">{{ reasonLabel(r) }}</q-chip>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" class="text-center">
<q-btn flat dense round icon="fact_check" color="primary" @click="openDetail(props.row)">
<q-tooltip>신고 내역</q-tooltip>
</q-btn>
<q-btn flat dense round icon="block" color="negative" :disable="props.row.status === 'BANNED'" @click="onBlock(props.row)">
<q-tooltip>블랙리스트(차단)</q-tooltip>
</q-btn>
</q-td>
</template>
</q-table>
<!-- 신고 내역 다이얼로그 -->
<q-dialog v-model="detailOpen">
<q-card style="min-width: 420px; max-width: 90vw">
<q-card-section class="row items-center q-pb-none">
<div class="text-subtitle1 text-weight-bold">{{ current?.nickname }} 신고 내역</div>
<q-space />
<q-btn flat dense round icon="close" v-close-popup />
</q-card-section>
<q-card-section>
<div v-if="detailLoading" class="text-center q-pa-md"><q-spinner color="primary" /></div>
<div v-else-if="!items.length" class="text-caption text-grey-5">신고 없음</div>
<q-list v-else separator>
<q-item v-for="it in items" :key="it.id">
<q-item-section>
<q-item-label>
<q-badge :color="reasonColor(it.reason)" class="q-mr-xs">{{ reasonLabel(it.reason) }}</q-badge>
<span class="text-caption text-grey-6">신고자 {{ it.reporter || '탈퇴' }} · {{ dateTime(it.createdAt) }}</span>
</q-item-label>
<q-item-label caption>{{ it.detail || '내용 없음' }}</q-item-label>
</q-item-section>
<q-item-section side>
<div class="row items-center no-wrap">
<q-badge v-if="it.status !== 'PENDING'" :color="it.status === 'RESOLVED' ? 'positive' : 'grey'" class="q-mr-sm">
{{ it.status === 'RESOLVED' ? '처리됨' : '기각' }}
</q-badge>
<template v-else>
<q-btn dense flat size="sm" color="positive" label="처리" :loading="busyReport === it.id" @click="setStatus(it, 'RESOLVED')" />
<q-btn dense flat size="sm" color="grey" label="기각" :loading="busyReport === it.id" @click="setStatus(it, 'DISMISSED')" />
</template>
</div>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useQuasar, type QTableColumn } from 'quasar'
import { reportsApi, type ReportItem, type ReportStatus, type ReportedUser } from '@/api/reports'
import { blacklistApi } from '@/api/blacklist'
import { ApiError } from '@/api/http'
const $q = useQuasar()
const rows = ref<ReportedUser[]>([])
const loading = ref(false)
const error = ref(false)
const detailOpen = ref(false)
const detailLoading = ref(false)
const current = ref<ReportedUser | null>(null)
const items = ref<ReportItem[]>([])
const busyReport = ref<number | null>(null)
const columns: QTableColumn<ReportedUser>[] = [
{ name: 'member', label: '회원', field: 'email', align: 'left' },
{ name: 'status', label: '상태', field: 'status', align: 'center' },
{ name: 'total', label: '총 신고', field: 'totalReports', align: 'center', sortable: true },
{ name: 'pending', label: '미처리', field: 'pendingReports', align: 'center', sortable: true },
{ name: 'reasons', label: '사유', field: 'reasons', align: 'left' },
{ name: 'last', label: '최근 신고', field: 'lastReportedAt', align: 'center', sortable: true, format: (v: string) => (v ? new Date(v).toLocaleDateString('ko-KR') : '-') },
{ name: 'actions', label: '', field: 'userId', align: 'center' },
]
const REASONS: Record<string, string> = { SPAM: '스팸', ABUSE: '욕설/비방', INAPPROPRIATE: '부적절', FRAUD: '사기', ETC: '기타' }
function reasonLabel(r: string): string {
return REASONS[r] ?? r
}
function reasonColor(r: string): string {
return r === 'FRAUD' ? 'deep-orange' : r === 'ABUSE' ? 'red' : r === 'SPAM' ? 'orange' : 'blue-grey'
}
function statusLabel(s: string): string {
return s === 'ACTIVE' ? '활성' : s === 'BANNED' ? '정지' : '휴면'
}
function dateTime(s: string): string {
return new Date(s).toLocaleString('ko-KR', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
onMounted(load)
async function load() {
loading.value = true
error.value = false
try {
rows.value = await reportsApi.reportedUsers()
} catch (e) {
error.value = true
console.error(e)
} finally {
loading.value = false
}
}
async function openDetail(u: ReportedUser) {
current.value = u
detailOpen.value = true
detailLoading.value = true
try {
items.value = await reportsApi.byUser(u.userId)
} catch (e) {
console.error(e)
} finally {
detailLoading.value = false
}
}
async function setStatus(it: ReportItem, status: ReportStatus) {
busyReport.value = it.id
try {
await reportsApi.updateStatus(it.id, status)
it.status = status
await load()
} catch (e) {
$q.notify({ color: 'negative', message: e instanceof ApiError ? e.message : '처리에 실패했습니다.', icon: 'error', position: 'top' })
} finally {
busyReport.value = null
}
}
function onBlock(u: ReportedUser) {
$q.dialog({
title: '블랙리스트 차단',
message: `${u.nickname}(${u.email}) 회원을 차단할까요? 계정이 정지되고 재가입이 차단됩니다.`,
prompt: { model: '', type: 'text', label: '사유(선택)' },
cancel: true,
ok: { label: '차단', color: 'negative' },
}).onOk(async (reason: string) => {
try {
await blacklistApi.add({ userId: u.userId, reason: reason || '신고 누적' })
$q.notify({ color: 'positive', message: '블랙리스트에 추가하고 계정을 정지했습니다.', icon: 'block', position: 'top' })
await load()
} catch (e) {
$q.notify({ color: 'negative', message: e instanceof ApiError ? e.message : '차단에 실패했습니다.', icon: 'error', position: 'top' })
}
})
}
</script>
+2
View File
@@ -17,6 +17,8 @@ const routes: RouteRecordRaw[] = [
{ path: 'members', name: 'members', component: () => import('@/pages/MembersPage.vue') }, { path: 'members', name: 'members', component: () => import('@/pages/MembersPage.vue') },
{ path: 'members/:id', name: 'member-detail', component: () => import('@/pages/MemberDetailPage.vue') }, { path: 'members/:id', name: 'member-detail', component: () => import('@/pages/MemberDetailPage.vue') },
{ path: 'subscriptions', name: 'subscriptions', component: () => import('@/pages/SubscriptionsPage.vue') }, { path: 'subscriptions', name: 'subscriptions', component: () => import('@/pages/SubscriptionsPage.vue') },
{ path: 'reports', name: 'reports', component: () => import('@/pages/ReportsPage.vue') },
{ path: 'blacklist', name: 'blacklist', component: () => import('@/pages/BlacklistPage.vue') },
], ],
}, },
] ]