Files
xsh-assistant-next/app/pages/generation/admin/materials.vue

1673 lines
48 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import { object, string, number } from 'yup'
import type { FormSubmitEvent } from '#ui/types'
useHead({
title: '片头片尾管理 | 管理员',
})
const toast = useToast()
const loginState = useLoginState()
// 检查用户权限
if (loginState.user.auth_code !== 2) {
throw createError({
statusCode: 403,
statusMessage: '无权访问管理页面',
})
}
// 状态筛选
const statusFilter = ref<0 | 1>(0) // 0: 待处理, 1: 已完成
// 分页
const pagination = reactive({
page: 1,
pageSize: 20,
})
// 获取用户片头片尾请求列表
// 用户片头请求的原始模板信息类型
interface TitlesTemplateInfo {
create_time: number
opening_url: string
opening_file: string
ending_url: string
ending_file: string
type: number
title: string
description: string
}
// 用户片头请求类型(包含原始模板信息)
type UserTitlesRequest = TitlesTemplate & {
user_id?: number
to_user_id?: number
remark?: string
info?: TitlesTemplateInfo
}
const {
data: titlesListResp,
status: titlesListStatus,
refresh: refreshTitlesList,
} = useAsyncData(
'admin-titles-list',
() =>
useFetchWrapped<
PagedDataRequest & AuthedRequest & { process_status: 0 | 1 },
BaseResponse<PagedData<UserTitlesRequest>>
>('App.User_UserTitles.GetStatusList', {
token: loginState.token!,
user_id: loginState.user.id,
page: pagination.page,
perpage: pagination.pageSize,
process_status: statusFilter.value,
}),
{
watch: [pagination, statusFilter],
}
)
const titlesList = computed(() => titlesListResp.value?.data.items || [])
// 表格列定义
const columns = [
{
key: 'id',
label: 'ID',
},
{
key: 'user_id',
label: '用户ID',
},
{
key: 'title',
label: '课程名称',
},
{
key: 'description',
label: '主讲人',
},
{
key: 'remark',
label: '备注',
},
{
key: 'info',
label: '原始模板',
},
{
key: 'create_time',
label: '创建时间',
},
{
key: 'preview',
label: '制作结果',
},
{
key: 'actions',
label: '操作',
},
]
// 处理弹窗相关状态
const isProcessModalOpen = ref(false)
const currentTitlesItem = ref<UserTitlesRequest | null>(null)
const processFormState = reactive({
title: '',
description: '',
opening_url: '',
opening_file: '',
ending_url: '',
ending_file: '',
})
const processFormSchema = object({
title: string().required('请输入课程名称'),
description: string().required('请输入主讲人'),
opening_file: string().required('请上传片头视频'),
opening_url: string().required('请上传片头封面'),
ending_file: string().required('请上传片尾视频'),
ending_url: string().required('请上传片尾封面'),
})
const isProcessing = ref(false)
// 片头片尾文件上传相关
const openingVideoFile = ref<File | null>(null)
const openingCoverFile = ref<File | null>(null)
const endingVideoFile = ref<File | null>(null)
const endingCoverFile = ref<File | null>(null)
const isUploadingOpeningVideo = ref(false)
const isUploadingOpeningCover = ref(false)
const isUploadingEndingVideo = ref(false)
const isUploadingEndingCover = ref(false)
// 处理片头片尾请求
const handleProcessTitles = (
item: TitlesTemplate & {
user_id?: number
to_user_id?: number
remark?: string
}
) => {
currentTitlesItem.value = item
// 预填充表单数据
processFormState.title = item.title || ''
processFormState.description = item.description || ''
processFormState.opening_url = item.opening_url || ''
processFormState.opening_file = item.opening_file || ''
processFormState.ending_url = item.ending_url || ''
processFormState.ending_file = item.ending_file || ''
openingVideoFile.value = null
openingCoverFile.value = null
endingVideoFile.value = null
endingCoverFile.value = null
isProcessModalOpen.value = true
}
// 处理文件上传 - 片头视频
const handleOpeningVideoUpload = async (files: FileList) => {
const file = files[0]
if (!file) return
// 验证文件类型
if (!file.type.startsWith('video/')) {
toast.add({
title: '文件格式错误',
description: '请上传视频文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
// 验证文件大小 (100MB)
if (file.size > 100 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '视频文件大小不能超过100MB',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
try {
isUploadingOpeningVideo.value = true
openingVideoFile.value = file
const uploadUrl = await useFileGo(file, 'material')
processFormState.opening_file = uploadUrl
toast.add({
title: '片头视频上传成功',
color: 'green',
icon: 'i-tabler-check',
})
} catch (error) {
console.error('片头视频上传失败:', error)
toast.add({
title: '上传失败',
description: error instanceof Error ? error.message : '请重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isUploadingOpeningVideo.value = false
}
}
// 处理文件上传 - 片头封面
const handleOpeningCoverUpload = async (files: FileList) => {
const file = files[0]
if (!file) return
// 验证文件类型
if (!file.type.startsWith('image/')) {
toast.add({
title: '文件格式错误',
description: '请上传图片文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
// 验证文件大小 (10MB)
if (file.size > 10 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '图片文件大小不能超过10MB',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
try {
isUploadingOpeningCover.value = true
openingCoverFile.value = file
const uploadUrl = await useFileGo(file, 'material')
processFormState.opening_url = uploadUrl
toast.add({
title: '片头封面上传成功',
color: 'green',
icon: 'i-tabler-check',
})
} catch (error) {
console.error('片头封面上传失败:', error)
toast.add({
title: '上传失败',
description: error instanceof Error ? error.message : '请重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isUploadingOpeningCover.value = false
}
}
// 处理文件上传 - 片尾视频
const handleEndingVideoUpload = async (files: FileList) => {
const file = files[0]
if (!file) return
// 验证文件类型
if (!file.type.startsWith('video/')) {
toast.add({
title: '文件格式错误',
description: '请上传视频文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
// 验证文件大小 (100MB)
if (file.size > 100 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '视频文件大小不能超过100MB',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
try {
isUploadingEndingVideo.value = true
endingVideoFile.value = file
const uploadUrl = await useFileGo(file, 'material')
processFormState.ending_file = uploadUrl
toast.add({
title: '片尾视频上传成功',
color: 'green',
icon: 'i-tabler-check',
})
} catch (error) {
console.error('片尾视频上传失败:', error)
toast.add({
title: '上传失败',
description: error instanceof Error ? error.message : '请重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isUploadingEndingVideo.value = false
}
}
// 处理文件上传 - 片尾封面
const handleEndingCoverUpload = async (files: FileList) => {
const file = files[0]
if (!file) return
// 验证文件类型
if (!file.type.startsWith('image/')) {
toast.add({
title: '文件格式错误',
description: '请上传图片文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
// 验证文件大小 (10MB)
if (file.size > 10 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '图片文件大小不能超过10MB',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
try {
isUploadingEndingCover.value = true
endingCoverFile.value = file
const uploadUrl = await useFileGo(file, 'material')
processFormState.ending_url = uploadUrl
toast.add({
title: '片尾封面上传成功',
color: 'green',
icon: 'i-tabler-check',
})
} catch (error) {
console.error('片尾封面上传失败:', error)
toast.add({
title: '上传失败',
description: error instanceof Error ? error.message : '请重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isUploadingEndingCover.value = false
}
}
// 提交处理表单
const onProcessSubmit = async (
event: FormSubmitEvent<typeof processFormState>
) => {
if (!currentTitlesItem.value) return
if (isProcessing.value) return
try {
isProcessing.value = true
const result = await useFetchWrapped<
{
to_user_id: number
user_title_id: number
process_status: 0 | 1
opening_url: string
opening_file: string
ending_url: string
ending_file: string
title: string
description: string
} & AuthedRequest,
BaseResponse<0 | 1>
>('App.User_UserTitles.updateStatus', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id:
currentTitlesItem.value.to_user_id ||
currentTitlesItem.value.user_id ||
0,
user_title_id: currentTitlesItem.value.id,
process_status: 1, // 标记为已完成
opening_url: event.data.opening_url,
opening_file: event.data.opening_file,
ending_url: event.data.ending_url,
ending_file: event.data.ending_file,
title: event.data.title,
description: event.data.description,
})
if (result.ret === 200 && result.data === 1) {
toast.add({
title: '处理成功',
description: `片头片尾已成功分配给用户 ${currentTitlesItem.value.to_user_id || currentTitlesItem.value.user_id}`,
color: 'green',
icon: 'i-tabler-check',
})
// 重置表单和状态
processFormState.title = ''
processFormState.description = ''
processFormState.opening_url = ''
processFormState.opening_file = ''
processFormState.ending_url = ''
processFormState.ending_file = ''
openingVideoFile.value = null
openingCoverFile.value = null
endingVideoFile.value = null
endingCoverFile.value = null
currentTitlesItem.value = null
isProcessModalOpen.value = false
// 刷新列表
await refreshTitlesList()
} else {
throw new Error(result.msg || '处理失败')
}
} catch (error) {
console.error('处理片头片尾失败:', error)
const errorMessage =
error instanceof Error ? error.message : '处理失败,请重试'
toast.add({
title: '处理失败',
description: errorMessage,
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isProcessing.value = false
}
}
// 删除请求
const handleDeleteTitles = async (
item: TitlesTemplate & { user_id?: number; to_user_id?: number }
) => {
try {
const result = await useFetchWrapped<
{
to_user_id: number
user_title_id: number
} & AuthedRequest,
BaseResponse<{ code: 0 | 1 }>
>('App.User_UserTitles.DeleteConn', {
token: loginState.token!,
user_id: loginState.user.id,
to_user_id: item.to_user_id || item.user_id || 0,
user_title_id: item.id,
})
if (result.ret === 200 && result.data.code === 1) {
toast.add({
title: '删除成功',
description: '片头片尾请求已删除',
color: 'green',
icon: 'i-tabler-check',
})
await refreshTitlesList()
} else {
throw new Error(result.msg || '删除失败')
}
} catch (error) {
console.error('删除片头片尾请求失败:', error)
const errorMessage =
error instanceof Error ? error.message : '删除失败,请重试'
toast.add({
title: '删除失败',
description: errorMessage,
color: 'red',
icon: 'i-tabler-alert-triangle',
})
}
}
// 格式化时间
const formatTime = (timestamp: number) => {
if (!timestamp) return '-'
return new Date(timestamp * 1000).toLocaleString('zh-CN')
}
// 预览视频
const previewVideoUrl = ref('')
const previewVideoTitle = ref('')
const isPreviewModalOpen = ref(false)
const previewVideo = (videoUrl: string, title: string) => {
previewVideoUrl.value = videoUrl
previewVideoTitle.value = title
isPreviewModalOpen.value = true
}
// 预览图片
const previewImageUrl = ref('')
const previewImageTitle = ref('')
const isPreviewImageModalOpen = ref(false)
const previewImage = (imageUrl: string, title: string) => {
previewImageUrl.value = imageUrl
previewImageTitle.value = title
isPreviewImageModalOpen.value = true
}
// ========== 创建系统片头片尾模板 ==========
const isCreateModalOpen = ref(false)
const isCreating = ref(false)
const createFormState = reactive({
title: '',
description: '',
opening_url: '',
opening_file: '',
ending_url: '',
ending_file: '',
type: 0, // 0: 系统自带, 1: 用户自定义
})
const createFormSchema = object({
title: string().required('请输入标题'),
description: string().required('请输入描述'),
opening_file: string().required('请上传片头视频'),
opening_url: string().required('请上传片头封面'),
ending_file: string().required('请上传片尾视频'),
ending_url: string().required('请上传片尾封面'),
type: number().required(),
})
// 创建表单的文件上传状态
const createOpeningVideoFile = ref<File | null>(null)
const createOpeningCoverFile = ref<File | null>(null)
const createEndingVideoFile = ref<File | null>(null)
const createEndingCoverFile = ref<File | null>(null)
const isUploadingCreateOpeningVideo = ref(false)
const isUploadingCreateOpeningCover = ref(false)
const isUploadingCreateEndingVideo = ref(false)
const isUploadingCreateEndingCover = ref(false)
// 打开创建弹窗
const openCreateModal = () => {
createFormState.title = ''
createFormState.description = ''
createFormState.opening_url = ''
createFormState.opening_file = ''
createFormState.ending_url = ''
createFormState.ending_file = ''
createFormState.type = 0
createOpeningVideoFile.value = null
createOpeningCoverFile.value = null
createEndingVideoFile.value = null
createEndingCoverFile.value = null
isCreateModalOpen.value = true
}
// 创建表单的文件上传处理
const handleCreateOpeningVideoUpload = async (files: FileList) => {
const file = files[0]
if (!file) return
if (!file.type.startsWith('video/')) {
toast.add({
title: '文件格式错误',
description: '请上传视频文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
if (file.size > 100 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '视频文件大小不能超过100MB',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
try {
isUploadingCreateOpeningVideo.value = true
createOpeningVideoFile.value = file
const uploadUrl = await useFileGo(file, 'material')
createFormState.opening_file = uploadUrl
toast.add({
title: '片头视频上传成功',
color: 'green',
icon: 'i-tabler-check',
})
} catch (error) {
console.error('片头视频上传失败:', error)
toast.add({
title: '上传失败',
description: error instanceof Error ? error.message : '请重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isUploadingCreateOpeningVideo.value = false
}
}
const handleCreateOpeningCoverUpload = async (files: FileList) => {
const file = files[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast.add({
title: '文件格式错误',
description: '请上传图片文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
if (file.size > 10 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '图片文件大小不能超过10MB',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
try {
isUploadingCreateOpeningCover.value = true
createOpeningCoverFile.value = file
const uploadUrl = await useFileGo(file, 'material')
createFormState.opening_url = uploadUrl
toast.add({
title: '片头封面上传成功',
color: 'green',
icon: 'i-tabler-check',
})
} catch (error) {
console.error('片头封面上传失败:', error)
toast.add({
title: '上传失败',
description: error instanceof Error ? error.message : '请重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isUploadingCreateOpeningCover.value = false
}
}
const handleCreateEndingVideoUpload = async (files: FileList) => {
const file = files[0]
if (!file) return
if (!file.type.startsWith('video/')) {
toast.add({
title: '文件格式错误',
description: '请上传视频文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
if (file.size > 100 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '视频文件大小不能超过100MB',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
try {
isUploadingCreateEndingVideo.value = true
createEndingVideoFile.value = file
const uploadUrl = await useFileGo(file, 'material')
createFormState.ending_file = uploadUrl
toast.add({
title: '片尾视频上传成功',
color: 'green',
icon: 'i-tabler-check',
})
} catch (error) {
console.error('片尾视频上传失败:', error)
toast.add({
title: '上传失败',
description: error instanceof Error ? error.message : '请重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isUploadingCreateEndingVideo.value = false
}
}
const handleCreateEndingCoverUpload = async (files: FileList) => {
const file = files[0]
if (!file) return
if (!file.type.startsWith('image/')) {
toast.add({
title: '文件格式错误',
description: '请上传图片文件',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
if (file.size > 10 * 1024 * 1024) {
toast.add({
title: '文件过大',
description: '图片文件大小不能超过10MB',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
return
}
try {
isUploadingCreateEndingCover.value = true
createEndingCoverFile.value = file
const uploadUrl = await useFileGo(file, 'material')
createFormState.ending_url = uploadUrl
toast.add({
title: '片尾封面上传成功',
color: 'green',
icon: 'i-tabler-check',
})
} catch (error) {
console.error('片尾封面上传失败:', error)
toast.add({
title: '上传失败',
description: error instanceof Error ? error.message : '请重试',
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isUploadingCreateEndingCover.value = false
}
}
// 提交创建表单
const onCreateSubmit = async (
event: FormSubmitEvent<typeof createFormState>
) => {
if (isCreating.value) return
try {
isCreating.value = true
const result = await useFetchWrapped<
{
opening_url: string
opening_file: string
ending_url: string
ending_file: string
type: number
title: string
description: string
} & AuthedRequest,
BaseResponse<{ title_id: number }>
>('App.Digital_Titles.Create', {
token: loginState.token!,
user_id: loginState.user.id,
opening_url: event.data.opening_url,
opening_file: event.data.opening_file,
ending_url: event.data.ending_url,
ending_file: event.data.ending_file,
type: event.data.type,
title: event.data.title,
description: event.data.description,
})
if (result.ret === 200 && result.data.title_id) {
toast.add({
title: '创建成功',
description: `系统片头片尾模板已创建ID: ${result.data.title_id}`,
color: 'green',
icon: 'i-tabler-check',
})
// 重置表单
createFormState.title = ''
createFormState.description = ''
createFormState.opening_url = ''
createFormState.opening_file = ''
createFormState.ending_url = ''
createFormState.ending_file = ''
createFormState.type = 0
createOpeningVideoFile.value = null
createOpeningCoverFile.value = null
createEndingVideoFile.value = null
createEndingCoverFile.value = null
isCreateModalOpen.value = false
} else {
throw new Error(result.msg || '创建失败')
}
} catch (error) {
console.error('创建片头片尾模板失败:', error)
const errorMessage =
error instanceof Error ? error.message : '创建失败,请重试'
toast.add({
title: '创建失败',
description: errorMessage,
color: 'red',
icon: 'i-tabler-alert-triangle',
})
} finally {
isCreating.value = false
}
}
</script>
<template>
<div>
<div class="p-4 pb-0">
<BubbleTitle
title="片头片尾管理"
subtitle="Materials Management"
>
<template #action>
<div class="flex gap-2">
<UButton
color="primary"
variant="soft"
icon="i-tabler-plus"
label="创建系统模板"
@click="openCreateModal"
/>
<UButton
color="gray"
variant="soft"
icon="i-tabler-refresh"
label="刷新"
@click="refreshTitlesList"
/>
</div>
</template>
</BubbleTitle>
<GradientDivider />
</div>
<div class="p-4">
<UAlert
icon="i-tabler-user-shield"
title="管理员功能"
description="当前正在管理用户提交的片头片尾制作请求,仅管理员可见"
class="mb-4"
/>
<!-- 状态筛选 -->
<div class="flex items-center gap-4 mb-4">
<span class="text-sm text-gray-600 dark:text-gray-400">状态筛选</span>
<UButtonGroup>
<UButton
:color="statusFilter === 0 ? 'primary' : 'gray'"
:variant="statusFilter === 0 ? 'solid' : 'ghost'"
label="待处理"
icon="i-tabler-clock"
@click="
() => {
statusFilter = 0
pagination.page = 1
}
"
/>
<UButton
:color="statusFilter === 1 ? 'primary' : 'gray'"
:variant="statusFilter === 1 ? 'solid' : 'ghost'"
label="已完成"
icon="i-tabler-check"
@click="
() => {
statusFilter = 1
pagination.page = 1
}
"
/>
</UButtonGroup>
<UBadge
v-if="titlesListResp?.data.total"
color="primary"
variant="subtle"
>
{{ titlesListResp.data.total }}
</UBadge>
</div>
<div class="flex flex-col gap-4">
<UTable
:rows="titlesList"
:columns="columns"
:loading="titlesListStatus === 'pending'"
:progress="{ color: 'amber', animation: 'carousel' }"
class="border dark:border-neutral-800 rounded-md"
>
<template #create_time-data="{ row }">
<span class="text-sm">{{ formatTime(row.create_time) }}</span>
</template>
<template #remark-data="{ row }">
<span class="text-sm text-gray-500 max-w-32 truncate block">
{{ row.remark || '-' }}
</span>
</template>
<template #info-data="{ row }">
<div
v-if="row.info"
class="flex items-center gap-2"
>
<img
v-if="row.info.opening_url"
:src="row.info.opening_url"
:alt="row.info.title"
class="w-16 h-9 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
@click="
previewVideo(
row.info.opening_file,
`原始模板: ${row.info.title}`
)
"
/>
<div class="flex flex-col min-w-0">
<span
class="text-xs font-medium text-gray-700 dark:text-gray-300 truncate"
:title="row.info.title"
>
{{ row.info.title }}
</span>
<span class="text-2xs text-gray-500 dark:text-gray-400">
{{ row.info.description }}
</span>
</div>
</div>
<span
v-else
class="text-sm text-gray-400"
>
-
</span>
</template>
<template #preview-data="{ row }">
<div
class="flex items-center gap-3"
v-if="row.opening_file || row.ending_file"
>
<!-- 片头 -->
<div
v-if="row.opening_file"
class="flex flex-col items-center gap-0.5"
>
<img
v-if="row.opening_url"
:src="row.opening_url"
alt="片头"
class="w-14 h-8 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity ring-1 ring-blue-200 dark:ring-blue-800"
@click="previewVideo(row.opening_file, '制作片头预览')"
/>
<div
v-else
class="w-14 h-8 bg-blue-100 dark:bg-blue-900/30 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
@click="previewVideo(row.opening_file, '制作片头预览')"
>
<UIcon
name="i-tabler-player-play"
class="text-blue-500"
/>
</div>
<span class="text-2xs text-blue-600 dark:text-blue-400">
片头
</span>
</div>
<!-- 片尾 -->
<div
v-if="row.ending_file"
class="flex flex-col items-center gap-0.5"
>
<img
v-if="row.ending_url"
:src="row.ending_url"
alt="片尾"
class="w-14 h-8 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity ring-1 ring-green-200 dark:ring-green-800"
@click="previewVideo(row.ending_file, '制作片尾预览')"
/>
<div
v-else
class="w-14 h-8 bg-green-100 dark:bg-green-900/30 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
@click="previewVideo(row.ending_file, '制作片尾预览')"
>
<UIcon
name="i-tabler-player-play"
class="text-green-500"
/>
</div>
<span class="text-2xs text-green-600 dark:text-green-400">
片尾
</span>
</div>
</div>
<span
v-else
class="text-sm text-gray-400"
>
未上传
</span>
</template>
<template #actions-data="{ row }">
<div class="flex gap-2">
<UButton
v-if="statusFilter === 0"
color="amber"
variant="soft"
size="xs"
icon="i-tabler-upload"
label="处理"
@click="handleProcessTitles(row)"
/>
<UButton
v-else
color="blue"
variant="soft"
size="xs"
icon="i-tabler-edit"
label="编辑"
@click="handleProcessTitles(row)"
/>
<UButton
color="red"
variant="soft"
size="xs"
icon="i-tabler-trash"
label="删除"
@click="handleDeleteTitles(row)"
/>
</div>
</template>
</UTable>
<div class="flex justify-end">
<UPagination
v-model="pagination.page"
:max="9"
:page-count="pagination.pageSize"
:total="titlesListResp?.data.total || 0"
/>
</div>
</div>
</div>
<!-- 处理片头片尾弹窗 -->
<USlideover v-model="isProcessModalOpen">
<UCard
:ui="{
body: { base: 'flex-1 overflow-y-auto' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-col flex-1"
>
<template #header>
<UButton
class="flex absolute end-5 top-5 z-10"
color="gray"
icon="i-tabler-x"
padded
size="sm"
square
variant="ghost"
@click="isProcessModalOpen = false"
/>
<div>
<h3 class="text-lg font-semibold">处理片头片尾请求</h3>
<p class="text-sm text-gray-500 mt-1">
为用户
{{ currentTitlesItem?.to_user_id || currentTitlesItem?.user_id }}
上传制作好的片头片尾视频
</p>
</div>
</template>
<UForm
class="space-y-4"
:schema="processFormSchema"
:state="processFormState"
@submit="onProcessSubmit"
>
<UFormGroup
label="课程名称"
name="title"
>
<UInput v-model="processFormState.title" />
</UFormGroup>
<UFormGroup
label="主讲人"
name="description"
>
<UInput v-model="processFormState.description" />
</UFormGroup>
<UDivider label="片头" />
<UFormGroup
label="上传片头视频"
name="opening_file"
required
>
<UniFileDnD
accept="video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-ms-wmv"
@change="handleOpeningVideoUpload"
>
<template #default>
<div class="text-center py-4">
<UIcon
v-if="!isUploadingOpeningVideo"
name="i-tabler-video-plus"
class="mx-auto h-12 w-12 text-gray-400"
/>
<UIcon
v-else
name="i-tabler-loader-2"
class="mx-auto h-12 w-12 text-gray-400 animate-spin"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
isUploadingOpeningVideo
? '上传中...'
: openingVideoFile
? openingVideoFile.name
: '点击或拖拽上传片头视频'
}}
</span>
</div>
<p
v-if="processFormState.opening_file"
class="mt-1 text-xs text-green-600 truncate max-w-xs mx-auto"
>
{{ processFormState.opening_file }}
</p>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<UFormGroup
label="上传片头封面"
name="opening_url"
required
>
<UniFileDnD
accept="image/png,image/jpeg,image/jpg,image/webp"
@change="handleOpeningCoverUpload"
>
<template #default>
<div class="text-center py-4">
<UIcon
v-if="!isUploadingOpeningCover"
name="i-tabler-photo-plus"
class="mx-auto h-12 w-12 text-gray-400"
/>
<UIcon
v-else
name="i-tabler-loader-2"
class="mx-auto h-12 w-12 text-gray-400 animate-spin"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
isUploadingOpeningCover
? '上传中...'
: openingCoverFile
? openingCoverFile.name
: '点击或拖拽上传片头封面'
}}
</span>
</div>
<p
v-if="processFormState.opening_url"
class="mt-1 text-xs text-green-600 truncate max-w-xs mx-auto"
>
{{ processFormState.opening_url }}
</p>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<UDivider label="片尾" />
<UFormGroup
label="上传片尾视频"
name="ending_file"
required
>
<UniFileDnD
accept="video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-ms-wmv"
@change="handleEndingVideoUpload"
>
<template #default>
<div class="text-center py-4">
<UIcon
v-if="!isUploadingEndingVideo"
name="i-tabler-video-plus"
class="mx-auto h-12 w-12 text-gray-400"
/>
<UIcon
v-else
name="i-tabler-loader-2"
class="mx-auto h-12 w-12 text-gray-400 animate-spin"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
isUploadingEndingVideo
? '上传中...'
: endingVideoFile
? endingVideoFile.name
: '点击或拖拽上传片尾视频'
}}
</span>
</div>
<p
v-if="processFormState.ending_file"
class="mt-1 text-xs text-green-600 truncate max-w-xs mx-auto"
>
{{ processFormState.ending_file }}
</p>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<UFormGroup
label="上传片尾封面"
name="ending_url"
required
>
<UniFileDnD
accept="image/png,image/jpeg,image/jpg,image/webp"
@change="handleEndingCoverUpload"
>
<template #default>
<div class="text-center py-4">
<UIcon
v-if="!isUploadingEndingCover"
name="i-tabler-photo-plus"
class="mx-auto h-12 w-12 text-gray-400"
/>
<UIcon
v-else
name="i-tabler-loader-2"
class="mx-auto h-12 w-12 text-gray-400 animate-spin"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
isUploadingEndingCover
? '上传中...'
: endingCoverFile
? endingCoverFile.name
: '点击或拖拽上传片尾封面'
}}
</span>
</div>
<p
v-if="processFormState.ending_url"
class="mt-1 text-xs text-green-600 truncate max-w-xs mx-auto"
>
{{ processFormState.ending_url }}
</p>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<div class="flex justify-end gap-2 pt-4">
<UButton
type="button"
color="gray"
variant="soft"
@click="isProcessModalOpen = false"
>
取消
</UButton>
<UButton
type="submit"
color="primary"
:loading="isProcessing"
:disabled="
isProcessing ||
isUploadingOpeningVideo ||
isUploadingOpeningCover ||
isUploadingEndingVideo ||
isUploadingEndingCover
"
>
{{ isProcessing ? '提交中...' : '提交并分配' }}
</UButton>
</div>
</UForm>
</UCard>
</USlideover>
<!-- 视频预览弹窗 -->
<UModal v-model="isPreviewModalOpen">
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{{ previewVideoTitle }}</h3>
<UButton
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isPreviewModalOpen = false"
/>
</div>
</template>
<div class="aspect-video">
<video
v-if="previewVideoUrl"
:src="previewVideoUrl"
controls
class="w-full h-full rounded"
/>
</div>
</UCard>
</UModal>
<!-- 图片预览弹窗 -->
<UModal v-model="isPreviewImageModalOpen">
<UCard
:ui="{
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
>
<template #header>
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">{{ previewImageTitle }}</h3>
<UButton
color="gray"
icon="i-tabler-x"
variant="ghost"
@click="isPreviewImageModalOpen = false"
/>
</div>
</template>
<div class="flex justify-center">
<img
v-if="previewImageUrl"
:src="previewImageUrl"
:alt="previewImageTitle"
class="max-w-full max-h-[70vh] rounded object-contain"
/>
</div>
</UCard>
</UModal>
<!-- 创建系统片头片尾模板弹窗 -->
<USlideover v-model="isCreateModalOpen">
<UCard
:ui="{
body: { base: 'flex-1 overflow-y-auto' },
footer: { base: 'sticky bottom-0 bg-white dark:bg-gray-900' },
ring: '',
divide: 'divide-y divide-gray-100 dark:divide-gray-800',
}"
class="flex flex-col flex-1 h-full"
>
<template #header>
<UButton
class="flex absolute end-5 top-5 z-10"
color="gray"
icon="i-tabler-x"
padded
size="sm"
square
variant="ghost"
@click="isCreateModalOpen = false"
/>
<div>
<h3 class="text-lg font-semibold">创建系统片头片尾模板</h3>
<p class="text-sm text-gray-500 mt-1">
创建新的系统片头片尾模板供所有用户使用
</p>
</div>
</template>
<UForm
id="createForm"
class="space-y-4"
:schema="createFormSchema"
:state="createFormState"
@submit="onCreateSubmit"
>
<UFormGroup
label="标题"
name="title"
required
>
<UInput
v-model="createFormState.title"
placeholder="请输入模板标题"
/>
</UFormGroup>
<UFormGroup
label="描述"
name="description"
required
>
<UTextarea
v-model="createFormState.description"
placeholder="请输入模板描述"
/>
</UFormGroup>
<UDivider label="片头" />
<UFormGroup
label="上传片头视频"
name="opening_file"
required
>
<UniFileDnD
accept="video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-ms-wmv"
@change="handleCreateOpeningVideoUpload"
>
<template #default>
<div class="text-center py-4">
<UIcon
v-if="!isUploadingCreateOpeningVideo"
name="i-tabler-video-plus"
class="mx-auto h-12 w-12 text-gray-400"
/>
<UIcon
v-else
name="i-tabler-loader-2"
class="mx-auto h-12 w-12 text-gray-400 animate-spin"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
isUploadingCreateOpeningVideo
? '上传中...'
: createOpeningVideoFile
? createOpeningVideoFile.name
: '点击或拖拽上传片头视频'
}}
</span>
</div>
<p
v-if="createFormState.opening_file"
class="mt-1 text-xs text-green-600 truncate max-w-xs mx-auto"
>
{{ createFormState.opening_file }}
</p>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<UFormGroup
label="上传片头封面"
name="opening_url"
required
>
<UniFileDnD
accept="image/png,image/jpeg,image/jpg,image/webp"
@change="handleCreateOpeningCoverUpload"
>
<template #default>
<div class="text-center py-4">
<UIcon
v-if="!isUploadingCreateOpeningCover"
name="i-tabler-photo-plus"
class="mx-auto h-12 w-12 text-gray-400"
/>
<UIcon
v-else
name="i-tabler-loader-2"
class="mx-auto h-12 w-12 text-gray-400 animate-spin"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
isUploadingCreateOpeningCover
? '上传中...'
: createOpeningCoverFile
? createOpeningCoverFile.name
: '点击或拖拽上传片头封面'
}}
</span>
</div>
<p
v-if="createFormState.opening_url"
class="mt-1 text-xs text-green-600 truncate max-w-xs mx-auto"
>
{{ createFormState.opening_url }}
</p>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<UDivider label="片尾" />
<UFormGroup
label="上传片尾视频"
name="ending_file"
required
>
<UniFileDnD
accept="video/mp4,video/webm,video/quicktime,video/x-msvideo,video/x-ms-wmv"
@change="handleCreateEndingVideoUpload"
>
<template #default>
<div class="text-center py-4">
<UIcon
v-if="!isUploadingCreateEndingVideo"
name="i-tabler-video-plus"
class="mx-auto h-12 w-12 text-gray-400"
/>
<UIcon
v-else
name="i-tabler-loader-2"
class="mx-auto h-12 w-12 text-gray-400 animate-spin"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
isUploadingCreateEndingVideo
? '上传中...'
: createEndingVideoFile
? createEndingVideoFile.name
: '点击或拖拽上传片尾视频'
}}
</span>
</div>
<p
v-if="createFormState.ending_file"
class="mt-1 text-xs text-green-600 truncate max-w-xs mx-auto"
>
{{ createFormState.ending_file }}
</p>
</div>
</template>
</UniFileDnD>
</UFormGroup>
<UFormGroup
label="上传片尾封面"
name="ending_url"
required
>
<UniFileDnD
accept="image/png,image/jpeg,image/jpg,image/webp"
@change="handleCreateEndingCoverUpload"
>
<template #default>
<div class="text-center py-4">
<UIcon
v-if="!isUploadingCreateEndingCover"
name="i-tabler-photo-plus"
class="mx-auto h-12 w-12 text-gray-400"
/>
<UIcon
v-else
name="i-tabler-loader-2"
class="mx-auto h-12 w-12 text-gray-400 animate-spin"
/>
<div class="mt-2">
<span class="text-sm text-gray-600 dark:text-gray-400">
{{
isUploadingCreateEndingCover
? '上传中...'
: createEndingCoverFile
? createEndingCoverFile.name
: '点击或拖拽上传片尾封面'
}}
</span>
</div>
<p
v-if="createFormState.ending_url"
class="mt-1 text-xs text-green-600 truncate max-w-xs mx-auto"
>
{{ createFormState.ending_url }}
</p>
</div>
</template>
</UniFileDnD>
</UFormGroup>
</UForm>
<template #footer>
<div class="flex justify-end gap-2">
<UButton
type="button"
color="gray"
variant="soft"
@click="isCreateModalOpen = false"
>
取消
</UButton>
<UButton
type="submit"
form="createForm"
color="primary"
:loading="isCreating"
:disabled="
isCreating ||
isUploadingCreateOpeningVideo ||
isUploadingCreateOpeningCover ||
isUploadingCreateEndingVideo ||
isUploadingCreateEndingCover
"
>
{{ isCreating ? '创建中...' : '创建模板' }}
</UButton>
</div>
</template>
</UCard>
</USlideover>
</div>
</template>
<style scoped></style>