- /admin/reports 라우트 + 사이드바 메뉴 + 헤더 타이틀 - boardApi.reports()/dismissReport() 추가(기존 block/unblock 재사용) - 글 보기·블라인드(사유)·해제·신고 무시 액션 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,6 +76,14 @@ export const boardApi = {
|
|||||||
unblockComment(commentId) {
|
unblockComment(commentId) {
|
||||||
return http.post(`/board/comments/${commentId}/unblock`)
|
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) {
|
block(id, reason) {
|
||||||
return http.post(`/board/posts/${id}/block`, { reason })
|
return http.post(`/board/posts/${id}/block`, { reason })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const TITLES = {
|
|||||||
users: '회원 관리',
|
users: '회원 관리',
|
||||||
'admin-tags': '태그 관리(관리자)',
|
'admin-tags': '태그 관리(관리자)',
|
||||||
'admin-default-categories': '기본 분류 설정',
|
'admin-default-categories': '기본 분류 설정',
|
||||||
|
'admin-reports': '신고 관리',
|
||||||
settings: '설정',
|
settings: '설정',
|
||||||
'settings-account': '계정정보',
|
'settings-account': '계정정보',
|
||||||
'settings-account-edit': '가입정보 변경',
|
'settings-account-edit': '가입정보 변경',
|
||||||
|
|||||||
@@ -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"/>',
|
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"/>',
|
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"/>',
|
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>
|
</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" />
|
<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>
|
<span>기본 분류 설정</span>
|
||||||
</RouterLink>
|
</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>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/admin/DefaultCategoryView.vue'),
|
component: () => import('../views/admin/DefaultCategoryView.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true },
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/reports',
|
||||||
|
name: 'admin-reports',
|
||||||
|
component: () => import('../views/admin/ReportAdminView.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// 구 가계부 대시보드 → 내역으로 (대시보드 정보는 홈/통계로 이전)
|
// 구 가계부 대시보드 → 내역으로 (대시보드 정보는 홈/통계로 이전)
|
||||||
path: '/account',
|
path: '/account',
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user