2026-03-27 22:20:25 +08:00
|
|
|
<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>
|
2026-03-31 13:56:20 +08:00
|
|
|
</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>
|
2026-03-27 22:20:25 +08:00
|
|
|
</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'
|
2026-03-31 13:56:20 +08:00
|
|
|
import { useRoute, useRouter } from 'vue-router'
|
2026-03-27 22:20:25 +08:00
|
|
|
import { message } from 'ant-design-vue'
|
2026-03-31 13:56:20 +08:00
|
|
|
import {
|
|
|
|
|
ArrowLeftOutlined, LeftOutlined, RightOutlined,
|
|
|
|
|
HeartOutlined, HeartFilled, StarOutlined, StarFilled, EyeOutlined,
|
|
|
|
|
} from '@ant-design/icons-vue'
|
|
|
|
|
import { publicUserWorksApi, publicGalleryApi, publicInteractionApi, type UserWork } from '@/api/public'
|
2026-03-27 22:20:25 +08:00
|
|
|
import dayjs from 'dayjs'
|
|
|
|
|
|
|
|
|
|
const route = useRoute()
|
2026-03-31 13:56:20 +08:00
|
|
|
const router = useRouter()
|
2026-03-27 22:20:25 +08:00
|
|
|
const workId = Number(route.params.id)
|
|
|
|
|
|
|
|
|
|
const work = ref<UserWork | null>(null)
|
|
|
|
|
const loading = ref(true)
|
|
|
|
|
const currentPageIndex = ref(0)
|
2026-03-31 13:56:20 +08:00
|
|
|
const interaction = ref({ liked: false, favorited: false })
|
|
|
|
|
const actionLoading = ref(false)
|
2026-03-27 22:20:25 +08:00
|
|
|
|
|
|
|
|
const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null)
|
|
|
|
|
|
2026-03-31 13:56:20 +08:00
|
|
|
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
|
|
|
|
|
2026-03-27 22:20:25 +08:00
|
|
|
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 }
|
|
|
|
|
})
|
|
|
|
|
|
2026-03-31 13:56:20 +08:00
|
|
|
const displayLikeCount = computed(() => work.value?.likeCount || 0)
|
|
|
|
|
const displayFavoriteCount = computed(() => work.value?.favoriteCount || 0)
|
|
|
|
|
|
2026-03-27 22:20:25 +08:00
|
|
|
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++ }
|
|
|
|
|
|
2026-03-31 13:56:20 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 22:20:25 +08:00
|
|
|
const fetchWork = async () => {
|
|
|
|
|
loading.value = true
|
|
|
|
|
try {
|
2026-03-31 13:56:20 +08:00
|
|
|
// 优先尝试广场接口(公开作品),失败再尝试自己作品库
|
|
|
|
|
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 { /* 忽略 */ }
|
|
|
|
|
}
|
2026-03-27 22:20:25 +08:00
|
|
|
} 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);
|
|
|
|
|
|
2026-04-09 10:16:23 +08:00
|
|
|
// 不固定宽高比:横图/竖图均以「长边」受 max 约束,避免 3:4 框导致横图上下大片留白
|
2026-03-27 22:20:25 +08:00
|
|
|
.page-display {
|
2026-04-09 10:16:23 +08:00
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
width: 100%;
|
|
|
|
|
min-height: 160px;
|
|
|
|
|
padding: 8px 12px;
|
|
|
|
|
box-sizing: border-box;
|
2026-03-27 22:20:25 +08:00
|
|
|
background: #f8f7fc;
|
|
|
|
|
|
2026-04-09 10:16:23 +08:00
|
|
|
.page-image {
|
|
|
|
|
display: block;
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
width: auto;
|
|
|
|
|
height: auto;
|
|
|
|
|
// 长边优先:横图限宽、竖图限高,另一维按比例收缩
|
|
|
|
|
max-height: min(72vh, 85vw);
|
|
|
|
|
object-fit: contain;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.page-placeholder {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
min-height: 200px;
|
|
|
|
|
width: 100%;
|
|
|
|
|
color: #d1d5db;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
}
|
2026-03-27 22:20:25 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.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; }
|
2026-03-31 13:56:20 +08:00
|
|
|
.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 {
|
2026-03-27 22:20:25 +08:00
|
|
|
display: flex;
|
2026-03-31 13:56:20 +08:00
|
|
|
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;
|
|
|
|
|
}
|
2026-03-27 22:20:25 +08:00
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 13:56:20 +08:00
|
|
|
|
|
|
|
|
@keyframes pop {
|
|
|
|
|
0% { transform: scale(1); }
|
|
|
|
|
50% { transform: scale(1.3); }
|
|
|
|
|
100% { transform: scale(1); }
|
|
|
|
|
}
|
2026-03-27 22:20:25 +08:00
|
|
|
</style>
|