From a2ccc95b4e291c59e14d550c7d347c4d2940eb6d Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Fri, 29 Aug 2025 23:56:03 +0800 Subject: [PATCH] feat: add poll post support --- frontend_nuxt/components/PostTypeSelect.vue | 1 + frontend_nuxt/pages/index.vue | 7 +- frontend_nuxt/pages/new-post.vue | 85 +++++++++++- frontend_nuxt/pages/posts/[id]/index.vue | 146 +++++++++++++++++++- 4 files changed, 232 insertions(+), 7 deletions(-) diff --git a/frontend_nuxt/components/PostTypeSelect.vue b/frontend_nuxt/components/PostTypeSelect.vue index 3d01eacd7..fd713628c 100644 --- a/frontend_nuxt/components/PostTypeSelect.vue +++ b/frontend_nuxt/components/PostTypeSelect.vue @@ -33,6 +33,7 @@ export default { return [ { id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' }, { id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' }, + { id: 'POLL', name: '投票帖子', icon: 'fa-solid fa-square-poll-vertical' }, ] } diff --git a/frontend_nuxt/pages/index.vue b/frontend_nuxt/pages/index.vue index dfd51e349..e2be72a60 100644 --- a/frontend_nuxt/pages/index.vue +++ b/frontend_nuxt/pages/index.vue @@ -70,6 +70,10 @@ + {{ article.title }} @@ -542,7 +546,8 @@ const sanitizeDescription = (text) => stripMarkdown(text) } .pinned-icon, -.lottery-icon { +.lottery-icon, +.poll-icon { margin-right: 4px; color: var(--primary-color); } diff --git a/frontend_nuxt/pages/new-post.vue b/frontend_nuxt/pages/new-post.vue index 1252330a0..43935c1af 100644 --- a/frontend_nuxt/pages/new-post.vue +++ b/frontend_nuxt/pages/new-post.vue @@ -85,6 +85,30 @@ +
+
+ 投票问题 + +
+
+ 投票选项 +
+ + +
+
添加选项
+
+
+ 投票结束时间 + + + +
+
@@ -120,6 +144,8 @@ const prizeDescription = ref('') const pointCost = ref(0) const endTime = ref(null) const startTime = ref(null) +const pollQuestion = ref('') +const pollOptions = ref(['', '']) const dateConfig = { enableTime: true, time_24hr: true, dateFormat: 'Y-m-d H:i' } const isWaitingPosting = ref(false) const isAiLoading = ref(false) @@ -151,6 +177,14 @@ watch(pointCost, (val) => { if (val > 100) pointCost.value = 100 }) +const addOption = () => { + pollOptions.value.push('') +} + +const removeOption = (idx) => { + if (pollOptions.value.length > 2) pollOptions.value.splice(idx, 1) +} + const loadDraft = async () => { const token = getToken() if (!token) return @@ -189,6 +223,8 @@ const clearPost = async () => { pointCost.value = 0 endTime.value = null startTime.value = null + pollQuestion.value = '' + pollOptions.value = ['', ''] // 删除草稿 const token = getToken() @@ -339,6 +375,20 @@ const submitPost = async () => { return } } + if (postType.value === 'POLL') { + if (!pollQuestion.value.trim()) { + toast.error('请输入投票问题') + return + } + if (pollOptions.value.length < 2 || pollOptions.value.some((o) => !o.trim())) { + toast.error('请填写至少两个投票选项') + return + } + if (!endTime.value) { + toast.error('请选择投票结束时间') + return + } + } try { const token = getToken() await ensureTags(token) @@ -375,12 +425,14 @@ const submitPost = async () => { prizeName: postType.value === 'LOTTERY' ? prizeName.value : undefined, prizeCount: postType.value === 'LOTTERY' ? prizeCount.value : undefined, prizeDescription: postType.value === 'LOTTERY' ? prizeDescription.value : undefined, + question: postType.value === 'POLL' ? pollQuestion.value : undefined, + options: postType.value === 'POLL' ? pollOptions.value : undefined, startTime: postType.value === 'LOTTERY' ? new Date(startTime.value).toISOString() : undefined, pointCost: postType.value === 'LOTTERY' ? pointCost.value : undefined, // 将时间转换为 UTC+8.5 时区 todo: 需要优化 endTime: - postType.value === 'LOTTERY' + postType.value === 'LOTTERY' || postType.value === 'POLL' ? new Date(new Date(endTime.value).getTime() + 8.02 * 60 * 60 * 1000).toISOString() : undefined, }), @@ -517,7 +569,8 @@ const submitPost = async () => { padding-bottom: 50px; } -.lottery-section { +.lottery-section, +.poll-section { margin-top: 20px; display: flex; flex-direction: column; @@ -526,7 +579,8 @@ const submitPost = async () => { margin-bottom: 200px; } -.prize-row-title { +.prize-row-title, +.poll-row-title { font-size: 16px; color: var(--text-color); font-weight: bold; @@ -612,6 +666,31 @@ const submitPost = async () => { color: var(--text-color); } +.poll-option-item { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} + +.remove-option-icon { + cursor: pointer; +} + +.add-option { + color: var(--primary-color); + cursor: pointer; + width: fit-content; + margin-top: 5px; +} + +.poll-options-row, +.poll-question-row, +.poll-time-row { + display: flex; + flex-direction: column; +} + .prize-count-input-field { width: 50px; height: 30px; diff --git a/frontend_nuxt/pages/posts/[id]/index.vue b/frontend_nuxt/pages/posts/[id]/index.vue index c6bd9d440..63be402bd 100644 --- a/frontend_nuxt/pages/posts/[id]/index.vue +++ b/frontend_nuxt/pages/posts/[id]/index.vue @@ -171,6 +171,38 @@ +
+
{{ poll.question }}
+
+
+
{{ opt }}
+
+
+
{{ pollVotes[idx] || 0 }} 票
+
+
+ 投票 +
+
+
+
+ +
+
该帖子已关闭,内容仅供阅读,无法进行互动
@@ -325,6 +357,7 @@ const loggedIn = computed(() => authState.loggedIn) const isAdmin = computed(() => authState.role === 'ADMIN') const isAuthor = computed(() => authState.username === author.value.username) const lottery = ref(null) +const poll = ref(null) const countdown = ref('00:00:00') let countdownTimer = null const lotteryParticipants = computed(() => lottery.value?.participants || []) @@ -337,12 +370,36 @@ 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 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)) +}) +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 (!lottery.value || !lottery.value.endTime) { + if (!currentEndTime.value) { countdown.value = '00:00:00' return } - const diff = new Date(lottery.value.endTime).getTime() - Date.now() + const diff = new Date(currentEndTime.value).getTime() - Date.now() if (diff <= 0) { countdown.value = '00:00:00' if (countdownTimer) { @@ -523,7 +580,9 @@ watchEffect(() => { rssExcluded.value = data.rssExcluded postTime.value = TimeManager.format(data.createdAt) lottery.value = data.lottery || null - if (lottery.value && lottery.value.endTime) startCountdown() + poll.value = data.poll || null + if ((lottery.value && lottery.value.endTime) || (poll.value && poll.value.endTime)) + startCountdown() }) // 404 客户端跳转 @@ -833,6 +892,25 @@ const joinLottery = async () => { } } +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() + } else { + toast.error(data.error || '操作失败') + } +} + const fetchCommentSorts = () => { return Promise.resolve([ { id: 'NEWEST', name: '最新', icon: 'fas fa-clock' }, @@ -1286,6 +1364,68 @@ onMounted(async () => { 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 { + margin-bottom: 10px; +} + +.poll-option-progress { + position: relative; + background-color: var(--border-color); + height: 20px; + border-radius: 5px; + overflow: hidden; +} + +.poll-option-progress-bar { + background-color: var(--primary-color); + height: 100%; +} + +.poll-option-progress-info { + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); + font-size: 12px; + line-height: 20px; + color: #fff; +} + +.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;