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
+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>