Merge remote-tracking branch 'origin/master'
This commit is contained in:
commit
b5e3cdcd0b
@ -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",
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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}`, {});
|
||||
// },
|
||||
};
|
||||
|
||||
@ -23,4 +23,3 @@ export function knowledgeMessagesSummary() {
|
||||
export function knowledgeMessagesAudit(id: string | number) {
|
||||
return client.put(`/v1/knowledge-messages/audit/${id}`, {});
|
||||
}
|
||||
|
||||
|
||||
130
app/backend/src/api/textbook.ts
Normal file
130
app/backend/src/api/textbook.ts
Normal file
@ -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 });
|
||||
}
|
||||
@ -305,3 +305,6 @@
|
||||
.icon-icon-password:before {
|
||||
content: '\e73d';
|
||||
}
|
||||
.icon-icon-addIcon:before {
|
||||
content: '\271A';
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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": "注意:章节目录超过三级,请调整!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -428,6 +428,7 @@ const CoursePage = () => {
|
||||
<>
|
||||
<div className="tree-main-body">
|
||||
<div className="left-box">
|
||||
{/*树状分类*/}
|
||||
<TreeCategory
|
||||
selected={category_ids}
|
||||
type=""
|
||||
|
||||
@ -329,8 +329,7 @@ const ExamAdministrationPage = () => {
|
||||
}*/
|
||||
// 根据不同操作类型,调用对应的后端批量接口
|
||||
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) {
|
||||
|
||||
@ -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 = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={styles['kb-entries-card']} loading={knowledgeLoading} bodyStyle={{ padding: 24 }}>
|
||||
<Card
|
||||
className={styles['kb-entries-card']}
|
||||
loading={knowledgeLoading}
|
||||
bodyStyle={{ padding: 24 }}
|
||||
>
|
||||
<div className={styles['kb-entries-header']}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
知识库条目
|
||||
@ -450,4 +446,3 @@ const ResourceLibraryKnowledgeBasePage = () => {
|
||||
};
|
||||
|
||||
export default ResourceLibraryKnowledgeBasePage;
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
32
app/backend/src/pages/textbook/chapter.module.less
Normal file
32
app/backend/src/pages/textbook/chapter.module.less
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
134
app/backend/src/pages/textbook/compenents/chapterModal.tsx
Normal file
134
app/backend/src/pages/textbook/compenents/chapterModal.tsx
Normal file
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
681
app/backend/src/pages/textbook/compenents/chapterTree.tsx
Normal file
681
app/backend/src/pages/textbook/compenents/chapterTree.tsx
Normal file
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
548
app/backend/src/pages/textbook/compenents/updateTextbook.tsx
Normal file
548
app/backend/src/pages/textbook/compenents/updateTextbook.tsx
Normal file
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -187,6 +187,7 @@ const FormModal = (props: AddFormProps) => {
|
||||
message.error('请检查表单中的错误');
|
||||
}
|
||||
} finally {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
const getTabByFieldName = (fieldName: string) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -102,7 +102,9 @@ const LocateSoftModal = (props: locateSoftModalProps) => {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* empty */
|
||||
} finally {
|
||||
/* empty */
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -106,7 +106,9 @@ const OptDetailModal = (props: ModalProps) => {
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
id && getModalData(id);
|
||||
if (id) {
|
||||
getModalData(id);
|
||||
}
|
||||
}, [id]);
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 />} />,
|
||||
|
||||
81
app/backend/src/types/course.ts
Normal file
81
app/backend/src/types/course.ts
Normal file
@ -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;
|
||||
}
|
||||
48
app/backend/src/types/editor.ts
Normal file
48
app/backend/src/types/editor.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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 = '';
|
||||
|
||||
@ -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()],
|
||||
},
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user