fix: resolve 10 code review issues

High priority:
- Fix concurrent race condition for view_count/like_count (atomic update)
- Add route request ID tracking to prevent race conditions
- Filter get_joke by status=approved (no pending content leak)
- Add error feedback for like button

Performance:
- Optimize random joke query (avoid full table sort)
- Limit page_size max to 100 (DoS prevention)

Medium:
- Add localStorage quota error handling
- Handle empty AI response gracefully
- Fix generate content title extraction

Low:
- Add rejected_jokes to stats API
- Update dashboard to show rejected count
This commit is contained in:
bwstudio
2026-06-02 20:35:08 +08:00
parent 0b43973236
commit ceed63fcb0
144 changed files with 191660 additions and 270 deletions
+263 -12
View File
@@ -1,21 +1,272 @@
<template>
<div id="app">
<AppHeader />
<main class="main-content">
<router-view />
</main>
<AppFooter />
<div id="app" class="app">
<!-- 顶部主导航 -->
<AppHeader
:theme="themeStore.theme"
@toggleTheme="themeStore.toggleTheme()"
@random="handleRandom"
/>
<!-- 内容区域: 左副导航 + 主内容 + 右信息栏 -->
<div class="content-wrapper">
<!-- 左侧副导航内容区内 -->
<AppSubNav
:types="allTypes"
:crowds="allCrowds"
:current-type-ids="filterTypeIds"
:current-crowd-ids="filterCrowdIds"
:show="showSubNav"
@select-type="handleSelectType"
@select-crowd="handleSelectCrowd"
@random="handleRandom"
/>
<!-- 主内容路由区 -->
<main class="main-content">
<router-view />
</main>
<!-- 右侧信息栏仅首页 -->
<AppRightbar
v-if="showRightBar"
:hot-jokes="hotJokes"
:types="allTypes"
:crowds="allCrowds"
/>
</div>
<!-- 底部 -->
<Footer />
</div>
</template>
<script setup>
import AppHeader from '@/components/Header.vue'
import AppFooter from '@/components/Footer.vue'
import { ref, provide, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppHeader from '@/components/AppHeader.vue'
import AppSubNav from '@/components/AppSubNav.vue'
import AppRightbar from '@/components/AppRightbar.vue'
import Footer from '@/components/Footer.vue'
import { getTypes, getCrowds } from '@/api/category'
import { getJokes } from '@/api/joke'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const route = useRoute()
const router = useRouter()
// 初始化主题
themeStore.initTheme()
// 共享分类数据
const allTypes = ref([])
const allCrowds = ref([])
// 当前筛选状态(支持多选)
const filterTypeIds = ref([])
const filterCrowdIds = ref([])
// 热门笑话(右侧栏)
const hotJokes = ref([])
// 右侧栏:仅首页显示
const showRightBar = computed(() => route.path === '/')
// 副导航:首页/分类/搜索显示,详情页显示推荐列表
const showSubNav = computed(() => !route.path.startsWith('/detail'))
// 函数必须先定义,才能在 provide 中使用
const handleRandom = async () => {
try {
const res = await getJokes({ page: 1, page_size: 50 })
if (res.items && res.items.length > 0) {
const rnd = res.items[Math.floor(Math.random() * res.items.length)]
router.push(`/detail/${rnd.id}`)
}
} catch (e) { /* ignore */ }
}
const handleSelectType = (typeId) => {
const ids = filterTypeIds.value
const idx = ids.indexOf(typeId)
if (idx >= 0) ids.splice(idx, 1)
else ids.push(typeId)
filterCrowdIds.value = []
const query = ids.length ? { type_ids: ids.join(',') } : {}
router.push({ path: '/', query })
}
const handleSelectCrowd = (crowdId) => {
const ids = filterCrowdIds.value
const idx = ids.indexOf(crowdId)
if (idx >= 0) ids.splice(idx, 1)
else ids.push(crowdId)
filterTypeIds.value = []
const query = ids.length ? { crowd_ids: ids.join(',') } : {}
router.push({ path: '/', query })
}
// provide 必须在函数定义之后
provide('filterTypeIds', filterTypeIds)
provide('filterCrowdIds', filterCrowdIds)
provide('openRandom', handleRandom)
provide('allTypes', allTypes)
provide('allCrowds', allCrowds)
// 同步 URL query 到 filter state
watch(() => route.query, (q) => {
filterTypeIds.value = q.type_ids ? String(q.type_ids).split(',').map(Number) : []
filterCrowdIds.value = q.crowd_ids ? String(q.crowd_ids).split(',').map(Number) : []
}, { 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)
}
try {
const res = await getJokes({ page: 1, page_size: 30 })
console.log('[APP] jokes loaded:', res.items?.length, 'total:', res.total)
const sorted = ((res.items || []).sort((a, b) => (b.view_count || 0) - (a.view_count || 0)))
hotJokes.value = sorted.slice(0, 5)
} catch (e) {
console.warn('[APP] Failed to load hot jokes:', e)
hotJokes.value = []
}
})
</script>
<style>
/* ============================================================
双主题 CSS 变量架构
============================================================ */
/* 浅色主题(默认) */
:root,
:root[data-theme="light"] {
--primary: #FF6B00;
--primary-light: #FF8C30;
--primary-dark: #E55A00;
--bg-page: #f0f2f5;
--bg-card: #ffffff;
--bg-subnav: #ffffff;
--bg-header: #ffffff;
--bg-sidebar: #1a1a2e;
--text-primary: #1f1f1f;
--text-secondary: #555555;
--text-muted: #999999;
--text-on-dark: #ffffff;
--border: #eeeeee;
--border-dark: #2a2a3e;
--shadow: 0 2px 12px rgba(0,0,0,0.06);
--shadow-hover: 0 8px 28px rgba(0,0,0,0.13);
--radius: 10px;
--radius-lg: 14px;
--tag-type-bg: #FFF0E6;
--tag-crowd-bg: #E3F2FD;
}
/* 深色主题 */
:root[data-theme="dark"] {
--primary: #FF8C30;
--primary-light: #FFA050;
--primary-dark: #FF6B00;
--bg-page: #0f0f14;
--bg-card: #1a1a24;
--bg-subnav: #1a1a24;
--bg-header: #141420;
--bg-sidebar: #0a0a14;
--text-primary: #e8e8ec;
--text-secondary: #a0a0a8;
--text-muted: #606068;
--text-on-dark: #ffffff;
--border: #2a2a3a;
--border-dark: #2a2a3e;
--shadow: 0 2px 12px rgba(0,0,0,0.25);
--shadow-hover: 0 8px 28px rgba(0,0,0,0.4);
--tag-type-bg: #3a2010;
--tag-crowd-bg: #0a2040;
}
/* ============================================================
全局基础样式
============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #fafafa; }
#app { min-height: 100vh; display: flex; flex-direction: column; }
.main-content { flex: 1; max-width: 1200px; margin: 0 auto; padding: 20px; width: 100%; }
</style>
body {
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
background: var(--bg-page);
color: var(--text-primary);
line-height: 1.6;
transition: background 0.3s, color 0.3s;
}
#app { min-height: 100vh; }
/* ============================================================
整体布局
============================================================ */
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* 内容区域:副导航 + 主内容 + 右侧栏 */
.content-wrapper {
display: flex;
flex: 1;
max-width: 1440px;
margin: 0 auto;
width: 100%;
padding-top: 64px;
min-height: 100vh;
}
.main-content {
flex: 1;
min-width: 0;
padding: 24px 20px 40px;
}
/* ============================================================
辅助类
============================================================ */
.tag {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
transition: background 0.3s, color 0.3s;
}
.tag-type { background: var(--tag-type-bg, #FFF0E6); color: var(--primary); }
.tag-crowd { background: var(--tag-crowd-bg, #E8F4FD); color: #2196F3; }
/* ============================================================
Element Plus 覆盖
============================================================ */
.el-input__wrapper { border-radius: 8px !important; }
.el-button { border-radius: 8px !important; }
.el-pagination.is-background .el-pager li.is-active { background: var(--primary) !important; }
/* ============================================================
深色主题 Element Plus 额外覆盖
============================================================ */
[data-theme="dark"] .el-input__wrapper {
background: #252535 !important;
box-shadow: none !important;
border: 1px solid var(--border) !important;
}
[data-theme="dark"] .el-input__inner {
color: var(--text-primary) !important;
}
[data-theme="dark"] .el-pagination {
--el-pagination-bg-color: var(--bg-card);
--el-pagination-button-bg-color: var(--bg-card);
}
</style>
+2 -2
View File
@@ -1,4 +1,4 @@
import request from './request'
export const getTypes = () => request.get('/types')
export const getCrowds = () => request.get('/crowds')
export const getTypes = () => request.get('/categories/types')
export const getCrowds = () => request.get('/categories/crowds')
+3
View File
@@ -0,0 +1,3 @@
import request from '@/api/request'
export const submitFeedback = data => request.post('/feedback', data)
+3 -2
View File
@@ -1,6 +1,7 @@
import request from './request'
export const getJokes = params => request.get('/jokes', { params })
export const getJokes = params => request.get('/jokes/', { params })
export const getJoke = id => request.get(`/jokes/${id}`)
export const getRandomJoke = () => request.get('/jokes/random')
export const searchJokes = params => request.get('/search', { params })
export const likeJoke = id => request.post(`/jokes/${id}/like`)
export const searchJokes = params => request.get('/jokes/', { params })
+3
View File
@@ -0,0 +1,3 @@
import request from '@/api/request'
export const getLinks = () => request.get('/links')
+5 -1
View File
@@ -1,6 +1,10 @@
import axios from 'axios'
export default axios.create({
const request = axios.create({
baseURL: '/api',
timeout: 10000
})
request.interceptors.response.use(res => res.data)
export default request
+169
View File
@@ -0,0 +1,169 @@
<template>
<aside class="rightbar">
<!-- 热门笑话 -->
<div class="rightbar-section">
<div class="section-title">
<span class="title-icon">&#x1F525;</span>
热门笑话
</div>
<div class="hot-list" v-if="hotJokes.length">
<div
v-for="(joke, idx) in hotJokes"
:key="joke.id"
class="hot-item"
@click="$router.push(`/detail/${joke.id}`)"
>
<span class="hot-rank" :class="{ top: idx < 3 }">{{ idx + 1 }}</span>
<div class="hot-content">
<div class="hot-title">{{ joke.title }}</div>
<div class="hot-meta">&#x1F441; {{ joke.view_count || 0 }}</div>
</div>
</div>
</div>
<div v-else class="empty-hint">暂无数据</div>
</div>
<!-- 类型快捷 -->
<div class="rightbar-section">
<div class="section-title">
<span class="title-icon">&#x1F4CB;</span>
类型分类
</div>
<div class="quick-tags">
<span
v-for="t in (types || []).slice(0, 6)"
:key="t.id"
class="quick-tag type"
@click="$router.push({ path: '/', query: { type_id: t.id } })"
>{{ t.name }}</span>
</div>
</div>
<!-- 人群快捷 -->
<div class="rightbar-section">
<div class="section-title">
<span class="title-icon">&#x1F465;</span>
人群分类
</div>
<div class="quick-tags">
<span
v-for="c in (crowds || []).slice(0, 6)"
:key="c.id"
class="quick-tag crowd"
@click="$router.push({ path: '/', query: { crowd_id: c.id } })"
>{{ c.name }}</span>
</div>
</div>
<!-- 每日一笑 -->
<div class="rightbar-section daily-section">
<div class="section-title">
<span class="title-icon">&#x1F60A;</span>
每日一笑
</div>
<p class="daily-text">
生活太累笑一笑吧<br />
每天进步一点点<br />
快乐就多一点点
</p>
</div>
</aside>
</template>
<script setup>
defineProps({
hotJokes: { type: Array, default: () => [] },
types: { type: Array, default: () => [] },
crowds: { type: Array, default: () => [] }
})
</script>
<style scoped>
.rightbar {
width: 260px;
min-width: 260px;
padding: 24px 14px;
display: flex;
flex-direction: column;
gap: 18px;
flex-shrink: 0;
}
.rightbar-section {
background: var(--bg-card);
border-radius: var(--radius);
padding: 16px;
box-shadow: var(--shadow);
transition: background 0.3s, box-shadow 0.3s;
}
.section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
margin-bottom: 12px;
transition: color 0.3s;
}
.title-icon { font-size: 15px; }
/* 热门列表 */
.hot-list { display: flex; flex-direction: column; gap: 12px; }
.hot-item {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
transition: opacity 0.2s;
}
.hot-item:hover { opacity: 0.7; }
.hot-rank {
min-width: 17px;
height: 17px;
line-height: 17px;
text-align: center;
background: var(--bg-page);
border-radius: 4px;
font-size: 10px;
color: var(--text-muted);
font-weight: 700;
flex-shrink: 0;
transition: background 0.3s;
}
.hot-rank.top { background: var(--tag-type-bg); color: var(--primary); }
.hot-title { font-size: 13px; color: var(--text-primary); line-height: 1.4; }
.hot-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; transition: color 0.3s; }
.empty-hint { font-size: 13px; color: var(--text-muted); text-align: center; padding: 12px 0; }
/* 快捷标签 */
.quick-tags { display: flex; flex-wrap: wrap; gap: 7px; }
.quick-tag {
padding: 5px 12px;
border-radius: 20px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
}
.quick-tag.type {
background: var(--tag-type-bg);
color: var(--primary);
}
.quick-tag.type:hover { background: var(--primary); color: white; }
.quick-tag.crowd {
background: var(--tag-crowd-bg);
color: #2196F3;
}
.quick-tag.crowd:hover { background: #2196F3; color: white; }
/* 每日一笑 */
.daily-section { background: var(--tag-type-bg); }
.daily-text {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.8;
font-style: italic;
transition: color 0.3s;
}
</style>
+171
View File
@@ -0,0 +1,171 @@
<template>
<aside class="sidebar">
<!-- Logo 区域 -->
<div class="sidebar-logo" @click="$router.push('/')">
<span class="logo-icon">&#x1F60A;</span>
<span class="logo-text">笑话大全</span>
</div>
<!-- 主导航 -->
<nav class="sidebar-nav">
<div class="nav-label">导航</div>
<router-link to="/" class="nav-item" :class="{ active: $route.path === '/' }">
<span class="nav-icon">&#x1F3E0;</span>
<span>首页</span>
</router-link>
<router-link to="/category" class="nav-item" :class="{ active: $route.path === '/category' }">
<span class="nav-icon">&#x1F4CB;</span>
<span>分类</span>
</router-link>
<router-link to="/search" class="nav-item" :class="{ active: $route.path === '/search' }">
<span class="nav-icon">&#x1F50D;</span>
<span>搜索</span>
</router-link>
</nav>
<!-- 分类列表 -->
<div class="sidebar-categories">
<div class="nav-label">分类</div>
<div
v-for="type in types"
:key="type.id"
class="cat-item"
:class="{ active: currentType === type.id }"
@click="$emit('selectType', type.id)"
>
{{ type.name }}
</div>
</div>
<!-- 底部随机按钮 -->
<div class="sidebar-footer">
<button class="random-btn" @click="$emit('random')">
<span>&#x1F3B2;</span> 随机看看
</button>
</div>
</aside>
</template>
<script setup>
defineProps({
types: { type: Array, default: () => [] },
currentType: { type: Number, default: null }
})
defineEmits(['selectType', 'random'])
</script>
<style scoped>
.sidebar {
width: var(--sidebar-width);
min-width: var(--sidebar-width);
min-height: 100vh;
background: linear-gradient(180deg, #1a1a2e 0%, #16213e 100%);
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
flex-shrink: 0;
}
.sidebar::-webkit-scrollbar { width: 0; }
/* Logo */
.sidebar-logo {
padding: 28px 20px 20px;
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.logo-icon { font-size: 28px; }
.logo-text {
font-size: 20px;
font-weight: bold;
color: white;
letter-spacing: 2px;
}
/* 导航 */
.sidebar-nav { padding: 16px 12px 8px; }
.nav-label {
color: rgba(255,255,255,0.35);
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1.5px;
padding: 0 8px;
margin-bottom: 8px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 10px;
color: rgba(255,255,255,0.7);
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
margin-bottom: 2px;
}
.nav-item:hover {
background: rgba(255,255,255,0.08);
color: white;
}
.nav-item.active {
background: rgba(255,107,0,0.25);
color: #FF8C30;
font-weight: 600;
}
.nav-icon { font-size: 16px; }
/* 分类 */
.sidebar-categories {
padding: 8px 12px;
flex: 1;
}
.cat-item {
padding: 8px 12px;
border-radius: 8px;
color: rgba(255,255,255,0.6);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 2px;
}
.cat-item:hover {
background: rgba(255,255,255,0.06);
color: white;
}
.cat-item.active {
background: rgba(255,107,0,0.2);
color: var(--primary-light, #FF8C30);
}
/* 底部随机 */
.sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(255,255,255,0.06);
}
.random-btn {
width: 100%;
padding: 10px 16px;
background: var(--primary, #FF6B00);
color: white;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
}
.random-btn:hover { background: var(--primary-light, #FF8C30); transform: translateY(-1px); }
</style>
+158
View File
@@ -0,0 +1,158 @@
<template>
<aside class="sub-nav" v-if="show">
<!-- Logo mini -->
<div class="sub-nav-brand" @click="$router.push('/')">
<span class="brand-icon">&#x1F60A;</span>
<span class="brand-text">笑话大全</span>
</div>
<!-- 分类筛选 -->
<div class="filter-section">
<div class="section-label">
<span>&#x1F4CB;</span> 类型
</div>
<div
v-for="type in types"
:key="type.id"
class="filter-item"
:class="{ active: currentTypeIds.includes(type.id) }"
@click="$emit('selectType', type.id)"
>
{{ type.name }}
</div>
</div>
<div class="section-divider"></div>
<div class="filter-section">
<div class="section-label">
<span>&#x1F465;</span> 人群
</div>
<div
v-for="crowd in crowds"
:key="crowd.id"
class="filter-item"
:class="{ active: currentCrowdIds.includes(crowd.id) }"
@click="$emit('selectCrowd', crowd.id)"
>
{{ crowd.name }}
</div>
</div>
<!-- 底部随机 -->
<div class="sub-nav-footer">
<button class="random-btn" @click="$emit('random')">
<span>&#x1F3B2;</span> 随机看看
</button>
</div>
</aside>
</template>
<script setup>
defineProps({
types: { type: Array, default: () => [] },
crowds: { type: Array, default: () => [] },
currentTypeIds: { type: Array, default: () => [] },
currentCrowdIds: { type: Array, default: () => [] },
show: { type: Boolean, default: true }
})
defineEmits(['selectType', 'selectCrowd', 'random'])
</script>
<style scoped>
.sub-nav {
width: 220px;
min-width: 220px;
height: 100%;
background: var(--bg-subnav);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 16px 12px;
overflow-y: auto;
transition: background 0.3s, border-color 0.3s;
flex-shrink: 0;
}
.sub-nav::-webkit-scrollbar { width: 0; }
/* Brand */
.sub-nav-brand {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
margin-bottom: 8px;
cursor: pointer;
}
.brand-icon { font-size: 22px; }
.brand-text {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: 1px;
}
/* Filter sections */
.filter-section { padding: 4px 0; }
.section-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1.2px;
padding: 6px 10px 8px;
}
.section-divider { height: 1px; background: var(--border); margin: 8px 0; }
.filter-item {
padding: 9px 12px;
border-radius: 8px;
font-size: 14px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
}
.filter-item:hover {
background: var(--primary);
color: white;
transform: translateX(3px);
}
.filter-item.active {
background: var(--primary);
color: white;
font-weight: 600;
}
/* Footer */
.sub-nav-footer {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.random-btn {
width: 100%;
padding: 10px;
background: var(--primary);
color: white;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: all 0.2s;
}
.random-btn:hover {
background: var(--primary-dark);
transform: translateY(-1px);
}
</style>
+46 -7
View File
@@ -1,15 +1,54 @@
<template>
<footer class="footer">
<p>笑话大全 - 让快乐传递</p>
<footer class="app-footer">
<div class="footer-inner">
<nav class="footer-nav">
<router-link to="/" class="footer-link">首页</router-link>
<router-link to="/links" class="footer-link">友情链接</router-link>
<router-link to="/feedback" class="footer-link">意见建议</router-link>
<router-link to="/contact" class="footer-link">联系我们</router-link>
</nav>
<p class="footer-copyright">笑话大全 &mdash; 让快乐传递每一刻</p>
</div>
</footer>
</template>
<style scoped>
.footer {
background: #333;
color: white;
.app-footer {
background: var(--bg-card);
border-top: 1px solid var(--border);
padding: 24px 20px;
text-align: center;
padding: 20px;
margin-top: auto;
transition: background 0.3s, border-color 0.3s;
}
</style>
.footer-inner {
max-width: 1440px;
margin: 0 auto;
}
.footer-nav {
display: flex;
justify-content: center;
gap: 24px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.footer-link {
color: var(--text-secondary);
text-decoration: none;
font-size: 14px;
transition: color 0.2s;
}
.footer-link:hover {
color: var(--primary);
}
.footer-copyright {
color: var(--text-muted);
font-size: 13px;
margin: 0;
}
</style>
+4 -1
View File
@@ -11,7 +11,10 @@ export const useJokeStore = defineStore('joke', {
async fetchJokes(params) {
this.loading = true
try {
const res = await getJokes(params)
const cleanParams = Object.fromEntries(
Object.entries(params).filter(([_, v]) => v != null && v !== '')
)
const res = await getJokes(cleanParams)
this.jokes = res.items
this.total = res.total
} finally {
+17
View File
@@ -0,0 +1,17 @@
import { defineStore } from 'pinia'
export const useThemeStore = defineStore('theme', {
state: () => ({
theme: localStorage.getItem('joke-theme') || 'light'
}),
actions: {
toggleTheme() {
this.theme = this.theme === 'light' ? 'dark' : 'light'
localStorage.setItem('joke-theme', this.theme)
document.documentElement.setAttribute('data-theme', this.theme)
},
initTheme() {
document.documentElement.setAttribute('data-theme', this.theme)
}
}
})
+67 -41
View File
@@ -1,72 +1,98 @@
<template>
<div class="category-page">
<h2>分类浏览</h2>
<!-- 分类标签展示页 -->
<div class="category-header">
<div class="cat-title">笑话分类</div>
</div>
<!-- 按类型 -->
<div class="category-section">
<h3>按类型</h3>
<div class="category-list">
<div
<div class="section-title">&#x1F4CB; 按类型</div>
<div class="category-pills">
<span
v-for="type in types"
:key="type.id"
class="category-card"
@click="selectFilter('type_id', type.id)"
>
<span class="cat-name">{{ type.name }}</span>
</div>
class="pill type-pill"
@click="router.push({ path: '/', query: { type_ids: String(type.id) } })"
>{{ type.name }}</span>
</div>
</div>
<!-- 按人群 -->
<div class="category-section">
<h3>按人群</h3>
<div class="category-list">
<div
v-for="crowd in crowds"
:key="crowd.id"
class="category-card"
@click="selectFilter('crowd_id', crowd.id)"
>
<span class="cat-name">{{ crowd.name }}</span>
</div>
<div class="section-title">&#x1F465; 按人群</div>
<div class="category-pills">
<span
v-for="c in crowds"
:key="c.id"
class="pill crowd-pill"
@click="router.push({ path: '/', query: { crowd_ids: String(c.id) } })"
>{{ c.name }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, inject } from 'vue'
import { useRouter } from 'vue-router'
import { getTypes, getCrowds } from '@/api/category'
const router = useRouter()
const types = ref([])
const crowds = ref([])
const selectFilter = (key, value) => {
router.push({ path: '/', query: { [key]: value } })
window.location.reload()
}
const allTypes = inject('allTypes')
const allCrowds = inject('allCrowds')
onMounted(async () => {
types.value = await getTypes()
crowds.value = await getCrowds()
types.value = allTypes.value?.length ? allTypes.value : await getTypes()
crowds.value = allCrowds.value?.length ? allCrowds.value : await getCrowds()
})
</script>
<style scoped>
.category-page h2 { margin-bottom: 20px; }
.category-section { margin-bottom: 30px; }
.category-section h3 { margin-bottom: 15px; color: #666; }
.category-list { display: flex; flex-wrap: wrap; gap: 12px; }
.category-card {
padding: 12px 24px;
background: white;
border-radius: 25px;
.category-page { display: flex; flex-direction: column; gap: 24px; }
.category-header { margin-bottom: 4px; }
.cat-title {
font-size: 22px;
font-weight: 800;
color: var(--text-primary);
}
.category-section {
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px 24px;
box-shadow: var(--shadow);
transition: background 0.3s;
}
.section-title {
font-size: 14px;
font-weight: 700;
color: var(--text-secondary);
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.category-pills { display: flex; flex-wrap: wrap; gap: 10px; }
.pill {
padding: 7px 18px;
border-radius: 20px;
font-size: 14px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
transition: all 0.2s;
}
.category-card:hover {
background: #FF9500;
color: white;
transform: scale(1.05);
.type-pill {
background: #FFF0E6;
color: var(--primary);
}
.cat-name { font-size: 15px; }
.type-pill:hover { background: var(--primary); color: white; }
.crowd-pill {
background: #E3F2FD;
color: #2196F3;
}
.crowd-pill:hover { background: #2196F3; color: white; }
</style>
+103
View File
@@ -0,0 +1,103 @@
<template>
<div class="contact-page">
<h1 class="page-title">联系我们</h1>
<p class="page-subtitle">期待与您的沟通</p>
<div class="contact-cards">
<!-- 邮箱 -->
<div class="contact-card">
<div class="contact-icon">&#x1F4E7;</div>
<div class="contact-label">电子邮箱</div>
<a class="contact-value" href="mailto:contact@xiaohua.com">contact@xiaohua.com</a>
</div>
<!-- 社交媒体 -->
<div class="contact-card">
<div class="contact-icon">&#x1F30D;</div>
<div class="contact-label">社交媒体</div>
<p class="contact-value">微信公众号笑话大全</p>
<p class="contact-value">微博@笑话大全</p>
</div>
<!-- 反馈通道 -->
<div class="contact-card">
<div class="contact-icon">&#x1F4AC;</div>
<div class="contact-label">反馈通道</div>
<p class="contact-value">
您也可以前往
<router-link to="/feedback" class="inline-link">意见建议</router-link>
页面直接提交反馈
</p>
</div>
</div>
</div>
</template>
<style scoped>
.contact-page {
max-width: 800px;
margin: 0 auto;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 28px;
}
.contact-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.contact-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 24px;
box-shadow: var(--shadow);
text-align: center;
transition: box-shadow 0.25s, transform 0.25s;
}
.contact-card:hover {
box-shadow: var(--shadow-hover);
transform: translateY(-2px);
}
.contact-icon {
font-size: 36px;
margin-bottom: 12px;
}
.contact-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 10px;
}
.contact-value {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
margin: 0;
}
.inline-link {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.inline-link:hover {
text-decoration: underline;
}
</style>
+219 -35
View File
@@ -1,71 +1,255 @@
<template>
<div class="detail-page" v-if="joke">
<div class="joke-detail">
<div class="tags">
<span class="tag type-tag" v-if="joke.type_name">{{ joke.type_name }}</span>
<span class="tag crowd-tag" v-if="joke.crowd_name">{{ joke.crowd_name }}</span>
<div class="joke-detail-card">
<!-- 标签 -->
<div class="detail-tags">
<span class="tag tag-type" v-for="(name, i) in (joke.type_names||[])" :key="'t'+i">{{ name }}</span>
<span class="tag tag-crowd" v-for="(name, i) in (joke.crowd_names||[])" :key="'c'+i">{{ name }}</span>
</div>
<h1 class="title">{{ joke.title }}</h1>
<div class="content">{{ joke.content }}</div>
<div class="actions">
<button class="action-btn" @click="handleRandom">再看一条</button>
<!-- 标题 -->
<h1 class="detail-title">{{ joke.title }}</h1>
<!-- 内容 -->
<div class="detail-content">{{ joke.content }}</div>
<!-- 操作栏 -->
<div class="detail-actions">
<button class="action-primary" @click="handleNext">
<span>&#x1F3B2;</span> 再看一条
</button>
<button class="action-secondary" @click="$router.push('/')">
<span>&#x1F3E0;</span> 返回首页
</button>
</div>
<div class="meta">
浏览 {{ joke.view_count }} | {{ joke.like_count }}
<!-- 统计 -->
<div class="detail-meta">
<span>浏览 {{ joke.view_count || 0 }}</span>
<span class="sep">|</span>
<button class="like-btn" :class="{ 'liked': liked }" @click.stop="handleLike">
<span class="heart">&#x2764;</span>
<span>点赞 {{ joke.like_count || 0 }}</span>
</button>
<span class="sep">|</span>
<span>{{ formatDate(joke.created_at) }}</span>
</div>
</div>
</div>
<div class="detail-loading" v-else>
<div class="spinner"></div>
<span>加载中...</span>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getJoke } from '@/api/joke'
import { getJoke, getJokes, likeJoke } from '@/api/joke'
import { ElMessage } from 'element-plus'
const route = useRoute()
const router = useRouter()
const joke = ref(null)
const liked = ref(false)
const loading = ref(false)
// 请求 ID 追踪,防止竞态
let currentRequestId = 0
const loadJoke = async () => {
joke.value = await getJoke(route.params.id)
const requestId = ++currentRequestId
loading.value = true
try {
const res = await getJoke(route.params.id)
// 只接受最新请求的响应
if (requestId !== currentRequestId) return
joke.value = res
liked.value = false
loading.value = false
} catch (e) {
if (requestId !== currentRequestId) return
loading.value = false
// 笑话不存在时返回首页
if (e?.detail === '笑话不存在' || e?.response?.status === 404) {
router.push('/')
}
}
}
const handleRandom = () => {
router.push('/')
setTimeout(() => window.location.reload(), 100)
const handleLike = async () => {
if (liked.value || !joke.value) return
try {
const res = await likeJoke(route.params.id)
if (joke.value && res?.like_count !== undefined) {
joke.value.like_count = res.like_count
}
liked.value = true
ElMessage.success('点赞成功')
} catch (e) {
ElMessage.error('点赞失败,请重试')
}
}
const handleNext = async () => {
try {
const res = await getJokes({ page: 1, page_size: 50 })
if (res.items && res.items.length) {
const rnd = res.items[Math.floor(Math.random() * res.items.length)]
router.push(`/detail/${rnd.id}`)
}
} catch (e) { /* ignore */ }
}
const formatDate = (dateStr) => {
if (!dateStr) return ''
return new Date(dateStr).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
onMounted(() => loadJoke())
watch(() => route.params.id, () => loadJoke())
</script>
<style scoped>
.joke-detail {
background: white;
padding: 30px;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
.detail-page {
display: flex;
justify-content: center;
padding: 0;
}
.tags { display: flex; gap: 10px; margin-bottom: 15px; }
.tag { padding: 6px 14px; border-radius: 15px; font-size: 13px; }
.type-tag { background: #FFF3E0; color: #FF9500; }
.crowd-tag { background: #E3F2FD; color: #2196F3; }
.title { font-size: 24px; margin-bottom: 20px; color: #333; }
.content {
line-height: 1.8;
.joke-detail-card {
width: 100%;
max-width: 780px;
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 40px 48px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
gap: 20px;
transition: background 0.3s, box-shadow 0.3s;
}
.detail-tags { display: flex; gap: 10px; }
.detail-title {
font-size: 26px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.4;
letter-spacing: 0.5px;
transition: color 0.3s;
}
.detail-content {
font-size: 17px;
color: #444;
line-height: 2;
color: var(--text-secondary);
white-space: pre-wrap;
letter-spacing: 0.3px;
transition: color 0.3s;
}
.actions { margin-top: 30px; }
.action-btn {
padding: 12px 30px;
background: linear-gradient(135deg, #FF9500, #FFB800);
.detail-actions { display: flex; gap: 14px; padding-top: 8px; }
.action-primary {
padding: 12px 28px;
background: var(--primary);
color: white;
border: none;
border-radius: 25px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.action-btn:hover { opacity: 0.9; }
.meta { margin-top: 20px; color: #999; font-size: 14px; text-align: center; }
.action-primary:hover {
background: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(255,107,0,0.3);
}
.action-secondary {
padding: 12px 28px;
background: var(--bg-card);
color: var(--text-secondary);
border: 1.5px solid var(--border);
border-radius: 25px;
font-size: 15px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.action-secondary:hover {
border-color: var(--primary);
color: var(--primary);
}
.detail-meta {
text-align: center;
color: var(--text-muted);
font-size: 13px;
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
padding-top: 8px;
border-top: 1px solid var(--border);
transition: color 0.3s, border-color 0.3s;
}
.sep { color: #ddd; }
.like-btn {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
padding: 0;
transition: color 0.2s;
}
.like-btn:hover { color: #e74c3c; }
.like-btn.liked { color: #e74c3c; }
.like-btn .heart {
font-size: 14px;
color: #ccc;
transition: color 0.2s, transform 0.2s;
}
.like-btn:hover .heart { color: #e74c3c; }
.like-btn.liked .heart {
color: #e74c3c;
animation: pop 0.3s ease;
}
@keyframes pop {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
/* 加载状态 */
.detail-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 80px;
color: var(--text-muted);
}
.spinner {
width: 28px; height: 28px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="feedback-page">
<h1 class="page-title">意见建议</h1>
<p class="page-subtitle">您的反馈是我们前进的动力</p>
<div class="form-card">
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="80px"
label-position="left"
>
<el-form-item label="昵称" prop="name">
<el-input v-model="form.name" placeholder="您的昵称(选填)" maxlength="50" />
</el-form-item>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="您的邮箱(选填)" maxlength="100" />
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model="form.content"
type="textarea"
:rows="6"
placeholder="请详细描述您的意见或建议..."
maxlength="2000"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
{{ submitting ? '提交中...' : '提交反馈' }}
</el-button>
</el-form-item>
</el-form>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { submitFeedback } from '@/api/feedback'
const formRef = ref(null)
const submitting = ref(false)
const form = reactive({
name: '',
email: '',
content: ''
})
const rules = {
content: [
{ required: true, message: '请输入反馈内容', trigger: 'blur' },
{ min: 2, message: '内容至少 2 个字符', trigger: 'blur' }
]
}
const handleSubmit = async () => {
if (!formRef.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
submitting.value = true
try {
await submitFeedback({
name: form.name || undefined,
email: form.email || undefined,
content: form.content
})
ElMessage.success('感谢您的反馈!')
form.name = ''
form.email = ''
form.content = ''
formRef.value.resetFields()
} catch (e) {
ElMessage.error('提交失败,请稍后重试')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.feedback-page {
max-width: 640px;
margin: 0 auto;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.page-subtitle {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 24px;
}
.form-card {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 28px;
box-shadow: var(--shadow);
}
</style>
+19 -7
View File
@@ -153,7 +153,8 @@ async function handleGenerate() {
const exists = favorites.value.find(f => f.content === res.content)
isFavorited.value = !!exists
} catch (e) {
ElMessage.error(e.detail || '生成失败,请重试')
const msg = e?.detail || e?.message || '生成失败,请重试'
ElMessage.error(msg)
generatedJoke.value = null
} finally {
loading.value = false
@@ -168,20 +169,31 @@ function handleFavorite() {
ElMessage.info('已经收藏过了')
return
}
favorites.value.unshift({
const newFavorite = {
id: Date.now().toString(),
title: generatedJoke.value.title,
content: generatedJoke.value.content,
created_at: new Date().toISOString()
})
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
isFavorited.value = true
ElMessage.success('收藏成功')
}
favorites.value.unshift(newFavorite)
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
isFavorited.value = true
ElMessage.success('收藏成功')
} catch (e) {
// 处理 localStorage 满的情况
favorites.value.shift() // 移除刚添加的
ElMessage.error('收藏失败,存储空间可能已满,请清理部分收藏')
}
}
function removeFavorite(i) {
favorites.value.splice(i, 1)
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites.value))
} catch (e) {
ElMessage.error('保存失败')
}
// 检查当前生成的是否也从收藏中被删除了
if (generatedJoke.value) {
const stillExists = favorites.value.find(f => f.content === generatedJoke.value.content)
+93 -31
View File
@@ -1,14 +1,30 @@
<template>
<div class="joke-card" @click="$router.push(`/detail/${joke.id}`)">
<!-- 顶部标签 -->
<div class="card-header">
<span class="tag type-tag">{{ joke.type_name || '段子' }}</span>
<span class="tag crowd-tag" v-if="joke.crowd_name">{{ joke.crowd_name }}</span>
<span class="tag tag-type" v-for="(name, i) in (joke.type_names||[])" :key="'t'+i">{{ name }}</span>
<span class="tag tag-crowd" v-for="(name, i) in (joke.crowd_names||[])" :key="'c'+i">{{ name }}</span>
</div>
<h3 class="title">{{ joke.title }}</h3>
<p class="content">{{ joke.content }}</p>
<!-- 标题 -->
<h3 class="card-title">{{ joke.title }}</h3>
<!-- 内容 -->
<p class="card-content">{{ joke.content }}</p>
<!-- 底部信息 -->
<div class="card-footer">
<span class="views">浏览 {{ joke.view_count }}</span>
<span class="likes"> {{ joke.like_count }}</span>
<div class="card-stats">
<span class="stat">
<span class="stat-icon">&#x1F441;</span>
{{ joke.view_count || 0 }}
</span>
<span class="stat">
<span class="stat-icon">&#x2764;</span>
{{ joke.like_count || 0 }}
</span>
</div>
<span class="read-more">阅读 &#x2192;</span>
</div>
</div>
</template>
@@ -21,41 +37,87 @@ defineProps({
<style scoped>
.joke-card {
background: white;
border-radius: 12px;
padding: 20px;
background: var(--bg-card);
border-radius: var(--radius);
padding: 22px 24px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
transition: all 0.25s;
box-shadow: var(--shadow);
border: 1px solid transparent;
display: flex;
flex-direction: column;
gap: 10px;
position: relative;
overflow: hidden;
}
.joke-card::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--primary);
transform: scaleY(0);
transition: transform 0.25s;
border-radius: 0 0 0 var(--radius);
}
.joke-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
transform: translateY(-3px);
box-shadow: var(--shadow-hover);
border-color: #FFF0E6;
}
.card-header { display: flex; gap: 8px; margin-bottom: 12px; }
.tag {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
}
.type-tag { background: #FFF3E0; color: #FF9500; }
.crowd-tag { background: #E3F2FD; color: #2196F3; }
.title { font-size: 18px; margin-bottom: 10px; color: #333; }
.content {
color: #666;
line-height: 1.6;
.joke-card:hover::before { transform: scaleY(1); }
/* 标签 */
.card-header { display: flex; gap: 8px; flex-wrap: wrap; }
/* 标题 */
.card-title {
font-size: 17px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 内容 */
.card-content {
font-size: 14px;
color: var(--text-secondary);
line-height: 1.7;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
flex: 1;
}
/* 底部 */
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #f0f0f0;
color: #999;
font-size: 13px;
padding-top: 12px;
border-top: 1px solid var(--border);
margin-top: auto;
}
.card-stats { display: flex; gap: 14px; }
.stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-muted);
}
.stat-icon { font-size: 13px; }
.read-more {
font-size: 12px;
color: var(--primary);
font-weight: 600;
opacity: 0;
transition: opacity 0.2s;
}
.joke-card:hover .read-more { opacity: 1; }
</style>
+131 -36
View File
@@ -1,68 +1,163 @@
<template>
<div class="home">
<div class="filter-bar">
<div class="filter-group">
<span>类型:</span>
<el-select v-model="filter.type_id" placeholder="全部" clearable @change="loadJokes">
<el-option v-for="t in types" :key="t.id" :label="t.name" :value="t.id" />
</el-select>
</div>
<div class="filter-group">
<span>人群:</span>
<el-select v-model="filter.crowd_id" placeholder="全部" clearable @change="loadJokes">
<el-option v-for="c in crowds" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</div>
<!-- 筛选信息栏 -->
<div class="filter-bar" v-if="filterText">
<span class="filter-text">{{ filterText }}</span>
<span class="filter-count"> {{ jokeStore.total }} </span>
<el-button link type="primary" @click="clearFilter" v-if="jokeStore.total > 0">清除筛选</el-button>
</div>
<div class="joke-grid">
<!-- 笑话网格 -->
<div class="joke-grid" v-if="jokeStore.jokes.length">
<JokeCard v-for="joke in jokeStore.jokes" :key="joke.id" :joke="joke" />
</div>
<el-pagination
v-if="jokeStore.total > 20"
:current-page="page"
:page-size="20"
:total="jokeStore.total"
layout="prev, pager, next"
@current-change="loadJokes"
style="margin-top: 20px; justify-content: center; display: flex"
/>
<!-- 空状态 -->
<div class="empty-state" v-else-if="!jokeStore.loading && !filterText">
<div class="empty-icon">&#x1F60A;</div>
<div class="empty-text">暂无笑话内容</div>
<div class="empty-sub">试试从左侧选择分类筛选</div>
</div>
<div class="empty-state" v-else-if="!jokeStore.loading && filterText">
<div class="empty-icon">&#x1F50D;</div>
<div class="empty-text">此分类下暂无笑话</div>
<div class="empty-sub">试试其他分类或清除筛选</div>
</div>
<!-- 加载状态 -->
<div class="loading-state" v-if="jokeStore.loading">
<div class="spinner"></div>
<span>加载中...</span>
</div>
<!-- 分页 -->
<div class="pagination-wrap" v-if="jokeStore.total > pageSize">
<el-pagination
:current-page="page"
:page-size="pageSize"
:total="jokeStore.total"
layout="prev, pager, next, total"
@current-change="loadJokes"
background
/>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, watch, inject } from 'vue'
import { useRoute } from 'vue-router'
import { useJokeStore } from '@/stores/joke'
import { getTypes, getCrowds } from '@/api/category'
import JokeCard from './components/JokeCard.vue'
const jokeStore = useJokeStore()
const route = useRoute()
const allTypes = inject('allTypes')
const allCrowds = inject('allCrowds')
const types = ref([])
const crowds = ref([])
const page = ref(1)
const filter = ref({ type_id: null, crowd_id: null })
const pageSize = 20
const filterTypeIds = computed(() => route.query.type_ids ? String(route.query.type_ids).split(',').map(Number) : [])
const filterCrowdIds = computed(() => route.query.crowd_ids ? String(route.query.crowd_ids).split(',').map(Number) : [])
const filterText = computed(() => {
if (filterTypeIds.value.length) {
return filterTypeIds.value.map(id => types.value.find(t => t.id === id)?.name).filter(Boolean).join(' / ')
}
if (filterCrowdIds.value.length) {
return filterCrowdIds.value.map(id => crowds.value.find(c => c.id === id)?.name).filter(Boolean).join(' / ')
}
return ''
})
const loadJokes = async (p = 1) => {
page.value = p
await jokeStore.fetchJokes({ page: p, page_size: 20, ...filter.value })
const params = { page: p, page_size: pageSize }
if (filterTypeIds.value.length) params.type_ids = filterTypeIds.value.join(',')
if (filterCrowdIds.value.length) params.crowd_ids = filterCrowdIds.value.join(',')
await jokeStore.fetchJokes(params)
}
const clearFilter = () => {
window.history.pushState({}, '', '/')
loadJokes(1)
}
onMounted(async () => {
types.value = await getTypes()
crowds.value = await getCrowds()
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, () => {
page.value = 1
loadJokes(1)
}, { deep: true })
</script>
<style scoped>
.home { display: flex; flex-direction: column; gap: 20px; }
/* 筛选信息栏 */
.filter-bar {
background: var(--bg-card);
border-radius: var(--radius);
padding: 14px 20px;
display: flex;
gap: 20px;
margin-bottom: 20px;
padding: 15px;
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
align-items: center;
gap: 12px;
box-shadow: var(--shadow);
border-left: 3px solid var(--primary);
transition: background 0.3s;
}
.filter-group { display: flex; align-items: center; gap: 10px; }
.joke-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 20px; }
.filter-text { font-size: 15px; font-weight: 700; color: var(--primary); }
.filter-count { font-size: 13px; color: var(--text-muted); margin-left: auto; }
/* 网格:双列自适应 */
.joke-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
background: var(--bg-card);
border-radius: var(--radius);
box-shadow: var(--shadow);
transition: background 0.3s;
}
.empty-icon { font-size: 64px; margin-bottom: 16px; }
.empty-text { font-size: 18px; color: var(--text-primary); font-weight: 600; margin-bottom: 8px; transition: color 0.3s; }
.empty-sub { font-size: 14px; color: var(--text-muted); }
/* 加载 */
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px;
color: var(--text-muted);
}
.spinner {
width: 20px; height: 20px;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* 分页 */
.pagination-wrap { display: flex; justify-content: center; padding: 8px 0; }
:deep(.el-pagination.is-background .el-pager li.is-active) { background: var(--primary) !important; }
</style>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div class="links-page">
<h1 class="page-title">友情链接</h1>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<span>加载中...</span>
</div>
<!-- 空状态 -->
<div v-else-if="links.length === 0" class="empty-state">
<p>暂无友情链接</p>
</div>
<!-- 链接网格 -->
<div v-else class="links-grid">
<a
v-for="link in links"
:key="link.id"
:href="link.url"
target="_blank"
rel="noopener noreferrer"
class="link-card"
>
<div class="link-name">{{ link.name }}</div>
<div class="link-desc" v-if="link.description">{{ link.description }}</div>
</a>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getLinks } from '@/api/link'
const links = ref([])
const loading = ref(true)
onMounted(async () => {
try {
links.value = await getLinks()
} catch (e) {
console.warn('[Links] Failed to load:', e)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.links-page {
max-width: 900px;
margin: 0 auto;
}
.page-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 24px;
color: var(--text-primary);
}
.loading-state,
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 15px;
}
.links-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.link-card {
display: block;
background: var(--bg-card);
border-radius: var(--radius);
padding: 20px;
text-decoration: none;
box-shadow: var(--shadow);
transition: box-shadow 0.25s, transform 0.25s;
}
.link-card:hover {
box-shadow: var(--shadow-hover);
transform: translateY(-2px);
}
.link-name {
font-size: 16px;
font-weight: 600;
color: var(--primary);
margin-bottom: 8px;
}
.link-desc {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.5;
}
</style>
+123 -32
View File
@@ -1,67 +1,158 @@
<template>
<div class="search-page">
<!-- 搜索框 -->
<div class="search-box">
<el-input
v-model="keyword"
placeholder="搜索笑话..."
placeholder="输入关键词搜索笑话..."
size="large"
@keyup.enter="handleSearch"
class="search-input"
>
<template #append>
<el-button @click="handleSearch">搜索</el-button>
<el-button class="search-btn-inner" @click="handleSearch">搜索</el-button>
</template>
</el-input>
</div>
<div class="search-results" v-if="results.length > 0">
<div
v-for="joke in results"
:key="joke.id"
class="result-item"
@click="$router.push(`/detail/${joke.id}`)"
>
<h3>{{ joke.title }}</h3>
<p>{{ joke.content }}</p>
<div class="hot-words" v-if="!searched">
<span>热门</span>
<span v-for="w in hotWords" :key="w" class="hot-word" @click="quickSearch(w)">{{ w }}</span>
</div>
</div>
<el-empty v-else-if="searched" description="未找到相关笑话" />
<!-- 结果 -->
<div class="results-area" v-if="searched">
<div class="results-count" v-if="results.length > 0">
找到 <strong>{{ results.length }}</strong> 条相关笑话
</div>
<div class="joke-grid" v-if="results.length">
<JokeCard v-for="j in results" :key="j.id" :joke="j" />
</div>
<div class="empty-state" v-else>
<div class="empty-icon">&#x1F50D;</div>
<div>未找到相关笑话换个关键词试试吧</div>
</div>
</div>
<!-- 默认提示 -->
<div class="search-hint" v-if="!searched">
<div class="hint-icon">&#x1F50D;</div>
<div class="hint-text">输入关键词开始搜索</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ref, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { getJokes } from '@/api/joke'
import JokeCard from '../home/components/JokeCard.vue'
const route = useRoute()
const router = useRouter()
const keyword = ref(route.query.q || '')
const keyword = ref('')
const results = ref([])
const searched = ref(false)
const hotWords = ['冷笑话', '段子', '谐音梗', '职场', '校园']
const handleSearch = async () => {
if (!keyword.value.trim()) return
searched.value = true
const res = await getJokes({ page: 1, page_size: 50 })
results.value = res.items.filter(j =>
j.title.includes(keyword.value) || j.content.includes(keyword.value)
)
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))
)
} catch (e) {
results.value = []
}
}
if (keyword.value) handleSearch()
const quickSearch = (w) => {
keyword.value = w
handleSearch()
}
onMounted(() => {
if (route.query.q) {
keyword.value = route.query.q
handleSearch()
}
})
watch(() => route.query.q, (q) => {
if (q) {
keyword.value = q
handleSearch()
}
})
</script>
<style scoped>
.search-box { max-width: 600px; margin: 0 auto 30px; }
.search-results { display: flex; flex-direction: column; gap: 15px; }
.result-item {
background: white;
padding: 20px;
.search-page { display: flex; flex-direction: column; gap: 20px; }
/* 搜索框 */
.search-box {
background: var(--bg-card);
border-radius: var(--radius);
padding: 24px;
box-shadow: var(--shadow);
transition: background 0.3s;
}
.search-input { max-width: 600px; }
:deep(.el-input__wrapper) { border-radius: 24px !important; }
:deep(.el-input-group__append) { border-radius: 0 24px 24px 0 !important; }
.search-btn-inner {
background: var(--primary) !important;
color: white !important;
border: none !important;
font-weight: 600;
}
.hot-words {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
font-size: 13px;
color: var(--text-muted);
flex-wrap: wrap;
}
.hot-word {
padding: 3px 12px;
background: var(--bg-page);
border-radius: 12px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
transition: transform 0.2s;
transition: all 0.2s;
font-size: 13px;
}
.result-item:hover { transform: translateX(5px); }
.result-item h3 { margin-bottom: 8px; color: #333; }
.result-item p { color: #666; line-height: 1.5; }
.hot-word:hover { background: var(--primary); color: white; }
/* 结果 */
.results-count {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 12px;
}
.results-count strong { color: var(--primary); }
.joke-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
/* 空/默认 */
.empty-state, .search-hint {
text-align: center;
padding: 60px 20px;
background: var(--bg-card);
border-radius: var(--radius);
transition: background 0.3s;
}
.empty-icon, .hint-icon { font-size: 56px; margin-bottom: 16px; }
.empty-state > div { font-size: 16px; color: var(--text-muted); }
.hint-text { font-size: 18px; font-weight: 600; color: var(--text-primary); }
</style>