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 @@
-
-
-
-
-
-
-
-
-
-
{{ lottery.prizeDescription }}
-
x {{ lottery.prizeCount }}
-
-
-
离结束还有
-
{{ countdown }}
-
-
-
-
-
-
-
-
-
-
-
获奖者:
-
-
- {{ lotteryWinners[0].username }}
-
-
-
-
-
-
-
-
-
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 @@
-
-
-
-
-
-
-
-
{{ opt }}
-
- {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票)
-
-
-
-
-
-
-
-
-
-
-
-
{{ pollParticipants.length }}
-
投票人
-
-
-
-
- 投票
-
-
- 结果
-
-
-
-
离结束还有
-
{{ countdown }}
-
-
-
-
-
-
-
-
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 }}
+
+
+
+
+
+
+
+
+
+
+
获奖者:
+
+
+ {{ lotteryWinners[0].username }}
+
+
+
+
-
+
+
+
+
+
+
+
{{ opt }}
+
+ {{ pollPercentages[idx] }}% ({{ pollVotes[idx] || 0 }}人已投票)
+
+
+
+
+
+
+
+
+
+
+
+
{{ 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;
}
}