library-picturebook-activity/frontend/src/views/content/WorkManagement.vue
aid f246b38fc1 Day5: 超管端内容管理模块全面优化 + 广场推荐作品展示
作品审核:
- 批量通过/批量拒绝 + 撤销审核机制
- 默认筛选待审核,表格加描述预览+审核时间列
- 详情Drawer加上一个/下一个导航,审核后自动跳下一个
- 操作日志时间线展示,筛选下拉自动查询

作品管理:
- 修复筛选/排序失效,新增推荐中筛选
- 下架改为弹窗选择原因,取消推荐二次确认
- 详情Drawer补全描述/标签/操作按钮/操作日志
- 统计卡片可点击筛选,下架自动取消推荐

标签管理:
- 按分类分组卡片式展示,分类改为下拉选择
- 新增标签颜色字段(预设色+自定义)
- 上移/下移排序按钮,使用次数可点击跳转作品管理
- 新增/编辑时实时预览用户端标签效果

广场推荐:
- 新增推荐作品列表接口 GET /public/gallery/recommended
- 广场顶部新增「编辑推荐」横向滚动栏

文档更新:内容管理设计文档补充实施记录,UGC开发计划P1-1标记已完成

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:21:21 +08:00

453 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="work-mgmt-page">
<a-card class="title-card">
<template #title>作品管理</template>
</a-card>
<!-- 统计卡片可点击筛选 -->
<div class="stats-row">
<div
v-for="item in statsItems"
:key="item.key"
:class="['stat-card', { active: activeStatKey === item.key }]"
@click="handleStatClick(item.key)"
>
<div class="stat-icon" :style="{ background: item.bgColor }">
<component :is="item.icon" :style="{ color: item.color }" />
</div>
<div class="stat-info">
<span class="stat-count">{{ item.value }}</span>
<span class="stat-label">{{ item.label }}</span>
</div>
</div>
</div>
<!-- 筛选 -->
<div class="filter-bar">
<a-form layout="inline" @finish="handleSearch">
<a-form-item label="作品/作者">
<a-input v-model:value="keyword" placeholder="作品名称或作者" allow-clear style="width: 180px" />
</a-form-item>
<a-form-item label="状态">
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option value="">全部</a-select-option>
<a-select-option value="published">正常</a-select-option>
<a-select-option value="taken_down">已下架</a-select-option>
<a-select-option value="recommended">推荐中</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="排序">
<a-select v-model:value="sortBy" style="width: 120px" @change="handleSearch">
<a-select-option value="latest">最新发布</a-select-option>
<a-select-option value="hot">最多点赞</a-select-option>
<a-select-option value="views">最多浏览</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" html-type="submit"><template #icon><SearchOutlined /></template>搜索</a-button>
<a-button @click="handleReset"><template #icon><ReloadOutlined /></template>重置</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
<!-- 表格 -->
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination" row-key="id" @change="handleTableChange" class="data-table">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}</template>
<template v-else-if="column.key === 'cover'">
<img v-if="record.coverUrl" :src="record.coverUrl" class="cover-thumb" />
<div v-else class="cover-empty">无</div>
</template>
<template v-else-if="column.key === 'titleDesc'">
<div class="title-cell">
<span class="work-title">{{ record.title }}</span>
<span v-if="record.description" class="work-desc">{{ record.description }}</span>
</div>
</template>
<template v-else-if="column.key === 'author'">{{ record.creator?.nickname || '-' }}</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'published' ? 'green' : 'red'">
{{ record.status === 'published' ? '正常' : '已下架' }}
</a-tag>
<a-tag v-if="record.isRecommended" color="blue">推荐</a-tag>
</template>
<template v-else-if="column.key === 'publishTime'">{{ formatDate(record.publishTime) }}</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button type="link" size="small" @click="showDetail(record.id)">查看</a-button>
<a-button type="link" size="small" @click="handleRecommend(record)">
{{ record.isRecommended ? '取消推荐' : '推荐' }}
</a-button>
<a-button v-if="record.status === 'published'" type="link" danger size="small" @click="openTakedown(record)">下架</a-button>
<a-button v-else type="link" size="small" style="color: #10b981" @click="handleRestore(record)">恢复</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 下架弹窗(填写原因) -->
<a-modal v-model:open="takedownVisible" title="下架作品" @ok="handleTakedown" :confirm-loading="takedownLoading">
<p style="margin-bottom: 12px; color: #6b7280; font-size: 13px">
下架后作品「{{ takedownTarget?.title }}」将不再公开展示,请填写下架原因:
</p>
<a-radio-group v-model:value="takedownReason" style="display: flex; flex-direction: column; gap: 8px">
<a-radio value="含不适宜内容">含不适宜内容</a-radio>
<a-radio value="涉嫌抄袭/侵权">涉嫌抄袭/侵权</a-radio>
<a-radio value="用户投诉/举报">用户投诉/举报</a-radio>
<a-radio value="违反平台规范">违反平台规范</a-radio>
<a-radio value="other">其他</a-radio>
</a-radio-group>
<a-input v-if="takedownReason === 'other'" v-model:value="takedownCustom" placeholder="请输入下架原因" style="margin-top: 12px" />
</a-modal>
<!-- 详情 Drawer -->
<a-drawer v-model:open="detailVisible" title="作品详情" :width="580" :destroy-on-close="true">
<template v-if="detailData">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="标题" :span="2">{{ detailData.title }}</a-descriptions-item>
<a-descriptions-item label="作者">{{ detailData.creator?.nickname }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="detailData.status === 'published' ? 'green' : 'red'">{{ detailData.status === 'published' ? '正常' : '已下架' }}</a-tag>
<a-tag v-if="detailData.isRecommended" color="blue">推荐</a-tag>
</a-descriptions-item>
<a-descriptions-item label="浏览">{{ detailData.viewCount || 0 }}</a-descriptions-item>
<a-descriptions-item label="点赞">{{ detailData.likeCount || 0 }}</a-descriptions-item>
<a-descriptions-item label="收藏">{{ detailData.favoriteCount || 0 }}</a-descriptions-item>
<a-descriptions-item label="发布时间">{{ formatDate(detailData.publishTime) }}</a-descriptions-item>
</a-descriptions>
<!-- 作品描述 -->
<div v-if="detailData.description" class="detail-section">
<h4>作品简介</h4>
<p class="detail-desc">{{ detailData.description }}</p>
</div>
<!-- 标签 -->
<div v-if="detailData.tags?.length" class="detail-section">
<h4>标签</h4>
<div style="display: flex; gap: 6px; flex-wrap: wrap">
<a-tag v-for="t in detailData.tags" :key="t.tag?.id" color="purple">{{ t.tag?.name }}</a-tag>
</div>
</div>
<!-- 绘本翻页预览 -->
<div v-if="detailData.pages?.length" class="preview-section">
<h4>绘本内容预览</h4>
<div class="page-preview">
<img v-if="detailData.pages[previewPage]?.imageUrl" :src="detailData.pages[previewPage].imageUrl" class="preview-img" />
<p v-if="detailData.pages[previewPage]?.text" class="preview-text">{{ detailData.pages[previewPage].text }}</p>
<div class="preview-nav">
<a-button :disabled="previewPage === 0" size="small" @click="previewPage--">上一页</a-button>
<span>{{ previewPage + 1 }} / {{ detailData.pages.length }}</span>
<a-button :disabled="previewPage === detailData.pages.length - 1" size="small" @click="previewPage++">下一页</a-button>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="drawer-actions">
<a-space style="width: 100%">
<a-button
v-if="detailData.status === 'published'"
@click="handleRecommendInDrawer"
:style="{ flex: 1, color: detailData.isRecommended ? undefined : '#1677ff', borderColor: detailData.isRecommended ? undefined : '#1677ff' }"
>
{{ detailData.isRecommended ? '取消推荐' : '设为推荐' }}
</a-button>
<a-button
v-if="detailData.status === 'published'"
danger
style="flex: 1"
@click="openTakedown(detailData); detailVisible = false"
>
下架
</a-button>
<a-button
v-if="detailData.status === 'taken_down'"
type="primary"
style="flex: 1"
@click="handleRestoreInDrawer"
>
恢复上架
</a-button>
</a-space>
</div>
<!-- 操作日志 -->
<div class="log-section">
<h4>操作日志</h4>
<div v-if="detailLogs.length === 0" class="log-empty">暂无操作记录</div>
<a-timeline v-else>
<a-timeline-item
v-for="log in detailLogs"
:key="log.id"
:color="logActionColor[log.action] || 'gray'"
>
<div class="log-item">
<span class="log-action">{{ logActionText[log.action] || log.action }}</span>
<span class="log-operator">{{ log.operator?.nickname || '系统' }}</span>
<span class="log-time">{{ formatDate(log.createTime) }}</span>
</div>
<div v-if="log.reason" class="log-reason">原因:{{ log.reason }}</div>
<div v-if="log.note" class="log-note">备注:{{ log.note }}</div>
</a-timeline-item>
</a-timeline>
</div>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
SearchOutlined, ReloadOutlined,
AppstoreOutlined, PlusCircleOutlined, EyeOutlined, StopOutlined,
} from '@ant-design/icons-vue'
import request from '@/utils/request'
import dayjs from 'dayjs'
const loading = ref(false)
const dataSource = ref<any[]>([])
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true, showTotal: (t: number) => `共 ${t} 条` })
const keyword = ref('')
const sortBy = ref('latest')
const filterStatus = ref('')
const activeStatKey = ref('')
// 统计
const statsRaw = ref({ total: 0, todayNew: 0, totalViews: 0, takenDown: 0 })
const statsItems = computed(() => [
{ key: 'total', label: '总作品数', value: statsRaw.value.total, icon: AppstoreOutlined, color: '#6366f1', bgColor: 'rgba(99,102,241,0.1)' },
{ key: 'todayNew', label: '今日新增', value: statsRaw.value.todayNew, icon: PlusCircleOutlined, color: '#10b981', bgColor: 'rgba(16,185,129,0.1)' },
{ key: 'totalViews', label: '累计浏览', value: statsRaw.value.totalViews, icon: EyeOutlined, color: '#f59e0b', bgColor: 'rgba(245,158,11,0.1)' },
{ key: 'takenDown', label: '已下架', value: statsRaw.value.takenDown, icon: StopOutlined, color: '#ef4444', bgColor: 'rgba(239,68,68,0.1)' },
])
// 下架弹窗
const takedownVisible = ref(false)
const takedownLoading = ref(false)
const takedownTarget = ref<any>(null)
const takedownReason = ref('')
const takedownCustom = ref('')
// 详情 + 日志
const detailVisible = ref(false)
const detailData = ref<any>(null)
const detailLogs = ref<any[]>([])
const previewPage = ref(0)
const logActionText: Record<string, string> = { approve: '审核通过', reject: '审核拒绝', takedown: '下架', restore: '恢复', revoke: '撤销审核' }
const logActionColor: Record<string, string> = { approve: 'green', reject: 'red', takedown: 'gray', restore: 'blue', revoke: 'orange' }
// #6 表格加描述预览列
const columns = [
{ title: '序号', key: 'index', width: 50 },
{ title: '封面', key: 'cover', width: 70 },
{ title: '作品名称', key: 'titleDesc', width: 220 },
{ title: '作者', key: 'author', width: 90 },
{ title: '浏览', dataIndex: 'viewCount', key: 'viewCount', width: 65 },
{ title: '点赞', dataIndex: 'likeCount', key: 'likeCount', width: 65 },
{ title: '收藏', dataIndex: 'favoriteCount', key: 'favoriteCount', width: 65 },
{ title: '状态', key: 'status', width: 110 },
{ title: '发布时间', key: 'publishTime', width: 130 },
{ title: '操作', key: 'action', width: 210, fixed: 'right' as const },
]
const formatDate = (d: string) => d ? dayjs(d).format('YYYY-MM-DD HH:mm') : '-'
const fetchStats = async () => {
try {
statsRaw.value = await request.get('/content-review/management/stats') as any
} catch { /* */ }
}
const fetchList = async () => {
loading.value = true
try {
const isRecommendedFilter = filterStatus.value === 'recommended'
const res: any = await request.get('/content-review/works', {
params: {
page: pagination.current,
pageSize: pagination.pageSize,
status: isRecommendedFilter ? 'published' : (filterStatus.value || 'published,taken_down'),
keyword: keyword.value || undefined,
sortBy: sortBy.value,
isRecommended: isRecommendedFilter ? '1' : undefined,
},
})
dataSource.value = res.list
pagination.total = res.total
} catch { message.error('获取失败') }
finally { loading.value = false }
}
// #7 统计卡片点击筛选
const handleStatClick = (key: string) => {
if (activeStatKey.value === key) {
activeStatKey.value = ''
filterStatus.value = ''
} else {
activeStatKey.value = key
if (key === 'takenDown') filterStatus.value = 'taken_down'
else if (key === 'total' || key === 'todayNew') filterStatus.value = 'published'
else filterStatus.value = ''
}
pagination.current = 1
fetchList()
}
// #1 筛选自动查询
const handleSearch = () => { activeStatKey.value = ''; pagination.current = 1; fetchList() }
const handleReset = () => { keyword.value = ''; sortBy.value = 'latest'; filterStatus.value = ''; activeStatKey.value = ''; pagination.current = 1; fetchList(); fetchStats() }
const handleTableChange = (pag: any) => { pagination.current = pag.current; pagination.pageSize = pag.pageSize; fetchList() }
// 详情
const showDetail = async (id: number) => {
previewPage.value = 0
detailLogs.value = []
try {
const [work, logs]: any[] = await Promise.all([
request.get(`/content-review/works/${id}`),
request.get('/content-review/logs', { params: { workId: id, pageSize: 50 } }),
])
detailData.value = work
detailLogs.value = logs.list || []
detailVisible.value = true
} catch { message.error('获取详情失败') }
}
// #3 推荐/取消推荐(取消推荐时二次确认)
const handleRecommend = async (record: any) => {
if (record.isRecommended) {
Modal.confirm({
title: '确定取消推荐?',
content: `作品「${record.title}」将不再显示在推荐位`,
onOk: async () => {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已取消推荐'); fetchList() }
catch { message.error('操作失败') }
},
})
} else {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已推荐'); fetchList() }
catch { message.error('操作失败') }
}
}
// #4 详情内推荐操作
const handleRecommendInDrawer = async () => {
const record = detailData.value
if (!record) return
if (record.isRecommended) {
Modal.confirm({
title: '确定取消推荐?',
onOk: async () => {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已取消推荐'); fetchList(); showDetail(record.id) }
catch { message.error('操作失败') }
},
})
} else {
try { await request.post(`/content-review/works/${record.id}/recommend`); message.success('已推荐'); fetchList(); showDetail(record.id) }
catch { message.error('操作失败') }
}
}
// #2 下架填写原因
const openTakedown = (record: any) => {
takedownTarget.value = record
takedownReason.value = ''
takedownCustom.value = ''
takedownVisible.value = true
}
const handleTakedown = async () => {
const reason = takedownReason.value === 'other' ? takedownCustom.value : takedownReason.value
if (!reason) { message.warning('请选择下架原因'); return }
takedownLoading.value = true
try {
await request.post(`/content-review/works/${takedownTarget.value.id}/takedown`, { reason })
message.success('已下架')
takedownVisible.value = false
fetchList(); fetchStats()
} catch { message.error('操作失败') }
finally { takedownLoading.value = false }
}
const handleRestore = async (record: any) => {
try { await request.post(`/content-review/works/${record.id}/restore`); message.success('已恢复'); fetchList(); fetchStats() }
catch { message.error('操作失败') }
}
// #4 详情内恢复操作
const handleRestoreInDrawer = async () => {
const id = detailData.value?.id
if (!id) return
try { await request.post(`/content-review/works/${id}/restore`); message.success('已恢复'); fetchList(); fetchStats(); showDetail(id) }
catch { message.error('操作失败') }
}
onMounted(() => { fetchStats(); fetchList() })
</script>
<style scoped lang="scss">
$primary: #6366f1;
.title-card { margin-bottom: 16px; border: none; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); :deep(.ant-card-head) { border-bottom: none; .ant-card-head-title { font-size: 18px; font-weight: 600; } } :deep(.ant-card-body) { padding: 0; } }
// #7 统计卡片可点击
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; }
.stat-card { flex: 1; display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); cursor: pointer; border: 2px solid transparent; transition: all 0.2s;
&:hover { box-shadow: 0 4px 16px rgba($primary, 0.12); } &.active { border-color: $primary; background: rgba($primary, 0.02); }
.stat-icon { width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
.stat-info { display: flex; flex-direction: column; .stat-count { font-size: 18px; font-weight: 700; color: #1e1b4b; line-height: 1.2; } .stat-label { font-size: 12px; color: #9ca3af; } }
}
.filter-bar { padding: 20px 24px; background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); margin-bottom: 16px; }
.data-table { :deep(.ant-table-wrapper) { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow: hidden; .ant-table-thead > tr > th { background: #fafafa; font-weight: 600; } .ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); } .ant-table-pagination { padding: 16px; margin: 0; } } }
// #6 标题+描述
.title-cell {
display: flex; flex-direction: column; gap: 2px;
.work-title { font-size: 13px; font-weight: 600; color: #1e1b4b; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-desc { font-size: 11px; color: #9ca3af; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
}
.cover-thumb { width: 48px; height: 64px; object-fit: cover; border-radius: 6px; }
.cover-empty { width: 48px; height: 64px; background: #f3f4f6; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #d1d5db; }
// 详情区块
.detail-section {
margin-top: 16px;
h4 { font-size: 13px; font-weight: 600; color: #374151; margin: 0 0 8px; }
.detail-desc { font-size: 13px; color: #6b7280; line-height: 1.6; margin: 0; }
}
.preview-section { margin-top: 20px; h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; } }
.page-preview {
.preview-img { width: 100%; max-height: 300px; object-fit: contain; border-radius: 8px; margin-bottom: 8px; }
.preview-text { font-size: 13px; color: #374151; line-height: 1.6; padding: 8px 0; }
.preview-nav { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; span { font-size: 12px; color: #6b7280; } }
}
// #4 详情内操作按钮
.drawer-actions {
margin-top: 20px; padding-top: 16px; border-top: 1px solid #f0ecf9;
:deep(.ant-space) { display: flex; .ant-space-item { flex: 1; .ant-btn { width: 100%; } } }
}
// 操作日志
.log-section {
margin-top: 24px;
h4 { font-size: 14px; font-weight: 600; color: #1e1b4b; margin: 0 0 16px; padding-bottom: 8px; border-bottom: 1px solid #f0ecf9; }
.log-empty { font-size: 13px; color: #9ca3af; text-align: center; padding: 20px 0; }
}
.log-item {
display: flex; align-items: center; gap: 8px;
.log-action { font-size: 13px; font-weight: 600; color: #1e1b4b; }
.log-operator { font-size: 12px; color: #6b7280; }
.log-time { font-size: 11px; color: #9ca3af; margin-left: auto; }
}
.log-reason, .log-note { font-size: 12px; color: #6b7280; margin-top: 2px; }
</style>