add textbook-page, and chapter ui

This commit is contained in:
penpenwang 2025-11-25 17:25:27 +08:00
parent 30a37d3c0d
commit fb53b5ee4b
36 changed files with 3458 additions and 569 deletions

View File

@ -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",

View File

@ -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';

View File

@ -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}`, {});
// },
};

View File

@ -23,4 +23,3 @@ export function knowledgeMessagesSummary() {
export function knowledgeMessagesAudit(id: string | number) {
return client.put(`/v1/knowledge-messages/audit/${id}`, {});
}

View 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 });
}

View File

@ -305,3 +305,6 @@
.icon-icon-password:before {
content: '\e73d';
}
.icon-icon-addIcon:before {
content: '\271A';
}

View File

@ -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

View File

@ -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": "注意:章节目录超过三级,请调整!"
}
}
}

View File

@ -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"
}
}
}

View File

@ -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;
}

View File

@ -428,6 +428,7 @@ const CoursePage = () => {
<>
<div className="tree-main-body">
<div className="left-box">
{/*树状分类*/}
<TreeCategory
selected={category_ids}
type=""

View File

@ -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) {

View File

@ -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;

View File

@ -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

View 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;
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View 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>
);
};

View File

@ -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;
}

View 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>
);
};

View File

@ -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;
}
}

View 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}
</>
);
};

View File

@ -187,6 +187,7 @@ const FormModal = (props: AddFormProps) => {
message.error('请检查表单中的错误');
}
} finally {
/* empty */
}
};
const getTabByFieldName = (fieldName: string) => {

View File

@ -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);

View File

@ -102,7 +102,9 @@ const LocateSoftModal = (props: locateSoftModalProps) => {
}
}
} catch {
/* empty */
} finally {
/* empty */
}
};

View File

@ -106,7 +106,9 @@ const OptDetailModal = (props: ModalProps) => {
];
useEffect(() => {
id && getModalData(id);
if (id) {
getModalData(id);
}
}, [id]);
return (
<>

View File

@ -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) => {

View File

@ -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 />} />,

View 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;
}

View 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;
}

View File

@ -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 = '';

View File

@ -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()],
},
},