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:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
/** AI 质量等级文本映射 */
|
||||
export function getLevelText(level) {
|
||||
const map = {
|
||||
'excellent': '⭐⭐⭐ 精品',
|
||||
'good': '⭐⭐ 良好',
|
||||
'ordinary': '⭐ 普通',
|
||||
'poor': '⚠️ 待优化'
|
||||
}
|
||||
return map[level] || ''
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user