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:
parent
8638876fa9
commit
37bd82714d
@ -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 {
|
||||
|
||||
@ -25,8 +25,18 @@
|
||||
:class="['selector-card', { selected: selectedWork?.id === work.id }]"
|
||||
@click="selectedWork = work"
|
||||
>
|
||||
<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; }
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user