Compare commits
2 Commits
7b4d6a9f54
...
0d5781d07a
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d5781d07a | |||
| 1400a6b7b8 |
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}} 个)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -0,0 +1,11 @@
|
||||
.editor-container {
|
||||
border: none;
|
||||
|
||||
.w-e-bar {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.w-e-text-container {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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={
|
||||
|
||||
@ -420,7 +420,6 @@ const ResourcePage = () => {
|
||||
onCancel={handleAddCancel}
|
||||
resourceId={editId}
|
||||
bookId={bookId}
|
||||
typeOptions={TypeOptions}
|
||||
></CreateResourceModal>
|
||||
)}
|
||||
|
||||
|
||||
@ -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<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);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user