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

  1. <!-- src/views/vet/training/TrainingHome.vue -->
  2. <template>
  3. <div class="training-home">
  4. <!-- 顶部导航 -->
  5. <div class="training-header">
  6. <h2><i class="el-icon-video-camera"></i> 兽医培训中心</h2>
  7. <div class="header-actions">
  8. <el-button
  9. type="primary"
  10. icon="el-icon-upload"
  11. @click="activeTab = 'upload'"
  12. >
  13. 上传视频
  14. </el-button>
  15. <el-button
  16. plain
  17. @click="activeTab = 'list'"
  18. >
  19. 返回列表
  20. </el-button>
  21. </div>
  22. </div>
  23. <!-- 主内容区 -->
  24. <div class="training-main">
  25. <!-- 左侧菜单 -->
  26. <div class="sidebar">
  27. <el-menu
  28. :default-active="activeTab"
  29. class="training-menu"
  30. @select="handleMenuSelect"
  31. >
  32. <el-menu-item index="list">
  33. <i class="el-icon-video-camera"></i>
  34. <span slot="title">视频列表</span>
  35. </el-menu-item>
  36. <el-menu-item index="upload">
  37. <i class="el-icon-upload"></i>
  38. <span slot="title">上传视频</span>
  39. </el-menu-item>
  40. <el-menu-item index="my">
  41. <i class="el-icon-user"></i>
  42. <span slot="title">我的视频</span>
  43. </el-menu-item>
  44. </el-menu>
  45. </div>
  46. <!-- 右侧内容区 -->
  47. <div class="content-area">
  48. <!-- ================= 视频列表页 ================= -->
  49. <div v-if="activeTab === 'list'" class="tab-content">
  50. <!-- 搜索栏 -->
  51. <div class="search-bar">
  52. <el-input
  53. v-model="searchKeyword"
  54. placeholder="搜索视频标题"
  55. clearable
  56. @keyup.enter.native="loadPublicVideos"
  57. @clear="loadPublicVideos"
  58. style="width: 300px"
  59. >
  60. <i slot="prefix" class="el-icon-search"></i>
  61. </el-input>
  62. <el-select
  63. v-model="filterCategory"
  64. placeholder="全部分类"
  65. clearable
  66. @change="loadPublicVideos"
  67. style="width: 120px"
  68. >
  69. <el-option label="手术技巧" value="surgery" />
  70. <el-option label="疾病诊断" value="diagnosis" />
  71. <el-option label="药物使用" value="medication" />
  72. <el-option label="其他" value="other" />
  73. </el-select>
  74. <el-button
  75. type="primary"
  76. icon="el-icon-refresh"
  77. @click="loadPublicVideos"
  78. >
  79. 刷新
  80. </el-button>
  81. </div>
  82. <!-- 视频列表 -->
  83. <h3 style="margin: 20px 0 10px 0; color: #333;">
  84. 培训视频
  85. <span v-if="videos.length > 0" style="font-size: 14px; color: #999; margin-left: 10px;">
  86. ( {{ videos.length }} )
  87. </span>
  88. </h3>
  89. <div v-if="loading" class="loading">
  90. <el-skeleton :rows="6" animated />
  91. </div>
  92. <div v-else-if="videos.length === 0" class="empty">
  93. <el-empty description="暂无视频"></el-empty>
  94. </div>
  95. <div v-else class="video-grid">
  96. <div
  97. v-for="video in videos"
  98. :key="video.id"
  99. class="video-card"
  100. @click="showVideoDetail(video)"
  101. >
  102. <div class="cover">
  103. <!-- 封面图片 -->
  104. <img
  105. v-if="video.coverImage && video.coverImage !== 'null'"
  106. :src="getCoverImage(video)"
  107. :alt="video.title"
  108. @load="handleCoverImageLoad(video.id)"
  109. @error="handleCoverImageError(video)"
  110. style="width: 100%; height: 100%; object-fit: cover;"
  111. />
  112. <!-- 默认封面 -->
  113. <div
  114. v-else
  115. class="default-cover"
  116. :style="{ background: getCategoryColor(video.category) }"
  117. >
  118. <div class="cover-content">
  119. <i class="el-icon-video-camera cover-icon"></i>
  120. <span class="cover-text">{{ getCategoryText(video.category) }}</span>
  121. </div>
  122. </div>
  123. <div class="duration">{{ formatDuration(video.duration) }}</div>
  124. </div>
  125. <div class="info">
  126. <div class="title">{{ video.title }}</div>
  127. <div class="meta">
  128. <span>
  129. <i class="el-icon-user"></i>
  130. {{ video.userName || '未知' }}
  131. </span>
  132. <span>
  133. <i class="el-icon-view"></i>
  134. {{ video.viewCount || 0 }}
  135. </span>
  136. </div>
  137. <div class="time">{{ formatTime(video.createTime) }}</div>
  138. </div>
  139. </div>
  140. </div>
  141. </div>
  142. <!-- ================= 上传视频页 ================= -->
  143. <div v-else-if="activeTab === 'upload'" class="tab-content">
  144. <h3 style="margin-bottom: 20px; color: #333;">上传培训视频</h3>
  145. <div class="upload-area" @click="handleUploadAreaClick">
  146. <input
  147. ref="fileInput"
  148. type="file"
  149. accept="video/*"
  150. style="display: none"
  151. @change="handleFileSelect"
  152. />
  153. <div v-if="!selectedFile" class="upload-placeholder">
  154. <i class="el-icon-upload upload-icon"></i>
  155. <p>点击选择视频文件</p>
  156. <p class="hint">支持 mp4avimov 等格式最大 1GB</p>
  157. </div>
  158. <div v-else class="file-info">
  159. <i class="el-icon-video-camera file-icon"></i>
  160. <div class="file-details">
  161. <div class="file-name">{{ selectedFile.name }}</div>
  162. <div class="file-size">{{ formatFileSize(selectedFile.size) }}</div>
  163. </div>
  164. <el-button type="text" @click.stop="removeFile">
  165. <i class="el-icon-close"></i>
  166. </el-button>
  167. </div>
  168. </div>
  169. <!-- 视频信息表单 -->
  170. <div class="upload-form">
  171. <el-form :model="uploadForm" label-width="80px" style="margin-top: 20px;">
  172. <el-form-item label="视频标题" required>
  173. <el-input
  174. v-model="uploadForm.title"
  175. placeholder="请输入视频标题"
  176. maxlength="50"
  177. show-word-limit
  178. />
  179. </el-form-item>
  180. <el-form-item label="分类">
  181. <el-select v-model="uploadForm.category" placeholder="请选择分类" style="width: 100%">
  182. <el-option label="手术技巧" value="surgery" />
  183. <el-option label="疾病诊断" value="diagnosis" />
  184. <el-option label="药物使用" value="medication" />
  185. <el-option label="其他" value="other" />
  186. </el-select>
  187. </el-form-item>
  188. <el-form-item label="描述">
  189. <el-input
  190. v-model="uploadForm.description"
  191. type="textarea"
  192. :rows="3"
  193. placeholder="请输入视频描述"
  194. maxlength="200"
  195. show-word-limit
  196. />
  197. </el-form-item>
  198. <el-form-item label="视频状态">
  199. <el-radio-group v-model="uploadForm.status">
  200. <el-radio label="1">公开所有人可见</el-radio>
  201. <el-radio label="0">私有仅自己可见</el-radio>
  202. </el-radio-group>
  203. </el-form-item>
  204. <el-form-item>
  205. <el-button
  206. type="primary"
  207. :loading="uploading"
  208. :disabled="!selectedFile || !uploadForm.title"
  209. @click="handleUpload"
  210. >
  211. {{ uploading ? '上传中...' : '开始上传' }}
  212. </el-button>
  213. <el-button @click="resetUploadForm">重置</el-button>
  214. </el-form-item>
  215. </el-form>
  216. </div>
  217. </div>
  218. <!-- ================= 我的视频页 ================= -->
  219. <div v-else-if="activeTab === 'my'" class="tab-content">
  220. <div class="my-header">
  221. <h3 style="margin: 0; color: #333;">我的视频</h3>
  222. <el-input
  223. v-model="mySearchKeyword"
  224. placeholder="搜索我的视频"
  225. clearable
  226. @keyup.enter.native="loadMyVideos"
  227. @clear="loadMyVideos"
  228. style="width: 200px"
  229. >
  230. <i slot="prefix" class="el-icon-search"></i>
  231. </el-input>
  232. </div>
  233. <div v-if="myVideosLoading" class="loading">
  234. <el-skeleton :rows="3" animated />
  235. </div>
  236. <div v-else-if="myVideos.length === 0" class="empty">
  237. <el-empty description="你还没有上传过视频">
  238. <el-button type="primary" @click="activeTab = 'upload'">
  239. 去上传
  240. </el-button>
  241. </el-empty>
  242. </div>
  243. <div v-else class="my-video-list">
  244. <div class="my-video-item" v-for="video in myVideos" :key="video.id">
  245. <div class="video-info" @click="showVideoDetail(video)">
  246. <div class="left">
  247. <div class="cover-small-container">
  248. <img v-if="video.coverImage && video.coverImage !== 'null'"
  249. :src="getCoverImage(video)"
  250. class="cover-small"
  251. @error="handleCoverImageError(video)" />
  252. <div v-else class="cover-small-placeholder" :style="{ background: getCategoryColor(video.category) }">
  253. <i class="el-icon-video-camera"></i>
  254. </div>
  255. </div>
  256. <div class="details">
  257. <div class="title">{{ video.title }}</div>
  258. <div class="meta">
  259. <el-tag :type="video.status === '1' ? 'success' : 'warning'" size="small">
  260. {{ video.status === '1' ? '公开' : '私有' }}
  261. </el-tag>
  262. <span class="time">{{ formatTime(video.createTime) }}</span>
  263. <span class="views">{{ video.viewCount || 0 }}次观看</span>
  264. </div>
  265. </div>
  266. </div>
  267. <div class="right">
  268. <el-button
  269. size="small"
  270. type="danger"
  271. @click.stop="deleteMyVideo(video.id)"
  272. >
  273. 删除
  274. </el-button>
  275. </div>
  276. </div>
  277. </div>
  278. </div>
  279. </div>
  280. </div>
  281. </div>
  282. <!-- ================= 视频详情弹窗 ================= -->
  283. <el-dialog
  284. :visible.sync="showDetailDialog"
  285. :title="currentVideo ? currentVideo.title : '视频详情'"
  286. width="800px"
  287. @open="handleDialogOpen"
  288. @close="handleDialogClose"
  289. >
  290. <div v-if="currentVideo" class="video-detail-dialog">
  291. <!-- 视频播放器 -->
  292. <div class="video-player">
  293. <div v-if="!videoPlayerReady && !videoLoadFailed" class="video-loading">
  294. <i class="el-icon-loading"></i>
  295. <p>加载视频中...</p>
  296. </div>
  297. <div v-else-if="videoLoadFailed" class="video-error">
  298. <i class="el-icon-video-camera"></i>
  299. <p>视频加载失败</p>
  300. <p class="error-tip">可能的原因</p>
  301. <ul>
  302. <li>视频文件不存在</li>
  303. <li>网络连接问题</li>
  304. <li>视频格式不支持</li>
  305. </ul>
  306. <div class="error-actions">
  307. <el-button type="primary" @click="retryLoadVideo" size="small">
  308. <i class="el-icon-refresh"></i> 重试加载
  309. </el-button>
  310. <el-button @click="openVideoInNewTab" size="small">
  311. <i class="el-icon-link"></i> 在新标签页打开
  312. </el-button>
  313. <el-button @click="showDetailDialog = false" size="small">
  314. 关闭
  315. </el-button>
  316. </div>
  317. </div>
  318. <video
  319. v-else
  320. ref="videoPlayer"
  321. :src="currentVideo.videoUrl"
  322. controls
  323. preload="metadata"
  324. style="width: 100%; max-height: 400px; border-radius: 6px;"
  325. @error="handleVideoError"
  326. @loadeddata="handleVideoLoaded"
  327. @canplay="handleVideoCanPlay"
  328. crossorigin="anonymous"
  329. >
  330. <source :src="currentVideo.videoUrl" type="video/mp4">
  331. <source :src="currentVideo.videoUrl" type="video/webm">
  332. 您的浏览器不支持视频播放
  333. </video>
  334. </div>
  335. <!-- 视频信息 -->
  336. <div class="video-info-card">
  337. <div class="info-row">
  338. <span class="label">发布者</span>
  339. <span class="value">{{ currentVideo.userName || '未知' }}</span>
  340. </div>
  341. <div class="info-row">
  342. <span class="label">分类</span>
  343. <span class="value">{{ getCategoryText(currentVideo.category) }}</span>
  344. </div>
  345. <div class="info-row">
  346. <span class="label">发布时间</span>
  347. <span class="value">{{ formatTime(currentVideo.createTime) }}</span>
  348. </div>
  349. <div class="info-row">
  350. <span class="label">观看次数</span>
  351. <span class="value">{{ currentVideo.viewCount || 0 }}</span>
  352. </div>
  353. <div v-if="currentVideo.description" class="info-row">
  354. <span class="label">视频描述</span>
  355. <p class="value description-text">{{ currentVideo.description }}</p>
  356. </div>
  357. <div class="info-row">
  358. <span class="label">视频地址</span>
  359. <p class="value">
  360. <el-link :href="currentVideo.videoUrl" target="_blank" type="primary">
  361. {{ currentVideo.videoUrl }}
  362. </el-link>
  363. </p>
  364. </div>
  365. </div>
  366. </div>
  367. </el-dialog>
  368. </div>
  369. </template>
  370. <script>
  371. import trainingApi from '@/api/vet/training'
  372. export default {
  373. name: 'TrainingHome',
  374. data() {
  375. return {
  376. // 当前激活的标签页
  377. activeTab: 'list',
  378. // ========== 视频列表相关 ==========
  379. searchKeyword: '',
  380. filterCategory: '',
  381. videos: [],
  382. loading: false,
  383. // ========== 上传视频相关 ==========
  384. fileInput: null,
  385. selectedFile: null,
  386. uploading: false,
  387. uploadForm: {
  388. title: '',
  389. category: '',
  390. description: '',
  391. status: '1'
  392. },
  393. // ========== 我的视频相关 ==========
  394. mySearchKeyword: '',
  395. myVideos: [],
  396. myVideosLoading: false,
  397. // ========== 视频详情相关 ==========
  398. showDetailDialog: false,
  399. currentVideo: null,
  400. videoPlayerReady: false,
  401. videoLoadFailed: false,
  402. // ========== 封面相关 ==========
  403. coverLoadingStates: new Map(),
  404. coverErrorStates: new Map(),
  405. defaultCoverCache: new Map(),
  406. }
  407. },
  408. mounted() {
  409. this.loadPublicVideos()
  410. },
  411. methods: {
  412. // 菜单选择
  413. handleMenuSelect(index) {
  414. this.activeTab = index
  415. if (index === 'list') {
  416. this.loadPublicVideos()
  417. } else if (index === 'my') {
  418. this.loadMyVideos()
  419. }
  420. },
  421. // 上传区域点击事件
  422. handleUploadAreaClick() {
  423. if (this.$refs.fileInput) {
  424. this.$refs.fileInput.click()
  425. }
  426. },
  427. // 加载公开视频
  428. async loadPublicVideos() {
  429. try {
  430. this.loading = true
  431. console.log('🔄 加载公开视频...')
  432. const params = {
  433. title: this.searchKeyword,
  434. category: this.filterCategory,
  435. pageNum: 1,
  436. pageSize: 100
  437. }
  438. const res = await trainingApi.getPublicVideos(params)
  439. console.log('✅ 公开视频响应:', res)
  440. if (res && res.rows) {
  441. console.log(`📊 找到 ${res.rows.length} 个视频`)
  442. this.videos = res.rows
  443. } else {
  444. console.warn('⚠️ 公开视频响应数据格式不正确')
  445. this.videos = []
  446. }
  447. } catch (error) {
  448. console.error('❌ 加载公开视频失败:', error)
  449. this.$message.error('加载失败')
  450. } finally {
  451. this.loading = false
  452. }
  453. },
  454. // 加载我的视频
  455. async loadMyVideos() {
  456. try {
  457. this.myVideosLoading = true
  458. console.log('🔄 加载我的视频...')
  459. const params = {
  460. title: this.mySearchKeyword,
  461. pageNum: 1,
  462. pageSize: 100
  463. }
  464. const res = await trainingApi.getMyVideos(params)
  465. console.log('✅ 我的视频响应:', res)
  466. if (res && res.rows) {
  467. console.log(`📊 找到 ${res.rows.length} 个我的视频`)
  468. this.myVideos = res.rows
  469. } else {
  470. console.warn('⚠️ 我的视频响应数据格式不正确')
  471. this.myVideos = []
  472. }
  473. } catch (error) {
  474. console.error('❌ 加载我的视频失败:', error)
  475. this.$message.error('加载失败')
  476. } finally {
  477. this.myVideosLoading = false
  478. }
  479. },
  480. // 文件选择
  481. handleFileSelect(event) {
  482. const input = event.target
  483. if (!input) return
  484. const file = input.files[0]
  485. if (!file) return
  486. // 检查文件大小(1GB)
  487. if (file.size > 1024 * 1024 * 1024) {
  488. this.$message.error('文件大小不能超过 1GB')
  489. return
  490. }
  491. // 检查文件类型
  492. if (!file.type.startsWith('video/')) {
  493. this.$message.error('请选择视频文件')
  494. return
  495. }
  496. this.selectedFile = file
  497. },
  498. // 移除文件
  499. removeFile() {
  500. this.selectedFile = null
  501. if (this.$refs.fileInput) {
  502. this.$refs.fileInput.value = ''
  503. }
  504. },
  505. // 上传视频
  506. async handleUpload() {
  507. if (!this.selectedFile) {
  508. this.$message.error('请选择视频文件')
  509. return
  510. }
  511. if (!this.uploadForm.title.trim()) {
  512. this.$message.error('请输入视频标题')
  513. return
  514. }
  515. try {
  516. this.uploading = true
  517. const formData = new FormData()
  518. formData.append('title', this.uploadForm.title)
  519. formData.append('videoFile', this.selectedFile)
  520. formData.append('status', this.uploadForm.status)
  521. if (this.uploadForm.category) {
  522. formData.append('category', this.uploadForm.category)
  523. }
  524. if (this.uploadForm.description) {
  525. formData.append('description', this.uploadForm.description)
  526. }
  527. await trainingApi.uploadVideo(formData)
  528. this.$message.success('上传成功!')
  529. this.resetUploadForm()
  530. this.activeTab = 'my'
  531. this.loadMyVideos()
  532. } catch (error) {
  533. console.error('上传失败:', error)
  534. this.$message.error(error.message || '上传失败,请重试')
  535. } finally {
  536. this.uploading = false
  537. }
  538. },
  539. // 重置上传表单
  540. resetUploadForm() {
  541. this.selectedFile = null
  542. this.uploadForm.title = ''
  543. this.uploadForm.category = ''
  544. this.uploadForm.description = ''
  545. this.uploadForm.status = '1'
  546. if (this.$refs.fileInput) {
  547. this.$refs.fileInput.value = ''
  548. }
  549. },
  550. // ========== 视频详情相关方法 ==========
  551. // 显示视频详情
  552. showVideoDetail(video) {
  553. console.log('🔍 查看视频详情,原始数据:', video)
  554. if (!video || !video.videoUrl) {
  555. this.$message.error('视频信息不完整')
  556. return
  557. }
  558. // 获取完整URL
  559. const fullVideoUrl = this.getVideoUrl(video.videoUrl)
  560. console.log('📹 视频URL分析:')
  561. console.log(' 原始URL:', video.videoUrl)
  562. console.log(' 完整URL:', fullVideoUrl)
  563. console.log(' 是否有效URL:', fullVideoUrl.startsWith('http') || fullVideoUrl.startsWith('/'))
  564. // 测试URL可访问性
  565. this.testVideoUrl(fullVideoUrl).then(isAccessible => {
  566. if (!isAccessible) {
  567. console.log('⚠️ 视频URL可能无法访问')
  568. }
  569. this.currentVideo = {
  570. ...video,
  571. videoUrl: fullVideoUrl,
  572. coverImage: this.getCoverImage(video)
  573. }
  574. this.showDetailDialog = true
  575. }).catch(() => {
  576. this.currentVideo = {
  577. ...video,
  578. videoUrl: fullVideoUrl,
  579. coverImage: this.getCoverImage(video)
  580. }
  581. this.showDetailDialog = true
  582. })
  583. },
  584. // 测试视频URL是否可访问
  585. testVideoUrl(url) {
  586. return new Promise((resolve) => {
  587. const testVideo = document.createElement('video')
  588. testVideo.preload = 'metadata'
  589. testVideo.onloadedmetadata = () => {
  590. console.log('✅ 视频URL可访问,时长:', testVideo.duration)
  591. resolve(true)
  592. }
  593. testVideo.onerror = () => {
  594. console.log('❌ 视频URL不可访问:', url)
  595. resolve(false)
  596. }
  597. // 设置超时
  598. setTimeout(() => {
  599. console.log('⏰ 视频加载超时')
  600. resolve(false)
  601. }, 3000)
  602. testVideo.src = url
  603. })
  604. },
  605. // 弹窗打开时
  606. handleDialogOpen() {
  607. this.videoPlayerReady = false
  608. this.videoLoadFailed = false
  609. // 延迟初始化视频播放器
  610. this.$nextTick(() => {
  611. if (this.$refs.videoPlayer) {
  612. this.$refs.videoPlayer.load()
  613. }
  614. })
  615. },
  616. // 弹窗关闭时
  617. handleDialogClose() {
  618. // 暂停视频
  619. if (this.$refs.videoPlayer) {
  620. this.$refs.videoPlayer.pause()
  621. }
  622. this.currentVideo = null
  623. this.videoPlayerReady = false
  624. this.videoLoadFailed = false
  625. },
  626. // 视频可以播放时
  627. handleVideoCanPlay() {
  628. console.log('🎬 视频可以播放了')
  629. this.videoPlayerReady = true
  630. this.videoLoadFailed = false
  631. },
  632. // 视频加载完成
  633. handleVideoLoaded() {
  634. console.log('✅ 视频加载完成')
  635. },
  636. // 视频加载错误
  637. handleVideoError(event) {
  638. console.error('❌ 视频加载错误:', event)
  639. this.videoLoadFailed = true
  640. this.$message.error('视频加载失败,请检查视频文件')
  641. },
  642. // 重试加载视频
  643. retryLoadVideo() {
  644. console.log('🔄 重试加载视频')
  645. this.videoPlayerReady = false
  646. this.videoLoadFailed = false
  647. if (this.currentVideo && this.currentVideo.videoUrl) {
  648. // 添加时间戳避免缓存
  649. const videoUrl = this.currentVideo.videoUrl +
  650. (this.currentVideo.videoUrl.includes('?') ? '&' : '?') +
  651. 't=' + Date.now()
  652. this.currentVideo.videoUrl = videoUrl
  653. this.$nextTick(() => {
  654. const videoElement = this.$refs.videoPlayer
  655. if (videoElement) {
  656. videoElement.load()
  657. }
  658. })
  659. }
  660. },
  661. // 在新标签页打开视频
  662. openVideoInNewTab() {
  663. if (this.currentVideo && this.currentVideo.videoUrl) {
  664. window.open(this.currentVideo.videoUrl, '_blank')
  665. }
  666. },
  667. // 删除我的视频
  668. async deleteMyVideo(videoId) {
  669. try {
  670. await this.$confirm('确定要删除这个视频吗?删除后无法恢复。', '提示', {
  671. confirmButtonText: '确定',
  672. cancelButtonText: '取消',
  673. type: 'warning'
  674. })
  675. await trainingApi.deleteVideo(videoId)
  676. this.$message.success('删除成功')
  677. // 删除后立即刷新数据
  678. this.loadMyVideos()
  679. // 同时刷新公开视频列表(如果删除的是公开视频)
  680. if (this.activeTab === 'list') {
  681. this.loadPublicVideos()
  682. }
  683. } catch (error) {
  684. if (error !== 'cancel') {
  685. console.error('删除失败:', error)
  686. this.$message.error('删除失败: ' + (error.message || '未知错误'))
  687. }
  688. }
  689. },
  690. // ========== 封面相关方法 ==========
  691. // 获取封面图片
  692. getCoverImage(video) {
  693. // 如果视频对象有有效封面,返回封面URL
  694. if (video.coverImage &&
  695. video.coverImage !== 'null' &&
  696. video.coverImage !== 'undefined' &&
  697. video.coverImage !== '') {
  698. return this.getFullUrl(video.coverImage)
  699. }
  700. // 否则返回默认封面
  701. return this.getCategoryDefaultCover(video.category || 'other')
  702. },
  703. // 根据分类获取默认封面
  704. getCategoryDefaultCover(category) {
  705. // 缓存默认封面,避免重复生成
  706. if (this.defaultCoverCache.has(category)) {
  707. return this.defaultCoverCache.get(category)
  708. }
  709. // 根据分类生成不同的默认封面SVG
  710. const color = this.getCategoryColor(category)
  711. const text = this.getCategoryText(category)
  712. // 创建SVG
  713. const svgString = `<svg width="320" height="180" xmlns="http://www.w3.org/2000/svg">
  714. <rect width="100%" height="100%" fill="${color}" />
  715. <text x="50%" y="50%" font-family="Arial, sans-serif" font-size="16"
  716. fill="white" text-anchor="middle" dy=".3em">
  717. ${text}
  718. </text>
  719. </svg>`
  720. // 转换为base64
  721. const base64 = btoa(unescape(encodeURIComponent(svgString)))
  722. const dataUrl = `data:image/svg+xml;base64,${base64}`
  723. // 缓存
  724. this.defaultCoverCache.set(category, dataUrl)
  725. return dataUrl
  726. },
  727. // 获取分类颜色
  728. getCategoryColor(category) {
  729. const colors = {
  730. surgery: '#667eea', // 蓝色
  731. diagnosis: '#67cf77', // 绿色
  732. medication: '#ff6b6b', // 红色
  733. other: '#ffb366' // 橙色
  734. }
  735. return colors[category] || colors.other
  736. },
  737. // 获取分类文本
  738. getCategoryText(category) {
  739. const texts = {
  740. surgery: '手术技巧',
  741. diagnosis: '疾病诊断',
  742. medication: '药物使用',
  743. other: '其他'
  744. }
  745. return texts[category] || '其他'
  746. },
  747. // 处理封面图片加载成功
  748. handleCoverImageLoad(videoId) {
  749. console.log(`✅ 封面图片加载成功: ${videoId}`)
  750. this.coverLoadingStates.set(videoId, true)
  751. this.coverErrorStates.set(videoId, false)
  752. },
  753. // 处理封面图片加载错误
  754. handleCoverImageError(video) {
  755. const videoId = video.id
  756. console.log(`❌ 封面图片加载失败: ${videoId}`)
  757. // 标记为错误状态
  758. this.coverErrorStates.set(videoId, true)
  759. // 使用默认封面替换
  760. this.$nextTick(() => {
  761. const imgElements = document.querySelectorAll(`[data-video-id="${videoId}"] img`)
  762. imgElements.forEach(img => {
  763. img.src = this.getCategoryDefaultCover(video.category || 'other')
  764. })
  765. })
  766. },
  767. // ========== URL处理方法 ==========
  768. // 获取完整URL
  769. getFullUrl(url) {
  770. if (!url) return ''
  771. // 清理可能的空格
  772. url = url.trim()
  773. // 如果是完整URL或base64,直接返回
  774. if (url.startsWith('http://') ||
  775. url.startsWith('https://') ||
  776. url.startsWith('data:') ||
  777. url.startsWith('blob:')) {
  778. return url
  779. }
  780. // 如果是相对路径(以/开头),直接返回
  781. if (url.startsWith('/')) {
  782. return window.location.origin + url
  783. }
  784. // 否则添加uploads前缀
  785. return window.location.origin + '/uploads/' + url
  786. },
  787. // 获取视频URL
  788. getVideoUrl(videoUrl) {
  789. return this.getFullUrl(videoUrl)
  790. },
  791. // ========== 工具函数 ==========
  792. formatDuration(seconds) {
  793. if (!seconds) return '00:00'
  794. const min = Math.floor(seconds / 60)
  795. const sec = seconds % 60
  796. return `${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
  797. },
  798. formatTime(dateStr) {
  799. if (!dateStr) return ''
  800. try {
  801. const date = new Date(dateStr)
  802. return date.toLocaleDateString('zh-CN') + ' ' + date.toLocaleTimeString('zh-CN', {
  803. hour: '2-digit',
  804. minute: '2-digit'
  805. })
  806. } catch (error) {
  807. return dateStr
  808. }
  809. },
  810. formatFileSize(bytes) {
  811. if (bytes === 0) return '0 B'
  812. const k = 1024
  813. const sizes = ['B', 'KB', 'MB', 'GB']
  814. const i = Math.floor(Math.log(bytes) / Math.log(k))
  815. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
  816. },
  817. }
  818. }
  819. </script>
  820. <style scoped>
  821. .training-home {
  822. height: calc(100vh - 50px);
  823. display: flex;
  824. flex-direction: column;
  825. }
  826. .training-header {
  827. display: flex;
  828. justify-content: space-between;
  829. align-items: center;
  830. padding: 16px 20px;
  831. background: white;
  832. border-bottom: 1px solid #e8e8e8;
  833. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
  834. }
  835. .training-header h2 {
  836. margin: 0;
  837. color: #1890ff;
  838. font-size: 18px;
  839. display: flex;
  840. align-items: center;
  841. gap: 8px;
  842. }
  843. .training-main {
  844. flex: 1;
  845. display: flex;
  846. background: #f0f2f5;
  847. overflow: hidden;
  848. }
  849. .sidebar {
  850. width: 180px;
  851. background: white;
  852. border-right: 1px solid #e8e8e8;
  853. }
  854. .training-menu {
  855. border-right: none;
  856. }
  857. .training-menu .el-menu-item {
  858. height: 50px;
  859. line-height: 50px;
  860. font-size: 14px;
  861. }
  862. .content-area {
  863. flex: 1;
  864. padding: 20px;
  865. overflow-y: auto;
  866. }
  867. .tab-content {
  868. background: white;
  869. border-radius: 8px;
  870. padding: 20px;
  871. min-height: 500px;
  872. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
  873. }
  874. /* 搜索栏样式 */
  875. .search-bar {
  876. display: flex;
  877. gap: 12px;
  878. margin-bottom: 20px;
  879. align-items: center;
  880. }
  881. .my-header {
  882. display: flex;
  883. justify-content: space-between;
  884. align-items: center;
  885. margin-bottom: 20px;
  886. }
  887. /* 视频列表样式 */
  888. .video-grid {
  889. display: grid;
  890. grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  891. gap: 16px;
  892. }
  893. .video-card {
  894. border: 1px solid #e8e8e8;
  895. border-radius: 8px;
  896. overflow: hidden;
  897. cursor: pointer;
  898. transition: all 0.3s;
  899. background: white;
  900. }
  901. .video-card:hover {
  902. transform: translateY(-4px);
  903. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  904. }
  905. .video-card .cover {
  906. position: relative;
  907. height: 140px;
  908. background: #f5f5f5;
  909. overflow: hidden;
  910. }
  911. .video-card .cover img {
  912. width: 100%;
  913. height: 100%;
  914. object-fit: cover;
  915. display: block;
  916. transition: opacity 0.3s;
  917. }
  918. /* 默认封面样式 */
  919. .default-cover {
  920. width: 100%;
  921. height: 100%;
  922. display: flex;
  923. align-items: center;
  924. justify-content: center;
  925. }
  926. .cover-content {
  927. text-align: center;
  928. color: white;
  929. }
  930. .cover-icon {
  931. font-size: 36px;
  932. margin-bottom: 8px;
  933. display: block;
  934. }
  935. .cover-text {
  936. font-size: 14px;
  937. font-weight: 500;
  938. }
  939. .duration {
  940. position: absolute;
  941. bottom: 8px;
  942. right: 8px;
  943. background: rgba(0, 0, 0, 0.7);
  944. color: white;
  945. padding: 2px 6px;
  946. border-radius: 4px;
  947. font-size: 12px;
  948. z-index: 2;
  949. }
  950. .video-card .info {
  951. padding: 12px;
  952. }
  953. .video-card .title {
  954. margin: 0 0 8px 0;
  955. font-size: 14px;
  956. font-weight: 500;
  957. line-height: 1.4;
  958. color: #333;
  959. display: -webkit-box;
  960. -webkit-line-clamp: 2;
  961. -webkit-box-orient: vertical;
  962. overflow: hidden;
  963. }
  964. .video-card .meta {
  965. display: flex;
  966. justify-content: space-between;
  967. font-size: 12px;
  968. color: #8c8c8c;
  969. margin-bottom: 4px;
  970. }
  971. .video-card .meta span {
  972. display: flex;
  973. align-items: center;
  974. gap: 4px;
  975. }
  976. .video-card .time {
  977. font-size: 12px;
  978. color: #bfbfbf;
  979. }
  980. /* 上传区域样式 */
  981. .upload-area {
  982. border: 2px dashed #dcdfe6;
  983. border-radius: 8px;
  984. padding: 40px 20px;
  985. text-align: center;
  986. cursor: pointer;
  987. transition: border-color 0.3s;
  988. background: #fafafa;
  989. margin-bottom: 20px;
  990. }
  991. .upload-area:hover {
  992. border-color: #409eff;
  993. }
  994. .upload-placeholder {
  995. color: #909399;
  996. }
  997. .upload-icon {
  998. font-size: 48px;
  999. margin-bottom: 16px;
  1000. color: #409eff;
  1001. }
  1002. .hint {
  1003. font-size: 12px;
  1004. color: #c0c4cc;
  1005. margin-top: 8px;
  1006. }
  1007. .file-info {
  1008. display: flex;
  1009. align-items: center;
  1010. justify-content: center;
  1011. gap: 16px;
  1012. }
  1013. .file-icon {
  1014. font-size: 36px;
  1015. color: #409eff;
  1016. }
  1017. .file-details {
  1018. flex: 1;
  1019. text-align: left;
  1020. }
  1021. .file-name {
  1022. font-weight: 500;
  1023. margin-bottom: 4px;
  1024. word-break: break-all;
  1025. }
  1026. .file-size {
  1027. font-size: 12px;
  1028. color: #909399;
  1029. }
  1030. .upload-form {
  1031. max-width: 600px;
  1032. }
  1033. /* 我的视频列表样式 */
  1034. .my-video-list {
  1035. margin-top: 20px;
  1036. }
  1037. .my-video-item {
  1038. border: 1px solid #e8e8e8;
  1039. border-radius: 8px;
  1040. margin-bottom: 12px;
  1041. background: white;
  1042. transition: border-color 0.3s;
  1043. }
  1044. .my-video-item:hover {
  1045. border-color: #1890ff;
  1046. }
  1047. .video-info {
  1048. display: flex;
  1049. justify-content: space-between;
  1050. align-items: center;
  1051. padding: 12px;
  1052. cursor: pointer;
  1053. }
  1054. .video-info .left {
  1055. display: flex;
  1056. align-items: center;
  1057. gap: 12px;
  1058. flex: 1;
  1059. }
  1060. .cover-small-container {
  1061. position: relative;
  1062. width: 80px;
  1063. height: 45px;
  1064. flex-shrink: 0;
  1065. }
  1066. .cover-small {
  1067. width: 100%;
  1068. height: 100%;
  1069. border-radius: 4px;
  1070. object-fit: cover;
  1071. }
  1072. .cover-small-placeholder {
  1073. width: 100%;
  1074. height: 100%;
  1075. border-radius: 4px;
  1076. display: flex;
  1077. align-items: center;
  1078. justify-content: center;
  1079. color: white;
  1080. }
  1081. .details {
  1082. flex: 1;
  1083. }
  1084. .details .title {
  1085. margin: 0 0 6px 0;
  1086. font-weight: 500;
  1087. color: #333;
  1088. display: -webkit-box;
  1089. -webkit-line-clamp: 1;
  1090. -webkit-box-orient: vertical;
  1091. overflow: hidden;
  1092. }
  1093. .details .meta {
  1094. display: flex;
  1095. align-items: center;
  1096. gap: 12px;
  1097. font-size: 12px;
  1098. color: #8c8c8c;
  1099. }
  1100. /* 视频详情弹窗样式 */
  1101. .video-detail-dialog {
  1102. padding: 10px 0;
  1103. }
  1104. /* 视频加载状态 */
  1105. .video-loading {
  1106. padding: 60px;
  1107. text-align: center;
  1108. color: #666;
  1109. background: #f5f5f5;
  1110. border-radius: 6px;
  1111. min-height: 200px;
  1112. display: flex;
  1113. flex-direction: column;
  1114. align-items: center;
  1115. justify-content: center;
  1116. }
  1117. .video-loading .el-icon-loading {
  1118. font-size: 36px;
  1119. margin-bottom: 16px;
  1120. color: #409eff;
  1121. }
  1122. /* 视频错误状态 */
  1123. .video-error {
  1124. padding: 40px;
  1125. text-align: center;
  1126. color: #f56c6c;
  1127. background: #fff5f5;
  1128. border-radius: 6px;
  1129. min-height: 200px;
  1130. display: flex;
  1131. flex-direction: column;
  1132. align-items: center;
  1133. justify-content: center;
  1134. }
  1135. .video-error .el-icon-video-camera {
  1136. font-size: 48px;
  1137. margin-bottom: 16px;
  1138. }
  1139. .error-tip {
  1140. margin: 16px 0 8px 0;
  1141. color: #333;
  1142. font-weight: 500;
  1143. }
  1144. .error-tip ul {
  1145. text-align: left;
  1146. display: inline-block;
  1147. margin: 8px auto;
  1148. color: #666;
  1149. }
  1150. .error-tip li {
  1151. margin: 4px 0;
  1152. }
  1153. .error-actions {
  1154. margin-top: 20px;
  1155. display: flex;
  1156. justify-content: center;
  1157. gap: 12px;
  1158. }
  1159. .video-player {
  1160. margin-bottom: 20px;
  1161. border-radius: 6px;
  1162. overflow: hidden;
  1163. min-height: 300px;
  1164. display: flex;
  1165. align-items: center;
  1166. justify-content: center;
  1167. }
  1168. .video-info-card {
  1169. padding: 16px;
  1170. background: #fafafa;
  1171. border-radius: 6px;
  1172. }
  1173. .info-row {
  1174. margin-bottom: 12px;
  1175. font-size: 14px;
  1176. display: flex;
  1177. }
  1178. .info-row .label {
  1179. color: #666;
  1180. font-weight: 500;
  1181. margin-right: 8px;
  1182. flex-shrink: 0;
  1183. width: 80px;
  1184. }
  1185. .info-row .value {
  1186. color: #333;
  1187. flex: 1;
  1188. }
  1189. .description-text {
  1190. white-space: pre-wrap;
  1191. line-height: 1.5;
  1192. margin: 0;
  1193. }
  1194. /* 加载和空状态 */
  1195. .loading, .empty {
  1196. padding: 60px 0;
  1197. text-align: center;
  1198. color: #8c8c8c;
  1199. }
  1200. /* 响应式设计 */
  1201. @media (max-width: 768px) {
  1202. .training-main {
  1203. flex-direction: column;
  1204. }
  1205. .sidebar {
  1206. width: 100%;
  1207. border-right: none;
  1208. border-bottom: 1px solid #e8e8e8;
  1209. }
  1210. .video-grid {
  1211. grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  1212. gap: 12px;
  1213. }
  1214. .search-bar {
  1215. flex-direction: column;
  1216. align-items: stretch;
  1217. }
  1218. .search-bar .el-input {
  1219. width: 100% !important;
  1220. }
  1221. .my-header {
  1222. flex-direction: column;
  1223. align-items: stretch;
  1224. gap: 12px;
  1225. }
  1226. }
  1227. </style>