作品审核: - 批量通过/批量拒绝 + 撤销审核机制 - 默认筛选待审核,表格加描述预览+审核时间列 - 详情Drawer加上一个/下一个导航,审核后自动跳下一个 - 操作日志时间线展示,筛选下拉自动查询 作品管理: - 修复筛选/排序失效,新增推荐中筛选 - 下架改为弹窗选择原因,取消推荐二次确认 - 详情Drawer补全描述/标签/操作按钮/操作日志 - 统计卡片可点击筛选,下架自动取消推荐 标签管理: - 按分类分组卡片式展示,分类改为下拉选择 - 新增标签颜色字段(预设色+自定义) - 上移/下移排序按钮,使用次数可点击跳转作品管理 - 新增/编辑时实时预览用户端标签效果 广场推荐: - 新增推荐作品列表接口 GET /public/gallery/recommended - 广场顶部新增「编辑推荐」横向滚动栏 文档更新:内容管理设计文档补充实施记录,UGC开发计划P1-1标记已完成 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
398 lines
11 KiB
Vue
398 lines
11 KiB
Vue
<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="recommend-section" v-if="recommendedWorks.length > 0">
|
|
<div class="section-header">
|
|
<span class="section-title"><fire-outlined /> 编辑推荐</span>
|
|
</div>
|
|
<div class="recommend-scroll">
|
|
<div
|
|
v-for="rw in recommendedWorks"
|
|
:key="rw.id"
|
|
class="recommend-card"
|
|
@click="$router.push(`/p/works/${rw.id}`)"
|
|
>
|
|
<div class="recommend-cover">
|
|
<img v-if="rw.coverUrl" :src="rw.coverUrl" :alt="rw.title" />
|
|
<div v-else class="recommend-cover-empty"><picture-outlined /></div>
|
|
</div>
|
|
<div class="recommend-info">
|
|
<span class="recommend-title">{{ rw.title }}</span>
|
|
<span class="recommend-author">{{ rw.creator?.nickname }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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
|
|
:class="['like-btn', { liked: likedSet.has(work.id) }]"
|
|
@click.stop="handleLike(work)"
|
|
>
|
|
<heart-filled v-if="likedSet.has(work.id)" />
|
|
<heart-outlined v-else />
|
|
{{ 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, reactive, computed, onMounted } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { message } from 'ant-design-vue'
|
|
import { SearchOutlined, PictureOutlined, HeartOutlined, HeartFilled, EyeOutlined, FireOutlined } from '@ant-design/icons-vue'
|
|
import { publicGalleryApi, publicTagsApi, publicInteractionApi, type UserWork, type WorkTag } from '@/api/public'
|
|
|
|
const router = useRouter()
|
|
const works = ref<UserWork[]>([])
|
|
const hotTags = ref<WorkTag[]>([])
|
|
const recommendedWorks = ref<UserWork[]>([])
|
|
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 likedSet = reactive(new Set<number>())
|
|
|
|
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
|
const hasMore = computed(() => works.value.length < total.value)
|
|
|
|
const fetchTags = async () => {
|
|
try { hotTags.value = await publicTagsApi.hot() } catch { /* */ }
|
|
}
|
|
|
|
const fetchRecommended = async () => {
|
|
try { recommendedWorks.value = await publicGalleryApi.recommended() } catch { /* */ }
|
|
}
|
|
|
|
const fetchWorks = async (reset = false) => {
|
|
if (reset) { page.value = 1; works.value = []; likedSet.clear() }
|
|
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
|
|
// 已登录时批量查询点赞状态
|
|
if (isLoggedIn.value && res.list.length > 0) {
|
|
try {
|
|
const ids = res.list.map((w: any) => w.id)
|
|
const statuses = await publicInteractionApi.batchStatus(ids)
|
|
for (const [id, status] of Object.entries(statuses)) {
|
|
if ((status as any).liked) likedSet.add(Number(id))
|
|
}
|
|
} catch { /* 忽略 */ }
|
|
}
|
|
} catch { /* */ }
|
|
finally { loading.value = false }
|
|
}
|
|
|
|
const handleLike = async (work: any) => {
|
|
if (!isLoggedIn.value) { router.push('/p/login'); return }
|
|
const wasLiked = likedSet.has(work.id)
|
|
// 乐观更新
|
|
if (wasLiked) { likedSet.delete(work.id) } else { likedSet.add(work.id) }
|
|
work.likeCount = (work.likeCount || 0) + (wasLiked ? -1 : 1)
|
|
try {
|
|
const res = await publicInteractionApi.like(work.id)
|
|
if (res.liked) { likedSet.add(work.id) } else { likedSet.delete(work.id) }
|
|
work.likeCount = res.likeCount
|
|
} catch {
|
|
// 回滚
|
|
if (wasLiked) { likedSet.add(work.id) } else { likedSet.delete(work.id) }
|
|
work.likeCount = (work.likeCount || 0) + (wasLiked ? 1 : -1)
|
|
message.error('操作失败')
|
|
}
|
|
}
|
|
|
|
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(() => {
|
|
fetchRecommended()
|
|
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; }
|
|
}
|
|
}
|
|
|
|
// 推荐作品
|
|
.recommend-section {
|
|
margin-bottom: 16px;
|
|
|
|
.section-header {
|
|
margin-bottom: 10px;
|
|
.section-title {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
color: #1e1b4b;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
|
|
:deep(.anticon) { color: #f59e0b; }
|
|
}
|
|
}
|
|
}
|
|
|
|
.recommend-scroll {
|
|
display: flex;
|
|
gap: 10px;
|
|
overflow-x: auto;
|
|
padding-bottom: 8px;
|
|
-webkit-overflow-scrolling: touch;
|
|
&::-webkit-scrollbar { display: none; }
|
|
}
|
|
|
|
.recommend-card {
|
|
flex-shrink: 0;
|
|
width: 120px;
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
border: 1px solid rgba($primary, 0.06);
|
|
|
|
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); transform: translateY(-2px); }
|
|
|
|
.recommend-cover {
|
|
width: 120px; height: 160px; background: #f5f3ff;
|
|
img { width: 100%; height: 100%; object-fit: cover; }
|
|
.recommend-cover-empty { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 24px; color: #d1d5db; }
|
|
}
|
|
|
|
.recommend-info {
|
|
padding: 8px 10px;
|
|
display: flex; flex-direction: column; gap: 2px;
|
|
|
|
.recommend-title { font-size: 12px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.recommend-author { font-size: 10px; color: #9ca3af; }
|
|
}
|
|
}
|
|
|
|
.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; }
|
|
|
|
.like-btn {
|
|
cursor: pointer;
|
|
transition: color 0.2s;
|
|
&:hover { color: #ec4899; }
|
|
&.liked {
|
|
color: #ec4899;
|
|
:deep(.anticon) { animation: pop 0.3s ease; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.load-more {
|
|
text-align: center;
|
|
padding: 20px 0;
|
|
}
|
|
|
|
@keyframes pop {
|
|
0% { transform: scale(1); }
|
|
50% { transform: scale(1.3); }
|
|
100% { transform: scale(1); }
|
|
}
|
|
</style>
|