| | |
| | | </ContentWrap> |
| | | |
| | | <!-- 列表 --> |
| | | <!-- 列表 --> |
| | | <ContentWrap> |
| | | <el-table v-loading="loading" :data="list"> |
| | | <el-table-column :label="t('myCourse.videoCode')" align="center" prop="id" /> |
| | |
| | | width="120" |
| | | :formatter="dateFormatter" |
| | | /> |
| | | <!-- <el-table-column :label="t('myCourse.progress')" align="center" prop="progress">--> |
| | | <!-- <template #default="scope">--> |
| | | <!-- <el-progress :percentage="getProgress(scope.row.status, scope.row.progress)" />--> |
| | | <!-- </template>--> |
| | | <!-- </el-table-column>--> |
| | | <el-table-column :label="t('myCourse.SynthesisTime')" align="center"> |
| | | <template #default="scope"> |
| | | {{ calculateDuration(scope.row.createTime, scope.row.finishTime) }} |
| | |
| | | </template> |
| | | <el-button |
| | | link |
| | | type="primary" |
| | | @click="openSubtitleDialog(scope.row.id)" |
| | | > |
| | | 字幕 |
| | | </el-button> |
| | | <el-button |
| | | link |
| | | type="danger" |
| | | @click="handleDelete(scope.row.id)" |
| | | > |
| | |
| | | @pagination="getList" |
| | | /> |
| | | </ContentWrap> |
| | | |
| | | <!-- 视频播放弹框 --> |
| | | <videoDialog ref="videoRef" /> |
| | | |
| | | <!-- 字幕生成弹框 --> |
| | | <el-dialog |
| | | v-model="subtitleDialogVisible" |
| | | title="字幕生成" |
| | | width="60%" |
| | | @closed="resetSubtitleForm" |
| | | > |
| | | <el-form :model="subtitleForm" ref="subtitleFormRef"> |
| | | <el-row> |
| | | <el-col :span="8"> |
| | | <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="8"> |
| | | <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="8"> |
| | | <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-form-item> |
| | | </el-col> |
| | | </el-row> |
| | | <el-form-item label="字幕内容" prop="content" :rules="[ |
| | | { required: true, message: '请先生成或上传字幕内容', trigger: 'blur' } |
| | | ]"> |
| | | <el-input |
| | | v-model="subtitleForm.content" |
| | | type="textarea" |
| | | :rows="10" |
| | | placeholder="字幕内容将显示在这里(SRT格式)" |
| | | resize="none" |
| | | /> |
| | | </el-form-item> |
| | | </el-form> |
| | | |
| | | <template #footer> |
| | | <div class="dialog-footer"> |
| | | <el-button @click="subtitleDialogVisible = false">取消</el-button> |
| | | <el-button |
| | | type="primary" |
| | | @click="saveSubtitles" |
| | | :loading="saving" |
| | | :disabled="!subtitleForm.content" |
| | | > |
| | | 保存字幕 |
| | | </el-button> |
| | | </div> |
| | | </template> |
| | | </el-dialog> |
| | | </template> |
| | | <script lang="ts" setup> |
| | | import { DICT_TYPE } from '@/utils/dict' |
| | |
| | | import * as pptTemplateApi from '@/api/pptTemplate' |
| | | import { useRouter } from 'vue-router' |
| | | import videoDialog from "./videoDialog.vue" |
| | | const router = useRouter() // 路由 |
| | | const message = useMessage() // 消息弹窗 |
| | | const { t } = useI18n() // 国际化 |
| | | import { getAccessToken, getTenantId } from "@/utils/auth" |
| | | import axios from 'axios' |
| | | import { config } from '@/config/axios/config' |
| | | 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 loading = ref(true) |
| | | const total = ref(0) |
| | | const list = ref([]) |
| | | const queryParams = reactive({ |
| | | pageNo: 1, |
| | | pageSize: 20, |
| | | name: undefined |
| | | }) |
| | | const queryFormRef = ref() // 搜索的表单 |
| | | 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 handleQuery = () => { |
| | | queryParams.pageNo = 1 |
| | | getList() |
| | | } |
| | | |
| | | /** 重置按钮操作 */ |
| | | // 重置搜索 |
| | | const resetQuery = () => { |
| | | queryFormRef.value.resetFields() |
| | | handleQuery() |
| | | } |
| | | |
| | | /** 预览按钮*/ |
| | | const videoRef = ref() |
| | | // 预览视频 |
| | | const openPreview = (row) => { |
| | | if(row){ |
| | | videoRef.value.open(row.previewUrl, row.subtitlesVttUrl); |
| | | 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) => { |
| | | //如果url为空,则提示未找到资源文件 |
| | | if (!url) { |
| | | message.warning("未找到资源文件!"); |
| | | return; |
| | | message.warning("未找到资源文件!") |
| | | return |
| | | } |
| | | 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 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) { |
| | |
| | | return |
| | | } |
| | | if (res.pageMode === 2 || res.pageMode === 0) { |
| | | router.push({ path: '/chooseTemplate/index', query: { id } }); |
| | | router.push({ path: '/chooseTemplate/index', query: { id } }) |
| | | } else if (res.pageMode === 3) { |
| | | router.push({ path: '/chooseTemplate/speakvideo', query: { id } }); |
| | | 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 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 |
| | | loading.value = true |
| | | |
| | | // 重新合成视频 |
| | | const res = await pptTemplateApi.reMegerMedia({ id }); |
| | | |
| | | console.log("---------", res); |
| | | const reMegerMedia = async (id: number) => { |
| | | try { |
| | | loading.value = true |
| | | const res = await pptTemplateApi.reMegerMedia({ id }) |
| | | if (res) { |
| | | message.success("合成视频任务提交成功,请到我的视频中查看!"); |
| | | message.success("合成视频任务提交成功,请到我的视频中查看!") |
| | | } |
| | | |
| | | } catch (error) { |
| | | console.error(error); |
| | | console.error(error) |
| | | } finally { |
| | | // 刷新列表 |
| | | getList(); |
| | | // 无论成功还是失败,都需要关闭 loading |
| | | loading.value = false; |
| | | getList() |
| | | loading.value = false |
| | | } |
| | | } |
| | | |
| | | /** 根据创建时间和完成时间计算合成耗时 */ |
| | | // 计算合成耗时 |
| | | const calculateDuration = (createTime: string, finishTime: string) => { |
| | | if (!createTime || !finishTime) return '未完成'; |
| | | if (!createTime || !finishTime) return '未完成' |
| | | |
| | | const start = new Date(createTime).getTime(); |
| | | const end = new Date(finishTime).getTime(); |
| | | 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); |
| | | 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}秒`; |
| | | return `${hrs > 0 ? `${hrs}时` : ''}${mins > 0 ? `${mins}分` : ''}${secs}秒` |
| | | } |
| | | |
| | | /** 获取合成进度,关联状态 */ |
| | | const getProgress = (status: number, completionPercentage: number) => { |
| | | if (status === 2) { |
| | | return 100; // 已完成,进度固定为100% |
| | | } else if (status === 0) { |
| | | return 0; // 未启动,进度固定为0% |
| | | // 打开字幕弹框 |
| | | const openSubtitleDialog = (videoId: number) => { |
| | | subtitleForm.videoId = videoId |
| | | subtitleDialogVisible.value = true |
| | | } |
| | | |
| | | // 重置字幕表单 |
| | | 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 = { |
| | | videoId: subtitleForm.videoId, |
| | | timeThreshold: parseFloat(subtitleForm.timeThreshold), |
| | | language: subtitleForm.language |
| | | } |
| | | await pptTemplateApi.generateSubtitles(params) |
| | | message.success('字幕生成任务已开始') |
| | | |
| | | const maxAttempts = 20 |
| | | 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 { |
| | | return completionPercentage || 0; // 合成中或合成失败,按实际进度展示 |
| | | pollingTimer = window.setTimeout(poll, interval) |
| | | } |
| | | } catch (error) { |
| | | console.error('轮询出错:', error) |
| | | if (attempts >= maxAttempts) { |
| | | message.error('字幕状态检查超时') |
| | | stopPolling() |
| | | } else { |
| | | pollingTimer = window.setTimeout(poll, interval) |
| | | } |
| | | } |
| | | /** 初始化 **/ |
| | | onMounted(async () => { |
| | | await getList() |
| | | } |
| | | |
| | | 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 |
| | | } |
| | | |
| | | // 清理定时器 |
| | | onBeforeUnmount(() => { |
| | | stopPolling() |
| | | }) |
| | | |
| | | // 初始化 |
| | | onMounted(() => { |
| | | getList() |
| | | }) |
| | | </script> |