You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

1414 lines
35 KiB

<!-- src/views/vet/training/TrainingHome.vue -->
<template>
<div class="training-home">
<!-- 顶部导航 -->
<div class="training-header">
<h2><i class="el-icon-video-camera"></i> 兽医培训中心</h2>
<div class="header-actions">
<el-button
type="primary"
icon="el-icon-upload"
@click="activeTab = 'upload'"
>
上传视频
</el-button>
<el-button
plain
@click="activeTab = 'list'"
>
返回列表
</el-button>
</div>
</div>
<!-- 主内容区 -->
<div class="training-main">
<!-- 左侧菜单 -->
<div class="sidebar">
<el-menu
:default-active="activeTab"
class="training-menu"
@select="handleMenuSelect"
>
<el-menu-item index="list">
<i class="el-icon-video-camera"></i>
<span slot="title">视频列表</span>
</el-menu-item>
<el-menu-item index="upload">
<i class="el-icon-upload"></i>
<span slot="title">上传视频</span>
</el-menu-item>
<el-menu-item index="my">
<i class="el-icon-user"></i>
<span slot="title">我的视频</span>
</el-menu-item>
</el-menu>
</div>
<!-- 右侧内容区 -->
<div class="content-area">
<!-- ================= 视频列表页 ================= -->
<div v-if="activeTab === 'list'" class="tab-content">
<!-- 搜索栏 -->
<div class="search-bar">
<el-input
v-model="searchKeyword"
placeholder="搜索视频标题"
clearable
@keyup.enter.native="loadPublicVideos"
@clear="loadPublicVideos"
style="width: 300px"
>
<i slot="prefix" class="el-icon-search"></i>
</el-input>
<el-select
v-model="filterCategory"
placeholder="全部分类"
clearable
@change="loadPublicVideos"
style="width: 120px"
>
<el-option label="手术技巧" value="surgery" />
<el-option label="疾病诊断" value="diagnosis" />
<el-option label="药物使用" value="medication" />
<el-option label="其他" value="other" />
</el-select>
<el-button
type="primary"
icon="el-icon-refresh"
@click="loadPublicVideos"
>
刷新
</el-button>
</div>
<!-- 视频列表 -->
<h3 style="margin: 20px 0 10px 0; color: #333;">
培训视频
<span v-if="videos.length > 0" style="font-size: 14px; color: #999; margin-left: 10px;">
(共 {{ videos.length }} 个)
</span>
</h3>
<div v-if="loading" class="loading">
<el-skeleton :rows="6" animated />
</div>
<div v-else-if="videos.length === 0" class="empty">
<el-empty description="暂无视频"></el-empty>
</div>
<div v-else class="video-grid">
<div
v-for="video in videos"
:key="video.id"
class="video-card"
@click="showVideoDetail(video)"
>
<div class="cover">
<!-- 封面图片 -->
<img
v-if="video.coverImage && video.coverImage !== 'null'"
:src="getCoverImage(video)"
:alt="video.title"
@load="handleCoverImageLoad(video.id)"
@error="handleCoverImageError(video)"
style="width: 100%; height: 100%; object-fit: cover;"
/>
<!-- 默认封面 -->
<div
v-else
class="default-cover"
:style="{ background: getCategoryColor(video.category) }"
>
<div class="cover-content">
<i class="el-icon-video-camera cover-icon"></i>
<span class="cover-text">{{ getCategoryText(video.category) }}</span>
</div>
</div>
<div class="duration">{{ formatDuration(video.duration) }}</div>
</div>
<div class="info">
<div class="title">{{ video.title }}</div>
<div class="meta">
<span>
<i class="el-icon-user"></i>
{{ video.userName || '未知' }}
</span>
<span>
<i class="el-icon-view"></i>
{{ video.viewCount || 0 }}
</span>
</div>
<div class="time">{{ formatTime(video.createTime) }}</div>
</div>
</div>
</div>
</div>
<!-- ================= 上传视频页 ================= -->
<div v-else-if="activeTab === 'upload'" class="tab-content">
<h3 style="margin-bottom: 20px; color: #333;">上传培训视频</h3>
<div class="upload-area" @click="handleUploadAreaClick">
<input
ref="fileInput"
type="file"
accept="video/*"
style="display: none"
@change="handleFileSelect"
/>
<div v-if="!selectedFile" class="upload-placeholder">
<i class="el-icon-upload upload-icon"></i>
<p>点击选择视频文件</p>
<p class="hint">支持 mp4、avi、mov 等格式,最大 1GB</p>
</div>
<div v-else class="file-info">
<i class="el-icon-video-camera file-icon"></i>
<div class="file-details">
<div class="file-name">{{ selectedFile.name }}</div>
<div class="file-size">{{ formatFileSize(selectedFile.size) }}</div>
</div>
<el-button type="text" @click.stop="removeFile">
<i class="el-icon-close"></i>
</el-button>
</div>
</div>
<!-- 视频信息表单 -->
<div class="upload-form">
<el-form :model="uploadForm" label-width="80px" style="margin-top: 20px;">
<el-form-item label="视频标题" required>
<el-input
v-model="uploadForm.title"
placeholder="请输入视频标题"
maxlength="50"
show-word-limit
/>
</el-form-item>
<el-form-item label="分类">
<el-select v-model="uploadForm.category" placeholder="请选择分类" style="width: 100%">
<el-option label="手术技巧" value="surgery" />
<el-option label="疾病诊断" value="diagnosis" />
<el-option label="药物使用" value="medication" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="描述">
<el-input
v-model="uploadForm.description"
type="textarea"
:rows="3"
placeholder="请输入视频描述"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="视频状态">
<el-radio-group v-model="uploadForm.status">
<el-radio label="1">公开(所有人可见)</el-radio>
<el-radio label="0">私有(仅自己可见)</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="uploading"
:disabled="!selectedFile || !uploadForm.title"
@click="handleUpload"
>
{{ uploading ? '上传中...' : '开始上传' }}
</el-button>
<el-button @click="resetUploadForm">重置</el-button>
</el-form-item>
</el-form>
</div>
</div>
<!-- ================= 我的视频页 ================= -->
<div v-else-if="activeTab === 'my'" class="tab-content">
<div class="my-header">
<h3 style="margin: 0; color: #333;">我的视频</h3>
<el-input
v-model="mySearchKeyword"
placeholder="搜索我的视频"
clearable
@keyup.enter.native="loadMyVideos"
@clear="loadMyVideos"
style="width: 200px"
>
<i slot="prefix" class="el-icon-search"></i>
</el-input>
</div>
<div v-if="myVideosLoading" class="loading">
<el-skeleton :rows="3" animated />
</div>
<div v-else-if="myVideos.length === 0" class="empty">
<el-empty description="你还没有上传过视频">
<el-button type="primary" @click="activeTab = 'upload'">
去上传
</el-button>
</el-empty>
</div>
<div v-else class="my-video-list">
<div class="my-video-item" v-for="video in myVideos" :key="video.id">
<div class="video-info" @click="showVideoDetail(video)">
<div class="left">
<div class="cover-small-container">
<img v-if="video.coverImage && video.coverImage !== 'null'"
:src="getCoverImage(video)"
class="cover-small"
@error="handleCoverImageError(video)" />
<div v-else class="cover-small-placeholder" :style="{ background: getCategoryColor(video.category) }">
<i class="el-icon-video-camera"></i>
</div>
</div>
<div class="details">
<div class="title">{{ video.title }}</div>
<div class="meta">
<el-tag :type="video.status === '1' ? 'success' : 'warning'" size="small">
{{ video.status === '1' ? '公开' : '私有' }}
</el-tag>
<span class="time">{{ formatTime(video.createTime) }}</span>
<span class="views">{{ video.viewCount || 0 }}次观看</span>
</div>
</div>
</div>
<div class="right">
<el-button
size="small"
type="danger"
@click.stop="deleteMyVideo(video.id)"
>
删除
</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ================= 视频详情弹窗 ================= -->
<el-dialog
:visible.sync="showDetailDialog"
:title="currentVideo ? currentVideo.title : '视频详情'"
width="800px"
@open="handleDialogOpen"
@close="handleDialogClose"
>
<div v-if="currentVideo" class="video-detail-dialog">
<!-- 视频播放器 -->
<div class="video-player">
<div v-if="!videoPlayerReady && !videoLoadFailed" class="video-loading">
<i class="el-icon-loading"></i>
<p>加载视频中...</p>
</div>
<div v-else-if="videoLoadFailed" class="video-error">
<i class="el-icon-video-camera"></i>
<p>视频加载失败</p>
<p class="error-tip">可能的原因:</p>
<ul>
<li>视频文件不存在</li>
<li>网络连接问题</li>
<li>视频格式不支持</li>
</ul>
<div class="error-actions">
<el-button type="primary" @click="retryLoadVideo" size="small">
<i class="el-icon-refresh"></i> 重试加载
</el-button>
<el-button @click="openVideoInNewTab" size="small">
<i class="el-icon-link"></i> 在新标签页打开
</el-button>
<el-button @click="showDetailDialog = false" size="small">
关闭
</el-button>
</div>
</div>
<video
v-else
ref="videoPlayer"
:src="currentVideo.videoUrl"
controls
preload="metadata"
style="width: 100%; max-height: 400px; border-radius: 6px;"
@error="handleVideoError"
@loadeddata="handleVideoLoaded"
@canplay="handleVideoCanPlay"
crossorigin="anonymous"
>
<source :src="currentVideo.videoUrl" type="video/mp4">
<source :src="currentVideo.videoUrl" type="video/webm">
您的浏览器不支持视频播放
</video>
</div>
<!-- 视频信息 -->
<div class="video-info-card">
<div class="info-row">
<span class="label">发布者:</span>
<span class="value">{{ currentVideo.userName || '未知' }}</span>
</div>
<div class="info-row">
<span class="label">分类:</span>
<span class="value">{{ getCategoryText(currentVideo.category) }}</span>
</div>
<div class="info-row">
<span class="label">发布时间:</span>
<span class="value">{{ formatTime(currentVideo.createTime) }}</span>
</div>
<div class="info-row">
<span class="label">观看次数:</span>
<span class="value">{{ currentVideo.viewCount || 0 }}</span>
</div>
<div v-if="currentVideo.description" class="info-row">
<span class="label">视频描述:</span>
<p class="value description-text">{{ currentVideo.description }}</p>
</div>
<div class="info-row">
<span class="label">视频地址:</span>
<p class="value">
<el-link :href="currentVideo.videoUrl" target="_blank" type="primary">
{{ currentVideo.videoUrl }}
</el-link>
</p>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script>
import trainingApi from '@/api/vet/training'
export default {
name: 'TrainingHome',
data() {
return {
// 当前激活的标签页
activeTab: 'list',
// ========== 视频列表相关 ==========
searchKeyword: '',
filterCategory: '',
videos: [],
loading: false,
// ========== 上传视频相关 ==========
fileInput: null,
selectedFile: null,
uploading: false,
uploadForm: {
title: '',
category: '',
description: '',
status: '1'
},
// ========== 我的视频相关 ==========
mySearchKeyword: '',
myVideos: [],
myVideosLoading: false,
// ========== 视频详情相关 ==========
showDetailDialog: false,
currentVideo: null,
videoPlayerReady: false,
videoLoadFailed: false,
// ========== 封面相关 ==========
coverLoadingStates: new Map(),
coverErrorStates: new Map(),
defaultCoverCache: new Map(),
}
},
mounted() {
this.loadPublicVideos()
},
methods: {
// 菜单选择
handleMenuSelect(index) {
this.activeTab = index
if (index === 'list') {
this.loadPublicVideos()
} else if (index === 'my') {
this.loadMyVideos()
}
},
// 上传区域点击事件
handleUploadAreaClick() {
if (this.$refs.fileInput) {
this.$refs.fileInput.click()
}
},
// 加载公开视频
async loadPublicVideos() {
try {
this.loading = true
console.log('🔄 加载公开视频...')
const params = {
title: this.searchKeyword,
category: this.filterCategory,
pageNum: 1,
pageSize: 100
}
const res = await trainingApi.getPublicVideos(params)
console.log('✅ 公开视频响应:', res)
if (res && res.rows) {
console.log(`📊 找到 ${res.rows.length} 个视频`)
this.videos = res.rows
} else {
console.warn('⚠️ 公开视频响应数据格式不正确')
this.videos = []
}
} catch (error) {
console.error('❌ 加载公开视频失败:', error)
this.$message.error('加载失败')
} finally {
this.loading = false
}
},
// 加载我的视频
async loadMyVideos() {
try {
this.myVideosLoading = true
console.log('🔄 加载我的视频...')
const params = {
title: this.mySearchKeyword,
pageNum: 1,
pageSize: 100
}
const res = await trainingApi.getMyVideos(params)
console.log('✅ 我的视频响应:', res)
if (res && res.rows) {
console.log(`📊 找到 ${res.rows.length} 个我的视频`)
this.myVideos = res.rows
} else {
console.warn('⚠️ 我的视频响应数据格式不正确')
this.myVideos = []
}
} catch (error) {
console.error('❌ 加载我的视频失败:', error)
this.$message.error('加载失败')
} finally {
this.myVideosLoading = false
}
},
// 文件选择
handleFileSelect(event) {
const input = event.target
if (!input) return
const file = input.files[0]
if (!file) return
// 检查文件大小(1GB)
if (file.size > 1024 * 1024 * 1024) {
this.$message.error('文件大小不能超过 1GB')
return
}
// 检查文件类型
if (!file.type.startsWith('video/')) {
this.$message.error('请选择视频文件')
return
}
this.selectedFile = file
},
// 移除文件
removeFile() {
this.selectedFile = null
if (this.$refs.fileInput) {
this.$refs.fileInput.value = ''
}
},
// 上传视频
async handleUpload() {
if (!this.selectedFile) {
this.$message.error('请选择视频文件')
return
}
if (!this.uploadForm.title.trim()) {
this.$message.error('请输入视频标题')
return
}
try {
this.uploading = true
const formData = new FormData()
formData.append('title', this.uploadForm.title)
formData.append('videoFile', this.selectedFile)
formData.append('status', this.uploadForm.status)
if (this.uploadForm.category) {
formData.append('category', this.uploadForm.category)
}
if (this.uploadForm.description) {
formData.append('description', this.uploadForm.description)
}
await trainingApi.uploadVideo(formData)
this.$message.success('上传成功!')
this.resetUploadForm()
this.activeTab = 'my'
this.loadMyVideos()
} catch (error) {
console.error('上传失败:', error)
this.$message.error(error.message || '上传失败,请重试')
} finally {
this.uploading = false
}
},
// 重置上传表单
resetUploadForm() {
this.selectedFile = null
this.uploadForm.title = ''
this.uploadForm.category = ''
this.uploadForm.description = ''
this.uploadForm.status = '1'
if (this.$refs.fileInput) {
this.$refs.fileInput.value = ''
}
},
// ========== 视频详情相关方法 ==========
// 显示视频详情
showVideoDetail(video) {
console.log('🔍 查看视频详情,原始数据:', video)
if (!video || !video.videoUrl) {
this.$message.error('视频信息不完整')
return
}
// 获取完整URL
const fullVideoUrl = this.getVideoUrl(video.videoUrl)
console.log('📹 视频URL分析:')
console.log(' 原始URL:', video.videoUrl)
console.log(' 完整URL:', fullVideoUrl)
console.log(' 是否有效URL:', fullVideoUrl.startsWith('http') || fullVideoUrl.startsWith('/'))
// 测试URL可访问性
this.testVideoUrl(fullVideoUrl).then(isAccessible => {
if (!isAccessible) {
console.log('⚠️ 视频URL可能无法访问')
}
this.currentVideo = {
...video,
videoUrl: fullVideoUrl,
coverImage: this.getCoverImage(video)
}
this.showDetailDialog = true
}).catch(() => {
this.currentVideo = {
...video,
videoUrl: fullVideoUrl,
coverImage: this.getCoverImage(video)
}
this.showDetailDialog = true
})
},
// 测试视频URL是否可访问
testVideoUrl(url) {
return new Promise((resolve) => {
const testVideo = document.createElement('video')
testVideo.preload = 'metadata'
testVideo.onloadedmetadata = () => {
console.log('✅ 视频URL可访问,时长:', testVideo.duration)
resolve(true)
}
testVideo.onerror = () => {
console.log('❌ 视频URL不可访问:', url)
resolve(false)
}
// 设置超时
setTimeout(() => {
console.log('⏰ 视频加载超时')
resolve(false)
}, 3000)
testVideo.src = url
})
},
// 弹窗打开时
handleDialogOpen() {
this.videoPlayerReady = false
this.videoLoadFailed = false
// 延迟初始化视频播放器
this.$nextTick(() => {
if (this.$refs.videoPlayer) {
this.$refs.videoPlayer.load()
}
})
},
// 弹窗关闭时
handleDialogClose() {
// 暂停视频
if (this.$refs.videoPlayer) {
this.$refs.videoPlayer.pause()
}
this.currentVideo = null
this.videoPlayerReady = false
this.videoLoadFailed = false
},
// 视频可以播放时
handleVideoCanPlay() {
console.log('🎬 视频可以播放了')
this.videoPlayerReady = true
this.videoLoadFailed = false
},
// 视频加载完成
handleVideoLoaded() {
console.log('✅ 视频加载完成')
},
// 视频加载错误
handleVideoError(event) {
console.error('❌ 视频加载错误:', event)
this.videoLoadFailed = true
this.$message.error('视频加载失败,请检查视频文件')
},
// 重试加载视频
retryLoadVideo() {
console.log('🔄 重试加载视频')
this.videoPlayerReady = false
this.videoLoadFailed = false
if (this.currentVideo && this.currentVideo.videoUrl) {
// 添加时间戳避免缓存
const videoUrl = this.currentVideo.videoUrl +
(this.currentVideo.videoUrl.includes('?') ? '&' : '?') +
't=' + Date.now()
this.currentVideo.videoUrl = videoUrl
this.$nextTick(() => {
const videoElement = this.$refs.videoPlayer
if (videoElement) {
videoElement.load()
}
})
}
},
// 在新标签页打开视频
openVideoInNewTab() {
if (this.currentVideo && this.currentVideo.videoUrl) {
window.open(this.currentVideo.videoUrl, '_blank')
}
},
// 删除我的视频
async deleteMyVideo(videoId) {
try {
await this.$confirm('确定要删除这个视频吗?删除后无法恢复。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
await trainingApi.deleteVideo(videoId)
this.$message.success('删除成功')
// 删除后立即刷新数据
this.loadMyVideos()
// 同时刷新公开视频列表(如果删除的是公开视频)
if (this.activeTab === 'list') {
this.loadPublicVideos()
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error)
this.$message.error('删除失败: ' + (error.message || '未知错误'))
}
}
},
// ========== 封面相关方法 ==========
// 获取封面图片
getCoverImage(video) {
// 如果视频对象有有效封面,返回封面URL
if (video.coverImage &&
video.coverImage !== 'null' &&
video.coverImage !== 'undefined' &&
video.coverImage !== '') {
return this.getFullUrl(video.coverImage)
}
// 否则返回默认封面
return this.getCategoryDefaultCover(video.category || 'other')
},
// 根据分类获取默认封面
getCategoryDefaultCover(category) {
// 缓存默认封面,避免重复生成
if (this.defaultCoverCache.has(category)) {
return this.defaultCoverCache.get(category)
}
// 根据分类生成不同的默认封面SVG
const color = this.getCategoryColor(category)
const text = this.getCategoryText(category)
// 创建SVG
const svgString = `<svg width="320" height="180" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="${color}" />
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="16"
fill="white" text-anchor="middle" dy=".3em">
${text}
</text>
</svg>`
// 转换为base64
const base64 = btoa(unescape(encodeURIComponent(svgString)))
const dataUrl = `data:image/svg+xml;base64,${base64}`
// 缓存
this.defaultCoverCache.set(category, dataUrl)
return dataUrl
},
// 获取分类颜色
getCategoryColor(category) {
const colors = {
surgery: '#667eea', // 蓝色
diagnosis: '#67cf77', // 绿色
medication: '#ff6b6b', // 红色
other: '#ffb366' // 橙色
}
return colors[category] || colors.other
},
// 获取分类文本
getCategoryText(category) {
const texts = {
surgery: '手术技巧',
diagnosis: '疾病诊断',
medication: '药物使用',
other: '其他'
}
return texts[category] || '其他'
},
// 处理封面图片加载成功
handleCoverImageLoad(videoId) {
console.log(` 封面图片加载成功: ${videoId}`)
this.coverLoadingStates.set(videoId, true)
this.coverErrorStates.set(videoId, false)
},
// 处理封面图片加载错误
handleCoverImageError(video) {
const videoId = video.id
console.log(` 封面图片加载失败: ${videoId}`)
// 标记为错误状态
this.coverErrorStates.set(videoId, true)
// 使用默认封面替换
this.$nextTick(() => {
const imgElements = document.querySelectorAll(`[data-video-id="${videoId}"] img`)
imgElements.forEach(img => {
img.src = this.getCategoryDefaultCover(video.category || 'other')
})
})
},
// ========== URL处理方法 ==========
// 获取完整URL
getFullUrl(url) {
if (!url) return ''
// 清理可能的空格
url = url.trim()
// 如果是完整URL或base64,直接返回
if (url.startsWith('http://') ||
url.startsWith('https://') ||
url.startsWith('data:') ||
url.startsWith('blob:')) {
return url
}
// 如果是相对路径(以/开头),直接返回
if (url.startsWith('/')) {
return window.location.origin + url
}
// 否则添加uploads前缀
return window.location.origin + '/uploads/' + url
},
// 获取视频URL
getVideoUrl(videoUrl) {
return this.getFullUrl(videoUrl)
},
// ========== 工具函数 ==========
formatDuration(seconds) {
if (!seconds) return '00:00'
const min = Math.floor(seconds / 60)
const sec = seconds % 60
return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
},
formatTime(dateStr) {
if (!dateStr) return ''
try {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN') + ' ' + date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
} catch (error) {
return dateStr
}
},
formatFileSize(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
},
}
}
</script>
<style scoped>
.training-home {
height: calc(100vh - 50px);
display: flex;
flex-direction: column;
}
.training-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: white;
border-bottom: 1px solid #e8e8e8;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
.training-header h2 {
margin: 0;
color: #1890ff;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.training-main {
flex: 1;
display: flex;
background: #f0f2f5;
overflow: hidden;
}
.sidebar {
width: 180px;
background: white;
border-right: 1px solid #e8e8e8;
}
.training-menu {
border-right: none;
}
.training-menu .el-menu-item {
height: 50px;
line-height: 50px;
font-size: 14px;
}
.content-area {
flex: 1;
padding: 20px;
overflow-y: auto;
}
.tab-content {
background: white;
border-radius: 8px;
padding: 20px;
min-height: 500px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
}
/* 搜索栏样式 */
.search-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
align-items: center;
}
.my-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
/* 视频列表样式 */
.video-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
}
.video-card {
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
background: white;
}
.video-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.video-card .cover {
position: relative;
height: 140px;
background: #f5f5f5;
overflow: hidden;
}
.video-card .cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: opacity 0.3s;
}
/* 默认封面样式 */
.default-cover {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.cover-content {
text-align: center;
color: white;
}
.cover-icon {
font-size: 36px;
margin-bottom: 8px;
display: block;
}
.cover-text {
font-size: 14px;
font-weight: 500;
}
.duration {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
z-index: 2;
}
.video-card .info {
padding: 12px;
}
.video-card .title {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 500;
line-height: 1.4;
color: #333;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.video-card .meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #8c8c8c;
margin-bottom: 4px;
}
.video-card .meta span {
display: flex;
align-items: center;
gap: 4px;
}
.video-card .time {
font-size: 12px;
color: #bfbfbf;
}
/* 上传区域样式 */
.upload-area {
border: 2px dashed #dcdfe6;
border-radius: 8px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: border-color 0.3s;
background: #fafafa;
margin-bottom: 20px;
}
.upload-area:hover {
border-color: #409eff;
}
.upload-placeholder {
color: #909399;
}
.upload-icon {
font-size: 48px;
margin-bottom: 16px;
color: #409eff;
}
.hint {
font-size: 12px;
color: #c0c4cc;
margin-top: 8px;
}
.file-info {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.file-icon {
font-size: 36px;
color: #409eff;
}
.file-details {
flex: 1;
text-align: left;
}
.file-name {
font-weight: 500;
margin-bottom: 4px;
word-break: break-all;
}
.file-size {
font-size: 12px;
color: #909399;
}
.upload-form {
max-width: 600px;
}
/* 我的视频列表样式 */
.my-video-list {
margin-top: 20px;
}
.my-video-item {
border: 1px solid #e8e8e8;
border-radius: 8px;
margin-bottom: 12px;
background: white;
transition: border-color 0.3s;
}
.my-video-item:hover {
border-color: #1890ff;
}
.video-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
cursor: pointer;
}
.video-info .left {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.cover-small-container {
position: relative;
width: 80px;
height: 45px;
flex-shrink: 0;
}
.cover-small {
width: 100%;
height: 100%;
border-radius: 4px;
object-fit: cover;
}
.cover-small-placeholder {
width: 100%;
height: 100%;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.details {
flex: 1;
}
.details .title {
margin: 0 0 6px 0;
font-weight: 500;
color: #333;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.details .meta {
display: flex;
align-items: center;
gap: 12px;
font-size: 12px;
color: #8c8c8c;
}
/* 视频详情弹窗样式 */
.video-detail-dialog {
padding: 10px 0;
}
/* 视频加载状态 */
.video-loading {
padding: 60px;
text-align: center;
color: #666;
background: #f5f5f5;
border-radius: 6px;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.video-loading .el-icon-loading {
font-size: 36px;
margin-bottom: 16px;
color: #409eff;
}
/* 视频错误状态 */
.video-error {
padding: 40px;
text-align: center;
color: #f56c6c;
background: #fff5f5;
border-radius: 6px;
min-height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.video-error .el-icon-video-camera {
font-size: 48px;
margin-bottom: 16px;
}
.error-tip {
margin: 16px 0 8px 0;
color: #333;
font-weight: 500;
}
.error-tip ul {
text-align: left;
display: inline-block;
margin: 8px auto;
color: #666;
}
.error-tip li {
margin: 4px 0;
}
.error-actions {
margin-top: 20px;
display: flex;
justify-content: center;
gap: 12px;
}
.video-player {
margin-bottom: 20px;
border-radius: 6px;
overflow: hidden;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.video-info-card {
padding: 16px;
background: #fafafa;
border-radius: 6px;
}
.info-row {
margin-bottom: 12px;
font-size: 14px;
display: flex;
}
.info-row .label {
color: #666;
font-weight: 500;
margin-right: 8px;
flex-shrink: 0;
width: 80px;
}
.info-row .value {
color: #333;
flex: 1;
}
.description-text {
white-space: pre-wrap;
line-height: 1.5;
margin: 0;
}
/* 加载和空状态 */
.loading, .empty {
padding: 60px 0;
text-align: center;
color: #8c8c8c;
}
/* 响应式设计 */
@media (max-width: 768px) {
.training-main {
flex-direction: column;
}
.sidebar {
width: 100%;
border-right: none;
border-bottom: 1px solid #e8e8e8;
}
.video-grid {
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.search-bar {
flex-direction: column;
align-items: stretch;
}
.search-bar .el-input {
width: 100% !important;
}
.my-header {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
}
</style>