- /admin/reports 라우트 + 사이드바 메뉴 + 헤더 타이틀 - boardApi.reports()/dismissReport() 추가(기존 block/unblock 재사용) - 글 보기·블라인드(사유)·해제·신고 무시 액션 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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