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,
|
page_size: int = 20,
|
||||||
type_ids: str | None = Query(None, description="逗号分隔的类型 ID,如 1,3,5"),
|
type_ids: str | None = Query(None, description="逗号分隔的类型 ID,如 1,3,5"),
|
||||||
crowd_ids: str | None = Query(None, description="逗号分隔的人群 ID,如 2,4"),
|
crowd_ids: str | None = Query(None, description="逗号分隔的人群 ID,如 2,4"),
|
||||||
|
keyword: str | None = Query(None, description="关键词搜索(标题+内容)"),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""获取笑话列表(仅返回已审核通过的笑话)"""
|
"""获取笑话列表(仅返回已审核通过的笑话)"""
|
||||||
query = db.query(Joke).filter(Joke.status == "approved")
|
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
|
# 支持多选过滤:逗号分隔的 ID
|
||||||
# 使用自定义 JSON 匹配函数避免 LIKE %N% 的错误匹配
|
# 使用自定义 JSON 匹配函数避免 LIKE %N% 的错误匹配
|
||||||
if type_ids:
|
if type_ids:
|
||||||
|
|||||||
+28
-34
@@ -15,7 +15,7 @@
|
|||||||
<AppSidebar
|
<AppSidebar
|
||||||
:types="allTypes"
|
:types="allTypes"
|
||||||
:current-type="null"
|
:current-type="null"
|
||||||
@selectType="handleDrawerSelectType"
|
@selectType="(id) => handleSelectType(id, true)"
|
||||||
@random="handleRandom"
|
@random="handleRandom"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,7 +119,7 @@ const handleRandom = async () => {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectType = (typeId) => {
|
const handleSelectType = (typeId, closeMenu = false) => {
|
||||||
// 单选:点同一个取消,点别的切换
|
// 单选:点同一个取消,点别的切换
|
||||||
if (filterTypeIds.value.length === 1 && filterTypeIds.value[0] === typeId) {
|
if (filterTypeIds.value.length === 1 && filterTypeIds.value[0] === typeId) {
|
||||||
filterTypeIds.value = []
|
filterTypeIds.value = []
|
||||||
@@ -129,6 +129,7 @@ const handleSelectType = (typeId) => {
|
|||||||
filterCrowdIds.value = []
|
filterCrowdIds.value = []
|
||||||
const query = filterTypeIds.value.length ? { type_ids: filterTypeIds.value.join(',') } : {}
|
const query = filterTypeIds.value.length ? { type_ids: filterTypeIds.value.join(',') } : {}
|
||||||
router.push({ path: '/', query })
|
router.push({ path: '/', query })
|
||||||
|
if (closeMenu) menuOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectCrowd = (crowdId) => {
|
const handleSelectCrowd = (crowdId) => {
|
||||||
@@ -141,21 +142,6 @@ const handleSelectCrowd = (crowdId) => {
|
|||||||
filterTypeIds.value = []
|
filterTypeIds.value = []
|
||||||
const query = filterCrowdIds.value.length ? { crowd_ids: filterCrowdIds.value.join(',') } : {}
|
const query = filterCrowdIds.value.length ? { crowd_ids: filterCrowdIds.value.join(',') } : {}
|
||||||
router.push({ path: '/', query })
|
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
|
menuOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,28 +159,36 @@ watch(() => route.query, (q) => {
|
|||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
console.log('[APP] mounting, route:', route.path)
|
// 并行加载:分类、热门、统计互不依赖
|
||||||
try {
|
const results = await Promise.allSettled([
|
||||||
allTypes.value = await getTypes()
|
Promise.all([getTypes(), getCrowds()]),
|
||||||
allCrowds.value = await getCrowds()
|
getHotMonthly(),
|
||||||
console.log('[APP] categories loaded:', allTypes.value.length, allCrowds.value.length)
|
getStats(),
|
||||||
} catch (e) {
|
])
|
||||||
console.warn('[APP] Failed to load categories:', e)
|
|
||||||
|
// 分类数据
|
||||||
|
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()
|
if (hotResult.status === 'fulfilled') {
|
||||||
console.log('[APP] monthly hot jokes loaded:', hotJokes.value.length)
|
hotJokes.value = hotResult.value
|
||||||
} catch (e) {
|
} else {
|
||||||
console.warn('[APP] Failed to load hot jokes:', e)
|
console.warn('[APP] Failed to load hot jokes:', hotResult.reason)
|
||||||
hotJokes.value = []
|
hotJokes.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// 统计数据
|
||||||
stats.value = await getStats()
|
if (statsResult.status === 'fulfilled') {
|
||||||
console.log('[APP] stats loaded:', stats.value)
|
stats.value = statsResult.value
|
||||||
} catch (e) {
|
} else {
|
||||||
console.warn('[APP] Failed to load stats:', e)
|
console.warn('[APP] Failed to load stats:', statsResult.reason)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,5 @@ 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 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 getHotMonthly = () => request.get('/jokes/hot-monthly')
|
||||||
export const getStats = () => request.get('/jokes/stats')
|
export const getStats = () => request.get('/jokes/stats')
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -99,12 +99,15 @@ const doSearch = () => {
|
|||||||
searchKw.value = ''
|
searchKw.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听滚动
|
// 监听滚动(含清理)
|
||||||
if (typeof window !== 'undefined') {
|
const onScroll = () => { isScrolled.value = window.scrollY > 10 }
|
||||||
window.addEventListener('scroll', () => {
|
|
||||||
isScrolled.value = window.scrollY > 10
|
onMounted(() => {
|
||||||
}, { passive: true })
|
window.addEventListener('scroll', onScroll, { passive: true })
|
||||||
}
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('scroll', onScroll)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
v-for="t in (types || []).slice(0, 6)"
|
v-for="t in (types || []).slice(0, 6)"
|
||||||
:key="t.id"
|
:key="t.id"
|
||||||
class="quick-tag type"
|
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>
|
>{{ t.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
v-for="c in (crowds || []).slice(0, 6)"
|
v-for="c in (crowds || []).slice(0, 6)"
|
||||||
:key="c.id"
|
:key="c.id"
|
||||||
class="quick-tag crowd"
|
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>
|
>{{ c.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, watchEffect } from 'vue'
|
import { ref, watch, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { getJoke, getJokes } from '@/api/joke'
|
import { getJoke, getJokes } from '@/api/joke'
|
||||||
import { useVote } from '@/composables/useVote'
|
import { useVote } from '@/composables/useVote'
|
||||||
|
import { getLevelText } from '@/utils/joke'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -141,16 +142,6 @@ const formatDate = (dateStr) => {
|
|||||||
return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
|
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())
|
onMounted(() => loadJoke())
|
||||||
|
|
||||||
watch(() => route.params.id, () => loadJoke())
|
watch(() => route.params.id, () => loadJoke())
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useVote } from '@/composables/useVote'
|
import { useVote } from '@/composables/useVote'
|
||||||
|
import { getLevelText } from '@/utils/joke'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
joke: { type: Object, required: true }
|
joke: { type: Object, required: true }
|
||||||
@@ -46,16 +47,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const jokeId = computed(() => props.joke.id)
|
const jokeId = computed(() => props.joke.id)
|
||||||
const { state: voteState, handleLike, handleDislike } = useVote(jokeId)
|
const { state: voteState, handleLike, handleDislike } = useVote(jokeId)
|
||||||
|
|
||||||
function getLevelText(level) {
|
|
||||||
const map = {
|
|
||||||
'excellent': '⭐⭐⭐ 精品',
|
|
||||||
'good': '⭐⭐ 良好',
|
|
||||||
'ordinary': '⭐ 普通',
|
|
||||||
'poor': '⚠️ 待优化'
|
|
||||||
}
|
|
||||||
return map[level] || ''
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -86,9 +86,7 @@ const loadJokes = async (p = 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()
|
||||||
console.log('[DEBUG] types:', types.value.length, 'crowds:', crowds.value.length)
|
|
||||||
await loadJokes()
|
await loadJokes()
|
||||||
console.log('[DEBUG] jokes:', jokeStore.jokes.length, 'total:', jokeStore.total)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => route.query, () => {
|
watch(() => route.query, () => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<!-- 结果 -->
|
<!-- 结果 -->
|
||||||
<div class="results-area" v-if="searched">
|
<div class="results-area" v-if="searched">
|
||||||
<div class="results-count" v-if="results.length > 0">
|
<div class="results-count" v-if="results.length > 0">
|
||||||
找到 <strong>{{ results.length }}</strong> 条相关笑话
|
找到 <strong>{{ total }}</strong> 条相关笑话
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="joke-grid" v-if="results.length">
|
<div class="joke-grid" v-if="results.length">
|
||||||
@@ -52,6 +52,7 @@ import JokeCard from '../home/components/JokeCard.vue'
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const keyword = ref('')
|
const keyword = ref('')
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
|
const total = ref(0)
|
||||||
const searched = ref(false)
|
const searched = ref(false)
|
||||||
|
|
||||||
const hotWords = ['冷笑话', '段子', '谐音梗', '职场', '校园']
|
const hotWords = ['冷笑话', '段子', '谐音梗', '职场', '校园']
|
||||||
@@ -60,14 +61,12 @@ const handleSearch = async () => {
|
|||||||
if (!keyword.value.trim()) return
|
if (!keyword.value.trim()) return
|
||||||
searched.value = true
|
searched.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getJokes({ page: 1, page_size: 100 })
|
const res = await getJokes({ page: 1, page_size: 50, keyword: keyword.value.trim() })
|
||||||
const kw = keyword.value.toLowerCase()
|
results.value = res.items || []
|
||||||
results.value = (res.items || []).filter(j =>
|
total.value = res.total || 0
|
||||||
(j.title && j.title.toLowerCase().includes(kw)) ||
|
|
||||||
(j.content && j.content.toLowerCase().includes(kw))
|
|
||||||
)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
results.value = []
|
results.value = []
|
||||||
|
total.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,10 +139,21 @@ watch(() => route.query.q, (q) => {
|
|||||||
|
|
||||||
.joke-grid {
|
.joke-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.search-box {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.joke-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* 空/默认 */
|
/* 空/默认 */
|
||||||
.empty-state, .search-hint {
|
.empty-state, .search-hint {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user