Compare commits

...

2 Commits

Author SHA1 Message Date
0d5781d07a Merge branch 'dev-p' 2025-12-02 15:23:05 +08:00
1400a6b7b8 Editor upload local video and img 2025-12-02 15:21:43 +08:00
16 changed files with 1432 additions and 540 deletions

View File

@ -250,3 +250,25 @@ export function getKnowledgeByCodesApi(codes: string) {
/*
* resource List
* */
export function AddResourceItemApi(
bookId: number,
name: string,
knowledgeCode: string,
txtDesc: string,
extension: string,
size: number,
path: string,
chapterId?: string
) {
return client.post(`/jc/resource`, {
bookId,
name,
knowledgeCode,
txtDesc,
extension,
size,
path,
chapterId,
});
}

View File

@ -446,7 +446,7 @@ export const AddQuestion = (props: PropsInterface) => {
<Typography.Text></Typography.Text>
<Select
style={{ width: 150 }}
placeholder={selectedTextbookId ? "请选择知识点" : "请先选择教材"}
placeholder={selectedTextbookId ? '请选择知识点' : '请先选择教材'}
value={knowledgeCode || undefined}
disabled={!selectedTextbookId}
loading={knowledgeLoading}

View File

@ -78,7 +78,14 @@
"uploadDrop": "请将视频文件拖入此处上传",
"myDevice": "选择文件",
"uploadError": "当前文件不可上传",
"exportPaper": "导出答卷"
"exportPaper": "导出答卷",
"uploadCancelled": "上传已取消",
"uploadInProgress": "文件上传中",
"waitForUploadComplete": "请等待当前文件上传完成",
"cancel": "取消",
"uploading": "上传中",
"uploadSuccess": "上传成功",
"uploadFailed": "上传失败"
},
"error": {
"text1": "无权限操作",
@ -2069,8 +2076,11 @@
"typePlaceholder": "请选择资源类型",
"desc": "资源描述",
"descPlaceholder": "请输入资源描述",
"knowledgeId": "所属知识点",
"knowledgePlaceholder": "请选择所属知识点",
"chapter": "所属章节",
"chapterPlaceholder": "请选择所属章节",
"uploadPlaceholder": "请上传文件",
"uploadTips": "点击或拖拽文件到此处上传",
"uploadTips2": "支持视频、图片、文档、音频等常见格式",
"btnSave": "保存内容",
@ -2084,8 +2094,12 @@
"doc": "文档",
"audio": "音频",
"other": "其他"
}
},
"uploadLimitReached": "已达到上传限制",
"uploadLimitReachedDesc": "无法上传更多文件",
"singleFileLimit": "只能上传一个文件",
"multiFileLimit": "最多只能上传 {{maxCount}} 个文件",
"remainingCount": "还可上传 {{remaining}} 个文件(最多 {{maxCount}} 个)"
}
}
}

View File

