<template>
|
<!-- 搜索 -->
|
<ContentWrap>
|
<el-form
|
class="-mb-15px"
|
:model="queryParams"
|
ref="queryFormRef"
|
:inline="true"
|
label-width="68px"
|
>
|
<el-form-item :label="t('myCourse.videoName')" prop="name">
|
<el-input
|
v-model="queryParams.name"
|
:placeholder="t('common.inputText')+t('myCourse.videoName')"
|
clearable
|
@keyup.enter="handleQuery"
|
class="!w-240px"
|
/>
|
</el-form-item>
|
<el-form-item>
|
<el-button @click="handleQuery">
|
<Icon icon="ep:search" class="mr-5px" />
|
{{t('table.search')}}
|
</el-button>
|
<el-button @click="resetQuery">
|
<Icon icon="ep:refresh" class="mr-5px" />
|
{{t('table.reset')}}
|
</el-button>
|
</el-form-item>
|
</el-form>
|
</ContentWrap>
|
|
<!-- 列表 -->
|
<ContentWrap>
|
<el-table v-loading="loading" :data="list">
|
<el-table-column :label="t('myCourse.videoCode')" align="center" prop="id" />
|
<el-table-column :label="t('myCourse.videoName')" align="center" prop="name" />
|
<!-- <el-table-column :label="t('myCourse.duration')" align="center" prop="duration">-->
|
<!-- <template #default="scope">-->
|
<!-- {{ formatDuration(scope.row.duration) }}-->
|
<!-- </template>-->
|
<!-- </el-table-column>-->
|
<el-table-column :label="t('myCourse.courseName')" align="center" prop="courseName">
|
<template #default="scope">
|
<el-link type="primary" @click="goDetail(scope.row.courseId)">{{ scope.row.courseName }}</el-link>
|
</template>
|
</el-table-column>
|
<el-table-column
|
:label="t('table.createTime')"
|
align="center"
|
prop="createTime"
|
width="120"
|
:formatter="dateFormatter"
|
/>
|
<el-table-column
|
:label="t('myCourse.finishTime')"
|
align="center"
|
prop="finishTime"
|
width="120"
|
:formatter="dateFormatter"
|
/>
|
<el-table-column :label="t('myCourse.SynthesisTime')" align="center">
|
<template #default="scope">
|
{{ calculateDuration(scope.row.createTime, scope.row.finishTime) }}
|
</template>
|
</el-table-column>
|
<el-table-column :label="t('myCourse.errorReason')" align="center" prop="errorReason">
|
<template #default="scope">
|
<el-tooltip :content="scope.row.errorReason || '--'" placement="top">
|
<span>
|
{{ scope.row.errorReason ? (scope.row.errorReason.length > 20 ? scope.row.errorReason.slice(0, 20) + '...' : scope.row.errorReason) : '--' }}
|
</span>
|
</el-tooltip>
|
</template>
|
</el-table-column>
|
<el-table-column :label="t('myCourse.status')" align="center" prop="status">
|
<template #default="scope">
|
<dict-tag :type="DICT_TYPE.VIDEO_STATUS" :value="scope.row.status" />
|
</template>
|
</el-table-column>
|
<el-table-column :label="t('table.action')" align="center" min-width="110" fixed="right">
|
<template #default="scope">
|
<template v-if="scope.row.status == 2">
|
<el-button
|
link
|
type="primary"
|
@click="openPreview(scope.row)"
|
>
|
{{t('myCourse.preview')}}
|
</el-button>
|
<el-button
|
link
|
type="primary"
|
@click="handleDownload(scope.row.previewUrl,scope.row.courseName)"
|
>
|
{{t('myCourse.downloadVideo')}}
|
</el-button>
|
|
</template>
|
<template v-if=" scope.row.status == 3">
|
<el-button
|
link
|
type="warning"
|
@click="reMegerMedia(scope.row.id)"
|
>
|
{{t('myCourse.resynthesize')}}
|
</el-button>
|
</template>
|
<template v-if="scope.row.status == 2">
|
<el-button
|
link
|
type="primary"
|
@click="openSubtitleDialog(scope.row.id)"
|
>
|
字幕
|
</el-button>
|
</template>
|
|
<el-button
|
link
|
type="danger"
|
@click="handleDelete(scope.row.id)"
|
>
|
{{ t('action.del') }}
|
</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
<!-- 分页 -->
|
<Pagination
|
:total="total"
|
v-model:page="queryParams.pageNo"
|
v-model:limit="queryParams.pageSize"
|
@pagination="getList"
|
/>
|
</ContentWrap>
|
|
<!-- 视频播放弹框 -->
|
<videoDialog ref="videoRef" />
|
|
<!-- 字幕生成弹框 -->
|
<el-dialog
|
v-model="subtitleDialogVisible"
|
title="字幕查看修改"
|
width="60%"
|
>
|
<el-form :model="subtitleForm" ref="subtitleFormRef">
|
<el-row :gutter="20">
|
<el-col :span="7">
|
<el-form-item label="断句时间阈值" prop="timeThreshold" :rules="[
|
{ required: true, message: '请输入断句时间阈值', trigger: 'blur' },
|
{ pattern: /^\d+(\.\d+)?$/, message: '请输入有效数字', trigger: 'blur' }
|
]">
|
<el-input v-model="subtitleForm.timeThreshold" placeholder="例如:0.05" clearable />
|
</el-form-item>
|
</el-col>
|
<el-col :span="7">
|
<el-form-item label="语言" prop="language" :rules="[
|
{ required: true, message: '请选择语言', trigger: 'change' }
|
]">
|
<el-select v-model="subtitleForm.language" placeholder="请选择语言" clearable>
|
<el-option label="中文" value="zh" />
|
<el-option label="英文" value="en" />
|
</el-select>
|
</el-form-item>
|
</el-col>
|
<el-col :span="10">
|
<el-form-item>
|
<el-button
|
type="primary"
|
@click="generateSubtitles"
|
:loading="generating || polling"
|
>
|
查看字幕
|
</el-button>
|
<el-button
|
type="primary"
|
@click="triggerFileUpload"
|
>
|
上传SRT文件
|
<input
|
ref="fileInput"
|
type="file"
|
accept=".srt"
|
style="display: none"
|
@change="handleFileUpload"
|
/>
|
</el-button>
|
<el-button
|
type="primary"
|
@click="downloadSubtitles"
|
>
|
字幕视频合成
|
</el-button>
|
</el-form-item>
|
</el-col>
|
</el-row>
|
<el-form-item label="字幕内容" prop="content" :rules="[
|
{ required: true, message: '请先生成或上传字幕内容', trigger: 'blur' }
|
]">
|
<div style="width: 100%;" class="textarea-wrapper">
|
<el-input
|
class="scroll-outside"
|
v-model="subtitleForm.content"
|
type="textarea"
|
:rows="20"
|
placeholder="字幕内容将显示在这里(SRT格式)"
|
resize="none"
|
/>
|
<el-button
|
style="margin-top: 20px;float: right;margin-left: 20px"
|
type="primary"
|
:disabled="!subtitleForm.subtitlesUrl"
|
@click="handleDownload(subtitleForm.subtitlesUrl,subtitleForm.courseName)"
|
>
|
{{t('myCourse.downloadSubtitles')}}
|
</el-button>
|
<el-button
|
style="margin-top: 20px;float: right"
|
type="primary"
|
@click="saveSubtitles"
|
:loading="saving"
|
:disabled="!subtitleForm.content"
|
>
|
保存字幕
|
</el-button>
|
|
</div>
|
|
</el-form-item>
|
<el-form-item label="预览视频" v-if="subtitleForm.subtitlesAddStatus==2">
|
<div style="width: 100%;">
|
<video width="100%" :src="subtitleForm.videoUrl" controls></video>
|
<el-button
|
style="margin-top: 20px;float: right"
|
type="primary"
|
@click="handleDownload(subtitleForm.videoUrl,subtitleForm.videoUrl)"
|
>
|
下载视频
|
</el-button>
|
</div>
|
</el-form-item>
|
</el-form>
|
|
|
<template #footer>
|
<div class="dialog-footer">
|
<el-button @click="subtitleDialogVisible = false">关 闭</el-button>
|
|
</div>
|
</template>
|
</el-dialog>
|
</template>
|
<script lang="ts" setup>
|
import { DICT_TYPE } from '@/utils/dict'
|
import { dateFormatter } from '@/utils/formatTime'
|
import download from '@/utils/download'
|
import * as pptTemplateApi from '@/api/pptTemplate'
|
import { useRouter } from 'vue-router'
|
import videoDialog from "./videoDialog.vue"
|
import { getAccessToken, getTenantId } from "@/utils/auth"
|
import axios from 'axios'
|
import { config } from '@/config/axios/config'
|
import {videoMeger} from "@/api/pptTemplate";
|
const router = useRouter()
|
const message = useMessage()
|
const { t } = useI18n()
|
const polling = ref(false)
|
let pollingTimer: number | null = null
|
|
// 视频列表相关数据
|
const loading = ref(true)
|
const total = ref(0)
|
const list = ref([])
|
const queryParams = reactive({
|
pageNo: 1,
|
pageSize: 20,
|
name: undefined
|
})
|
const queryFormRef = ref()
|
|
// 视频预览相关
|
const videoRef = ref()
|
|
// 字幕弹框相关
|
const subtitleDialogVisible = ref(false)
|
const subtitleFormRef = ref()
|
const fileInput = ref<HTMLInputElement | null>(null)
|
const subtitleForm = reactive({
|
videoId: null as number | null,
|
timeThreshold: '0.05',
|
language: 'zh',
|
content: ''
|
})
|
const generating = ref(false)
|
const saving = ref(false)
|
|
// 获取视频列表
|
const getList = async () => {
|
loading.value = true
|
try {
|
const data = await pptTemplateApi.myCourseList(queryParams)
|
list.value = data.list
|
total.value = data.total
|
} finally {
|
loading.value = false
|
}
|
}
|
|
// 搜索视频
|
const handleQuery = () => {
|
queryParams.pageNo = 1
|
getList()
|
}
|
|
// 重置搜索
|
const resetQuery = () => {
|
queryFormRef.value.resetFields()
|
handleQuery()
|
}
|
|
// 预览视频
|
const openPreview = (row) => {
|
if(row){
|
videoRef.value.open(row.previewUrl, row.subtitlesVttUrl)
|
}
|
}
|
|
// 删除视频
|
const handleDelete = async (id: number) => {
|
try {
|
await message.delConfirm()
|
await pptTemplateApi.deleteMyCourse(id)
|
message.success(t('common.delSuccess'))
|
await getList()
|
} catch {}
|
}
|
|
// 下载文件
|
const handleDownload = (url, courseName) => {
|
if (!url) {
|
message.warning("未找到资源文件!")
|
return
|
}
|
// window.open(url, '_blank');
|
const link = document.createElement('a')
|
link.href = url
|
link.download = courseName
|
link.target = '_blank'
|
document.body.appendChild(link)
|
link.click()
|
document.body.removeChild(link)
|
}
|
|
// 跳转到课程详情
|
const goDetail = (id) => {
|
pptTemplateApi.coursesDetail(id).then((res) => {
|
if (!res) {
|
message.warning('关联课件视频或口播视频被删除')
|
return
|
}
|
if (res.pageMode === 2 || res.pageMode === 0) {
|
router.push({ path: '/chooseTemplate/index', query: { id } })
|
} else if (res.pageMode === 3) {
|
router.push({ path: '/chooseTemplate/speakvideo', query: { id } })
|
}
|
})
|
}
|
|
// 格式化视频时长
|
const formatDuration = (milliseconds: number) => {
|
const seconds = Math.floor(milliseconds / 1000)
|
const hrs = Math.floor(seconds / 3600)
|
const mins = Math.floor((seconds % 3600) / 60)
|
const secs = seconds % 60
|
return `${hrs > 0 ? `${hrs}时` : ''}${mins > 0 ? `${mins}分` : ''}${secs}秒`
|
}
|
|
// 重新合成视频
|
const reMegerMedia = async (id: number) => {
|
try {
|
loading.value = true
|
const res = await pptTemplateApi.reMegerMedia({ id })
|
if (res) {
|
message.success("合成视频任务提交成功,请到我的视频中查看!")
|
}
|
} catch (error) {
|
console.error(error)
|
} finally {
|
getList()
|
loading.value = false
|
}
|
}
|
|
// 计算合成耗时
|
const calculateDuration = (createTime: string, finishTime: string) => {
|
if (!createTime || !finishTime) return '未完成'
|
|
const start = new Date(createTime).getTime()
|
const end = new Date(finishTime).getTime()
|
|
const duration = (end - start) / 1000
|
const hrs = Math.floor(duration / 3600)
|
const mins = Math.floor((duration % 3600) / 60)
|
const secs = Math.floor(duration % 60)
|
|
return `${hrs > 0 ? `${hrs}时` : ''}${mins > 0 ? `${mins}分` : ''}${secs}秒`
|
}
|
|
// 打开字幕弹框
|
const openSubtitleDialog = async (videoId: number) => {
|
try {
|
|
subtitleDialogVisible.value = true
|
subtitleForm.videoId = videoId
|
const videoDetail = await pptTemplateApi.myCourseDetail(videoId)
|
console.log('视频详情:', videoDetail)
|
// 立即获取视频详情检查字幕状态
|
subtitleForm.subtitlesAddStatus=videoDetail.subtitlesAddStatus
|
if (videoDetail.subtitlesAddStatus === 2) {
|
subtitleForm.videoUrl = videoDetail.videoUrl || ''
|
|
}
|
if (videoDetail.subtitlesStatus === 2) { // 2 表示字幕已生成
|
if (videoDetail.subtitlesUrl) {
|
subtitleForm.subtitlesUrl = videoDetail.subtitlesUrl
|
subtitleForm.courseName=videoDetail.courseName
|
try {
|
// 尝试从URL获取字幕内容
|
const response = await fetch(videoDetail.subtitlesUrl)
|
if (response.ok) {
|
const srtContent = await response.text()
|
subtitleForm.content = srtContent
|
} else {
|
// 如果URL不可用,检查是否有直接的字幕内容
|
subtitleForm.content = videoDetail.subtitlesContent || ''
|
}
|
} catch (error) {
|
console.error('获取字幕内容失败:', error)
|
subtitleForm.content = videoDetail.subtitlesContent || ''
|
}
|
} else if (videoDetail.subtitlesContent) {
|
// 直接使用字幕内容
|
subtitleForm.content = videoDetail.subtitlesContent
|
}
|
} else {
|
// 字幕未生成或生成失败,清空内容
|
subtitleForm.content = ''
|
}
|
} catch (error) {
|
console.error('获取视频详情失败:', error)
|
message.error('获取视频详情失败,请重试')
|
subtitleDialogVisible.value = false
|
}
|
}
|
|
// 重置字幕表单
|
const resetSubtitleForm = () => {
|
subtitleFormRef.value?.resetFields()
|
subtitleForm.videoId = null
|
subtitleForm.content = ''
|
}
|
|
// 触发文件上传
|
const triggerFileUpload = () => {
|
fileInput.value?.click()
|
}
|
|
// 处理文件上传
|
const handleFileUpload = async (event: Event) => {
|
const input = event.target as HTMLInputElement
|
if (!input.files?.length) return
|
|
const file = input.files[0]
|
if (!file.name.endsWith('.srt')) {
|
message.warning('请上传SRT格式的字幕文件')
|
return
|
}
|
|
try {
|
const content = await readFileAsText(file)
|
subtitleForm.content = content
|
message.success('字幕文件上传成功')
|
} catch (error) {
|
message.error('读取字幕文件失败')
|
console.error(error)
|
} finally {
|
input.value = ''
|
}
|
}
|
|
// 读取文件为文本
|
const readFileAsText = (file: File): Promise<string> => {
|
return new Promise((resolve, reject) => {
|
const reader = new FileReader()
|
reader.onload = (e) => resolve(e.target?.result as string)
|
reader.onerror = (e) => reject(e)
|
reader.readAsText(file)
|
})
|
}
|
|
// 生成字幕
|
const generateSubtitles = async () => {
|
try {
|
await subtitleFormRef.value.validateField(['timeThreshold', 'language'])
|
|
if (!subtitleForm.videoId) {
|
message.warning('视频ID不能为空')
|
return
|
}
|
|
generating.value = true
|
|
const params = {
|
id: subtitleForm.videoId,
|
sentenceGap: parseFloat(subtitleForm.timeThreshold),
|
lang: subtitleForm.language
|
}
|
await pptTemplateApi.generateSubtitles(params)
|
message.success('字幕生成任务已开始')
|
|
const maxAttempts = 20000
|
const interval = 3000
|
let attempts = 0
|
|
const poll = async () => {
|
polling.value = true
|
attempts++
|
|
try {
|
const videoDetail = await pptTemplateApi.myCourseDetail(subtitleForm.videoId!)
|
console.log('轮询结果:', videoDetail)
|
if (videoDetail.subtitlesStatus === 2) {
|
if (videoDetail.subtitlesUrl) {
|
try {
|
const response = await fetch(videoDetail.subtitlesUrl)
|
if (response.ok) {
|
const srtContent = await response.text()
|
subtitleForm.content = srtContent
|
}
|
} catch (error) {
|
console.error('Error fetching SRT file:', error)
|
}
|
} else if (videoDetail.subtitlesContent) {
|
subtitleForm.content = videoDetail.subtitlesContent
|
}
|
message.success('字幕生成成功')
|
stopPolling()
|
} else if (videoDetail.subtitlesStatus === 3) {
|
// message.error(`字幕生成失败: ${videoDetail.errorReason || '未知原因'}`)
|
stopPolling()
|
} else if (attempts >= maxAttempts) {
|
message.warning('字幕生成超时,请稍后手动检查')
|
stopPolling()
|
} else {
|
pollingTimer = window.setTimeout(poll, interval)
|
}
|
} catch (error) {
|
console.error('轮询出错:', error)
|
if (attempts >= maxAttempts) {
|
message.error('字幕状态检查超时')
|
stopPolling()
|
} else {
|
pollingTimer = window.setTimeout(poll, interval)
|
}
|
}
|
}
|
|
poll()
|
} catch (error) {
|
console.error('生成字幕出错:', error)
|
// message.error(`生成字幕失败: ${error.message || '未知错误'}`)
|
stopPolling()
|
} finally {
|
generating.value = false
|
}
|
}
|
|
const saveSubtitles = async () => {
|
try {
|
saving.value = true
|
|
// 1. 将字幕内容转换为SRT格式
|
const srtContent = formatToSrt(subtitleForm.content)
|
|
// 2. 创建Blob对象表示SRT文件
|
const blob = new Blob([srtContent], { type: 'text/plain' })
|
const file = new File([blob], 'subtitles.srt', { type: 'text/plain' })
|
|
// 3. 创建FormData并添加文件
|
const formData = new FormData()
|
formData.append('file', file)
|
|
// 4. 上传文件 - 使用 axios 替代 request
|
const uploadResponse = await axios({
|
url: config.base_url+'/infra/file/upload',
|
method: 'post',
|
data: formData,
|
headers: {
|
'Content-Type': 'multipart/form-data',
|
'Authorization': `Bearer ${getAccessToken()}`,
|
'tenant-id': getTenantId()
|
}
|
})
|
|
// 5. 调用保存字幕接口
|
const params = {
|
id: subtitleForm.videoId,
|
subtitlesUrl: uploadResponse.data.data,
|
}
|
|
await pptTemplateApi.saveSubtitles(params)
|
message.success('字幕保存成功')
|
subtitleDialogVisible.value = false
|
|
// 刷新列表
|
getList()
|
} catch (error) {
|
console.error('保存字幕失败:', error)
|
message.error(`保存字幕失败: ${error.message || '未知错误'}`)
|
} finally {
|
saving.value = false
|
}
|
}
|
// 将文本内容格式化为SRT格式
|
const formatToSrt = (content: string): string => {
|
if (content.trim().match(/^\d+\s+\d{2}:\d{2}:\d{2},\d{3}\s-->\s\d{2}:\d{2}:\d{2},\d{3}/)) {
|
return content
|
}
|
|
const lines = content.split('\n').filter(line => line.trim())
|
let srtContent = ''
|
|
lines.forEach((line, index) => {
|
srtContent += `${index + 1}\n`
|
srtContent += `00:00:${String(index).padStart(2, '0')},000 --> 00:00:${String(index + 1).padStart(2, '0')},000\n`
|
srtContent += `${line}\n\n`
|
})
|
|
return srtContent
|
}
|
|
// 停止轮询
|
const stopPolling = () => {
|
if (pollingTimer) {
|
clearTimeout(pollingTimer)
|
pollingTimer = null
|
}
|
polling.value = false
|
}
|
//字幕视频合成
|
const downloadSubtitles = async () => {
|
try {
|
// 判断字幕内容是否为空
|
if (!subtitleForm.content.trim()) {
|
message.warning('请先生成或上传字幕内容')
|
return
|
}
|
|
generating.value = true
|
const obj = {
|
id: subtitleForm.videoId
|
}
|
await pptTemplateApi.videoMeger(obj)
|
message.success('字幕视频合成任务已开始')
|
|
const maxAttempts = 200000
|
const interval = 3000
|
let attempts = 0
|
|
const poll = async () => {
|
polling.value = true
|
attempts++
|
|
try {
|
const videoDetail = await pptTemplateApi.myCourseDetail(subtitleForm.videoId!)
|
console.log('轮询字幕视频合成结果:', videoDetail)
|
|
if (videoDetail.subtitlesAddStatus === 2) {
|
message.success('字幕视频合成成功')
|
if (videoDetail.previewUrl) {
|
subtitleForm.content = '' // 清空当前字幕内容
|
stopPolling()
|
subtitleDialogVisible.value = false
|
getList() // 刷新列表
|
}
|
} else if (videoDetail.subtitlesAddStatus === 3) {
|
message.error(`字幕视频合成失败: ${videoDetail.errorReason || '未知原因'}`)
|
stopPolling()
|
} else if (attempts >= maxAttempts) {
|
message.warning('字幕视频合成超时,请稍后手动检查')
|
stopPolling()
|
} else {
|
pollingTimer = window.setTimeout(poll, interval)
|
}
|
} catch (error) {
|
console.error('轮询字幕视频合成状态出错:', error)
|
if (attempts >= maxAttempts) {
|
message.error('字幕视频合成状态检查超时')
|
stopPolling()
|
} else {
|
pollingTimer = window.setTimeout(poll, interval)
|
}
|
}
|
}
|
|
poll()
|
} catch (error) {
|
console.error('字幕视频合成失败:', error)
|
message.error(`字幕视频合成失败: ${error.message || '未知错误'}`)
|
stopPolling()
|
} finally {
|
generating.value = false
|
}
|
}
|
// 清理定时器
|
onBeforeUnmount(() => {
|
stopPolling()
|
})
|
|
// 初始化
|
onMounted(() => {
|
getList()
|
})
|
</script>
|
<style scoped>
|
.textarea-wrapper {
|
position: relative;
|
width: fit-content;
|
}
|
|
.scroll-outside {
|
/* 隐藏默认滚动条 */
|
overflow: hidden;
|
}
|
|
.scroll-outside .el-textarea__inner {
|
/* 显示自定义滚动条 */
|
overflow-y: auto;
|
/* 确保滚动条不会挤压内容 */
|
padding-right: 0;
|
/* 可选:增加右边距为滚动条留出空间 */
|
margin-right: 16px;
|
}
|
|
/* 可选:自定义滚动条样式 */
|
.scroll-outside .el-textarea__inner::-webkit-scrollbar {
|
width: 8px;
|
}
|
|
.scroll-outside .el-textarea__inner::-webkit-scrollbar-thumb {
|
background: #c0c4cc;
|
border-radius: 4px;
|
}
|
</style>
|