Files
joke/web/src/views/detail/index.vue
T
bwstudio 595394fcb7 feat: 前后台静态资源路径冲突修复 + 用户管理功能
- 修改 admin/vite.config.js 添加 base: '/admin/' 解决静态资源路径问题
- 修复后台 index.html 资源引用从 /assets/ → /admin/assets/
- 更新优化器默认 API 地址为服务器地址
- 添加友链管理相关代码
- 修复多处分类管理页面
2026-06-10 07:30:49 +08:00

339 lines
8.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="detail-page" v-if="joke">
<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>
<!-- 标题 + AI评价 -->
<div class="title-row">
<h1 class="detail-title">{{ joke.title }}</h1>
<span v-if="joke.ai_level" class="ai-badge" :class="'ai-' + joke.ai_level">
{{ getLevelText(joke.ai_level) }}
</span>
</div>
<!-- 内容优先显示润色内容 -->
<div class="detail-content">
<template v-if="joke.polished_content">
<div class="polished-label"> AI 润色版</div>
{{ joke.polished_content }}
</template>
<template v-else>{{ joke.content }}</template>
</div>
<!-- 原始内容如果有润色 -->
<details v-if="joke.polished_content" class="original-content">
<summary>查看原文</summary>
{{ joke.content }}
</details>
<!-- 操作栏 -->
<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="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>
<span v-if="joke.ai_score" class="sep">|</span>
<span v-if="joke.ai_score" class="ai-score">评分: {{ joke.ai_score.toFixed(1) }}</span>
</div>
</div>
</div>
<div class="detail-loading" v-else>
<div class="spinner"></div>
<span>加载中...</span>
</div>
</template>
<script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
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 () => {
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 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' })
}
function getLevelText(level) {
const map = {
'excellent': '⭐⭐⭐ 精品',
'good': '⭐⭐ 良好',
'ordinary': '⭐ 普通',
'poor': '⚠️ 待优化'
}
return map[level] || ''
}
onMounted(() => loadJoke())
watch(() => route.params.id, () => loadJoke())
</script>
<style scoped>
.detail-page {
display: flex;
justify-content: center;
padding: 0;
}
.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; flex-wrap: wrap; align-items: center; }
/* 标题行:标题 + AI评价右对齐 */
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
/* AI 评价徽章 */
.ai-badge {
font-size: 13px;
padding: 4px 14px;
border-radius: 14px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.ai-excellent { background: linear-gradient(135deg, #fef3c7, #fde68a); color: #92400e; }
.ai-good { background: linear-gradient(135deg, #d1fae5, #a7f3d0); color: #065f46; }
.ai-ordinary { background: #f3f4f6; color: #4b5563; }
.ai-poor { background: #fee2e2; color: #991b1b; }
/* AI 润色标签 */
.polished-label {
font-size: 12px;
color: #9333ea;
font-weight: 600;
margin-bottom: 8px;
display: inline-block;
}
/* 原文折叠 */
.original-content {
margin-top: 16px;
padding: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 13px;
color: var(--text-muted);
}
.original-content summary {
cursor: pointer;
font-weight: 600;
color: var(--text-secondary);
}
/* AI 评分 */
.ai-score {
color: #9333ea;
font-weight: 600;
}
.detail-title {
font-size: 26px;
font-weight: 800;
color: var(--text-primary);
line-height: 1.4;
letter-spacing: 0.5px;
transition: color 0.3s;
margin: 0;
flex: 1;
}
.detail-content {
font-size: 17px;
line-height: 2;
color: var(--text-secondary);
white-space: pre-wrap;
letter-spacing: 0.3px;
transition: color 0.3s;
}
.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-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>