2025-10-26 22:41:53 +08:00

734 lines
16 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, onMounted, computed } from 'vue'
import axios from 'axios'
// 响应式数据
const currentTab = ref('ranking') // 'ranking' 或 'news'
const rankingData = ref([])
const loading = ref(false)
const selectedDate = ref('')
const currentPage = ref(1)
const totalPages = ref(1)
const updateTime = ref('') // 添加更新时间字段
const showDatePicker = ref(false) // 控制日期选择器显示
const dateOptions = ref([]) // 日期选项列表
// 初始化日期为今天
const initDate = () => {
const today = new Date()
selectedDate.value = today.toISOString().split('T')[0]
generateDateOptions()
}
// 生成日期选项今天和往前7天
const generateDateOptions = () => {
const options = []
const today = new Date()
for (let i = 0; i < 8; i++) {
const date = new Date(today)
date.setDate(today.getDate() - i)
const value = date.toISOString().split('T')[0]
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekday = weekdays[date.getDay()]
let label = ''
if (i === 0) {
label = '今天'
} else if (i === 1) {
label = '昨天'
} else {
label = `${i}天前`
}
const display = `${date.getMonth() + 1}${date.getDate()}${weekday}`
options.push({
value,
label,
display
})
}
dateOptions.value = options
}
// 获取排行榜数据
const fetchRankingData = async () => {
loading.value = true
try {
const response = await axios.get('http://localhost:5001/api/rank/videos', {
params: {
page: currentPage.value,
limit: 20,
sort: 'growth',
start_date: selectedDate.value,
end_date: selectedDate.value
}
})
if (response.data.success) {
rankingData.value = response.data.data
totalPages.value = response.data.pagination.pages
// 获取后端返回的更新时间
updateTime.value = response.data.update_time || ''
} else {
console.error('获取数据失败:', response.data.message)
rankingData.value = []
}
} catch (error) {
console.error('API调用失败:', error)
rankingData.value = []
} finally {
loading.value = false
}
}
// 获取当前时间
const getCurrentTime = () => {
const now = new Date()
return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}`
}
const getRankClass = (rank) => {
if (rank === 1) return 'rank-first'
if (rank === 2) return 'rank-second'
if (rank === 3) return 'rank-third'
return 'rank-normal'
}
// 格式化播放量
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 formatGrowth = (item) => {
const timelineData = item.timeline_data || {}
const change = timelineData.play_vv_change || 0
const changeRate = timelineData.play_vv_change_rate || 0
if (change > 0) {
return `${formatPlayCount(change)}`
}
return '暂无数据'
}
// 切换标签页
const switchTab = (tab) => {
currentTab.value = tab
if (tab === 'ranking') {
fetchRankingData()
}
}
// 获取图片源地址
const getImageSrc = (item) => {
// 优先使用 cover_image_url
if (item.cover_image_url) {
return item.cover_image_url
}
// 如果有备用链接,使用第一个
if (item.cover_backup_urls && item.cover_backup_urls.length > 0) {
return item.cover_backup_urls[0]
}
// 最后使用占位符
return '/placeholder-poster.svg'
}
// 处理图片加载错误
const handleImageError = (event, item) => {
const img = event.target
console.log('图片加载失败:', img.src, '视频:', item.title)
// 如果当前显示的是主链接,尝试备用链接
if (img.src === item.cover_image_url && item.cover_backup_urls && item.cover_backup_urls.length > 0) {
console.log('尝试备用链接:', item.cover_backup_urls[0])
// 尝试第一个备用链接
img.src = item.cover_backup_urls[0]
return
}
// 如果当前显示的是第一个备用链接,尝试其他备用链接
if (item.cover_backup_urls && item.cover_backup_urls.length > 1) {
const currentIndex = item.cover_backup_urls.indexOf(img.src)
if (currentIndex >= 0 && currentIndex < item.cover_backup_urls.length - 1) {
console.log('尝试下一个备用链接:', item.cover_backup_urls[currentIndex + 1])
img.src = item.cover_backup_urls[currentIndex + 1]
return
}
}
// 所有链接都失败,使用占位符
console.log('使用占位符图片')
img.src = '/placeholder-poster.svg'
}
// 日期改变处理
const onDateChange = () => {
currentPage.value = 1
fetchRankingData()
}
// 格式化显示日期
const formatDisplayDate = (dateStr) => {
if (!dateStr) return '选择日期'
const date = new Date(dateStr)
const today = new Date()
const diffTime = today.getTime() - date.getTime()
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24))
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`
}
// 切换日期选择器显示状态
const toggleDatePicker = () => {
showDatePicker.value = !showDatePicker.value
}
// 关闭日期选择器
const closeDatePicker = () => {
showDatePicker.value = false
}
// 选择日期
const selectDate = (dateValue) => {
selectedDate.value = dateValue
showDatePicker.value = false
onDateChange()
}
// 页面加载时初始化
onMounted(() => {
initDate()
fetchRankingData()
})
</script>
<template>
<div class="app">
<!-- 主内容区域 -->
<div class="main-content">
<!-- 排行榜页面 -->
<div v-if="currentTab === 'ranking'" class="ranking-page">
<!-- 标题 -->
<div class="header">
<div class="title-container">
<i class="bi bi-stars lightning-icon"></i>
<h1 class="title">抖音AI短剧榜</h1>
<i class="bi bi-stars lightning-icon"></i>
</div>
<div class="update-time">
基于实时热度排行 {{ updateTime || getCurrentTime() }}更新
<span class="refresh-icon">🔄</span>
</div>
</div>
<!-- 自定义日期选择器 -->
<div class="custom-date-selector">
<div class="date-display" @click="toggleDatePicker">
<span class="date-text">{{ formatDisplayDate(selectedDate) }}<i class="bi bi-chevron-compact-right"></i></span>
</div>
<!-- 日期选择弹窗 -->
<div v-if="showDatePicker" class="date-picker-overlay" @click="closeDatePicker">
<div class="date-picker-popup" @click.stop>
<div class="date-picker-header">
<h3>选择日期</h3>
<button class="close-btn" @click="closeDatePicker">×</button>
</div>
<div class="date-list">
<div
v-for="date in dateOptions"
:key="date.value"
class="date-option"
:class="{ active: selectedDate === date.value }"
@click="selectDate(date.value)"
>
<span class="date-label">{{ date.label }}</span>
<span class="date-value">{{ date.display }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
<!-- 榜单内容 -->
<div v-else class="ranking-list">
<div
v-for="(item, index) in rankingData"
:key="item._id || index"
class="ranking-item"
>
<!-- 排名 -->
<div class="rank-number" :class="getRankClass(index + 1)">
{{ index + 1 }}
</div>
<!-- 海报 -->
<div class="poster">
<img
:src="getImageSrc(item)"
:alt="item.title || item.mix_name"
@error="handleImageError($event, item)"
class="poster-img"
/>
</div>
<!-- 内容信息 -->
<div class="content-info">
<!-- 剧名 -->
<h3 class="drama-name">{{ item.title || item.mix_name || '未知剧名' }}</h3>
<!-- 当前播放量 -->
<div class="play-count">
<span class="play-label">总播放量</span>
<span class="play-value">{{ formatPlayCount(item.play_vv) }}</span>
</div>
</div>
<!-- 增长数据 -->
<div class="growth-data">
<i class="bi bi-fire"></i>
<span class="growth-number">{{ formatGrowth(item) }}</span>
</div>
</div>
<!-- 空状态 -->
<div v-if="rankingData.length === 0" class="empty-state">
<p>暂无排行榜数据</p>
</div>
</div>
</div>
<!-- 资讯页面占位 -->
<div v-else class="news-page">
<div class="header">
<h1 class="title">资讯中心</h1>
</div>
<div class="coming-soon">
<p>资讯功能即将上线...</p>
</div>
</div>
</div>
<!-- 底部导航 -->
<div class="bottom-nav">
<div
class="nav-item"
:class="{ active: currentTab === 'news' }"
@click="switchTab('news')"
>
<i class="bi bi-newspaper"></i>
<span class="nav-text">资讯</span>
</div>
<div
class="nav-item"
:class="{ active: currentTab === 'ranking' }"
@click="switchTab('ranking')"
>
<i class="bi bi-list-stars"></i>
<span class="nav-text">排行榜</span>
</div>
</div>
</div>
</template>
<style scoped>
/* 全局样式 */
* {
box-sizing: border-box;
}
.app {
min-height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding-bottom: 80px; /* 为底部导航留出空间 */
color: white;
padding: 0;
margin: 0;
}
/* 主内容区域 */
.main-content {
padding: 20px 16px 80px;
max-width: 100%;
}
/* 标题区域 */
.header {
text-align: center;
}
.title-container {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 8px;
}
.lightning-icon {
font-size: 20px;
color: #ffd700;
}
.title {
color: #555;
font-size: 24px;
font-weight: bold;
margin: 0;
}
.update-time {
font-size: 12px;
color: #999;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
}
.refresh-icon {
font-size: 12px;
color: #4CAF50;
}
/* 自定义日期选择器 */
.custom-date-selector {
padding: 16px 0 8px;
}
.date-display {
display: flex;
align-items: center;
justify-content: flex-end;
cursor: pointer;
}
.date-text {
font-size: 12px;
font-weight: 500;
color: #999;
}
/* 日期选择弹窗 */
.date-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-end;
z-index: 1000;
animation: fadeIn 0.3s ease;
}
.date-picker-popup {
width: 100%;
max-height: 70vh;
background: white;
border-radius: 20px 20px 0 0;
animation: slideUp 0.3s ease;
overflow: hidden;
}
.date-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid #eee;
background: #f8f9fa;
}
.date-picker-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
color: #666;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: all 0.2s ease;
}
.close-btn:hover {
background: #e9ecef;
color: #333;
}
.date-list {
max-height: 400px;
overflow-y: auto;
}
.date-option {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
cursor: pointer;
transition: all 0.2s ease;
border-bottom: 1px solid #f0f0f0;
}
.date-option:hover {
background: #f8f9fa;
}
.date-option.active {
background: #e3f2fd;
border-left: 4px solid #2196f3;
}
.date-option.active .date-label {
color: #2196f3;
font-weight: 600;
}
.date-label {
font-size: 16px;
font-weight: 500;
color: #333;
}
.date-value {
font-size: 14px;
color: #666;
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}
/* 加载状态 */
.loading {
text-align: center;
padding: 40px 20px;
color: white;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255,255,255,0.3);
border-top: 4px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 15px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 榜单列表 */
.ranking-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.ranking-item {
border-radius: 16px;
display: flex;
align-items: flex-start;
gap: 15px;
padding: 4px;
}
/* 排名数字 */
.rank-number {
color: #333;
width: 16px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 16px;
flex-shrink: 0;
}
/* 前三名特殊样式 */
.rank-first {
color: #ffd700;
font-size: 24px;
}
.rank-second {
color: #afe3f6;
font-size: 24px;
}
.rank-third {
color: #cd7f32;
font-size: 24px;
}
.rank-normal {
color: #666;
}
/* 海报 */
.poster {
flex-shrink: 0;
}
.poster-img {
width: 72px;
object-fit: cover;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
/* 内容信息 */
.content-info {
flex: 1;
min-width: 0;
}
.drama-name {
font-size: 16px;
font-weight: bold;
color: #555;
margin: 0 0 8px 0;
line-height: 1.3;
}
.growth-data {
color: #e74c3c;
font-size: 14px;
}
.growth-info, .play-count {
display: flex;
align-items: center;
margin-bottom: 6px;
font-size: 13px;
}
.growth-label, .play-label {
color: #7f8c8d;
margin-right: 5px;
}
.growth-value {
color: #e74c3c;
font-weight: 600;
}
.play-value {
color: #3498db;
font-weight: 600;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 60px 20px;
color: rgba(255,255,255,0.8);
font-size: 16px;
}
/* 资讯页面 */
.news-page .coming-soon {
text-align: center;
padding: 100px 20px;
color: rgba(255,255,255,0.8);
font-size: 18px;
}
/* 底部导航 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
display: flex;
border-top: 1px solid rgba(0,0,0,0.1);
box-shadow: 0 -2px 20px rgba(0,0,0,0.1);
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
cursor: pointer;
transition: all 0.2s ease;
color: #7f8c8d;
}
.nav-item.active {
color: #667eea;
background: rgba(102, 126, 234, 0.1);
}
.nav-item:hover {
background: rgba(0,0,0,0.05);
}
.nav-icon {
font-size: 20px;
margin-bottom: 4px;
}
.nav-text {
font-size: 12px;
font-weight: 500;
}
</style>