- 大图为 AI 生成的绘本封面(coverUrl),右下角小图为用户上传的原图(originalImageUrl),形成"创作素材→AI 成果"的视觉对比 - 草稿等无 AI 封面的作品:大图为占位图,PIP 仍展示原图 涉及文件: - works/Index.vue 作品库列表卡片加 PIP(右下角 34% 正方形 + 白边阴影) - Gallery.vue 发现页卡片加同款 PIP - mine/Favorites.vue 收藏列表加 PIP,type 加 originalImageUrl 字段 - components/WorkSelector.vue 作品选择器加更小尺寸 PIP(32%) - works/Detail.vue 详情页新增「画作原图」独立卡片(左 84px 缩略图 + 右文字描述) · 点击缩略图全屏 overlay 放大查看,背景毛玻璃 + 紫黑半透明 · hover 缩略图时显示放大镜图标 - _dev-mock.ts 5 条 mock 作品都加 originalImageUrl(不同 hue 区分),id=102 (draft) 的 coverUrl 设为 null 测试占位边界 兼容性: - v-if 检查 originalImageUrl 不为空且与 coverUrl 不同,防止字段未拆分时显示重复 - 后端 originalImageUrl 字段为 null 时 PIP 不显示,老数据自动兼容 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
967 lines
26 KiB
Vue
967 lines
26 KiB
Vue
<template>
|
||
<div class="work-detail-page">
|
||
<a-spin :spinning="loading">
|
||
<template v-if="work">
|
||
<!-- 顶部 -->
|
||
<div class="detail-header">
|
||
<button class="back-btn" @click="$router.back()">
|
||
<left-outlined />
|
||
</button>
|
||
<h1>{{ work.title }}</h1>
|
||
<span :class="['status-tag', work.status]">{{ statusTextMap[work.status] }}</span>
|
||
</div>
|
||
|
||
<!-- 拒绝原因(仅作者 + rejected)-->
|
||
<div v-if="isOwner && work.status === 'rejected' && work.reviewNote" class="reject-card">
|
||
<warning-filled class="reject-icon" />
|
||
<div class="reject-body">
|
||
<div class="reject-title">未通过审核</div>
|
||
<div class="reject-content">{{ work.reviewNote }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 草稿提示(仅作者 + draft)-->
|
||
<div v-else-if="isOwner && work.status === 'draft'" class="info-card draft-card">
|
||
<info-circle-outlined class="info-icon" />
|
||
<div class="info-body">
|
||
<div class="info-title">这是一个未完成的草稿</div>
|
||
<div class="info-desc">继续完成创作后才能公开发布</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 未发布提示(仅作者 + unpublished)-->
|
||
<div v-else-if="isOwner && work.status === 'unpublished'" class="info-card unpublished-card">
|
||
<info-circle-outlined class="info-icon" />
|
||
<div class="info-body">
|
||
<div class="info-title">作品仅你自己可见</div>
|
||
<div class="info-desc">点击下方「公开发布」提交审核,通过后将在「发现」页展示</div>
|
||
</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">
|
||
<picture-outlined />
|
||
</div>
|
||
</div>
|
||
<div class="page-text" v-if="currentPageData?.text">
|
||
{{ currentPageData.text }}
|
||
</div>
|
||
<div class="page-audio" v-if="currentPageData?.audioUrl">
|
||
<audio :src="currentPageData.audioUrl" controls class="audio-player"></audio>
|
||
</div>
|
||
<div class="page-nav">
|
||
<button class="nav-btn" :disabled="currentPageIndex === 0" @click="prevPage">
|
||
<left-outlined />
|
||
</button>
|
||
<span class="page-indicator">{{ currentPageIndex + 1 }} / {{ work.pages.length }}</span>
|
||
<button class="nav-btn" :disabled="currentPageIndex === work.pages.length - 1" @click="nextPage">
|
||
<right-outlined />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 画作原图 -->
|
||
<div v-if="work.originalImageUrl" class="original-card">
|
||
<div class="original-thumb" @click="previewOriginal = work.originalImageUrl || ''">
|
||
<img :src="work.originalImageUrl" alt="画作原图" />
|
||
<div class="zoom-hint"><zoom-in-outlined /></div>
|
||
</div>
|
||
<div class="original-text">
|
||
<div class="original-title">画作原图</div>
|
||
<div class="original-desc">AI 根据这张画作生成的绘本</div>
|
||
</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">
|
||
<span v-for="t in work.tags" :key="t.tag.id" class="info-tag">{{ t.tag.name }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 互动栏:仅在已发布作品上显示 -->
|
||
<div v-if="work.status === 'published'" 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>
|
||
|
||
<!-- 作者私有操作 -->
|
||
<div v-if="isOwner" class="owner-actions">
|
||
<!-- 主操作:根据 status 切换 -->
|
||
<button
|
||
v-if="work.status === 'unpublished'"
|
||
class="op-btn primary"
|
||
:disabled="actionLoading"
|
||
@click="handlePublish"
|
||
>
|
||
<send-outlined />
|
||
<span>公开发布</span>
|
||
</button>
|
||
|
||
<button
|
||
v-else-if="work.status === 'rejected'"
|
||
class="op-btn primary"
|
||
:disabled="actionLoading"
|
||
@click="handleResubmit"
|
||
>
|
||
<send-outlined />
|
||
<span>修改后重交</span>
|
||
</button>
|
||
|
||
<button
|
||
v-else-if="work.status === 'draft'"
|
||
class="op-btn primary"
|
||
@click="handleContinue"
|
||
>
|
||
<edit-outlined />
|
||
<span>继续创作</span>
|
||
</button>
|
||
|
||
<button
|
||
v-else-if="work.status === 'pending_review'"
|
||
class="op-btn outline"
|
||
:disabled="actionLoading"
|
||
@click="handleWithdraw"
|
||
>
|
||
<undo-outlined />
|
||
<span>撤回审核</span>
|
||
</button>
|
||
|
||
<button
|
||
v-else-if="work.status === 'published'"
|
||
class="op-btn outline"
|
||
:disabled="actionLoading"
|
||
@click="handleUnpublish"
|
||
>
|
||
<inbox-outlined />
|
||
<span>下架</span>
|
||
</button>
|
||
|
||
<!-- 编辑信息(unpublished 状态)-->
|
||
<button
|
||
v-if="work.status === 'unpublished'"
|
||
class="op-btn outline-soft"
|
||
@click="handleEditInfo"
|
||
>
|
||
<edit-outlined />
|
||
<span>编辑信息</span>
|
||
</button>
|
||
|
||
<!-- 删除(所有状态)-->
|
||
<button
|
||
class="op-btn ghost-danger"
|
||
:disabled="actionLoading"
|
||
@click="handleDelete"
|
||
>
|
||
<delete-outlined />
|
||
<span>删除</span>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<a-empty v-else-if="!loading" description="作品不存在" style="padding: 80px 0" />
|
||
</a-spin>
|
||
|
||
<!-- 二次确认弹窗 -->
|
||
<a-modal
|
||
v-model:open="confirmVisible"
|
||
:title="confirmTitle"
|
||
:ok-text="confirmOkText"
|
||
cancel-text="取消"
|
||
:confirm-loading="actionLoading"
|
||
@ok="handleConfirmOk"
|
||
@cancel="handleConfirmCancel"
|
||
>
|
||
<p>{{ confirmContent }}</p>
|
||
</a-modal>
|
||
|
||
<!-- 原图全屏预览 -->
|
||
<Transition name="fade">
|
||
<div v-if="previewOriginal" class="preview-overlay" @click="previewOriginal = ''">
|
||
<img :src="previewOriginal" class="preview-full-img" alt="画作原图" />
|
||
</div>
|
||
</Transition>
|
||
</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 {
|
||
LeftOutlined, RightOutlined,
|
||
HeartOutlined, HeartFilled,
|
||
StarOutlined, StarFilled,
|
||
EyeOutlined,
|
||
PictureOutlined,
|
||
WarningFilled,
|
||
InfoCircleOutlined,
|
||
SendOutlined,
|
||
EditOutlined,
|
||
UndoOutlined,
|
||
InboxOutlined,
|
||
DeleteOutlined,
|
||
ZoomInOutlined,
|
||
} from '@ant-design/icons-vue'
|
||
import {
|
||
publicUserWorksApi,
|
||
publicGalleryApi,
|
||
publicInteractionApi,
|
||
type UserWork,
|
||
} from '@/api/public'
|
||
import { getMockWorkDetail, isMockWorkId } from './_dev-mock'
|
||
import dayjs from 'dayjs'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const workId = Number(route.params.id)
|
||
|
||
const isDev = import.meta.env.DEV
|
||
|
||
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 previewOriginal = ref('')
|
||
|
||
const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null)
|
||
|
||
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
||
|
||
const isOwner = computed(() => {
|
||
// dev mock 模式:mock 作品默认是当前用户作品
|
||
if (isDev && work.value && isMockWorkId(work.value.id)) return true
|
||
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: '草稿',
|
||
unpublished: '未发布',
|
||
pending_review: '审核中',
|
||
published: '已发布',
|
||
rejected: '被拒绝',
|
||
taken_down: '已下架',
|
||
}
|
||
|
||
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 isMock = computed(() => isDev && work.value && isMockWorkId(work.value.id))
|
||
|
||
/** 公开发布:unpublished → pending_review */
|
||
async function handlePublish() {
|
||
if (!work.value) return
|
||
actionLoading.value = true
|
||
try {
|
||
if (isMock.value) {
|
||
await new Promise(r => setTimeout(r, 300))
|
||
} else {
|
||
await publicUserWorksApi.publish(workId)
|
||
}
|
||
work.value.status = 'pending_review'
|
||
message.success('已提交审核,等待超管确认')
|
||
} catch (e: any) {
|
||
message.error(e.message || '发布失败')
|
||
} finally {
|
||
actionLoading.value = false
|
||
}
|
||
}
|
||
|
||
/** 修改后重交:rejected → 跳到编辑信息页 */
|
||
function handleResubmit() {
|
||
// TODO: 真实场景需要 leai workId 跳到 EditInfoView,等后端 work.leaiWorkId 字段确认后接入
|
||
message.info('编辑功能待后端联调,dev 模式暂无法跳转')
|
||
}
|
||
|
||
/** 继续创作:draft → 跳回创作流程 */
|
||
function handleContinue() {
|
||
router.push('/p/create')
|
||
}
|
||
|
||
/** 撤回审核:pending_review → unpublished */
|
||
function handleWithdraw() {
|
||
showConfirm(
|
||
'撤回审核',
|
||
'撤回后作品将回到「未发布」状态,可继续编辑或重新提交审核',
|
||
'确认撤回',
|
||
async () => {
|
||
if (!work.value) return
|
||
actionLoading.value = true
|
||
try {
|
||
if (isMock.value) {
|
||
await new Promise(r => setTimeout(r, 300))
|
||
} else {
|
||
// TODO: 后端需要新增 POST /public/works/{id}/withdraw 接口
|
||
message.warning('撤回接口待后端联调')
|
||
return
|
||
}
|
||
work.value.status = 'unpublished'
|
||
message.success('已撤回审核')
|
||
} catch (e: any) {
|
||
message.error(e.message || '撤回失败')
|
||
} finally {
|
||
actionLoading.value = false
|
||
}
|
||
},
|
||
)
|
||
}
|
||
|
||
/** 下架作品:published → unpublished */
|
||
function handleUnpublish() {
|
||
showConfirm(
|
||
'下架作品',
|
||
'下架后作品将从「发现」页移除,回到「未发布」状态。下架后仍可重新提交审核',
|
||
'确认下架',
|
||
async () => {
|
||
if (!work.value) return
|
||
actionLoading.value = true
|
||
try {
|
||
if (isMock.value) {
|
||
await new Promise(r => setTimeout(r, 300))
|
||
} else {
|
||
// TODO: 后端需要新增 POST /public/works/{id}/unpublish 接口
|
||
message.warning('下架接口待后端联调')
|
||
return
|
||
}
|
||
work.value.status = 'unpublished'
|
||
message.success('已下架到「未发布」')
|
||
} catch (e: any) {
|
||
message.error(e.message || '下架失败')
|
||
} finally {
|
||
actionLoading.value = false
|
||
}
|
||
},
|
||
)
|
||
}
|
||
|
||
/** 编辑信息:跳到 EditInfoView */
|
||
function handleEditInfo() {
|
||
// TODO: 真实场景需要 work.leaiWorkId 字段,等后端确认后接入
|
||
message.info('编辑信息功能待后端联调')
|
||
}
|
||
|
||
/** 删除作品 */
|
||
function handleDelete() {
|
||
showConfirm(
|
||
'删除作品',
|
||
'删除后无法恢复,确认要删除这个作品吗?',
|
||
'确认删除',
|
||
async () => {
|
||
actionLoading.value = true
|
||
try {
|
||
if (isMock.value) {
|
||
await new Promise(r => setTimeout(r, 300))
|
||
} else {
|
||
await publicUserWorksApi.delete(workId)
|
||
}
|
||
message.success('已删除')
|
||
router.push('/p/works')
|
||
} catch (e: any) {
|
||
message.error(e.message || '删除失败')
|
||
} finally {
|
||
actionLoading.value = false
|
||
}
|
||
},
|
||
)
|
||
}
|
||
|
||
// ─── 二次确认弹窗 ───
|
||
const confirmVisible = ref(false)
|
||
const confirmTitle = ref('')
|
||
const confirmContent = ref('')
|
||
const confirmOkText = ref('确认')
|
||
let confirmHandler: (() => Promise<void>) | null = null
|
||
|
||
function showConfirm(title: string, content: string, okText: string, handler: () => Promise<void>) {
|
||
confirmTitle.value = title
|
||
confirmContent.value = content
|
||
confirmOkText.value = okText
|
||
confirmHandler = handler
|
||
confirmVisible.value = true
|
||
}
|
||
async function handleConfirmOk() {
|
||
if (confirmHandler) {
|
||
await confirmHandler()
|
||
confirmHandler = null
|
||
}
|
||
confirmVisible.value = false
|
||
}
|
||
function handleConfirmCancel() {
|
||
confirmVisible.value = false
|
||
confirmHandler = null
|
||
}
|
||
|
||
// ─── 加载作品 ───
|
||
const fetchWork = async () => {
|
||
loading.value = true
|
||
|
||
// dev 兜底:mock id 直接用 mock 数据
|
||
if (isDev && isMockWorkId(workId)) {
|
||
const mock = getMockWorkDetail(workId)
|
||
if (mock) {
|
||
work.value = mock
|
||
loading.value = false
|
||
return
|
||
}
|
||
}
|
||
|
||
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 {
|
||
// dev 兜底:真实接口失败时尝试 mock 数据
|
||
if (isDev) {
|
||
const mock = getMockWorkDetail(workId) || getMockWorkDetail(101)
|
||
if (mock) {
|
||
work.value = mock
|
||
} else {
|
||
message.error('获取作品详情失败')
|
||
}
|
||
} else {
|
||
message.error('获取作品详情失败')
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(fetchWork)
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
$primary: #6366f1;
|
||
$accent: #ec4899;
|
||
|
||
.work-detail-page {
|
||
max-width: 600px;
|
||
margin: 0 auto;
|
||
padding-bottom: 24px;
|
||
}
|
||
|
||
/* ---------- 顶部 ---------- */
|
||
.detail-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-bottom: 16px;
|
||
|
||
h1 {
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
color: #1e1b4b;
|
||
margin: 0;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
.back-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
background: rgba($primary, 0.08);
|
||
color: $primary;
|
||
border: none;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
flex-shrink: 0;
|
||
|
||
:deep(.anticon) { font-size: 15px; }
|
||
|
||
&:hover { background: rgba($primary, 0.14); }
|
||
}
|
||
.status-tag {
|
||
display: inline-block;
|
||
padding: 3px 10px;
|
||
border-radius: 10px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
letter-spacing: 0.3px;
|
||
|
||
&.draft { background: rgba(107, 114, 128, 0.85); }
|
||
&.unpublished { background: rgba(99, 102, 241, 0.9); }
|
||
&.pending_review { background: rgba(245, 158, 11, 0.92); }
|
||
&.published { background: rgba(16, 185, 129, 0.92); }
|
||
&.rejected { background: rgba(239, 68, 68, 0.92); }
|
||
&.taken_down { background: rgba(107, 114, 128, 0.85); }
|
||
}
|
||
|
||
/* ---------- 拒绝原因 / 信息提示卡片 ---------- */
|
||
.reject-card,
|
||
.info-card {
|
||
display: flex;
|
||
gap: 12px;
|
||
padding: 14px 16px;
|
||
border-radius: 14px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.reject-card {
|
||
background: rgba(239, 68, 68, 0.06);
|
||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||
}
|
||
.reject-icon {
|
||
font-size: 18px;
|
||
color: #ef4444;
|
||
flex-shrink: 0;
|
||
margin-top: 2px;
|
||
}
|
||
.reject-body { flex: 1; }
|
||
.reject-title { font-size: 13px; font-weight: 700; color: #b91c1c; margin-bottom: 4px; }
|
||
.reject-content { font-size: 13px; color: #4b5563; line-height: 1.6; }
|
||
|
||
.info-card {
|
||
background: linear-gradient(135deg, rgba($primary, 0.08), rgba($accent, 0.05));
|
||
border: 1px solid rgba($primary, 0.15);
|
||
}
|
||
.info-icon {
|
||
font-size: 18px;
|
||
color: $primary;
|
||
flex-shrink: 0;
|
||
margin-top: 2px;
|
||
}
|
||
.info-body { flex: 1; }
|
||
.info-title { font-size: 13px; font-weight: 700; color: #1e1b4b; margin-bottom: 3px; }
|
||
.info-desc { font-size: 12px; color: #6b7280; line-height: 1.6; }
|
||
|
||
/* ---------- 画作原图卡片 ---------- */
|
||
.original-card {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
background: #fff;
|
||
border: 1px solid rgba($primary, 0.06);
|
||
border-radius: 16px;
|
||
padding: 14px;
|
||
margin-bottom: 14px;
|
||
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||
}
|
||
.original-thumb {
|
||
position: relative;
|
||
width: 84px;
|
||
aspect-ratio: 1 / 1;
|
||
border-radius: 12px;
|
||
overflow: hidden;
|
||
border: 2px solid rgba($primary, 0.18);
|
||
cursor: zoom-in;
|
||
flex-shrink: 0;
|
||
background: #f5f3ff;
|
||
transition: all 0.2s;
|
||
|
||
&:hover {
|
||
border-color: $primary;
|
||
transform: scale(1.03);
|
||
.zoom-hint { opacity: 1; }
|
||
}
|
||
|
||
img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
|
||
.zoom-hint {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(15, 12, 41, 0.4);
|
||
color: #fff;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 22px;
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
}
|
||
}
|
||
.original-text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.original-title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #1e1b4b;
|
||
margin-bottom: 4px;
|
||
}
|
||
.original-desc {
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ---------- 全屏原图预览 ---------- */
|
||
.preview-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 999;
|
||
background: rgba(15, 12, 41, 0.88);
|
||
backdrop-filter: blur(8px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: zoom-out;
|
||
}
|
||
.preview-full-img {
|
||
max-width: 90%;
|
||
max-height: 80vh;
|
||
object-fit: contain;
|
||
border-radius: 16px;
|
||
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||
}
|
||
.fade-enter-active,
|
||
.fade-leave-active { transition: opacity 0.2s; }
|
||
.fade-enter-from,
|
||
.fade-leave-to { opacity: 0; }
|
||
|
||
/* ---------- 绘本阅读器 ---------- */
|
||
.book-reader {
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
margin-bottom: 14px;
|
||
border: 1px solid rgba($primary, 0.06);
|
||
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||
|
||
.page-display {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
min-height: 200px;
|
||
background: #1e1b4b;
|
||
|
||
.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: rgba(255, 255, 255, 0.3);
|
||
font-size: 36px;
|
||
}
|
||
}
|
||
|
||
.page-text {
|
||
padding: 16px 20px;
|
||
font-size: 14px;
|
||
line-height: 1.8;
|
||
color: #374151;
|
||
border-top: 1px solid rgba($primary, 0.06);
|
||
}
|
||
|
||
.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 rgba($primary, 0.06);
|
||
|
||
.nav-btn {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
border: 1px solid rgba($primary, 0.2);
|
||
background: #fff;
|
||
color: $primary;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
|
||
:deep(.anticon) { font-size: 14px; }
|
||
|
||
&:hover:not(:disabled) {
|
||
border-color: $primary;
|
||
background: rgba($primary, 0.04);
|
||
}
|
||
|
||
&:disabled {
|
||
opacity: 0.35;
|
||
cursor: not-allowed;
|
||
}
|
||
}
|
||
|
||
.page-indicator {
|
||
font-size: 13px;
|
||
color: #6b7280;
|
||
font-weight: 600;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
}
|
||
}
|
||
|
||
/* ---------- 作品信息 ---------- */
|
||
.info-section {
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
padding: 16px 18px;
|
||
border: 1px solid rgba($primary, 0.06);
|
||
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||
margin-bottom: 14px;
|
||
|
||
.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.7;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.tags-row {
|
||
display: flex;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.info-tag {
|
||
display: inline-block;
|
||
padding: 3px 10px;
|
||
border-radius: 10px;
|
||
background: rgba($primary, 0.08);
|
||
color: $primary;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
border: 1px solid rgba($primary, 0.15);
|
||
}
|
||
}
|
||
|
||
/* ---------- 互动栏 ---------- */
|
||
.interaction-bar {
|
||
display: flex;
|
||
justify-content: space-around;
|
||
margin-bottom: 14px;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
padding: 12px 0;
|
||
border: 1px solid rgba($primary, 0.06);
|
||
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||
|
||
.action-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 18px;
|
||
border-radius: 22px;
|
||
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: $accent;
|
||
&:hover { background: rgba($accent, 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); }
|
||
}
|
||
|
||
/* ---------- 作者私有操作区 ---------- */
|
||
.owner-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
padding: 14px 16px;
|
||
border: 1px solid rgba($primary, 0.06);
|
||
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||
}
|
||
.op-btn {
|
||
flex: 1;
|
||
min-width: 100px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
padding: 11px 12px;
|
||
border-radius: 20px;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
white-space: nowrap;
|
||
|
||
:deep(.anticon) { font-size: 13px; }
|
||
|
||
&:active { transform: scale(0.97); }
|
||
&:disabled { opacity: 0.4; pointer-events: none; }
|
||
}
|
||
|
||
.op-btn.primary {
|
||
background: linear-gradient(135deg, $primary 0%, $accent 100%);
|
||
color: #fff;
|
||
border: none;
|
||
box-shadow: 0 4px 14px rgba($primary, 0.32);
|
||
|
||
&:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba($primary, 0.4); }
|
||
}
|
||
|
||
.op-btn.outline {
|
||
background: #fff;
|
||
color: $primary;
|
||
border: 1.5px solid rgba($primary, 0.4);
|
||
|
||
&:hover {
|
||
border-color: $primary;
|
||
background: rgba($primary, 0.04);
|
||
}
|
||
}
|
||
|
||
.op-btn.outline-soft {
|
||
background: rgba($primary, 0.04);
|
||
color: $primary;
|
||
border: 1px solid rgba($primary, 0.2);
|
||
|
||
&:hover {
|
||
background: rgba($primary, 0.08);
|
||
border-color: rgba($primary, 0.4);
|
||
}
|
||
}
|
||
|
||
.op-btn.ghost-danger {
|
||
flex: 0 0 auto;
|
||
min-width: 0;
|
||
padding: 11px 16px;
|
||
background: transparent;
|
||
color: #ef4444;
|
||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||
|
||
&:hover {
|
||
background: rgba(239, 68, 68, 0.04);
|
||
border-color: rgba(239, 68, 68, 0.4);
|
||
}
|
||
}
|
||
</style>
|