library-picturebook-activity/frontend/src/views/public/works/Index.vue
aid 37bd82714d feat: 作品封面引入 PIP 画中画展示原图,详情页加全屏放大查看
- 大图为 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>
2026-04-09 19:29:31 +08:00

327 lines
8.5 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="works-list-page">
<div class="page-header">
<h2>我的作品库</h2>
<a-button type="primary" shape="round" @click="$router.push('/p/create')">
<plus-outlined /> 创作绘本
</a-button>
</div>
<!-- 状态 Tab -->
<div class="status-tabs">
<span
v-for="tab in tabs"
:key="tab.key"
:class="['tab-item', { active: activeTab === tab.key }]"
@click="switchTab(tab.key)"
>
{{ tab.label }}
</span>
</div>
<div v-if="loading" class="loading-wrap"><a-spin /></div>
<div v-else-if="works.length === 0" class="empty-wrap">
<a-empty :description="emptyDescription">
<a-button type="primary" shape="round" @click="$router.push('/p/create')">开始创作</a-button>
</a-empty>
</div>
<div v-else class="works-grid">
<div v-for="work in works" :key="work.id" class="work-card" @click="$router.push(`/p/works/${work.id}`)">
<div class="work-cover">
<!-- 大图AI 生成的绘本封面 -->
<img v-if="work.coverUrl" :src="work.coverUrl" :alt="work.title" />
<div v-else class="cover-placeholder">
<picture-outlined />
</div>
<!-- 右下角 PIP用户上传的原图 -->
<div
v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl"
class="cover-pip"
:title="'原图'"
>
<img :src="work.originalImageUrl" alt="原图" />
</div>
<div class="work-status-tag" :class="work.status">
{{ statusTextMap[work.status] || work.status }}
</div>
</div>
<div class="work-info">
<h3>{{ work.title }}</h3>
<div class="work-meta">
<span>{{ work._count?.pages || 0 }}页</span>
<span v-if="work.status === 'published'">
{{ work.viewCount || 0 }}浏览 · {{ work.likeCount || 0 }}赞
</span>
<span v-else>{{ formatDate(work.createTime) }}</span>
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div v-if="total > pageSize" class="pagination-wrap">
<a-pagination
v-model:current="currentPage"
:total="total"
:page-size="pageSize"
simple
@change="fetchWorks"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { PlusOutlined, PictureOutlined } from '@ant-design/icons-vue'
import { publicUserWorksApi, type UserWork } from '@/api/public'
import { MOCK_USER_WORKS } from './_dev-mock'
import dayjs from 'dayjs'
const isDev = import.meta.env.DEV
const route = useRoute()
const router = useRouter()
const works = ref<UserWork[]>([])
const loading = ref(true)
const currentPage = ref(1)
const pageSize = 12
const total = ref(0)
// 合法的 tab key防止 query 注入非法值
const VALID_TABS = ['', 'draft', 'unpublished', 'pending_review', 'published', 'rejected']
const initialTab = typeof route.query.tab === 'string' && VALID_TABS.includes(route.query.tab)
? route.query.tab
: ''
const activeTab = ref(initialTab)
const tabs = [
{ key: '', label: '全部' },
{ key: 'draft', label: '草稿' },
{ key: 'unpublished', label: '未发布' },
{ key: 'pending_review', label: '审核中' },
{ key: 'published', label: '已发布' },
{ key: 'rejected', label: '被拒绝' },
]
const statusTextMap: Record<string, string> = {
draft: '草稿',
unpublished: '未发布',
pending_review: '审核中',
published: '已发布',
rejected: '被拒绝',
taken_down: '已下架',
}
const formatDate = (d: string) => dayjs(d).format('MM-DD HH:mm')
const emptyDescription = computed(() => {
switch (activeTab.value) {
case 'draft': return '还没有草稿,开始你的第一本绘本'
case 'unpublished': return '还没有未发布的作品'
case 'pending_review': return '没有正在审核的作品'
case 'published': return '还没有发布的作品'
case 'rejected': return '没有被拒绝的作品'
default: return '还没有作品,开始你的第一本绘本'
}
})
const fetchWorks = async () => {
loading.value = true
try {
const res = await publicUserWorksApi.list({
page: currentPage.value,
pageSize,
status: activeTab.value || undefined,
})
works.value = res.list
total.value = res.total
} catch {
// dev 兜底:真实接口失败时显示 mock 数据,方便 UI 调试
if (isDev) {
const filtered = activeTab.value
? MOCK_USER_WORKS.filter(w => w.status === activeTab.value)
: MOCK_USER_WORKS
works.value = filtered
total.value = filtered.length
} else {
message.error('获取作品列表失败')
}
} finally {
loading.value = false
}
}
const switchTab = (key: string) => {
activeTab.value = key
currentPage.value = 1
// 同步到 URL刷新或分享链接能保持当前 tab
router.replace({ query: { ...route.query, tab: key || undefined } })
fetchWorks()
}
// 响应外部跳转带来的 query.tab 变化(如从 EditInfoView 跳过来)
watch(
() => route.query.tab,
(newTab) => {
const t = typeof newTab === 'string' && VALID_TABS.includes(newTab) ? newTab : ''
if (t !== activeTab.value) {
activeTab.value = t
currentPage.value = 1
fetchWorks()
}
}
)
onMounted(fetchWorks)
</script>
<style scoped lang="scss">
$primary: #6366f1;
.works-list-page {
max-width: 700px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h2 { font-size: 20px; font-weight: 700; color: #1e1b4b; margin: 0; }
}
.status-tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
overflow-x: auto;
.tab-item {
padding: 6px 14px;
border-radius: 20px;
font-size: 13px;
color: #6b7280;
background: #f3f4f6;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
&.active { background: $primary; color: #fff; }
&:hover:not(.active) { background: #e5e7eb; }
}
}
.loading-wrap, .empty-wrap {
padding: 60px 0;
display: flex;
justify-content: center;
}
.works-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
@media (min-width: 640px) {
grid-template-columns: repeat(3, 1fr);
}
}
.work-card {
background: #fff;
border-radius: 14px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
border: 1px solid rgba($primary, 0.04);
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
.work-cover {
position: relative;
aspect-ratio: 3/4;
background: #f5f3ff;
img { width: 100%; height: 100%; object-fit: cover; }
.cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
font-size: 32px;
color: #d1d5db;
}
.work-status-tag {
position: absolute;
top: 8px;
left: 8px;
padding: 2px 8px;
border-radius: 8px;
font-size: 10px;
font-weight: 600;
&.draft { background: rgba(107,114,128,0.85); color: #fff; }
&.unpublished { background: rgba(99,102,241,0.9); color: #fff; }
&.pending_review { background: rgba(245,158,11,0.92); color: #fff; }
&.published { background: rgba(16,185,129,0.92); color: #fff; }
&.rejected { background: rgba(239,68,68,0.92); color: #fff; }
&.taken_down { background: rgba(107,114,128,0.85); color: #fff; }
}
/* 右下角 PIP用户原图 */
.cover-pip {
position: absolute;
right: 8px;
bottom: 8px;
width: 34%;
aspect-ratio: 1 / 1;
border-radius: 8px;
overflow: hidden;
border: 2px solid #fff;
background: #fff;
box-shadow: 0 3px 10px rgba(15, 12, 41, 0.25);
transition: transform 0.2s;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
}
&:hover .cover-pip {
transform: scale(1.05);
}
.work-info {
padding: 10px 12px;
h3 { font-size: 13px; font-weight: 600; color: #1e1b4b; margin: 0 0 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-meta {
display: flex;
gap: 8px;
span { font-size: 11px; color: #9ca3af; }
}
}
}
.pagination-wrap {
display: flex;
justify-content: center;
padding: 24px 0;
}
</style>