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>
This commit is contained in:
aid 2026-04-09 19:29:31 +08:00
parent 8638876fa9
commit 37bd82714d
6 changed files with 263 additions and 10 deletions

View File

@ -77,10 +77,19 @@
@click="$router.push(`/p/works/${work.id}`)"
>
<div class="card-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>
<div class="card-body">
<h3>{{ work.title }}</h3>
@ -351,10 +360,36 @@ $primary: #6366f1;
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
.card-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; height: 100%; font-size: 28px; color: #d1d5db; }
/* 右下角 PIP用户原图 */
.cover-pip {
position: absolute;
right: 6px;
bottom: 6px;
width: 34%;
aspect-ratio: 1 / 1;
border-radius: 7px;
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;
}
}
}
&:hover .cover-pip {
transform: scale(1.05);
}
.card-body {

View File

@ -25,8 +25,18 @@
:class="['selector-card', { selected: selectedWork?.id === work.id }]"
@click="selectedWork = work"
>
<img v-if="work.coverUrl" :src="work.coverUrl" class="selector-cover" />
<div v-else class="selector-cover-empty"><picture-outlined /></div>
<div class="cover-wrap">
<img v-if="work.coverUrl" :src="work.coverUrl" class="selector-cover" />
<div v-else class="selector-cover-empty"><picture-outlined /></div>
<!-- 右下角 PIP用户上传的原图 -->
<div
v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl"
class="cover-pip"
title="原图"
>
<img :src="work.originalImageUrl" alt="原图" />
</div>
</div>
<div class="selector-info">
<h4>{{ work.title }}</h4>
<span>{{ work._count?.pages || 0 }}</span>
@ -107,15 +117,22 @@ $primary: #6366f1;
&:hover { border-color: rgba($primary, 0.3); }
&.selected { border-color: $primary; box-shadow: 0 0 0 2px rgba($primary, 0.2); }
.cover-wrap {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
}
.selector-cover {
width: 100%;
aspect-ratio: 3/4;
height: 100%;
object-fit: cover;
display: block;
}
.selector-cover-empty {
width: 100%;
aspect-ratio: 3/4;
height: 100%;
background: #f5f3ff;
display: flex;
align-items: center;
@ -124,6 +141,26 @@ $primary: #6366f1;
color: #d1d5db;
}
/* 右下角 PIP用户原图更小尺寸 */
.cover-pip {
position: absolute;
right: 5px;
bottom: 5px;
width: 32%;
aspect-ratio: 1 / 1;
border-radius: 6px;
overflow: hidden;
border: 1.5px solid #fff;
background: #fff;
box-shadow: 0 2px 8px rgba(15, 12, 41, 0.22);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.selector-info {
padding: 8px 10px;
h4 { font-size: 12px; font-weight: 600; color: #1e1b4b; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

View File

@ -26,6 +26,14 @@
<div v-else class="cover-placeholder">
<picture-outlined />
</div>
<!-- 右下角 PIP用户上传的原图 -->
<div
v-if="item.originalImageUrl && item.originalImageUrl !== item.coverUrl"
class="cover-pip"
title="原图"
>
<img :src="item.originalImageUrl" alt="原图" />
</div>
</div>
<div class="card-body">
<h3>{{ item.title }}</h3>
@ -65,6 +73,7 @@ interface FavoriteListItem {
workId: number
title: string
coverUrl?: string | null
originalImageUrl?: string | null
likeCount?: number
viewCount?: number
}
@ -129,10 +138,36 @@ $primary: #6366f1;
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
.card-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; height: 100%; font-size: 28px; color: #d1d5db; }
/* 右下角 PIP用户原图 */
.cover-pip {
position: absolute;
right: 6px;
bottom: 6px;
width: 34%;
aspect-ratio: 1 / 1;
border-radius: 7px;
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;
}
}
}
&:hover .cover-pip {
transform: scale(1.05);
}
.card-body {

View File

@ -63,6 +63,18 @@
</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">
@ -187,6 +199,13 @@
>
<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>
@ -207,6 +226,7 @@ import {
UndoOutlined,
InboxOutlined,
DeleteOutlined,
ZoomInOutlined,
} from '@ant-design/icons-vue'
import {
publicUserWorksApi,
@ -228,6 +248,7 @@ 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)
@ -592,6 +613,96 @@ $accent: #ec4899;
.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;

View File

@ -30,10 +30,19 @@
<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>
@ -268,6 +277,32 @@ $primary: #6366f1;
&.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 {

View File

@ -48,7 +48,7 @@ export const MOCK_USER_WORKS: UserWork[] = [
visibility: 'private',
status: 'unpublished',
reviewNote: null,
originalImageUrl: null,
originalImageUrl: mockCover(40),
voiceInputUrl: null,
textInput: null,
aiMeta: null,
@ -67,12 +67,12 @@ export const MOCK_USER_WORKS: UserWork[] = [
id: 102,
userId: 1,
title: '正在创作中…',
coverUrl: mockCover(180),
coverUrl: null,
description: null,
visibility: 'private',
status: 'draft',
reviewNote: null,
originalImageUrl: null,
originalImageUrl: mockCover(60),
voiceInputUrl: null,
textInput: null,
aiMeta: null,
@ -96,7 +96,7 @@ export const MOCK_USER_WORKS: UserWork[] = [
visibility: 'public',
status: 'pending_review',
reviewNote: null,
originalImageUrl: null,
originalImageUrl: mockCover(80),
voiceInputUrl: null,
textInput: null,
aiMeta: null,
@ -120,7 +120,7 @@ export const MOCK_USER_WORKS: UserWork[] = [
visibility: 'public',
status: 'published',
reviewNote: null,
originalImageUrl: null,
originalImageUrl: mockCover(120),
voiceInputUrl: null,
textInput: null,
aiMeta: null,
@ -144,7 +144,7 @@ export const MOCK_USER_WORKS: UserWork[] = [
visibility: 'public',
status: 'rejected',
reviewNote: '内容包含疑似版权角色,请修改后重新提交',
originalImageUrl: null,
originalImageUrl: mockCover(160),
voiceInputUrl: null,
textInput: null,
aiMeta: null,