library-picturebook-activity/frontend/src/views/public/Gallery.vue

257 lines
6.4 KiB
Vue
Raw Normal View History

<template>
<div class="gallery-page">
<!-- 搜索栏 -->
<div class="search-bar">
<a-input
v-model:value="keyword"
placeholder="搜索作品..."
allow-clear
@press-enter="handleSearch"
>
<template #prefix><search-outlined /></template>
</a-input>
</div>
<!-- 热门标签 -->
<div class="tags-scroll" v-if="hotTags.length > 0">
<span
v-for="tag in hotTags"
:key="tag.id"
:class="['tag-chip', { active: selectedTagId === tag.id }]"
@click="selectTag(tag.id)"
>
{{ tag.name }}
</span>
</div>
<!-- 排序 -->
<div class="sort-bar">
<span
:class="['sort-item', { active: sortBy === 'latest' }]"
@click="changeSort('latest')"
>最新</span>
<span
:class="['sort-item', { active: sortBy === 'hot' }]"
@click="changeSort('hot')"
>最热</span>
</div>
<!-- 作品网格 -->
<div v-if="loading && works.length === 0" class="loading-wrap"><a-spin /></div>
<div v-else-if="works.length === 0" class="empty-wrap">
<a-empty description="还没有作品,快来创作吧">
<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="card-cover">
<img v-if="work.coverUrl" :src="work.coverUrl" :alt="work.title" />
<div v-else class="cover-placeholder">
<picture-outlined />
</div>
</div>
<div class="card-body">
<h3>{{ work.title }}</h3>
<div class="card-author">
<a-avatar :size="20" :src="work.creator?.avatar">
{{ work.creator?.nickname?.charAt(0) }}
</a-avatar>
<span>{{ work.creator?.nickname }}</span>
</div>
<div class="card-stats">
<span><heart-outlined /> {{ work.likeCount || 0 }}</span>
<span><eye-outlined /> {{ work.viewCount || 0 }}</span>
</div>
</div>
</div>
</div>
<!-- 加载更多 -->
<div v-if="hasMore" class="load-more">
<a-button :loading="loading" shape="round" @click="loadMore">加载更多</a-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { SearchOutlined, PictureOutlined, HeartOutlined, EyeOutlined } from '@ant-design/icons-vue'
import { publicGalleryApi, publicTagsApi, type UserWork, type WorkTag } from '@/api/public'
const works = ref<UserWork[]>([])
const hotTags = ref<WorkTag[]>([])
const loading = ref(false)
const keyword = ref('')
const selectedTagId = ref<number | null>(null)
const sortBy = ref('latest')
const page = ref(1)
const total = ref(0)
const pageSize = 12
const hasMore = computed(() => works.value.length < total.value)
const fetchTags = async () => {
try { hotTags.value = await publicTagsApi.hot() } catch { /* */ }
}
const fetchWorks = async (reset = false) => {
if (reset) { page.value = 1; works.value = [] }
loading.value = true
try {
const res = await publicGalleryApi.list({
page: page.value,
pageSize,
sortBy: sortBy.value,
tagId: selectedTagId.value || undefined,
keyword: keyword.value || undefined,
})
if (reset) {
works.value = res.list
} else {
works.value.push(...res.list)
}
total.value = res.total
} catch { /* */ }
finally { loading.value = false }
}
const handleSearch = () => fetchWorks(true)
const selectTag = (tagId: number) => {
selectedTagId.value = selectedTagId.value === tagId ? null : tagId
fetchWorks(true)
}
const changeSort = (s: string) => { sortBy.value = s; fetchWorks(true) }
const loadMore = () => { page.value++; fetchWorks() }
onMounted(() => {
fetchTags()
fetchWorks()
})
</script>
<style scoped lang="scss">
$primary: #6366f1;
.gallery-page {
max-width: 700px;
margin: 0 auto;
}
.search-bar {
margin-bottom: 14px;
:deep(.ant-input-affix-wrapper) {
border-radius: 24px;
padding: 8px 16px;
border-color: rgba($primary, 0.15);
&:focus, &:hover { border-color: $primary; }
}
}
.tags-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
padding-bottom: 12px;
margin-bottom: 8px;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar { display: none; }
.tag-chip {
padding: 4px 14px;
border-radius: 16px;
font-size: 12px;
background: #f3f4f6;
color: #6b7280;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
flex-shrink: 0;
&.active { background: $primary; color: #fff; }
&:hover:not(.active) { background: #e5e7eb; }
}
}
.sort-bar {
display: flex;
gap: 16px;
margin-bottom: 16px;
.sort-item {
font-size: 13px;
color: #9ca3af;
cursor: pointer;
padding-bottom: 4px;
transition: all 0.2s;
&.active {
color: $primary;
font-weight: 600;
border-bottom: 2px solid $primary;
}
}
}
.loading-wrap, .empty-wrap {
padding: 60px 0;
display: flex;
justify-content: center;
}
.works-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
@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); }
.card-cover {
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; }
}
.card-body {
padding: 10px 12px;
h3 { font-size: 13px; font-weight: 600; color: #1e1b4b; margin: 0 0 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-author {
display: flex; align-items: center; gap: 6px; margin-bottom: 6px;
span { font-size: 11px; color: #6b7280; }
}
.card-stats {
display: flex; gap: 12px;
span { font-size: 11px; color: #9ca3af; display: flex; align-items: center; gap: 3px; }
}
}
}
.load-more {
text-align: center;
padding: 20px 0;
}
</style>