diff --git a/app/backend/package.json b/app/backend/package.json index 06c29c8..73d07ba 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -14,6 +14,12 @@ "dependencies": { "@ant-design/icons": "5.x", "@reduxjs/toolkit": "^1.9.3", + "@tiptap/extension-color": "^3.11.0", + "@tiptap/extension-text-align": "^3.11.0", + "@tiptap/extension-text-style": "^3.11.0", + "@tiptap/extension-underline": "^3.11.0", + "@tiptap/react": "^3.11.0", + "@tiptap/starter-kit": "^3.11.0", "@uppy/aws-s3": "4.1.0", "@uppy/core": "4.2.0", "@uppy/dashboard": "4.1.0", diff --git a/app/backend/src/api/index.ts b/app/backend/src/api/index.ts index cfddbbb..916b915 100644 --- a/app/backend/src/api/index.ts +++ b/app/backend/src/api/index.ts @@ -33,3 +33,4 @@ export * as knowledgeDataset from './knowledge-dataset'; export { knowledgeDatasetApi } from './knowledge-dataset'; export * as taskIndustry from './taskIndustry'; export * as taskTemplate from './taskTemplate'; +export * as textbook from './textbook'; diff --git a/app/backend/src/api/knowledge-dataset.ts b/app/backend/src/api/knowledge-dataset.ts index 60d9677..d8cce48 100644 --- a/app/backend/src/api/knowledge-dataset.ts +++ b/app/backend/src/api/knowledge-dataset.ts @@ -36,7 +36,7 @@ export const knowledgeDatasetApi = { return client.get('/knowledge/dataset/getDropDownList', {}); }, -// validDataSet: (id: number) => { -// return client.get(`/knowledge/dataset/valid/${id}`, {}); -// }, + // validDataSet: (id: number) => { + // return client.get(`/knowledge/dataset/valid/${id}`, {}); + // }, }; diff --git a/app/backend/src/api/knowledge-messages.ts b/app/backend/src/api/knowledge-messages.ts index fe7ef11..52de731 100644 --- a/app/backend/src/api/knowledge-messages.ts +++ b/app/backend/src/api/knowledge-messages.ts @@ -23,4 +23,3 @@ export function knowledgeMessagesSummary() { export function knowledgeMessagesAudit(id: string | number) { return client.put(`/v1/knowledge-messages/audit/${id}`, {}); } - diff --git a/app/backend/src/api/textbook.ts b/app/backend/src/api/textbook.ts new file mode 100644 index 0000000..35cc17c --- /dev/null +++ b/app/backend/src/api/textbook.ts @@ -0,0 +1,130 @@ +import client from './internal/httpClient'; +// textBook + +export function textbookList(page: number, size: number, title: string) { + return client.get('/backend/v1/jc/textbook/index', { + // return client.get('/backend/v1/course/index', { + page: page, + size: size, + title: title, + }); +} + +export function createTextbook( + title: string, + thumb: string, + shortDesc: string, + author: string, + major: string, + depIds: number[], + groupIds: number[], + userIds: number[], + publishTime: string, + publishUnit: string, + createTime: string +) { + return client.post('/backend/v1/jc/textbook', { + title, + thumb, + shortDesc, + author, + major, + dep_ids: depIds, + groupIds, + userIds, + publishTime, + publishUnit, + createTime, + }); +} + +export function textbookDetail(id: number) { + return client.get(`/backend/v1/jc/textbook/${id}`, {}); +} + +export function updateTextbook( + id: number, + title: string, + thumb: string, + shortDesc: string, + author: string, + major: string, + depIds: number[], + groupIds: number[], + userIds: number[], + publishTime: string, + publishUnit: string, + createTime: string +) { + return client.put(`/backend/v1/jc/textbook`, { + id, + title, + thumb, + shortDesc, + author, + major, + dep_ids: depIds, + groupIds, + userIds, + publishTime, + publishUnit, + createTime, + }); +} + +export function destroyTextbook(id: number) { + return client.destroy(`/backend/v1/jc/textbook/${id}`); +} + +export function courseUser( + courseId: number, + page: number, + size: number, + sortField: string, + sortAlgo: string, + name: string, + state: number | null, + depId: number | null +) { + return client.get(`/backend/v1/offline/course/${courseId}/user/index`, { + page: page, + size: size, + sort_field: sortField, + sort_algo: sortAlgo, + name: name, + state, + dep_id: depId, + }); +} + +export function updateUserStateMulti(courseId: number, ids: number[], state: number) { + return client.post(`/backend/v1/offline/course/${courseId}/user/state-multi`, { + user_ids: ids, + state, + }); +} + +export function updateUser( + courseId: number, + userId: number, + state: number, + grade: string, + remark: string +) { + return client.post(`/backend/v1/offline/course/${courseId}/user/${userId}`, { + state, + grade, + remark, + }); +} + +export function storeBatch(courseId: number, startLine: number, records: string[][]) { + return client.post(`/backend/v1/offline/course/${courseId}/user/store-batch`, { + start_line: startLine, + records, + }); +} + +export function getDynamic(code: string) { + return client.get(`/backend/v1/offline/course/dynamic`, { code: code }); +} diff --git a/app/backend/src/assets/iconfont/iconfont.css b/app/backend/src/assets/iconfont/iconfont.css index bf6a146..1482959 100644 --- a/app/backend/src/assets/iconfont/iconfont.css +++ b/app/backend/src/assets/iconfont/iconfont.css @@ -305,3 +305,6 @@ .icon-icon-password:before { content: '\e73d'; } +.icon-icon-addIcon:before { + content: '\271A'; +} diff --git a/app/backend/src/compenents/left-menu/index.tsx b/app/backend/src/compenents/left-menu/index.tsx index a58f195..6a5e43e 100644 --- a/app/backend/src/compenents/left-menu/index.tsx +++ b/app/backend/src/compenents/left-menu/index.tsx @@ -40,6 +40,7 @@ export const LeftMenu: React.FC = () => { '^/group': ['user'], '^/course': ['courses'], '^/offline-course': ['courses'], + '^/textbook': ['textbook'], '^/task': ['task'], '^/system': ['system'], '^/cert': ['resource'], @@ -62,6 +63,7 @@ export const LeftMenu: React.FC = () => { null, 'menu-index' ), + // 分类 getItem( t('leftMenu.category'), '/resource-category', @@ -120,6 +122,7 @@ export const LeftMenu: React.FC = () => { getItem(t('leftMenu.offlineCourse'), '/offline-course', null, null, null, 'offline-course'), getItem(t('leftMenu.teacher'), '/teacher', null, null, null, 'teacher'), getItem(t('leftMenu.experiment'), '/experiment', null, null, null, 'experiment'), + getItem(t('leftMenu.textbook'), '/textbook', null, null, null, 'course'), //textbook ], null, null diff --git a/app/backend/src/compenents/uploadFile/index.tsx b/app/backend/src/compenents/uploadFile/index.tsx index 9784623..51e3f51 100644 --- a/app/backend/src/compenents/uploadFile/index.tsx +++ b/app/backend/src/compenents/uploadFile/index.tsx @@ -7,585 +7,651 @@ import { getToken, getTenant } from '../../utils'; // 生成随机字符串的函数 const generateRandomString = (length: number = 32): string => { - const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let result = ''; - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * characters.length)); - } - return result; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + return result; }; type UploadStatus = 'pending' | 'uploading' | 'success' | 'failed'; interface FileUploaderProps { - uploadFolder: (formData: FormData, signal?: AbortSignal) => Promise<{ data?: string }>; - form: any; - customFormFields: string; + uploadFolder: (formData: FormData, signal?: AbortSignal) => Promise<{ data?: string }>; + form: any; + customFormFields: string; } - +// @ts-ignore +// eslint-disable-next-line react/display-name export const FileUploader = forwardRef( - ({ uploadFolder, form, customFormFields }: FileUploaderProps, ref) => { - const [uploadProgress, setUploadProgress] = useState(0); - const [fileStatuses, setFileStatuses] = useState>({}); - const [fileObjects, setFileObjects] = useState>({}); // 存储文件对象用于重试 - const [showUploader, setShowUploader] = useState(true); - const [indexHtmlPath, setIndexHtmlPath] = useState(null); // 用于存储index.html路径 - const [activeTab, setActiveTab] = useState('all'); // 默认显示所有文件 + ({ uploadFolder, form, customFormFields }: FileUploaderProps, ref) => { + const [uploadProgress, setUploadProgress] = useState(0); + const [fileStatuses, setFileStatuses] = useState>({}); + const [fileObjects, setFileObjects] = useState>({}); // 存储文件对象用于重试 + const [showUploader, setShowUploader] = useState(true); + const [indexHtmlPath, setIndexHtmlPath] = useState(null); // 用于存储index.html路径 + const [activeTab, setActiveTab] = useState('all'); // 默认显示所有文件 - const updateProgress = (uploaded: number, total: number) => { - const percent = Math.floor((uploaded / total) * 100); - setUploadProgress(percent); - }; + const updateProgress = (uploaded: number, total: number) => { + const percent = Math.floor((uploaded / total) * 100); + setUploadProgress(percent); + }; - const abortControllerRef = useRef(null); - const fileInputRef = useRef(null); - const activeUploadsRef = useRef(0); // 当前正在进行的上传数量 - const uploadQueueRef = useRef([]); // 等待上传的文件队列 - const completedUploadsRef = useRef(0); // 已完成的上传数量 - const randomStringRef = useRef(''); // 随机字符串 + const abortControllerRef = useRef(null); + const fileInputRef = useRef(null); + const activeUploadsRef = useRef(0); // 当前正在进行的上传数量 + const uploadQueueRef = useRef([]); // 等待上传的文件队列 + const completedUploadsRef = useRef(0); // 已完成的上传数量 + const randomStringRef = useRef(''); // 随机字符串 - const resetUploader = () => { - setShowUploader(false); - setFileStatuses({}); - setFileObjects({}); - setUploadProgress(0); - setIndexHtmlPath(null); - setActiveTab('all'); - abortControllerRef.current?.abort(); + const resetUploader = () => { + setShowUploader(false); + setFileStatuses({}); + setFileObjects({}); + setUploadProgress(0); + setIndexHtmlPath(null); + setActiveTab('all'); + abortControllerRef.current?.abort(); - // 重置所有引用 - activeUploadsRef.current = 0; - uploadQueueRef.current = []; - completedUploadsRef.current = 0; - randomStringRef.current = ''; + // 重置所有引用 + activeUploadsRef.current = 0; + uploadQueueRef.current = []; + completedUploadsRef.current = 0; + randomStringRef.current = ''; - if (fileInputRef.current) { - fileInputRef.current.value = ''; + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + setTimeout(() => { + setFileStatuses({}); + setFileObjects({}); + setUploadProgress(0); + setIndexHtmlPath(null); + setActiveTab('all'); + activeUploadsRef.current = 0; + uploadQueueRef.current = []; + completedUploadsRef.current = 0; + randomStringRef.current = ''; + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + setShowUploader(true); + }, 500); + }; + + // 暴露方法给父组件 + useImperativeHandle(ref, () => ({ + resetUploader, + })); + + // 获取预签名URL并上传文件到OSS + const uploadFileToOSS = async (file: File, retry = 0): Promise => { + return new Promise((resolve, reject) => { + const doUpload = async (retryCount: number) => { + try { + // 构建基础URL + let appUrl = config.app_url.replace(/\/+$/, ''); + if (!appUrl.startsWith('http')) { + appUrl = + window.location.protocol + + '//' + + window.location.host + + (appUrl.startsWith('/') ? appUrl : '/' + appUrl); } - setTimeout(() => { - setFileStatuses({}); - setFileObjects({}); - setUploadProgress(0); - setIndexHtmlPath(null); - setActiveTab('all'); - activeUploadsRef.current = 0; - uploadQueueRef.current = []; - completedUploadsRef.current = 0; - randomStringRef.current = ''; - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - setShowUploader(true); - }, 500); - }; - // 暴露方法给父组件 - useImperativeHandle(ref, () => ({ - resetUploader, - })); + // 获取认证信息 + const token = getToken(); + const tenant = getTenant(); - // 获取预签名URL并上传文件到OSS - const uploadFileToOSS = async (file: File, retry = 0): Promise => { - return new Promise((resolve, reject) => { - const doUpload = async (retryCount: number) => { - try { - // 构建基础URL - let appUrl = config.app_url.replace(/\/+$/, ''); - if (!appUrl.startsWith('http')) { - appUrl = - window.location.protocol + - '//' + - window.location.host + - (appUrl.startsWith('/') ? appUrl : '/' + appUrl); - } + // 1. 获取上传ID (使用新的 allUpload-id 接口) + const extension = file.name.split('.').pop() || ''; + // 获取完整的相对路径 + const relativePath = file.webkitRelativePath || file.name; + const uploadIdResponse = await fetch( + `${appUrl}/backend/v1/upload/minio/allUpload-id?extension=${extension}&randomStr=${randomStringRef.current}&originalFilename=${encodeURIComponent(file.name)}&filePath=${encodeURIComponent(relativePath)}`, + { + method: 'GET', + headers: { + Authorization: 'Bearer ' + token, + 'tenant-id': tenant || '', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }, + signal: abortControllerRef.current?.signal, + } + ); - // 获取认证信息 - const token = getToken(); - const tenant = getTenant(); + if (!uploadIdResponse.ok) { + const errorText = await uploadIdResponse.text(); + throw new Error('获取上传ID失败: ' + uploadIdResponse.status + ' ' + errorText); + } - // 1. 获取上传ID (使用新的 allUpload-id 接口) - const extension = file.name.split('.').pop() || ''; - // 获取完整的相对路径 - const relativePath = file.webkitRelativePath || file.name; - const uploadIdResponse = await fetch(`${appUrl}/backend/v1/upload/minio/allUpload-id?extension=${extension}&randomStr=${randomStringRef.current}&originalFilename=${encodeURIComponent(file.name)}&filePath=${encodeURIComponent(relativePath)}`, { - method: 'GET', - headers: { - 'Authorization': 'Bearer ' + token, - 'tenant-id': tenant || '', - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' - }, - signal: abortControllerRef.current?.signal - }); + const uploadIdData = await uploadIdResponse.json(); + const { upload_id, filename } = uploadIdData.data; - if (!uploadIdResponse.ok) { - const errorText = await uploadIdResponse.text(); - throw new Error('获取上传ID失败: ' + uploadIdResponse.status + ' ' + errorText); - } + // 2. 获取预签名URL + const contentType2 = getContentType(extension); + const preSignResponse = await fetch( + `${appUrl}/backend/v1/upload/minio/pre-sign-url?upload_id=${upload_id}&part_number=1&filename=${encodeURIComponent(filename)}${contentType2 ? '&content_type=' + encodeURIComponent(contentType2) : ''}`, + { + method: 'GET', + headers: { + Authorization: 'Bearer ' + token, + 'tenant-id': tenant || '', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + Pragma: 'no-cache', + Expires: '0', + }, + signal: abortControllerRef.current?.signal, + } + ); - const uploadIdData = await uploadIdResponse.json(); - const { upload_id, filename } = uploadIdData.data; + if (!preSignResponse.ok) { + const errorText = await preSignResponse.text(); + throw new Error('获取预签名URL失败: ' + preSignResponse.status + ' ' + errorText); + } - // 2. 获取预签名URL - const contentType2 = getContentType(extension); - const preSignResponse = await fetch(`${appUrl}/backend/v1/upload/minio/pre-sign-url?upload_id=${upload_id}&part_number=1&filename=${encodeURIComponent(filename)}${contentType2 ? '&content_type=' + encodeURIComponent(contentType2) : ''}`, { - method: 'GET', - headers: { - 'Authorization': 'Bearer ' + token, - 'tenant-id': tenant || '', - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' - }, - signal: abortControllerRef.current?.signal - }); + const preSignData = await preSignResponse.json(); + const { url } = preSignData.data; - if (!preSignResponse.ok) { - const errorText = await preSignResponse.text(); - throw new Error('获取预签名URL失败: ' + preSignResponse.status + ' ' + errorText); - } + // 3. 上传文件到OSS + const xhr = new XMLHttpRequest(); - const preSignData = await preSignResponse.json(); - const { url } = preSignData.data; - - // 3. 上传文件到OSS - const xhr = new XMLHttpRequest(); - - xhr.upload.addEventListener('progress', (event) => { - if (event.lengthComputable) { - // 可以在这里更新单个文件的进度 - } - }); - - xhr.addEventListener('load', async () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { - // 4. 合并文件 (使用新的简化版接口) - const mergeResponse = await fetch(`${appUrl}/backend/v1/upload/minio/allMerge-file`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer ' + token, - 'tenant-id': tenant || '', - }, - body: JSON.stringify({ - filename: filename, - upload_id: upload_id, - category_ids: "", // 根据需要设置分类ID - original_filename: file.name, - extension: extension, - size: file.size, - duration: 0, - module: "OTHER" - }), - signal: abortControllerRef.current?.signal - }); - - if (!mergeResponse.ok) { - const errorText = await mergeResponse.text(); - throw new Error('合并文件失败: ' + mergeResponse.status + ' ' + errorText); - } - - const mergeResult = await mergeResponse.json(); - - resolve(filename); // 返回文件路径 - } catch (error) { - handleError(error, retryCount, doUpload, reject); - } - } else { - handleError(new Error('上传文件到OSS失败: ' + xhr.status), retryCount, doUpload, reject); - } - }); - - xhr.addEventListener('error', () => { - handleError(new Error('网络错误'), retryCount, doUpload, reject); - }); - - xhr.addEventListener('abort', () => { - reject(new Error('上传被中止')); - }); - - xhr.open('PUT', url, true); - // 设置正确的Content-Type - const contentType = getContentType(extension); - if (contentType) { - xhr.setRequestHeader('Content-Type', contentType); - } - // 发送文件 - xhr.send(file); - - // 支持中止上传 - if (abortControllerRef.current) { - abortControllerRef.current.signal.addEventListener('abort', () => { - xhr.abort(); - }); - } - } catch (error) { - handleError(error, retryCount, doUpload, reject); - } - }; - - const handleError = (error: any, retryCount: number, retryFn: (count: number) => void, rejectFn: (reason?: any) => void) => { - if (retryCount < 3) { - setTimeout(() => { - retryFn(retryCount + 1); - }, 1000 * (retryCount + 1)); - } else { - rejectFn(error); - } - }; - - doUpload(retry); + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + // 可以在这里更新单个文件的进度 + } }); - }; - // 根据文件扩展名获取Content-Type - const getContentType = (extension: string): string | null => { - const contentTypeMap: { [key: string]: string } = { - 'html': 'text/html', - 'htm': 'text/html', - 'css': 'text/css', - 'js': 'application/javascript', - 'json': 'application/json', - 'xml': 'application/xml', - 'txt': 'text/plain', - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - 'svg': 'image/svg+xml', - 'ico': 'image/x-icon', - 'woff': 'font/woff', - 'woff2': 'font/woff2', - 'ttf': 'font/ttf', - 'eot': 'application/vnd.ms-fontobject', - 'pdf': 'application/pdf', - 'zip': 'application/zip', - 'rar': 'application/x-rar-compressed', - 'mp3': 'audio/mpeg', - 'mp4': 'video/mp4', - 'avi': 'video/x-msvideo', - 'mov': 'video/quicktime', - 'wmv': 'video/x-ms-wmv' - }; + xhr.addEventListener('load', async () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + // 4. 合并文件 (使用新的简化版接口) + const mergeResponse = await fetch( + `${appUrl}/backend/v1/upload/minio/allMerge-file`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + token, + 'tenant-id': tenant || '', + }, + body: JSON.stringify({ + filename: filename, + upload_id: upload_id, + category_ids: '', // 根据需要设置分类ID + original_filename: file.name, + extension: extension, + size: file.size, + duration: 0, + module: 'OTHER', + }), + signal: abortControllerRef.current?.signal, + } + ); - return contentTypeMap[extension.toLowerCase()] || 'application/octet-stream'; - }; + if (!mergeResponse.ok) { + const errorText = await mergeResponse.text(); + throw new Error('合并文件失败: ' + mergeResponse.status + ' ' + errorText); + } - // 处理大文件分片上传到OSS - const uploadLargeFileToOSS = async (file: File): Promise => { - // 这里实现分片上传逻辑 - // 由于代码复杂性,简化处理为普通上传 - // 实际项目中应实现完整的分片上传逻辑 - return uploadFileToOSS(file); - }; + const mergeResult = await mergeResponse.json(); - // 处理单个文件上传 - const uploadSingleFile = async (file: File, retry = 0): Promise => { - const key = file.name; - - // 检查是否应该中止 - if (abortControllerRef.current?.signal.aborted) { - return; - } - - // 更新文件状态为上传中 - setFileStatuses((prev) => ({ ...prev, [key]: 'uploading' })); - - try { - let filePath: string; - - // 判断文件大小,决定使用普通上传还是分片上传 - if (file.size > 100 * 1024 * 1024) { // 大于100MB使用分片上传 - filePath = await uploadLargeFileToOSS(file); - } else { - filePath = await uploadFileToOSS(file); + resolve(filename); // 返回文件路径 + } catch (error) { + handleError(error, retryCount, doUpload, reject); } - - // 检查是否是index.html文件 - if (file.name.toLowerCase() === 'index.html') { - setIndexHtmlPath(filePath); - // 设置表单字段值 - form.setFieldsValue({ [customFormFields]: filePath }); - } - - // 更新完成计数和进度 - completedUploadsRef.current++; - updateProgress( - completedUploadsRef.current, - uploadQueueRef.current.length + completedUploadsRef.current + } else { + handleError( + new Error('上传文件到OSS失败: ' + xhr.status), + retryCount, + doUpload, + reject ); - setFileStatuses((prev) => ({ ...prev, [key]: 'success' })); - - // 当前上传完成,减少活动计数 - activeUploadsRef.current--; - - // 检查是否有等待上传的文件 - processUploadQueue(); - } catch (err) { - if (abortControllerRef.current?.signal.aborted) { - return; - } - - // 不再自动重试,直接标记为失败 - console.error(`上传失败:${file.name}`, err); - message.error(`${file.name} 上传失败`); - setFileStatuses((prev) => ({ ...prev, [key]: 'failed' })); - - // 上传失败,减少活动计数 - activeUploadsRef.current--; - completedUploadsRef.current++; - - // 检查是否有等待上传的文件 - processUploadQueue(); - } - }; - - // 重新上传失败的文件 - const retryFailedUpload = (fileName: string) => { - const file = fileObjects[fileName]; - if (file) { - // 更新状态为上传中 - setFileStatuses(prev => ({ ...prev, [fileName]: 'uploading' })); - - // 重新上传文件(不使用重试参数,从0开始) - uploadSingleFile(file); - } - }; - - // 处理上传队列 - const processUploadQueue = () => { - // 确保同时运行的上传任务不超过3个 - while (activeUploadsRef.current < 3 && uploadQueueRef.current.length > 0) { - const file = uploadQueueRef.current.shift(); - if (file) { - activeUploadsRef.current++; - // 不等待uploadSingleFile完成,让它异步执行 - uploadSingleFile(file); - } - } - }; - - const handleFileChange = async (e: React.ChangeEvent) => { - setShowUploader(false); - const files = Array.from(e.target.files || []); - if (files.length === 0) return; - - // 过滤掉临时文件和系统文件(如以~$开头的Office临时文件) - const validFiles = files.filter(file => { - // 过滤掉以~$开头的临时文件 - if (file.name.startsWith('~$')) { - return false; - } - // 过滤掉以.Thumb开头的缩略图文件 - if (file.name.startsWith('.Thumb')) { - return false; - } - // 过滤掉以.DS_Store开头的系统文件 - if (file.name.startsWith('.DS_Store')) { - return false; - } - // 过滤掉隐藏文件(以.开头的文件) - if (file.name.startsWith('.')) { - return false; - } - return true; + } }); - if (validFiles.length === 0) { - message.warning('没有找到有效的文件'); - setShowUploader(true); - return; - } - - // 中止之前的上传 - abortControllerRef.current?.abort(); - - // 初始化新的上传控制器 - abortControllerRef.current = new AbortController(); - - // 生成随机字符串 - randomStringRef.current = generateRandomString(5); - - // 重置状态 - setFileStatuses({}); - setFileObjects({}); - setUploadProgress(0); - setIndexHtmlPath(null); - activeUploadsRef.current = 0; - uploadQueueRef.current = [...validFiles]; - completedUploadsRef.current = 0; - - // 初始化文件状态和文件对象 - const initialStatus: Record = {}; - const initialFiles: Record = {}; - validFiles.forEach((file) => { - initialStatus[file.name] = 'pending'; - initialFiles[file.name] = file; + xhr.addEventListener('error', () => { + handleError(new Error('网络错误'), retryCount, doUpload, reject); }); - setFileStatuses(initialStatus); - setFileObjects(initialFiles); - // 开始处理上传队列 - processUploadQueue(); - }; + xhr.addEventListener('abort', () => { + reject(new Error('上传被中止')); + }); - // 组件卸载时中止上传并清空状态 - useEffect(() => { - return () => { - abortControllerRef.current?.abort(); - resetUploader(); - }; - }, []); - - // 计算各状态文件数量 - const successCount = Object.values(fileStatuses).filter(status => status === 'success').length; - const failedCount = Object.values(fileStatuses).filter(status => status === 'failed').length; - const uploadingCount = Object.values(fileStatuses).filter(status => status === 'uploading').length; - const pendingCount = Object.values(fileStatuses).filter(status => status === 'pending').length; - - // 根据tab筛选文件 - const getFilteredFiles = () => { - if (activeTab === 'success') { - return Object.entries(fileStatuses).filter(([_, status]) => status === 'success'); - } else if (activeTab === 'failed') { - return Object.entries(fileStatuses).filter(([_, status]) => status === 'failed'); - } else { - return Object.entries(fileStatuses); + xhr.open('PUT', url, true); + // 设置正确的Content-Type + const contentType = getContentType(extension); + if (contentType) { + xhr.setRequestHeader('Content-Type', contentType); } + // 发送文件 + xhr.send(file); + + // 支持中止上传 + if (abortControllerRef.current) { + abortControllerRef.current.signal.addEventListener('abort', () => { + xhr.abort(); + }); + } + } catch (error) { + handleError(error, retryCount, doUpload, reject); + } }; - const filteredFiles = getFilteredFiles(); + const handleError = ( + error: any, + retryCount: number, + retryFn: (count: number) => void, + rejectFn: (reason?: any) => void + ) => { + if (retryCount < 3) { + setTimeout( + () => { + retryFn(retryCount + 1); + }, + 1000 * (retryCount + 1) + ); + } else { + rejectFn(error); + } + }; - return ( -
- {showUploader && ( - - )} - - {uploadProgress > 0 && ( - - )} - {indexHtmlPath && ( -
- 主页文件路径: - {indexHtmlPath} -
- )} -
- {Object.keys(fileStatuses).length > 0 && ( -
- -
    - {filteredFiles.map(([fileName, status]) => ( -
  • - - {fileName}{' '} - {status === 'success' && ✅ 成功} - {status === 'failed' && ❌ 失败} - {status === 'uploading' && ⏳ 上传中} - {status === 'pending' && ⏳ 等待中} - {fileName.toLowerCase() === 'index.html' && status === 'success' && 🏠 主页} - - {status === 'failed' && ( - - )} -
  • - ))} -
-
- ), - }, - { - key: 'success', - label: `成功 (${successCount})`, - children: ( -
-
    - {filteredFiles.map(([fileName, status]) => ( -
  • - {fileName}{' '} - {fileName.toLowerCase() === 'index.html' && 🏠 主页} -
  • - ))} -
-
- ), - }, - { - key: 'failed', - label: `失败 (${failedCount})`, - children: ( -
-
    - {filteredFiles.map(([fileName, status]) => ( -
  • - - {fileName} - - -
  • - ))} -
-
- ), - }, - ]} - /> -
- )} -
- + doUpload(retry); + }); + }; + + // 根据文件扩展名获取Content-Type + const getContentType = (extension: string): string | null => { + const contentTypeMap: { [key: string]: string } = { + html: 'text/html', + htm: 'text/html', + css: 'text/css', + js: 'application/javascript', + json: 'application/json', + xml: 'application/xml', + txt: 'text/plain', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + svg: 'image/svg+xml', + ico: 'image/x-icon', + woff: 'font/woff', + woff2: 'font/woff2', + ttf: 'font/ttf', + eot: 'application/vnd.ms-fontobject', + pdf: 'application/pdf', + zip: 'application/zip', + rar: 'application/x-rar-compressed', + mp3: 'audio/mpeg', + mp4: 'video/mp4', + avi: 'video/x-msvideo', + mov: 'video/quicktime', + wmv: 'video/x-ms-wmv', + }; + + return contentTypeMap[extension.toLowerCase()] || 'application/octet-stream'; + }; + + // 处理大文件分片上传到OSS + const uploadLargeFileToOSS = async (file: File): Promise => { + // 这里实现分片上传逻辑 + // 由于代码复杂性,简化处理为普通上传 + // 实际项目中应实现完整的分片上传逻辑 + return uploadFileToOSS(file); + }; + + // 处理单个文件上传 + const uploadSingleFile = async (file: File, retry = 0): Promise => { + const key = file.name; + + // 检查是否应该中止 + if (abortControllerRef.current?.signal.aborted) { + return; + } + + // 更新文件状态为上传中 + setFileStatuses((prev) => ({ ...prev, [key]: 'uploading' })); + + try { + let filePath: string; + + // 判断文件大小,决定使用普通上传还是分片上传 + if (file.size > 100 * 1024 * 1024) { + // 大于100MB使用分片上传 + filePath = await uploadLargeFileToOSS(file); + } else { + filePath = await uploadFileToOSS(file); + } + + // 检查是否是index.html文件 + if (file.name.toLowerCase() === 'index.html') { + setIndexHtmlPath(filePath); + // 设置表单字段值 + form.setFieldsValue({ [customFormFields]: filePath }); + } + + // 更新完成计数和进度 + completedUploadsRef.current++; + updateProgress( + completedUploadsRef.current, + uploadQueueRef.current.length + completedUploadsRef.current ); - } + setFileStatuses((prev) => ({ ...prev, [key]: 'success' })); + + // 当前上传完成,减少活动计数 + activeUploadsRef.current--; + + // 检查是否有等待上传的文件 + processUploadQueue(); + } catch (err) { + if (abortControllerRef.current?.signal.aborted) { + return; + } + + // 不再自动重试,直接标记为失败 + console.error(`上传失败:${file.name}`, err); + message.error(`${file.name} 上传失败`); + setFileStatuses((prev) => ({ ...prev, [key]: 'failed' })); + + // 上传失败,减少活动计数 + activeUploadsRef.current--; + completedUploadsRef.current++; + + // 检查是否有等待上传的文件 + processUploadQueue(); + } + }; + + // 重新上传失败的文件 + const retryFailedUpload = (fileName: string) => { + const file = fileObjects[fileName]; + if (file) { + // 更新状态为上传中 + setFileStatuses((prev) => ({ ...prev, [fileName]: 'uploading' })); + + // 重新上传文件(不使用重试参数,从0开始) + uploadSingleFile(file); + } + }; + + // 处理上传队列 + const processUploadQueue = () => { + // 确保同时运行的上传任务不超过3个 + while (activeUploadsRef.current < 3 && uploadQueueRef.current.length > 0) { + const file = uploadQueueRef.current.shift(); + if (file) { + activeUploadsRef.current++; + // 不等待uploadSingleFile完成,让它异步执行 + uploadSingleFile(file); + } + } + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + setShowUploader(false); + const files = Array.from(e.target.files || []); + if (files.length === 0) return; + + // 过滤掉临时文件和系统文件(如以~$开头的Office临时文件) + const validFiles = files.filter((file) => { + // 过滤掉以~$开头的临时文件 + if (file.name.startsWith('~$')) { + return false; + } + // 过滤掉以.Thumb开头的缩略图文件 + if (file.name.startsWith('.Thumb')) { + return false; + } + // 过滤掉以.DS_Store开头的系统文件 + if (file.name.startsWith('.DS_Store')) { + return false; + } + // 过滤掉隐藏文件(以.开头的文件) + if (file.name.startsWith('.')) { + return false; + } + return true; + }); + + if (validFiles.length === 0) { + message.warning('没有找到有效的文件'); + setShowUploader(true); + return; + } + + // 中止之前的上传 + abortControllerRef.current?.abort(); + + // 初始化新的上传控制器 + abortControllerRef.current = new AbortController(); + + // 生成随机字符串 + randomStringRef.current = generateRandomString(5); + + // 重置状态 + setFileStatuses({}); + setFileObjects({}); + setUploadProgress(0); + setIndexHtmlPath(null); + activeUploadsRef.current = 0; + uploadQueueRef.current = [...validFiles]; + completedUploadsRef.current = 0; + + // 初始化文件状态和文件对象 + const initialStatus: Record = {}; + const initialFiles: Record = {}; + validFiles.forEach((file) => { + initialStatus[file.name] = 'pending'; + initialFiles[file.name] = file; + }); + setFileStatuses(initialStatus); + setFileObjects(initialFiles); + + // 开始处理上传队列 + processUploadQueue(); + }; + + // 组件卸载时中止上传并清空状态 + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + resetUploader(); + }; + }, []); + + // 计算各状态文件数量 + const successCount = Object.values(fileStatuses).filter( + (status) => status === 'success' + ).length; + const failedCount = Object.values(fileStatuses).filter((status) => status === 'failed').length; + const uploadingCount = Object.values(fileStatuses).filter( + (status) => status === 'uploading' + ).length; + const pendingCount = Object.values(fileStatuses).filter( + (status) => status === 'pending' + ).length; + + // 根据tab筛选文件 + const getFilteredFiles = () => { + if (activeTab === 'success') { + return Object.entries(fileStatuses).filter(([_, status]) => status === 'success'); + } else if (activeTab === 'failed') { + return Object.entries(fileStatuses).filter(([_, status]) => status === 'failed'); + } else { + return Object.entries(fileStatuses); + } + }; + + const filteredFiles = getFilteredFiles(); + + return ( +
+ {showUploader && ( + + )} + + {uploadProgress > 0 && ( + + )} + {indexHtmlPath && ( +
+ 主页文件路径: + {indexHtmlPath} +
+ )} +
+ {Object.keys(fileStatuses).length > 0 && ( +
+ +
    + {filteredFiles.map(([fileName, status]) => ( +
  • + + {fileName}{' '} + {status === 'success' && ( + ✅ 成功 + )} + {status === 'failed' && ( + ❌ 失败 + )} + {status === 'uploading' && ( + ⏳ 上传中 + )} + {status === 'pending' && ( + ⏳ 等待中 + )} + {fileName.toLowerCase() === 'index.html' && + status === 'success' && ( + 🏠 主页 + )} + + {status === 'failed' && ( + + )} +
  • + ))} +
+
+ ), + }, + { + key: 'success', + label: `成功 (${successCount})`, + children: ( +
+
    + {filteredFiles.map(([fileName, status]) => ( +
  • + {fileName}{' '} + {fileName.toLowerCase() === 'index.html' && ( + 🏠 主页 + )} +
  • + ))} +
+
+ ), + }, + { + key: 'failed', + label: `失败 (${failedCount})`, + children: ( +
+
    + {filteredFiles.map(([fileName, status]) => ( +
  • + {fileName} + +
  • + ))} +
+
+ ), + }, + ]} + /> +
+ )} +
+ + ); + } ); diff --git a/app/backend/src/i18n/cn.json b/app/backend/src/i18n/cn.json index a369d65..343b3a1 100644 --- a/app/backend/src/i18n/cn.json +++ b/app/backend/src/i18n/cn.json @@ -259,6 +259,7 @@ "testReport": "实验报告模板", "lesson": "课程中心", "course": "线上课", + "textbook": "教材管理", "offlineCourse": "线下课", "teacher": "讲师", "experiment": "实验课", @@ -820,11 +821,16 @@ "label": "全部分类", "label2": "分类", "label3": "线上课", + "label4": "教材管理", + "createTextbook": "新建教材", + "updateTextbook": "编辑教材", "create": "新建线上课", "update": "编辑线上课", "delText": "删除前请检查选中课程无关联学习任务,确认删除?", "name": "课程名称:", + "textbook": "教材信息:", "namePlaceholder": "请输入名称关键字", + "textbookPlaceholder": "请输入教材名称、作者、学科专业", "aboutTitle": "关联详情", "aboutText": "请先将关联课程的学习任务解除再删除!", "task": "任务", @@ -847,6 +853,7 @@ "text2": "选修课", "text3": "阅卷" }, + "user": { "label": "线上课学员", "deltext": "请选择学员后再重置", @@ -908,6 +915,7 @@ "radio2": "有章节", "addText1": "添加课时", "addText2": "请点击上方按钮添加课时", + "addText3": "请点击上方按钮添加章节", "delText1": "删除章节", "addChapter": "添加章节", "option": "更多选项", @@ -1981,5 +1989,57 @@ "delSuccess": "撤销成功" } } + }, + "textbook": { + "delTextbook": "删除教材后无法恢复,确认删除此教材?", + "bookColumns": { + "title1": "教材名称", + "title2": "简介", + "title3": "学科专业", + "title4": "作者", + "title5": "出版社", + "title6": "章节总数", + "title7": "发布时间", + "title8": "创建时间", + "title9": "操作", + "option1": "章节管理", + "option2": "关联资源", + "option3": "编辑", + "option4": "删除" + }, + "create": { + "assign": "教材指派", + "assignPlaceholder": "请选择指派范围", + "name": "教材名称", + "namePlaceholder": "请填写教材名称", + "desc": "教材简介", + "descPlaceholder": "请填写教材简介(最多200字)", + "thumb": "教材封面", + "thumbPlaceholder": "请选择教材封面", + "subject": "学科专业", + "subjectPlaceholder": "请填写学科专业", + "author": "作者", + "authorPlaceholder": "请填写作者", + "publisher": "出版社", + "publisherPlaceholder": "请填写出版社", + "total": "章节总数", + "totalPlaceholder": "章节总数", + "createTime": "创建时间", + "createTimePlaceholder": "请填写创建时间", + "publishTime":"发布时间", + "publishTimePlaceholder":"请填写发布时间", + "thumbTip": "(推荐尺寸:400x300px)" + }, + "chapter":{ + "management": "章节管理", + "add": "添加章节", + "edit": "编辑章节", + "delete": "删除章节", + "drag": "拖拽章节", + "saveDrag": "保存目录", + "parent": "章节层级", + "tips": "注意:章节目录不可超过三级!", + "tips3": "注意:章节目录超过三级,请调整!" + } } } diff --git a/app/backend/src/i18n/tc.json b/app/backend/src/i18n/tc.json index 76d69b1..13cb33b 100644 --- a/app/backend/src/i18n/tc.json +++ b/app/backend/src/i18n/tc.json @@ -193,6 +193,7 @@ "cert": "證書", "lesson": "課程中心", "course": "線上課", + "textbook": "教材管理", "offlineCourse": "線下課", "teacher": "講師", "exam": "試題管理", @@ -751,11 +752,15 @@ "label": "全部分類", "label2": "分類", "label3": "線上課", + "label4": "教材管理", + "createTextbook": "新建教材", "create": "新建線上課", "update": "編輯線上課", "delText": "刪除前請檢查選中課程無關聯學習任務,確認刪除?", "name": "課程名稱:", + "textbook": "教材信息:", "namePlaceholder": "請輸入名稱關鍵字", + "textbookPlaceholder": "請輸入教材名稱、作者、學科專業", "aboutTitle": "關聯詳情", "aboutText": "請先將關聯課程的學習任務解除再刪除!", "task": "任務", @@ -778,6 +783,7 @@ "text2": "選修課", "text3": "閱卷" }, + "user": { "label": "線上課學員", "deltext": "請選擇學員後再重置", @@ -837,6 +843,7 @@ "radio2": "有章節", "addText1": "添加課時", "addText2": "請點擊上方按鈕添加課時", + "addText3": "請點擊上方按鈕添加章節", "delText1": "刪除章節", "addChapter": "添加章節", "option": "更多選項", @@ -1906,5 +1913,45 @@ "delSuccess": "撤銷成功" } } + }, + "textbook": { + "bookColumns": { + "title1": "教材名稱", + "title2": "簡介", + "title3": "學科專業", + "title4": "作者", + "title5": "出版社", + "title6": "章節總數", + "title7": "發佈時間", + "title8": "創建時間", + "title9": "操作", + "option1": "章節管理", + "option2": "關聯資源", + "option3": "編輯", + "option4": "刪除" + }, + "create": { + "assign": "教材指派", + "assignPlaceholder": "請選擇指派範圍", + "name": "教材名稱", + "namePlaceholder": "請填入教材名稱", + "desc": "教材簡介", + "descPlaceholder": "請填入教材簡介(最多200字)", + "thumb": "教材封面", + "thumbPlaceholder": "請選擇教材封面", + "subject": "學科專業", + "subjectPlaceholder": "請填入學科專業", + "author": "作者", + "authorPlaceholder": "請填入作者", + "publisher": "出版社", + "publisherPlaceholder": "請填入出版社", + "total": "章節總數", + "totalPlaceholder": "章節總數", + "createTime": "建立時間", + "createTimePlaceholder": "請填入建立時間", + "publishTime":"發佈時間", + "publishTimePlaceholder":"請填入發佈時間", + "thumbTip": "(建議尺寸:400x300px)" + } } } diff --git a/app/backend/src/index.less b/app/backend/src/index.less index 0128db0..f607140 100644 --- a/app/backend/src/index.less +++ b/app/backend/src/index.less @@ -1218,3 +1218,11 @@ textarea.ant-input { .ant-picker-now-btn { color: #ff4d4f !important; } + + +.two-lines-clamp { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} \ No newline at end of file diff --git a/app/backend/src/pages/course/index.tsx b/app/backend/src/pages/course/index.tsx index ae996df..ea50414 100644 --- a/app/backend/src/pages/course/index.tsx +++ b/app/backend/src/pages/course/index.tsx @@ -428,6 +428,7 @@ const CoursePage = () => { <>
+ {/*树状分类*/} { }*/ // 根据不同操作类型,调用对应的后端批量接口 switch (currentOperation) { - case 'resetExam': - // 批量重置接口:传递 examIds + case 'resetExam': { // 批量重置接口:传递 examIds const resResetExam = (await updateList(ids ?? [], '3', '')) as UpdateListResponse; if (resResetExam?.code === 0) { message.success(`成功重置 ${data.records.length} 名学生的考试状态`); @@ -342,8 +341,9 @@ const ExamAdministrationPage = () => { } break; + } // 1补时 2强制交卷 3重置考试 - case 'addTime': + case 'addTime': { const timeValue = data.time ?? ''; // 提供默认空字符串防止 undefined const resAddTime = (await updateList(ids ?? [], '1', timeValue)) as UpdateListResponse; // 批量补时接口:传递 examIds + 补时时间 @@ -354,8 +354,9 @@ const ExamAdministrationPage = () => { message.error('补时失败,请重试'); } break; + } - case 'forceSubmit': + case 'forceSubmit': { const resForceSubmit = (await updateList(ids ?? [], '2', '')) as UpdateListResponse; // 批量强制交卷接口 @@ -366,6 +367,7 @@ const ExamAdministrationPage = () => { message.error('强制交卷失败,请重试'); } break; + } case 'deleteRecord': if (ids && ids.length > 0) { diff --git a/app/backend/src/pages/resource/knowledge-base.tsx b/app/backend/src/pages/resource/knowledge-base.tsx index 485c85b..3eb95e5 100644 --- a/app/backend/src/pages/resource/knowledge-base.tsx +++ b/app/backend/src/pages/resource/knowledge-base.tsx @@ -71,7 +71,8 @@ const formatDate = (value?: string | number | null) => { return ''; } - const date = timestamp.toString().length === 10 ? new Date(timestamp * 1000) : new Date(timestamp); + const date = + timestamp.toString().length === 10 ? new Date(timestamp * 1000) : new Date(timestamp); if (Number.isNaN(date.getTime())) { return ''; } @@ -95,8 +96,7 @@ const normalizeListResponse = (payload: any) => { root?.items, Array.isArray(root) ? root : null, ]; - const list = - listCandidates.find((candidate) => Array.isArray(candidate)) ?? []; + const list = listCandidates.find((candidate) => Array.isArray(candidate)) ?? []; const total = root?.total ?? @@ -179,9 +179,7 @@ const ResourceLibraryKnowledgeBasePage = () => { const { list, total: totalCount, pages } = normalizeListResponse(res?.data); setKnowledgeList(list); setTotal(totalCount); - setTotalPages( - pages || (pageSize ? Math.ceil(totalCount / pageSize) : 0) - ); + setTotalPages(pages || (pageSize ? Math.ceil(totalCount / pageSize) : 0)); setKnowledgeLoading(false); }) .catch(() => { @@ -189,11 +187,7 @@ const ResourceLibraryKnowledgeBasePage = () => { }); }; - const fetchQuestionList = ( - currentPage: number, - keyword?: string, - statusText?: string - ) => { + const fetchQuestionList = (currentPage: number, keyword?: string, statusText?: string) => { setQuestionLoading(true); setQuestionList([]); let kmType: number | undefined; @@ -240,9 +234,7 @@ const ResourceLibraryKnowledgeBasePage = () => { }); const filtered = kmType !== undefined - ? normalizedList.filter( - (item) => Number(item?.kmType) === Number(kmType) - ) + ? normalizedList.filter((item) => Number(item?.kmType) === Number(kmType)) : normalizedList; setQuestionList(filtered); if (kmType !== undefined && filtered.length !== list.length) { @@ -332,7 +324,11 @@ const ResourceLibraryKnowledgeBasePage = () => {
- +
知识库条目 @@ -450,4 +446,3 @@ const ResourceLibraryKnowledgeBasePage = () => { }; export default ResourceLibraryKnowledgeBasePage; - diff --git a/app/backend/src/pages/template/reportForm/subject.tsx b/app/backend/src/pages/template/reportForm/subject.tsx index 23bb74e..be6e487 100644 --- a/app/backend/src/pages/template/reportForm/subject.tsx +++ b/app/backend/src/pages/template/reportForm/subject.tsx @@ -1533,7 +1533,7 @@ const ExperimentSubjectPage: React.FC = () => { {module.tableHeads && ( <div className={styles.table_row}> {module.tableHeads.map((head, index) => ( - <div style={{ flex: 1 }}> + <div style={{ flex: 1 }} key={index}> <div className={styles.ds_}> <div className={styles.head_title_}>数据:</div> <div diff --git a/app/backend/src/pages/textbook/chapter.module.less b/app/backend/src/pages/textbook/chapter.module.less new file mode 100644 index 0000000..1eea8a5 --- /dev/null +++ b/app/backend/src/pages/textbook/chapter.module.less @@ -0,0 +1,32 @@ +.chapter-main-body { + width: 100%; + height: auto; + min-height: calc(100vh - 172px); + float: left; + box-sizing: border-box; + border-radius: 12px; + display: flex; + flex-direction: row; + overflow: hidden; + .left-box { + width: 350px; + float: left; + height: auto; + min-height: calc(100vh - 172px); + border-right: 1px solid #f6f6f6; + box-sizing: border-box; + padding: 24px 16px; + background-color: white; + } + .right-box { + width: calc(100% - 351px); + float: left; + height: auto; + min-height: calc(100vh - 172px); + box-sizing: border-box; + padding: 24px; + background-color: white; + } +} + + diff --git a/app/backend/src/pages/textbook/compenents/TextEditor/EditorToolbar.less b/app/backend/src/pages/textbook/compenents/TextEditor/EditorToolbar.less new file mode 100644 index 0000000..af247b0 --- /dev/null +++ b/app/backend/src/pages/textbook/compenents/TextEditor/EditorToolbar.less @@ -0,0 +1,91 @@ +/* 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; + } +} \ No newline at end of file diff --git a/app/backend/src/pages/textbook/compenents/TextEditor/EditorToolbar.tsx b/app/backend/src/pages/textbook/compenents/TextEditor/EditorToolbar.tsx new file mode 100644 index 0000000..9cb0143 --- /dev/null +++ b/app/backend/src/pages/textbook/compenents/TextEditor/EditorToolbar.tsx @@ -0,0 +1,254 @@ +// components/EnhancedToolbar.tsx +import React from 'react'; +import { Editor, useEditorState } from '@tiptap/react'; +import { EditorState } from '../../../../types/editor'; +import Highlight from '@tiptap/extension-highlight'; +import TextAlign from '@tiptap/extension-text-align'; +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: true, + }, + ], + }, + { + name: 'history', + buttons: [ + { + icon: '↶', + title: '撤销', + action: () => editor.chain().focus().undo().run(), + disabled: !editorState.canUndo, + active: !editorState.canUndo, + }, + { + icon: '↷', + title: '重做', + action: () => editor.chain().focus().redo().run(), + disabled: !editorState.canRedo, + active: !editorState.canRedo, + }, + ], + }, + ]; + + 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; diff --git a/app/backend/src/pages/textbook/compenents/TextEditor/EnhancedTextbookEditor.less b/app/backend/src/pages/textbook/compenents/TextEditor/EnhancedTextbookEditor.less new file mode 100644 index 0000000..61685d9 --- /dev/null +++ b/app/backend/src/pages/textbook/compenents/TextEditor/EnhancedTextbookEditor.less @@ -0,0 +1,287 @@ +/* EnhancedTextbookEditor.css */ +.enhanced-textbook-editor { + display: flex; + flex-direction: column; + height: 100%; + background: white; + border: 1px solid #e1e5e9; + border-radius: 8px; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +/* 空状态 */ +.empty-state { + display: flex; + align-items: center; + justify-content: center; + height: 400px; + color: #6c757d; +} + +.empty-content { + text-align: center; +} + +.empty-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.empty-content h3 { + margin: 0 0 8px 0; + color: #495057; +} + +.empty-content p { + margin: 0; + color: #6c757d; +} + +/* 编辑器头部 */ +.editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: #f8f9fa; + border-bottom: 1px solid #e1e5e9; +} + +.header-left .chapter-title { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #1a1a1a; +} + +.header-left .chapter-id { + font-size: 12px; + color: #6c757d; + margin-top: 4px; +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.editor-stats { + display: flex; + align-items: center; + gap: 12px; + font-size: 12px; + color: #6c757d; +} + +.word-count { + font-weight: 500; +} + +.editing-indicator { + color: #28a745; + font-weight: 500; +} + +.save-button { + padding: 8px 16px; + border: 1px solid #007bff; + background: #007bff; + color: white; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: all 0.2s; +} + +.save-button:hover:not(:disabled) { + background: #0056b3; + border-color: #0056b3; +} + +.save-button:disabled { + background: #6c757d; + border-color: #6c757d; + cursor: not-allowed; +} + +.save-button.has-changes { + background: #28a745; + border-color: #28a745; +} + +.save-button.has-changes:hover { + background: #218838; + border-color: #1e7e34; +} + +/* 工具栏 */ +.toolbar-container { + background: #f8f9fa; + border-bottom: 1px solid #e1e5e9; + padding: 12px 16px; +} + +.enhanced-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.toolbar-section { + display: flex; + align-items: center; +} + +.section-divider { + width: 1px; + height: 24px; + background: #dee2e6; + margin: 0 12px; +} + +.section-buttons { + display: flex; + gap: 4px; +} + +.toolbar-button { + padding: 6px 10px; + border: 1px solid transparent; + background: white; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + min-width: 36px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + color: #495057; +} + +.toolbar-button:hover:not(:disabled) { + background: #e9ecef; + border-color: #ced4da; +} + +.toolbar-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toolbar-button.is-active { + background: #007bff; + color: white; + border-color: #007bff; +} + +/* 编辑器容器 */ +.editor-container { + flex: 1; + overflow-y: auto; + background: white; +} + +/* 编辑器内容样式 */ +.editor-content { + outline: none; + padding: 20px; + min-height: 500px; + font-size: 14px; + line-height: 1.6; + color: #333; +} + +.editor-content h1, +.editor-content h2, +.editor-content h3, +.editor-content h4, +.editor-content h5, +.editor-content h6 { + margin: 1.5em 0 0.5em 0; + font-weight: 600; + line-height: 1.25; +} + +.editor-content h1 { + font-size: 2em; + border-bottom: 2px solid #007bff; + padding-bottom: 0.3em; +} + +.editor-content h2 { + font-size: 1.5em; +} + +.editor-content h3 { + font-size: 1.25em; +} + +.editor-content p { + margin: 1em 0; +} + +.editor-content ul, +.editor-content ol { + margin: 1em 0; + padding-left: 2em; +} + +.editor-content li { + margin: 0.5em 0; +} + +.editor-content blockquote { + border-left: 4px solid #007bff; + margin: 1.5em 0; + padding: 0.5em 1em; + background: #f8f9fa; + font-style: italic; +} + +.editor-content code { + background: #f1f3f4; + padding: 0.2em 0.4em; + border-radius: 3px; + font-family: 'Monaco', 'Menlo', monospace; + font-size: 0.9em; +} + +.editor-content pre { + background: #1a1a1a; + color: #f8f9fa; + padding: 1em; + border-radius: 6px; + overflow-x: auto; + margin: 1.5em 0; +} + +.editor-content pre code { + background: none; + padding: 0; + color: inherit; +} + +.editor-content hr { + border: none; + border-top: 2px solid #dee2e6; + margin: 2em 0; +} + +/* 状态栏 */ +.editor-status { + padding: 8px 16px; + background: #f8f9fa; + border-top: 1px solid #e1e5e9; + font-size: 12px; + color: #6c757d; +} + +.status-info { + display: flex; + justify-content: center; +} \ No newline at end of file diff --git a/app/backend/src/pages/textbook/compenents/TextEditor/EnhancedTextbookEditor.tsx b/app/backend/src/pages/textbook/compenents/TextEditor/EnhancedTextbookEditor.tsx new file mode 100644 index 0000000..cf3659c --- /dev/null +++ b/app/backend/src/pages/textbook/compenents/TextEditor/EnhancedTextbookEditor.tsx @@ -0,0 +1,178 @@ +// components/EnhancedTextbookEditor.tsx +import React, { useEffect, useState, useCallback } from 'react'; +import { EditorContent, useEditor } from '@tiptap/react'; +import { TextStyleKit } from '@tiptap/extension-text-style'; +import StarterKit from '@tiptap/starter-kit'; +import EnhancedToolbar from './EditorToolbar'; +import { EditorProps } from '../../../../types/editor'; +import './EnhancedTextbookEditor.less'; +import TextAlign from '@tiptap/extension-text-align'; +import Highlight from '@tiptap/extension-highlight'; +import Color from '@tiptap/extension-color'; + +const extensions = [ + TextStyleKit, + StarterKit.configure({ + heading: { + levels: [1, 2, 3], + }, + }), + TextAlign.configure({ + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right', 'justify'], + }), + Highlight.configure({ + multicolor: true, + }), + Color.configure({ + types: ['textStyle'], + }), +]; + +const EnhancedTextbookEditor: React.FC<EditorProps> = ({ + chapterId, + chapterTitle, + initialContent = '', + onSave, + onContentChange, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [wordCount, setWordCount] = useState(0); + const [lastSaved, setLastSaved] = useState<Date | null>(null); + + // 初始化编辑器 + const editor = useEditor({ + extensions, + content: + initialContent || + ` + <h2>${chapterTitle}</h2> + <p>开始编写本章节的内容...</p> + `, + immediatelyRender: false, + onUpdate: ({ editor }) => { + const content = editor.getHTML(); + const text = editor.getText(); + + // 更新统计 + setWordCount(text.split(/\s+/).filter((word) => word.length > 0).length); + + // 通知父组件 + onContentChange(chapterId, content); + setIsEditing(true); + }, + onBlur: ({ editor }) => { + // 失去焦点时自动保存 + if (isEditing) { + const content = editor.getHTML(); + onSave(chapterId, content); + setLastSaved(new Date()); + setIsEditing(false); + } + }, + editorProps: { + attributes: { + class: 'editor-content', + 'data-chapter-id': chapterId, + }, + }, + }); + + // 章节切换时更新内容 + useEffect(() => { + if (editor && initialContent !== editor.getHTML()) { + editor.commands.setContent( + initialContent || + ` + <h2>${chapterTitle}</h2> + <p>开始编写本章节的内容...</p> + ` + ); + setIsEditing(false); + + // 更新统计 + const text = editor.getText(); + setWordCount(text.split(/\s+/).filter((word) => word.length > 0).length); + } + }, [chapterId, initialContent, chapterTitle, editor]); + + // 手动保存 + const handleManualSave = useCallback(() => { + if (editor && isEditing) { + const content = editor.getHTML(); + onSave(chapterId, content); + setLastSaved(new Date()); + setIsEditing(false); + } + }, [editor, chapterId, isEditing, onSave]); + + // 格式化时间 + const formatTime = (date: Date | null): string => { + if (!date) return '未保存'; + return date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + }); + }; + + if (!chapterId) { + return ( + <div className="enhanced-textbook-editor empty-state"> + <div className="empty-content"> + <div className="empty-icon">📚</div> + <h3>请选择章节</h3> + <p>从左侧目录中选择一个章节开始编辑</p> + </div> + </div> + ); + } + + if (!editor) { + return <div>编辑器加载中...</div>; + } + + return ( + <div className="enhanced-textbook-editor"> + {/* 编辑器头部 */} + <div className="editor-header"> + <div className="header-left"> + <h2 className="chapter-title">{chapterTitle}</h2> + <span className="chapter-id">ID: {chapterId}</span> + </div> + <div className="header-right"> + <div className="editor-stats"> + <span className="word-count">字数: {wordCount}</span> + {isEditing && <span className="editing-indicator">● 编辑中</span>} + <span className="last-saved">最后保存: {formatTime(lastSaved)}</span> + </div> + <button + className={`save-button ${isEditing ? 'has-changes' : ''}`} + onClick={handleManualSave} + disabled={!isEditing} + > + {isEditing ? '保存更改' : '已保存'} + </button> + </div> + </div> + + {/* 工具栏 */} + <div className="toolbar-container"> + <EnhancedToolbar editor={editor} /> + </div> + + {/* 编辑器内容 */} + <div className="editor-container"> + <EditorContent editor={editor} /> + </div> + + {/* 状态栏 */} + <div className="editor-status"> + <div className="status-info"> + <span>TipTap v3 • 章节编辑器</span> + </div> + </div> + </div> + ); +}; + +export default EnhancedTextbookEditor; diff --git a/app/backend/src/pages/textbook/compenents/chapterModal.tsx b/app/backend/src/pages/textbook/compenents/chapterModal.tsx new file mode 100644 index 0000000..fe668ef --- /dev/null +++ b/app/backend/src/pages/textbook/compenents/chapterModal.tsx @@ -0,0 +1,134 @@ +import { Modal, Form, Input, Select } from 'antd'; +import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +const { Option } = Select; + +interface ChapterItem { + id: string | number; + name: string; + title?: string; + level: number; + parent_chain: string; + parent_id: string; + sort: number; + children?: ChapterItem[]; +} + +interface ChapterItemModel { + created_at: string; + id: number; + name: string; + from_scene: number; + parent_chain: string; + parent_id: number; + sort: number; + updated_at: string; +} + +interface ChapterModalProps { + visible: boolean; + mode: 'add' | 'edit'; + initialData?: { + title?: string; + level?: number; + }; + parentChapter?: ChapterItemModel | null; + onOk: (values: { title: string; level: number }) => void; + onCancel: () => void; + confirmLoading?: boolean; +} + +export const ChapterModal: React.FC<ChapterModalProps> = ({ + visible, + mode, + initialData, + parentChapter, + onOk, + onCancel, + confirmLoading = false, +}) => { + const [form] = Form.useForm(); + const { t } = useTranslation(); + useEffect(() => { + if (visible) { + if (mode === 'edit' && initialData) { + form.setFieldsValue(initialData); + } else { + form.resetFields(); + let defaultLevel = 1; + if (parentChapter) { + defaultLevel = parentChapter.parent_chain.split(',').length + 1; + } + form.setFieldsValue({ + level: defaultLevel, + ...initialData, + }); + } + } + }, [visible, mode, initialData, parentChapter, form]); + + const handleOk = async () => { + try { + const values = await form.validateFields(); + onOk(values); + console.log(values, 'add values'); + } catch (error) { + console.error('表单验证失败:', error); + } + }; + + const handleCancel = () => { + form.resetFields(); + onCancel(); + }; + + return ( + <Modal + title={mode == 'edit' ? '编辑章节' : '添加章节'} + open={visible} + onOk={handleOk} + onCancel={handleCancel} + okText="确认" + cancelText="取消" + confirmLoading={confirmLoading} + destroyOnHidden={true} + > + <Form form={form} layout="vertical" preserve={false}> + <Form.Item + name="parent_id" + label={t('textbook.chapter.parent')} + rules={[{ required: true, message: '请选择章节层级' }]} + > + <Select placeholder="请选择章节层级" disabled={mode === 'edit'}> + <Option + value={1} + disabled={parentChapter && parentChapter.parent_chain.split(',').length >= 1} + > + 章 + </Option> + <Option + value={2} + disabled={!parentChapter || parentChapter.parent_chain.split(',').length === 2} + > + 节 + </Option> + <Option + value={3} + disabled={!parentChapter || parentChapter.parent_chain.split(',').length >= 3} + > + 小节 + </Option> + </Select> + </Form.Item> + <Form.Item + name="title" + label="章节标题" + rules={[{ required: true, message: '请输入章节标题' }]} + > + <Input placeholder="请输入章节标题" /> + </Form.Item> + </Form> + </Modal> + ); +}; diff --git a/app/backend/src/pages/textbook/compenents/chapterTree.module.less b/app/backend/src/pages/textbook/compenents/chapterTree.module.less new file mode 100644 index 0000000..a4a6ce7 --- /dev/null +++ b/app/backend/src/pages/textbook/compenents/chapterTree.module.less @@ -0,0 +1,177 @@ +// chapterTree.module.less +.chapterTree { + display: block; + width: 330px; + overflow: hidden; + background: white; +} + +.chapterTitle { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + font-weight: bold; + border-bottom: 1px solid #cccccc; + padding: 16px 2px; + box-sizing: border-box; + font-size:16px +} + +.bottom-tree-box { + padding: 10px 0; + line-height: 32px; + width: 100%; + box-sizing: border-box; +} +.tree-node-content { + display: flex; + justify-content: space-between; + align-items: center; + padding:0 8px; + width: 100%; + box-sizing: border-box; + min-height: 40px; +} + +.tree-title-content { + flex: 1; + min-width: 0; + margin-right: 8px; + display: flex; + align-items: flex-start; // 顶部对齐 + padding: 4px 0; // 添加垂直内边距 +} + +.tree-title-elli { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #333; +} +.tree-node-actions { + display: flex; + gap: 4px; + transition: opacity 0.2s; + flex-direction: row; + align-items: center; + flex-shrink: 0; + width: auto; + min-width: 28px; + max-width: 60px; + justify-content: flex-end; +} + +.tree-node-actions-hidden { + display: flex; + gap: 4px; + transition: opacity 0.2s; + flex-direction: row; + align-items: center; + flex-shrink: 0; + width: auto; + min-width: 28px; + max-width: 60px; + justify-content: flex-end; + opacity: 0; +} + +// 章节级别样式 - 移除固定宽度,使用弹性布局 +.chapterLevel1 { + font-size: 16px; + font-weight: 500; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + line-height: 1.4; + max-height: 2.8em; +} + +.chapterLevel2 { + font-size: 15px; + font-weight: 400; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + line-height: 1.4; + max-height: 2.8em; +} + +.chapterLevel3 { + font-size: 14px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + white-space: normal; + line-height: 1.4; + max-height: 2.8em; +} + +.action-btn { + background: none; + border: none; + cursor: pointer; + padding: 2px 6px; + border-radius: 3px; + font-size: 12px; + transition: background-color 0.2s; + flex-shrink: 0; +} + +.category-label { + padding: 8px 20px; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.2s; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + box-sizing: border-box; +} + +.category-label:hover { + background-color: #f5f5f5; +} + +.category-label.active { + background-color: #e6f7ff; + color: #1890ff; +} + +.icon-hover { + transition: color 0.3s ease; +} + +.icon-hover:hover { + color: #ef4444 !important; /* 或者你想要的 hover 颜色 */ +} +.dragVisible { + visibility: visible; + cursor: grab; +} + +.dragHidden { + visibility: hidden; +} + +#chapter-tree-container .ant-tree-switcher:before { + pointer-events: none !important; + content: "" !important; + width: 24px !important; + height: 24px !important; + position: absolute !important; + left: 0 !important; + top: 14px !important; + border-radius: 6px !important; + transition: all 0.3s !important; +} \ No newline at end of file diff --git a/app/backend/src/pages/textbook/compenents/chapterTree.tsx b/app/backend/src/pages/textbook/compenents/chapterTree.tsx new file mode 100644 index 0000000..f3abc70 --- /dev/null +++ b/app/backend/src/pages/textbook/compenents/chapterTree.tsx @@ -0,0 +1,681 @@ +import { Button, Image, Tree, Modal, Form, message, Spin, Tooltip } from 'antd'; +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './chapterTree.module.less'; +import { + CaretDownFilled, + CaretRightFilled, + CaretUpFilled, + DeleteOutlined, + DownOutlined, + DragOutlined, + EditFilled, + ExclamationCircleFilled, + FormOutlined, + HolderOutlined, + PlusCircleFilled, + PlusOutlined, + SaveOutlined, +} from '@ant-design/icons'; +import { department } from '../../../api/index'; +const { confirm } = Modal; +import { ChapterModal } from './chapterModal'; +import type { DataNode, TreeProps } from 'antd/es/tree'; +import { useNavigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import textbook from '../index'; +interface ChapterItem { + id: string | number; + name: string; + title?: string; + level?: number; + parent_chain: string; + parent_id: string; + sort: number; + children?: ChapterItem[]; +} + +interface ChapterItemModel { + created_at: string; + id: number; + name: string; + from_scene: number; + parent_chain: string; + parent_id: number; + sort: number; + updated_at: string; +} + +interface ChaptersBoxModel { + [key: number]: ChapterItemModel[]; +} + +interface Option { + key: string | number; + title: any; + children?: Option[]; + level?: number; +} + +interface PropInterface { + chapterTreeData: ChaptersBoxModel; + isLoading: boolean; + selected?: any; + selectedId?: any; + onUpdate?: (keys: any, title: any) => void; + onAdd?: (parentId: string | number | null, level: number) => void; + onEdit?: (item: ChapterItem) => void; + onDelete?: (item: ChapterItem) => void; + onSelect?: (item: ChapterItem) => void; + onOrderChange?: (chapters: any[]) => void; // 新增:拖拽顺序改变回调 +} + +export const ChapterTree = (props: PropInterface) => { + const { + chapterTreeData, + isLoading, + selected, + onUpdate, + onAdd, + onEdit, + onDelete, + onSelect, + onOrderChange, + } = props; + const permissions = useSelector((state: any) => state.loginUser.value.permissions); + + const through = (p: string) => { + if (!permissions) { + return false; + } + return typeof permissions[p] !== 'undefined'; + }; + const navigate = useNavigate(); + const { t } = useTranslation(); + const [treeData, setTreeData] = useState<Option[]>([]); + const [loading, setLoading] = useState<boolean>(true); + const [selectKey, setSelectKey] = useState<number[]>([]); + const [selectedNodeId, setSelectedNodeId] = useState<string | number | null>(null); + const [modalVisible, setModalVisible] = useState<boolean>(false); + const [editingChapter, setEditingChapter] = useState<ChapterItemModel | null>(null); + const [parentChapter, setParentChapter] = useState<ChapterItemModel | null>(null); + const [form] = Form.useForm(); + const [dragEnabled, setDragEnabled] = useState<boolean>(false); // 拖拽状态 + const [did, setDid] = useState<number>(0); + const [modal, contextHolder] = Modal.useModal(); + const [submitLoading, setSubmitLoading] = useState<boolean>(false); + const [refresh, setRefresh] = useState(false); + const [isMoreThanThree, setIsMoreThanThree] = useState<boolean>(false); + // 切换拖拽模式 + const toggleDragMode = () => { + const newState = !dragEnabled; + setDragEnabled(newState); + message.info(newState ? '拖拽模式已开启,可以拖拽节点调整顺序' : '拖拽模式已关闭'); + }; + + // 根据层级获取样式类名 + const getLevelClassName = (level: number): string => { + switch (level) { + case 1: + return styles.chapterLevel1; + case 2: + return styles.chapterLevel2; + case 3: + return styles.chapterLevel3; + default: + return styles.chapterLevel3; + } + }; + const onDragEnter: TreeProps['onDragEnter'] = (info) => { + console.log(info); + // expandedKeys 需要受控时设置 + // setExpandedKeys(info.expandedKeys) + }; + + const onDrop: TreeProps['onDrop'] = (info) => { + const dropKey = info.node.key; //目标节点key + const dragKey = info.dragNode.key; // 被拖拽节点的key + const dropPos = info.node.pos.split('-'); + const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]); + + const loop = ( + data: DataNode[], + key: React.Key, + callback: (node: DataNode, i: number, data: DataNode[]) => void + ) => { + for (let i = 0; i < data.length; i++) { + if (data[i].key === key) { + // 找到目标节点 执行回调 + return callback(data[i], i, data); + } + if (data[i].children) { + // 递归查找 子节点 + loop(data[i].children!, key, callback); + } + } + }; + const data = [...treeData]; + + let isTop = false; + // 判断被拖拽节点是否在顶层 + for (let i = 0; i < data.length; i++) { + if (data[i].key === dragKey) { + isTop = true; + } + } + // 查找并移除被拖拽的节点 + let dragObj: DataNode; + loop(data, dragKey, (item, index, arr) => { + arr.splice(index, 1); // 从原位置删除 + dragObj = item; // 保存被拖拽的节点 + }); + // 根据拖拽位置处理不同的放置情况 + if (!info.dropToGap) { + loop(data, dropKey, (item) => { + item.children = item.children || []; + item.children.unshift(dragObj); + }); + } else if ( + ((info.node as any).props.children || []).length > 0 && + (info.node as any).props.expanded && + dropPosition === 1 + ) { + loop(data, dropKey, (item) => { + item.children = item.children || []; + item.children.unshift(dragObj); + }); + } else { + let ar: DataNode[] = []; + let i: number; + loop(data, dropKey, (_item, index, arr) => { + ar = arr; + i = index; + }); + if (dropPosition === -1) { + ar.splice(i!, 0, dragObj!); + } else { + ar.splice(i! + 1, 0, dragObj!); + } + } + setTreeData(data); + // 检查是否超过三级 + const isExceedThreeLevels = (data: DataNode[]): boolean => { + let exceeded = false; + + const checkLevel = (nodes: DataNode[], currentLevel: number = 1) => { + if (currentLevel > 3) { + exceeded = true; + return; + } + + for (const node of nodes) { + if (node.children && node.children.length > 0) { + checkLevel(node.children, currentLevel + 1); + if (exceeded) return; // 提前退出 + } + } + }; + + checkLevel(data); + setIsMoreThanThree(exceeded); + return exceeded; + }; + + // 使用示例 + if (isExceedThreeLevels(data)) { + message.error('目录结构超过3级限制'); + } + submitDrop(isTop, data, dragKey); + }; + + const submitChildDrop = (key: any, pid: any, ids: any) => { + department.dropDiffClass(key, pid, ids.ids).then((res: any) => { + console.log('ok'); + }); + }; + + const checkDropArr = (data: any, key: any) => { + const ids = []; + let isSame = false; + for (let i = 0; i < data.length; i++) { + ids.push(data[i].key); + if (data[i].key === key) { + isSame = true; + } + if (data[i].children) { + const res: any = checkDropArr(data[i].children, key); + if (res) { + submitChildDrop(key, data[i].key, res); + } + } + } + if (isSame) { + return { key, ids }; + } + }; + + const submitDrop = (isTop: boolean, data: any, key: any) => { + const result = checkDropArr(data, key); + if (result) { + if (isTop) { + department.dropSameClass(result.ids).then((res: any) => { + console.log('ok'); + }); + } else { + submitChildDrop(key, 0, result); + } + } + }; + + // 生成带序号的名称 - 需要更新以处理级别变化 + const generateChapterName = (chapter: ChapterItem, parent?: ChapterItem | null): string => { + // 如果节点成为一级目录,重新生成名称 + if (chapter.level === 1) { + return `第${chapter.sort}章 ${chapter.title}`; + } else if (chapter.level === 2) { + const parentSort = parent?.sort ?? '?'; + return `${parentSort}.${chapter.sort} ${chapter.title}`; + } else { + const parentSort = parent?.sort ?? '?'; + return `${parentSort}.${chapter.sort} ${chapter.title}`; + } + }; + + // 更新 regenerateChapterNames 以处理级别变化 + const regenerateChapterNames = (data: { [key: number]: ChapterItem[] }) => { + const regenerate = (chapters: ChapterItem[], parent?: ChapterItem): ChapterItem[] => { + return chapters.map((chapter, index) => { + // 更新排序 + const updatedChapter = { + ...chapter, + sort: index + 1, + }; + + // 重新生成名称 + updatedChapter.name = generateChapterName(updatedChapter, parent); + + // 递归处理子节点 + if (chapter.children && chapter.children.length > 0) { + return { + ...updatedChapter, + children: regenerate(chapter.children, updatedChapter), + }; + } + + return updatedChapter; + }); + }; + + return { 0: regenerate(data[0]) }; + }; + + useEffect(() => { + if (selected && selected.length > 0) { + setSelectKey(selected); + } + }, [selected]); + + useEffect(() => { + setTimeout(() => { + if (JSON.stringify(chapterTreeData) !== '{}') { + const new_arr: Option[] = checkArr(chapterTreeData, 0); + setTreeData(new_arr); + } + }, 500); + }, [chapterTreeData]); + + const checkArr = (departments: ChaptersBoxModel, id: number) => { + const arr: any = []; + if (!departments[id]) return arr; + + for (let i = 0; i < departments[id].length; i++) { + const item: ChapterItemModel = departments[id][i]; + const level = item.parent_chain ? item.parent_chain.split(',').length + 1 : 1; + const hasChildren = departments[item.id]; + + const name = ( + <div className={styles['tree-node-content']}> + <div className={styles['tree-title-content']}> + <div className={`${styles['tree-title-elli']} ${getLevelClassName(level)}`}> + {item.name} + </div> + </div> + <div className={styles['tree-node-actions']}> + <Button + type="link" + className="b-link c-red" + onClick={(e) => { + e.stopPropagation(); + }} + style={{ padding: '6px', minWidth: 'auto' }} + > + <EditFilled + className={styles['icon-hover']} + style={{ fontSize: '18px', color: '#8c8c8c' }} + /> + </Button> + + {level !== 3 && ( + <Button + type="link" + className="b-link c-red" + onClick={(e) => { + e.stopPropagation(); + handleAdd(item); + }} + style={{ padding: '6px', minWidth: 'auto' }} + > + <PlusCircleFilled + className={styles['icon-hover']} + style={{ fontSize: '18px', color: '#8c8c8c' }} + /> + </Button> + )} + </div> + </div> + ); + + if (hasChildren) { + const new_arr: Option[] = checkArr(departments, item.id); + arr.push({ + title: name, + key: item.id, + children: new_arr, + level: level, + }); + } else { + arr.push({ + title: name, + key: item.id, + level: level, + }); + } + } + return arr; + }; + const removeItem = (id: number, label: string) => { + if (id === 0) { + return; + } + department.checkDestroy(id).then((res: any) => { + if ( + res.data.children && + res.data.children.length === 0 && + res.data.courses && + res.data.courses.length === 0 && + res.data.users && + res.data.users.length === 0 + ) { + delUser(id); + } else { + if (res.data.children && res.data.children.length > 0) { + modal.warning({ + title: t('commen.confirmError'), + centered: true, + okText: t('commen.okText2'), + content: ( + <p> + {t('department.unbindText1', { + // depName: depNameOBJ[systemLanguage][depDefaultName], + })} + <span className="c-red"> + ({res.data.children.length} + {t('department.unbindText2', { + // depName: depNameOBJ[systemLanguage][depDefaultName], + })} + ) + </span> + ,{t('department.unbindText3')} + </p> + ), + }); + } else { + modal.warning({ + title: t('commen.confirmError'), + centered: true, + okText: t('commen.okText2'), + content: ( + <p> + {t('department.unbindText4', { + // depName: depNameOBJ[systemLanguage][depDefaultName], + })} + {res.data.courses && res.data.courses.length > 0 && ( + <Button + style={{ paddingLeft: 4, paddingRight: 4 }} + type="link" + danger + onClick={() => navigate('/course?did=' + id + '&label=' + label)} + > + ({res.data.courses.length} + {t('department.unbindText5')}), + </Button> + )} + {res.data.users && res.data.users.length > 0 && ( + <Button + type="link" + style={{ paddingLeft: 4, paddingRight: 4 }} + danger + onClick={() => navigate('/member/index?did=' + id + '&label=' + label)} + > + ({res.data.users.length} + {t('department.unbindText6')}), + </Button> + )} + {t('department.unbindText3')} + </p> + ), + }); + } + } + }); + }; + + const resetData = () => { + setTreeData([]); + setRefresh(!refresh); + }; + + const delUser = (id: any) => { + confirm({ + title: t('commen.confirmError'), + icon: <ExclamationCircleFilled />, + content: t('department.delText', { + // depName: depNameOBJ[systemLanguage][depDefaultName], + }), + centered: true, + okText: t('commen.okText'), + cancelText: t('commen.cancelText'), + onOk() { + department.destroyDepartment(id).then((res: any) => { + message.success(t('commen.success')); + resetData(); + }); + }, + onCancel() { + console.log('Cancel'); + }, + }); + }; + + // 打开添加章节弹窗 + const handleAdd = (parent?: ChapterItemModel) => { + setParentChapter(parent || null); + setEditingChapter(null); + setModalVisible(true); + }; + + // 打开编辑章节弹窗 + const handleEdit = (chapter: ChapterItemModel) => { + setEditingChapter(chapter); + setParentChapter(chapter); + setModalVisible(true); + }; + + // 操作完成后清除选中ID + const handleOperationComplete = () => { + setSelectedNodeId(null); + }; + + const onSelectTree = (selectedKeys: any, info: any) => { + setSelectKey(selectedKeys); + }; + + const getNodeTitle = (node: any): string => { + if (node.title && node.title.props && node.title.props.children) { + const titleContent = node.title.props.children; + if (typeof titleContent === 'string') { + return titleContent; + } else if (titleContent.props && titleContent.props.children) { + return titleContent.props.children; + } + } + return node.title || ''; + }; + + const onExpand = (selectedKeys: any, info: any) => { + // 处理展开逻辑 + }; + + // 关闭弹窗 + const handleModalCancel = () => { + setModalVisible(false); + setEditingChapter(null); + setParentChapter(null); + }; + + const handleSubmit = (values: any) => { + if (loading) { + return; + } + setLoading(true); + department + .storeDepartment(values.name, values.parent_id || 0, 0) + .then((res: any) => { + setLoading(false); + message.success(t('commen.saveSuccess')); + handleModalCancel(); + }) + .catch((e) => { + setLoading(false); + }); + }; + return ( + <div className={styles.chapterTree} id={'chapter-tree-container'}> + <div className={styles.chapterTitle}> + <div>{t('textbook.chapter.management')}</div> + <div> + <Button + type="link" + className="b-link c-red mr-8" + onClick={() => handleAdd()} + disabled={dragEnabled} + title={t('textbook.chapter.add')} + > + <PlusOutlined style={{ fontSize: '20px' }} /> + </Button> + <Button + type="link" + className="b-link c-red mr-8" + title={t('textbook.chapter.edit')} + disabled={dragEnabled} + onClick={(e) => e.preventDefault()} + > + <FormOutlined style={{ fontSize: '20px' }} /> + </Button> + <Button + type="link" + className="b-link c-red mr-8" + title={t('textbook.chapter.delete')} + disabled={dragEnabled} + onClick={(e) => e.preventDefault()} + > + <DeleteOutlined style={{ fontSize: '20px' }} /> + </Button> + {!dragEnabled ? ( + <Button + type="link" + className={`b-link c-red ${dragEnabled ? styles['drag-active'] : ''}`} + onClick={toggleDragMode} + title={t('textbook.chapter.drag')} + > + <DragOutlined + style={{ fontSize: '20px', color: dragEnabled ? '#1890ff' : undefined }} + /> + </Button> + ) : ( + <Button + type="link" + className={`b-link c-red ${styles['drag-active']}`} + onClick={toggleDragMode} + title={t('textbook.chapter.saveDrag')} + > + <SaveOutlined style={{ fontSize: '20px', color: '#1890ff' }} /> + </Button> + )} + </div> + </div> + <div className="float-left "> + {isLoading && ( + <div className="float-left text-center mt-30"> + <Spin></Spin> + </div> + )} + {dragEnabled && <div className={'primary'}> {t('textbook.chapter.tips')}</div>} + {isMoreThanThree && <div className={'primary'}> {t('textbook.chapter.tips3')}</div>} + {treeData.length > 0 && ( + <div className={`${styles[`bottom-tree-box`]}`}> + <Tree + draggable={ + dragEnabled + ? { + icon: ( + <div className={'mt-8 '}> + <i + className="iconfont icon-icon-drag " + style={{ + fontSize: 24, + visibility: 'visible', + cursor: 'grab', + }} + /> + </div> + ), + nodeDraggable: (node) => true, + } + : false + } + onDrop={onDrop} + defaultExpandedKeys={['0-0-0']} + selectedKeys={selectKey} + onSelect={onSelectTree} + blockNode + treeData={treeData} + onExpand={onExpand} + switcherIcon={ + <CaretDownFilled style={{ color: '#a89f9e', fontSize: 20, paddingTop: 10 }} /> + } + /> + </div> + )} + </div> + <ChapterModal + visible={!dragEnabled && modalVisible} + mode={editingChapter ? 'edit' : 'add'} + initialData={ + editingChapter + ? { + title: editingChapter.name, + level: editingChapter?.parent_chain?.split(',').length + ? editingChapter?.parent_chain?.split(',').length + 1 + : 1, + } + : undefined + } + parentChapter={parentChapter} + onOk={handleSubmit} + onCancel={handleModalCancel} + confirmLoading={submitLoading} + /> + </div> + ); +}; diff --git a/app/backend/src/pages/textbook/compenents/chatperModal.module.less b/app/backend/src/pages/textbook/compenents/chatperModal.module.less new file mode 100644 index 0000000..e69de29 diff --git a/app/backend/src/pages/textbook/compenents/updateTextbook.module.less b/app/backend/src/pages/textbook/compenents/updateTextbook.module.less new file mode 100644 index 0000000..b44cb43 --- /dev/null +++ b/app/backend/src/pages/textbook/compenents/updateTextbook.module.less @@ -0,0 +1,22 @@ +.thumb-item { + width: 80px; + height: 60px; + cursor: pointer; + margin-right: 8px; + border-radius: 6px; + &:last-child { + margin-right: 0; + } +} + +.thumb-item-avtive { + width: 80px; + height: 60px; + border: 2px solid #ff4d4f; + cursor: pointer; + margin-right: 8px; + border-radius: 8px; + &:last-child { + margin-right: 0; + } +} diff --git a/app/backend/src/pages/textbook/compenents/updateTextbook.tsx b/app/backend/src/pages/textbook/compenents/updateTextbook.tsx new file mode 100644 index 0000000..ec97f55 --- /dev/null +++ b/app/backend/src/pages/textbook/compenents/updateTextbook.tsx @@ -0,0 +1,548 @@ +import React, { useState, useEffect } from 'react'; +import { + Space, + Radio, + Button, + Drawer, + Form, + TreeSelect, + Input, + message, + Image, + Spin, + Select, + DatePicker, + Tag, + Switch, + InputNumber, +} from 'antd'; +import styles from './update.module.less'; +import { course, teacher } from '../../../api/index'; +import { UploadImageButton, SelectRange } from '../../../compenents'; +import defaultThumb1 from '../../../assets/thumb/thumb1.png'; +import defaultThumb2 from '../../../assets/thumb/thumb2.png'; +import defaultThumb3 from '../../../assets/thumb/thumb3.png'; +import { useTranslation } from 'react-i18next'; +import dayjs from 'dayjs'; +import moment from 'moment'; +import { textbook } from '../../../api'; + +const { TextArea } = Input; +interface PropInterface { + id: number; + open: boolean; + onCancel: () => void; +} + +export const TextbookUpdate: React.FC<PropInterface> = ({ id, open, onCancel }) => { + const { t } = useTranslation(); + const [form] = Form.useForm(); + const [init, setInit] = useState(true); + const [loading, setLoading] = useState(false); + const [thumb, setThumb] = useState(''); + const [resourceUrl, setResourceUrl] = useState<ResourceUrlModel>({}); + const [depIds, setDepIds] = useState<number[]>([]); + const [groupIds, setGroupIds] = useState<number[]>([]); + const [userIds, setUserIds] = useState<number[]>([]); + const [deps, setDeps] = useState<any[]>([]); + const [groups, setGroups] = useState<any[]>([]); + const [users, setUsers] = useState<any[]>([]); + const [idsVisible, setIdsVisible] = useState(false); + + useEffect(() => { + setInit(true); + if (id === 0) { + return; + } + if (open) { + setDepIds([]); + setDeps([]); + setGroupIds([]); + setGroups([]); + setUserIds([]); + setUsers([]); + getDetail(); + } + }, [form, id, open]); + // 信息回显 + const getDetail = () => { + textbook.textbookDetail(id).then((res: any) => { + form.setFieldsValue({ + title: res.data.textbook.title, + thumb: res.data.textbook.thumb, + short_desc: res.data.textbook.shortDesc, + author: res.data.textbook.author, + major: res.data.textbook.major, + publish_time: res.data.textbook.publishTime ? dayjs(res.data.textbook.publishTime) : '', + publish_unit: res.data.textbook.publishUnit, + create_time: res.data.textbook.createTime ? dayjs(res.data.textbook.createTime) : '', + }); + const deps = res.data.deps; + if (deps && JSON.stringify(deps) !== '{}') { + getDepsDetail(deps); + } + const groups = res.data.groups; + if (groups && JSON.stringify(groups) !== '{}') { + getGroupsDetail(groups); + } + const users = res.data.users; + if (users && JSON.stringify(users) !== '{}') { + getUsersDetail(users); + } + if ( + (deps && JSON.stringify(deps) !== '{}') || + (groups && JSON.stringify(groups) !== '{}') || + (users && JSON.stringify(users) !== '{}') + ) { + form.setFieldsValue({ ids: [1, 2] }); + } + setResourceUrl(res.data.resource_url); + setThumb( + res.data.textbook.thumb === -1 + ? defaultThumb1 + : res.data.textbook.thumb === -2 + ? defaultThumb2 + : res.data.textbook.thumb === -3 + ? defaultThumb3 + : res.data.resource_url[res.data.textbook.thumb] + ); + setInit(false); + }); + }; + + const getDepsDetail = (deps: any) => { + const arr: any = []; + const arr2: any = []; + Object.keys(deps).map((v, i) => { + arr.push(Number(v)); + arr2.push({ + key: Number(v), + title: { + props: { + children: deps[v], + }, + }, + }); + }); + setDepIds(arr); + setDeps(arr2); + }; + + const getGroupsDetail = (groups: any) => { + const arr: any = []; + const arr2: any = []; + Object.keys(groups).map((v, i) => { + arr.push(Number(v)); + arr2.push({ + key: Number(v), + title: { + props: { + children: groups[v], + }, + }, + }); + }); + setGroupIds(arr); + setGroups(arr2); + }; + + const getUsersDetail = (users: any) => { + const arr: any = []; + const arr2: any = []; + Object.keys(users).map((v, i) => { + arr.push(Number(v)); + arr2.push({ + id: Number(v), + name: users[v], + }); + }); + setUserIds(arr); + setUsers(arr2); + }; + + const onFinish = (values: any) => { + if (loading) { + return; + } + const dep_ids: any[] = depIds; + const user_ids: any[] = userIds; + const group_ids: any[] = groupIds; + + values.sort_at = moment(new Date(values.sort_at)).format('YYYY-MM-DD'); + setLoading(true); + textbook + .updateTextbook( + id, + values.title, + values.thumb, + values.short_desc, + values.author, + values.major, + dep_ids, + group_ids, + user_ids, + values.publish_time, + values.publish_unit, + values.create_time + ) + .then((res: any) => { + setLoading(false); + message.success(t('commen.saveSuccess')); + onCancel(); + }) + .catch((e) => { + setLoading(false); + }); + }; + + const onFinishFailed = (errorInfo: any) => { + console.log('Failed:', errorInfo); + }; + + const disabledDate = (current: any) => { + return current && current >= moment().add(0, 'days'); + }; + + return ( + <> + {open ? ( + <Drawer + title={t('course.updateTextbook')} + onClose={onCancel} + maskClosable={false} + open={true} + footer={ + <Space className="j-r-flex"> + <Button onClick={() => onCancel()}>{t('commen.drawerCancel')}</Button> + <Button loading={loading} onClick={() => form.submit()} type="primary"> + {t('commen.drawerOk')} + </Button> + </Space> + } + width={700} + > + <div className="float-left mt-24"> + <SelectRange + defaultDepIds={depIds} + defaultGroupIds={groupIds} + defaultUserIds={userIds} + defaultDeps={deps} + defaultGroups={groups} + defaultUsers={users} + open={idsVisible} + onCancel={() => setIdsVisible(false)} + onSelected={(selDepIds, selDeps, selGroupIds, selGroups, selUserIds, selUsers) => { + setDepIds(selDepIds); + setDeps(selDeps); + setGroupIds(selGroupIds); + setGroups(selGroups); + setUserIds(selUserIds); + setUsers(selUsers); + form.setFieldsValue({ + ids: selDepIds.concat(selGroupIds).concat(selUserIds), + }); + setIdsVisible(false); + }} + /> + {/* 表单 */} + <Form + form={form} + name="create-basic" + labelCol={{ span: 5 }} + wrapperCol={{ span: 19 }} + initialValues={{ remember: true }} + onFinish={onFinish} + onFinishFailed={onFinishFailed} + autoComplete="off" + > + {/* 表单字段 */} + <Form.Item + label={t('textbook.create.name')} + name="title" + rules={[{ required: true, message: t('textbook.create.namePlaceholder') }]} + > + <Input + style={{ width: 424 }} + placeholder={t('textbook.create.namePlaceholder')} + allowClear + /> + </Form.Item> + + <Form.Item + label={t('textbook.create.thumb')} + name="thumb" + rules={[{ required: true, message: t('textbook.create.thumbPlaceholder') }]} + > + <div className="d-flex"> + <Image + src={thumb} + width={160} + height={120} + style={{ borderRadius: 6 }} + preview={false} + /> + <div className="c-flex ml-8 flex-1"> + <div className="d-flex mb-28"> + <div + className={ + thumb === defaultThumb1 + ? styles['thumb-item-avtive'] + : styles['thumb-item'] + } + onClick={() => { + setThumb(defaultThumb1); + form.setFieldsValue({ + thumb: -1, + }); + }} + > + <Image + src={defaultThumb1} + width={80} + height={60} + style={{ borderRadius: 6 }} + preview={false} + /> + </div> + <div + className={ + thumb === defaultThumb2 + ? styles['thumb-item-avtive'] + : styles['thumb-item'] + } + onClick={() => { + setThumb(defaultThumb2); + form.setFieldsValue({ + thumb: -2, + }); + }} + > + <Image + src={defaultThumb2} + width={80} + height={60} + style={{ borderRadius: 6 }} + preview={false} + /> + </div> + <div + className={ + thumb === defaultThumb3 + ? styles['thumb-item-avtive'] + : styles['thumb-item'] + } + onClick={() => { + setThumb(defaultThumb3); + form.setFieldsValue({ + thumb: -3, + }); + }} + > + <Image + src={defaultThumb3} + width={80} + height={60} + style={{ borderRadius: 6 }} + preview={false} + /> + </div> + </div> + <div className="d-flex"> + <UploadImageButton + text={t('course.edit.thumbText')} + isDefault + onSelected={(url, id) => { + setThumb(url); + form.setFieldsValue({ thumb: id }); + }} + ></UploadImageButton> + <span className="helper-text ml-16">{t('textbook.create.thumbTip')}</span> + </div> + </div> + </div> + </Form.Item> + {/*范围指派*/} + <Form.Item + label={t('textbook.create.assign')} + name="ids" + rules={[{ required: true, message: t('textbook.create.assignPlaceholder') }]} + > + <div + className="d-flex" + style={{ width: '100%', flexWrap: 'wrap', marginBottom: -8 }} + > + <Button + type="default" + style={{ marginBottom: 14 }} + onClick={() => setIdsVisible(true)} + > + {t('course.edit.idsText')} + </Button> + <div + className="d-flex" + style={{ + width: '100%', + flexWrap: 'wrap', + marginBottom: -16, + }} + > + {deps.length > 0 && + deps.map((item: any, i: number) => ( + <Tag + key={i} + closable + style={{ + height: 32, + lineHeight: '32px', + fontSize: 14, + color: '#FF4D4F', + background: 'rgba(255,77,79,0.1)', + marginRight: 16, + marginBottom: 16, + }} + onClose={(e) => { + e.preventDefault(); + const arr = [...deps]; + const arr2 = [...depIds]; + arr.splice(i, 1); + arr2.splice(i, 1); + setDeps(arr); + setDepIds(arr2); + form.setFieldsValue({ + ids: arr2.concat(groupIds).concat(userIds), + }); + }} + > + {item.title.props.children} + </Tag> + ))} + {groups.length > 0 && + groups.map((item: any, i: number) => ( + <Tag + key={i} + closable + style={{ + height: 32, + lineHeight: '32px', + fontSize: 14, + color: '#FF4D4F', + background: 'rgba(255,77,79,0.1)', + marginRight: 16, + marginBottom: 16, + }} + onClose={(e) => { + e.preventDefault(); + const arr = [...groups]; + const arr2 = [...groupIds]; + arr.splice(i, 1); + arr2.splice(i, 1); + setGroups(arr); + setGroupIds(arr2); + form.setFieldsValue({ + ids: depIds.concat(arr2).concat(userIds), + }); + }} + > + {item.title.props.children} + </Tag> + ))} + {users.length > 0 && + users.map((item: any, j: number) => ( + <Tag + key={j} + closable + style={{ + height: 32, + lineHeight: '32px', + fontSize: 14, + color: '#FF4D4F', + background: 'rgba(255,77,79,0.1)', + marginRight: 16, + marginBottom: 16, + }} + onClose={(e) => { + e.preventDefault(); + const arr = [...users]; + const arr2 = [...userIds]; + arr.splice(j, 1); + arr2.splice(j, 1); + setUsers(arr); + setUserIds(arr2); + form.setFieldsValue({ + dep_ids: depIds.concat(groupIds).concat(arr2), + }); + }} + > + {item.name} + </Tag> + ))} + </div> + </div> + </Form.Item> + <Form.Item + label={t('textbook.create.subject')} + name="major" + rules={[{ required: true, message: t('textbook.create.subjectPlaceholder') }]} + > + <Input + style={{ width: 424 }} + placeholder={t('textbook.create.subjectPlaceholder')} + allowClear + /> + </Form.Item> + <Form.Item + label={t('textbook.create.author')} + name="author" + rules={[{ required: true, message: t('textbook.create.authorPlaceholder') }]} + > + <Input + style={{ width: 424 }} + placeholder={t('textbook.create.authorPlaceholder')} + allowClear + /> + </Form.Item> + <Form.Item + label={t('textbook.create.publisher')} + name="publish_unit" + rules={[{ required: true, message: t('textbook.create.publisherPlaceholder') }]} + > + <Input + style={{ width: 424 }} + placeholder={t('textbook.create.publisherPlaceholder')} + allowClear + /> + </Form.Item> + <Form.Item + label={t('textbook.create.publishTime')} + name="publish_time" + rules={[{ required: true, message: t('textbook.create.publishTimePlaceholder') }]} + > + <DatePicker + style={{ width: 424 }} + placeholder={t('textbook.create.publishTimePlaceholder')} + allowClear + format="YYYY-MM-DD" + disabledDate={disabledDate} + /> + </Form.Item> + <Form.Item + label={t('textbook.create.desc')} + name="short_desc" + rules={[{ required: true, message: t('textbook.create.descPlaceholder') }]} + > + <TextArea + style={{ width: 424 }} + rows={6} + placeholder={t('textbook.create.descPlaceholder')} + allowClear + maxLength={200} + autoSize={{ minRows: 6, maxRows: 6 }} + /> + </Form.Item> + </Form> + </div> + </Drawer> + ) : null} + </> + ); +}; diff --git a/app/backend/src/pages/virtual/components/FormModal/index.tsx b/app/backend/src/pages/virtual/components/FormModal/index.tsx index 62fece3..ab23903 100644 --- a/app/backend/src/pages/virtual/components/FormModal/index.tsx +++ b/app/backend/src/pages/virtual/components/FormModal/index.tsx @@ -187,6 +187,7 @@ const FormModal = (props: AddFormProps) => { message.error('请检查表单中的错误'); } } finally { + /* empty */ } }; const getTabByFieldName = (fieldName: string) => { diff --git a/app/backend/src/pages/virtual/components/SignleTypeUploader/index.tsx b/app/backend/src/pages/virtual/components/SignleTypeUploader/index.tsx index a17849e..934d397 100644 --- a/app/backend/src/pages/virtual/components/SignleTypeUploader/index.tsx +++ b/app/backend/src/pages/virtual/components/SignleTypeUploader/index.tsx @@ -129,6 +129,7 @@ const getContentType = (extension: string): string => { return contentTypeMap[extension.toLowerCase()] || 'application/octet-stream'; }; +// eslint-disable-next-line react/display-name export const SingleTypeUploader = forwardRef( ({ form, allowedType, isShowUploader }: FileUploaderProps, ref) => { const [uploadProgress, setUploadProgress] = useState(0); diff --git a/app/backend/src/pages/virtualCard/components/LocateSoftModal/index.tsx b/app/backend/src/pages/virtualCard/components/LocateSoftModal/index.tsx index 4057ce0..dd5df53 100644 --- a/app/backend/src/pages/virtualCard/components/LocateSoftModal/index.tsx +++ b/app/backend/src/pages/virtualCard/components/LocateSoftModal/index.tsx @@ -102,7 +102,9 @@ const LocateSoftModal = (props: locateSoftModalProps) => { } } } catch { + /* empty */ } finally { + /* empty */ } }; diff --git a/app/backend/src/pages/virtualCard/components/OptDetailModal/index.tsx b/app/backend/src/pages/virtualCard/components/OptDetailModal/index.tsx index 0f63691..8915033 100644 --- a/app/backend/src/pages/virtualCard/components/OptDetailModal/index.tsx +++ b/app/backend/src/pages/virtualCard/components/OptDetailModal/index.tsx @@ -106,7 +106,9 @@ const OptDetailModal = (props: ModalProps) => { ]; useEffect(() => { - id && getModalData(id); + if (id) { + getModalData(id); + } }, [id]); return ( <> diff --git a/app/backend/src/pages/virtualCard/components/VirtualCard/index.tsx b/app/backend/src/pages/virtualCard/components/VirtualCard/index.tsx index c33f3d8..611b64a 100644 --- a/app/backend/src/pages/virtualCard/components/VirtualCard/index.tsx +++ b/app/backend/src/pages/virtualCard/components/VirtualCard/index.tsx @@ -101,6 +101,7 @@ const DeviceCard: React.FC<DeviceCardProps> = ({ data, onLocate, onDetail }) => } actions={[ <Button + key={'setting'} type="text" icon={<SettingOutlined />} onClick={(e) => { @@ -111,6 +112,7 @@ const DeviceCard: React.FC<DeviceCardProps> = ({ data, onLocate, onDetail }) => 软件分配 </Button>, <Button + key={'detail'} type="text" icon={<AppstoreOutlined />} onClick={(e) => { diff --git a/app/backend/src/routes/index.tsx b/app/backend/src/routes/index.tsx index 529483e..fc579c9 100644 --- a/app/backend/src/routes/index.tsx +++ b/app/backend/src/routes/index.tsx @@ -57,6 +57,9 @@ const CourseUserPage = lazy(() => import('../pages/course/user')); const OfflineCoursePage = lazy(() => import('../pages/offline-course/index')); const OfflineCourseUserPage = lazy(() => import('../pages/offline-course/user')); const OfflineCourseQrcodePage = lazy(() => import('../pages/offline-course/qrcode')); +// 教材管理 +const TextbookPage = lazy(() => import('../pages/textbook/index')); +const ChapterManagementPage = lazy(() => import('../pages/textbook/chapter')); //试卷课时人工阅卷 const CoursePaperMarkPage = lazy(() => import('../pages/course/mark')); //学员相关 @@ -170,7 +173,7 @@ const routes: RouteObject[] = [ path: ':code', element: RootPage, children: [ - // 这里可以继续嵌套其他页面,比如 /jndx/course/list 等 + // 这里可以继续嵌套其他页面,比如 /jndex/course/list 等 ], }, { @@ -257,6 +260,16 @@ const routes: RouteObject[] = [ path: '/offline-course/user/:courseId', element: <PrivateRoute Component={<OfflineCourseUserPage />} />, }, + // 教材管理 + { + path: '/textbook', + element: <PrivateRoute Component={<TextbookPage />} />, + }, + // 章节管理 + { + path: '/textbook/chapter/:bookId', + element: <PrivateRoute Component={<ChapterManagementPage />} />, + }, { path: '/exam/questions', element: <PrivateRoute Component={<ExamQuestionsPage />} />, diff --git a/app/backend/src/types/course.ts b/app/backend/src/types/course.ts new file mode 100644 index 0000000..386a688 --- /dev/null +++ b/app/backend/src/types/course.ts @@ -0,0 +1,81 @@ +// types/course.ts + +// 知识点接口 +export interface KnowledgePoint { + id: number; + name: string; + content?: string; + sortOrder: number; +} + +// 课时接口 +export interface Lesson { + id: number; + rid: number; + title: string; + type: string; + duration: number; + sortOrder: number; + extra?: any; +} + +// 章节接口 - 支持三级结构 +export interface Chapter { + id: number; + name: string; + level: 1 | 2 | 3; + parentId: number | null; + sortOrder: number; + children?: Chapter[]; + knowledgePoints?: KnowledgePoint[]; + hours: Lesson[]; +} + +// 现有的课程章节模型(保持兼容) +export interface CourseChaptersModel { + id?: number; + name: string; + level?: 1 | 2 | 3; + parentId?: number | null; + sortOrder?: number; + children?: CourseChaptersModel[]; + knowledgePoints?: KnowledgePoint[]; + hours: any[]; +} + +// 课时模型 +export interface CourseHourModel { + rid: number; + name: string; + type: string; + duration: number; + extra?: any; +} + +// 附件数据模型 +export interface AttachmentDataModel { + rid: number; + name: string; + type: string; + size: number; +} + +// 组件Props接口 +export interface CourseCreateProps { + cateIds: any; + open: boolean; + onCancel: () => void; +} + +// 选项接口 +export interface Option { + value: string | number; + title: string; + children?: Option[]; +} + +// 教师选择模型 +export interface TeacherModel { + label: string; + value: number; +} diff --git a/app/backend/src/types/editor.ts b/app/backend/src/types/editor.ts new file mode 100644 index 0000000..edc08fe --- /dev/null +++ b/app/backend/src/types/editor.ts @@ -0,0 +1,48 @@ +// types/editor.ts +export interface Chapter { + id: string; + title: string; + content?: string; + order: number; + parentId?: string; + level: number; +} + +export interface EditorProps { + chapterId: string; + chapterTitle: string; + initialContent?: string; + onSave: (chapterId: string, content: string) => void; + onContentChange: (chapterId: string, content: string) => void; +} + +export interface EditorState { + isBold: boolean; + canBold: boolean; + isItalic: boolean; + canItalic: boolean; + isStrike: boolean; + canStrike: boolean; + isCode: boolean; + canCode: boolean; + canClearMarks: boolean; + isParagraph: boolean; + isHeading1: boolean; + isHeading2: boolean; + isHeading3: boolean; + isHeading4: boolean; + isHeading5: boolean; + isHeading6: boolean; + isBulletList: boolean; + isOrderedList: boolean; + isCodeBlock: boolean; + isBlockquote: boolean; + canUndo: boolean; + canRedo: boolean; + isHighlight: boolean; + canHighlight: boolean; + isTextAlignLeft: boolean; + isTextAlignCenter: boolean; + isTextAlignRight: boolean; + isTextAlignJustify: boolean; +} diff --git a/app/backend/src/utils/index.ts b/app/backend/src/utils/index.ts index b9cedc1..bb258d7 100644 --- a/app/backend/src/utils/index.ts +++ b/app/backend/src/utils/index.ts @@ -42,6 +42,12 @@ export function dateFormat(dateStr: string) { } return moment(dateStr).format('YYYY-MM-DD HH:mm'); } +export function dateFormatNoTime(dateStr: string) { + if (!dateStr) { + return '-'; + } + return moment(dateStr).format('YYYY-MM-DD'); +} export function generateUUID(): string { let guid = ''; diff --git a/app/backend/vite.config.ts b/app/backend/vite.config.ts index 54f63a3..5821d18 100644 --- a/app/backend/vite.config.ts +++ b/app/backend/vite.config.ts @@ -9,10 +9,18 @@ export default defineConfig({ server: { host: '0.0.0.0', port: 4000, + proxy: { + '/backend': { + target: 'https://adminplatform.mstarai.cn', + changeOrigin: true, + secure: false, + }, + }, }, plugins: [react()], build: { rollupOptions: { + // @ts-ignore plugins: [gzipPlugin()], }, },