rank_backend/frontend/src/AdminPanel.vue
Qyir 91761b6754 添加短剧版权方认证页面
添加申请管理页面上传至TOS
2025-11-13 17:49:48 +08:00

1043 lines
26 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.

<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
const router = useRouter()
// 响应式数据
const rankingData = ref([])
const loading = ref(false)
const showEditModal = ref(false)
// 编辑表单数据
const editForm = reactive({
id: null,
mix_id: '',
title: '',
mix_name: '',
series_author: '',
Manufacturing_Field: '',
Copyright_field: '',
classification_type: '', // 新增:女频/玄等
release_date: '', // 新增:上线日期
play_vv: 0,
total_likes_formatted: '',
cover_image_url: '',
cover_backup_urls: [],
timeline_data: {
play_vv_change: 0,
play_vv_change_rate: 0
},
// 分类字段
isNovel: false,
isAnime: false,
isDrama: false,
// 评论总结字段
comments_summary: ''
})
// API基础URL
// const API_BASE_URL = 'http://159.75.150.210:8443/api' // 远程服务器
const API_BASE_URL = 'http://localhost:8443/api' // 本地服务器
// 格式化播放量
const formatPlayCount = (count) => {
if (!count) return '0'
if (count >= 100000000) {
return (count / 100000000).toFixed(1) + '亿'
} else if (count >= 10000) {
return (count / 10000).toFixed(1) + '万'
}
return count.toString()
}
// 获取排行榜数据
const fetchRankingData = async () => {
loading.value = true
try {
const response = await axios.get(`${API_BASE_URL}/rank/videos`, {
params: {
page: 1,
limit: 100,
sort: 'growth'
}
})
if (response.data.success) {
rankingData.value = response.data.data.map((item, index) => ({
...item,
id: item._id || item.id || index + 1 // 确保每个项目都有ID
}))
} else {
console.error('获取数据失败:', response.data.message)
rankingData.value = []
}
} catch (error) {
console.error('API调用失败:', error)
// 如果API失败使用模拟数据
rankingData.value = [
{
id: 1,
title: "测试视频1",
mix_name: "测试合集1",
series_author: "测试剧场1",
play_vv: 1234567,
total_likes_formatted: "12.3万",
cover_image_url: "https://via.placeholder.com/150",
timeline_data: {
play_vv_change: 50000,
play_vv_change_rate: 4.2
}
},
{
id: 2,
title: "测试视频2",
mix_name: "测试合集2",
series_author: "测试剧场2",
play_vv: 987654,
total_likes_formatted: "9.8万",
cover_image_url: "https://via.placeholder.com/150",
timeline_data: {
play_vv_change: 30000,
play_vv_change_rate: 3.1
}
}
]
} finally {
loading.value = false
}
}
// 编辑项目
const editItem = async (item) => {
editForm.id = item.id || item._id
editForm.mix_id = item.mix_id || ''
editForm.title = item.title || ''
editForm.mix_name = item.mix_name || ''
editForm.series_author = item.series_author || ''
editForm.Manufacturing_Field = item.Manufacturing_Field || ''
editForm.Copyright_field = item.Copyright_field || ''
editForm.classification_type = item.classification_type || '' // 新增
editForm.release_date = item.release_date || '' // 新增
editForm.play_vv = item.play_vv || 0
editForm.total_likes_formatted = item.total_likes_formatted || ''
editForm.cover_image_url = item.cover_image_url || ''
editForm.cover_backup_urls = item.cover_backup_urls || []
editForm.timeline_data = {
play_vv_change: item.timeline_data?.play_vv_change || 0,
play_vv_change_rate: item.timeline_data?.play_vv_change_rate || 0
}
editForm.comments_summary = item.comments_summary || ''
// 加载分类状态(优先使用 mix_id兼容 mix_name
await loadClassificationStatus(item.mix_id, item.mix_name)
showEditModal.value = true
}
// 加载分类状态
const loadClassificationStatus = async (mixId, mixName) => {
try {
const response = await axios.get(`${API_BASE_URL}/rank/get_content_classification`, {
params: { mix_id: mixId, mix_name: mixName }
})
if (response.data.success) {
const classifications = response.data.data.classification_status || response.data.data
editForm.isNovel = classifications.novel || false
editForm.isAnime = classifications.anime || false
editForm.isDrama = classifications.drama || false
}
} catch (error) {
console.error('加载分类状态失败:', error)
// 如果加载失败重置为false
editForm.isNovel = false
editForm.isAnime = false
editForm.isDrama = false
}
}
// 更新分类
const updateClassification = async (classificationType, isChecked) => {
if (!editForm.mix_id && !editForm.mix_name) {
alert('缺少短剧标识mix_id 或 mix_name')
return
}
// 如果是选中状态,需要先取消其他分类(实现互斥)
if (isChecked) {
// 先在前端更新状态,实现互斥效果
if (classificationType === 'novel') {
editForm.isAnime = false
editForm.isDrama = false
} else if (classificationType === 'anime') {
editForm.isNovel = false
editForm.isDrama = false
} else if (classificationType === 'drama') {
editForm.isNovel = false
editForm.isAnime = false
}
}
try {
const response = await axios.post(`${API_BASE_URL}/rank/update_content_classification`, {
mix_id: editForm.mix_id,
mix_name: editForm.mix_name,
classification_type: classificationType,
action: isChecked ? 'add' : 'remove',
exclusive: true // 添加互斥标志
})
if (response.data.success) {
console.log(`${classificationType}分类更新成功`)
// 如果后端返回了更新后的分类状态,使用后端数据
if (response.data.data && response.data.data.classification_status) {
const status = response.data.data.classification_status
editForm.isNovel = status.novel || false
editForm.isAnime = status.anime || false
editForm.isDrama = status.drama || false
}
} else {
alert(`分类更新失败: ${response.data.message}`)
// 恢复checkbox状态
await loadClassificationStatus(editForm.mix_id, editForm.mix_name)
}
} catch (error) {
console.error('分类更新失败:', error)
alert('分类更新失败,请检查网络连接')
// 恢复checkbox状态
await loadClassificationStatus(editForm.mix_id, editForm.mix_name)
}
}
// 清空评论总结(优先使用 mix_id
const clearCommentsSummary = async () => {
if (!confirm('确定要清空评论总结吗?清空后下次定时任务会重新生成。')) {
return
}
try {
const today = new Date().toISOString().split('T')[0]
const requestData = {
date: today
}
// 优先使用 mix_id备用 mix_name
if (editForm.mix_id) {
requestData.mix_id = editForm.mix_id
} else {
requestData.mix_name = editForm.mix_name
}
const response = await axios.post(`${API_BASE_URL}/rank/clear_comments_summary`, requestData)
if (response.data.success) {
editForm.comments_summary = ''
alert('评论总结已清空')
} else {
alert(`清空失败: ${response.data.message}`)
}
} catch (error) {
console.error('清空评论总结失败:', error)
alert('清空评论总结失败,请检查网络连接')
}
}
// 删除项目
const deleteItem = async (item) => {
if (!confirm(`确定要删除 "${item.title || item.mix_name}" 吗?`)) {
return
}
try {
const itemId = item.id || item._id
// 尝试调用后端API删除数据
try {
await axios.delete(`${API_BASE_URL}/rank/videos/${itemId}`)
// 重新获取最新数据,确保前端显示的是数据库中的最新数据
await fetchRankingData()
alert('删除成功!')
} catch (apiError) {
console.warn('API删除失败使用本地删除:', apiError)
// 从本地数据中删除
const index = rankingData.value.findIndex(i => (i.id || i._id) === itemId)
if (index > -1) {
rankingData.value.splice(index, 1)
}
alert('删除失败,但本地数据已更新。请检查网络连接。')
}
} catch (error) {
console.error('删除失败:', error)
alert('删除失败!')
}
}
// 保存编辑
const saveEdit = async () => {
try {
const updateData = {
mix_id: editForm.mix_id,
title: editForm.title,
mix_name: editForm.mix_name,
series_author: editForm.series_author,
Manufacturing_Field: editForm.Manufacturing_Field,
Copyright_field: editForm.Copyright_field,
classification_type: editForm.classification_type,
release_date: editForm.release_date,
play_vv: editForm.play_vv,
total_likes_formatted: editForm.total_likes_formatted,
cover_image_url: editForm.cover_image_url,
cover_backup_urls: editForm.cover_backup_urls,
timeline_data: editForm.timeline_data,
comments_summary: editForm.comments_summary
}
// 调用后端API更新数据
try {
const response = await axios.post(`${API_BASE_URL}/rank/update_drama_info`, updateData)
if (response.data.success) {
console.log('API更新成功:', response.data.message)
// 关闭编辑模态框
showEditModal.value = false
// 重新获取最新数据,确保前端显示的是数据库中的最新数据
await fetchRankingData()
alert('保存成功!')
} else {
throw new Error(response.data.message || '保存失败')
}
} catch (apiError) {
console.error('API更新失败:', apiError)
// 如果API失败仍然更新本地数据作为备用
const index = rankingData.value.findIndex(i => (i.id || i._id) === editForm.id)
if (index > -1) {
rankingData.value[index] = { ...rankingData.value[index], ...updateData }
}
showEditModal.value = false
alert('保存到服务器失败,但本地数据已更新。请检查网络连接。')
}
} catch (error) {
console.error('保存失败:', error)
alert('保存失败:' + error.message)
}
}
// 取消编辑
const cancelEdit = () => {
showEditModal.value = false
}
// 返回前端页面
const goBack = () => {
router.push('/')
}
// 跳转到认领申请管理页面
const goToClaimApplications = () => {
router.push('/admin/claim-applications')
}
// 页面加载时初始化
onMounted(() => {
fetchRankingData()
})
</script>
<template>
<div class="admin-panel">
<!-- 主容器 -->
<div class="main-container">
<!-- 顶部标题区域 -->
<div class="header-section">
<div class="title-wrapper">
<button class="back-btn" @click="goBack"></button>
<h1 class="main-title">AI棒榜 - 后台管理</h1>
</div>
<div class="header-actions">
<button class="btn btn-primary" @click="goToClaimApplications">
认领申请管理
</button>
<button class="btn btn-secondary" @click="fetchRankingData">
刷新数据
</button>
</div>
</div>
<!-- 管理内容区域 -->
<div class="admin-content">
<!-- 加载状态 -->
<div v-if="loading" class="loading">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
<!-- 数据表格 -->
<div v-else class="data-table">
<table class="table">
<thead>
<tr>
<th>排名</th>
<th>封面</th>
<th>剧名</th>
<th>剧场名</th>
<th>承制方</th>
<th>版权方</th>
<th>播放量</th>
<th>点赞数</th>
<th>增长量</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in rankingData" :key="item._id || index"
class="clickable-row"
@click="editItem(item)"
title="点击编辑此短剧">
<td class="rank-cell">
<span class="rank-badge" :class="getRankClass(index + 1)">
{{ index + 1 }}
</span>
</td>
<td class="image-cell">
<img :src="item.cover_image_url || 'https://via.placeholder.com/150'"
alt="封面"
class="cover-image"
@error="$event.target.src='https://via.placeholder.com/150'" />
</td>
<td class="title-cell">
<div class="title-text">{{ item.title || item.mix_name || '未知' }}</div>
</td>
<td>{{ item.series_author || '未知' }}</td>
<td>{{ item.Manufacturing_Field || '未知' }}</td>
<td>{{ item.Copyright_field || '未知' }}</td>
<td>{{ formatPlayCount(item.play_vv) }}</td>
<td>{{ item.total_likes_formatted || '0' }}</td>
<td class="growth-cell">
<span class="growth-value">
{{ formatPlayCount(item.timeline_data?.play_vv_change || 0) }}
</span>
</td>
<td class="action-cell" @click.stop>
<button class="btn btn-sm btn-delete" @click="deleteItem(item)">
删除
</button>
</td>
</tr>
</tbody>
</table>
<!-- 空状态 -->
<div v-if="rankingData.length === 0" class="empty-state">
<p>暂无数据</p>
</div>
</div>
</div>
</div>
<!-- 编辑模态框 -->
<div v-if="showEditModal" class="modal-overlay" @click="cancelEdit">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3>编辑项目</h3>
<button class="close-btn" @click="cancelEdit">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>剧名</label>
<input v-model="editForm.title" type="text" class="form-input" placeholder="请输入剧名" />
</div>
<div class="form-group">
<label>合集名</label>
<input v-model="editForm.mix_name" type="text" class="form-input" placeholder="请输入合集名" />
</div>
<div class="form-group">
<label>剧场名</label>
<input v-model="editForm.series_author" type="text" class="form-input" placeholder="请输入剧场名" />
</div>
<div class="form-section">
<h4 class="section-title">制作信息锁定字段</h4>
<div class="form-group">
<label>版权方</label>
<input v-model="editForm.Copyright_field" type="text" class="form-input" placeholder="请输入版权方(填写后锁定)" />
</div>
<div class="form-group">
<label>承制方</label>
<input v-model="editForm.Manufacturing_Field" type="text" class="form-input" placeholder="请输入承制方(填写后锁定)" />
</div>
</div>
<div class="form-section">
<h4 class="section-title">短剧详细信息锁定字段</h4>
<div class="form-group">
<label>类型/元素女频/玄等</label>
<input v-model="editForm.classification_type" type="text" class="form-input" placeholder="如:女频/现代(填写后锁定)" />
</div>
<div class="form-group">
<label>上线日期</label>
<input v-model="editForm.release_date" type="text" class="form-input" placeholder="如2025年1月1日填写后锁定" />
</div>
</div>
<div class="form-section">
<h4 class="section-title">数据信息</h4>
<div class="form-group">
<label>播放量</label>
<input v-model.number="editForm.play_vv" type="number" class="form-input" placeholder="请输入播放量" />
</div>
<div class="form-group">
<label>点赞数格式化</label>
<input v-model="editForm.total_likes_formatted" type="text" class="form-input" placeholder="如1.2万" />
</div>
<div class="form-group">
<label>增长量</label>
<input v-model.number="editForm.timeline_data.play_vv_change" type="number" class="form-input" placeholder="请输入增长量" />
</div>
</div>
<div class="form-section">
<h4 class="section-title">内容分类</h4>
<div class="classification-group">
<div class="classification-item">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.isNovel" @change="updateClassification('novel', editForm.isNovel)" />
<span class="checkbox-text">小说</span>
</label>
</div>
<div class="classification-item">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.isAnime" @change="updateClassification('anime', editForm.isAnime)" />
<span class="checkbox-text">动漫</span>
</label>
</div>
<div class="classification-item">
<label class="checkbox-label">
<input type="checkbox" v-model="editForm.isDrama" @change="updateClassification('drama', editForm.isDrama)" />
<span class="checkbox-text">短剧</span>
</label>
</div>
</div>
</div>
<!-- 评论总结区域 -->
<div class="form-section">
<h4 class="section-title">评论总结</h4>
<div class="form-group">
<label>评论总结内容</label>
<textarea
v-model="editForm.comments_summary"
class="form-input"
rows="6"
placeholder="评论总结内容(可手动编辑或由系统自动生成)"
style="resize: vertical;"
></textarea>
<button
v-if="editForm.comments_summary"
class="btn btn-sm btn-delete"
@click="clearCommentsSummary"
style="margin-top: 8px;"
>
清空评论总结
</button>
</div>
</div>
<div class="form-section">
<h4 class="section-title">其他信息</h4>
<div class="form-group">
<label>封面图片URL</label>
<input v-model="editForm.cover_image_url" type="url" class="form-input" placeholder="请输入封面图片URL" />
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="cancelEdit">取消</button>
<button class="btn btn-primary" @click="saveEdit">保存</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
methods: {
getRankClass(rank) {
if (rank === 1) return 'rank-first'
if (rank === 2) return 'rank-second'
if (rank === 3) return 'rank-third'
return 'rank-normal'
}
}
}
</script>
<style scoped>
/* 全局样式 */
.admin-panel {
min-height: 100vh;
background: #f5f5f5;
font-family: 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica, sans-serif;
}
/* 主容器 */
.main-container {
max-width: 428px;
margin: 0 auto;
background: #f5f5f5;
min-height: 100vh;
position: relative;
}
/* 顶部标题区域 */
.header-section {
padding: 20px 16px;
background: white;
border-bottom: 1px solid #e0e0e0;
}
.title-wrapper {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
position: relative;
}
.back-btn {
position: absolute;
left: 0;
background: none;
border: none;
font-size: 20px;
color: #4a90e2;
cursor: pointer;
padding: 8px;
border-radius: 4px;
transition: background-color 0.3s ease;
}
.back-btn:hover {
background: #f0f0f0;
}
.main-title {
font-size: 20px;
font-weight: bold;
color: #333;
margin: 0;
}
.header-actions {
display: flex;
gap: 8px;
justify-content: center;
}
/* 按钮样式 */
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
}
.btn-primary {
background: #4a90e2;
color: white;
}
.btn-primary:hover {
background: #357abd;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #545b62;
}
.btn-sm {
padding: 4px 8px;
font-size: 12px;
}
.btn-edit {
background: #28a745;
color: white;
margin-right: 4px;
}
.btn-edit:hover {
background: #218838;
}
.btn-delete {
background: #dc3545;
color: white;
}
.btn-delete:hover {
background: #c82333;
}
/* 管理内容区域 */
.admin-content {
padding: 16px;
}
/* 加载状态 */
.loading {
text-align: center;
padding: 40px 20px;
color: #666;
}
.loading-spinner {
width: 30px;
height: 30px;
border: 3px solid #f3f3f3;
border-top: 3px solid #4a90e2;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 16px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 数据表格 */
.data-table {
background: white;
border-radius: 8px;
overflow-x: auto;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.table {
width: 100%;
min-width: 1100px;
border-collapse: collapse;
font-size: 14px;
}
.table th {
background: #f8f9fa;
padding: 16px 12px;
text-align: left;
font-weight: 600;
color: #333;
border-bottom: 2px solid #e0e0e0;
white-space: nowrap;
}
.table td {
padding: 16px 12px;
border-bottom: 1px solid #e0e0e0;
vertical-align: middle;
}
.table tr:hover {
background: #f8f9fa;
}
.clickable-row {
cursor: pointer;
transition: background-color 0.2s ease;
}
.clickable-row:hover {
background: #e3f2fd !important;
}
/* 表格单元格样式 */
.rank-cell {
text-align: center;
width: 60px;
}
.title-cell {
width: 200px;
max-width: 200px;
}
.title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank-badge {
display: inline-block;
width: 24px;
height: 24px;
border-radius: 50%;
color: white;
font-weight: bold;
font-size: 12px;
line-height: 24px;
text-align: center;
}
.rank-badge.rank-first {
background: linear-gradient(135deg, #ffd700, #ffed4e);
color: #333;
}
.rank-badge.rank-second {
background: linear-gradient(135deg, #c0c0c0, #e8e8e8);
color: #333;
}
.rank-badge.rank-third {
background: linear-gradient(135deg, #cd7f32, #daa520);
}
.rank-badge.rank-normal {
background: #6b7280;
}
.image-cell {
width: 80px;
text-align: center;
}
.cover-image {
width: 60px;
height: 80px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #e0e0e0;
}
.title-cell {
max-width: 80px;
}
.title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.growth-cell {
text-align: center;
}
.growth-value {
color: #dc3545;
font-weight: 600;
}
.action-cell {
width: 80px;
min-width: 80px;
text-align: center;
white-space: nowrap;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 40px 20px;
color: #666;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: white;
border-radius: 12px;
max-width: 350px;
width: 85%;
max-height: 85vh;
overflow: hidden;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
}
.modal-header {
background: #4a90e2;
color: white;
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background 0.3s ease;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.modal-body {
padding: 20px;
max-height: 400px;
overflow-y: auto;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: #333;
}
.form-section {
margin-bottom: 24px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #4a90e2;
}
.section-title {
margin: 0 0 16px 0;
font-size: 14px;
font-weight: 600;
color: #4a90e2;
font-size: 14px;
}
.form-input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
box-sizing: border-box;
}
.form-input:focus {
outline: none;
border-color: #4a90e2;
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
}
/* 分类选择器样式 */
.classification-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.classification-item {
display: flex;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
cursor: pointer;
font-size: 14px;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
margin-right: 8px;
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #4a90e2;
}
.checkbox-text {
color: #333;
font-weight: 500;
}
.checkbox-label:hover .checkbox-text {
color: #4a90e2;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid #e0e0e0;
display: flex;
gap: 12px;
justify-content: flex-end;
}
/* 响应式设计 */
@media (max-width: 480px) {
.main-container {
max-width: 100%;
}
.header-section {
padding: 16px 12px;
}
.admin-content {
padding: 12px;
}
.table {
font-size: 11px;
}
.table th,
.table td {
padding: 8px 4px;
}
}
</style>