library-picturebook-activity/frontend/src/views/content/WorkManagement.vue

453 lines
21 KiB
Vue
Raw Normal View History

<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>