完成视频案例管理界面

This commit is contained in:
2025-11-01 19:37:51 +08:00
parent 1772e5fa9e
commit c275bc2897

View File

@@ -1,13 +1,10 @@
<template> <template>
<el-config-provider :locale="zhCn"> <el-config-provider :locale="zhCn">
<div class="p-6 bg-white rounded-lg shadow-md min-h-screen pb-24" v-loading="loading"> <div class="p-6 bg-white rounded-lg shadow-md min-h-screen pb-24" v-loading="loading">
<!-- 标题与新增按钮 -->
<div class="flex justify-between items-center mb-6"> <div class="flex justify-between items-center mb-6">
<span class="text-2xl font-bold text-gray-700">视频案例管理列表</span> <span class="text-2xl font-bold text-gray-700">视频案例管理列表</span>
<el-button type="primary" :icon="Plus" @click="handleAdd">新增视频案例</el-button> <el-button type="primary" :icon="Plus" @click="handleAdd">新增视频案例</el-button>
</div> </div>
<!-- 搜索栏 -->
<div class="mb-6 flex gap-4"> <div class="mb-6 flex gap-4">
<el-input <el-input
v-model="searchKeyword" v-model="searchKeyword"
@@ -17,8 +14,6 @@
/> />
<el-button type="primary" @click="fetchData">搜索</el-button> <el-button type="primary" @click="fetchData">搜索</el-button>
</div> </div>
<!-- 视频案例表格 -->
<el-table :data="tableData" style="width: 100%" row-key="id" max-height="82vh"> <el-table :data="tableData" style="width: 100%" row-key="id" max-height="82vh">
<el-table-column prop="id" label="ID" width="80" sortable fixed></el-table-column> <el-table-column prop="id" label="ID" width="80" sortable fixed></el-table-column>
<el-table-column prop="title" label="视频标题" min-width="200" show-overflow-tooltip> <el-table-column prop="title" label="视频标题" min-width="200" show-overflow-tooltip>
@@ -66,8 +61,6 @@
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
<!-- 视频预览弹窗 -->
<el-dialog v-model="videoDialogVisible" title="视频预览" :width="`800px`" :before-close="handleCloseVideo"> <el-dialog v-model="videoDialogVisible" title="视频预览" :width="`800px`" :before-close="handleCloseVideo">
<div class="video-container" v-if="currentVideoUrl"> <div class="video-container" v-if="currentVideoUrl">
<video <video
@@ -83,8 +76,6 @@
</div> </div>
</div> </div>
</el-dialog> </el-dialog>
<!-- 详情弹窗可选如需完整详情 -->
<el-dialog v-model="detailDialogVisible" title="视频案例详情" :width="`800px`"> <el-dialog v-model="detailDialogVisible" title="视频案例详情" :width="`800px`">
<div class="detail-container"> <div class="detail-container">
<h3 class="text-xl font-bold mb-4">{{ currentVideoCase.title }}</h3> <h3 class="text-xl font-bold mb-4">{{ currentVideoCase.title }}</h3>
@@ -114,8 +105,6 @@
</div> </div>
</el-dialog> </el-dialog>
</div> </div>
<!-- 分页组件 -->
<div class="fixed bottom-0 right-0 w-full p-4 bg-white shadow-[0_-2px_5px_rgba(0,0,0,0.05)] flex justify-end z-10 border-t border-gray-200"> <div class="fixed bottom-0 right-0 w-full p-4 bg-white shadow-[0_-2px_5px_rgba(0,0,0,0.05)] flex justify-end z-10 border-t border-gray-200">
<el-pagination <el-pagination
class="custom-pagination" class="custom-pagination"
@@ -129,8 +118,6 @@
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
></el-pagination> ></el-pagination>
</div> </div>
<!-- 新增/编辑抽屉 -->
<el-drawer <el-drawer
v-model="drawerVisible" v-model="drawerVisible"
:title="form.id ? '编辑视频案例' : '新增视频案例'" :title="form.id ? '编辑视频案例' : '新增视频案例'"
@@ -150,19 +137,40 @@
maxlength="255" maxlength="255"
/> />
</div> </div>
<div class="form-group video-upload-group">
<div class="form-group video-url-group"> <label class="form-label required">视频上传</label>
<label class="form-label required">视频播放地址</label> <el-upload
<el-input class="video-upload"
v-model="form.video_url" action="http://localhost:8080/api/upload/file"
placeholder="请输入视频直接播放地址如MP4 URL" name="file"
clearable :file-list="uploadFileList"
:before-upload="handleBeforeUpload"
:on-success="handleUploadSuccess"
:on-error="handleUploadError"
:on-progress="handleUploadProgress"
:on-change="handleUploadChange"
:headers="uploadHeaders"
:disabled="isSubmitting" :disabled="isSubmitting"
maxlength="512" :limit="1"
/> accept="video/*"
<p class="text-gray-500 text-sm mt-2">支持MP4等主流视频格式的直接播放地址</p> list-type="picture-card"
auto-upload="true"
:with-credentials="false"
>
<i class="el-icon-plus video-upload-icon"></i>
</el-upload>
<div v-if="form.video_url" class="mt-4">
<video
:src="form.video_url"
controls
style="width: 300px; height: auto;"
:poster="videoPoster"
>
您的浏览器不支持视频播放
</video>
</div>
<p class="text-gray-500 text-sm mt-2">支持 MP4MOVAVI 等主流视频格式单个文件大小不超过 {{ maxFileSize }}MB</p>
</div> </div>
<div class="form-group intro-group"> <div class="form-group intro-group">
<label class="form-label">视频简介</label> <label class="form-label">视频简介</label>
<el-input <el-input
@@ -174,7 +182,6 @@
:disabled="isSubmitting" :disabled="isSubmitting"
/> />
</div> </div>
<div class="form-group designer-group"> <div class="form-group designer-group">
<label class="form-label required">设计人员名单</label> <label class="form-label required">设计人员名单</label>
<el-input <el-input
@@ -185,7 +192,6 @@
maxlength="1000" maxlength="1000"
/> />
</div> </div>
<div class="form-group tutor-group"> <div class="form-group tutor-group">
<label class="form-label required">指导老师名单</label> <label class="form-label required">指导老师名单</label>
<el-input <el-input
@@ -196,7 +202,6 @@
maxlength="1000" maxlength="1000"
/> />
</div> </div>
<div class="form-group sort-group"> <div class="form-group sort-group">
<label class="form-label">排序</label> <label class="form-label">排序</label>
<el-input <el-input
@@ -208,7 +213,6 @@
/> />
</div> </div>
</div> </div>
<template #footer> <template #footer>
<div style="flex: auto"> <div style="flex: auto">
<el-button @click="handleDrawerClose">取消</el-button> <el-button @click="handleDrawerClose">取消</el-button>
@@ -222,13 +226,13 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted, watch, nextTick } from 'vue'; import { ref, onMounted } from 'vue';
import { ElMessage, ElMessageBox, ElConfigProvider, ElDrawer, ElInput, ElUpload, ElPagination, ElTable, ElTableColumn, ElDialog, ElButton } from 'element-plus'; import { ElMessage, ElMessageBox, ElUpload, ElPagination, ElTable, ElTableColumn, ElDialog, ElButton, ElInput, ElDrawer, ElConfigProvider } from 'element-plus';
import { Edit, Delete, Plus } from '@element-plus/icons-vue'; import { Edit, Delete, Plus } from '@element-plus/icons-vue';
import type { UploadProps } from 'element-plus'; import type { UploadProps, UploadFile, UploadProgressEvent } from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn'; import zhCn from 'element-plus/es/locale/lang/zh-cn';
// --- 类型定义 --- // 接口类型定义
interface VideoCase { interface VideoCase {
id: number; id: number;
title: string; title: string;
@@ -262,13 +266,22 @@ interface UpdateVideoCaseReq extends CreateVideoCaseReq {
id: number; id: number;
} }
// --- 常量定义 --- interface UploadSuccessResponse {
const API_BASE_URL = 'http://localhost:8080/api'; code: number;
const videoPoster = 'https://via.placeholder.com/800x450?text=视频封面'; // 默认视频封面 data: {
filename: string;
size: number;
type: string;
url: string;
};
message: string;
}
// ================================================================= // 常量定义
// 列表页相关状态与逻辑 const videoPoster = 'https://via.placeholder.com/800x450?text=视频封面';
// ================================================================= const maxFileSize = 500;
// 列表数据相关
const loading = ref(true); const loading = ref(true);
const tableData = ref<VideoCase[]>([]); const tableData = ref<VideoCase[]>([]);
const currentPage = ref(1); const currentPage = ref(1);
@@ -276,7 +289,7 @@ const pageSize = ref(10);
const total = ref(0); const total = ref(0);
const searchKeyword = ref(''); const searchKeyword = ref('');
// 弹窗状态 // 视频预览相关
const videoDialogVisible = ref(false); const videoDialogVisible = ref(false);
const currentVideoUrl = ref(''); const currentVideoUrl = ref('');
const detailDialogVisible = ref(false); const detailDialogVisible = ref(false);
@@ -293,13 +306,13 @@ const currentVideoCase = ref<VideoCase>({
is_delete: 0 is_delete: 0
}); });
// --- 格式化姓名显示(超长截断) --- // 格式化名称显示
const formatNames = (names: string) => { const formatNames = (names: string) => {
if (!names) return ''; if (!names) return '';
return names.length > 15 ? `${names.slice(0, 15)}...` : names; return names.length > 15 ? `${names.slice(0, 15)}...` : names;
}; };
// --- 获取视频案例列表 --- // 获取列表数据
const fetchData = async () => { const fetchData = async () => {
loading.value = true; loading.value = true;
try { try {
@@ -309,16 +322,13 @@ const fetchData = async () => {
keyword: searchKeyword.value.trim(), keyword: searchKeyword.value.trim(),
sort: 0 sort: 0
}; };
const response = await fetch(`${API_BASE_URL}/video-cases/list`, { const response = await fetch('http://localhost:8080/api/video-cases/list', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reqData) body: JSON.stringify(reqData)
}); });
if (!response.ok) throw new Error(`请求失败!状态码:${response.status}`); if (!response.ok) throw new Error(`请求失败!状态码:${response.status}`);
const data = await response.json(); const data = await response.json();
console.log("获取到视频案例数据:", data);
// 适配后端返回的小写字段list和total
tableData.value = data.list || []; tableData.value = data.list || [];
total.value = data.total || 0; total.value = data.total || 0;
} catch (error) { } catch (error) {
@@ -331,7 +341,7 @@ const fetchData = async () => {
onMounted(fetchData); onMounted(fetchData);
// --- 视频预览 --- // 视频预览处理
const handlePreviewVideo = (url: string) => { const handlePreviewVideo = (url: string) => {
currentVideoUrl.value = url; currentVideoUrl.value = url;
videoDialogVisible.value = true; videoDialogVisible.value = true;
@@ -342,13 +352,13 @@ const handleCloseVideo = () => {
currentVideoUrl.value = ''; currentVideoUrl.value = '';
}; };
// --- 查看详情 --- // 详情查看
const handleViewDetail = (row: VideoCase) => { const handleViewDetail = (row: VideoCase) => {
currentVideoCase.value = { ...row }; currentVideoCase.value = { ...row };
detailDialogVisible.value = true; detailDialogVisible.value = true;
}; };
// --- 删除案例 --- // 删除处理
const handleDelete = (row: VideoCase) => { const handleDelete = (row: VideoCase) => {
ElMessageBox.confirm(`确定要删除视频案例《${row.title}》吗?此操作无法撤销!`, '警告', { ElMessageBox.confirm(`确定要删除视频案例《${row.title}》吗?此操作无法撤销!`, '警告', {
confirmButtonText: '确定删除', confirmButtonText: '确定删除',
@@ -357,12 +367,12 @@ const handleDelete = (row: VideoCase) => {
}) })
.then(async () => { .then(async () => {
try { try {
const response = await fetch(`${API_BASE_URL}/video-cases/${row.id}`, { const response = await fetch(`http://localhost:8080/api/video-cases/${row.id}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!response.ok) { if (!response.ok) {
const errData = await response.json().catch(() => null); const errData = await response.json().catch(() => null);
throw new Error(errData?.msg || '删除失败'); throw new Error(errData?.message || '删除失败');
} }
ElMessage.success('删除成功!'); ElMessage.success('删除成功!');
fetchData(); fetchData();
@@ -376,7 +386,7 @@ const handleDelete = (row: VideoCase) => {
}); });
}; };
// --- 分页处理 --- // 分页处理
const handleSizeChange = (val: number) => { const handleSizeChange = (val: number) => {
pageSize.value = val; pageSize.value = val;
currentPage.value = 1; currentPage.value = 1;
@@ -388,13 +398,15 @@ const handleCurrentChange = (val: number) => {
fetchData(); fetchData();
}; };
// ================================================================= // 新增/编辑抽屉相关
// 抽屉编辑/新增相关状态与逻辑
// =================================================================
const drawerVisible = ref(false); const drawerVisible = ref(false);
const isSubmitting = ref(false); const isSubmitting = ref(false);
const uploadFileList = ref<UploadFile[]>([]);
const uploadHeaders = ref({
'Accept': 'application/json'
});
// --- 表单默认值 --- // 表单初始状态
const defaultFormState = () => ({ const defaultFormState = () => ({
id: null as number | null, id: null as number | null,
title: '', title: '',
@@ -406,12 +418,102 @@ const defaultFormState = () => ({
}); });
const form = ref(defaultFormState()); const form = ref(defaultFormState());
// --- 操作处理 --- // 核心修改字节上传完成显示99%后端响应成功后才显示100%
const handleUploadProgress: UploadProps['onProgress'] = (event: UploadProgressEvent, file: UploadFile) => {
let percent = Math.round(event.percent * 100) / 100;
// 字节上传完成percent=100显示99%并提示"等待后端响应"
if (percent >= 100) {
percent = 99;
console.log(`[上传进度] ${percent}%(文件字节上传完成,等待后端返回结果...`,
'已上传:', formatFileSize(event.loaded),
'总大小:', formatFileSize(event.total || 0)
);
} else {
console.log(`[上传进度] ${percent}%`,
'已上传:', formatFileSize(event.loaded),
'总大小:', formatFileSize(event.total || 0)
);
}
};
// 文件状态变化处理
const handleUploadChange: UploadProps['onChange'] = (file: UploadFile) => {
// 避免状态提前标记为完成
const statusText = {
'ready': '待上传',
'uploading': '上传中',
'success': '上传成功',
'error': '上传失败'
};
console.log(`[文件状态变更] 文件名: ${file.name},状态: ${statusText[file.status]}`);
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
return (bytes / 1024 / 1024).toFixed(2) + 'MB';
};
// 上传前校验
const handleBeforeUpload: UploadProps['beforeUpload'] = (rawFile) => {
console.log("开始上传视频",
'文件名:', rawFile.name,
'大小:', formatFileSize(rawFile.size),
'类型:', rawFile.type
);
const isVideo = rawFile.type.startsWith('video/');
if (!isVideo) {
ElMessage.error('请上传视频格式文件MP4、MOV、AVI 等)');
return Promise.reject(new Error('文件格式错误'));
}
const isLtMaxSize = rawFile.size / 1024 / 1024 <= maxFileSize;
if (!isLtMaxSize) {
ElMessage.error(`视频大小不能超过 ${maxFileSize}MB`);
return Promise.reject(new Error('文件过大'));
}
return Promise.resolve(true);
};
// 核心修改收到后端成功响应后显示100%并输出视频地址
const handleUploadSuccess: UploadProps['onSuccess'] = (response: UploadSuccessResponse) => {
// 显示最终100%进度
console.log(`[上传进度] 100%(后端响应成功,上传完成!)`);
// 输出成功信息和视频地址
console.log("===== 视频上传成功 =====");
console.log("响应数据:", response);
console.log("视频地址:", response.data.url);
console.log("=======================");
if (response.code === 200 && response.data?.url) {
form.value.video_url = response.data.url;
uploadFileList.value = [];
ElMessage.success(response.message || '视频上传成功!');
} else {
ElMessage.error(`视频上传失败:${response.message || '未知错误'}`);
}
};
// 上传错误处理
const handleUploadError: UploadProps['onError'] = (error: any, file: UploadFile) => {
console.log(`[上传进度] 上传失败!`);
console.error('[ERROR] 视频上传失败:', error);
if (error.message === 'Failed to fetch') {
console.error('[跨域可能] 请检查后端跨域配置');
}
if (error.response) {
console.error('[响应错误] 状态码:', error.response.status);
console.error('[响应错误] 响应体:', error.response.data);
}
ElMessage.error('视频上传失败,请检查接口或网络');
};
// 新增处理
const handleAdd = () => { const handleAdd = () => {
form.value = defaultFormState(); form.value = defaultFormState();
uploadFileList.value = [];
drawerVisible.value = true; drawerVisible.value = true;
}; };
// 编辑处理
const handleEdit = (row: VideoCase) => { const handleEdit = (row: VideoCase) => {
form.value = { form.value = {
id: row.id, id: row.id,
@@ -422,23 +524,25 @@ const handleEdit = (row: VideoCase) => {
tutor_names: row.tutor_names, tutor_names: row.tutor_names,
sort: row.sort, sort: row.sort,
}; };
uploadFileList.value = [];
drawerVisible.value = true; drawerVisible.value = true;
}; };
// 关闭抽屉
const handleDrawerClose = () => { const handleDrawerClose = () => {
drawerVisible.value = false; drawerVisible.value = false;
form.value = defaultFormState(); form.value = defaultFormState();
uploadFileList.value = [];
}; };
// --- 提交视频案例(新增/更新) --- // 提交表单
const submitVideoCase = async () => { const submitVideoCase = async () => {
// 表单校验
if (!form.value.title.trim()) { if (!form.value.title.trim()) {
ElMessage.warning('请输入视频标题'); ElMessage.warning('请输入视频标题');
return; return;
} }
if (!form.value.video_url.trim()) { if (!form.value.video_url.trim()) {
ElMessage.warning('请输入视频播放地址'); ElMessage.warning('请上传视频文件');
return; return;
} }
if (!form.value.designer_names.trim()) { if (!form.value.designer_names.trim()) {
@@ -452,7 +556,6 @@ const submitVideoCase = async () => {
isSubmitting.value = true; isSubmitting.value = true;
// 构造提交数据
const submitData: CreateVideoCaseReq | UpdateVideoCaseReq = { const submitData: CreateVideoCaseReq | UpdateVideoCaseReq = {
title: form.value.title.trim(), title: form.value.title.trim(),
intro: form.value.intro.trim(), intro: form.value.intro.trim(),
@@ -462,18 +565,16 @@ const submitVideoCase = async () => {
sort: form.value.sort || 0, sort: form.value.sort || 0,
}; };
// 编辑模式补充id
if (form.value.id) { if (form.value.id) {
(submitData as UpdateVideoCaseReq).id = form.value.id; (submitData as UpdateVideoCaseReq).id = form.value.id;
} }
try { try {
let url = `${API_BASE_URL}/video-cases`; let url = 'http://localhost:8080/api/video-cases';
let method = 'POST'; let method = 'POST';
// 编辑模式
if (form.value.id) { if (form.value.id) {
url = `${API_BASE_URL}/video-cases/${form.value.id}`; url = `http://localhost:8080/api/video-cases`;
method = 'PUT'; method = 'PUT';
} }
@@ -485,12 +586,13 @@ const submitVideoCase = async () => {
if (!response.ok) { if (!response.ok) {
const errData = await response.json().catch(() => null); const errData = await response.json().catch(() => null);
throw new Error(errData?.msg || '提交失败'); throw new Error(errData?.message || '提交失败');
} }
ElMessage.success(form.value.id ? '视频案例更新成功!' : '视频案例新增成功!'); ElMessage.success(form.value.id ? '视频案例更新成功!' : '视频案例新增成功!');
drawerVisible.value = false; drawerVisible.value = false;
form.value = defaultFormState(); form.value = defaultFormState();
uploadFileList.value = [];
fetchData(); fetchData();
} catch (error) { } catch (error) {
@@ -503,35 +605,26 @@ const submitVideoCase = async () => {
</script> </script>
<style scoped> <style scoped>
/* 基础样式 */
::v-deep(.el-drawer__body) { padding: 20px 0 !important; } ::v-deep(.el-drawer__body) { padding: 20px 0 !important; }
.required::after { content: '*'; color: #f56c6c; margin-left: 4px; } .required::after { content: '*'; color: #f56c6c; margin-left: 4px; }
/* 表格样式 */
.el-table .el-table__cell { vertical-align: middle; } .el-table .el-table__cell { vertical-align: middle; }
.el-table__header-wrapper th { background-color: #fafafa !important; font-weight: 600; color: #333; } .el-table__header-wrapper th { background-color: #fafafa !important; font-weight: 600; color: #333; }
.action-buttons .el-button { margin-right: 8px; } .action-buttons .el-button { margin-right: 8px; }
.intro-preview { color: #666; line-height: 1.5; word-break: break-all; } .intro-preview { color: #666; line-height: 1.5; word-break: break-all; }
/* 弹窗样式 */
.video-container { padding: 10px 0; } .video-container { padding: 10px 0; }
.detail-container { line-height: 1.8; } .detail-container { line-height: 1.8; }
/* 分页器样式 */
.custom-pagination { justify-content: flex-end !important; } .custom-pagination { justify-content: flex-end !important; }
.custom-pagination .el-pagination__total, .custom-pagination .el-pagination__total,
.custom-pagination .el-pagination__sizes, .custom-pagination .el-pagination__sizes,
.custom-pagination .el-pagination__jump { margin-right: 16px !important; } .custom-pagination .el-pagination__jump { margin-right: 16px !important; }
.custom-pagination .el-pagination__jump .el-input { width: 60px !important; } .custom-pagination .el-pagination__jump .el-input { width: 60px !important; }
/* 抽屉内表单样式 */
.publish-form-container { padding: 0 20px; } .publish-form-container { padding: 0 20px; }
.form-group { margin-bottom: 24px; } .form-group { margin-bottom: 24px; }
.form-label { display: block; margin-bottom: 8px; color: #333; font-size: 14px; font-weight: 600; } .form-label { display: block; margin-bottom: 8px; color: #333; font-size: 14px; font-weight: 600; }
.el-textarea__inner { min-height: 100px !important; } .el-textarea__inner { min-height: 100px !important; }
.video-upload-icon { font-size: 24px; color: #666; }
/* 响应式调整 */ ::v-deep(.video-upload .el-upload--picture-card) { width: 120px; height: 120px; line-height: 120px; }
@media (max-width: 1440px) { @media (max-width: 1440px) {
.el-table-column { min-width: 120px; } .el-table-column { min-width: 120px; }
} }
</style> </style>