+
0}>
+
+
+
+
+ {isLimitReached
+ ? t('textbook.resource.uploadLimitReached')
+ : uploadFiles.some((f) => f.status === 'uploading')
+ ? t('commen.uploadInProgress')
+ : t('textbook.resource.uploadTips')}
+
+
+ {isLimitReached
+ ? t('textbook.resource.uploadLimitReachedDesc')
+ : uploadFiles.some((f) => f.status === 'uploading')
+ ? t('commen.waitForUploadComplete')
+ : t('textbook.resource.uploadTips2')}
+
+
+
+ {/* 上传文件列表 */}
+ {uploadFiles.length > 0 && (
+
+
{
+ const statusInfo = getStatusInfo(item.status);
+ return (
+
+
+
+
+
+
+
{item.name}
+
+ ({formatFileSize(item.size)})
+
+
+ {statusInfo.text}
+
+
+
+
+ {item.status === 'uploading' && (
+
+ )}
+ }
+ size="small"
+ onClick={() => removeUploadFile(item.id)}
+ disabled={item.status === 'uploading'}
+ />
+
+
+ {item.status === 'uploading' && (
+
+ )}
+
+
+ );
+ }}
+ />
+
+ )}
+
+ );
+};
diff --git a/app/backend/src/pages/textbook/compenents/chapterTree.tsx b/app/backend/src/pages/textbook/compenents/chapterTree.tsx
index 43d45a7..5094306 100644
--- a/app/backend/src/pages/textbook/compenents/chapterTree.tsx
+++ b/app/backend/src/pages/textbook/compenents/chapterTree.tsx
@@ -402,14 +402,6 @@ export const ChapterTree = (props: PropInterface) => {
setParentChapter(null);
};
- const handleSubmit = () => {
- /* setEditSelectItem(null);
- setEditSelectId(null);
- setIsEditClass(false);*/
- setModalVisible(false);
- refreshTreeData();
- };
-
return (
{contextHolder}
@@ -465,7 +457,7 @@ export const ChapterTree = (props: PropInterface) => {
)}
{dragEnabled &&
{t('textbook.chapter.tips')}
}
{isMoreThanThree &&
{t('textbook.chapter.tips3')}
}
- {treeData.length > 0 ? (
+ {!isLoading && treeData.length > 0 ? (
{
onCancel={handleAddCancel}
resourceId={editId}
bookId={bookId}
- typeOptions={TypeOptions}
>
)}
diff --git a/app/backend/src/utils/index.ts b/app/backend/src/utils/index.ts
index bb258d7..b0954d5 100644
--- a/app/backend/src/utils/index.ts
+++ b/app/backend/src/utils/index.ts
@@ -197,3 +197,214 @@ export function returnAppUrl(url: string): string {
export function blobValidate(data: any) {
return data.type !== 'application/json';
}
+
+/**
+ * 从视频文件提取第一帧作为封面
+ * @param file 视频文件
+ * @param time 截取的时间点(秒),默认0.1秒
+ * @param quality 封面质量(0-1),默认0.8
+ * @returns Promise base64格式的图片
+ */
+export const extractVideoThumbnail = (
+ file: File,
+ time: number = 0.1,
+ quality: number = 0.8
+): Promise => {
+ return new Promise((resolve, reject) => {
+ // 验证文件类型
+ if (!file.type.startsWith('video/')) {
+ reject(new Error('文件不是视频格式'));
+ return;
+ }
+
+ // 创建视频元素
+ const video = document.createElement('video');
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
+
+ if (!context) {
+ reject(new Error('无法创建canvas上下文'));
+ return;
+ }
+
+ // 创建对象URL
+ const url = URL.createObjectURL(file);
+
+ // 配置视频
+ video.src = url;
+ video.muted = true; // 静音以避免自动播放限制
+ video.crossOrigin = 'anonymous';
+ video.preload = 'metadata';
+
+ // 事件监听器
+ const handleError = (error?: Error) => {
+ cleanup();
+ reject(error || new Error('无法读取视频文件'));
+ };
+
+ const handleSuccess = () => {
+ cleanup();
+ };
+
+ const cleanup = () => {
+ video.removeEventListener('loadeddata', onLoaded);
+ video.removeEventListener('seeked', onSeeked);
+ video.removeEventListener('error', onError);
+ URL.revokeObjectURL(url);
+ };
+
+ const onError = () => {
+ handleError(new Error('视频加载失败'));
+ };
+
+ const onLoaded = () => {
+ // 检查视频尺寸
+ if (video.videoWidth === 0 || video.videoHeight === 0) {
+ handleError(new Error('视频尺寸无效'));
+ return;
+ }
+
+ // 设置canvas尺寸
+ // 限制最大尺寸,避免生成过大的图片
+ const maxWidth = 800;
+ const maxHeight = 450;
+
+ let width = video.videoWidth;
+ let height = video.videoHeight;
+
+ // 按比例缩放
+ if (width > maxWidth || height > maxHeight) {
+ const ratio = Math.min(maxWidth / width, maxHeight / height);
+ width = Math.floor(width * ratio);
+ height = Math.floor(height * ratio);
+ }
+
+ canvas.width = width;
+ canvas.height = height;
+
+ // 尝试截取指定时间点的帧
+ video.currentTime = Math.min(time, video.duration || 1);
+ };
+
+ const onSeeked = () => {
+ try {
+ // 绘制视频帧到canvas
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
+
+ // 将canvas转为base64
+ const thumbnail = canvas.toDataURL('image/jpeg', quality);
+
+ // 检查生成的图片是否有效
+ if (!thumbnail || thumbnail.length < 100) {
+ handleError(new Error('生成封面失败'));
+ return;
+ }
+
+ resolve(thumbnail);
+ handleSuccess();
+ } catch (error) {
+ handleError(error instanceof Error ? error : new Error('生成封面失败'));
+ }
+ };
+
+ // 绑定事件
+ video.addEventListener('loadeddata', onLoaded);
+ video.addEventListener('seeked', onSeeked);
+ video.addEventListener('error', onError);
+
+ // 设置超时处理
+ const timeout = setTimeout(() => {
+ handleError(new Error('生成封面超时'));
+ }, 10000); // 10秒超时
+
+ // 清理超时
+ const originalResolve = resolve;
+ const originalReject = reject;
+
+ resolve = ((...args) => {
+ clearTimeout(timeout);
+ originalResolve(...args);
+ }) as typeof resolve;
+
+ reject = ((...args) => {
+ clearTimeout(timeout);
+ originalReject(...args);
+ }) as typeof reject;
+
+ // 开始加载视频
+ video.load();
+ });
+};
+
+/**
+ * 生成默认的视频封面(当提取失败时使用)
+ * @param width 封面宽度
+ * @param height 封面高度
+ * @param text 封面文字
+ * @returns base64格式的SVG图片
+ */
+export const generateDefaultVideoPoster = (
+ width: number = 800,
+ height: number = 450,
+ text: string = '视频预览'
+): string => {
+ const colors = [
+ { bg: '#1890ff', text: '#ffffff' }, // 蓝色
+ { bg: '#52c41a', text: '#ffffff' }, // 绿色
+ { bg: '#fa8c16', text: '#ffffff' }, // 橙色
+ { bg: '#f5222d', text: '#ffffff' }, // 红色
+ { bg: '#722ed1', text: '#ffffff' }, // 紫色
+ ];
+
+ const color = colors[Math.floor(Math.random() * colors.length)];
+
+ const svg = `
+
+ `;
+
+ return `data:image/svg+xml;base64,${btoa(svg)}`;
+};
+
+/**
+ * base64转Blob对象
+ */
+export const dataURLtoBlob = (dataurl: string): Blob => {
+ const arr = dataurl.split(',');
+ const mimeMatch = arr[0].match(/:(.*?);/);
+ const mime = mimeMatch ? mimeMatch[1] : 'image/jpeg';
+ const bstr = atob(arr[1]);
+ let n = bstr.length;
+ const u8arr = new Uint8Array(n);
+
+ while (n--) {
+ u8arr[n] = bstr.charCodeAt(n);
+ }
+
+ return new Blob([u8arr], { type: mime });
+};
+
+/**
+ * 获取文件扩展名
+ */
+export const getFileExtension = (filename: string): string => {
+ return filename.slice(((filename.lastIndexOf('.') - 1) >>> 0) + 2);
+};