Files
sb-front/src/views/board/BoardListView.vue
T
ByungCheol 13308a82ef feat: 가계부·게시판 프론트엔드 구현 + Slim Budget 브랜딩/모바일 대응
- 가계부: 대시보드(예산 대비 지출·분류별 파이·기간별 막대·순자산 추이),
  내역(검색·필터·태그), 계좌(은행/카드/대출/투자), 예산, 분류, 정기 거래,
  태그, 투자 포트폴리오(종목·매수/매도·평가손익)
- 게시판: 목록/상세/작성(마크다운)/댓글/태그/열람제한
- 공통: 인증(Bearer 토큰·401 처리), 모바일 반응형(드로어·아이콘 버튼),
  Slim Budget 브랜딩(로고/타이틀/푸터), 홈 대시보드 자리
- 문서: docs/FRONTEND.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:42:52 +09:00

474 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { boardApi } from '@/api/boardApi'
import { formatRelative } from '@/utils/datetime'
import { useAuthStore } from '@/stores/auth'
import IconBtn from '@/components/ui/IconBtn.vue'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const isAdmin = computed(() => auth.user?.role === 'ADMIN')
const posts = ref([])
const tags = ref([])
const loading = ref(false)
const error = ref(null)
const page = ref(1)
const size = ref(10)
const totalPages = ref(0)
const totalElements = ref(0)
const activeTag = ref('')
const activeKeyword = ref('')
const activeSearchType = ref('title')
// 검색 모달 상태
const searchOpen = ref(false)
const modalKeyword = ref('')
const modalSearchType = ref('title')
const SEARCH_LABELS = { title: '제목', content: '본문', comment: '댓글' }
const searchTypeLabel = computed(() => SEARCH_LABELS[activeSearchType.value] || '제목')
async function load() {
loading.value = true
error.value = null
try {
const res = await boardApi.list({
page: page.value,
size: size.value,
tag: activeTag.value,
keyword: activeKeyword.value,
searchType: activeSearchType.value,
})
posts.value = res.content
totalPages.value = res.totalPages
totalElements.value = res.totalElements
} catch (e) {
error.value = e.response?.data?.message || '목록을 불러오지 못했습니다.'
} finally {
loading.value = false
}
}
async function loadTags() {
try {
tags.value = await boardApi.tags()
} catch {
tags.value = []
}
}
// URL 쿼리 → 상태 동기화 (뒤로가기 시 검색 조건/페이지 복원)
function syncFromQuery() {
page.value = Number(route.query.page) || 1
activeTag.value = route.query.tag || ''
activeKeyword.value = route.query.keyword || ''
activeSearchType.value = route.query.searchType || 'title'
}
// 상태 → URL 쿼리 반영 (빈 값은 생략). 쿼리 변경을 watch 가 감지해 load 수행
function pushQuery({ page: pg = 1, tag = '', keyword = '', searchType = 'title' }) {
const q = {}
if (pg && pg > 1) q.page = String(pg)
if (tag) q.tag = tag
if (keyword) {
q.keyword = keyword
q.searchType = searchType || 'title'
}
router.push({ query: q })
}
function filterByTag(tag) {
const next = activeTag.value === tag ? '' : tag
pushQuery({ page: 1, tag: next, keyword: activeKeyword.value, searchType: activeSearchType.value })
}
function goPage(p) {
if (p < 1 || p > totalPages.value || p === page.value) return
pushQuery({ page: p, tag: activeTag.value, keyword: activeKeyword.value, searchType: activeSearchType.value })
}
function openSearch() {
modalKeyword.value = activeKeyword.value
modalSearchType.value = activeSearchType.value || 'title'
searchOpen.value = true
}
function doSearch() {
searchOpen.value = false
pushQuery({ page: 1, tag: activeTag.value, keyword: modalKeyword.value.trim(), searchType: modalSearchType.value })
}
function clearSearch() {
pushQuery({ page: 1, tag: activeTag.value })
}
// 목록 표시 순번: 전체건수 기준 내림차순 (최신글이 큰 번호)
function rowNumber(index) {
return totalElements.value - (page.value - 1) * size.value - index
}
// 쿼리가 바뀔 때마다(검색·페이지 이동·뒤로가기 포함) 상태 동기화 후 재조회
watch(
() => route.query,
() => {
syncFromQuery()
load()
},
{ immediate: true },
)
onMounted(loadTags)
</script>
<template>
<section class="board">
<header class="board-head">
<h1>자유게시판</h1>
<IconBtn icon="edit" title="글쓰기" variant="primary" @click="router.push('/board/write')" />
</header>
<div v-if="tags.length" class="tag-filter">
<button
v-for="t in tags"
:key="t"
type="button"
class="tag-chip"
:class="{ active: activeTag === t }"
@click="filterByTag(t)"
>{{ t }}</button>
</div>
<!-- 검색 적용 표시 -->
<div v-if="activeKeyword" class="active-search">
<span>{{ searchTypeLabel }} 검색: "{{ activeKeyword }}"</span>
<IconBtn icon="close" title="검색 해제" size="sm" @click="clearSearch" />
</div>
<p v-if="error" class="msg error">{{ error }}</p>
<p v-if="loading" class="msg">불러오는 중...</p>
<table v-else-if="posts.length" class="post-table">
<thead>
<tr>
<th class="col-id">번호</th>
<th>제목</th>
<th class="col-author">작성자</th>
<th class="col-num">조회</th>
<th class="col-date">작성일</th>
</tr>
</thead>
<tbody>
<tr v-for="(p, index) in posts" :key="p.id" @click="router.push(`/board/${p.id}`)">
<td class="col-id">{{ rowNumber(index) }}</td>
<td>
<template v-if="p.blocked && !isAdmin">
<span class="blocked-msg">🚫 관리자에 의해 제한된 게시글입니다.</span>
</template>
<template v-else>
<span class="title">{{ p.title }}</span>
<span v-if="p.blocked" class="blocked-badge">제한</span>
<span v-if="p.commentCount" class="comment-count">[{{ p.commentCount }}]</span>
</template>
</td>
<td class="col-author">{{ p.authorName }}</td>
<td class="col-num">{{ p.viewCount }}</td>
<td class="col-date">{{ formatRelative(p.createdAt) }}</td>
</tr>
</tbody>
</table>
<p v-else-if="!loading" class="msg">게시글이 없습니다.</p>
<!-- 하단: 페이징과 같은 행에 돋보기(검색) 버튼 -->
<div class="list-footer">
<nav v-if="totalPages > 1" class="pagination">
<IconBtn icon="chevronLeft" title="이전" size="sm" :disabled="page <= 1" @click="goPage(page - 1)" />
<button
v-for="p in totalPages"
:key="p"
type="button"
class="page-num"
:class="{ active: p === page }"
@click="goPage(p)"
>{{ p }}</button>
<IconBtn icon="chevronRight" title="다음" size="sm" :disabled="page >= totalPages" @click="goPage(page + 1)" />
</nav>
<IconBtn icon="search" title="검색" class="search-btn" @click="openSearch" />
</div>
<!-- 검색 모달 -->
<Teleport to="body">
<Transition name="fade">
<div v-if="searchOpen" class="modal-backdrop" @click.self="searchOpen = false">
<div class="modal" role="dialog" aria-modal="true" aria-label="검색">
<button class="close" type="button" aria-label="닫기" @click="searchOpen = false">×</button>
<form class="search-form" @submit.prevent="doSearch">
<select v-model="modalSearchType" class="search-type">
<option value="title">제목</option>
<option value="content">본문</option>
<option value="comment">댓글</option>
</select>
<input v-model="modalKeyword" type="text" placeholder="검색어를 입력하세요" />
<IconBtn icon="search" title="검색" variant="primary" type="submit" />
</form>
</div>
</div>
</Transition>
</Teleport>
</section>
</template>
<style scoped>
.board {
max-width: 900px;
margin: 0 auto;
}
.board-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
h1 {
font-size: 1.5rem;
}
button {
padding: 0.45rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tag-filter {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
margin-bottom: 1rem;
}
.tag-chip {
padding: 0.25rem 0.6rem;
font-size: 0.85rem;
border-radius: 999px;
}
.tag-chip.active {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.active-search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.9rem;
}
.active-search .clear {
padding: 0.1rem 0.45rem;
font-size: 0.78rem;
}
.post-table {
width: 100%;
border-collapse: collapse;
}
.post-table th,
.post-table td {
padding: 0.6rem 0.5rem;
border-bottom: 1px solid var(--color-border);
text-align: left;
font-size: 0.92rem;
}
.post-table tbody tr {
cursor: pointer;
}
.post-table tbody tr:hover {
background: var(--color-background-soft);
}
.col-id,
.col-num {
width: 60px;
text-align: center;
}
.col-author {
width: 100px;
}
.col-date {
width: 110px;
white-space: nowrap;
}
.comment-count {
margin-left: 0.3rem;
color: hsla(160, 100%, 37%, 1);
font-size: 0.85rem;
}
.blocked-msg {
color: #c0392b;
font-size: 0.9rem;
}
.blocked-badge {
margin-left: 0.35rem;
padding: 0.05rem 0.35rem;
border: 1px solid #c0392b;
border-radius: 3px;
color: #c0392b;
font-size: 0.72rem;
}
/* 하단 영역: 페이징(가운데) + 돋보기(오른쪽) 같은 행 */
.list-footer {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 2.4rem;
margin-top: 1.5rem;
}
.pagination {
display: flex;
gap: 0.3rem;
}
.page-num.active {
border-color: hsla(160, 100%, 37%, 1);
color: hsla(160, 100%, 37%, 1);
}
.search-btn {
position: absolute;
right: 0;
}
.msg {
margin: 1rem 0;
}
.msg.error {
color: #c0392b;
}
/* 검색 모달 */
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
padding: 1rem;
}
.modal {
position: relative;
width: 100%;
max-width: 420px;
padding: 2.5rem 1.5rem 1.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.modal .close {
position: absolute;
top: 0.5rem;
right: 0.6rem;
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
font-size: 1.5rem;
line-height: 1;
}
.search-form {
display: flex;
gap: 0.5rem;
}
.search-type {
padding: 0.5rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.search-form input {
flex: 1;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background-soft);
color: var(--color-text);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.18s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* ===== 모바일: 표를 2줄(제목 / 메타) 카드로 ===== */
@media (max-width: 768px) {
.board {
max-width: 100%;
}
.post-table thead {
display: none;
}
.post-table,
.post-table tbody,
.post-table tr,
.post-table td {
display: block;
}
.post-table tr {
display: flex;
flex-wrap: wrap;
align-items: baseline;
column-gap: 0.5rem;
row-gap: 0.15rem;
padding: 0.7rem 0.2rem;
border-bottom: 1px solid var(--color-border);
}
.post-table td {
padding: 0;
border: 0;
width: auto !important;
text-align: left !important;
font-size: 0.8rem;
opacity: 0.7;
}
/* 제목(2번째 셀): 첫 줄 전체 */
.post-table td:nth-child(2) {
order: 0;
width: 100% !important;
font-size: 0.97rem;
opacity: 1;
font-weight: 500;
}
.post-table .col-id {
order: 1;
}
.post-table .col-id::before {
content: '#';
}
.post-table .col-author {
order: 2;
}
.post-table .col-num {
order: 3;
}
.post-table .col-num::before {
content: '· 조회 ';
}
.post-table .col-date {
order: 4;
}
.post-table .col-date::before {
content: '· ';
}
}
</style>