Files
sb-front/src/views/board/BoardListView.vue
T
ByungCheol 4e6a89fd7a feat: 홈 일별 캘린더·시세 갱신·게시판 카테고리·약관동의·계좌 마스킹
- 홈: 6월 예산 아래 일별 수입/지출 캘린더(마우스오버/탭 시 내역 목록)
- 투자: 시세 자동조회 버튼/진입 시 갱신, 보유종목 2줄 표기, 평가액 직접입력
- 게시판: 커뮤니티/짠테크 수다방/재테크 팁 분리, 사이드바 영역 구분선
- 회원가입: 이용약관·개인정보 수집 동의(필수 체크)
- 보안: 계좌번호 화면 마스킹(끝 4자리+눈 토글)
- 정기→고정지출, 내역/정기 라디오·sticky 저장, 태그 드래그 정렬
- fix: 모바일웹 새로고침 시 로그인 팝업(restore localStorage 우선)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:01:33 +09:00

466 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 { boardLabel, DEFAULT_BOARD } from '@/constants/boards'
import IconBtn from '@/components/ui/IconBtn.vue'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const isAdmin = computed(() => auth.user?.role === 'ADMIN')
// 현재 게시판(카테고리)
const category = computed(() => route.params.category || DEFAULT_BOARD)
const boardTitle = computed(() => boardLabel(category.value))
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({
category: category.value,
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 })
}
// 경로(게시판 전환)·쿼리(검색·페이지·뒤로가기)가 바뀌면 상태 동기화 후 재조회
watch(
() => route.fullPath,
() => {
syncFromQuery()
load()
},
{ immediate: true },
)
onMounted(loadTags)
</script>
<template>
<section class="board">
<header class="board-head">
<h1>{{ boardTitle }}</h1>
<IconBtn icon="edit" title="글쓰기" variant="primary" @click="router.push(`/board/${category}/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>제목</th>
<th class="col-author">작성자</th>
<th class="col-num">조회</th>
<th class="col-date">작성일</th>
</tr>
</thead>
<tbody>
<tr v-for="p in posts" :key="p.id" @click="router.push(`/board/${category}/${p.id}`)">
<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-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;
}
/* 제목(첫 번째 셀): 첫 줄 전체 */
.post-table td:nth-child(1) {
order: 0;
width: 100% !important;
font-size: 0.97rem;
opacity: 1;
font-weight: 500;
}
.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>