feat: 新增点踩功能,列表卡片和详情页支持 👍/👎 投票

- 后端: 新增 dislike_count 字段(模型/Schema/数据库迁移)
- 后端: 新增 POST /api/jokes/{id}/dislike 端点
- 后端: 管理后台统计新增 total_dislikes
- 前端: 新增 useVote 组合函数(localStorage 持久化防重复)
- 前端: JokeCard 列表卡片新增 👍/👎 可点击投票按钮
- 前端: 详情页 ❤️ 改为 👍 赞一下 / 👎 踩一脚 双按钮
This commit is contained in:
bwstudio
2026-06-11 19:17:12 +08:00
parent b5194c4965
commit fde86b90d2
9 changed files with 191 additions and 45 deletions
+1
View File
@@ -4,4 +4,5 @@ export const getJokes = params => request.get('/jokes/', { params })
export const getJoke = id => request.get(`/jokes/${id}`)
export const getRandomJoke = () => request.get('/jokes/random')
export const likeJoke = id => request.post(`/jokes/${id}/like`)
export const dislikeJoke = id => request.post(`/jokes/${id}/dislike`)
export const searchJokes = params => request.get('/jokes/', { params })
+67
View File
@@ -0,0 +1,67 @@
/**
* 点赞/点踩 投票状态管理 (localStorage 持久化)
* 键名格式: joke_vote_{id} → { liked: bool, disliked: bool }
*
* @param {import('vue').Ref<number|null>} jokeIdRef — 响应式的笑话 ID
* @returns {{ state: { liked: boolean, disliked: boolean }, handleLike: () => Promise<number>, handleDislike: () => Promise<number> }}
*/
import { reactive, watchEffect, isRef, ref, toRef } from 'vue'
import { likeJoke, dislikeJoke } from '@/api/joke'
const STORAGE_PREFIX = 'joke_vote_'
function loadState(jokeId) {
if (!jokeId) return { liked: false, disliked: false }
try {
return JSON.parse(localStorage.getItem(STORAGE_PREFIX + jokeId)) || {}
} catch {
return {}
}
}
function persistState(jokeId, key) {
if (!jokeId) return
const prev = loadState(jokeId)
prev[key] = true
localStorage.setItem(STORAGE_PREFIX + jokeId, JSON.stringify(prev))
}
export function useVote(jokeIdRef) {
const state = reactive({ liked: false, disliked: false })
// 当 jokeId 变化时重新从 localStorage 读取
const _id = isRef(jokeIdRef) ? jokeIdRef : ref(jokeIdRef)
watchEffect(() => {
const saved = loadState(_id.value)
state.liked = !!saved.liked
state.disliked = !!saved.disliked
})
const handleLike = async () => {
const id = _id.value
if (!id || state.liked) return
try {
const res = await likeJoke(id)
state.liked = true
persistState(id, 'liked')
return res.like_count
} catch {
// 静默失败
}
}
const handleDislike = async () => {
const id = _id.value
if (!id || state.disliked) return
try {
const res = await dislikeJoke(id)
state.disliked = true
persistState(id, 'disliked')
return res.dislike_count
} catch {
// 静默失败
}
}
return { state, handleLike, handleDislike }
}
+49 -33
View File
@@ -44,9 +44,14 @@
<div class="detail-meta">
<span>浏览 {{ joke.view_count || 0 }}</span>
<span class="sep">|</span>
<button class="like-btn" :class="{ 'liked': liked }" @click.stop="handleLike">
<span class="heart">&#x2764;</span>
<span> {{ joke.like_count || 0 }}</span>
<button class="vote-btn" :class="{ voted: voteState.liked }" @click.stop="doLike">
<span class="vote-icon">&#x1F44D;</span>
<span>一下 {{ joke.like_count || 0 }}</span>
</button>
<span class="sep">|</span>
<button class="vote-btn" :class="{ voted: voteState.disliked }" @click.stop="doDislike">
<span class="vote-icon">&#x1F44E;</span>
<span>踩一脚 {{ joke.dislike_count || 0 }}</span>
</button>
<span class="sep">|</span>
<span>{{ formatDate(joke.created_at) }}</span>
@@ -63,50 +68,52 @@
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { ref, watch, onMounted, watchEffect } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getJoke, getJokes, likeJoke } from '@/api/joke'
import { ElMessage } from 'element-plus'
import { getJoke, getJokes } from '@/api/joke'
import { useVote } from '@/composables/useVote'
const route = useRoute()
const router = useRouter()
const joke = ref(null)
const liked = ref(false)
const loading = ref(false)
const currentJokeId = ref(null)
// 请求 ID 追踪,防止竞态
let currentRequestId = 0
const { state: voteState, handleLike, handleDislike } = useVote(currentJokeId)
const loadJoke = async () => {
const requestId = ++currentRequestId
loading.value = true
try {
const res = await getJoke(route.params.id)
// 只接受最新请求的响应
if (requestId !== currentRequestId) return
joke.value = res
liked.value = false
currentJokeId.value = res.id
loading.value = false
} catch (e) {
if (requestId !== currentRequestId) return
loading.value = false
// 笑话不存在时返回首页
if (e?.detail === '笑话不存在' || e?.response?.status === 404) {
router.push('/')
}
}
}
const handleLike = async () => {
if (liked.value || !joke.value) return
try {
const res = await likeJoke(route.params.id)
if (joke.value && res?.like_count !== undefined) {
joke.value.like_count = res.like_count
}
liked.value = true
ElMessage.success('点赞成功')
} catch (e) {
ElMessage.error('点赞失败,请重试')
const doLike = async () => {
if (!joke.value) return
const newCount = await handleLike()
if (newCount !== undefined && joke.value) {
joke.value.like_count = newCount
}
}
const doDislike = async () => {
if (!joke.value) return
const newCount = await handleDislike()
if (newCount !== undefined && joke.value) {
joke.value.dislike_count = newCount
}
}
@@ -291,7 +298,8 @@ watch(() => route.params.id, () => loadJoke())
}
.sep { color: #ddd; }
.like-btn {
/* 投票按钮(详情页) */
.vote-btn {
background: none;
border: none;
color: var(--text-muted);
@@ -300,19 +308,27 @@ watch(() => route.params.id, () => loadJoke())
align-items: center;
gap: 4px;
font-size: 12px;
padding: 0;
transition: color 0.2s;
padding: 2px 8px;
border-radius: 8px;
transition: all 0.2s;
font-family: inherit;
}
.like-btn:hover { color: #e74c3c; }
.like-btn.liked { color: #e74c3c; }
.like-btn .heart {
font-size: 13px;
color: #ccc;
transition: color 0.2s, transform 0.2s;
.vote-btn:hover {
background: var(--bg-page);
color: var(--text-primary);
}
.like-btn:hover .heart { color: #e74c3c; }
.like-btn.liked .heart {
color: #e74c3c;
.vote-btn.voted {
color: var(--primary);
font-weight: 700;
}
.vote-icon {
font-size: 14px;
transition: transform 0.2s;
}
.vote-btn:hover .vote-icon {
transform: scale(1.2);
}
.vote-btn.voted .vote-icon {
animation: pop 0.3s ease;
}
@keyframes pop {
+39 -5
View File
@@ -24,10 +24,12 @@
<span class="stat-icon">&#x1F441;</span>
{{ joke.view_count || 0 }}
</span>
<span class="stat">
<span class="stat-icon">&#x2764;</span>
{{ joke.like_count || 0 }}
</span>
<button class="stat vote-btn" :class="{ voted: voteState.liked }" @click.stop="handleLike">
&#x1F44D; {{ joke.like_count || 0 }}
</button>
<button class="stat vote-btn" :class="{ voted: voteState.disliked }" @click.stop="handleDislike">
&#x1F44E; {{ joke.dislike_count || 0 }}
</button>
</div>
<span class="read-more">阅读 &#x2192;</span>
</div>
@@ -35,10 +37,16 @@
</template>
<script setup>
defineProps({
import { computed } from 'vue'
import { useVote } from '@/composables/useVote'
const props = defineProps({
joke: { type: Object, required: true }
})
const jokeId = computed(() => props.joke.id)
const { state: voteState, handleLike, handleDislike } = useVote(jokeId)
function getLevelText(level) {
const map = {
'excellent': '⭐⭐⭐ 精品',
@@ -151,6 +159,32 @@ function getLevelText(level) {
color: var(--text-muted);
}
.stat-icon { font-size: 12px; }
/* 投票按钮 */
.vote-btn {
background: none;
border: none;
cursor: pointer;
padding: 2px 6px;
border-radius: 6px;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
color: var(--text-muted);
font-family: inherit;
}
.vote-btn:hover {
background: var(--bg-page);
transform: scale(1.08);
}
.vote-btn.voted {
color: var(--primary);
font-weight: 700;
}
.vote-btn .stat-icon { font-size: 14px; }
.read-more {
font-size: 11px;
color: var(--primary);