feat: 当月热门笑话 + 统计面板,删除首页清除筛选按钮

- 后端新增 GET /api/jokes/hot-monthly(当月浏览量 top 10)
- 后端新增 GET /api/jokes/stats(公开统计:总数/审核/待审/浏览等)
- 右侧栏热门改为当月热门笑话
- 右侧栏新增网站统计区(笑话总数/已审核/待审核/总浏览)
- 首页删除清除筛选按钮
This commit is contained in:
bwstudio
2026-06-12 05:48:32 +08:00
parent 93b1d725e3
commit 076812f1aa
5 changed files with 139 additions and 34 deletions
+61 -20
View File
@@ -1,5 +1,6 @@
import json import json
import random import random
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, text from sqlalchemy import func, text
@@ -161,26 +162,6 @@ def list_jokes(
) )
@router.get("/{joke_id}", response_model=JokeResponse)
def get_joke(joke_id: int, db: Session = Depends(get_db)):
"""获取单条笑话详情"""
# 只返回已审核通过的笑话
joke = db.query(Joke).filter(
Joke.id == joke_id,
Joke.status == "approved"
).first()
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
# 使用原子更新避免并发竞态
db.query(Joke).filter(Joke.id == joke_id).update({Joke.view_count: Joke.view_count + 1})
db.commit()
# 重新查询获取更新后的数据
db.refresh(joke)
# 批量加载类型和人群名称
type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
@router.get("/random", response_model=JokeResponse) @router.get("/random", response_model=JokeResponse)
def get_random_joke(db: Session = Depends(get_db)): def get_random_joke(db: Session = Depends(get_db)):
"""随机获取一条已审核通过的笑话""" """随机获取一条已审核通过的笑话"""
@@ -208,6 +189,66 @@ 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.get("/hot-monthly")
def hot_monthly_jokes(db: Session = Depends(get_db)):
"""当月热门笑话:本月浏览量最高的前 10 条"""
now = datetime.now()
start_of_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
jokes = (
db.query(Joke)
.filter(
Joke.status == "approved",
Joke.created_at >= start_of_month,
)
.order_by(Joke.view_count.desc())
.limit(10)
.all()
)
type_map, crowd_map = _batch_load_categories(db, jokes)
return [joke_to_response(j, type_map, crowd_map) for j in jokes]
@router.get("/stats")
def public_stats(db: Session = Depends(get_db)):
"""公开统计数据:笑话总数、审核/待审/拒绝数量、总浏览/赞/踩"""
total = db.query(Joke).count()
approved = db.query(Joke).filter(Joke.status == "approved").count()
pending = db.query(Joke).filter(Joke.status == "pending").count()
rejected = db.query(Joke).filter(Joke.status == "rejected").count()
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_dislikes = db.query(Joke).with_entities(func.sum(Joke.dislike_count)).scalar() or 0
return {
"total_jokes": total,
"approved_jokes": approved,
"pending_jokes": pending,
"rejected_jokes": rejected,
"total_views": total_views,
"total_likes": total_likes,
"total_dislikes": total_dislikes,
}
@router.get("/{joke_id}", response_model=JokeResponse)
def get_joke(joke_id: int, db: Session = Depends(get_db)):
"""获取单条笑话详情"""
# 只返回已审核通过的笑话
joke = db.query(Joke).filter(
Joke.id == joke_id,
Joke.status == "approved"
).first()
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
# 使用原子更新避免并发竞态
db.query(Joke).filter(Joke.id == joke_id).update({Joke.view_count: Joke.view_count + 1})
db.commit()
# 重新查询获取更新后的数据
db.refresh(joke)
# 批量加载类型和人群名称
type_map, crowd_map = _batch_load_categories(db, [joke])
return joke_to_response(joke, type_map, crowd_map)
def _vote_joke(joke_id: int, field: str, db: Session): def _vote_joke(joke_id: int, field: str, db: Session):
"""通用投票逻辑:点赞或点踩(仅允许已审核通过的笑话)""" """通用投票逻辑:点赞或点踩(仅允许已审核通过的笑话)"""
joke = db.query(Joke).filter( joke = db.query(Joke).filter(
+13 -5
View File
@@ -58,6 +58,7 @@
:hot-jokes="hotJokes" :hot-jokes="hotJokes"
:types="allTypes" :types="allTypes"
:crowds="allCrowds" :crowds="allCrowds"
:stats="stats"
/> />
</div> </div>
@@ -75,7 +76,7 @@ import AppSubNav from '@/components/AppSubNav.vue'
import AppRightbar from '@/components/AppRightbar.vue' import AppRightbar from '@/components/AppRightbar.vue'
import Footer from '@/components/Footer.vue' import Footer from '@/components/Footer.vue'
import { getTypes, getCrowds } from '@/api/category' import { getTypes, getCrowds } from '@/api/category'
import { getJokes } from '@/api/joke' import { getJokes, getHotMonthly, getStats } from '@/api/joke'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore() const themeStore = useThemeStore()
@@ -98,6 +99,8 @@ const filterCrowdIds = ref([])
// 热门笑话(右侧栏) // 热门笑话(右侧栏)
const hotJokes = ref([]) const hotJokes = ref([])
// 网站统计
const stats = ref(null)
// 右侧栏:仅首页显示 // 右侧栏:仅首页显示
@@ -180,14 +183,19 @@ onMounted(async () => {
} }
try { try {
const res = await getJokes({ page: 1, page_size: 30 }) hotJokes.value = await getHotMonthly()
console.log('[APP] jokes loaded:', res.items?.length, 'total:', res.total) console.log('[APP] monthly hot jokes loaded:', hotJokes.value.length)
const sorted = ((res.items || []).sort((a, b) => (b.view_count || 0) - (a.view_count || 0)))
hotJokes.value = sorted.slice(0, 5)
} catch (e) { } catch (e) {
console.warn('[APP] Failed to load hot jokes:', e) console.warn('[APP] Failed to load hot jokes:', e)
hotJokes.value = [] hotJokes.value = []
} }
try {
stats.value = await getStats()
console.log('[APP] stats loaded:', stats.value)
} catch (e) {
console.warn('[APP] Failed to load stats:', e)
}
}) })
</script> </script>
+2
View File
@@ -6,3 +6,5 @@ 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 dislikeJoke = id => request.post(`/jokes/${id}/dislike`)
export const searchJokes = params => request.get('/jokes/', { params }) export const searchJokes = params => request.get('/jokes/', { params })
export const getHotMonthly = () => request.get('/jokes/hot-monthly')
export const getStats = () => request.get('/jokes/stats')
+63 -3
View File
@@ -1,10 +1,10 @@
<template> <template>
<aside class="rightbar"> <aside class="rightbar">
<!-- 热门笑话 --> <!-- 当月热门笑话 -->
<div class="rightbar-section"> <div class="rightbar-section">
<div class="section-title"> <div class="section-title">
<span class="title-icon">&#x1F525;</span> <span class="title-icon">&#x1F525;</span>
热门笑话 当月热门笑话
</div> </div>
<div class="hot-list" v-if="hotJokes.length"> <div class="hot-list" v-if="hotJokes.length">
<div <div
@@ -23,6 +23,32 @@
<div v-else class="empty-hint">暂无数据</div> <div v-else class="empty-hint">暂无数据</div>
</div> </div>
<!-- 网站统计 -->
<div class="rightbar-section stats-section" v-if="stats">
<div class="section-title">
<span class="title-icon">&#x1F4CA;</span>
网站统计
</div>
<div class="stats-grid">
<div class="stat-item">
<span class="stat-num">{{ stats.total_jokes || 0 }}</span>
<span class="stat-label">笑话总数</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ stats.approved_jokes || 0 }}</span>
<span class="stat-label">已审核</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ stats.pending_jokes || 0 }}</span>
<span class="stat-label">待审核</span>
</div>
<div class="stat-item">
<span class="stat-num">{{ stats.total_views || 0 }}</span>
<span class="stat-label">总浏览</span>
</div>
</div>
</div>
<!-- 类型快捷 --> <!-- 类型快捷 -->
<div class="rightbar-section"> <div class="rightbar-section">
<div class="section-title"> <div class="section-title">
@@ -74,7 +100,8 @@
defineProps({ defineProps({
hotJokes: { type: Array, default: () => [] }, hotJokes: { type: Array, default: () => [] },
types: { type: Array, default: () => [] }, types: { type: Array, default: () => [] },
crowds: { type: Array, default: () => [] } crowds: { type: Array, default: () => [] },
stats: { type: Object, default: null }
}) })
</script> </script>
@@ -157,6 +184,32 @@ defineProps({
} }
.quick-tag.crowd:hover { background: #2196F3; color: white; } .quick-tag.crowd:hover { background: #2196F3; color: white; }
/* 统计区域 */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.stat-item {
text-align: center;
padding: 10px 6px;
background: var(--bg-page);
border-radius: 8px;
transition: background 0.3s;
}
.stat-num {
display: block;
font-size: 20px;
font-weight: 800;
color: var(--primary);
line-height: 1.2;
}
.stat-label {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* 每日一笑 */ /* 每日一笑 */
.daily-section { background: var(--tag-type-bg); } .daily-section { background: var(--tag-type-bg); }
.daily-text { .daily-text {
@@ -166,4 +219,11 @@ defineProps({
font-style: italic; font-style: italic;
transition: color 0.3s; transition: color 0.3s;
} }
/* 移动端隐藏右侧栏 */
@media (max-width: 1199px) {
.rightbar {
display: none;
}
}
</style> </style>
-6
View File
@@ -4,7 +4,6 @@
<div class="filter-bar" v-if="filterText"> <div class="filter-bar" v-if="filterText">
<span class="filter-text">{{ filterText }}</span> <span class="filter-text">{{ filterText }}</span>
<span class="filter-count"> {{ jokeStore.total }} </span> <span class="filter-count"> {{ jokeStore.total }} </span>
<el-button link type="primary" @click="clearFilter" v-if="jokeStore.total > 0">清除筛选</el-button>
</div> </div>
<!-- 笑话网格 --> <!-- 笑话网格 -->
@@ -84,11 +83,6 @@ const loadJokes = async (p = 1) => {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
const clearFilter = () => {
window.history.pushState({}, '', '/')
loadJokes(1)
}
onMounted(async () => { onMounted(async () => {
types.value = allTypes.value?.length ? allTypes.value : await getTypes() types.value = allTypes.value?.length ? allTypes.value : await getTypes()
crowds.value = allCrowds.value?.length ? allCrowds.value : await getCrowds() crowds.value = allCrowds.value?.length ? allCrowds.value : await getCrowds()