@ -59,18 +59,21 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
},
});
// 加载教材列表作为级联选择器第一级
textbook.getTextbookSelectListApi().then((res: any) => {
if (res.data && Array.isArray(res.data)) {
const options: CascaderOption[] = res.data.map((item: any) => ({
value: item.id,
label: item.title,
isLeaf: false, // 表示有子节点
}));
setCascaderOptions(options);
}
}).catch((err) => {
console.error('加载教材列表失败:', err);
});
textbook
.getTextbookSelectListApi()
.then((res: any) => {
if (res.data && Array.isArray(res.data)) {
const options: CascaderOption[] = res.data.map((item: any) => ({
value: item.id,
label: item.title,
isLeaf: false, // 表示有子节点
}));
setCascaderOptions(options);
}
})
.catch((err) => {
console.error('加载教材列表失败:', err);
});
setInit(false);
}
}, [form, open, id]);
@ -81,24 +84,27 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
targetOption.loading = true;
// 加载该教材下的知识点
textbook.getKnowledgeListApi(targetOption.value as number).then((res: any) => {
targetOption.loading = false;
if (res.data && Array.isArray(res.data)) {
targetOption.children = res.data.map((item: any) => ({
value: item.knowledgeCode || item.knowledge_code,
label: item.name,
isLeaf: true, // 知识点是叶子节点
}));
} else {
textbook
.getKnowledgeListApi(targetOption.value as number)
.then((res: any) => {
targetOption.loading = false;
if (res.data && Array.isArray(res.data)) {
targetOption.children = res.data.map((item: any) => ({
value: item.knowledgeCode || item.knowledge_code,
label: item.name,
isLeaf: true, // 知识点是叶子节点
}));
} else {
targetOption.children = [];
}
setCascaderOptions([...cascaderOptions]);
})
.catch((err) => {
console.error('加载知识点失败:', err);
targetOption.loading = false;
targetOption.children = [];
}
setCascaderOptions([...cascaderOptions]);
}).catch((err) => {
console.error('加载知识点失败:', err);
targetOption.loading = false;
targetOption.children = [];
setCascaderOptions([...cascaderOptions]);
});
setCascaderOptions([...cascaderOptions]);
});
};
const items: TabsProps['items'] = [
@ -352,11 +358,13 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
}
}
setLoading(true);
question.questionStore(id, params, values.level, Number(type), knowledgeCode).then((res: any) => {
setLoading(false);
message.success(t('commen.saveSuccess'));
onCancel();
});
question
.questionStore(id, params, values.level, Number(type), knowledgeCode)
.then((res: any) => {
setLoading(false);
message.success(t('commen.saveSuccess'));
onCancel();
});
};
const onFinishFailed = (errorInfo: any) => {
@ -475,10 +483,7 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
label="关联知识点"
name="knowledge_cascader"
>
<Form.Item label="关联知识点" name="knowledge_cascader">
<Cascader
options={cascaderOptions}
loadData={loadKnowledgeData as CascaderProps<CascaderOption>['loadData']}
@ -491,7 +496,9 @@ export const QuestionsDetailCreate: React.FC<PropInterface> = ({ id, open, onCan
filter: (inputValue: string, path: CascaderOption[]) =>
path.some(
(option) =>
(option.label as string).toLowerCase().indexOf(inputValue.toLowerCase()) > -1
(option.label as string)
.toLowerCase()
.indexOf(inputValue.toLowerCase()) > -1
),
}}
/>

View File

@ -12,7 +12,7 @@ import {
SaveChapterContentApi,
} from '../../api/textbook';
import TextbookEditor from './compenents/TextEditor/TextbookEditor';
import { Empty } from 'antd';
import { Empty, Spin } from 'antd';
export interface ChapterItemModel {
bookId: number;

View File

@ -0,0 +1,515 @@
import React, { useEffect, useState } from 'react';
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
import { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import '@wangeditor/editor/dist/css/style.css';
import './indesx.module.less';
import { Progress, Spin } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import {
dataURLtoBlob,
extractVideoThumbnail,
generateDefaultVideoPoster,
} from '../../../../utils';
// 自定义上传配置接口
interface UploadConfig {
getToken: () => string;
getTenant: () => string;
uploadUrl: string;
}
interface WangEditorProps {
value?: string;
editor: IDomEditor | null;
onChange?: (html: string) => void;
onCreated?: (editor: IDomEditor) => void;
defaultEditorConfig?: Partial<IEditorConfig>;
toolbarConfig?: Partial<IToolbarConfig>;
readonly?: boolean;
height?: number | string;
// 上传相关配置
uploadConfig: UploadConfig;
}
const EditorComponent: React.FC<WangEditorProps> = ({
value,
onChange,
onCreated,
defaultEditorConfig = {},
toolbarConfig,
readonly = false,
height = 550,
editor,
uploadConfig,
}) => {
const { uploadUrl, getToken, getTenant } = uploadConfig;
const [uploadState, setUploadState] = useState<{
visible: boolean;
progress: number;
filename: string;
}>({
visible: false,
progress: 0,
filename: '',
});
// 自定义上传IMG
const customUploadImage = async (file: File, insertFn: any) => {
if (!uploadConfig) {
throw new Error('请配置上传参数');
}
try {
const { getToken, getTenant, uploadUrl } = uploadConfig;
let fullUploadUrl = uploadUrl;
if (!fullUploadUrl.startsWith('http')) {
fullUploadUrl =
window.location.origin +
(fullUploadUrl.startsWith('/') ? fullUploadUrl : '/' + fullUploadUrl);
}
fullUploadUrl = fullUploadUrl.replace(/\/+$/, '');
const apiUrl = `${fullUploadUrl}/backend/v1/localUpload/upload`;
// 2. 准备FormData
const formData = new FormData();
formData.append('file', file);
const token = getToken();
const tenantId = getTenant();
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'tenant-id': tenantId,
// 注意:不要设置 Content-Type让浏览器自动设置
},
body: formData,
});
const result = await response.json();
console.log('上传响应:', result);
if (!response.ok || result.code !== 0) {
throw new Error(result.message || `上传失败 (${response.status})`);
}
// 插入图片到编辑器
const imageData = result.data;
if (!imageData) {
throw new Error('服务器返回数据格式错误');
}
let imageUrl = imageData.url;
// 如果还是没有URL尝试其他可能的字段
if (!imageUrl && typeof imageData === 'string') {
imageUrl = imageData;
}
if (!imageUrl) {
console.error('服务器返回数据:', result);
throw new Error('未找到图片URL字段');
}
// 确保URL是完整路径
if (
!imageUrl.startsWith('http') &&
!imageUrl.startsWith('//') &&
!imageUrl.startsWith('data:')
) {
if (imageUrl.startsWith('/')) {
imageUrl = window.location.origin + imageUrl;
} else {
imageUrl = fullUploadUrl + '/' + imageUrl;
}
}
insertFn(imageUrl, imageData.newFileName || file.name, imageUrl);
} catch (error) {
let errorMessage = '上传失败';
if (error instanceof Error) {
errorMessage = error.message;
}
alert(errorMessage);
throw error;
}
};
// 自定义视频上传VIDEO
const customUploadVideo = async (file: File, insertFn: any) => {
if (!uploadConfig) {
throw new Error('请配置上传参数');
}
// 1. 先提取视频封面
let videoPoster: string;
try {
videoPoster = await extractVideoThumbnail(file);
} catch (thumbnailError) {
videoPoster = generateDefaultVideoPoster();
}
try {
setUploadState({
visible: true,
progress: 0,
filename: file.name,
});
let fullUploadUrl = uploadUrl;
if (!fullUploadUrl.startsWith('http')) {
fullUploadUrl =
window.location.origin +
(fullUploadUrl.startsWith('/') ? fullUploadUrl : '/' + fullUploadUrl);
}
fullUploadUrl = fullUploadUrl.replace(/\/+$/, '');
const apiUrl = `${fullUploadUrl}/backend/v1/localUpload/upload`;
const formData = new FormData();
formData.append('file', file);
// 如果需要区分视频上传,可以添加额外参数
formData.append('file_type', 'video');
try {
if (videoPoster.startsWith('data:image')) {
const posterBlob = dataURLtoBlob(videoPoster);
const posterFile = new File([posterBlob], `poster_${Date.now()}.jpg`, {
type: 'image/jpeg',
});
formData.append('poster', posterFile);
console.log('封面文件已添加到上传');
}
} catch (error) {
console.warn('封面文件转换失败,跳过封面上传:', error);
}
formData.append('file_type', 'video');
formData.append('filename', file.name);
formData.append('filesize', file.size.toString());
formData.append('filetype', file.type);
// 3. 准备请求头
const token = getToken();
const tenantId = getTenant();
// 4. 创建XMLHttpRequest以支持进度显示
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percent = Math.round((event.loaded / event.total) * 100);
setUploadState((prev) => ({
...prev,
progress: percent,
}));
}
};
xhr.onload = () => {
setUploadState({
visible: false,
progress: 0,
filename: '',
});
if (xhr.status === 200) {
try {
const result = JSON.parse(xhr.responseText);
if (result.code !== 0) {
reject(new Error(result.message || `上传失败 (${xhr.status})`));
return;
}
const videoData = result.data;
if (!videoData) {
reject(new Error('服务器返回数据格式错误'));
return;
}
// 获取视频URL
let videoUrl = videoData.url || videoData.path || videoData.src || videoData.videoUrl;
// 如果还是没有URL尝试其他可能的字段
if (!videoUrl && typeof videoData === 'string') {
videoUrl = videoData;
}
if (!videoUrl) {
console.error('服务器返回数据:', result);
reject(new Error('未找到视频URL字段'));
return;
}
if (
!videoUrl.startsWith('http') &&
!videoUrl.startsWith('//') &&
!videoUrl.startsWith('data:')
) {
if (videoUrl.startsWith('/')) {
videoUrl = window.location.origin + videoUrl;
} else {
videoUrl = fullUploadUrl + '/' + videoUrl;
}
}
const poster = videoData.poster || videoPoster;
const videoInfo = {
src: videoUrl,
poster: poster, // 视频封面(如果有)
alt: videoData.newFileName || videoData.name || file.name || '视频',
width: videoData.width || 'auto',
height: videoData.height || 'auto',
};
insertFn(videoUrl, poster);
resolve(videoInfo);
} catch (parseError) {
console.error('解析响应失败:', parseError);
reject(new Error('解析服务器响应失败'));
}
} else {
reject(new Error(`上传失败 (${xhr.status})`));
}
};
xhr.onerror = () => {
setUploadState({
visible: false,
progress: 0,
filename: '',
});
reject(new Error('网络错误,上传失败'));
};
xhr.onabort = () => {
reject(new Error('上传被取消'));
};
// 5. 发送请求
xhr.open('POST', apiUrl, true);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.setRequestHeader('tenant-id', tenantId);
xhr.send(formData);
});
} catch (error) {
console.error('视频上传失败:', error);
// 给用户友好的错误提示
let errorMessage = '视频上传失败';
if (error instanceof Error) {
errorMessage = error.message;
}
// 可以在这里显示错误提示
alert(errorMessage);
throw error;
}
};
const editorConfig: Partial<IEditorConfig> = {
placeholder: '请输入内容...',
readOnly: readonly,
MENU_CONF: {
uploadImage: {
customUpload: uploadConfig ? customUploadImage : undefined,
allowedFileTypes: ['image/*'],
maxFileSize: 10 * 1024 * 1024, // 10MB
onProgress: (progress: number) => {
console.log('上传进度:', progress + '%');
},
onError: (error: Error, file: File) => {
console.error('上传错误:', error, file);
},
customValidate: (file: File) => {
const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
return '请上传图片文件 (JPEG, PNG, GIF, WebP)';
}
return true;
},
base64LimitSize: 100 * 1024, // 100KB以下转base64
},
uploadVideo: {
customUpload: uploadConfig ? customUploadVideo : undefined,
allowedFileTypes: ['video/*'],
maxFileSize: 500 * 1024 * 1024, // 500MB
timeout: 300 * 1000, // 5分钟
onProgress: (progress: number) => {
console.log('视频上传进度:', progress + '%');
},
onError: (error: Error, file: File) => {
console.error('视频上传错误:', error, file);
alert(`视频上传失败: ${error.message}`);
},
customValidate: (file: File) => {
// 支持的视频格式
const validVideoTypes = [
'video/mp4',
'video/mpeg',
'video/ogg',
'video/webm',
'video/quicktime',
'video/x-msvideo',
'video/x-flv',
'video/x-matroska',
];
if (!validVideoTypes.includes(file.type)) {
return '请上传支持的视频格式 (MP4, MPEG, OGG, WebM, MOV, AVI, FLV, MKV)';
}
// 检查文件大小(这里设为 500MB
const maxSize = 500 * 1024 * 1024;
if (file.size > maxSize) {
const sizeInMB = (file.size / (1024 * 1024)).toFixed(2);
const maxSizeInMB = (maxSize / (1024 * 1024)).toFixed(0);
return `视频文件过大 (${sizeInMB}MB),最大支持 ${maxSizeInMB}MB`;
}
return true;
},
},
},
};
// 合并配置
const mergedEditorConfig = { ...defaultEditorConfig, ...editorConfig };
// 深度合并 MENU_CONF确保自定义上传配置不被覆盖
if (editorConfig.MENU_CONF) {
mergedEditorConfig.MENU_CONF = {
...defaultEditorConfig.MENU_CONF,
...editorConfig.MENU_CONF,
};
}
useEffect(() => {
return () => {
window.localStorage.removeItem('html');
};
}, []);
return (
<div className="editor-container">
{/* 上传进度组件 */}
<Spin
spinning={uploadState.visible}
size="large"
fullscreen={uploadState.visible}
tip={
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 16,
marginTop: 20,
}}
>
{/* 自定义旋转图标 */}
<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} />
<div
style={{
fontSize: 16,
fontWeight: 500,
color: '#333',
maxWidth: 400,
wordBreak: 'break-word',
textAlign: 'center',
}}
>
{uploadState.filename.length > 30
? uploadState.filename.substring(0, 30) + '...'
: uploadState.filename}
</div>
{/* 进度条 */}
<div style={{ width: 300 }}>
<Progress
percent={uploadState.progress}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
strokeWidth={6}
strokeLinecap="round"
trailColor="#f0f0f0"
format={(percent) => (
<span
style={{
fontSize: 24,
fontWeight: 'bold',
color: percent === 100 ? '#52c41a' : '#1890ff',
}}
>
{percent}%
</span>
)}
/>
</div>
<div
style={{
fontSize: 14,
color: '#666',
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
{uploadState.progress === 100 ? (
<>
<span style={{ color: '#52c41a' }}></span>
<span>...</span>
</>
) : (
<>
<span style={{ color: '#fa8c16' }}></span>
<span>...</span>
</>
)}
</div>
</div>
}
style={{ fontSize: 48 }}
>
<div
style={{
minHeight: 40,
}}
>
<Toolbar
editor={editor}
defaultConfig={toolbarConfig}
mode="default"
style={{ borderBottom: '1px solid #ccc', backgroundColor: '#eee' }}
/>
</div>
<div
style={{
height: height,
overflowY: 'hidden',
paddingBottom: 15,
boxSizing: 'border-box',
}}
>
<Editor
defaultConfig={mergedEditorConfig}
value={value}
onCreated={onCreated}
onChange={(editor) => {
const currentHtml = editor.getHtml();
onChange?.(currentHtml);
window.localStorage.setItem('html', currentHtml);
}}
mode="default"
style={{
height: '100%',
boxSizing: 'border-box',
}}
/>
</div>
</Spin>
</div>
);
};
export default EditorComponent;

