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:
@@ -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}`),
|
||||
}
|
||||
@@ -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 }),
|
||||
}
|
||||
@@ -61,6 +61,8 @@ const menu = [
|
||||
{ name: 'dashboard', label: '대시보드', icon: 'dashboard' },
|
||||
{ name: 'members', label: '회원 관리', icon: 'group' },
|
||||
{ name: 'subscriptions', label: '구독 관리', icon: 'card_membership' },
|
||||
{ name: 'reports', label: '피신고자 관리', icon: 'report' },
|
||||
{ name: 'blacklist', label: '블랙리스트', icon: 'block' },
|
||||
]
|
||||
|
||||
async function onLogout() {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -17,6 +17,8 @@ const routes: RouteRecordRaw[] = [
|
||||
{ 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') },
|
||||
{ path: 'reports', name: 'reports', component: () => import('@/pages/ReportsPage.vue') },
|
||||
{ path: 'blacklist', name: 'blacklist', component: () => import('@/pages/BlacklistPage.vue') },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user