diff --git a/frontend_nuxt/components/PostLottery.vue b/frontend_nuxt/components/PostLottery.vue deleted file mode 100644 index 19e7d6393..000000000 --- a/frontend_nuxt/components/PostLottery.vue +++ /dev/null @@ -1,316 +0,0 @@ - - - - - diff --git a/frontend_nuxt/components/PostPoll.vue b/frontend_nuxt/components/PostPoll.vue deleted file mode 100644 index 6672916f6..000000000 --- a/frontend_nuxt/components/PostPoll.vue +++ /dev/null @@ -1,343 +0,0 @@ - - - - - diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index db7d9ae86..97630fee3 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -94,9 +94,157 @@ - +
+
+
+
+
+ + +
+
{{ lottery.prizeDescription }}
+
x {{ lottery.prizeCount }}
+
+
+
离结束还有
+
{{ countdown }}
+
+
+
+ 参与抽奖 {{ lottery.pointCost }} +
+
+
+
已参与
+
+
+
+
+ +
+
+
+ 参与抽奖 {{ lottery.pointCost }} +
+
+
+
已参与
+
+
+
+
+ +
+ + 获奖者: + +
+ {{ lotteryWinners[0].username }} +
+
+
+
- +
+
+
+
+
+
+
{{ opt }}
+
+ {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票) +
+
+
+
+
+
+ +
+
+
+
+
+ + {{ opt }} +
+
+
+
+
{{ pollParticipants.length }}
+
投票人
+
+
+
+
+ 投票 +
+
+ 结果 +
+ +
+
离结束还有
+
{{ countdown }}
+
+
+
该帖子已关闭,内容仅供阅读,无法进行互动
@@ -185,8 +333,6 @@ import ArticleTags from '~/components/ArticleTags.vue' import ArticleCategory from '~/components/ArticleCategory.vue' import ReactionsGroup from '~/components/ReactionsGroup.vue' import DropdownMenu from '~/components/DropdownMenu.vue' -import PostLottery from '~/components/PostLottery.vue' -import PostPoll from '~/components/PostPoll.vue' import { renderMarkdown, handleMarkdownClick, stripMarkdownLength } from '~/utils/markdown' import { getMedalTitle } from '~/utils/medal' import { toast } from '~/main' @@ -242,6 +388,7 @@ useHead(() => ({ if (import.meta.client) { onBeforeUnmount(() => { window.removeEventListener('scroll', updateCurrentIndex) + if (countdownTimer) clearInterval(countdownTimer) }) } @@ -253,6 +400,73 @@ const isAdmin = computed(() => authState.role === 'ADMIN') const isAuthor = computed(() => authState.username === author.value.username) const lottery = ref(null) const poll = ref(null) +const showPollResult = ref(false) +const countdown = ref('00:00:00') +let countdownTimer = null +const lotteryParticipants = computed(() => lottery.value?.participants || []) +const lotteryWinners = computed(() => lottery.value?.winners || []) +const lotteryEnded = computed(() => { + if (!lottery.value || !lottery.value.endTime) return false + return new Date(lottery.value.endTime).getTime() <= Date.now() +}) +const hasJoined = computed(() => { + if (!loggedIn.value) return false + return lotteryParticipants.value.some((p) => p.id === Number(authState.userId)) +}) +const pollParticipants = computed(() => poll.value?.participants || []) +const pollOptionParticipants = computed(() => poll.value?.optionParticipants || {}) +const pollVotes = computed(() => poll.value?.votes || {}) +const totalPollVotes = computed(() => Object.values(pollVotes.value).reduce((a, b) => a + b, 0)) +const pollPercentages = computed(() => + poll.value + ? poll.value.options.map((_, idx) => { + const c = pollVotes.value[idx] || 0 + return totalPollVotes.value ? ((c / totalPollVotes.value) * 100).toFixed(1) : 0 + }) + : [], +) +const pollEnded = computed(() => { + if (!poll.value || !poll.value.endTime) return false + return new Date(poll.value.endTime).getTime() <= Date.now() +}) +const hasVoted = computed(() => { + if (!loggedIn.value) return false + return pollParticipants.value.some((p) => p.id === Number(authState.userId)) +}) +watch([hasVoted, pollEnded], ([voted, ended]) => { + if (voted || ended) showPollResult.value = true +}) +const currentEndTime = computed(() => { + if (lottery.value && lottery.value.endTime) return lottery.value.endTime + if (poll.value && poll.value.endTime) return poll.value.endTime + return null +}) +const updateCountdown = () => { + if (!currentEndTime.value) { + countdown.value = '00:00:00' + return + } + const diff = new Date(currentEndTime.value).getTime() - Date.now() + if (diff <= 0) { + countdown.value = '00:00:00' + if (countdownTimer) { + clearInterval(countdownTimer) + countdownTimer = null + } + return + } + const h = String(Math.floor(diff / 3600000)).padStart(2, '0') + const m = String(Math.floor((diff % 3600000) / 60000)).padStart(2, '0') + const s = String(Math.floor((diff % 60000) / 1000)).padStart(2, '0') + countdown.value = `${h}:${m}:${s}` +} +const startCountdown = () => { + if (!import.meta.client) return + if (countdownTimer) clearInterval(countdownTimer) + updateCountdown() + countdownTimer = setInterval(updateCountdown, 1000) +} +const gotoUser = (id) => navigateTo(`/users/${id}`, { replace: true }) const articleMenuItems = computed(() => { const items = [] if (isAuthor.value || isAdmin.value) { @@ -414,6 +628,8 @@ watchEffect(() => { postTime.value = TimeManager.format(data.createdAt) lottery.value = data.lottery || null poll.value = data.poll || null + if ((lottery.value && lottery.value.endTime) || (poll.value && poll.value.endTime)) + startCountdown() }) // 404 客户端跳转 @@ -704,6 +920,45 @@ const unsubscribePost = async () => { } } +const joinLottery = async () => { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/lottery/join`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json().catch(() => ({})) + if (res.ok) { + toast.success('已参与抽奖') + await refreshPost() + } else { + toast.error(data.error || '操作失败') + } +} + +const voteOption = async (idx) => { + const token = getToken() + if (!token) { + toast.error('请先登录') + return + } + const res = await fetch(`${API_BASE_URL}/api/posts/${postId}/poll/vote?option=${idx}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + const data = await res.json().catch(() => ({})) + if (res.ok) { + toast.success('投票成功') + await refreshPost() + showPollResult.value = true + } else { + toast.error(data.error || '操作失败') + } +} + const fetchCommentSorts = () => { return Promise.resolve([ { id: 'NEWEST', name: '最新', icon: 'fas fa-clock' }, @@ -1032,7 +1287,404 @@ onMounted(async () => { cursor: pointer; } +.poll-option-button { + color: var(--text-color); + padding: 5px 10px; + border-radius: 8px; + background-color: var(--poll-option-button-background-color); + cursor: pointer; + width: fit-content; +} +.poll-top-container { + display: flex; + flex-direction: row; + align-items: center; + border-bottom: 1px solid var(--normal-border-color); +} + +.poll-options-container { + display: flex; + flex-direction: column; + overflow-y: auto; + flex: 4; +} + +.poll-info { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 100px; +} + +.total-votes { + font-size: 40px; + font-weight: bold; + opacity: 0.8; +} + +.total-votes-title { + font-size: 18px; + opacity: 0.5; +} + +.poll-option { + margin-bottom: 10px; + margin-right: 10px; + cursor: pointer; + display: flex; + align-items: center; +} + +.poll-option-result { + margin-bottom: 10px; + margin-right: 10px; + gap: 5px; + display: flex; + flex-direction: column; +} + +.poll-option-input { + margin-right: 10px; + width: 18px; + height: 18px; + accent-color: var(--primary-color); + border-radius: 50%; + border: 2px solid var(--primary-color); +} + +.poll-option-text { + font-size: 18px; +} + +.poll-bottom-container { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.poll-left-time { + display: flex; + flex-direction: row; +} + +.poll-left-time-title { + font-size: 13px; + opacity: 0.7; +} + +.action-menu-icon { + cursor: pointer; + font-size: 18px; + padding: 5px; +} + +.article-info-container { + display: flex; + flex-direction: row; + margin-top: 10px; + gap: 10px; + align-items: center; +} + +.info-content-container { + display: flex; + flex-direction: row; + gap: 10px; + padding: 0px; + border-bottom: 1px solid var(--normal-border-color); +} + +.user-avatar-container { + cursor: pointer; +} + +.user-avatar-item { + width: 50px; + height: 50px; +} + +.user-avatar-item-img { + width: 100%; + height: 100%; + border-radius: 50%; +} + +.info-content { + display: flex; + flex-direction: column; + gap: 3px; + width: 100%; +} + +.info-content-header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.user-name { + font-size: 16px; + font-weight: bold; + opacity: 0.7; +} + +.user-medal { + font-size: 12px; + margin-left: 4px; + opacity: 0.6; + cursor: pointer; + text-decoration: none; + color: var(--text-color); +} + +.post-time { + font-size: 14px; + opacity: 0.5; +} + +.info-content-text { + font-size: 16px; + line-height: 1.5; +} + +.article-footer-container { + display: flex; + flex-direction: row; + gap: 10px; + margin-top: 0px; +} + +.reactions-viewer { + display: flex; + flex-direction: row; + gap: 20px; + align-items: center; +} + +.reactions-viewer-item-container { + display: flex; + flex-direction: row; + gap: 2px; + align-items: center; +} + +.reactions-viewer-item { + font-size: 16px; +} + +.make-reaction-container { + display: flex; + flex-direction: row; + gap: 10px; +} + +.copy-link:hover { + background-color: #e2e2e2; +} + +.comment-editor-wrapper { + position: relative; +} + +.post-prize-container { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 10px; + background-color: var(--lottery-background-color); + border-radius: 10px; + padding: 10px; +} + +.post-poll-container { + margin-top: 20px; + display: flex; + flex-direction: column; + gap: 10px; + background-color: var(--lottery-background-color); + border-radius: 10px; + padding: 10px; +} + +.poll-question { + font-weight: bold; + margin-bottom: 10px; +} + +.poll-option-progress { + position: relative; + background-color: rgb(187, 187, 187); + height: 20px; + border-radius: 5px; + overflow: hidden; +} + +.poll-option-progress-bar { + background-color: var(--primary-color); + height: 100%; +} + +.poll-option-info-container { + display: flex; + flex-direction: row; + justify-content: space-between; +} + +.poll-option-progress-info { + font-size: 12px; + line-height: 20px; + color: var(--text-color); +} + +.poll-vote-button { + margin-top: 5px; + color: var(--primary-color); + cursor: pointer; + width: fit-content; +} + +.poll-participants { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.poll-participant-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + cursor: pointer; +} + +.prize-info { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + align-items: center; +} + +.join-prize-button-container-mobile { + margin-top: 15px; + margin-bottom: 10px; +} + +.prize-icon { + width: 24px; + height: 24px; +} + +.default-prize-icon { + font-size: 24px; + opacity: 0.5; +} + +.prize-icon-img { + width: 100%; + height: 100%; +} + +.prize-name { + font-size: 13px; + opacity: 0.7; + margin-left: 10px; +} + +.prize-count { + font-size: 13px; + font-weight: bold; + opacity: 0.7; + margin-left: 10px; + color: var(--primary-color); +} + +.prize-end-time { + display: flex; + flex-direction: row; + align-items: center; + font-size: 13px; + opacity: 0.7; + margin-left: 10px; +} + +.poll-left-time-title, +.prize-end-time-title { + font-size: 13px; + opacity: 0.7; + margin-right: 5px; +} + +.poll-left-time-value, +.prize-end-time-value { + font-size: 13px; + font-weight: bold; + color: var(--primary-color); +} + +.prize-info-left, +.prize-info-right { + display: flex; + flex-direction: row; + align-items: center; +} + +.join-prize-button { + margin-left: 10px; + background-color: var(--primary-color); + color: white; + padding: 5px 10px; + border-radius: 8px; + cursor: pointer; + text-align: center; +} + +.join-prize-button:hover { + background-color: var(--primary-color-hover); +} + +.join-prize-button-disabled { + text-align: center; + margin-left: 10px; + background-color: var(--primary-color); + color: white; + padding: 5px 10px; + border-radius: 8px; + background-color: var(--primary-color-disabled); + opacity: 0.5; + cursor: not-allowed; +} + +.prize-member-avatar { + width: 30px; + height: 30px; + margin-left: 3px; + border-radius: 50%; + object-fit: cover; + cursor: pointer; +} + +.prize-member-winner { + display: flex; + flex-direction: row; + align-items: center; + gap: 5px; + margin-top: 10px; +} + +.medal-icon { + font-size: 16px; + color: var(--primary-color); +} + +.prize-member-winner-name { + font-size: 13px; + opacity: 0.7; +} @media (max-width: 768px) { .post-page-main-container { @@ -1085,6 +1737,8 @@ onMounted(async () => { width: 100%; } + .join-prize-button, + .join-prize-button-disabled { margin-left: 0; } }