View File

@ -0,0 +1,11 @@
.editor-container {
border: none;
.w-e-bar {
background-color: #fafafa;
}
.w-e-text-container {
background-color: #fff;
}
}

View File

@ -1,9 +1,9 @@
import React, { useState, useEffect } from 'react';
import { Modal, Form, Input, message, Space, Spin, type FormProps, Select } from 'antd';
import { Modal, Form, Input, message, Spin, Select } from 'antd';
import { useTranslation } from 'react-i18next';
import { InboxOutlined } from '@ant-design/icons';
import Dragger from 'antd/es/upload/Dragger';
import TextArea from 'antd/es/input/TextArea';
import { AddResourceItemApi } from '../../../../api/textbook';
import { DraggerUpload } from '../Upload/DraggerUpload';
interface ModalPropsType {
isOpen: boolean;
@ -11,14 +11,25 @@ interface ModalPropsType {
onCancel: () => void;
isEdit: boolean;
resourceId: number;
typeOptions: any;
}
interface Option {
value: string | number;
title: string;
children?: Option[];
}
const CreateResourceModal = (props: ModalPropsType) => {
const { t } = useTranslation();
const { isOpen, onCancel, bookId, resourceId, isEdit, typeOptions } = props;
const { isOpen, onCancel, bookId, resourceId, isEdit } = props;
const [form] = Form.useForm();
const [loading, setLoading] = useState<boolean>(false);
const [spinInit, setSpinInit] = useState(false);
const [knowledgeOption, setKnowledgeOption] = useState<Option[]>([]);
const [fileSize, setFileSize] = useState<number>(0);
const [fileName, setFileName] = useState<string>('');
const [filePath, setFilePath] = useState<string>('');
const [fileExtension, setExtension] = useState<string>('');
const [knowledgeCode, setKnowledgeCode] = useState<string>('');
useEffect(() => {
setSpinInit(true);
@ -26,22 +37,32 @@ const CreateResourceModal = (props: ModalPropsType) => {
getDetail();
} else {
setSpinInit(false);
form.setFieldsValue({
type: '视频',
});
}
}, [form, isEdit]);
}, [isEdit, resourceId]);
/* useEffect(() => {
getChapterTreeData();
}, []);*/
/* const getKnowledgeOptionData = () => {
GetChapterTreeApi({ bookId }).then((res: any) => {
const resData: any = res.data;
// console.log(resData, '>>>');
setKnowledgeOption(resData);
});
};*/
const getDetail = () => {
/* if () {
form.setFieldsValue({
total_progress: hour.duration,
});
}*/
setSpinInit(false);
};
const onFinish = (values: any) => {
console.log('表单提交:', values);
const { name = '', desc = '', chapterId = '', type = '' } = values;
const { name = '', txtDesc = '', chapterId = '' } = values;
try {
if (isEdit) {
@ -69,20 +90,15 @@ const CreateResourceModal = (props: ModalPropsType) => {
setLoading(false);
});*/
} else {
/* UpdateTextbookApi(
editId,
values.title,
thumb,
values.short_desc,
values.author,
values.major,
dep_ids,
group_ids,
user_ids,
values.publish_time,
values.publish_unit,
values.create_time,
values.isbn
AddResourceItemApi(
bookId,
name,
knowledgeCode,
txtDesc,
fileExtension,
fileSize,
filePath,
chapterId
)
.then((res: any) => {
setLoading(false);
@ -91,7 +107,7 @@ const CreateResourceModal = (props: ModalPropsType) => {
})
.catch((e) => {
setLoading(false);
});*/
});
}
onCancel();
} catch (error) {
@ -99,20 +115,13 @@ const CreateResourceModal = (props: ModalPropsType) => {
}
};
const handleSelectChange = (e: any) => {
console.log(e, '>>.select e');
};
const onFinishFailed = (errorInfo: any) => {
console.log('Failed:', errorInfo);
};
//弹窗确认
const handleOk = async () => {
try {
await form.validateFields(); // 手动触发表单验证
await onFinish(form.getFieldsValue()); // 调用 onFinish 并传递当前表单的值
} catch (error) {
console.error('表单验证失败:', error);
} finally {
onCancel();
}
};
return (
<>
<Modal
@ -145,45 +154,78 @@ const CreateResourceModal = (props: ModalPropsType) => {
autoComplete="off"
>
<Form.Item
name="name"
label={t('textbook.resource.title1')}
rules={[{ required: true, message: t('textbook.resource.title1Placeholder') }]}
>
<Input allowClear placeholder={t('textbook.resource.title1Placeholder')} />
</Form.Item>
<Form.Item
name="desc"
name="txtDesc"
label={t('textbook.resource.desc')}
rules={[{ required: true, message: t('textbook.resource.descPlaceholder') }]}
>
<TextArea allowClear placeholder={t('textbook.resource.descPlaceholder')} />
</Form.Item>
<Form.Item
name="chapterId"
label={t('textbook.resource.chapter')}
rules={[{ required: true, message: t('textbook.resource.chapterPlaceholder') }]}
>
<Input allowClear placeholder={t('textbook.resource.chapterPlaceholder')} />
</Form.Item>
<Form.Item
name="type"
label={t('textbook.resource.type')}
rules={[{ required: true, message: t('textbook.resource.typePlaceholder') }]}
>
<Select
allowClear
placeholder={t('textbook.resource.typePlaceholder')}
options={typeOptions}
/>
</Form.Item>
<Dragger {...props}>
{/* <Dragger {...uploadProps}>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">{t('textbook.resource.uploadTips')}</p>
<p className="ant-upload-hint">{t('textbook.resource.uploadTips2')}</p>
</Dragger>
</Dragger>*/}
<Form.Item
name="path"
label={t('textbook.resource.upload')}
rules={[{ required: true, message: t('textbook.resource.uploadPlaceholder') }]}
>
<DraggerUpload
currentCount={0}
maxCount={1}
onUploadSuccess={(fileInfo: {
type: any;
fileExtension: any;
fileSize: any;
filePath: any;
fileName: any;
}) => {
// 类型: fileInfo.type, // "image/jpeg"
// 扩展名: fileInfo.fileExtension, // "jpg"
// 大小: fileInfo.fileSize, // 102400
// 路径: fileInfo.filePath, // "/uploads/2024/01/abc123.jpg"
// 文件名: fileInfo.fileName, // "example.jpg"
form.setFieldsValue({
name: fileInfo.fileName,
path: fileInfo.filePath,
});
setFileSize(fileInfo.fileSize);
setFileName(fileInfo.fileName);
setFilePath(fileInfo.filePath);
setExtension(fileInfo.fileExtension);
}}
onUploadError={(error) => {
console.error('上传失败:', error);
}}
/>
</Form.Item>
{fileName && (
<Form.Item
name="name"
label={t('textbook.resource.title1')}
rules={[{ required: true, message: t('textbook.resource.title1Placeholder') }]}
>
<Input allowClear placeholder={t('textbook.resource.title1Placeholder')} />
</Form.Item>
)}
<Form.Item
name="knowledge"
label={t('textbook.resource.knowledgeId')}
rules={[{ required: true, message: t('textbook.resource.konwledgePlaceholder') }]}
>
<Select
mode="multiple"
placeholder="Please select"
defaultValue={[]}
onChange={handleSelectChange}
style={{ width: '100%' }}
options={knowledgeOption}
/>
</Form.Item>
</Form>
</div>
</Modal>

View File

@ -0,0 +1,64 @@
import React from 'react';
import { Empty } from 'antd';
interface ContentPreviewProps {
content?: string;
height?: number | string;
backgroundColor?: string;
emptyText?: string;
emptyDescription?: string;
}
const ContentPreview: React.FC<ContentPreviewProps> = ({
content,
height = 590,
backgroundColor = '#fafafa',
emptyText = '暂无内容',
emptyDescription = '请在编辑器中添加内容',
}) => {
const hasContent = content && content !== '<p><br></p>' && content !== '<p></p>';
return (
<div
style={{
height,
borderRadius: '6px',
padding: '20px',
boxSizing: 'border-box',
backgroundColor,
overflowY: 'auto',
width: '100%',
overflowX: 'hidden',
}}
>
{hasContent ? (
<div style={{ width: '100%' }} dangerouslySetInnerHTML={{ __html: content }} />
) : (
<div
style={{
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
textAlign: 'center',
color: '#999',
fontSize: '16px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
<Empty description={false} />
</div>
<div style={{ marginBottom: '8px', fontWeight: 500 }}>{emptyText}</div>
<div style={{ fontSize: '14px', color: '#bfbfbf' }}>{emptyDescription}</div>
</div>
</div>
)}
</div>
);
};
export default ContentPreview;

View File

@ -1,91 +0,0 @@
/* Basic editor styles */
.tiptap {
:first-child {
margin-top: 0;
}
/* List styles */
ul,
ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
li p {
margin-top: 0.25em;
margin-bottom: 0.25em;
}
}
/* Heading styles */
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
margin-top: 2.5rem;
text-wrap: pretty;
}
h1,
h2 {
margin-top: 3.5rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.4rem;
}
h2 {
font-size: 1.2rem;
}
h3 {
font-size: 1.1rem;
}
h4,
h5,
h6 {
font-size: 1rem;
}
/* Code and preformatted text styles */
code {
background-color: var(--purple-light);
border-radius: 0.4rem;
color: var(--black);
font-size: 0.85rem;
padding: 0.25em 0.3em;
}
pre {
background: var(--black);
border-radius: 0.5rem;
color: var(--white);
font-family: 'JetBrainsMono', monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
}
}
blockquote {
border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem;
}
hr {
border: none;
border-top: 1px solid var(--gray-2);
margin: 2rem 0;
}
}

View File

@ -1,252 +0,0 @@
// components/EnhancedToolbar.tsx
import React from 'react';
import { Editor, useEditorState } from '@tiptap/react';
import { EditorState } from '../../../../types/editor';
import {
AlignCenterOutlined,
AlignLeftOutlined,
AlignRightOutlined,
BoldOutlined,
ItalicOutlined,
LineOutlined,
MenuOutlined,
OrderedListOutlined,
StrikethroughOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { Button } from 'antd';
interface EnhancedToolbarProps {
editor: Editor;
onFileUpload?: (file: File) => Promise<string>;
}
const EnhancedToolbar: React.FC<EnhancedToolbarProps> = ({ editor, onFileUpload }) => {
const editorState = useEditorState({
editor,
selector: (ctx): EditorState => {
// 添加需要的功能
return {
isBold: ctx.editor.isActive('bold') ?? false,
canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
isItalic: ctx.editor.isActive('italic') ?? false,
canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false,
isStrike: ctx.editor.isActive('strike') ?? false,
canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
isCode: ctx.editor.isActive('code') ?? false,
canCode: ctx.editor.can().chain().toggleCode().run() ?? false,
canClearMarks: ctx.editor.can().chain().unsetAllMarks().run() ?? false,
isParagraph: ctx.editor.isActive('paragraph') ?? false,
isHeading1: ctx.editor.isActive('heading', { level: 1 }) ?? false,
isHeading2: ctx.editor.isActive('heading', { level: 2 }) ?? false,
isHeading3: ctx.editor.isActive('heading', { level: 3 }) ?? false,
isHeading4: ctx.editor.isActive('heading', { level: 4 }) ?? false,
isHeading5: ctx.editor.isActive('heading', { level: 5 }) ?? false,
isHeading6: ctx.editor.isActive('heading', { level: 6 }) ?? false,
isBulletList: ctx.editor.isActive('bulletList') ?? false,
isOrderedList: ctx.editor.isActive('orderedList') ?? false,
isCodeBlock: ctx.editor.isActive('codeBlock') ?? false,
isBlockquote: ctx.editor.isActive('blockquote') ?? false,
canUndo: ctx.editor.can().chain().undo().run() ?? false,
canRedo: ctx.editor.can().chain().redo().run() ?? false,
isHighlight: ctx.editor.isActive('highlight') ?? false,
canHighlight: ctx.editor.can().chain().toggleHighlight().run() ?? false,
isTextAlignLeft: ctx.editor.isActive({ textAlign: 'left' }) ?? false,
isTextAlignCenter: ctx.editor.isActive({ textAlign: 'center' }) ?? false,
isTextAlignRight: ctx.editor.isActive({ textAlign: 'right' }) ?? false,
isTextAlignJustify: ctx.editor.isActive({ textAlign: 'justify' }) ?? false,
};
},
});
// 编辑顶部按钮
const toolbarSections = [
{
name: 'format',
buttons: [
{
icon: <BoldOutlined />,
title: '粗体',
action: () => editor.chain().focus().toggleBold().run(),
disabled: !editorState.canBold,
active: editorState.isBold,
},
{
icon: <ItalicOutlined />,
title: '斜体',
action: () => editor.chain().focus().toggleItalic().run(),
disabled: !editorState.canItalic,
active: editorState.isItalic,
},
{
icon: <StrikethroughOutlined />,
title: '删除线',
action: () => editor.chain().focus().toggleStrike().run(),
disabled: !editorState.canStrike,
active: editorState.isStrike,
},
/*{
icon: '</>',
title: '行内代码',
action: () => editor.chain().focus().toggleCode().run(),
disabled: !editorState.canCode,
active: editorState.isCode,
},*/
],
},
{
name: 'heading',
buttons: [
/*{
icon: '正文',
title: '正文',
action: () => editor.chain().focus().setParagraph().run(),
disabled: false,
active: editorState.isParagraph,
},*/
{
icon: 'H1',
title: '标题1',
action: () => editor.chain().focus().toggleHeading({ level: 1 }).run(),
disabled: false,
active: editorState.isHeading1,
},
{
icon: 'H2',
title: '标题2',
action: () => editor.chain().focus().toggleHeading({ level: 2 }).run(),
disabled: false,
active: editorState.isHeading2,
},
{
icon: 'H3',
title: '标题3',
action: () => editor.chain().focus().toggleHeading({ level: 3 }).run(),
disabled: false,
active: editorState.isHeading3,
},
],
},
{
name: 'alignment',
buttons: [
{
icon: <AlignLeftOutlined />,
title: '左对齐',
action: () => editor.chain().focus().setTextAlign('left').run(),
disabled: false,
active: editorState.isTextAlignLeft,
},
{
icon: <AlignCenterOutlined />,
title: '居中对齐',
action: () => editor.chain().focus().setTextAlign('center').run(),
disabled: false,
active: editorState.isTextAlignCenter,
},
{
icon: <AlignRightOutlined />,
title: '右对齐',
action: () => editor.chain().focus().setTextAlign('right').run(),
disabled: false,
active: editorState.isTextAlignRight,
},
{
icon: <MenuOutlined />,
title: '两端对齐',
action: () => editor.chain().focus().setTextAlign('justify').run(),
active: editorState.isTextAlignJustify,
disabled: false,
},
],
},
{
name: 'list',
buttons: [
{
icon: <UnorderedListOutlined />,
title: '无序列表',
action: () => editor.chain().focus().toggleBulletList().run(),
disabled: false,
active: editorState.isBulletList,
},
{
icon: <OrderedListOutlined />,
title: '有序列表',
action: () => editor.chain().focus().toggleOrderedList().run(),
active: editorState.isOrderedList,
disabled: false,
},
],
},
{
name: 'block',
buttons: [
{
icon: '❝',
title: '引用块',
action: () => editor.chain().focus().toggleBlockquote().run(),
active: editorState.isBlockquote,
disabled: false,
},
{
icon: '</>',
title: '代码块',
action: () => editor.chain().focus().toggleCodeBlock().run(),
active: editorState.isCodeBlock,
disabled: false,
},
{
icon: <LineOutlined />,
title: '分割线',
action: () => editor.chain().focus().setHorizontalRule().run(),
disabled: false,
active: false,
},
],
},
{
name: 'history',
buttons: [
{
icon: '↶',
title: '撤销',
action: () => editor.chain().focus().undo().run(),
disabled: !editorState.canUndo,
active: false,
},
{
icon: '↷',
title: '重做',
action: () => editor.chain().focus().redo().run(),
disabled: !editorState.canRedo,
active: false,
},
],
},
];
return (
<div className="enhanced-toolbar">
{toolbarSections.map((section, sectionIndex) => (
<div key={section.name} className="toolbar-section">
{sectionIndex > 0 && <div className="section-divider" />}
<div className="section-buttons">
{section?.buttons?.map((button, buttonIndex) => (
<Button
key={`${section.name}-${buttonIndex}`}
onClick={button.action}
disabled={button?.disabled || false}
className={`toolbar-button ${button?.active ? 'is-active' : ''}`}
title={button.title}
>
{button.icon}
</Button>
))}
</div>
</div>
))}
</div>
);
};
export default EnhancedToolbar;

View File

@ -5,10 +5,13 @@ import { Button, Drawer, Empty, Modal, Spin } from 'antd';
import { EditFilled, EyeFilled, ForkOutlined, SaveFilled } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import '@wangeditor/editor/dist/css/style.css';
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
import { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import { IDomEditor, IToolbarConfig } from '@wangeditor/editor';
import { DomEditor } from '@wangeditor/editor';
import { GetChapterContentApi } from '../../../../api/textbook';
import EditorComponent from '../EditorCompenent/EditorCompenent';
import ContentPreview from './ContentPreview';
import { getTenant, getToken } from '../../../../utils';
import config from '../../../../js/config';
const TextbookEditor: React.FC<EditorProps> = ({
chapterId,
@ -26,8 +29,7 @@ const TextbookEditor: React.FC<EditorProps> = ({
const [loading, setLoading] = useState<boolean>(true);
const [isPreviewModalShow, setIsPreviewModalShow] = useState<boolean>(false);
const toolbarConfig: Partial<IToolbarConfig> = {};
toolbarConfig.excludeKeys = ['fullScreen']; //移除不想要的fullScreen
toolbarConfig.excludeKeys = ['fullScreen', 'emotion']; //移除不想要的fullScreen
useEffect(() => {
setLoading(true);
@ -49,11 +51,6 @@ const TextbookEditor: React.FC<EditorProps> = ({
// setWordCount(text.split(/\s+/).filter((word) => word.length > 0).length);
// }
// 编辑器配置
const editorConfig: Partial<IEditorConfig> = {
placeholder: '请输入内容...',
};
/*仅用于本地开发 查看按钮*/
if (editor) {
const toolbar = DomEditor.getToolbar(editor);
@ -97,6 +94,11 @@ const TextbookEditor: React.FC<EditorProps> = ({
setEditor(editorInstance);
};
const handleContentChange = useCallback((newHtml: string) => {
setHtml(newHtml);
window.localStorage.setItem('html', newHtml);
}, []);
return (
<>
<div className="enhanced-textbook-editor">
@ -153,75 +155,26 @@ const TextbookEditor: React.FC<EditorProps> = ({
</div>
<div className="editor-main-box">
{isEditing ? (
<>
<Toolbar
editor={editor}
defaultConfig={toolbarConfig}
mode="default"
style={{ borderBottom: '1px solid #ccc' }}
/>
<Editor
defaultConfig={editorConfig}
value={html}
onCreated={(editor: any) => handleEditorCreated(editor)}
onChange={(editor) => {
const currentHtml = editor.getHtml();
setHtml(currentHtml);
window.localStorage.setItem('html', currentHtml);
}}
mode="default"
style={{
height: '550px',
paddingBottom: 15,
boxSizing: 'border-box',
overflowY: 'hidden',
}}
/>
</>
) : (
<div
style={{
height: '590px',
borderRadius: '6px',
padding: '20px',
boxSizing: 'border-box',
backgroundColor: '#fafafa',
overflowY: 'auto',
width: '100%',
overflowX: 'hidden',
<EditorComponent
editor={editor}
toolbarConfig={toolbarConfig}
value={html}
onChange={handleContentChange}
onCreated={handleEditorCreated}
height={550}
uploadConfig={{
getToken: () => getToken(),
getTenant: () => getTenant(),
uploadUrl: config.app_url,
}}
>
{html && html !== '<p><br></p>' && html !== '<p></p>' ? (
// @ts-ignore
<div style={{ width: '100%' }} dangerouslySetInnerHTML={{ __html: html }} />
) : (
<div
style={{
height: '550px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
textAlign: 'center',
color: '#999',
fontSize: '16px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>
<Empty description={false} />
</div>
<div style={{ marginBottom: '8px', fontWeight: 500 }}></div>
<div style={{ fontSize: '14px', color: '#bfbfbf' }}>
</div>
</div>
</div>
)}
</div>
/>
) : (
<ContentPreview
content={html}
height={550}
emptyText="暂无内容"
emptyDescription="请在编辑器中添加内容"
/>
)}
</div>
</>

View File

@ -0,0 +1,405 @@
import { useEffect, useState, useRef } from 'react';
import { Upload, message, Progress, List, Button, Space } from 'antd';
import { InboxOutlined, CloseOutlined, FileOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import { getTenant, getToken } from '../../../../utils';
import { useTranslation } from 'react-i18next';
import config from '../../../../js/config';
const { Dragger } = Upload;
interface IProps {
typeId?: number; // 1:视频, 2:图片, 3:文档, 4:音频, 5:其他
currentCount?: number;
maxCount?: number;
onUploadSuccess?: (fileInfo: {
type: string;
fileExtension: string;
fileSize: number;
filePath: string;
fileName: string;
}) => void;
onUploadError?: (error: any) => void;
}
interface UploadFileItem {
id: string;
name: string;
size: number;
progress: number;
status: 'uploading' | 'success' | 'error' | 'cancelled';
file: File;
xhr?: XMLHttpRequest;
}
// 根据类型ID获取对应的文件类型配置
// const getFileTypeConfig = (typeId: number) => {
// const configs = {
// 1: {
// // 视频
// accept: '.mp4,.mov,.avi,.wmv,.flv,.rmvb',
// },
// 2: {
// // 图片
// accept: '.png,.jpeg,.jpg,.gif',
// },
// 3: {
// // 文档
// accept: '.pdf,.doc,.docx,.ppt,.pptx',
// },
// 4: {
// // 音频
// accept: '.mp3,.aac,.wma,.wav',
// },
// 5: {
// // 其他
// accept: '*',
// },
// };
//
// return configs[typeId as keyof typeof configs] || configs[5];
// };
// 根据类型ID获取模块类型
// const getModuleType = (typeId: number): string => {
// const types = { 1: 'VIDEO', 2: 'IMAGE', 3: 'DOCUMENT', 4: 'AUDIO', 5: 'OTHER' };
// return types[typeId as keyof typeof types] || 'OTHER';
// };
export const DraggerUpload = (props: IProps) => {
const { t } = useTranslation();
const [uploadFiles, setUploadFiles] = useState<UploadFileItem[]>([]);
const xhrRef = useRef<Map<string, XMLHttpRequest>>(new Map());
const { typeId, currentCount = 0, maxCount = 1, onUploadSuccess, onUploadError } = props;
// const fileTypeConfig = getFileTypeConfig(typeId);
const isLimitReached = maxCount > 0 && currentCount >= maxCount;
// 获取文件扩展名
const getFileExtension = (fileName: string): string => {
return fileName.split('.').pop()?.toLowerCase() || '';
};
useEffect(() => {
setUploadFiles([]);
// 清理所有正在进行的上传
xhrRef.current.forEach((xhr) => {
xhr.abort();
});
xhrRef.current.clear();
}, [typeId]);
// 检查文件类型(仅对其他类型做限制)
const checkFileType = (file: File): boolean => {
if (typeId && typeId !== 5) return true;
const extension = getFileExtension(file.name);
const restrictedExtensions = [
'mp4',
'mov',
'avi',
'wmv',
'flv',
'rmvb',
'mp3',
'aac',
'wma',
'wav',
'png',
'jpeg',
'jpg',
'gif',
'docx',
'pptx',
'pdf',
'doc',
];
if (extension && restrictedExtensions.includes(extension)) {
message.error(t('textbook.resource.restrictedFileType'));
return false;
}
return true;
};
// 更新文件上传进度
const updateFileProgress = (
fileId: string,
progress: number,
status: UploadFileItem['status'] = 'uploading'
) => {
setUploadFiles((prev) =>
prev.map((item) => (item.id === fileId ? { ...item, progress, status } : item))
);
};
// 移除上传文件
const removeUploadFile = (fileId: string) => {
const fileItem = uploadFiles.find((item) => item.id === fileId);
// 如果文件正在上传,先取消上传
if (fileItem?.status === 'uploading') {
cancelUpload(fileId);
}
setUploadFiles((prev) => prev.filter((item) => item.id !== fileId));
xhrRef.current.delete(fileId);
};
// 取消上传
const cancelUpload = (fileId: string) => {
const xhr = xhrRef.current.get(fileId);
if (xhr) {
xhr.abort();
xhrRef.current.delete(fileId);
updateFileProgress(fileId, 0, 'cancelled');
message.info(t('commen.uploadCancelled'));
}
};
// 处理文件上传
const handleCustomRequest: UploadProps['customRequest'] = async (options) => {
const { file, onSuccess, onError, onProgress } = options;
const fileId = Math.random().toString(36).substr(2, 9);
const uploadFile: UploadFileItem = {
id: fileId,
name: (file as File).name,
size: (file as File).size,
progress: 0,
status: 'uploading',
file: file as File,
};
setUploadFiles((prev) => [...prev, uploadFile]);
try {
const formData = new FormData();
formData.append('file', file as File);
// formData.append('module', getModuleType(typeId));
formData.append('duration', '0');
let appUrl = config.app_url.replace(/\/+$/, '');
if (!appUrl.startsWith('http')) {
appUrl = `${window.location.protocol}//${window.location.host}${appUrl.startsWith('/') ? appUrl : '/' + appUrl}`;
}
const xhr = new XMLHttpRequest();
xhrRef.current.set(fileId, xhr);
// 监听上传进度
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percent = (event.loaded / event.total) * 100;
updateFileProgress(fileId, percent);
onProgress?.({ percent });
}
});
// 监听完成
xhr.addEventListener('load', () => {
xhrRef.current.delete(fileId);
if (xhr.status === 200) {
let response;
try {
response = JSON.parse(xhr.responseText);
} catch {
response = xhr.responseText;
}
updateFileProgress(fileId, 100, 'success');
onSuccess?.(response);
message.success(t('commen.uploadSuccess'));
// 构造返回的文件信息
const fileInfo = {
type: (file as File).type,
fileExtension: getFileExtension((file as File).name),
fileSize: (file as File).size,
filePath: response?.data?.path || response?.path || '', // 根据实际接口响应调整
fileName: (file as File).name,
// 如果需要,还可以返回原始响应
rawResponse: response,
};
onUploadSuccess?.(fileInfo);
} else {
throw new Error(`Upload failed with status ${xhr.status}`);
}
});
// 监听错误
xhr.addEventListener('error', () => {
xhrRef.current.delete(fileId);
// 如果是取消操作,不显示错误消息
if (xhr.status === 0 && xhr.readyState === 0) {
return; // 取消操作,不处理
}
updateFileProgress(fileId, 0, 'error');
const error = new Error('Upload failed');
onError?.(error);
onUploadError?.(error);
message.error(t('commen.uploadFailed'));
});
// 监听取消
xhr.addEventListener('abort', () => {
xhrRef.current.delete(fileId);
updateFileProgress(fileId, 0, 'cancelled');
// message.info(t('commen.uploadCancelled'));
});
xhr.open('POST', `${appUrl}/backend/v1/localUpload/upload`);
xhr.setRequestHeader('Authorization', 'Bearer ' + getToken());
xhr.setRequestHeader('tenant-id', getTenant());
xhr.send(formData);
} catch (error) {
xhrRef.current.delete(fileId);
updateFileProgress(fileId, 0, 'error');
onError?.(error as Error);
onUploadError?.(error);
message.error(t('commen.uploadFailed'));
}
};
const uploadProps: UploadProps = {
name: 'file',
multiple: false,
// accept: fileTypeConfig.accept,
customRequest: handleCustomRequest,
showUploadList: false,
disabled: isLimitReached || uploadFiles.some((file) => file.status === 'uploading'),
beforeUpload: (file) => {
if (isLimitReached) {
message.error(t('textbook.resource.uploadLimitReached'));
return false;
}
// 如果已经有文件在上传,不允许上传新文件
if (uploadFiles.some((f) => f.status === 'uploading')) {
message.error(t('commen.uploadInProgress'));
return false;
}
return checkFileType(file);
},
};
// 格式化文件大小
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// 获取状态对应的文本和颜色
const getStatusInfo = (status: UploadFileItem['status']) => {
switch (status) {
case 'uploading':
return { text: t('commen.uploading'), color: '#1890ff' };
case 'success':
return { text: t('commen.uploadSuccess'), color: '#52c41a' };
case 'error':
return { text: t('commen.uploadFailed'), color: '#ff4d4f' };
case 'cancelled':
return { text: t('commen.uploadCancelled'), color: '#faad14' };
default:
return { text: '', color: '#000' };
}
};
return (
<div>
<Dragger {...uploadProps} disabled={uploadFiles.length > 0}>
<p className="ant-upload-drag-icon">
<InboxOutlined style={{ color: isLimitReached ? '#d9d9d9' : undefined }} />
</p>
<p className="ant-upload-text">
{isLimitReached
? t('textbook.resource.uploadLimitReached')
: uploadFiles.some((f) => f.status === 'uploading')
? t('commen.uploadInProgress')
: t('textbook.resource.uploadTips')}
</p>
<p className="ant-upload-hint" style={{ color: isLimitReached ? '#999' : undefined }}>
{isLimitReached
? t('textbook.resource.uploadLimitReachedDesc')
: uploadFiles.some((f) => f.status === 'uploading')
? t('commen.waitForUploadComplete')
: t('textbook.resource.uploadTips2')}
</p>
</Dragger>
{/* 上传文件列表 */}
{uploadFiles.length > 0 && (
<div style={{ marginTop: 16 }}>
<List
size="small"
dataSource={uploadFiles}
renderItem={(item) => {
const statusInfo = getStatusInfo(item.status);
return (
<List.Item>
<div style={{ width: '100%' }}>
<Space
style={{ width: '100%', justifyContent: 'space-between', marginBottom: 8 }}
>
<Space>
<div className={'d-flex'}>
<FileOutlined />
<div className={'w-174px mr-5 '}>{item.name}</div>
<div className={'mr-5 '} style={{ color: '#999', fontSize: '12px' }}>
({formatFileSize(item.size)})
</div>
<div
className={'mr-5 '}
style={{ color: statusInfo.color, fontSize: '12px' }}
>
{statusInfo.text}
</div>
</div>
</Space>
<Space>
{item.status === 'uploading' && (
<Button
type="text"
size="small"
danger
onClick={() => cancelUpload(item.id)}
>
{t('commen.cancel')}
</Button>
)}
<Button
type="text"
icon={<CloseOutlined />}
size="small"
onClick={() => removeUploadFile(item.id)}
disabled={item.status === 'uploading'}
/>
</Space>
</Space>
{item.status === 'uploading' && (
<Progress
percent={item.progress}
status="active"
size="small"
showInfo={true}
/>
)}
</div>
</List.Item>
);
}}
/>
</div>
)}
</div>
);
};

View File

@ -402,14 +402,6 @@ export const ChapterTree = (props: PropInterface) => {
setParentChapter(null);
};
const handleSubmit = () => {
/* setEditSelectItem(null);
setEditSelectId(null);
setIsEditClass(false);*/
setModalVisible(false);
refreshTreeData();
};
return (
<div className={styles.chapterTree} id={'chapter-tree-container'}>
{contextHolder}
@ -465,7 +457,7 @@ export const ChapterTree = (props: PropInterface) => {
)}
{dragEnabled && <div className={'primary'}> {t('textbook.chapter.tips')}</div>}
{isMoreThanThree && <div className={'primary'}> {t('textbook.chapter.tips3')}</div>}
{treeData.length > 0 ? (
{!isLoading && treeData.length > 0 ? (
<div className={`${styles[`bottom-tree-box`]}`}>
<Tree
draggable={

View File

@ -420,7 +420,6 @@ const ResourcePage = () => {
onCancel={handleAddCancel}
resourceId={editId}
bookId={bookId}
typeOptions={TypeOptions}
></CreateResourceModal>
)}

View File

@ -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-10.8
* @returns Promise<string> base64格式的图片
*/
export const extractVideoThumbnail = (
file: File,
time: number = 0.1,
quality: number = 0.8
): Promise<string> => {
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 = `
<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="${color.bg}"/>
<path d="M${width / 2 - 40} ${height / 2 - 40} L${width / 2 + 40} ${height / 2} L${width / 2 - 40} ${height / 2 + 40} Z"
fill="${color.text}" stroke="${color.text}" stroke-width="2"/>
<text x="${width / 2}" y="${height / 2 + 80}"
text-anchor="middle"
font-family="Arial, sans-serif"
font-size="18"
font-weight="bold"
fill="${color.text}">
${text}
</text>
<text x="${width / 2}" y="${height - 20}"
text-anchor="middle"
font-family="Arial, sans-serif"
font-size="12"
fill="rgba(255,255,255,0.8)">
</text>
</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);
};