- UserAvatar 컴포넌트(커스텀>구글>이니셜, 로드 실패 시 이니셜 폴백) - 목록: 작성자 아바타 + 추천 수 표기 - 상세: 작성자 아바타, 추천/비추천 토글 버튼(내 투표 강조), 추천자 본문 하단 아바타 10개 + 초과 시 (+N명이 추천했습니다), 클릭 시 전체 목록 모달 - 댓글: 작성자 아바타 + 추천/비추천 버튼 - 계정정보: 포인트 표기(GET /auth/points 최신값) - boardApi.votePost/voteComment/recommenders, authApi.points 추가 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -39,4 +39,8 @@ export const authApi = {
|
|||||||
updateProfileImage(image) {
|
updateProfileImage(image) {
|
||||||
return http.put('/auth/profile-image', { image })
|
return http.put('/auth/profile-image', { image })
|
||||||
},
|
},
|
||||||
|
// 현재 사용자 활동 포인트 (최신값)
|
||||||
|
points() {
|
||||||
|
return http.get('/auth/points')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ export const boardApi = {
|
|||||||
removeComment(commentId) {
|
removeComment(commentId) {
|
||||||
return http.delete(`/board/comments/${commentId}`)
|
return http.delete(`/board/comments/${commentId}`)
|
||||||
},
|
},
|
||||||
|
// 추천/비추천 (토글) — type: 'UP' | 'DOWN'
|
||||||
|
votePost(id, type) {
|
||||||
|
return http.post(`/board/posts/${id}/vote`, { type })
|
||||||
|
},
|
||||||
|
voteComment(commentId, type) {
|
||||||
|
return http.post(`/board/comments/${commentId}/vote`, { type })
|
||||||
|
},
|
||||||
|
// 추천(👍)한 사람 목록
|
||||||
|
recommenders(id) {
|
||||||
|
return http.get(`/board/posts/${id}/recommenders`)
|
||||||
|
},
|
||||||
block(id, reason) {
|
block(id, reason) {
|
||||||
return http.post(`/board/posts/${id}/block`, { reason })
|
return http.post(`/board/posts/${id}/block`, { reason })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
// 작성자/추천자 아바타. 우선순위: 커스텀(profileImage) > 구글(googlePicture) > 이니셜.
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, default: '' },
|
||||||
|
googlePicture: { type: String, default: '' },
|
||||||
|
profileImage: { type: String, default: '' },
|
||||||
|
size: { type: Number, default: 28 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const broken = ref(false)
|
||||||
|
const src = computed(() => props.profileImage || props.googlePicture || '')
|
||||||
|
const initial = computed(() => {
|
||||||
|
const s = (props.name || '').trim()
|
||||||
|
return s ? s[0].toUpperCase() : '?'
|
||||||
|
})
|
||||||
|
// src 가 바뀌면 깨짐 상태 리셋
|
||||||
|
watch(src, () => { broken.value = false })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="user-avatar" :style="{ width: size + 'px', height: size + 'px' }" :title="name">
|
||||||
|
<img
|
||||||
|
v-if="src && !broken"
|
||||||
|
:src="src"
|
||||||
|
:alt="name"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
@error="broken = true"
|
||||||
|
/>
|
||||||
|
<span v-else class="ua-initial" :style="{ fontSize: Math.round(size * 0.45) + 'px' }">{{ initial }}</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: var(--color-background-mute);
|
||||||
|
}
|
||||||
|
.user-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.ua-initial {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: hsla(160, 100%, 37%, 1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -8,6 +8,7 @@ import { formatRelative } from '@/utils/datetime'
|
|||||||
import MarkdownViewer from '@/components/editor/MarkdownViewer.vue'
|
import MarkdownViewer from '@/components/editor/MarkdownViewer.vue'
|
||||||
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
|
import MarkdownEditor from '@/components/editor/MarkdownEditor.vue'
|
||||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
import { DEFAULT_BOARD } from '@/constants/boards'
|
import { DEFAULT_BOARD } from '@/constants/boards'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -105,6 +106,45 @@ async function removeComment(c) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 추천/비추천 =====
|
||||||
|
const voting = ref(false)
|
||||||
|
async function votePost(type) {
|
||||||
|
if (voting.value) return
|
||||||
|
voting.value = true
|
||||||
|
try {
|
||||||
|
const res = await boardApi.votePost(postId, type)
|
||||||
|
post.value.upCount = res.upCount
|
||||||
|
post.value.downCount = res.downCount
|
||||||
|
post.value.myVote = res.myVote
|
||||||
|
// 추천 변동 → 추천자 목록 갱신
|
||||||
|
post.value.recommenders = await boardApi.recommenders(postId)
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.message || '처리에 실패했습니다.')
|
||||||
|
} finally {
|
||||||
|
voting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function voteComment(c, type) {
|
||||||
|
try {
|
||||||
|
const res = await boardApi.voteComment(c.id, type)
|
||||||
|
c.upCount = res.upCount
|
||||||
|
c.downCount = res.downCount
|
||||||
|
c.myVote = res.myVote
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.response?.data?.message || '처리에 실패했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 추천자 목록 =====
|
||||||
|
const recommenders = computed(() => post.value?.recommenders || [])
|
||||||
|
const previewRecommenders = computed(() => recommenders.value.slice(0, 10))
|
||||||
|
const extraRecommenders = computed(() => Math.max(0, (post.value?.upCount || 0) - 10))
|
||||||
|
const showRecommenders = ref(false)
|
||||||
|
function openRecommenders() {
|
||||||
|
if (post.value?.upCount) showRecommenders.value = true
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -117,7 +157,13 @@ onMounted(load)
|
|||||||
<header class="post-head">
|
<header class="post-head">
|
||||||
<h1><span v-if="post.notice" class="notice-badge">공지</span>{{ post.title }}</h1>
|
<h1><span v-if="post.notice" class="notice-badge">공지</span>{{ post.title }}</h1>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
<span>{{ post.authorName }}</span>
|
<UserAvatar
|
||||||
|
:name="post.authorName"
|
||||||
|
:google-picture="post.authorGooglePicture"
|
||||||
|
:profile-image="post.authorProfileImage"
|
||||||
|
:size="26"
|
||||||
|
/>
|
||||||
|
<span class="meta-author">{{ post.authorName }}</span>
|
||||||
<span>· {{ formatRelative(post.createdAt) }}</span>
|
<span>· {{ formatRelative(post.createdAt) }}</span>
|
||||||
<span>· 조회 {{ post.viewCount }}</span>
|
<span>· 조회 {{ post.viewCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,6 +182,37 @@ onMounted(load)
|
|||||||
|
|
||||||
<MarkdownViewer v-else :content="post.content" class="content" />
|
<MarkdownViewer v-else :content="post.content" class="content" />
|
||||||
|
|
||||||
|
<!-- 추천 / 비추천 -->
|
||||||
|
<div v-if="!restricted" class="vote-bar">
|
||||||
|
<button
|
||||||
|
type="button" class="vote-btn up" :class="{ active: post.myVote === 'UP' }"
|
||||||
|
:disabled="voting" @click="votePost('UP')"
|
||||||
|
>👍 추천 <b>{{ post.upCount || 0 }}</b></button>
|
||||||
|
<button
|
||||||
|
type="button" class="vote-btn down" :class="{ active: post.myVote === 'DOWN' }"
|
||||||
|
:disabled="voting" @click="votePost('DOWN')"
|
||||||
|
>👎 비추천 <b>{{ post.downCount || 0 }}</b></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 추천자 (아바타 최대 10개 + 초과 안내, 클릭 시 전체 목록) -->
|
||||||
|
<div v-if="!restricted && post.upCount" class="recommenders" @click="openRecommenders">
|
||||||
|
<div class="rec-avatars">
|
||||||
|
<UserAvatar
|
||||||
|
v-for="r in previewRecommenders"
|
||||||
|
:key="r.memberId"
|
||||||
|
:name="r.name"
|
||||||
|
:google-picture="r.googlePicture"
|
||||||
|
:profile-image="r.profileImage"
|
||||||
|
:size="28"
|
||||||
|
class="rec-avatar"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="rec-label">
|
||||||
|
<template v-if="extraRecommenders > 0">+{{ extraRecommenders }}명이 추천했습니다</template>
|
||||||
|
<template v-else>{{ post.upCount }}명이 추천했습니다</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 태그 (본문 아래) -->
|
<!-- 태그 (본문 아래) -->
|
||||||
<div v-if="!restricted && post.tags?.length" class="tags">
|
<div v-if="!restricted && post.tags?.length" class="tags">
|
||||||
<span v-for="t in post.tags" :key="t" class="tag">{{ t }}</span>
|
<span v-for="t in post.tags" :key="t" class="tag">{{ t }}</span>
|
||||||
@@ -162,6 +239,12 @@ onMounted(load)
|
|||||||
<ul class="comment-list">
|
<ul class="comment-list">
|
||||||
<li v-for="c in post.comments" :key="c.id" class="comment">
|
<li v-for="c in post.comments" :key="c.id" class="comment">
|
||||||
<div class="comment-head">
|
<div class="comment-head">
|
||||||
|
<UserAvatar
|
||||||
|
:name="c.authorName"
|
||||||
|
:google-picture="c.authorGooglePicture"
|
||||||
|
:profile-image="c.authorProfileImage"
|
||||||
|
:size="24"
|
||||||
|
/>
|
||||||
<strong>{{ c.authorName }}</strong>
|
<strong>{{ c.authorName }}</strong>
|
||||||
<span class="comment-date">{{ formatRelative(c.createdAt) }}</span>
|
<span class="comment-date">{{ formatRelative(c.createdAt) }}</span>
|
||||||
<span v-if="canModifyComment(c)" class="comment-del">
|
<span v-if="canModifyComment(c)" class="comment-del">
|
||||||
@@ -169,6 +252,16 @@ onMounted(load)
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownViewer :content="c.content" class="comment-body" />
|
<MarkdownViewer :content="c.content" class="comment-body" />
|
||||||
|
<div class="comment-votes">
|
||||||
|
<button
|
||||||
|
type="button" class="cvote up" :class="{ active: c.myVote === 'UP' }"
|
||||||
|
@click="voteComment(c, 'UP')"
|
||||||
|
>👍 {{ c.upCount || 0 }}</button>
|
||||||
|
<button
|
||||||
|
type="button" class="cvote down" :class="{ active: c.myVote === 'DOWN' }"
|
||||||
|
@click="voteComment(c, 'DOWN')"
|
||||||
|
>👎 {{ c.downCount || 0 }}</button>
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-if="!post.comments.length" class="msg">첫 댓글을 남겨보세요.</p>
|
<p v-if="!post.comments.length" class="msg">첫 댓글을 남겨보세요.</p>
|
||||||
@@ -186,6 +279,29 @@ onMounted(load)
|
|||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- 추천자 전체 목록 (아바타 + 이름) -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showRecommenders" class="rec-modal-backdrop" @click.self="showRecommenders = false">
|
||||||
|
<div class="rec-modal" role="dialog" aria-modal="true" aria-label="추천한 사람">
|
||||||
|
<div class="rec-modal-head">
|
||||||
|
<strong>추천한 사람 {{ post?.upCount || 0 }}명</strong>
|
||||||
|
<IconBtn icon="close" title="닫기" size="sm" @click="showRecommenders = false" />
|
||||||
|
</div>
|
||||||
|
<ul class="rec-modal-list">
|
||||||
|
<li v-for="r in recommenders" :key="r.memberId">
|
||||||
|
<UserAvatar
|
||||||
|
:name="r.name"
|
||||||
|
:google-picture="r.googlePicture"
|
||||||
|
:profile-image="r.profileImage"
|
||||||
|
:size="32"
|
||||||
|
/>
|
||||||
|
<span class="rec-name">{{ r.name }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -208,11 +324,74 @@ h1 {
|
|||||||
.meta {
|
.meta {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
opacity: 0.75;
|
opacity: 0.85;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.meta-author {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
/* 추천/비추천 */
|
||||||
|
.vote-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
.vote-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.5rem 1.4rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.vote-btn b {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.vote-btn.up.active {
|
||||||
|
border-color: hsla(160, 100%, 37%, 1);
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
background: hsla(160, 100%, 37%, 0.08);
|
||||||
|
}
|
||||||
|
.vote-btn.down.active {
|
||||||
|
border-color: #e67e22;
|
||||||
|
color: #e67e22;
|
||||||
|
background: rgba(230, 126, 34, 0.08);
|
||||||
|
}
|
||||||
|
/* 추천자 */
|
||||||
|
.recommenders {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--color-background-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.recommenders:hover {
|
||||||
|
background: var(--color-background-mute);
|
||||||
|
}
|
||||||
|
.rec-avatars {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
.rec-avatar {
|
||||||
|
margin-left: -6px;
|
||||||
|
box-shadow: 0 0 0 2px var(--color-background-soft);
|
||||||
|
}
|
||||||
|
.rec-avatar:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.rec-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
.tags {
|
.tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
@@ -323,6 +502,68 @@ button.notice-btn {
|
|||||||
.comment-body {
|
.comment-body {
|
||||||
margin-top: 0.3rem;
|
margin-top: 0.3rem;
|
||||||
}
|
}
|
||||||
|
.comment-votes {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 0.4rem;
|
||||||
|
}
|
||||||
|
.cvote {
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.cvote.up.active {
|
||||||
|
border-color: hsla(160, 100%, 37%, 1);
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
}
|
||||||
|
.cvote.down.active {
|
||||||
|
border-color: #e67e22;
|
||||||
|
color: #e67e22;
|
||||||
|
}
|
||||||
|
/* 추천자 모달 */
|
||||||
|
.rec-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.rec-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
max-height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.rec-modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
.rec-modal-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.rec-modal-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
.rec-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
.comment-form {
|
.comment-form {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { formatRelative } from '@/utils/datetime'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { DEFAULT_BOARD } from '@/constants/boards'
|
import { DEFAULT_BOARD } from '@/constants/boards'
|
||||||
import IconBtn from '@/components/ui/IconBtn.vue'
|
import IconBtn from '@/components/ui/IconBtn.vue'
|
||||||
|
import UserAvatar from '@/components/UserAvatar.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -170,9 +171,20 @@ onMounted(loadTags)
|
|||||||
<span class="title">{{ p.title }}</span>
|
<span class="title">{{ p.title }}</span>
|
||||||
<span v-if="p.blocked" class="blocked-badge">제한</span>
|
<span v-if="p.blocked" class="blocked-badge">제한</span>
|
||||||
<span v-if="p.commentCount" class="comment-count">[{{ p.commentCount }}]</span>
|
<span v-if="p.commentCount" class="comment-count">[{{ p.commentCount }}]</span>
|
||||||
|
<span v-if="p.upCount" class="recommend-count">👍 {{ p.upCount }}</span>
|
||||||
</template>
|
</template>
|
||||||
</td>
|
</td>
|
||||||
<td class="col-author">{{ p.authorName }}</td>
|
<td class="col-author">
|
||||||
|
<span class="author-cell">
|
||||||
|
<UserAvatar
|
||||||
|
:name="p.authorName"
|
||||||
|
:google-picture="p.authorGooglePicture"
|
||||||
|
:profile-image="p.authorProfileImage"
|
||||||
|
:size="22"
|
||||||
|
/>
|
||||||
|
<span class="author-name">{{ p.authorName }}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td class="col-num">{{ p.viewCount }}</td>
|
<td class="col-num">{{ p.viewCount }}</td>
|
||||||
<td class="col-date">{{ formatRelative(p.createdAt) }}</td>
|
<td class="col-date">{{ formatRelative(p.createdAt) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -307,7 +319,18 @@ button:disabled {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.col-author {
|
.col-author {
|
||||||
width: 100px;
|
width: 130px;
|
||||||
|
}
|
||||||
|
.author-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.author-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.col-date {
|
.col-date {
|
||||||
width: 110px;
|
width: 110px;
|
||||||
@@ -318,6 +341,12 @@ button:disabled {
|
|||||||
color: hsla(160, 100%, 37%, 1);
|
color: hsla(160, 100%, 37%, 1);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
.recommend-count {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
color: var(--color-text);
|
||||||
|
opacity: 0.7;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
.blocked-msg {
|
.blocked-msg {
|
||||||
color: #c0392b;
|
color: #c0392b;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { authApi } from '@/api/authApi'
|
import { authApi } from '@/api/authApi'
|
||||||
@@ -10,6 +10,18 @@ const auth = useAuthStore()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
// 포인트(게시판 작성으로 수시 변동) — 진입 시 최신값 조회
|
||||||
|
const points = ref(auth.user?.points ?? 0)
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await authApi.points()
|
||||||
|
points.value = res.points ?? 0
|
||||||
|
await auth.applyUser({ points: points.value })
|
||||||
|
} catch {
|
||||||
|
// 조회 실패 시 캐시값 유지
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const u = computed(() => auth.user || {})
|
const u = computed(() => auth.user || {})
|
||||||
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
|
const roleLabel = computed(() => (u.value.role === 'ADMIN' ? '관리자' : '일반회원'))
|
||||||
const isPremium = computed(() => auth.isPremium)
|
const isPremium = computed(() => auth.isPremium)
|
||||||
@@ -116,6 +128,10 @@ function goEdit() {
|
|||||||
<RouterLink v-if="!isPremium" to="/upgrade" class="up-link">업그레이드 →</RouterLink>
|
<RouterLink v-if="!isPremium" to="/upgrade" class="up-link">업그레이드 →</RouterLink>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="k">포인트</span>
|
||||||
|
<span class="v"><span class="points">{{ points.toLocaleString('ko-KR') }} P</span></span>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button type="button" class="primary-btn" @click="goEdit">이름 변경</button>
|
<button type="button" class="primary-btn" @click="goEdit">이름 변경</button>
|
||||||
@@ -181,6 +197,10 @@ function goEdit() {
|
|||||||
.v .premium {
|
.v .premium {
|
||||||
color: #b8860b;
|
color: #b8860b;
|
||||||
}
|
}
|
||||||
|
.points {
|
||||||
|
color: hsla(160, 100%, 37%, 1);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
.up-link {
|
.up-link {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user