feat: 新增点踩功能,列表卡片和详情页支持 👍/👎 投票
- 后端: 新增 dislike_count 字段(模型/Schema/数据库迁移)
- 后端: 新增 POST /api/jokes/{id}/dislike 端点
- 后端: 管理后台统计新增 total_dislikes
- 前端: 新增 useVote 组合函数(localStorage 持久化防重复)
- 前端: JokeCard 列表卡片新增 👍/👎 可点击投票按钮
- 前端: 详情页 ❤️ 改为 👍 赞一下 / 👎 踩一脚 双按钮
This commit is contained in:
@@ -25,6 +25,7 @@ class Joke(Base):
|
|||||||
status = Column(String(20), default="pending")
|
status = Column(String(20), default="pending")
|
||||||
view_count = Column(Integer, default=0)
|
view_count = Column(Integer, default=0)
|
||||||
like_count = Column(Integer, default=0)
|
like_count = Column(Integer, default=0)
|
||||||
|
dislike_count = Column(Integer, default=0)
|
||||||
created_at = Column(DateTime, default=func.now())
|
created_at = Column(DateTime, default=func.now())
|
||||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
|
|||||||
status=joke.status,
|
status=joke.status,
|
||||||
view_count=joke.view_count,
|
view_count=joke.view_count,
|
||||||
like_count=joke.like_count,
|
like_count=joke.like_count,
|
||||||
|
dislike_count=joke.dislike_count,
|
||||||
created_at=joke.created_at,
|
created_at=joke.created_at,
|
||||||
updated_at=joke.updated_at,
|
updated_at=joke.updated_at,
|
||||||
type_names=type_names,
|
type_names=type_names,
|
||||||
@@ -224,6 +225,7 @@ def admin_stats(
|
|||||||
rejected_jokes = db.query(Joke).filter(Joke.status == "rejected").count()
|
rejected_jokes = db.query(Joke).filter(Joke.status == "rejected").count()
|
||||||
total_views = db.query(Joke).with_entities(func.sum(Joke.view_count)).scalar() or 0
|
total_views = db.query(Joke).with_entities(func.sum(Joke.view_count)).scalar() or 0
|
||||||
total_likes = db.query(Joke).with_entities(func.sum(Joke.like_count)).scalar() or 0
|
total_likes = db.query(Joke).with_entities(func.sum(Joke.like_count)).scalar() or 0
|
||||||
|
total_dislikes = db.query(Joke).with_entities(func.sum(Joke.dislike_count)).scalar() or 0
|
||||||
return {
|
return {
|
||||||
"total_jokes": total_jokes,
|
"total_jokes": total_jokes,
|
||||||
"approved_jokes": approved_jokes,
|
"approved_jokes": approved_jokes,
|
||||||
@@ -231,6 +233,7 @@ def admin_stats(
|
|||||||
"rejected_jokes": rejected_jokes,
|
"rejected_jokes": rejected_jokes,
|
||||||
"total_views": total_views,
|
"total_views": total_views,
|
||||||
"total_likes": total_likes,
|
"total_likes": total_likes,
|
||||||
|
"total_dislikes": total_dislikes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ def joke_to_response(joke: Joke, type_map: dict = None, crowd_map: dict = None)
|
|||||||
status=joke.status,
|
status=joke.status,
|
||||||
view_count=joke.view_count,
|
view_count=joke.view_count,
|
||||||
like_count=joke.like_count,
|
like_count=joke.like_count,
|
||||||
|
dislike_count=joke.dislike_count,
|
||||||
created_at=joke.created_at,
|
created_at=joke.created_at,
|
||||||
updated_at=joke.updated_at,
|
updated_at=joke.updated_at,
|
||||||
type_names=type_names,
|
type_names=type_names,
|
||||||
@@ -207,10 +208,8 @@ def get_random_joke(db: Session = Depends(get_db)):
|
|||||||
return joke_to_response(joke, type_map, crowd_map)
|
return joke_to_response(joke, type_map, crowd_map)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{joke_id}/like")
|
def _vote_joke(joke_id: int, field: str, db: Session):
|
||||||
def like_joke(joke_id: int, db: Session = Depends(get_db)):
|
"""通用投票逻辑:点赞或点踩(仅允许已审核通过的笑话)"""
|
||||||
"""为笑话点赞(仅允许已审核通过的笑话)"""
|
|
||||||
# 先检查笑话是否存在且已审核
|
|
||||||
joke = db.query(Joke).filter(
|
joke = db.query(Joke).filter(
|
||||||
Joke.id == joke_id,
|
Joke.id == joke_id,
|
||||||
Joke.status == "approved"
|
Joke.status == "approved"
|
||||||
@@ -218,8 +217,20 @@ def like_joke(joke_id: int, db: Session = Depends(get_db)):
|
|||||||
if not joke:
|
if not joke:
|
||||||
raise HTTPException(status_code=404, detail="笑话不存在")
|
raise HTTPException(status_code=404, detail="笑话不存在")
|
||||||
# 使用原子更新避免并发竞态
|
# 使用原子更新避免并发竞态
|
||||||
db.query(Joke).filter(Joke.id == joke_id).update({Joke.like_count: Joke.like_count + 1})
|
update_data = {getattr(Joke, field): getattr(Joke, field) + 1}
|
||||||
|
db.query(Joke).filter(Joke.id == joke_id).update(update_data)
|
||||||
db.commit()
|
db.commit()
|
||||||
# 获取更新后的值
|
|
||||||
db.refresh(joke)
|
db.refresh(joke)
|
||||||
return {"message": "点赞成功", "like_count": joke.like_count}
|
return {"message": "操作成功", field: getattr(joke, field)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{joke_id}/like")
|
||||||
|
def like_joke(joke_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""为笑话点赞(仅允许已审核通过的笑话)"""
|
||||||
|
return _vote_joke(joke_id, "like_count", db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{joke_id}/dislike")
|
||||||
|
def dislike_joke(joke_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""为笑话点踩(仅允许已审核通过的笑话)"""
|
||||||
|
return _vote_joke(joke_id, "dislike_count", db)
|
||||||
@@ -34,6 +34,7 @@ class JokeResponse(JokeBase):
|
|||||||
ai_level: str | None = None
|
ai_level: str | None = None
|
||||||
view_count: int
|
view_count: int
|
||||||
like_count: int
|
like_count: int
|
||||||
|
dislike_count: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
type_names: list[str] | None = None
|
type_names: list[str] | None = None
|
||||||
|
|||||||
+12
@@ -12,6 +12,18 @@ from app.models.feedback import Feedback # 注册模型,确保 create_all 能
|
|||||||
# 创建新表(如果不存在)
|
# 创建新表(如果不存在)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# 对已有表新增字段的兼容迁移(SQLite 不支持 ALTER TABLE ADD COLUMN IF NOT EXISTS)
|
||||||
|
def _migrate_db():
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
inspector = inspect(engine)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns("jokes")]
|
||||||
|
if "dislike_count" not in columns:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("ALTER TABLE jokes ADD COLUMN dislike_count INTEGER DEFAULT 0"))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
_migrate_db()
|
||||||
|
|
||||||
# Create FastAPI application
|
# Create FastAPI application
|
||||||
app = FastAPI(title=API_TITLE, version=API_VERSION)
|
app = FastAPI(title=API_TITLE, version=API_VERSION)
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export const getJokes = params => request.get('/jokes/', { params })
|
|||||||
export const getJoke = id => request.get(`/jokes/${id}`)
|
export const getJoke = id => request.get(`/jokes/${id}`)
|
||||||
export const getRandomJoke = () => request.get('/jokes/random')
|
export const getRandomJoke = () => request.get('/jokes/random')
|
||||||
export const likeJoke = id => request.post(`/jokes/${id}/like`)
|
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 })
|
export const searchJokes = params => request.get('/jokes/', { params })
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -44,9 +44,14 @@
|
|||||||
<div class="detail-meta">
|
<div class="detail-meta">
|
||||||
<span>浏览 {{ joke.view_count || 0 }}</span>
|
<span>浏览 {{ joke.view_count || 0 }}</span>
|
||||||
<span class="sep">|</span>
|
<span class="sep">|</span>
|
||||||
<button class="like-btn" :class="{ 'liked': liked }" @click.stop="handleLike">
|
<button class="vote-btn" :class="{ voted: voteState.liked }" @click.stop="doLike">
|
||||||
<span class="heart">❤</span>
|
<span class="vote-icon">👍</span>
|
||||||
<span>点赞 {{ joke.like_count || 0 }}</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">👎</span>
|
||||||
|
<span>踩一脚 {{ joke.dislike_count || 0 }}</span>
|
||||||
</button>
|
</button>
|
||||||
<span class="sep">|</span>
|
<span class="sep">|</span>
|
||||||
<span>{{ formatDate(joke.created_at) }}</span>
|
<span>{{ formatDate(joke.created_at) }}</span>
|
||||||
@@ -63,50 +68,52 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, watch, onMounted, watchEffect } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { getJoke, getJokes, likeJoke } from '@/api/joke'
|
import { getJoke, getJokes } from '@/api/joke'
|
||||||
import { ElMessage } from 'element-plus'
|
import { useVote } from '@/composables/useVote'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const joke = ref(null)
|
const joke = ref(null)
|
||||||
const liked = ref(false)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const currentJokeId = ref(null)
|
||||||
// 请求 ID 追踪,防止竞态
|
// 请求 ID 追踪,防止竞态
|
||||||
let currentRequestId = 0
|
let currentRequestId = 0
|
||||||
|
|
||||||
|
const { state: voteState, handleLike, handleDislike } = useVote(currentJokeId)
|
||||||
|
|
||||||
const loadJoke = async () => {
|
const loadJoke = async () => {
|
||||||
const requestId = ++currentRequestId
|
const requestId = ++currentRequestId
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getJoke(route.params.id)
|
const res = await getJoke(route.params.id)
|
||||||
// 只接受最新请求的响应
|
|
||||||
if (requestId !== currentRequestId) return
|
if (requestId !== currentRequestId) return
|
||||||
joke.value = res
|
joke.value = res
|
||||||
liked.value = false
|
currentJokeId.value = res.id
|
||||||
loading.value = false
|
loading.value = false
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (requestId !== currentRequestId) return
|
if (requestId !== currentRequestId) return
|
||||||
loading.value = false
|
loading.value = false
|
||||||
// 笑话不存在时返回首页
|
|
||||||
if (e?.detail === '笑话不存在' || e?.response?.status === 404) {
|
if (e?.detail === '笑话不存在' || e?.response?.status === 404) {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLike = async () => {
|
const doLike = async () => {
|
||||||
if (liked.value || !joke.value) return
|
if (!joke.value) return
|
||||||
try {
|
const newCount = await handleLike()
|
||||||
const res = await likeJoke(route.params.id)
|
if (newCount !== undefined && joke.value) {
|
||||||
if (joke.value && res?.like_count !== undefined) {
|
joke.value.like_count = newCount
|
||||||
joke.value.like_count = res.like_count
|
|
||||||
}
|
}
|
||||||
liked.value = true
|
}
|
||||||
ElMessage.success('点赞成功')
|
|
||||||
} catch (e) {
|
const doDislike = async () => {
|
||||||
ElMessage.error('点赞失败,请重试')
|
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; }
|
.sep { color: #ddd; }
|
||||||
|
|
||||||
.like-btn {
|
/* 投票按钮(详情页) */
|
||||||
|
.vote-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -300,19 +308,27 @@ watch(() => route.params.id, () => loadJoke())
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 0;
|
padding: 2px 8px;
|
||||||
transition: color 0.2s;
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.like-btn:hover { color: #e74c3c; }
|
.vote-btn:hover {
|
||||||
.like-btn.liked { color: #e74c3c; }
|
background: var(--bg-page);
|
||||||
.like-btn .heart {
|
color: var(--text-primary);
|
||||||
font-size: 13px;
|
|
||||||
color: #ccc;
|
|
||||||
transition: color 0.2s, transform 0.2s;
|
|
||||||
}
|
}
|
||||||
.like-btn:hover .heart { color: #e74c3c; }
|
.vote-btn.voted {
|
||||||
.like-btn.liked .heart {
|
color: var(--primary);
|
||||||
color: #e74c3c;
|
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;
|
animation: pop 0.3s ease;
|
||||||
}
|
}
|
||||||
@keyframes pop {
|
@keyframes pop {
|
||||||
|
|||||||
@@ -24,10 +24,12 @@
|
|||||||
<span class="stat-icon">👁</span>
|
<span class="stat-icon">👁</span>
|
||||||
{{ joke.view_count || 0 }}
|
{{ joke.view_count || 0 }}
|
||||||
</span>
|
</span>
|
||||||
<span class="stat">
|
<button class="stat vote-btn" :class="{ voted: voteState.liked }" @click.stop="handleLike">
|
||||||
<span class="stat-icon">❤</span>
|
👍 {{ joke.like_count || 0 }}
|
||||||
{{ joke.like_count || 0 }}
|
</button>
|
||||||
</span>
|
<button class="stat vote-btn" :class="{ voted: voteState.disliked }" @click.stop="handleDislike">
|
||||||
|
👎 {{ joke.dislike_count || 0 }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="read-more">阅读 →</span>
|
<span class="read-more">阅读 →</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,10 +37,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
defineProps({
|
import { computed } from 'vue'
|
||||||
|
import { useVote } from '@/composables/useVote'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
joke: { type: Object, required: true }
|
joke: { type: Object, required: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const jokeId = computed(() => props.joke.id)
|
||||||
|
const { state: voteState, handleLike, handleDislike } = useVote(jokeId)
|
||||||
|
|
||||||
function getLevelText(level) {
|
function getLevelText(level) {
|
||||||
const map = {
|
const map = {
|
||||||
'excellent': '⭐⭐⭐ 精品',
|
'excellent': '⭐⭐⭐ 精品',
|
||||||
@@ -151,6 +159,32 @@ function getLevelText(level) {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.stat-icon { font-size: 12px; }
|
.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 {
|
.read-more {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
|
|||||||
Reference in New Issue
Block a user