refactor(web): 前台代码全面改进

- 搜索逻辑改为后端关键词搜索(修复前端100条限制)
- 合并重复的 handleSelectType/handleDrawerSelectType
- 提取 getLevelText 到公共工具 utils/joke.js
- 删除废弃组件 Header.vue
- 删除重复 API searchJokes
- 修复 AppHeader 滚动事件监听未清理
- 统一 Rightbar URL 参数为 type_ids/crowd_ids
- 清除调试 console.log 代码
- 清理未使用导入 watchEffect
- onMounted 请求并行化(Promise.allSettled)
This commit is contained in:
bwstudio
2026-06-12 08:58:23 +08:00
parent 076812f1aa
commit cab4d6a9bd
11 changed files with 80 additions and 147 deletions
+8
View File
@@ -107,11 +107,19 @@ def list_jokes(
page_size: int = 20,
type_ids: str | None = Query(None, description="逗号分隔的类型 ID,如 1,3,5"),
crowd_ids: str | None = Query(None, description="逗号分隔的人群 ID,如 2,4"),
keyword: str | None = Query(None, description="关键词搜索(标题+内容)"),
db: Session = Depends(get_db),
):
"""获取笑话列表(仅返回已审核通过的笑话)"""
query = db.query(Joke).filter(Joke.status == "approved")
# 关键词搜索:标题和内容模糊匹配
if keyword:
kw = f"%{keyword}%"
query = query.filter(
(Joke.title.like(kw)) | (Joke.content.like(kw))
)
# 支持多选过滤:逗号分隔的 ID
# 使用自定义 JSON 匹配函数避免 LIKE %N% 的错误匹配
if type_ids:
+29 -35
View File
@@ -15,7 +15,7 @@
<AppSidebar
:types="allTypes"
:current-type="null"
@selectType="handleDrawerSelectType"
@selectType="(id) => handleSelectType(id, true)"
@random="handleRandom"
/>
</div>
@@ -119,7 +119,7 @@ const handleRandom = async () => {
} catch (e) { /* ignore */ }
}
const handleSelectType = (typeId) => {
const handleSelectType = (typeId, closeMenu = false) => {
// 单选:点同一个取消,点别的切换
if (filterTypeIds.value.length === 1 && filterTypeIds.value[0] === typeId) {
filterTypeIds.value = []
@@ -129,6 +129,7 @@ const handleSelectType = (typeId) => {
filterCrowdIds.value = []
const query = filterTypeIds.value.length ? { type_ids: filterTypeIds.value.join(',') } : {}
router.push({ path: '/', query })
if (closeMenu) menuOpen.value = false
}
const handleSelectCrowd = (crowdId) => {
@@ -141,21 +142,6 @@ const handleSelectCrowd = (crowdId) => {
filterTypeIds.value = []
const query = filterCrowdIds.value.length ? { crowd_ids: filterCrowdIds.value.join(',') } : {}
router.push({ path: '/', query })
// 移动端关闭菜单
menuOpen.value = false
}
// 抽屉菜单选择分类(单选)
const handleDrawerSelectType = (typeId) => {
if (filterTypeIds.value.length === 1 && filterTypeIds.value[0] === typeId) {
filterTypeIds.value = []
} else {
filterTypeIds.value = [typeId]
}
filterCrowdIds.value = []
const query = filterTypeIds.value.length ? { type_ids: filterTypeIds.value.join(',') } : {}
router.push({ path: '/', query })
// 移动端关闭菜单
menuOpen.value = false
}
@@ -173,30 +159,38 @@ watch(() => route.query, (q) => {
}, { immediate: true })
onMounted(async () => {
console.log('[APP] mounting, route:', route.path)
try {
allTypes.value = await getTypes()
allCrowds.value = await getCrowds()
console.log('[APP] categories loaded:', allTypes.value.length, allCrowds.value.length)
} catch (e) {
console.warn('[APP] Failed to load categories:', e)
// 并行加载:分类、热门、统计互不依赖
const results = await Promise.allSettled([
Promise.all([getTypes(), getCrowds()]),
getHotMonthly(),
getStats(),
])
// 分类数据
const [catResult, hotResult, statsResult] = results
if (catResult.status === 'fulfilled') {
const [types, crowds] = catResult.value
allTypes.value = types
allCrowds.value = crowds
} else {
console.warn('[APP] Failed to load categories:', catResult.reason)
}
try {
hotJokes.value = await getHotMonthly()
console.log('[APP] monthly hot jokes loaded:', hotJokes.value.length)
} catch (e) {
console.warn('[APP] Failed to load hot jokes:', e)
// 当月热门
if (hotResult.status === 'fulfilled') {
hotJokes.value = hotResult.value
} else {
console.warn('[APP] Failed to load hot jokes:', hotResult.reason)
hotJokes.value = []
}
try {
stats.value = await getStats()
console.log('[APP] stats loaded:', stats.value)
} catch (e) {
console.warn('[APP] Failed to load stats:', e)
// 统计数据
if (statsResult.status === 'fulfilled') {
stats.value = statsResult.value
} else {
console.warn('[APP] Failed to load stats:', statsResult.reason)
}
})
})
</script>
<style>
-1
View File
@@ -5,6 +5,5 @@ 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 })
export const getHotMonthly = () => request.get('/jokes/hot-monthly')
export const getStats = () => request.get('/jokes/stats')
+10 -7
View File
@@ -79,7 +79,7 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
@@ -99,12 +99,15 @@ const doSearch = () => {
searchKw.value = ''
}
// 监听滚动
if (typeof window !== 'undefined') {
window.addEventListener('scroll', () => {
isScrolled.value = window.scrollY > 10
}, { passive: true })
}
// 监听滚动(含清理)
const onScroll = () => { isScrolled.value = window.scrollY > 10 }
onMounted(() => {
window.addEventListener('scroll', onScroll, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
</script>
<style scoped>
+2 -2
View File
@@ -60,7 +60,7 @@
v-for="t in (types || []).slice(0, 6)"
:key="t.id"
class="quick-tag type"
@click="$router.push({ path: '/', query: { type_id: t.id } })"
@click="$router.push({ path: '/', query: { type_ids: t.id } })"
>{{ t.name }}</span>
</div>
</div>
@@ -76,7 +76,7 @@
v-for="c in (crowds || []).slice(0, 6)"
:key="c.id"
class="quick-tag crowd"
@click="$router.push({ path: '/', query: { crowd_id: c.id } })"
@click="$router.push({ path: '/', query: { crowd_ids: c.id } })"
>{{ c.name }}</span>
</div>
</div>
-71
View File
@@ -1,71 +0,0 @@
<template>
<header class="header">
<div class="header-content">
<router-link to="/" class="logo">笑话大全</router-link>
<nav class="nav">
<router-link to="/">首页</router-link>
<router-link to="/category">分类</router-link>
<router-link to="/search">搜索</router-link>
</nav>
<button class="random-btn" @click="handleRandom">随机笑话</button>
</div>
</header>
</template>
<script setup>
import { useRouter } from 'vue-router'
import { useJokeStore } from '@/stores/joke'
const router = useRouter()
const jokeStore = useJokeStore()
const handleRandom = async () => {
const res = await jokeStore.fetchRandom()
router.push(`/detail/${res.id}`)
}
</script>
<style scoped>
.header {
background: linear-gradient(135deg, #FF9500 0%, #FFB800 100%);
color: white;
padding: 0 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
}
.logo {
font-size: 24px;
font-weight: bold;
color: white;
text-decoration: none;
}
.nav {
display: flex;
gap: 30px;
}
.nav a {
color: white;
text-decoration: none;
opacity: 0.9;
transition: opacity 0.2s;
}
.nav a:hover, .nav a.router-link-active { opacity: 1; font-weight: 500; }
.random-btn {
background: white;
color: #FF9500;
border: none;
padding: 8px 20px;
border-radius: 20px;
cursor: pointer;
font-weight: 500;
transition: transform 0.2s;
}
.random-btn:hover { transform: scale(1.05); }
</style>
+10
View File
@@ -0,0 +1,10 @@
/** AI 质量等级文本映射 */
export function getLevelText(level) {
const map = {
'excellent': '⭐⭐⭐ 精品',
'good': '⭐⭐ 良好',
'ordinary': '⭐ 普通',
'poor': '⚠️ 待优化'
}
return map[level] || ''
}
+2 -11
View File
@@ -68,10 +68,11 @@
</template>
<script setup>
import { ref, watch, onMounted, watchEffect } from 'vue'
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getJoke, getJokes } from '@/api/joke'
import { useVote } from '@/composables/useVote'
import { getLevelText } from '@/utils/joke'
const route = useRoute()
const router = useRouter()
@@ -141,16 +142,6 @@ const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
function getLevelText(level) {
const map = {
'excellent': '⭐⭐⭐ 精品',
'good': '⭐⭐ 良好',
'ordinary': '⭐ 普通',
'poor': '⚠️ 待优化'
}
return map[level] || ''
}
onMounted(() => loadJoke())
watch(() => route.params.id, () => loadJoke())
+1 -10
View File
@@ -39,6 +39,7 @@
<script setup>
import { computed } from 'vue'
import { useVote } from '@/composables/useVote'
import { getLevelText } from '@/utils/joke'
const props = defineProps({
joke: { type: Object, required: true }
@@ -46,16 +47,6 @@ const props = defineProps({
const jokeId = computed(() => props.joke.id)
const { state: voteState, handleLike, handleDislike } = useVote(jokeId)
function getLevelText(level) {
const map = {
'excellent': '⭐⭐⭐ 精品',
'good': '⭐⭐ 良好',
'ordinary': '⭐ 普通',
'poor': '⚠️ 待优化'
}
return map[level] || ''
}
</script>
<style scoped>
-2
View File
@@ -86,9 +86,7 @@ const loadJokes = async (p = 1) => {
onMounted(async () => {
types.value = allTypes.value?.length ? allTypes.value : await getTypes()
crowds.value = allCrowds.value?.length ? allCrowds.value : await getCrowds()
console.log('[DEBUG] types:', types.value.length, 'crowds:', crowds.value.length)
await loadJokes()
console.log('[DEBUG] jokes:', jokeStore.jokes.length, 'total:', jokeStore.total)
})
watch(() => route.query, () => {
+18 -8
View File
@@ -22,7 +22,7 @@
<!-- 结果 -->
<div class="results-area" v-if="searched">
<div class="results-count" v-if="results.length > 0">
找到 <strong>{{ results.length }}</strong> 条相关笑话
找到 <strong>{{ total }}</strong> 条相关笑话
</div>
<div class="joke-grid" v-if="results.length">
@@ -52,6 +52,7 @@ import JokeCard from '../home/components/JokeCard.vue'
const route = useRoute()
const keyword = ref('')
const results = ref([])
const total = ref(0)
const searched = ref(false)
const hotWords = ['冷笑话', '段子', '谐音梗', '职场', '校园']
@@ -60,14 +61,12 @@ const handleSearch = async () => {
if (!keyword.value.trim()) return
searched.value = true
try {
const res = await getJokes({ page: 1, page_size: 100 })
const kw = keyword.value.toLowerCase()
results.value = (res.items || []).filter(j =>
(j.title && j.title.toLowerCase().includes(kw)) ||
(j.content && j.content.toLowerCase().includes(kw))
)
const res = await getJokes({ page: 1, page_size: 50, keyword: keyword.value.trim() })
results.value = res.items || []
total.value = res.total || 0
} catch (e) {
results.value = []
total.value = 0
}
}
@@ -140,10 +139,21 @@ watch(() => route.query.q, (q) => {
.joke-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
/* 移动端适配 */
@media (max-width: 767px) {
.search-box {
padding: 16px;
}
.joke-grid {
grid-template-columns: 1fr;
gap: 12px;
}
}
/* 空/默认 */
.empty-state, .search-hint {
text-align: center;