feat(admin): 신고 관리 화면 — 신고된 글/댓글 목록·블라인드·신고 무시
Deploy / deploy (push) Failing after 14m51s

- /admin/reports 라우트 + 사이드바 메뉴 + 헤더 타이틀
- boardApi.reports()/dismissReport() 추가(기존 block/unblock 재사용)
- 글 보기·블라인드(사유)·해제·신고 무시 액션

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-04 12:10:06 +09:00
parent a261dd2ca2
commit 7861eef05b
5 changed files with 352 additions and 0 deletions
+8
View File
@@ -76,6 +76,14 @@ export const boardApi = {
unblockComment(commentId) {
return http.post(`/board/comments/${commentId}/unblock`)
},
// 신고 관리 (관리자) — 신고된 글/댓글 목록
reports() {
return http.get('/board/reports')
},
// 신고 무시 (관리자) — 블라인드 없이 신고 기록만 초기화
dismissReport(targetType, targetId) {
return http.post('/board/reports/dismiss', { targetType, targetId })
},
block(id, reason) {
return http.post(`/board/posts/${id}/block`, { reason })
},
+1
View File
@@ -28,6 +28,7 @@ const TITLES = {
users: '회원 관리',
'admin-tags': '태그 관리(관리자)',
'admin-default-categories': '기본 분류 설정',
'admin-reports': '신고 관리',
settings: '설정',
'settings-account': '계정정보',
'settings-account-edit': '가입정보 변경',
+5
View File
@@ -25,6 +25,7 @@ const icons = {
board: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
}
</script>
@@ -106,6 +107,10 @@ const icons = {
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.sliders" />
<span>기본 분류 설정</span>
</RouterLink>
<RouterLink to="/admin/reports" class="menu-item">
<svg class="menu-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" v-html="icons.flag" />
<span>신고 관리</span>
</RouterLink>
</template>
</nav>
</aside>
+6
View File
@@ -104,6 +104,12 @@ const router = createRouter({
component: () => import('../views/admin/DefaultCategoryView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/admin/reports',
name: 'admin-reports',
component: () => import('../views/admin/ReportAdminView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
// 구 가계부 대시보드 → 내역으로 (대시보드 정보는 홈/통계로 이전)
path: '/account',
+332
View File
@@ -0,0 +1,332 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { boardApi } from '@/api/boardApi'
import { useDialog } from '@/composables/dialog'
const dialog = useDialog()
const items = ref([])
const loading = ref(false)
const error = ref(null)
const busyKey = ref(null) // 처리 중인 대상 키 (버튼 비활성화용)
const CATEGORY_LABEL = { community: '커뮤니티', saving: '짠테크 수다방', tips: '재테크 팁' }
const blockedCount = computed(() => items.value.filter((i) => i.blocked).length)
function keyOf(it) {
return `${it.targetType}:${it.targetId}`
}
function categoryLabel(c) {
return CATEGORY_LABEL[c] || c || '게시판'
}
function linkTo(it) {
return `/board/${it.category || 'community'}/${it.postId}`
}
function formatTime(s) {
if (!s) return ''
const d = new Date(s)
if (Number.isNaN(d.getTime())) return s
return d.toLocaleString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})
}
async function load() {
loading.value = true
error.value = null
try {
items.value = await boardApi.reports()
} catch (e) {
error.value = e.response?.data?.message || '신고 목록을 불러오지 못했습니다.'
} finally {
loading.value = false
}
}
async function run(it, fn, successMsg) {
busyKey.value = keyOf(it)
try {
await fn()
await load()
if (successMsg) await dialog.alert(successMsg)
} catch (e) {
await dialog.alert(e.response?.data?.message || '처리에 실패했습니다.')
} finally {
busyKey.value = null
}
}
// 글 블라인드 (사유 입력)
async function blockPost(it) {
const reason = await dialog.prompt('블라인드 사유를 입력하세요. (열람 제한 안내에 표시)', {
title: '글 블라인드',
okText: '블라인드',
defaultValue: '신고 누적으로 블라인드 처리되었습니다.',
})
if (reason === null) return
await run(it, () => boardApi.block(it.postId, reason || null), '블라인드 처리했습니다.')
}
// 블라인드 해제 (글/댓글) — 신고 기록도 초기화됨
async function unblock(it) {
const ok = await dialog.confirm('블라인드를 해제하고 신고 기록을 초기화할까요?', {
title: '블라인드 해제',
okText: '해제',
})
if (!ok) return
const call = it.targetType === 'POST'
? () => boardApi.unblock(it.postId)
: () => boardApi.unblockComment(it.targetId)
await run(it, call, '블라인드를 해제했습니다.')
}
// 신고 무시 — 블라인드 없이 신고 기록만 삭제
async function dismiss(it) {
const ok = await dialog.confirm('이 신고를 무시하고 목록에서 제거할까요? (블라인드하지 않고 신고 기록만 삭제)', {
title: '신고 무시',
okText: '무시',
danger: true,
})
if (!ok) return
await run(it, () => boardApi.dismissReport(it.targetType, it.targetId))
}
onMounted(load)
</script>
<template>
<div class="report-admin">
<div class="head">
<p class="desc">
신고 누적 <strong>5</strong> 이상이면 자동 블라인드됩니다. 관리자는 여기서 신고 내용을 검토해
<b>블라인드</b> 하거나, 정상 글이면 <b>신고 무시</b> 정리할 있습니다.
</p>
<button class="reload" type="button" :disabled="loading" @click="load"> 새로고침</button>
</div>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 </p>
<p v-else-if="!items.length" class="msg empty">🎉 신고된 ·댓글이 없습니다.</p>
<template v-else>
<p class="count">
<strong>{{ items.length }}</strong>
<span v-if="blockedCount"> · 블라인드 {{ blockedCount }}</span>
</p>
<ul class="list">
<li v-for="it in items" :key="keyOf(it)" class="card" :class="{ blocked: it.blocked }">
<div class="row1">
<span class="badge" :class="it.targetType === 'POST' ? 'post' : 'comment'">
{{ it.targetType === 'POST' ? '글' : '댓글' }}
</span>
<span class="cat">{{ categoryLabel(it.category) }}</span>
<span class="report-count">🚩 {{ it.reportCount }}</span>
<span v-if="it.blocked" class="badge blind">블라인드됨</span>
<span class="time">{{ formatTime(it.lastReportedAt) }}</span>
</div>
<RouterLink :to="linkTo(it)" class="title-link">
<span v-if="it.targetType === 'COMMENT'" class="on-post">[{{ it.title }}] 댓글</span>
<span v-else class="ttl">{{ it.title }}</span>
</RouterLink>
<p class="content">{{ it.content }}</p>
<p class="author">작성자: {{ it.authorName || '알 수 없음' }}</p>
<div class="actions">
<RouterLink :to="linkTo(it)" class="btn ghost"> 보기</RouterLink>
<button
v-if="it.targetType === 'POST' && !it.blocked"
class="btn danger"
type="button"
:disabled="busyKey === keyOf(it)"
@click="blockPost(it)"
>
블라인드
</button>
<button
v-if="it.blocked"
class="btn"
type="button"
:disabled="busyKey === keyOf(it)"
@click="unblock(it)"
>
블라인드 해제
</button>
<button
v-if="!it.blocked"
class="btn ghost"
type="button"
:disabled="busyKey === keyOf(it)"
@click="dismiss(it)"
>
신고 무시
</button>
</div>
</li>
</ul>
</template>
</div>
</template>
<style scoped>
.report-admin {
padding: 0.5rem 0 2rem;
}
.head {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 1rem;
}
.desc {
flex: 1;
margin: 0;
font-size: 0.9rem;
color: var(--color-text-soft, #666);
line-height: 1.5;
}
.reload {
flex-shrink: 0;
border: 1px solid var(--color-border);
background: var(--color-background-soft);
color: var(--color-text);
border-radius: 8px;
padding: 0.4rem 0.7rem;
cursor: pointer;
font-size: 0.85rem;
}
.msg {
padding: 1rem;
color: var(--color-text-soft, #666);
}
.msg.error {
color: #e5484d;
}
.msg.empty {
text-align: center;
padding: 3rem 1rem;
}
.count {
margin: 0 0 0.75rem;
font-size: 0.9rem;
color: var(--color-text-soft, #666);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.card {
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 0.85rem 1rem;
background: var(--color-background);
}
.card.blocked {
border-color: #e5484d55;
background: #e5484d0d;
}
.row1 {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 0.5rem;
}
.badge {
font-size: 0.72rem;
font-weight: 700;
padding: 0.12rem 0.45rem;
border-radius: 6px;
}
.badge.post {
background: #00bc7e22;
color: #00996a;
}
.badge.comment {
background: #3b82f622;
color: #2563eb;
}
.badge.blind {
background: #e5484d22;
color: #e5484d;
}
.cat {
font-size: 0.78rem;
color: var(--color-text-soft, #888);
}
.report-count {
font-size: 0.8rem;
font-weight: 700;
color: #e5484d;
}
.time {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-text-soft, #999);
}
.title-link {
display: block;
color: var(--color-text);
text-decoration: none;
font-weight: 600;
margin-bottom: 0.25rem;
}
.title-link:hover {
text-decoration: underline;
}
.on-post {
color: var(--color-text-soft, #777);
font-weight: 500;
}
.content {
margin: 0 0 0.4rem;
font-size: 0.88rem;
color: var(--color-text-soft, #555);
white-space: pre-wrap;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.author {
margin: 0 0 0.6rem;
font-size: 0.78rem;
color: var(--color-text-soft, #999);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.btn {
border: 1px solid var(--color-border);
background: var(--color-background-soft);
color: var(--color-text);
border-radius: 8px;
padding: 0.35rem 0.75rem;
cursor: pointer;
font-size: 0.82rem;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
.btn.ghost {
background: transparent;
}
.btn.danger {
border-color: #e5484d;
color: #e5484d;
}
</style>