- 大图为 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>
327 lines
8.5 KiB
Vue
327 lines
8.5 KiB
Vue
<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>
|