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:
+219
-35
@@ -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>🎲</span> 再看一条
|
||||
</button>
|
||||
<button class="action-secondary" @click="$router.push('/')">
|
||||
<span>🏠</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">❤</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>
|
||||
Reference in New Issue
Block a user