library-picturebook-activity/frontend/src/views/public/works/Detail.vue

329 lines
10 KiB
Vue
Raw Normal View History

<template>
<div class="work-detail-page">
<a-spin :spinning="loading">
<template v-if="work">
<!-- 顶部信息 -->
<div class="detail-header">
<a-button type="text" @click="$router.back()">
<arrow-left-outlined /> 返回
</a-button>
<h1>{{ work.title }}</h1>
<div class="header-actions" v-if="isOwner">
<a-button v-if="work.status === 'draft' || work.status === 'rejected'" type="primary" shape="round" size="small" @click="$router.push(`/p/works/${work.id}/publish`)">
发布作品
</a-button>
<a-tag v-else :color="statusColorMap[work.status]">{{ statusTextMap[work.status] }}</a-tag>
</div>
</div>
<!-- 绘本阅读器 -->
<div class="book-reader" v-if="work.pages && work.pages.length > 0">
<div class="page-display">
<img v-if="currentPageData?.imageUrl" :src="currentPageData.imageUrl" :alt="`第${currentPageIndex + 1}页`" class="page-image" />
<div v-else class="page-placeholder">暂无插图</div>
</div>
<div class="page-text" v-if="currentPageData?.text">
<p>{{ currentPageData.text }}</p>
</div>
<div class="page-audio" v-if="currentPageData?.audioUrl">
<audio :src="currentPageData.audioUrl" controls class="audio-player"></audio>
</div>
<div class="page-nav">
<a-button :disabled="currentPageIndex === 0" @click="prevPage" shape="round">
<left-outlined /> 上一页
</a-button>
<span class="page-indicator">{{ currentPageIndex + 1 }} / {{ work.pages.length }}</span>
<a-button :disabled="currentPageIndex === work.pages.length - 1" @click="nextPage" shape="round">
下一页 <right-outlined />
</a-button>
</div>
</div>
<!-- 作品信息 -->
<div class="info-section">
<div class="author-row">
<a-avatar :size="36" :src="work.creator?.avatar">
{{ work.creator?.nickname?.charAt(0) }}
</a-avatar>
<div class="author-info">
<span class="author-name">{{ work.creator?.nickname }}</span>
<span class="create-time">{{ formatDate(work.createTime) }}</span>
</div>
</div>
<div v-if="work.description" class="description">{{ work.description }}</div>
<div v-if="work.tags?.length" class="tags-row">
<a-tag v-for="t in work.tags" :key="t.tag.id" color="purple">{{ t.tag.name }}</a-tag>
</div>
</div>
<!-- 互动栏 -->
<div class="interaction-bar">
<div
:class="['action-btn', { active: interaction.liked }]"
@click="handleLike"
>
<heart-filled v-if="interaction.liked" />
<heart-outlined v-else />
<span>{{ displayLikeCount }}</span>
</div>
<div
:class="['action-btn', { active: interaction.favorited }]"
@click="handleFavorite"
>
<star-filled v-if="interaction.favorited" />
<star-outlined v-else />
<span>{{ displayFavoriteCount }}</span>
</div>
<div class="action-btn">
<eye-outlined />
<span>{{ work.viewCount || 0 }}</span>
</div>
</div>
</template>
<a-empty v-else-if="!loading" description="作品不存在" style="padding: 80px 0" />
</a-spin>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
ArrowLeftOutlined, LeftOutlined, RightOutlined,
HeartOutlined, HeartFilled, StarOutlined, StarFilled, EyeOutlined,
} from '@ant-design/icons-vue'
import { publicUserWorksApi, publicGalleryApi, publicInteractionApi, type UserWork } from '@/api/public'
import dayjs from 'dayjs'
const route = useRoute()
const router = useRouter()
const workId = Number(route.params.id)
const work = ref<UserWork | null>(null)
const loading = ref(true)
const currentPageIndex = ref(0)
const interaction = ref({ liked: false, favorited: false })
const actionLoading = ref(false)
const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null)
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
const isOwner = computed(() => {
const u = localStorage.getItem('public_user')
if (!u || !work.value) return false
try { return JSON.parse(u).id === work.value.userId } catch { return false }
})
const displayLikeCount = computed(() => work.value?.likeCount || 0)
const displayFavoriteCount = computed(() => work.value?.favoriteCount || 0)
const statusTextMap: Record<string, string> = {
draft: '草稿', pending_review: '审核中', published: '已发布', rejected: '被拒绝', taken_down: '已下架',
}
const statusColorMap: Record<string, string> = {
draft: 'default', pending_review: 'orange', published: 'green', rejected: 'red', taken_down: 'default',
}
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- }
const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ }
const handleLike = async () => {
if (!isLoggedIn.value) { router.push('/p/login'); return }
if (actionLoading.value) return
actionLoading.value = true
// 乐观更新
const wasLiked = interaction.value.liked
interaction.value.liked = !wasLiked
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? -1 : 1)
try {
const res = await publicInteractionApi.like(workId)
interaction.value.liked = res.liked
if (work.value) work.value.likeCount = res.likeCount
} catch {
// 回滚
interaction.value.liked = wasLiked
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? 1 : -1)
message.error('操作失败')
} finally {
actionLoading.value = false
}
}
const handleFavorite = async () => {
if (!isLoggedIn.value) { router.push('/p/login'); return }
if (actionLoading.value) return
actionLoading.value = true
const wasFavorited = interaction.value.favorited
interaction.value.favorited = !wasFavorited
if (work.value) work.value.favoriteCount = (work.value.favoriteCount || 0) + (wasFavorited ? -1 : 1)
try {
const res = await publicInteractionApi.favorite(workId)
interaction.value.favorited = res.favorited
if (work.value) work.value.favoriteCount = res.favoriteCount
} catch {
interaction.value.favorited = wasFavorited
if (work.value) work.value.favoriteCount = (work.value.favoriteCount || 0) + (wasFavorited ? 1 : -1)
message.error('操作失败')
} finally {
actionLoading.value = false
}
}
const fetchWork = async () => {
loading.value = true
try {
// 优先尝试广场接口(公开作品),失败再尝试自己作品库
try {
work.value = await publicGalleryApi.detail(workId)
} catch {
work.value = await publicUserWorksApi.detail(workId)
}
// 已登录时获取交互状态
if (isLoggedIn.value) {
try {
interaction.value = await publicInteractionApi.getInteraction(workId)
} catch { /* 忽略 */ }
}
} catch {
message.error('获取作品详情失败')
} finally {
loading.value = false
}
}
onMounted(fetchWork)
</script>
<style scoped lang="scss">
$primary: #6366f1;
.work-detail-page {
max-width: 600px;
margin: 0 auto;
}
.detail-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
h1 { font-size: 18px; font-weight: 700; color: #1e1b4b; margin: 0; flex: 1; }
}
.book-reader {
background: #fff;
border-radius: 16px;
overflow: hidden;
margin-bottom: 16px;
border: 1px solid rgba($primary, 0.06);
.page-display {
aspect-ratio: 3/4;
background: #f8f7fc;
.page-image { width: 100%; height: 100%; object-fit: contain; }
.page-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; color: #d1d5db; font-size: 14px; }
}
.page-text {
padding: 16px 20px;
p { font-size: 14px; line-height: 1.8; color: #374151; margin: 0; }
}
.page-audio {
padding: 0 20px 12px;
.audio-player { width: 100%; height: 36px; }
}
.page-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-top: 1px solid #f3f4f6;
.page-indicator { font-size: 13px; color: #6b7280; font-weight: 600; }
}
}
.info-section {
background: #fff;
border-radius: 16px;
padding: 18px 20px;
border: 1px solid rgba($primary, 0.06);
.author-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
.author-info { display: flex; flex-direction: column; }
.author-name { font-size: 14px; font-weight: 600; color: #1e1b4b; }
.create-time { font-size: 11px; color: #9ca3af; }
}
.description { font-size: 13px; color: #4b5563; line-height: 1.6; margin-bottom: 10px; }
.tags-row { display: flex; gap: 6px; flex-wrap: wrap; }
}
// ========== 互动栏 ==========
.interaction-bar {
display: flex;
justify-content: space-around;
margin-top: 16px;
background: #fff;
border-radius: 16px;
padding: 14px 0;
border: 1px solid rgba($primary, 0.06);
.action-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 20px;
border-radius: 24px;
font-size: 18px;
color: #9ca3af;
cursor: pointer;
transition: all 0.2s;
user-select: none;
span {
font-size: 13px;
font-weight: 500;
}
&:hover {
background: rgba($primary, 0.04);
color: #6b7280;
}
&.active {
color: #ec4899;
&:hover {
background: rgba(236, 72, 153, 0.06);
}
}
// 点赞动效
&.active :deep(.anticon) {
animation: pop 0.3s ease;
}
}
}
@keyframes pop {
0% { transform: scale(1); }
50% { transform: scale(1.3); }
100% { transform: scale(1); }
}
</style>